✨ feat(email): pkg/email + Mailpit docker-compose service (ADR-0029 Phase A.1)
Foundation for the passwordless auth migration (ADR-0028 Phase A) and the BDD email-parallel strategy (ADR-0030). This PR ships only the infrastructure — no auth code yet ; that lands in subsequent PRs. Changes: - docker-compose.yml: add mailpit service (axllent/mailpit:latest), SMTP on :1025, HTTP UI/API on :8025, MP_MAX_MESSAGES=5000 - pkg/email/sender.go: provider-agnostic Sender interface + Message struct - pkg/email/smtp_sender.go: SMTPSender implementation (net/smtp), with Mailpit-friendly defaults (localhost:1025, no TLS, no AUTH), context- aware Send with timeout, supports plain text and multipart/alternative - pkg/config: AuthConfig.Email field + EmailConfig struct + GetEmailConfig getter + 7 new env vars (DLC_AUTH_EMAIL_*) + defaults - documentation/EMAIL.md: setup, inspection (UI + API), code examples, cross-refs to ADR-0028/0029/0030 Tests (pkg/email/smtp_sender_test.go): - validateMessage rejects missing fields, accepts minimal - buildRFC5322 plain-text path produces single-part text/plain with expected headers - buildRFC5322 multipart path produces multipart/alternative with both parts and a closing boundary - buildRFC5322 custom headers are canonicalised (lowercase keys → Title-Case) - NewSMTPSender defaults are Mailpit-friendly - Send respects context cancellation (no 10s wait when ctx cancelled) Race detector clean. Build clean. Vet clean. Out of scope for this PR (Phase A.2+): - BDD email-steps helper package (pkg/bdd/mailpit/, pkg/bdd/steps/email_steps.go) - magic_link_tokens table + repository - magic-link/request and magic-link/consume HTTP handlers - BDD scenarios for the magic-link flow
This commit is contained in:
123
pkg/email/smtp_sender_test.go
Normal file
123
pkg/email/smtp_sender_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestValidateMessage_RejectsMissingFields confirms the contract gate:
|
||||
// To, From, and at least one body are required.
|
||||
func TestValidateMessage_RejectsMissingFields(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg Message
|
||||
wantErr string
|
||||
}{
|
||||
{"empty To", Message{From: "f@x", BodyText: "b"}, "To is required"},
|
||||
{"empty From", Message{To: "t@x", BodyText: "b"}, "From is required"},
|
||||
{"empty body", Message{To: "t@x", From: "f@x"}, "BodyText or BodyHTML"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateMessage(tc.msg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateMessage_AcceptsMinimal confirms the happy path: To, From,
|
||||
// and BodyText alone is a valid message.
|
||||
func TestValidateMessage_AcceptsMinimal(t *testing.T) {
|
||||
err := validateMessage(Message{To: "t@x", From: "f@x", BodyText: "b"})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestBuildRFC5322_PlainText verifies that a text-only message produces
|
||||
// a single-part text/plain RFC 5322 body with the expected headers.
|
||||
func TestBuildRFC5322_PlainText(t *testing.T) {
|
||||
msg := Message{
|
||||
To: "alice@example.com", From: "system@x", Subject: "Hi",
|
||||
BodyText: "Hello Alice",
|
||||
}
|
||||
body := string(buildRFC5322(msg))
|
||||
|
||||
assert.Contains(t, body, "To: alice@example.com\r\n")
|
||||
assert.Contains(t, body, "From: system@x\r\n")
|
||||
assert.Contains(t, body, "Subject: Hi\r\n")
|
||||
assert.Contains(t, body, "MIME-Version: 1.0\r\n")
|
||||
assert.Contains(t, body, "Content-Type: text/plain; charset=utf-8\r\n")
|
||||
assert.Contains(t, body, "\r\nHello Alice")
|
||||
assert.NotContains(t, body, "multipart/alternative", "no HTML => no multipart")
|
||||
}
|
||||
|
||||
// TestBuildRFC5322_Multipart verifies that text + HTML produces a
|
||||
// multipart/alternative body with both parts and a boundary close.
|
||||
func TestBuildRFC5322_Multipart(t *testing.T) {
|
||||
msg := Message{
|
||||
To: "alice@example.com", From: "system@x", Subject: "Hi",
|
||||
BodyText: "Plain hello", BodyHTML: "<p>HTML hello</p>",
|
||||
}
|
||||
body := string(buildRFC5322(msg))
|
||||
|
||||
assert.Contains(t, body, "Content-Type: multipart/alternative;")
|
||||
assert.Contains(t, body, "Plain hello", "plain part included")
|
||||
assert.Contains(t, body, "<p>HTML hello</p>", "HTML part included")
|
||||
// Boundary close marker
|
||||
assert.True(t, strings.Contains(body, "--alt-") && strings.HasSuffix(strings.TrimRight(body, "\r\n"), "--"),
|
||||
"multipart message should end with closing boundary")
|
||||
}
|
||||
|
||||
// TestBuildRFC5322_CustomHeaders verifies that user-supplied headers
|
||||
// appear in the output with canonicalised case.
|
||||
func TestBuildRFC5322_CustomHeaders(t *testing.T) {
|
||||
msg := Message{
|
||||
To: "alice@example.com", From: "system@x", Subject: "Hi",
|
||||
BodyText: "b",
|
||||
Headers: map[string]string{
|
||||
"x-bdd-scenario": "magic-link-happy-path",
|
||||
"X-Trace-Id": "abc-123",
|
||||
},
|
||||
}
|
||||
body := string(buildRFC5322(msg))
|
||||
|
||||
assert.Contains(t, body, "X-Bdd-Scenario: magic-link-happy-path\r\n",
|
||||
"lowercase key should be canonicalised to title-case")
|
||||
assert.Contains(t, body, "X-Trace-Id: abc-123\r\n",
|
||||
"already-canonical key should pass through unchanged")
|
||||
}
|
||||
|
||||
// TestSMTPSender_DefaultsAreMailpitFriendly verifies that NewSMTPSender
|
||||
// fills in localhost:1025 and a non-zero timeout when the caller passes
|
||||
// a zero-valued SMTPConfig — which is the recommended default.
|
||||
func TestSMTPSender_DefaultsAreMailpitFriendly(t *testing.T) {
|
||||
s := NewSMTPSender(SMTPConfig{})
|
||||
assert.Equal(t, "localhost", s.cfg.Host)
|
||||
assert.Equal(t, 1025, s.cfg.Port)
|
||||
assert.Greater(t, int64(s.cfg.Timeout), int64(0), "timeout must be set, default 10s")
|
||||
assert.Empty(t, s.cfg.Username, "default no AUTH")
|
||||
}
|
||||
|
||||
// TestSMTPSender_ContextCancelAbortsSend confirms a cancelled ctx
|
||||
// short-circuits Send rather than waiting for the SMTP timeout.
|
||||
// We point at a port no SMTP server is listening on so the goroutine
|
||||
// will hang ; ctx cancel is what wins.
|
||||
func TestSMTPSender_ContextCancelAbortsSend(t *testing.T) {
|
||||
s := NewSMTPSender(SMTPConfig{
|
||||
Host: "127.0.0.1",
|
||||
Port: 1, // privileged port, definitely no SMTP server here
|
||||
// keep the default 10s timeout — we want ctx to win
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // pre-cancelled
|
||||
|
||||
err := s.Send(ctx, Message{To: "t@x", From: "f@x", BodyText: "b"})
|
||||
require.Error(t, err)
|
||||
// The error is ctx.Err() OR the net/smtp connect error if the goroutine
|
||||
// happened to fail before the ctx select ran. Both are acceptable —
|
||||
// the only unacceptable outcome is the test taking 10 seconds.
|
||||
}
|
||||
Reference in New Issue
Block a user