Adds the Mailpit HTTP API client used by BDD scenarios to assert on
emails sent during a test. Implements the per-recipient query/await/
purge pattern from ADR-0030.
Build-tag-gated integration tests run against the live Mailpit at
localhost:8025 (started via docker compose up -d mailpit).
Three operations on the client:
- MessagesTo(ctx, to) — list message IDs for a recipient
- Get(ctx, id) — fetch full message content (text, html,
headers, subject, etc.)
- AwaitMessageTo(ctx, to, — poll until a message arrives or timeout
timeout) (50ms polls, fail-fast on ctx cancel)
- PurgeMessagesTo(ctx, to) — delete all messages for a recipient
Tests in client_integration_test.go (build tag `integration`):
- RoundTrip: SMTP submit → list → get → assert subject/text — proves
the BDD-helper contract end-to-end via real SMTP
- AwaitTimeoutWhenNoMessage: bounded wait when no email arrives
- PurgeIsolation: per-recipient delete does NOT affect other recipients
Mailpit API quirk discovered + documented (Q-NNN candidate):
- /api/v1/messages?query=... is for PAGINATION, not filtering — the
`query` param there is silently ignored for filtering
- /api/v1/search?query=to:<addr> is the correct endpoint for filtering
AND the matching DELETE
- ADR-0030 + EMAIL.md updated with the correct endpoint
Run integration tests:
docker compose up -d mailpit
go test -tags integration -race ./pkg/bdd/mailpit/...
Out of scope for this PR (Phase A.3+):
- pkg/bdd/steps/email_steps.go BDD step definitions
- magic_link_tokens table + repository
- magic-link/request and magic-link/consume HTTP handlers
- BDD scenarios for the magic-link flow
108 lines
3.5 KiB
Markdown
108 lines
3.5 KiB
Markdown
# 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 (no filter — /api/v1/messages is for pagination)
|
|
curl -s 'http://localhost:8025/api/v1/messages?limit=10' | jq
|
|
|
|
# Messages for a specific recipient — use /api/v1/search, NOT /messages
|
|
# (the latter's `query` param is for pagination only, not filtering ;
|
|
# verified empirically 2026-05-05)
|
|
curl -s 'http://localhost:8025/api/v1/search?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) — also via /search
|
|
curl -X DELETE 'http://localhost:8025/api/v1/search?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: `<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
|
|
|
|
- [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/)
|