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:
2026-05-05 10:46:45 +02:00
parent 235cc41f68
commit ea4bae5807
6 changed files with 482 additions and 3 deletions

45
pkg/email/sender.go Normal file
View File

@@ -0,0 +1,45 @@
// Package email provides the abstraction over outgoing email transport.
//
// ADR-0029 picked Mailpit for local dev and BDD ; production sender is
// deferred. The Sender interface is the swap point : a future production
// adapter (AWS SES, Postmark, SendGrid) implements the same contract
// without touching call sites.
package email
import "context"
// Sender sends email messages. Implementations must be safe for
// concurrent use — multiple goroutines may call Send simultaneously.
type Sender interface {
Send(ctx context.Context, msg Message) error
}
// Message is the wire-level representation of an outgoing email.
// Headers is for trace correlation (e.g. X-Test-Scenario-ID for BDD)
// and arbitrary application-specific tags. Implementations include
// these as RFC 5322 header fields.
type Message struct {
// To is the recipient address (single recipient ; we don't currently
// support multi-recipient broadcasts — keeps the contract simple
// and matches the magic-link use case which is always 1:1).
To string
// From is the sender address. Required.
From string
// Subject is the RFC 5322 Subject. Required for non-empty body.
Subject string
// BodyText is the plain-text body. At least one of BodyText or
// BodyHTML must be non-empty.
BodyText string
// BodyHTML is the optional HTML body. When both are set, the
// SMTP-level message is multipart/alternative.
BodyHTML string
// Headers are extra RFC 5322 header fields. Keys are case-insensitive ;
// implementations canonicalise via textproto.CanonicalMIMEHeaderKey.
// Useful for BDD test correlation (X-BDD-Scenario, etc.).
Headers map[string]string
}

155
pkg/email/smtp_sender.go Normal file
View File

@@ -0,0 +1,155 @@
package email
import (
"context"
"errors"
"fmt"
"net/smtp"
"net/textproto"
"strings"
"time"
)
// SMTPConfig configures an SMTPSender. Defaults match local Mailpit
// (host=localhost, port=1025, no TLS, no auth).
type SMTPConfig struct {
Host string
Port int
Username string // empty means no AUTH
Password string // empty means no AUTH
UseTLS bool // STARTTLS — false for Mailpit local
// Timeout bounds Send to avoid hanging forever on a stuck server.
// Defaults to 10s when zero.
Timeout time.Duration
}
// SMTPSender sends mail through an SMTP server. Configured by SMTPConfig
// with sensible Mailpit-friendly defaults.
type SMTPSender struct {
cfg SMTPConfig
}
// NewSMTPSender returns a Sender backed by SMTP. The SMTPConfig is copied —
// mutating the caller's struct after this call has no effect.
func NewSMTPSender(cfg SMTPConfig) *SMTPSender {
if cfg.Host == "" {
cfg.Host = "localhost"
}
if cfg.Port == 0 {
cfg.Port = 1025
}
if cfg.Timeout == 0 {
cfg.Timeout = 10 * time.Second
}
return &SMTPSender{cfg: cfg}
}
// Send delivers the message via SMTP. Returns an error if the message is
// invalid (missing required fields), if connecting / authenticating fails,
// or if the SMTP server rejects the envelope or data.
func (s *SMTPSender) Send(ctx context.Context, msg Message) error {
if err := validateMessage(msg); err != nil {
return err
}
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
body := buildRFC5322(msg)
var auth smtp.Auth
if s.cfg.Username != "" {
auth = smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
}
// Run the SMTP exchange in a goroutine so we can honour the
// context's cancellation independently of net/smtp's own timeouts.
done := make(chan error, 1)
go func() {
done <- smtp.SendMail(addr, auth, msg.From, []string{msg.To}, body)
}()
select {
case err := <-done:
if err != nil {
return fmt.Errorf("smtp send to %s: %w", msg.To, err)
}
return nil
case <-ctx.Done():
return ctx.Err()
case <-time.After(s.cfg.Timeout):
return errors.New("smtp send: timeout")
}
}
// validateMessage checks the minimum required fields for an outgoing email.
func validateMessage(msg Message) error {
if msg.To == "" {
return errors.New("email: To is required")
}
if msg.From == "" {
return errors.New("email: From is required")
}
if msg.BodyText == "" && msg.BodyHTML == "" {
return errors.New("email: at least one of BodyText or BodyHTML is required")
}
return nil
}
// buildRFC5322 builds an RFC 5322 message body from Message. Plain-text
// only when BodyHTML is empty ; multipart/alternative when both are set.
//
// Keep in mind: this is the body sent to net/smtp.SendMail, which adds
// no headers itself. We add the canonical From, To, Subject, MIME-Version,
// Content-Type, and any caller-provided custom headers.
func buildRFC5322(msg Message) []byte {
var b strings.Builder
// Custom headers first so they show up early in the message.
for k, v := range msg.Headers {
// Normalise header name case (Foo-Bar, not foo-BAR)
k = textproto.CanonicalMIMEHeaderKey(k)
fmt.Fprintf(&b, "%s: %s\r\n", k, v)
}
fmt.Fprintf(&b, "From: %s\r\n", msg.From)
fmt.Fprintf(&b, "To: %s\r\n", msg.To)
fmt.Fprintf(&b, "Subject: %s\r\n", msg.Subject)
fmt.Fprintf(&b, "MIME-Version: 1.0\r\n")
if msg.BodyHTML == "" {
// Plain text only
fmt.Fprintf(&b, "Content-Type: text/plain; charset=utf-8\r\n")
fmt.Fprintf(&b, "\r\n")
b.WriteString(msg.BodyText)
return []byte(b.String())
}
// multipart/alternative — boundary is deterministic for tests but unique
// enough not to collide with body content. We don't put random bytes in
// the boundary because the alphabetical "alt-" prefix is recognisably
// ours and the rest is timestamped.
boundary := fmt.Sprintf("alt-%d", time.Now().UnixNano())
fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=%q\r\n", boundary)
fmt.Fprintf(&b, "\r\n")
// Plain part (always first — clients that only render text/plain see
// it ; clients preferring HTML go to the HTML part)
fmt.Fprintf(&b, "--%s\r\n", boundary)
fmt.Fprintf(&b, "Content-Type: text/plain; charset=utf-8\r\n\r\n")
if msg.BodyText != "" {
b.WriteString(msg.BodyText)
} else {
b.WriteString("(see HTML version)")
}
fmt.Fprintf(&b, "\r\n")
// HTML part
fmt.Fprintf(&b, "--%s\r\n", boundary)
fmt.Fprintf(&b, "Content-Type: text/html; charset=utf-8\r\n\r\n")
b.WriteString(msg.BodyHTML)
fmt.Fprintf(&b, "\r\n")
// Close boundary
fmt.Fprintf(&b, "--%s--\r\n", boundary)
return []byte(b.String())
}

View 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.
}