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:
Executable → Regular
+49
-165
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user