0022: Send transactional email through an outbox and an SMTP relay
- Status: Accepted
- Date: 2026-10-18
Context
Tò He is opening to the public with email as the account identifier: signing up, resetting a password, changing the address, linking a sign-in method and stepping up all send a one-time code or a security notice. The API sends no email today. Codes are short-lived (minutes), so mail has to leave quickly; it must never leave for a change that was rolled back, nor be lost for one that was saved. Codes are secrets while they live. ADR 0008 asks for an ADR for a new service, and this adds one: an email relay.
Decision
- Outbox. A module adds an
EmailMessageto theemail.outboxtable throughIEmailOutbox, in the same unit of work as the change it reports; nothing is sent unless that transaction commits. A background worker (EmailDelivery, everyEmail:PollInterval, 3 s by default) hands due messages to the relay — one API instance (ADR 0016), so no locking. - Retries. A refused message waits 1 min, 5 min, 15 min, 1 h, then 6 h between tries, and is given up after
Email:MaxAttempts(6). Delivery is at least once: a message the relay took just before the API stopped goes again. - Nothing secret stays. Once sent or given up, the subject and both bodies are erased; the envelope (kind,
recipient, times, last error) stays for
Email:Retention(30 days) and is then deleted. Logs never carry a recipient or a body. - SMTP, through MailKit. One protocol for every environment and provider: Mailpit in the local stack (web UI
on port 4760), Resend on UAT and production — the relay the
platformservices already use, on 587 with STARTTLS. The sender isTò He <no-reply@…>(the domain is a deployment setting). MailKit rather thanSystem.Net.Mail.SmtpClient, which Microsoft does not recommend for new development. - One look.
EmailLayoutrenders every message: Vietnamese, a heading, short paragraphs, an optional code set apart, a “do not reply” footer; plain text and HTML; no images, no tracking, no links that are not the point. - Off is a state.
Email:Mode=Disabled(the default) sends nothing, andIEmailOutbox.Enabledtells features that need email to stay unavailable — adding a message then is a programming error and throws.Email:Mode=Smtpwith an incomplete configuration stops the API at start-up, naming what is missing.
Not done: marketing or digest mail, bounce and complaint handling, a provider’s HTTP API.
Consequences
- New dependency MailKit (with its MimeKit). Its transitive
System.Security.Cryptography.Pkcs10.0.0 has published advisories; the API pins the patched 10.0.13 directly. - Configuration:
Email:*(Configuration). The local stack gains amailpitservice; UAT reads the relay’s settings fromuat.envand stays off until they are there (UAT); the Helm chart gains an SMTP block once it lands. - A code reaches the relay within a few seconds of being asked for; the poll interval bounds it.
- Whether production shares the platform’s Resend account depends on its plan: the free plan’s 100 emails a day are shared by everything on the account.
Alternatives considered
- Send inside the request: the person waits on the relay, a relay hiccup loses the code, and a rolled-back transaction can still have sent one.
- A queue (Redis, a broker): another moving part for a handful of messages an hour; the database is already transactional with the change being reported.
- The provider’s HTTP API: slightly richer, but ties the code to one provider and needs a fake locally; SMTP works the same against Mailpit and Resend.