Receive system mail through SES inbound
## 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
C3 SES inbound: `ses_inbound.py` + `ses-inbound` cron, verdict rule, rate limit, `inbound_emails` migration, the S5 dispatch seam, Unmatched emails shows *Rejected*; retire the gmail-push route, the `gmail-mailbox` cron, every Gmail operator knob, and delete `gmail_mailbox.py` (§9.9 owner). Delivers R3, AS3–AS5 and AC5 in full.
## Requirements (verbatim from the PRD)
### R3 — System inbound through SES (A1, Q1, Q5)
Mail to any address `@mail.jaah.ai` / `@mail.dev.jaah.ai` is received by SES and stored as an inbound email, then routed as today (first matching trigger; else Unmatched emails). Works on **dev and prod**. The Gmail push route `/api/public/email/gmail-push`, the operator Gmail mailbox knobs and the `gmail-mailbox` cron no longer serve system mail.
AS3. Assumed SES inbound **spam/virus verdict FAIL and DMARC FAIL (p=reject sender) mail is not routed** to any trigger (still listed to the operator as rejected).
AS4. Assumed existing `inbound_emails` rows and trigger deliveries are **preserved** and still listed; Gmail-specific columns may be generalised but no history is lost.
AS5. Assumed existing operator email triggers are **not auto-rewritten**; a trigger whose `to` names the old Gmail mailbox address must be re-pointed by the operator, and the runbook says so.
Today: Gmail `watch` → Pub/Sub → `POST /api/public/email/gmail-push` (`routes/public.py:342-391`) → `sync` stores `inbound_emails` rows (`models/notifications.py:156-187`) → `trigger_intake.route_emails` (`trigger_intake.py:676-772`): first org-zero email trigger whose `to`/`from`/`subject` substrings match; else **Unmatched emails** (`apps/web/src/pages/triggers/UnmatchedEmails.tsx`). Hourly `gmail-mailbox` cron renews the watch (`cron_system_activities.py:47,596`).
## Approach (from the settled architecture)
- `ses_inbound.py` + system cron `ses-inbound` (30 s): SQS → receipt notification → `s3:GetObject` raw → stdlib `email` parse (text part, capped as `BODY_MAX` today) → verdicts → resolve each recipient (S5) → one `inbound_emails` row per target → route: reserved local parts (`dmarc-reports`, `postmaster`, `abuse`) → their registered handler; tenant hosted address → resolver (C10 registers it); everything else → org zero → `trigger_intake.route_emails` unchanged.
- Fan-out: the verdict rule runs once per message **before** fan-out; each `receipt.recipients` entry resolves to a target and gets its own row in its own org; duplicate targets collapse to one row.
- Verdicts (AS3): `spamVerdict|virusVerdict = FAIL` or `dmarcVerdict = FAIL` with `receipt.dmarcPolicy = reject` ⇒ row stored with `rejected_reason`, never routed, listed as *Rejected* in Unmatched emails.
- Rate limit (§9.7): before routing, ≤ 50 routed messages/day per (authenticated sender domain, target org); domain = DKIM-pass `d=`, else SPF-pass MAIL FROM domain, else the From domain. Excess stored with `rejected_reason = rate_limited`. Unit test.
- `inbound_emails` migration (additive, two-release rule): add `provider` (`gmail|ses|tenant_gmail`, backfill `gmail`), `provider_message_id` (backfill from `gmail_message_id`), unique `(provider, source_ref, provider_message_id, recipient)`, `org_id` = target org, `source_ref` (connection id, hosted-address id, or `system`), `recipient`, `verdicts` JSONB, `rejected_reason`; `gmail_message_id` becomes nullable (dropped later, F4). History rows and `trigger_deliveries.inbound_email_id` untouched (AS4).
- S8: `GmailConfig` gains `inbox: Literal["system"] | UUID` and `address_id: UUID | None`; `"system"` stays operator-org only.
- Retire: `routes/public.py:342-391` (gmail-push), `gmail_mailbox.watch/sync/maintain/verify_push_token`, the `gmail-mailbox` cron (`cron_system_activities.py` + a migration deleting its seeded row), the mailbox state model, and EVERY Gmail operator knob (`operator_settings/catalog_integrations.py:163-191`: credential, address, Pub/Sub topic, push audience, push SA — C2a kept them because receive still read them). Delete `gmail_mailbox.py` (C7 already moved the client into `app/tenant_gmail/client.py`; re-point any leftover import there) and update the docstrings naming it (`ci_oidc.py:8`, `operator_credentials.py:4,31`).
- Runbook (`docs/integrations/ses.md`, created by C1): add the AS5 step (re-point triggers whose `to` names the old mailbox).
Pinned seams (build against these exactly; other children build against them too):
- S1 env (C1 → C2/C3): `SES_MAIL_DOMAIN`, `SES_CONFIGURATION_SET`, `SES_EVENTS_QUEUE_URL`, `SES_INBOUND_QUEUE_URL`, `SES_INBOUND_BUCKET`; inbound objects under prefix `inbound/`.
- S3 queue payloads: SNS envelope `{Type:"Notification", Message:"<json>"}` (raw delivery OFF, as the alarm relay expects). Events: SES event-publishing JSON (`eventType`, `mail.messageId`, `mail.tags`, `bounce.bounceType`, `complaint`). Inbound: SES receipt notification (`notificationType:"Received"`, `receipt.recipients`, `receipt.{spam,virus,spf,dkim,dmarc}Verdict.status`, `receipt.dmarcPolicy`, `receipt.action.{bucketName,objectKey}`, `mail.messageId`, `mail.commonHeaders`).
- S5 inbound dispatch (C3 → C4/C10): `ses_inbound.register(local_part: str, handler)` for reserved names and `ses_inbound.register_resolver(fn: (session, recipient) -> RouteTarget | None)` for tenant addresses; `RouteTarget(org_id, trigger_id | None, tenant_address_id)`; handler signature `(session, InboundEmailRow, raw: bytes) -> None`.
- S8 email trigger config (C3/C10): `GmailConfig` gains `inbox: Literal["system"] | UUID` (connection) and `address_id: UUID | None`; `"system"` remains operator-org only (`routes/triggers.py:595-599`).
Credentials & reach:
| Oracle | Credential / identity / environment | Status |
|---|---|---|
| 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 |
| 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 |
| AC5, AC18, unit/integration suites | worktree only | **verified** — nothing beyond the worktree |
Out of scope:
- Do NOT build the DMARC handler (C4) or tenant resolver/hosted addresses/tenant poll (C10) — only the S5 registration seam.
- Do NOT auto-rewrite existing operator triggers (AS5).
- Do NOT hand attachments to agents.
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:
- **AC7 (R3) Inbound on dev.** → verify: from an external mailbox send to `anything@mail.dev.jaah.ai`; within 2 minutes it appears under *Automations > Unmatched emails* with sender and subject; create an operator email trigger with `to` = that address, send again → a task is created. A spam-verdict test (GTUBE) is not routed (AS3). (External sender = `aws sesv2 send-email` from jaah-dev via `me/jaah/system/bootstrap-admin`, allowed in sandbox since the domain is verified; GTUBE body for the spam case — it shows as *Rejected*.)
- **AC8 (R3) History preserved.** → verify: an inbound email stored before cutover is still listed and its trigger delivery still links to it. (On dev: the pre-migration rows remain listed after deploy; plus a migration test with a seeded Gmail-era row + trigger delivery.)
- **AC5 (R1) No Gmail on the system path.** → verify: `grep -rn "gmail.googleapis.com" apps/api/app` returns only the tenant-Gmail module(s); the operator *Settings > Integrations > Email* shows SES fields, no Pub/Sub/push-identity fields (and no Gmail credential/address fields); `apps/api/app/gmail_mailbox.py` no longer exists; `/api/public/email/gmail-push` returns 404.
- Fan-out and rate-limit tests: one message to two targets → two rows in their own orgs; the 51st routed message/day from one sender domain to one org is stored `rate_limited`, not routed.
- Unmatched emails shows *Rejected* rows with their reason, readable at 390 px and desktop (Playwright screenshot in the report).
- 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