Files
dance-lessons-coach/documentation/EMAIL.md
Gabriel Radureau ea4bae5807 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
2026-05-05 10:46:45 +02:00

3.3 KiB

Email infrastructure

Outgoing email transport. Per ADR-0029: Mailpit for local dev + BDD tests, production sender deferred.

Local setup (one-time)

Mailpit is part of docker-compose.yml:

docker compose up -d                 # starts postgres + mailpit
docker compose ps                    # confirm both running

Mailpit listens on:

  • SMTP submissionlocalhost:1025 (the app sends here)
  • HTTP UI / APIhttp://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:

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)

# 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/<id>' | 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

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):

err := sender.Send(ctx, email.Message{
    To: "alice@example.com", From: "...", Subject: "...",
    BodyText: "Click: https://...",
    BodyHTML: `<p>Click <a href="https://...">your magic link</a></p>`,
})

Production sender (TBD)

Not chosen yet. When ready, implement another email.Sender in pkg/email/<provider>_sender.go and wire it via the config. The Sender interface is the swap point — call sites don't change.

Cross-references