apistockdocs
v0.4 GitHub apistock.dev
Decisions/Records

Email setup and delivery

ADR-0037Accepted (2026-09-14)

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=full doesn'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#

  1. Only at creation (aps new); switching means editing code by hand.
  2. Both providers compiled in, selected by an environment variable.
  3. A provider file generated by a command that can run at any time (aps add mail), with the same question in aps new --preset=full later.

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#

  1. The official Resend Go SDK (ADR-0025).
  2. 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_DELIVERY defaults to mailpit in development and provider in production; mailpit in production is a configuration error, and provider in 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 .env by 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.go and internal/app/infra_mail_test.go, the .env.example block, apistock.yaml, go.mod (adds the provider module, with a replace to the local checkout when the app uses one) and go mod tidy. .env is updated when it exists, or created from .env.example with 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.go and the .env.example block must exist); needs a clean git tree unless --allow-dirty; refuses to save secrets in .env when git doesn't ignore it; never prints secret values (the summary and --json list 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-single and examples/full-multi exactly (golden test); the end-to-end test switches a copy of the example to SMTP and back, and builds, vets and runs go 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 smtp left them failing. The provider's side of the tests now lives in infra_mail_test.go, which aps add mail replaces with infra_mail.go; every other test reads the provider's variables and /ops/mail details from its fixtures. The recipe fills in the app's module path from go.mod. Apps created before this change keep Resend-specific tests: after switching to SMTP, replace RESEND_API_KEY/re_123 in their tests with the fixtures, as in examples/full-single.
  • aps new --preset=full will 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/smtp and modules/mail/resend are 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, permissions ops.mail.read and ops.mail.test, error code invalid_recipient, audit action mail.test.requested, the aps add mail flags and the # aps:begin mail markers are public API (ADR-0015).
  • Guide: email.
esc
↑↓ move↵ openesc close