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— stdlibnet/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.Clientsatisfies the package'sSMTPClientinterface directly — no adapter.net/smtpupgrades to STARTTLS based on the server's EHLO, so TLS happens transparently on submission ports where it is advertised.Username == ""skipsAUTH— 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
Dialfield is an injectable hook so unit tests can drive the SMTP conversation with a fakeSMTPClientinstead 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
SMTPClientinterface andDialhook are stable contracts. Tests across the factory depend on swappingDial— 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
Messageextensions.
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.v2as the SMTP client — rejected. A popular abandoned (last commit 2019) library that wrapsnet/smtpwith 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: —