Email setup and delivery
Status: Accepted (2026-09-14) · Amends: ADR-0025, ADR-0035
Context#
ADR-0025 chose a core mail.Sender with Resend and SMTP provider modules, Mailpit in development, delivery through jobs, and a creation prompt (--mail=resend|smtp). ADR-0033 added the mail worker and jobs.AsyncSender. Building the providers and the Full preset settled what those ADRs left open:
aps new --preset=fulldoesn't exist yet, but Full apps (examples/full-single) need email now, and teams must be able to switch provider later.- Setup must be very easy: pick Resend or SMTP in the terminal, be told exactly where the Resend API key goes, and change everything else from the admin dashboard, following the environment vs runtime settings rule (ADR-0031).
- ADR-0035 requires a flag for every prompt, but secrets passed as flags end up in shell history and CI logs.
- Some provider failures (an unverified sender domain, an invalid address) will never succeed on retry, yet the mail worker retried every error up to eight times.
Options for choosing the provider#
- Only at creation (
aps new); switching means editing code by hand. - Both providers compiled in, selected by an environment variable.
- A provider file generated by a command that can run at any time (
aps add mail), with the same question inaps new --preset=fulllater.
Option 3. The app carries only the chosen provider's code and variables, and switching is one command with a reviewable diff.
Options for the Resend client#
- The official Resend Go SDK (ADR-0025).
- Resend's HTTP API called directly.
Option 2, amending ADR-0025. apistock uses one endpoint; calling it directly adds no dependency, sends the Idempotency-Key header, and classifies responses precisely into permanent and temporary failures, which the SDK's generic errors don't.
Decision#
Library#
| Piece | Decision |
|---|---|
Core mail | Adds ErrRejected, wrapped by providers when retrying the same message can't succeed, and WithDefaults(sender, mail.Defaults{FromName, FromEmail, ReplyTo}), which fills an empty sender from config.Values (runtime settings) on every send |
modules/jobs | The mail worker cancels a job whose send is ErrRejected (the run shows why) and retries everything else |
modules/mail/smtp | Standard library only (net/smtp). TLS modes starttls (default, required before auth), tls (implicit, port 465) and none (local servers); AUTH PLAIN only over TLS or to a local host, and New refuses credentials with none to a remote host. One connection per send, bounded by a timeout (30 s) and the context. Message-ID derived from the idempotency key, so a retried job sends the same ID. 5xx replies are ErrRejected; authentication failures stay temporary because fixing credentials lets a retry succeed |
modules/mail/resend | POST /emails with Authorization, Idempotency-Key (hashed above 256 characters) and tags. 400, 403, 404, 405, 413, 422 and 409 invalid_idempotent_request are ErrRejected (403 adds a hint to verify the domain or change mail.from_email); 401 (fix RESEND_API_KEY), 409 concurrent_idempotent_requests, 429 and 5xx are temporary. Errors never contain the API key |
Configuration#
| Value | Where | Why |
|---|---|---|
RESEND_API_KEY | Environment (config.Secret) | Secret |
SMTP_HOST, SMTP_PORT, SMTP_TLS, SMTP_USERNAME, SMTP_PASSWORD | Environment | Infrastructure and secrets |
MAIL_DELIVERY (mailpit or provider), MAILPIT_SMTP_ADDR | Environment | Infrastructure |
mail.from_name, mail.from_email, mail.reply_to | Runtime settings, edited in /ops/settings | Non-secret tunables that operators change without a redeploy |
MAIL_DELIVERYdefaults tomailpitin development andproviderin production;mailpitin production is a configuration error, andproviderin development sends real email on purpose.- The provider's variables are required only when delivery is
provider, with messages that say where to get them. - In production, a still-default sender address (
no-reply@example.com) is logged as a warning at startup.
Application files#
| File | Content | Changes with the provider |
|---|---|---|
internal/app/mail.go | Delivery constants, newMailSender (Mailpit or provider), mailInfo, the default-sender warning | No |
internal/app/infra_mail.go | mailProvider, mailConfig, loadMailConfig, newMailProvider, details() (non-secret facts for ops) | Yes: written by aps add mail |
internal/app/infra_mail_test.go | The provider's test fixtures (mailProviderEnv, mailProviderRequired, mailProviderName, mailProviderDetails) and TestMailProviderConfiguration | Yes: written by aps add mail |
.env.example | The provider's variables between # aps:begin mail and # aps:end mail | Yes: the block is replaced |
internal/app/settings.go | The mail.* settings and mailDefaults() | No |
apistock.yaml | mail: resend or mail: smtp | Yes |
app.go registers the mail worker with the chosen sender and builds mailer := mail.WithDefaults(jobs.AsyncSender(client), settings.mailDefaults()) for modules.
aps add mail#
| Prompt | Flag | Default |
|---|---|---|
| How should the app send email? (Resend, recommended; SMTP) | --provider resend|smtp (implied by any --smtp-* flag) | resend |
| Resend: API key (optional, hidden input) | none: secrets are never flags | empty: add to .env later |
| SMTP: server (optional) | --smtp-host | empty: add to .env later |
| SMTP: port and encryption (587 STARTTLS, 465 TLS, 2525 STARTTLS, 25 none) | --smtp-port, --smtp-tls | 587, starttls (tls for 465) |
| SMTP: username (optional) | --smtp-username | empty |
| SMTP: password (optional, hidden input, only with a username) | none: secrets are never flags | empty |
Other flags: --dry-run, --json, --allow-dirty, --skip-tidy, --yes, --no-input, --plain.
- Flag parity exception (amends ADR-0035): secrets are asked with hidden input and saved only to
.env, or added to.envby hand. Scripts set them in the environment. - Before the questions, a note says what goes where: secrets in
.env, which git ignores; sender name, address and reply-to later in/ops/settings, with no redeploy. - What it changes:
internal/app/infra_mail.goandinternal/app/infra_mail_test.go, the.env.exampleblock,apistock.yaml,go.mod(adds the provider module, with areplaceto the local checkout when the app uses one) andgo mod tidy..envis updated when it exists, or created from.env.examplewith mode 0600 when there are values to save. Each variable keeps a value it already had anywhere in.env, and ends up defined once. - Safety: runs only in Full preset apps (
internal/app/mail.goand the.env.exampleblock must exist); needs a clean git tree unless--allow-dirty; refuses to save secrets in.envwhen git doesn't ignore it; never prints secret values (the summary and--jsonlist variable names only). - Output: a summary to confirm, then numbered next steps for the chosen provider: where to create a Resend key and verify the domain (or which SMTP variables remain), how to start the app, how to set the sender with
PUT /ops/settings/mail.*, how to send a test email, and that development email is in Mailpit. - Running it again with the current provider changes nothing and prints the next steps.
- Tests: the Resend recipe reproduces
examples/full-singleandexamples/full-multiexactly (golden test); the end-to-end test switches a copy of the example to SMTP and back, and builds, vets and runsgo test ./internal/app/each time. - Provider-neutral app tests (2026-09-15): an app's own tests passed only with Resend, so
aps add mail --provider smtpleft them failing. The provider's side of the tests now lives ininfra_mail_test.go, whichaps add mailreplaces withinfra_mail.go; every other test reads the provider's variables and/ops/maildetails from its fixtures. The recipe fills in the app's module path fromgo.mod. Apps created before this change keep Resend-specific tests: after switching to SMTP, replaceRESEND_API_KEY/re_123in their tests with the fixtures, as inexamples/full-single. aps new --preset=fullwill ask the same question when it ships.
Ops APIs#
| Endpoint | Permission | Result |
|---|---|---|
GET /ops/mail | ops.mail.read | Provider, delivery mode, non-secret provider details (api_key: configured|missing; SMTP host, port, TLS, whether it authenticates) and the current sender settings |
POST /ops/mail/test {to} | ops.mail.test | Queues a test email through the normal path (202); audit event mail.test.requested without the recipient; invalid_recipient (422) |
Why#
- Picking a provider takes one command with guidance at each step, and switching later is the same command.
- Secrets never leave
.env, and everything else is editable live, exactly as ADR-0031 requires. - Delivery in development is always safe (Mailpit), and production refuses to start without the provider's credentials.
- Permanent failures stop immediately and are visible in job runs; temporary ones retry with idempotency.
Trade-offs#
- Mailpit is a second development container.
- Two provider templates must stay compilable; the end-to-end switch test guards them.
- A hidden-input prompt can't be scripted; scripts put secrets in the environment instead.
- Calling Resend's API directly means following its API changes ourselves.
Consequences#
modules/mail/smtpandmodules/mail/resendare new modules with their own CI rows; CI runs Mailpit as a service container.- Setting keys
mail.from_name,mail.from_email,mail.reply_to, permissionsops.mail.readandops.mail.test, error codeinvalid_recipient, audit actionmail.test.requested, theaps add mailflags and the# aps:begin mailmarkers are public API (ADR-0015). - Guide: email.