Add RFC 8058 one-click unsubscribe
## Original request (verbatim)
> Migrate the emails system from GMail API to AWS SES. Use all the best practices. The quality of communication is of highest priority. Spare no time or expense.
>
> [jaah.ai](http://jaah.ai) MX is currently at GMail and will remain so, but we can carve out whatever we need
Epic context (BA PRD Task 9352, Architecture Task 9353) — settled: all system mail (send + receive) moves to SES on `mail.jaah.ai` (dev: `mail.dev.jaah.ai`, dev account, sends/receives real mail) with suppression, operator-only delivery log, RFC 8058 unsubscribe, DMARC monitoring, operator-approved template redesign; tenant Gmail (agent send, automation action, receive) is built in this epic behind one plan feature. All system AWS calls use the hand-signed SigV4 seam (`apps/api/app/aws/common.py` `signed_call`), no boto3. SES events and inbound mail are PULLED from SQS by system crons (precedent `apps/api/app/alarm_relay.py`, `apps/api/app/aws/sqs.py`); the only new public path is unsubscribe on the hooks edge.
## Scope of this child
C5 One-click unsubscribe: signed tokens, the public unsubscribe route on the hooks edge, List-Unsubscribe headers on optional mail, the `essential` flag on every template type, the new `budget` preference key. Delivers PRD R6. (Depends on C3 only to serialise this wave's schema migrations.)
## Requirements (verbatim from the PRD)
### R6 — One-click unsubscribe (A2, Q3, Q12)
Optional mail (event notifications: mentions, governance, merge lane, alerts, budget warnings, trigger notices) carries RFC 8058 `List-Unsubscribe` + `List-Unsubscribe-Post: List-Unsubscribe=One-Click` and a visible footer link. Using either turns off Email for **that event** for that user (the existing Profile > Alerts per-event setting), without sign-in, and the change is visible on Profile > Alerts. Essential mail (invitations, sign-up codes, password reset, welcome/approval/rejection, trust decisions, billing notices and payment links) carries no unsubscribe.
Q12: **Account + billing essential** (no unsubscribe); event notifications optional.
## Approach (from the settled architecture)
- `apps/api/app/email_unsubscribe.py`: fill the `UnsubscribeTarget(user_id, event)` stub (C2a). Token: `itsdangerous.URLSafeSerializer` over a dedicated operator secret `email_unsubscribe_secret` with a `current` + `previous` keyring (verify tries each — rotation keeps sent links valid; not the session secret), salt `"email-unsubscribe"`, over `{u: user_id, e: event, v: 1}` (precedent `oauth_flow_cookie.py:57-59`); no expiry.
- URL: `{HOOKS_PUBLIC_BASE_URL}/api/public/email/unsubscribe/{token}` (`config.py:83-86`). Blank hooks URL (local) ⇒ optional mail sent without the headers, never with a dead link. On a deployed env (`SES_MAIL_DOMAIN` set) a blank hooks URL is an error the operator Email page shows and the `ses-events` cron reports (§9.12d).
- Blank `email_unsubscribe_secret` behaves like a blank hooks URL: optional mail goes without the headers/footer, and on a deployed env the operator Email page shows the error. Set it on dev through the console as operator (Jaah UI only) so AC19 holds; C11's cutover checklist sets it on prod.
- `POST` (RFC 8058 body `List-Unsubscribe=One-Click`, or the confirm form) ⇒ remove `email` from that event's methods in `user.preferences` (`preferences.py:112-128,427-440`), idempotent, 200 + tiny HTML. `GET` ⇒ a brand-styled confirm page with one button (link scanners must not unsubscribe), no sign-in. Per-IP limit using the limiter `routes/public.py` used for gmail-push. The hooks-edge path is opened by C1.
- `TemplateType` gains `essential: bool` (essential set = R6 list); `email_notify` passes `UnsubscribeTarget(user_id, event)` only for optional types; S6: `_layout(..., unsubscribe_url)` renders the footer link only then, text part ends with `Unsubscribe: <url>`.
- Preference map (§9.5): the unsubscribe event is exactly the `event` key `email_notify.deliver_to_user` is called with (mention, question, reaction, support, triggers, the `ALERT_KINDS` members incl. governance and merge lane). New per-event key **`budget`** (`preferences.NOTIFICATION_EVENTS` + `ORG_ADMIN_EVENTS`, a row on Profile > Alerts), seeded per user from their current `billing` setting by migration; budget notices (`budgets.py:330`) deliver under `budget`.
Pinned seams (build against these exactly; other children build against them too):
- S4 system send (C2 → C5/C6 and every caller). System mail is always one recipient (verified-address rule): `OutboundEmail(to: str, subject: str, text: str, html: str|None, mail_type: str, essential: bool, headers: Mapping[str,str] = {}, reply_to: str|None = None)`; `system_mail.send(msg, *, delivery_id: UUID) -> str`; `notify_outbox.email(address, subject, text, html, *, what, mail_type, essential, unsubscribe=None) -> bool`; `UnsubscribeTarget(user_id: UUID, event: str)` defined in `email_unsubscribe.py` (C5) — until C5 lands, C2 ships the dataclass stub there and C5 fills it.
- S6 layout (C5 ↔ C6): `_layout(..., unsubscribe_url: str | None)`; text part ends with `Unsubscribe: <url>` when set.
Credentials & reach:
| Oracle | Credential / identity / environment | Status |
|---|---|---|
| AC11 unsubscribe POST | none (public hooks edge `https://hooks.dev.jaah.ai`) | **verified (reach)** — answered (404) from this node; the mention mail it needs runs after the SES production-access row (or via the non-privileged loopback verifier user `probe@<mail_domain>`, allowed in sandbox) |
| AC6, terraform plan, SES state, production-access filing (C1, C11) | `me/jaah/system/bootstrap-admin` (management 155534476372) assuming `OrganizationAccountAccessRole` in jaah-dev 905905585302 and jaah-main 366008913850 | **verified** — `credential_get` ok; read-only probe from this node: `sts get-caller-identity`, `sesv2 get-account`, `route53 list-hosted-zones` in both accounts answered |
| AC4 (local), AC7, AC9, AC10, AC13 (after sandbox exit), AC17(b) in the dev console | `me/jaah/debug/main/operator-login` (operator, `https://app.dev.jaah.ai`) | **verified** — `credential_get` ok; `https://app.dev.jaah.ai` answered 200 from this node |
| AC1/AC11 Gmail UI checks (inbox placement, native Unsubscribe, Show original) | a Google login to admin@jaah.ai | **deferred** — operator: "Skip it" (I2) |
| SES production access (sandbox exit) — gates AC2, AC3, AC11, AC13 and C11 | AWS approval of `PutAccountDetails` in jaah-dev and jaah-main (filed by C1) | **deferred** — reason: AWS reviews it; both accounts probed `ProductionAccessEnabled=false`. Owner C1 files on day one; dependent oracles run after approval |
| AC5, AC18, unit/integration suites | worktree only | **verified** — nothing beyond the worktree |
Out of scope:
- Do NOT redesign templates (C6) — add only the footer slot and headers in the existing layout.
- Do NOT reclassify budget warnings as essential; do NOT add unsubscribe to essential mail.
- Do NOT unsubscribe on GET.
Epic out-of-scope (PRD §7, verbatim):
- MUST NOT change the apex `jaah.ai` MX, SPF or `_dmarc.jaah.ai` record, nor any Workspace mailbox, alias or routing.
- MUST NOT add SES Virtual Deliverability Manager, BIMI/VMC, or a dedicated IP / dedicated IP pool.
- MUST NOT request Google's restricted `gmail.readonly` (or any restricted Gmail scope) via OAuth; no CASA assessment.
- MUST NOT add open/click tracking pixels or link rewriting to system mail.
- MUST NOT let tenant Gmail replace system mail for an org's members (Q9 option not chosen).
- MUST NOT expose the delivery log or suppression list to tenant admins or users (operator only, Q7).
- MUST NOT relax the verified-address rule or invitation budgets for system mail (AS6).
- MUST NOT add boto3/botocore to `apps/api/requirements.txt` unless the Architect records why the hand-signed SigV4 seam (`apps/api/app/aws/`) cannot serve SES (the repo guards its absence).
- MUST NOT put a tenant Gmail credential, OAuth token or service-account key on any node or in a runner's environment.
- MUST NOT change Slack/Telegram delivery, the support chat, or site enquiries (no email today).
Done means:
Verifier-recipient mechanism (Planner decision — the operator test send goes only to the caller's own verified login address, `routes/email_templates.py:318-331`, plus the four §9.1 simulator addresses; the verified-address rule AS6 is NOT relaxed, and no privileged account ever gets a shared or public inbox):
- Loopback `probe@<mail_domain>`: a **non-privileged verifier user** — an ordinary member invited into org B by its admin (`org/console/dev-orgb-login`, a non-operator org), never superadmin, never in the operator org — whose login email is `probe@mail.dev.jaah.ai`. Its mail lands as a raw object under `inbound/` in the inbound bucket (readable with `me/jaah/system/bootstrap-admin`; after C3, also under the operator's Unmatched emails); the verification link is taken from there. Sending to an address on the verified SES domain works inside the SES sandbox. Use it for user-triggered mail (mention, password reset, invitation, billing notice).
- Every template type: render each type's raw MIME with the api's own code path in the **local `./dc` api container, built from the commit deployed to that environment, using built-in template content** (a script that calls `email_templates` + `mail_message.build` exactly as the outbox does, with the S2 tags) — never by exec into a deployed task or reading a deployed DB (Jaah is operated only through its UI) — and send it with `aws sesv2 send-email --content Raw` from the environment's identity and configuration set via `me/jaah/system/bootstrap-admin`. No account's login email ever points at a mail-tester address.
- The verifier user is deleted at the end of the run that created it (Done-means item: verify it no longer exists in the org's members list).
- **AC11 (R6) One-click unsubscribe.** → verify: in Gmail, a mention email shows Gmail's native *Unsubscribe*; clicking it turns Mentions → Email off on Profile > Alerts (visible after reload), with no sign-in; the footer link does the same. An invitation, password reset and billing notice show **no** unsubscribe header or link (*Show original*). (Gmail's native button deferred — operator "Skip it" (I2). Substitute: as the loopback verifier user (login email `probe@mail.dev.jaah.ai`, Mentions → Email on), trigger a mention, an invitation, a password reset and a billing notice to it; the raw of the mention in the inbound bucket shows both headers and the raw of invitation/reset/billing shows none; the verifier POSTs `List-Unsubscribe=One-Click` to the header URL on `https://hooks.dev.jaah.ai` and sees Profile > Alerts flip after reload; the footer link via Playwright (GET shows confirm page, unchanged until the button).)
- The loopback raw's `DKIM-Signature h=` covers `List-Unsubscribe` and `List-Unsubscribe-Post`; if SES does not sign them, stop and report (§9.12c).
- Token tests: tampered token refused; a token signed with `previous` still verifies after rotation; replay is idempotent; per-IP limit enforced.
- Migration test: each user's `budget` setting equals their prior `billing` setting; budget notices deliver under `budget` and essential billing notices are unaffected by unsubscribing `budget`.
- Quick gate `./dc check quick` green and changed-area tests green; `python3 tools/check_context.py` green (any doc this child adds/edits is routed and single-homed).
Activity
- Draft
- Ready