From ef32e750ede42f09f8d6938fb060345672c195dd Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Tue, 5 May 2026 10:47:03 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(email):=20pkg/email=20+=20Mail?= =?UTF-8?q?pit=20docker-compose=20service=20(ADR-0029=20Phase=20A.1)=20(#5?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gabriel Radureau Co-committed-by: Gabriel Radureau --- docker-compose.yml | 17 ++++ documentation/EMAIL.md | 105 +++++++++++++++++++++++ pkg/config/config.go | 40 ++++++++- pkg/email/sender.go | 45 ++++++++++ pkg/email/smtp_sender.go | 155 ++++++++++++++++++++++++++++++++++ pkg/email/smtp_sender_test.go | 123 +++++++++++++++++++++++++++ 6 files changed, 482 insertions(+), 3 deletions(-) create mode 100644 documentation/EMAIL.md create mode 100644 pkg/email/sender.go create mode 100644 pkg/email/smtp_sender.go create mode 100644 pkg/email/smtp_sender_test.go diff --git a/docker-compose.yml b/docker-compose.yml index 70339e3..dc668ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,23 @@ services: - dance-lessons-coach-network restart: unless-stopped + # Mailpit — local SMTP capture for dev + BDD parallel email tests. + # Cf. ADR-0029 (email infrastructure) and ADR-0030 (BDD parallel strategy). + # SMTP submission on :1025 (used by the app), HTTP UI + API on :8025 + # (used by tests + manual inspection at http://localhost:8025). + mailpit: + image: axllent/mailpit:latest + container_name: dance-lessons-coach-mailpit + ports: + - "1025:1025" # SMTP submission + - "8025:8025" # HTTP UI / API + environment: + MP_MAX_MESSAGES: 5000 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 # local dev only - no TLS, no real auth + networks: + - dance-lessons-coach-network + restart: unless-stopped + # Application service (for reference) # app: # build: . diff --git a/documentation/EMAIL.md b/documentation/EMAIL.md new file mode 100644 index 0000000..207ed75 --- /dev/null +++ b/documentation/EMAIL.md @@ -0,0 +1,105 @@ +# Email infrastructure + +Outgoing email transport. Per [ADR-0029](../adr/0029-email-infrastructure-mailpit.md): Mailpit for local dev + BDD tests, production sender deferred. + +## Local setup (one-time) + +Mailpit is part of `docker-compose.yml`: + +```bash +docker compose up -d # starts postgres + mailpit +docker compose ps # confirm both running +``` + +Mailpit listens on: +- **SMTP submission** — `localhost:1025` (the app sends here) +- **HTTP UI / API** — http://localhost:8025 (you inspect captured messages here) + +No real emails leave the docker network. No internet required. + +## Application configuration + +The application's outgoing transport is configured under `auth.email.*` in `config.yaml` (or via `DLC_AUTH_EMAIL_*` env vars). Defaults already match local Mailpit: + +```yaml +auth: + email: + from: noreply@dance-lessons-coach.local + smtp_host: localhost + smtp_port: 1025 + smtp_use_tls: false + timeout: 10s + # smtp_username + smtp_password left empty for local Mailpit +``` + +For production, override these to point at the chosen provider (SES, Postmark, etc.). + +## Inspecting messages + +### Web UI + +http://localhost:8025 — list of all captured messages, search, raw view, HTML preview. + +### HTTP API (for automation) + +```bash +# Latest 10 messages +curl -s 'http://localhost:8025/api/v1/messages?limit=10' | jq + +# Messages for a specific recipient (used by BDD tests, cf. ADR-0030) +curl -s 'http://localhost:8025/api/v1/messages?query=to:test-user@bdd.local' | jq + +# Get a specific message by ID (full content, headers, attachments) +curl -s 'http://localhost:8025/api/v1/message/' | jq + +# Purge messages for a recipient (used in test cleanup) +curl -X DELETE 'http://localhost:8025/api/v1/messages?query=to:test-user@bdd.local' +``` + +Full API: https://mailpit.axllent.org/docs/api-v1/ + +## Sending email from Go code + +```go +import "dance-lessons-coach/pkg/email" + +sender := email.NewSMTPSender(email.SMTPConfig{ + Host: cfg.GetEmailConfig().SMTPHost, + Port: cfg.GetEmailConfig().SMTPPort, + // username/password optional — empty means no AUTH (Mailpit local) +}) + +err := sender.Send(ctx, email.Message{ + To: "alice@example.com", + From: cfg.GetEmailConfig().From, + Subject: "Your magic link", + BodyText: "Click: https://example.com/magic-link/consume?token=...", + Headers: map[string]string{ + // optional — useful for BDD test correlation + "X-Trace-Id": "req-abc-123", + }, +}) +``` + +Or, when both text and HTML are needed (`multipart/alternative`): + +```go +err := sender.Send(ctx, email.Message{ + To: "alice@example.com", From: "...", Subject: "...", + BodyText: "Click: https://...", + BodyHTML: `

Click your magic link

`, +}) +``` + +## Production sender (TBD) + +Not chosen yet. When ready, implement another `email.Sender` in +`pkg/email/_sender.go` and wire it via the config. The +`Sender` interface is the swap point — call sites don't change. + +## Cross-references + +- [ADR-0028 — Passwordless auth migration](../adr/0028-passwordless-auth-migration.md) (consumes this infrastructure) +- [ADR-0029 — Email infrastructure decision](../adr/0029-email-infrastructure-mailpit.md) +- [ADR-0030 — BDD email parallel strategy](../adr/0030-bdd-email-parallel-strategy.md) +- [Mailpit docs](https://mailpit.axllent.org/docs/) diff --git a/pkg/config/config.go b/pkg/config/config.go index e676f19..ce349f0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -104,9 +104,22 @@ type APIConfig struct { // AuthConfig holds authentication configuration type AuthConfig struct { - JWTSecret string `mapstructure:"jwt_secret"` - AdminMasterPassword string `mapstructure:"admin_master_password"` - JWT JWTConfig `mapstructure:"jwt"` + JWTSecret string `mapstructure:"jwt_secret"` + AdminMasterPassword string `mapstructure:"admin_master_password"` + JWT JWTConfig `mapstructure:"jwt"` + Email EmailConfig `mapstructure:"email"` +} + +// EmailConfig holds outgoing email transport configuration. +// Defaults match local Mailpit (cf. ADR-0029) so dev needs no extra setup. +type EmailConfig struct { + From string `mapstructure:"from"` + SMTPHost string `mapstructure:"smtp_host"` + SMTPPort int `mapstructure:"smtp_port"` + SMTPUsername string `mapstructure:"smtp_username"` + SMTPPassword string `mapstructure:"smtp_password"` + SMTPUseTLS bool `mapstructure:"smtp_use_tls"` + Timeout time.Duration `mapstructure:"timeout"` } // JWTConfig holds JWT-specific configuration @@ -256,6 +269,13 @@ func LoadConfig() (*Config, error) { v.SetDefault("auth.jwt.secret_retention.max_retention", 72*time.Hour) v.SetDefault("auth.jwt.secret_retention.cleanup_interval", 1*time.Hour) + // Email defaults — match local Mailpit (ADR-0029). + v.SetDefault("auth.email.from", "noreply@dance-lessons-coach.local") + v.SetDefault("auth.email.smtp_host", "localhost") + v.SetDefault("auth.email.smtp_port", 1025) + v.SetDefault("auth.email.smtp_use_tls", false) + v.SetDefault("auth.email.timeout", 10*time.Second) + // Check for custom config file path via environment variable if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" { v.SetConfigFile(configFile) @@ -301,6 +321,13 @@ func LoadConfig() (*Config, error) { v.BindEnv("auth.jwt.secret_retention.retention_factor", "DLC_AUTH_JWT_SECRET_RETENTION_FACTOR") v.BindEnv("auth.jwt.secret_retention.max_retention", "DLC_AUTH_JWT_SECRET_MAX_RETENTION") v.BindEnv("auth.jwt.secret_retention.cleanup_interval", "DLC_AUTH_JWT_SECRET_CLEANUP_INTERVAL") + v.BindEnv("auth.email.from", "DLC_AUTH_EMAIL_FROM") + v.BindEnv("auth.email.smtp_host", "DLC_AUTH_EMAIL_SMTP_HOST") + v.BindEnv("auth.email.smtp_port", "DLC_AUTH_EMAIL_SMTP_PORT") + v.BindEnv("auth.email.smtp_username", "DLC_AUTH_EMAIL_SMTP_USERNAME") + v.BindEnv("auth.email.smtp_password", "DLC_AUTH_EMAIL_SMTP_PASSWORD") + v.BindEnv("auth.email.smtp_use_tls", "DLC_AUTH_EMAIL_SMTP_USE_TLS") + v.BindEnv("auth.email.timeout", "DLC_AUTH_EMAIL_TIMEOUT") v.BindEnv("telemetry.sampler.type", "DLC_TELEMETRY_SAMPLER_TYPE") v.BindEnv("telemetry.sampler.ratio", "DLC_TELEMETRY_SAMPLER_RATIO") @@ -432,6 +459,13 @@ func (c *Config) GetAdminMasterPassword() string { return c.Auth.AdminMasterPassword } +// GetEmailConfig returns the outgoing email transport configuration. +// Defaults match local Mailpit (localhost:1025, no TLS, no auth) per +// ADR-0029. Used by pkg/email.NewSMTPSender. +func (c *Config) GetEmailConfig() EmailConfig { + return c.Auth.Email +} + // GetJWTTTL returns the JWT TTL func (c *Config) GetJWTTTL() time.Duration { if c.Auth.JWT.TTL == 0 { diff --git a/pkg/email/sender.go b/pkg/email/sender.go new file mode 100644 index 0000000..d11607b --- /dev/null +++ b/pkg/email/sender.go @@ -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 +} diff --git a/pkg/email/smtp_sender.go b/pkg/email/smtp_sender.go new file mode 100644 index 0000000..e4b14c1 --- /dev/null +++ b/pkg/email/smtp_sender.go @@ -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()) +} diff --git a/pkg/email/smtp_sender_test.go b/pkg/email/smtp_sender_test.go new file mode 100644 index 0000000..3696b39 --- /dev/null +++ b/pkg/email/smtp_sender_test.go @@ -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: "

HTML hello

", + } + 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, "

HTML hello

", "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. +}