feat: harden SMTP transport

Add explicit TLS modes, context-aware delivery, and typed transport errors.
Preserve raw MIME messages and cover the new delivery paths with local SMTP tests.
This commit is contained in:
2026-08-18 18:16:22 -06:00
parent 537fbeebd9
commit b3c22c261e
12 changed files with 1560 additions and 652 deletions
Executable → Regular
+49 -165
View File
@@ -1,184 +1,68 @@
package email
import (
"fmt"
"net/smtp"
"os"
"bytes"
"context"
"errors"
"strings"
"testing"
"github.com/joho/godotenv"
"github.com/kelseyhightower/envconfig"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type config struct {
MailUser string `required:"false" split_words:"true"`
MailPassword string `required:"false" split_words:"true"`
MailHost string `required:"false" split_words:"true"`
MailPort string `required:"false" split_words:"true"`
MailFrom string `required:"false" split_words:"true"`
MailTo string `required:"false" split_words:"true"`
}
func newConfig(envFile string) *config {
if envFile != "" {
err := godotenv.Load(envFile)
if err != nil {
panic(fmt.Errorf("godotenv.Load: %w", err))
func TestMockSendEmailCompatibility(t *testing.T) {
t.Parallel()
called := false
service := NewMockMailService(func(params ...interface{}) {
called = true
if len(params) != 1 || params[0] != "marker" {
t.Fatalf("unexpected callback params: %#v", params)
}
}
}, "marker")
cfg := &config{}
err := envconfig.Process("", cfg)
if err != nil {
panic(fmt.Errorf("envconfig.Process: %w", err))
}
return cfg
}
func TestNewConfig_MissingEnvFile(t *testing.T) {
assert.Panics(t, func() { newConfig(".missing_env_file") })
}
func TestMockSendEmail(t *testing.T) {
service := NewMockMailService(func(params ...interface{}) {})
emailData := MessageWithAttachments{
err := service.SendEmail(MessageWithAttachments{
To: "test@example.com",
Subject: "Test Email",
Body: "This is a test email.",
}
err := service.SendEmail(emailData)
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
t.Fatalf("SendEmail: %v", err)
}
if !called {
t.Fatal("mock callback was not invoked")
}
}
func TestNewInsecure(t *testing.T) {
cfg := newConfig(".env.test")
mailSrv := NewInsecure(SecureConfig{
Auth: smtp.PlainAuth("", cfg.MailUser, cfg.MailPassword, cfg.MailHost),
Host: cfg.MailHost,
Port: cfg.MailPort,
From: cfg.MailFrom,
})
t.Run("TestSendEmail", func(t *testing.T) {
data := MessageWithAttachments{
To: cfg.MailTo,
Subject: "Mail Sender",
Body: "Hello this is a test email",
func TestStructuredMessageRejectsHeaderInjection(t *testing.T) {
t.Parallel()
service := NewMockMailService(nil)
tests := []MessageWithAttachments{
{To: "victim@example.com", Subject: "subject\r\nBcc: attacker@example.com", Body: "body"},
{To: "victim@example.com\r\nBcc: attacker@example.com", Subject: "subject", Body: "body"},
{
To: "victim@example.com",
Subject: "subject",
Body: "body",
Attachments: []EmailAttachment{{
Title: "file.txt\r\nX-Injected: yes",
File: bytes.NewBufferString("content"),
}},
},
}
for index, message := range tests {
if err := service.SendEmailContext(context.Background(), message); err == nil {
t.Fatalf("case %d accepted injected header", index)
}
require.NoError(t, mailSrv.SendEmail(data))
})
t.Run("TestSendEmailWithAttachments", func(t *testing.T) {
reader, err := os.Open("testdata/attachment1.txt")
require.NoError(t, err)
defer reader.Close()
reader2, err := os.Open("testdata/attachment2.txt")
require.NoError(t, err)
defer reader2.Close()
reader3, err := os.Open("testdata/attachment3.txt")
require.NoError(t, err)
defer reader3.Close()
data := MessageWithAttachments{
To: cfg.MailTo,
Subject: "Mail Sender",
Body: "Hello this is a test email",
Attachments: []EmailAttachment{
{
Title: "attachment1.txt",
File: reader,
},
{
Title: "attachment2.txt",
File: reader2,
},
{
Title: "attachment3.txt",
File: reader3,
},
},
}
err = mailSrv.SendEmail(data)
require.NoError(t, err)
})
t.Run("TestWithAttachments", func(t *testing.T) {
msg := newMessage("from", "to", "subject")
content, err := msg.withAttachments("body", nil)
require.NoError(t, err)
assert.Greater(t, len(content), 0)
})
t.Run("TestSendEmail_InvalidRecipient", func(t *testing.T) {
data := MessageWithAttachments{
To: "invalid_email",
Subject: "Test Email",
Body: "This is a test email.",
}
err := mailSrv.SendEmail(data)
assert.Error(t, err)
})
t.Run("TestSendEmail_FailedAuthentication", func(t *testing.T) {
// set up authentication to fail
mailSrv := NewInsecure(SecureConfig{
Auth: smtp.PlainAuth("", "wronguser", "wrongpassword", cfg.MailHost),
Host: cfg.MailHost,
Port: cfg.MailPort,
From: cfg.MailFrom,
})
data := MessageWithAttachments{
To: cfg.MailTo,
Subject: "Test Email",
Body: "This is a test email.",
}
err := mailSrv.SendEmail(data)
assert.Error(t, err)
})
}
}
func TestSecure(t *testing.T) {
cfg := newConfig(".env.test")
emailService := NewSecure(SecureConfig{
Auth: smtp.PlainAuth("", cfg.MailUser, cfg.MailPassword, cfg.MailHost),
Host: cfg.MailHost,
Port: cfg.MailPort,
From: cfg.MailFrom,
})
// Assert that the tls.Config is set up correctly
assert.NotNil(t, emailService.tlsconfig)
assert.True(t, emailService.tlsconfig.InsecureSkipVerify)
assert.Equal(t, cfg.MailHost, emailService.tlsconfig.ServerName)
assert.NotNil(t, emailService.tlsconfig.VerifyConnection)
t.Run("TestSendEmail", func(t *testing.T) {
// Mock the client and test the StartTLS method
var called bool
mockDialFn := func(hostPort string) (SMTPClientIface, error) {
called = true
return &mockSMTP{}, nil
}
emailService.dial = mockDialFn
data := MessageWithAttachments{
To: cfg.MailTo,
Subject: "Mail Sender",
Body: "Hello this is a test email",
}
require.NoError(t, emailService.SendEmail(data))
assert.Equal(t, true, called)
})
func TestLegacyConstructorReturnsSafeConfigurationError(t *testing.T) {
t.Parallel()
service := NewSecure(SecureConfig{Host: "", Port: "587", From: "sender@example.com"})
err := service.SendRaw(RawMessage{To: "recipient@example.com", Body: "Subject: x\r\n\r\nbody"})
var transportError *Error
if !errors.As(err, &transportError) || transportError.Kind != ErrorKindPermanent {
t.Fatalf("error=%v, want permanent configuration error", err)
}
if strings.Contains(err.Error(), "recipient@example.com") {
t.Fatalf("configuration error leaked message data: %v", err)
}
}