Decisions

ADR-043: Stdlib `net/smtp` is the default concrete Sender; native SDKs are opt-in

ADR-042 commits us to a `Sender` abstraction with multiple concrete implementations. The question this ADR answers is which concrete sender ships first — and by extension, which one every product wires by default until…

#043

ADR-043: Stdlib net/smtp is the default concrete Sender; native SDKs are opt-in

Status: Accepted Date: 2026-04-21

Context

ADR-042 commits us to a Sender abstraction with multiple concrete implementations. The question this ADR answers is which concrete sender ships first — and by extension, which one every product wires by default until a specific feature pulls them off it.

The realistic candidates:

  • NewSESSender — AWS SES HTTP API via aws-sdk-go-v2. Pulls a large dependency tree (aws-sdk-go-v2, aws-sdk-go-v2/service/sesv2, credential chains, signers). Every consumer, even dev-only ones, pays that import cost.
  • NewPostmarkSender — Postmark HTTP API. Ties day-one to one vendor; a feature that hasn't differentiated yet makes a lasting commitment.
  • NewSMTPSender — stdlib net/smtp. Zero dependencies. Works with every reputable transactional provider (AWS SES, Postmark, Mailgun, Resend, SendGrid) because each exposes a standard SMTP submission endpoint. Also works with local relays (MailHog, Mailpit) for dev.

The cost-of-switch once a decision ships is asymmetric. Adding another sender later is additive. Replacing a sender already in production means updating every service's wiring at the same time, or living with drift. That asymmetry argues for the lowest-commitment default.

Decision

NewSMTPSender(host, port, username, password, from) is the first production-ready concrete Sender. Every product wires this until a specific provider feature (webhooks, suppression lists, per-tag analytics) pulls it off onto a native-SDK sender.

Provider-native senders (NewSESSender, NewPostmarkSender, NewResendSender) remain on the roadmap and are documented in the package README under "Deferred providers". They land when the first product needs a feature that SMTP cannot deliver, carried by the product team making that ask.

Transport design:

  • *smtp.Client satisfies the package's SMTPClient interface directly — no adapter. net/smtp upgrades to STARTTLS based on the server's EHLO, so TLS happens transparently on submission ports where it is advertised.
  • Username == "" skips AUTH — only for local relays in dev. Production always authenticates.
  • Context cancellation is checked before dial; in-flight SMTP calls use the underlying connection's deadline set by *smtp.Client.
  • The Dial field is an injectable hook so unit tests can drive the SMTP conversation with a fake SMTPClient instead of a real TCP session — keeps the package test suite fast and race-free.

Consequences

Easier:

  • Day-one deployments of any gyrum product can send mail with environment variables alone. No SDK version tracking, no IAM role configuration, no vendor-specific setup.
  • Swapping SMTP providers (SES → Postmark, Postmark → Mailgun) is an environment-variable change. The code does not know which provider is on the other side.
  • Local dev works against MailHog or Mailpit with the same binary path that runs in prod.

Harder:

  • SMTP is a lossy provider API. Bounces/complaints are surfaced through a follow-up channel (webhooks, a separate feedback loop) that SMTP cannot deliver. A product that needs first-class bounce tracking drops SMTP in favour of the provider-native sender.
  • Submission-port nuance varies by provider. 587 STARTTLS covers AWS SES, Postmark, Mailgun, Resend; 465 implicit-TLS covers Gmail and a few others. The wiring layer is responsible for getting the port right.
  • Rate-limiting and back-off are not built in. A product that sends bulk should queue in front of SMTPSender, not rely on the sender to smooth bursts.

Signed up to operate:

  • The SMTPClient interface and Dial hook are stable contracts. Tests across the factory depend on swapping Dial — breaking the interface breaks every email-using product's test suite.
  • Follow-up ADRs will accompany each provider-native sender when it lands: they cover credential shape, retry semantics, and any provider-specific Message extensions.

Alternatives considered

  • AWS SES SDK first — rejected. Large transitive dep tree for every service, even dev-only. Couples the factory to one vendor before the use case has asked for it.
  • gomail.v2 as the SMTP client — rejected. A popular abandoned (last commit 2019) library that wraps net/smtp with features we don't need. Stdlib covers our case; no reason to take on a dependency for it.
  • Skip SMTP; require every product to pick a vendor SDK — rejected. Creates a coordination cost for every new product ("which vendor do I pick?") that a library of infrastructure sensibly absorbs.

Supersedes: none Superseded by: