Decisions

ADR-042: `email.Sender` is the abstraction; provider SDKs slot in behind it

Transactional email (password resets, receipts, verification codes, invites) is a Tier-1 gap on the [platform gap audit](../platform-gap-audit.md). Every existing gyrum product (distill, buzzy, internal tooling) wants…

#042

ADR-042: email.Sender is the abstraction; provider SDKs slot in behind it

Status: Accepted Date: 2026-04-21

Context

Transactional email (password resets, receipts, verification codes, invites) is a Tier-1 gap on the platform gap audit. Every existing gyrum product (distill, buzzy, internal tooling) wants mail in the next quarter, and each has historically been prepared to wire its own provider SDK. Without a shared abstraction, the factory ends up with three services each imported against a different vendor (AWS SES in one, Postmark in another, Resend in a third), identical retry/validation/logging logic rewritten three times, and a sunk cost on the day someone wants to move a product off its original provider.

Two options are on the table: (a) each product imports a vendor SDK directly; (b) gyrum-go ships a Sender interface and every product depends on that. (b) is the hexagonal default for every other cross-cutting concern in the library (auth, billing, SMS, database), and the inconsistency of doing otherwise here is itself a cost.

Decision

pkg/email exposes a single port, Sender, with a one-method contract:

type Sender interface {
    Send(ctx context.Context, msg Message) error
}

Every product imports pkg/email and holds a Sender. The wiring at cmd/ level picks a concrete implementation. The first release ships four: Recorder (tests), LogSender (dev/CI), SMTPSender (production, any provider), and MultiSender (fail-over composition). Provider-native senders (SES, Postmark, Resend) ship in follow-up PRs as additional concrete types in the same package — not a separate package per provider — so the import path on the consumer side stays stable.

Message is rich but provider-agnostic: identity, routing, subject, text/html bodies, custom headers, and free-form tags. Features that only some providers implement (scheduled sends, template IDs, per-message suppression lists) are NOT on Message — the relevant provider-native sender can expose them via a wider, type-specific API that callers wire when they actually need them.

Consequences

Easier:

  • A product swaps providers by changing one line in cmd/main.go. No code in the domain or use-case layer references a vendor.
  • Test doubles are trivial. Every handler test injects &Recorder{} and asserts on captured messages; zero network, zero credentials, no risk of sending real mail from a CI runner.
  • Adding a provider is additive: a new file in pkg/email/, an ADR, and an exported NewXSender(...) constructor. No consumer has to migrate.

Harder:

  • The abstraction caps what Message can express. Provider-specific features (e.g. Postmark's TemplateAlias, SES's configuration sets) live on the vendor-native type, not on Message — callers that need them accept the coupling to that vendor explicitly.
  • Bugs in the shared validation or render path blast-radius-wise affect every product using email. This is the usual shared-library trade-off; mitigated by the 92% + test coverage and the separation of Validate/render from transport.

Signed up to operate:

  • The Sender interface and Message struct tags are a stable contract. Adding a field is fine; renaming or removing one requires a major version bump and a migration note.
  • When a product wires a provider-native sender, it owns the relationship (quota, key rotation, bounce handling). pkg/email is not a provider ops abstraction.

Alternatives considered

  • Per-product direct SDK imports — rejected. Guaranteed divergence: three products, three vendor SDKs, three retry policies, three logging conventions. Every migration is a rewrite.
  • Config-driven factory that picks a provider from env — rejected as premature. Today there is no product that switches providers at runtime; the cmd/main.go explicit wiring is clearer, safer, and one refactor away if the need appears.
  • Separate Go module per provider (gyrum-email-ses, gyrum-email-postmark) — rejected. Extra module = extra go.mod/release cadence/dep tree for a small body of code; the one-package-many-senders shape already ships in pkg/sms and works.

Supersedes: none Superseded by: