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 exportedNewXSender(...)constructor. No consumer has to migrate.
Harder:
- The abstraction caps what
Messagecan express. Provider-specific features (e.g. Postmark'sTemplateAlias, SES's configuration sets) live on the vendor-native type, not onMessage— 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
Senderinterface andMessagestruct 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/emailis 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.goexplicit 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 = extrago.mod/release cadence/dep tree for a small body of code; the one-package-many-senders shape already ships inpkg/smsand works.
Supersedes: none Superseded by: —