# 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=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.Value`s (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](/technical/email/).
