# Email: providers, verification, email change and password reset Status: **implemented** (server and UI). This is the operator guide and the contract the code follows. The research and the decisions behind it are in `EMAIL_VERIFICATION_RESEARCH_2026-10.md`. Notes marked **As implemented** record where the code settled a detail the first draft left open, or changed it. Keep this file accurate when the code changes. Code (server): `apps/web/lib/mail/` (types, providers, host rules, settings, transport, classify, templates, send, stats, limits, tokens, verification, change, admin, doctor, disposable), `apps/web/lib/secret-box.ts`, the routes under `app/api/auth/email`, `app/api/auth/password/{forgot,reset}`, `app/api/admin/mail` and `app/api/admin/users/[id]/verify-email`, migration `packages/db/drizzle/0046_email.sql` and `packages/db/src/queries/email.ts`. ## 1. Scope **In scope:** - One mail layer with a provider registry: the two professional choices, the free tiers, custom SMTP, and mailpit in development. - Email verification with three modes: `off`, `optional` and `required`. - Email change. - Password reset. - An admin screen, Doctor checks, and the e2e setup with mailpit. **Out of scope, later:** - bounce and complaint webhooks; - per-server verification levels; - HTTP API transports. The registry is built so that adding one is a new `transport` entry, not a rewrite. Nothing changes for an existing install until an admin configures mail: - the default mode is `off`, with no mail transport; - password reset then shows "ask your administrator". ## 2. One structure for every provider (`apps/web/lib/mail/`) ### 2.1 Transport interface ```ts interface MailTransport { readonly kind: 'smtp'; // later: 'ses-api', 'resend-api', ... send(message: OutgoingMail): Promise; verify(): Promise; // connect + auth, no message (used by the test button) } ``` - The only implementation in v1 is SMTP, through nodemailer `10.0.14` (exact pin, already in `apps/web/package.json`). - One pooled transport per configuration, cached on `globalThis` and keyed by a hash of the settings. It is rebuilt when the settings change. - Connection timeout 10 s, greeting 10 s, socket 20 s. - `disableFileAccess` and `disableUrlAccess` are on. - Every address passes zod validation before nodemailer sees it. **As implemented** (`lib/mail/transport.ts`): - The host passes the §3.4 rules and is resolved **once**; nodemailer connects to an allowed address (IPv4 first) with the host name as the TLS server name, so the certificate is still checked against the name and a DNS answer that changes afterwards cannot redirect the connection. Certificate verification is always on, minimum TLS 1.2. - The pooled transport (2 connections, 50 messages each) is also rebuilt every 10 minutes, so the host is resolved and checked again; a replaced one is closed 30 s later. The admin test uses a fresh unpooled transport. - The pool's cache key and the last-test fingerprint never take the password: they use `authStamp`, the sealed value as stored (or `env`), which changes whenever the password does. So a new password still rebuilds the pool and invalidates the recorded test. - `MailTransport` also has `close()`. `send` answers `{ ok: true, messageId }` or `{ ok: false, result, detail?, permanent }` (the §5 codes; `permanent` = a 5xx reply). - nodemailer is pinned at `10.0.14` in `apps/web/package.json`. Every GitHub advisory against nodemailer is fixed at or below 10.0.13 (the latest, GHSA-4ffr-jq9g-5ffx and GHSA-g73g-hqqh-jr95, in 10.0.13), so 10.0.14 carries them all. It was the newest release at least a week old when chosen (2026-10-10), a supply-chain margin; 10.0.15 and 10.0.16 were newer, and change only error typings and the address/cookie parsers. Dependabot proposes later versions. ### 2.2 Provider registry (pure data, importable from client code, no secrets) `lib/mail/providers.ts` exports `MAIL_PROVIDERS: MailProviderPreset[]`: ```ts interface MailProviderPreset { id: MailProviderId; // 'ses' | 'scaleway' | 'brevo' | 'smtp2go' | 'resend' | 'mailjet' | 'mailgun' | 'gmail' | 'custom' | 'mailpit' tier: 'professional' | 'free' | 'custom' | 'development'; transport: 'smtp'; regions?: { id: string; host: string }[]; // SES regions, Mailgun US/EU host?: string; // fixed host when there are no regions ports: { port: number; security: 'tls' | 'starttls' }[]; // first = recommended usernameHint: string; // message key, e.g. "SMTP username from the SES console" / "the literal 'resend'" passwordHint: string; // message key freeTier?: { perDay?: number; perMonth?: number; noteKey?: string }; pricingNoteKey?: string; docsUrl: string; // the provider's SMTP setup page dataRegionNoteKey?: string; // EU option, KVKK note } ``` **Presets.** Verify every host and port against the provider's CURRENT docs before coding; the research table is the starting point. - **professional:** - `ses`: regions, with `eu-central-1` first; host `email-smtp..amazonaws.com`; 587/2587 STARTTLS, 465/2465 TLS. - `scaleway`: Scaleway TEM, `smtp.tem.scaleway.com`; 587/2587 STARTTLS, 465/2465 TLS. The username is the project id and the password is the API secret key. - **free:** - `brevo`: 300/day. - `smtp2go`: 1,000/month; port 2525 first. - `resend`: 3,000/month, 100/day; the username is literally `resend`. - `mailjet`: 6,000/month and 200/day, with their logo in the email. - `mailgun`: 100/day; US or EU region. - `gmail`: 500/day, needs an app password; for testing and small closed groups. - **custom:** any host and port. - **development:** `mailpit`, `localhost:19525` in the dev stack and `mailpit:1025` inside compose, no TLS. Offered only when `NODE_ENV !== 'production'`, or when the host is the compose service name. Never offered on the official hub. Adding a provider later means appending one entry, plus its message keys. The admin UI renders from the registry. **As implemented** (`lib/mail/providers.ts`, keys in `messages//mailProviders.json`): - Two fields were added: `name` (the brand name, shown as is) and `crossBorder` (a hosted relay outside Türkiye: the screen shows the KVKK note; false for `custom` and `mailpit`). `ports[].security` can be `none` for the development preset only. - Ports are listed only where §3.4 allows them; providers' extra ports (80, 443, 588, 8025, 8465) are left out on purpose. - **The development preset (changed after e2e):** it is offered on EVERY instance that is not the official hub, labelled Development (its tier), and never on the hub — where the admin PUT refuses it (`invalid_settings`, issue `provider: not_offered`). "Only outside production" hid it from a production-mode compose stack although that stack runs mailpit as a service. What it fills depends on where the app runs: `offeredMailProviders({ production, official })` / `getMailProvider` hand out `localhost:19525` outside production (the dev host) and `MAILPIT_COMPOSE_PRESET` — `mailpit:1025`, no TLS — in a production build (inside compose; `localhost` there is the app container, and production refuses loopback anyway). The host rules (§3.4) accept exactly that pair in production, so what is offered is what a save accepts. Verified on 2026-10-04 against each provider's own documentation: | Preset | Host | Ports (recommended first) | User name / password | Free tier | Source | |---|---|---|---|---|---| | `ses` | `email-smtp..amazonaws.com`; regions eu-central-1 (default), eu-west-1, eu-west-2, eu-west-3, eu-north-1, us-east-1, us-east-2, us-west-1, us-west-2 (eu-south-1 and eu-central-2 have no SMTP endpoint) | 587, 2587 STARTTLS; 465, 2465 TLS (25 left out: throttled on EC2, blocked on most VPS) | SMTP credentials from the SES console (an IAM user's key id and a derived SMTP password — not the AWS access key); per region | none fixed (credit-based free plan; sandbox 200/day) | , , | | `scaleway` | `smtp.tem.scaleway.com` | 587, 2587 STARTTLS; 465, 2465 TLS | project ID / IAM API secret key | 300/month per organization | | | `brevo` | `smtp-relay.brevo.com` | 587, 2525 STARTTLS; 465 TLS | the SMTP login (`…@smtp-brevo.com`) / an SMTP key (not the API key) | 300/day | | | `smtp2go` | regions: global `mail.smtp2go.com` (default), `mail-eu.smtp2go.com`, `mail-us.smtp2go.com`, `mail-au.smtp2go.com` | 2525 (their recommendation), 587, 25 STARTTLS; 465 TLS | an SMTP user created under Sending → SMTP Users / its password | 1,000/month, 200/day, 25/hour until the domain is verified | , | | `resend` | `smtp.resend.com` | 587, 2587 STARTTLS; 465, 2465 TLS; 25 | the literal `resend` / an API key | 3,000/month, 100/day | , | | `mailjet` | `in-v3.mailjet.com` | 587, 2525 STARTTLS; 465 TLS; 25 | API key / secret key | 6,000/month, 200/day, Mailjet logo | , | | `mailgun` | regions: us `smtp.mailgun.org` (default), eu `smtp.eu.mailgun.org` | 587 (their recommendation), 2525 STARTTLS; 465 TLS; 25 | the domain's SMTP login (e.g. `postmaster@mg.example.org`) / its SMTP password; API-created domains need SMTP credentials added by hand | 100/day | | | `gmail` | `smtp.gmail.com` | 587 STARTTLS; 465 TLS | the full Gmail address / an app password (needs 2-Step Verification) | ~500/day | , , | | `custom` | any | 587, 465, 2525, 2587, 2465, 25 | whatever the server needs (may be empty) | — | this file | | `mailpit` | `localhost` (dev host) — `mailpit` inside compose | 19525, 1025, no TLS | none | — | | Not first-party: Brevo's `@smtp-brevo.com` login format and 300/day, and SMTP2GO's daily and hourly caps, came from the providers' help-centre search snippets (those pages refuse automated fetches). Resend's EU sending region keeps account data in the US. ### 2.3 Sending The entry point is `sendMail({ to, template, locale, vars })`. - **Templates:** - `verify`: code plus link; - `change-confirm`: sent to the new address; - `change-notice`: sent to the old address; - `reset`: code plus link; - `test`. - **Content:** plain text plus simple HTML, built from `messages/{en,tr}/email.json` in the recipient's saved `users.locale`, else the triggering request's language, else the instance default. No remote images, no tracking. The code is never in the subject line. - **Sign-up never blocks on mail.** It returns at once and the send happens without waiting. Failures are logged (no addresses in logs, or masked) and counted for Doctor. - **Daily limit:** an optional instance-wide `mailDailyLimit`. Doctor warns at 80%, and at 100% sends are refused with `mail_quota`. **As implemented** (`lib/mail/send.ts`, `templates.ts`, `stats.ts`): - **Language** (docs/TRANSLATING.md). An email goes out in the recipient's saved language (`users.locale`, written by Settings → Appearance → Language and, for the owner, by the setup wizard), else in the language of the request that triggered it — the requester's own saved language, the `lf_locale` cookie, then Accept-Language — else the instance default (saved in Admin → Overview or at setup, then `LOBBYFORGE_DEFAULT_LOCALE`), then English: the same order the pages use. Since migration 0048 NULL means "never chose", so an explicit English choice is honoured. A password reset is written in the ACCOUNT OWNER's saved language, not the language of whoever asked for it. The admin test uses the instance default. A saved code whose language was since removed is skipped. - **Links** are built from `LOBBYFORGE_APP_ORIGIN`, else `NEXT_PUBLIC_BASE_URL` — never from the request's Host header. With neither configured the email carries the code only. - **Minimal data:** no display name in any email; the `change-notice` shows the new address masked (`n***@example.org`). - Headers: `Auto-Submitted: auto-generated`, `Content-Language`. - The user routes answer `202` and send in the background too (`dispatchMail`); they check `mail_unavailable` / `mail_quota` first, so those still come back synchronously. - The daily count is per UTC day (`lf::mail:sent:`, in Redis in production). Counting fails open: a Redis outage does not stop verification emails (the provider's own limit is the backstop). Test sends count too. Failures are counted for Doctor (`lf::mail:failures`, `…:auth-failures`, `…:last-failure`, 24 h), logged with the address masked and never with the server's reply text. ## 3. Configuration ### 3.1 Migration 0046 (`email`) **`users`:** add `email_verified_at timestamptz null`. - Backfill it to `now()` for users whose Google identity link has `email_verified = true`. **New table `email_tokens`:** - `id uuid pk` - `user_id uuid fk → users on delete cascade` - `purpose text check in ('verify','change','reset')` - `target_email text` - `token_hash bytea`: sha256 of 32 random bytes - `code_hash bytea`: HMAC-SHA256 of the 6-digit code, keyed by HKDF(session secret, `lobbyforge:email-code:v1`), with the token id mixed in - `code_attempts int default 0` - `expires_at timestamptz`: the link - `code_expires_at timestamptz` - `consumed_at timestamptz null` - `created_at timestamptz default now()` Indexes on `token_hash`, and on `(user_id, purpose)`, which is unique while the token is not consumed. **`instance_settings` columns:** | Column | Default | |---|---| | `mail_provider` text (registry id or `none`) | `none` | | `mail_region` text | null | | `smtp_host`, `smtp_port int`, `smtp_security` (`tls`/`starttls`/`none`), `smtp_username` | null | | `smtp_password_encrypted` | null | | `mail_from` (e.g. `LobbyForge `) | null | | `mail_daily_limit int` | null | | `mail_last_test_at`, `mail_last_test_result` | null | | `email_verification_mode` (`off`/`optional`/`required`) | `off` | | `email_verification_scope` jsonb | `{"open_register":true,"invite_register":false}` | | `email_verification_enforced_since` timestamptz | null (set when the mode first becomes `required`) | | `email_verification_existing_deadline` timestamptz | null (optional: existing accounts must verify by then) | | `disposable_email_block` boolean | false | | `disposable_email_overrides` jsonb | `{"allow":[],"block":[]}` | **As implemented** (`0046_email.sql`, expand-only and idempotent; CI runs `email.integration.test.ts` against real Postgres): - `email_tokens.target_email` is `NOT NULL`. The token-hash index is **unique** (`email_tokens_token_hash_unique`); the live-row index is `email_tokens_user_purpose_active_unique … WHERE consumed_at IS NULL`. - CHECK backstops (SQL only, like 0045): the password is only ever stored as `v1...`; `token_hash` and `code_hash` are 32 bytes; `purpose`, `smtp_security` and the mode are enums; `mail_provider` and `mail_region` are slugs (`^[a-z0-9-]{1,32}$`, not an enum — adding a provider must not need a migration); port 1–65535; daily limit 1–10,000,000; the JSON columns are objects. - Two more columns than the table above (review): `users.signup_channel` (`open` | `invite` | `oauth` | `setup`, null for accounts that predate 0046; see §4.2) and `instance_settings.mail_last_test_fingerprint` (see §5). The register route sets `open`/`invite` (every hub sign-up is `open`), a Google sign-up `oauth`, `/setup` `setup`. - The Google backfill marks an account only when its address IS the verified Google address (`users.email = lower(link.provider_email)`) — Google sign-ups have no address, so in practice it marks accounts that share one with their Google link — and touches only rows still `NULL` (re-running it changes nothing). - Queries: `getInstanceMailSettings` / `setInstanceMailSettings` (partial), `recordInstanceMailTest` (with the tested fingerprint; does not touch `updated_at`), `ensureEmailVerificationEnforcedSince` (set once), and in `queries/email.ts` the challenge functions (`replaceEmailToken` serialized by an advisory lock, `reserveEmailCodeAttempt` — §4.1 — and `applyEmailVerification` / `applyEmailChange` / `applyPasswordReset`, each the conditional UPDATE plus its action in one transaction) and the account helpers (`getUserEmailState`, `markUserEmailVerified`, `markUserEmailVerifiedForAddress`, `changeUserEmailDirect`, `revokeEmailChallenges`, `listUserEmailVerification`, …). - **Stale challenges die with the write that makes them stale, in the same transaction:** every password write — a reset (`applyPasswordReset`) and a password change in the settings (`replaceUserPasswordHash`) — drops the account's live `change` and `reset` challenges, so a change started by someone who knew the old password cannot be confirmed after the owner took the account back; an address change (`applyEmailChange`) drops the live `reset` and `verify` challenges sent to the old address, and a direct change (`changeUserEmailDirect`) drops every live challenge. There is no separate "sign out everywhere" action (the sessions list revokes one session at a time); a password change is that action. ### 3.2 Environment overrides These win over the database, and the admin UI shows them as locked: - `LOBBYFORGE_MAIL_PROVIDER` - `LOBBYFORGE_SMTP_HOST`, `LOBBYFORGE_SMTP_PORT`, `LOBBYFORGE_SMTP_SECURITY` - `LOBBYFORGE_SMTP_USER`, `LOBBYFORGE_SMTP_PASSWORD` - `LOBBYFORGE_MAIL_FROM` - `LOBBYFORGE_EMAIL_VERIFICATION`: `off` is the emergency switch When `LOBBYFORGE_SMTP_HOST` is set without a provider, the provider counts as `custom`. **As implemented** (`lib/mail/settings.ts`): - An empty value counts as unset; an invalid one is ignored, logged once, and Doctor warns (`mail_env`). An environment value is never copied into the database (a PUT that sends the locked value back is fine; a different one is 409). The provider is locked when either `LOBBYFORGE_MAIL_PROVIDER` or `LOBBYFORGE_SMTP_HOST` is set. - Missing pieces come from the preset: the host from the region (or the first region), the port from the recommended one, the security from the preset's port (465/2465 → `tls`, else `starttls`). - Cached per process for 5 s on `globalThis`; an admin save invalidates it. When the row cannot be read: no transport, verification as the environment says or `off` (so restrictions fail **open** — the restricted actions need the database anyway), cached 3 s, logged at most once a minute. - `email_verification_enforced_since` is also recorded the first time the ENVIRONMENT makes the mode `required` (one conditional UPDATE when the settings are read), so `LOBBYFORGE_EMAIL_VERIFICATION=required` (the official hub) restricts accounts created from that moment on. ### 3.3 Secrets - Extract the CAPTCHA secret code into `apps/web/lib/secret-box.ts`: AES-256-GCM, HKDF from the session secret, a purpose label, and the `v1...` format. - CAPTCHA keeps its label (`lobbyforge:captcha-secret:v1`), so existing ciphertexts still decrypt; a test proves it. - SMTP uses `lobbyforge:smtp-secret:v1`. - The password never goes back to a browser. The API returns only `passwordSet` and `passwordHint`. **As implemented:** each purpose has its own HKDF `info` AND its own AES-GCM associated data (the column it lives in), so a ciphertext never opens under another purpose: `CAPTCHA_SECRET_BOX` (info `lobbyforge:captcha-secret:v1`, AAD `lobbyforge:instance_settings.captcha_secret_encrypted` — both unchanged) and `SMTP_SECRET_BOX` (`lobbyforge:smtp-secret:v1`, `lobbyforge:instance_settings.smtp_password_encrypted`). `lib/__tests__/secret-box.test.ts` decrypts a ciphertext produced by the pre-extraction CAPTCHA code. ### 3.4 SMTP host rules (SSRF) - Allowed ports: 25, 465, 587, 2465, 2525, 2587, plus 1025 for mailpit/development. - Refuse link-local and metadata addresses (169.254.0.0/16, fd00:ec2::254), checked on the resolved addresses. - Refuse loopback and private ranges in production, unless the host is the compose service name `mailpit` and the instance is not official. Use the existing IP helpers. - TLS certificate verification cannot be turned off. `none` security is allowed only for localhost or mailpit. **As implemented** (`lib/mail/host-rules.ts`): the development ports are 1025 **and 19525** (the dev stack publishes mailpit's 1025 on the host as 19525, which is what `localhost` reaches) — both only outside production; in production 1025 only for the host `mailpit` on an instance that is not the official hub. Link-local is 169.254.0.0/16 and fe80::/10 (and their IPv4-mapped forms) plus fd00:ec2::254; a name that does not resolve is `connection` / `host_not_found`. The admin PUT runs the checks that need no DNS (`staticSmtpTargetRefusal`); every connection and the test run the full check on the resolved addresses. ## 4. Verification ### 4.1 Rules - **Who gets a verification email:** in `optional` and `required` modes, a sign-up in scope (`open_register`, and/or `invite_register`). A verify token is created and the `verify` email sent. - **Link:** `/verify-email?t=`. - GET only shows a confirmation page with a button. It never uses up the token, because security scanners open links. - The button POSTs, and that consumes the token. - The page sends `Referrer-Policy: no-referrer`. - The link never signs anyone in. - **Code:** 6 digits, valid 15 minutes, at most 5 wrong attempts. The link is valid 24 hours. A new send invalidates the previous token. - **Consuming a token** is one conditional `UPDATE … WHERE consumed_at IS NULL AND expires_at > now()`. Compare with `timingSafeEqual`. - **Accounts that count as verified:** guests have no email and are never restricted. A Google sign-in with `email_verified=true` sets `email_verified_at`. A successful password reset also verifies. - **Admin:** can mark a user verified (audited as `user.email_verified_by_admin`). **As implemented** (`lib/mail/tokens.ts`): - The link token is 32 random bytes, base64url (43 characters) in the link; only `sha256(bytes)` is stored and looked up. The code HMAC input is `:`. - Validity: link 24 h (verify, change) / 60 min (reset); code 15 min; 5 attempts per code (the link still works after that). - **An attempt is reserved before the code is compared:** every code submission first runs one conditional UPDATE — `code_attempts = code_attempts + 1 WHERE … code_attempts < 5 AND consumed_at IS NULL AND code_expires_at > now() AND expires_at > now()` — and only a submission that got a row compares (in constant time). So however many guesses arrive at once, at most 5 are ever compared (proven against real Postgres with 12 concurrent reservations). The submission that uses the fifth attempt with a wrong code answers `too_many_attempts`; a right code on the fifth attempt still works (the consuming UPDATE accepts a count AT the cap, never over it). - A reset challenge works only while the account's address is still the one it was sent to (the reset UPDATE carries `users.email = target_email`; otherwise nothing changes). - The `change-confirm` link points to `/verify-email?t=…` too: the page's `POST /api/auth/email/verify` accepts a CHANGE token and applies the change (answer `{ "verified": true, "changed": true }`). `POST /api/auth/email/change/confirm` accepts the same token. - A verification for an address the account no longer has is consumed and verifies nothing (`invalid_token`). - Expired and consumed challenges older than a day are deleted at most once an hour per process, on the next send. ### 4.2 What an unverified account may do **`optional`:** everything, plus a banner. **`required`:** the account is restricted only if all of these hold: - it was created after `enforced_since`, or `existing_deadline` has passed; - it is not the owner or an instance admin; - it is not a guest. Restricted accounts can still sign in, read, change settings and profile, change email and delete the account. They cannot: - send messages, DMs or reactions; - get a voice token; - create servers, channels or invites; - upload files; - create bots, bot tokens or webhooks; - on the hub: publish plugins or list a community. **Enforcement:** - One helper on the server, `requireVerifiedEmail(user, action)`, refuses with 403 `{ "error": "email_unverified" }`. - Apply it in every route for the actions above. Grep for each; don't miss the bot, webhook and upload routes. **`required` safety:** - The mode cannot be saved unless a transport is configured AND the last test succeeded. This is checked on the server and in the UI. - If mail later breaks, sign-up still works and Doctor goes critical. **As implemented** (`lib/mail/verification.ts`, gate placement): - Signature: `requireVerifiedEmail(user: string | { id }, action)`, where `action` is one of `message`, `dm`, `reaction`, `voice`, `server_create`, `channel_create`, `invite_create`, `upload`, `bot_create`, `bot_token`, `webhook`, `plugin_publish`, `directory_listing`. Outside `required` it answers from the cached settings without touching the database; if the check itself fails it lets the request through (logged). - "Owner or instance admin": the instance's only admin is the owner (`instance_settings.owner_user_id`). When the owner cannot be looked up, nobody is restricted (fail open, like unreadable settings). - **Scope applies to the restriction too** (review, product decision): an account is restricted only when its sign-up channel is in the verification scope — `open` follows `open_register`, `invite` follows `invite_register` (default off: an invite is already a gate, like CAPTCHA's `invite_register`), `oauth` and `setup` never. An account with no channel (created before 0046) is covered by the `enforced_since` / existing-deadline rules as before. An out-of-scope account is not restricted even after the existing-accounts deadline. - **`enforced_since` is set once** — the first time the mode is `required` — and never moves. Switching `required` off and on again later keeps the original timestamp, so accounts created in between (while the mode was `off` or `optional`) count as created after it and are restricted again when `required` comes back. GET `/api/admin/mail` returns `verification.enforcedSince`, so the screen can warn before re-enabling. - Where it runs (each with a test in `lib/mail/__tests__/restriction-coverage.test.ts`, which also pins the list): | Action | Routes | |---|---| | `message` | `POST /api/servers/{id}/channels/{channelId}/messages`; `PATCH …/messages/{messageId}` when it changes the text (pinning is not gated); `POST …/commands/{commandId}/invoke` (it posts into the channel); `POST …/polls` (a poll is a message, docs/CHAT_POLLS.md) | | `dm` | `POST /api/dm`, `POST /api/dm/{channelId}/messages` | | `reaction` | `PUT` / `DELETE …/polls/{pollId}/vote` (a poll vote is reaction-sized). Emoji reactions have no HTTP route — they travel over the LiveKit data channel, which needs a voice token, so the `voice` gate covers them | | `voice` | `POST /api/livekit/token` | | `server_create` | `POST /api/servers` (the hub) | | `channel_create` | `POST /api/servers/{id}/channels` | | `invite_create` | `POST /api/servers/{id}/invites` | | `upload` | `POST /api/users/me/avatar`; `POST /api/users/me/banner` and `POST /api/servers/{id}/banner` when they carry an image (the only upload routes) | | `bot_create` | `POST /api/servers/{id}/bots`, `PUT /api/servers/{id}/bots/builtin/{type}` | | `bot_token` | `POST /api/servers/{id}/bots/{botId}/token` | | `webhook` | `POST /api/servers/{id}/channels/{channelId}/webhooks`, `POST …/webhooks/{webhookId}/token` | | `plugin_publish` | `POST /api/marketplace/submit` | | `directory_listing` | `GET /api/directory/register/challenge`, `POST /api/directory/register` | | `join_request` | `POST /api/servers/{id}/join-requests/mine` and `POST /api/invites/{code}/redeem` when they carry a note (free text sent to the moderators); asking or joining without one is not gated | Not gated: the Bot API and a bot's event endpoint (bot tokens, no account), the admin instance logo (owner only), activity (game) actions and renames (display name, nickname, server/channel names by those who may manage them) — they are bounded or moderated text, not new channels. - **Uploads policy:** a restricted account cannot upload avatar or banner images — an image upload is an abuse channel (anything can be put in front of every member who sees the profile or the server). Text profile edits (display name, status, bio, nickname) stay allowed, and so does **removing** a banner (`POST …/banner` with `dataUrl: null`, or `DELETE /api/servers/{id}/banner`), so a restricted account can always take an image down. The UI follows the same policy: the upload controls are disabled for a restricted account, the text fields and "Remove banner" are not. - **Switching to `required`:** 409 `test_required` applies when the mode CHANGES to `required` (from `off`/`optional`) unless the last recorded test passed for EXACTLY the configuration that would be in force (its fingerprint, §5) AND is less than 24 hours old (`REQUIRED_TEST_MAX_AGE_MS` in `lib/mail/types.ts`): credentials revoked at the provider since an old test must not unlock it. The admin screen says so ("staleTest"). While the resulting mode is `required` (saved, or from the environment), every save must leave a working transport: provider `none`, an undecryptable password or a missing piece is 409 `transport_required`. Once `required`, the connection can still be changed to another working one (to fix broken mail); the last test then no longer describes it, and Doctor warns until a new test passes. ### 4.3 HTTP API (user side; the session is required unless noted) - **`GET /api/auth/email/status`** returns `{ email, verified, mode, restricted, pendingChange: string|null, resendAvailableAt: ISO|null, mailConfigured }`. - **`POST /api/auth/email/verify/send`** takes `{}` and returns 202 `{ sent: true, resendAvailableAt }`. Errors: - 409 `already_verified`; - 429 `rate_limited` with `retryAfter`; - 503 `mail_unavailable` or `mail_quota`. - **`POST /api/auth/email/verify`** takes `{ code }` (session) or `{ token }` (no session needed) and returns 200 `{ verified: true }`. Errors are 400: `invalid_code`, `expired`, `too_many_attempts` or `invalid_token`. - **`POST /api/auth/email/change`** takes `{ newEmail, currentPassword }`. - In `off` mode with no transport, it changes the address directly and returns 200 `{ changed: true }`. - Otherwise it sends `change-confirm` to the new address and returns 202 `{ pending: true }`. - Errors: 400 `invalid_password` or `invalid_email`; 409 `email_taken`; the same 429 and 503 as above. - **`POST /api/auth/email/change/confirm`** takes `{ code }` or `{ token }`. - The address must still be free when the change is applied; otherwise 409 `email_taken`. - Applying it sets `email_verified_at = now()`, sends `change-notice` to the old address, and revokes the other sessions. - **`POST /api/auth/password/forgot`** has no session. It takes `{ email, captchaToken?, captchaProvider?, formToken?, website? }`. - It always answers 202 `{ sent: true }`, with the same body and roughly the same time whether or not the account exists. - The new CAPTCHA surface `password_reset` (default `on`; add it to `lib/captcha` surfaces, the admin card and `docs/CAPTCHA.md`) applies first. - With no transport it answers 503 `mail_unavailable`, so the UI can say "ask your administrator". That leaks only that mail is off, which is public anyway. - **`POST /api/auth/password/reset`** has no session. It takes `{ token, newPassword }` or `{ email, code, newPassword }`. - The link is valid 60 minutes. - On success it sets the password (same policy as sign-up), marks the email verified, revokes ALL sessions and device cookies (the existing password-change machinery), and returns 200. - **Pages:** - `/verify-email` (token confirmation); - `/forgot-password`; - `/reset-password?t=…`; - a code entry where the banner lives; - a settings section for email change. All of them use `Referrer-Policy: no-referrer`. **As implemented** (the routes; every error body is `{ "error": code }`): - Every route answers 400 `invalid_request` for a malformed body (the schemas are `.strict()`); a missing session is the usual 401. - `status`: built by `emailStatusFor(userId, { user?, settings? })` (`lib/mail/status.ts`) — the ONE builder of this answer; the route and the page-render read `lib/email-status-ssr.ts` (`emailStatusForPage`, for the banner's first paint; it shows nothing to guests and answers `undefined` when it cannot tell) both return it, so they cannot drift. It answers null for a missing or deleted account (the route then 401s). `resendAvailableAt` is null when the limit store cannot be read. `email` is null for a guest; `pendingChange` is the address of a live change challenge; `resendAvailableAt` comes from the send limits (§4.4); `mailConfigured` is false when no transport is usable. - `verify/send`: also 400 `no_email` (a guest or an account without an address). - `verify` with `{ code }` on an already verified account answers 200 `{ verified: true }`. A code whose challenge was consumed in between answers `expired`. - `change`: also 400 `invalid_email` for the account's current address and 400 `disposable_email` (when blocked). The current-password check shares the password change's per-account counter: over it, the generic 429 `{ error: "Rate limit exceeded", retryAfter, resetAt }`. In `off` mode with a transport the change goes through confirmation (202) too; only `off` with no usable transport changes it directly (and clears `email_verified_at`, drops every live challenge and revokes the other sessions, like a confirmed change — `warning: "sessions_not_revoked"` if that fails). The sends share the verification limits (§4.4). - `change/confirm`: 200 `{ changed: true, email }`. The other sessions are revoked; the confirming session survives when it is the account's own, so a link opened in another browser signs every session out. `warning: "sessions_not_revoked"` is added when the revocation failed (the change still applied). - `forgot`: 400 `invalid_email` for a malformed address. The order is: zod → 503 `mail_unavailable` / `mail_quota` (both instance-wide facts) → the per-address send limit peeked → the CAPTCHA surface `password_reset` → the limit counted → 202. The account lookup, the per-target cap and the send run AFTER the answer, and every accepted request takes at least 250 ms, so neither the body nor the timing says whether the address exists. Guests and deleted accounts get nothing (same answer). - `reset`: 400 `weak_password` below 12 characters (the sign-up policy; at most 128). **By code, every failure is the same 400 `invalid_code`** — a wrong, used-up or expired code, an unknown or guest account, an address that changed since the code was sent — and the attempts count in a budget of their own keyed by the address typed (`RESET_CODE_ATTEMPTS`, 10 / 15 min, counted before the account is looked up, never shared with verify or change), so nothing tells one address from another. By link: 400 `invalid_token` / `expired`. 200 `{ reset: true }` (+ `warning: "sessions_not_revoked"`). Besides revoking every session (none kept) it drops pending desktop handoff codes and clears the sign-in lock of the address; device cookies die with the old password hash. It never signs in. - `POST /api/auth/register` (both deployment modes): 400 `disposable_email` before the challenge when blocked; when a verification email was started the 201 body also has `verificationEmailSent: true` (absent otherwise, so the old shape is unchanged). - The Google callback marks the account verified on every sign-in where Google says `email_verified` AND the Google address is the account's address (`markUserEmailVerifiedForAddress`; a Google sign-up has no address, so it is never marked) (best effort; it never fails the sign-in). - `Referrer-Policy: no-referrer` on the pages is the pages' (UI) concern. ### 4.4 Rate limits Redis-backed, in the `auth-throttle` pattern. Behind an unknown address (no trusted proxy), use an instance-wide backstop, as in `lib/captcha/limits.ts`. | What | Limit | |---|---| | Verification or change email, per account | 60 s cooldown; 5/hour; 10/day — each kind its own buckets (see below) | | Same target address, across accounts | 3/hour | | Sends per address | 10 / 15 min | | Code attempts | 5 per code; 10 per account per 15 min | | Token POSTs per address | 10/min | | Forgot-password per target email | 3/hour (silently, keeping the same 202) | **As implemented** (`lib/mail/limits.ts`): the send buckets (the account's cooldown, hour and day, and the target's hour) are taken ATOMICALLY with `reserveWindows` — one Redis script (one synchronous step in memory) that counts a hit in every bucket or, when any is full, in none — so a burst of concurrent sends gets exactly one through (tested). The routes still peek the cooldown first, only so a client inside it hears 429 before any other answer. Fixed windows in the bot-protection store (Redis in production), keys `lf::rate-limit:email-:` (accounts and addresses only hashed; the documented e2e reset clears them). **Change emails have their own per-account buckets** (`change-cooldown`, `change-hour`, `change-day`: the same 60 s / 5 per hour / 10 per day) separate from the verification email's (changed after e2e: fixing a typo right after sign-up must not wait out the verification cooldown); the per-target bucket (3 an hour) stays SHARED by both kinds — it protects the recipient's inbox. Each send still takes all its buckets in one atomic step. "Sends per address" covers verify/send, change and forgot. "Token POSTs" covers every code or token submission (verify, change/confirm, reset). Code attempts per account over the limit → 429 `rate_limited` (the reset route keys an unknown address's attempts by the address). Without a trusted proxy the per-address buckets become instance-wide backstops: 300 sends / 15 min and 600 submissions / min. Redis down in production → 429 `rate_limited` with `retryAfter: 5` (fail closed). ### 4.5 Disposable domains - Vendor the CC0 list `disposable-email-domains/disposable-email-domains` (`disposable_email_blocklist.conf`) as a data file, with a header saying where it came from and when. - Add `scripts/update-disposable-domains.mjs` to refresh it. - Subdomains match. Admin overrides go on top: allow wins over block. - When `disposable_email_block` is on, sign-up and email change refuse with 400 `disposable_email`. **As implemented:** the data file is `apps/web/lib/mail/disposable-domains.json` — a JSON object whose header fields (`source` pinned to the upstream commit, `commit`, `fetchedAt`, `license: CC0-1.0`, `count`, `_comment`) say where it came from, then `domains` (sorted, de-duplicated; 9,203 on 2026-10-04). Refresh with `node scripts/update-disposable-domains.mjs` (or `--ref `; `--check` validates the file; it refuses to write fewer than 1,000 domains). Matching is by label: `x.mailinator.com` matches `mailinator.com`, `xmailinator.com` does not. The admin lists are normalised (lower case, no `@` or trailing dot) and may hold up to 1,000 domains each. ## 5. Admin (instance admin only; changes audited, never secrets) **`GET /api/admin/mail`** returns: ```json { "provider": "none", "region": null, "host": null, "port": null, "security": null, "username": null, "passwordSet": false, "passwordHint": null, "from": null, "dailyLimit": null, "sentToday": 0, "lastTest": { "at": null, "result": null }, "verification": { "mode": "off", "scope": { "open_register": true, "invite_register": false }, "enforcedSince": null, "existingDeadline": null }, "disposable": { "block": false, "allow": [], "blockExtra": [] }, "locked": { "provider": false, "host": false, "port": false, "security": false, "username": false, "password": false, "from": false, "verification": false } } ``` **`PUT /api/admin/mail`** takes the same shape without the read-only fields. `password` is a string to set, `null` to clear, or omitted to keep. Errors: - 400 `invalid_settings` with `issues`; - 400 `host_not_allowed`; - 400 `password_required`: the save moves the connection (provider, host or user) while a password is set, without the password (added after review); - 409 `locked_by_env` with `field` (also `field: "password"` when an environment password would follow a new host); - 409 `test_required`: setting `required` without a passing test; - 409 `transport_required`: the resulting mode is `required` but the resulting configuration is not a working transport (added after review); - 503 `settings_unavailable`. **`POST /api/admin/mail/test`** takes `{ to?: string, ...unsaved overrides }`. - Errors (added after review): 400 `password_required` — the tested connection differs from the saved one and authenticates, but no `password` came with it (the saved one is never sent elsewhere); 409 `locked_by_env` with `field` — an override of an environment-locked field. - It sends the `test` template; the default recipient is the admin's own address. - It records `mail_last_test_*` only when it ran against the SAVED configuration. - It returns `{ result, detail? }`: - `result` is one of `ok`, `timeout`, `tls`, `auth`, `sender_rejected`, `recipient_rejected`, `connection`, `host_not_allowed` or `not_configured`; - `detail` is a code, never free text. For example, `try_port_2525` after a timeout on 25, 465 or 587. **`POST /api/admin/users/{id}/verify-email`** marks the user verified and writes an audit entry. **Members list:** the admin members list shows the verification state. **As implemented** (`lib/mail/admin.ts`; the routes use `requireInstanceAdmin` — the owner's session or the emergency admin token): - `PUT`: `password: ""` means keep (an empty write-only field); `""` in the other text fields means `null`. `issues` is `[{ path, message }]`, never the submitted values. A locked field is a 409 only when the value would CHANGE. `host_not_allowed` carries `detail` (`port_not_allowed`, `address_not_allowed`, `security_not_allowed`, `invalid_host`). With a provider set, the effective `from` is required and must parse (`Name ` or `addr`) — `invalid_settings` on `from`; an unknown region is `invalid_settings` on `region`; a list entry that is not a domain is `invalid_settings` on `disposable.allow.` / `disposable.blockExtra.`. Body limit 64 KiB; rate limits 30 / min (GET), 10 / min (PUT, test, per address). - **The password stays where it was saved** (review): a PUT that moves the connection to another provider, host or user — and still authenticates — while a password is set needs `password` in the same request: 400 `{ "error": "password_required" }`; with the password from the environment it is 409 `{ "error": "locked_by_env", "field": "password" }` (set `LOBBYFORGE_SMTP_HOST`, `_USER` and the provider in the environment too). Port and security changes alone, a move to provider `none` and clearing the password need nothing; moving from `none` to a host does (parking the transport cannot launder the stored password). - 409 `{ "error": "transport_required" }`: the resulting mode is `required` but the resulting configuration is not a working transport (§4.2). - The last test is recorded with an HMAC **fingerprint** of the configuration it tested (provider, host, port, security, user, password, from — key derived from the session secret, so the stored value gives no offline handle on the password). It counts — for `required`, in the GET view's `lastTest` and for Doctor — only while the configuration in force has that fingerprint; otherwise `lastTest` reads `{ at: null, result: null }`. A test that started before a save records the fingerprint of what it tested, so it can never unlock `required` for the new settings. `enforcedSince` is set the first time the saved mode is `required`. - Audit: `instance.mail_updated`, `metadata: { fields }` with names among `provider`, `region`, `host`, `port`, `security`, `username`, `password`, `from`, `dailyLimit`, `verificationMode`, `verificationScope`, `existingDeadline`, `disposableBlock`, `disposableAllow`, `disposableBlockExtra` — written only when something changed, filed under the first server like the CAPTCHA settings. Labels: `admin.audit.action.instance.mail_updated`, `admin.audit.event.mailUpdated`, `admin.audit.mailField.*` (en, tr). - `PUT` when the stored row cannot be read: 503 `settings_unavailable`. - `test` takes `{ to?, provider?, region?, host?, port?, security?, username?, password?, from? }`: it connects and authenticates (`verify()`), then sends; any override makes it an unsaved test (not recorded). **The saved (or environment) password is used only for the saved provider, host, port, security and user:** testing another connection needs `password` in the request (a string, or `null` to test without authentication) — 400 `{ "error": "password_required" }` when the tested connection has a user and no password was given. Overriding an environment-locked field (a different value; any `password` when it is locked) is 409 `{ "error": "locked_by_env", "field" }`. The detail codes: | `result` | `detail` | |---|---| | `timeout` | `try_port_2525` / `try_port_2587` (after 25, 465 or 587; whichever the provider offers) | | `tls` | `use_starttls` (implicit TLS on a STARTTLS port), `use_tls` (the reverse), `tls_certificate` | | `auth` | `check_credentials`, `gmail_app_password` | | `sender_rejected` | `sender_domain` (MAIL FROM refused), `message_rejected` (refused after DATA) | | `recipient_rejected` | — | | `connection` | `host_not_found`, `connection_refused`, or none | | `host_not_allowed` | `port_not_allowed`, `address_not_allowed`, `security_not_allowed`, `invalid_host` | | `not_configured` | `missing_host`, `missing_port`, `missing_from`, `missing_recipient` (no `to` and no admin address — the emergency token), `password_undecryptable` | - `verify-email`: 200 `{ verified: true }` (also when it already was — nothing written, no audit), 400 `no_email` (guest / no address), 404 `not_found`. The audit entry targets the user. - Members list: the admin members page reads `listUserEmailVerification(db, userIds)` — `{ userId, hasEmail, isGuest, emailVerifiedAt }`, never the addresses — and shows verified / unverified / none, with a "mark verified" action calling the endpoint above. ### 5.1 Admin screen Admin → Settings → **Email**, a new page or section: - The provider picker is grouped: Professional (SES, Scaleway), Free (with their limits shown), Custom, and Development (mailpit) when available. - Choosing a preset fills host, port and security, and shows the username and password hints, the free-tier limits, the data-region/KVKK note and a docs link. - **Fields:** - region where applicable; - "from" address; - password, write-only with a hint; - daily limit; - the test button, which shows the classified result and hint; - verification mode, which disables `required` until a test passes; - scope; - existing-accounts deadline; - disposable-domain block, with allow and block lists. - Environment-locked fields read "Set by LOBBYFORGE_…". - Choosing a provider outside Türkiye shows a short KVKK note: the data leaves the country, so the privacy notice should name the provider. - en/tr. ## 6. Doctor - **Critical:** - `required` without a transport; - the password can't be decrypted. - **Warning:** - the last test failed; - recent send failures or auth failures; - the daily limit is above 80%. - **Hint:** - port 25 is chosen ("blocked on most VPS"); - the "from" domain has no SPF TXT or `_dmarc` record (DNS lookup with a timeout); - a Gmail preset is used in production. **As implemented** (`lib/mail/doctor.ts`, category `services`): `mail_transport` and `mail_password` (critical), `mail_last_test` (warning: failed, or `required` with settings not tested since they changed), `mail_send_failures` (warning: failures in the last 24 h, auth failures counted separately; **critical** in `required` mode while nothing has gone out since the last failure — a successful send or test is remembered for 7 days in `lf::mail:last-success`), `mail_daily_limit` (warning from 80%), `mail_env` and `mail_settings` (warning), and the hints `mail_port_25`, `mail_dns` and `mail_gmail` — a hint is `ok: false` at level `info`, so it shows without making the report unhealthy. The DNS lookups (2 s timeout) are skipped for local hosts and mailpit; a lookup that fails (timeout, SERVFAIL) is not reported as a missing record. When nothing is wrong, one info line `mail` names the provider and the mode. ## 7. Official hub The hub is configured by env, with no code special-casing beyond the defaults: - `LOBBYFORGE_EMAIL_VERIFICATION=required`; - SES `eu-central-1`; - the disposable-domain block on. Setting up the AWS account and the DNS records is the owner's task. Write the checklist in this file. ### 7.1 Checklist: mail for lobbyforge.org (owner) **AWS account and SES** (region `eu-central-1`, Frankfurt): 1. Use (or create) the AWS account on a **paid account plan**; the free account plan restricts services. Set a billing alarm (e.g. $10/month) and turn on MFA for the root user. 2. SES → **Account dashboard**: since 21 July 2026 new accounts start on the **Essentials** plan ($0.16 / 1,000). Cancel it to return to pay-as-you-go ($0.10 / 1,000) — for an account put on Essentials by default, the first cancellation takes effect at once. 3. SES → **Identities** → create the domain identity `lobbyforge.org` with **Easy DKIM** (RSA 2048) and a custom **MAIL FROM** domain (`bounce.lobbyforge.org`). 4. Add the records SES shows to Cloudflare DNS (DNS only, not proxied): - the three DKIM `CNAME` records (`._domainkey.lobbyforge.org`); - for the MAIL FROM domain: `MX bounce.lobbyforge.org → feedback-smtp.eu-central-1.amazonses.com` (priority 10) and `TXT bounce.lobbyforge.org "v=spf1 include:amazonses.com ~all"`; - SPF on the apex if mail is sent as `@lobbyforge.org` from elsewhere too: merge `include:amazonses.com` into the existing record (one SPF record per name); - `TXT _dmarc.lobbyforge.org "v=DMARC1; p=none; rua=mailto:dmarc@lobbyforge.org; adkim=r; aspf=r"`. Wait until SES shows the identity and DKIM as **Verified**. 5. SES → **SMTP settings** → **Create SMTP credentials** (creates an IAM user limited to `ses:SendRawEmail`). Store the SMTP user name and password in the password manager; the password is shown once. These are NOT the IAM access keys, and they work in `eu-central-1` only. 6. **Production access** (the account starts in the sandbox: verified recipients only, 200/day): SES → Account dashboard → Request production access. Mail type **Transactional**; website `https://lobbyforge.org`; describe the use (sign-up verification codes, email change, password reset — no marketing; users request every email themselves; bounces and complaints are handled by the SES feedback and the account suppression list). The first answer usually comes within 24 h. 7. Bounces and complaints: keep **email feedback forwarding** on (the default) to a monitored address, and the **account-level suppression list** on for bounces and complaints. (SNS → webhook is a later phase.) Watch the reputation dashboard: review starts at a 5% bounce / 0.1% complaint rate. Gmail sends no complaint data to SES — register the domain in **Google Postmaster Tools**. **The hub's environment** (`.env.prod` on the hub server): ``` LOBBYFORGE_MAIL_PROVIDER=ses LOBBYFORGE_SMTP_HOST=email-smtp.eu-central-1.amazonaws.com LOBBYFORGE_SMTP_PORT=587 # 2587 if the host blocks 587 LOBBYFORGE_SMTP_SECURITY=starttls LOBBYFORGE_SMTP_USER= LOBBYFORGE_SMTP_PASSWORD= LOBBYFORGE_MAIL_FROM="LobbyForge " LOBBYFORGE_EMAIL_VERIFICATION=required LOBBYFORGE_APP_ORIGIN=https://lobbyforge.org ``` Then, in Admin → Settings → Email (the env fields show as locked): turn on **disposable address blocking** (the one hub default that is not an env variable), send a **test email**, and check Admin → Health: no `mail_*` warning, `mail_dns` silent. The verification mode is `required` from the first boot with these variables; accounts created before that moment are not restricted unless an existing-accounts deadline is set. **After launch:** after two weeks of clean DMARC reports, raise DMARC to `p=quarantine`. Check the sending quota SES granted (Account dashboard) before any announcement, and ask for more in time. Fallback provider: Scaleway TEM (`scaleway` preset) — switching is a change of the env variables above plus its DKIM/SPF records. ## 8. Tests - **Unit:** - the transport with a fake SMTP server or a mocked nodemailer; - registry integrity: every preset has a docs URL, ports and hints in en/tr; - the secret box (CAPTCHA ciphertext compatibility); - tokens and codes: expiry, attempts, single use, double-submit race; - restriction coverage: one test per protected action; - forgot-password: identical answers; - rate limits. - **Real Postgres:** migration 0046 and its backfill, and the token consumption race. - **e2e with mailpit:** the e2e stack gets `LOBBYFORGE_SMTP_HOST=mailpit`; Playwright reads mailpit's HTTP API. Cover: - sign-up → verify by code; - verify by link from a second browser; - the `required` restrictions; - change email; - forgot → reset; - the admin test email. **As implemented** (server side; the e2e specs and the e2e compose `LOBBYFORGE_SMTP_HOST=mailpit` wiring are not part of this change): - `packages/db`: `email-migration.test.ts` (file, journal, snapshot) and `email.integration.test.ts` (real Postgres, in CI's real-PG step): the backfill, the settings and their CHECKs, one live challenge per user and purpose, the attempt cap, expiry, and the consumption race (8 concurrent submissions, exactly one wins; two changes racing for one address). - `apps/web`: `lib/__tests__/secret-box.test.ts`; `lib/mail/__tests__/` — `providers` (registry integrity, hints in every complete language), `host-rules`, `transport` (nodemailer mocked: options, pool, error classification), `settings`, `templates-disposable`, `tokens-verification`, `limits-doctor-send`, `restriction-coverage` (one test per protected action); route suites `app/api/auth/email/__tests__/email-routes.test.ts`, `app/api/auth/password/__tests__/forgot-reset.test.ts` (identical forgot answers, double-submit), `app/api/admin/mail/__tests__/admin-mail.test.ts`, plus the Google callback and CAPTCHA admin suites.