Route tenant inbound mail to tenant triggers
## 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
C10 Tenant receive: SA-mailbox poll cron `tenant-gmail-sync`, hosted addresses `tenant_mail_addresses`, the S5 tenant resolver, tenant email triggers (S8, plan-gated), per-org Unmatched emails. Delivers PRD R12. (Depends on C4 only to serialise this wave's schema migrations.)
## Requirements (verbatim from the PRD)
### R12 — Tenant receiving into their triggers (A6, Q9, Q13, Q16)
An org can create email triggers fed by (a) its service-account-connected Gmail inbox and (b) Jaah-hosted addresses on `mail.jaah.ai` (AS1, AS2), routed only to that org's triggers; unmatched mail is visible to that org's admins. OAuth-connected (send-only) mailboxes cannot be a trigger source.
AS1. Tenant `mail.jaah.ai` trigger addresses sit behind the **same plan feature** as tenant Gmail.
AS2. Tenant trigger addresses carry an **unguessable component** (not `<org>@`).
AS3. Spam/virus FAIL and DMARC FAIL (p=reject) mail is not routed.
## Approach (from the settled architecture)
- Poll cron `tenant-gmail-sync` (60 s, `users.history.list` via `app/tenant_gmail/client.py`), SA connections with `can_receive` only; stores `inbound_emails` rows with `provider = tenant_gmail`, `tenant_connection_id`; skips orgs with the plan switch OFF.
- Hosted addresses `tenant_mail_addresses` (format `<org-slug>-<10 lowercase base32>@<mail_domain>`, per trigger, revocable); `ses_inbound.register_resolver(...)` (S5) maps a recipient to `RouteTarget(org_id, trigger_id, tenant_address_id)`; add nullable FKs `tenant_connection_id` / `tenant_address_id` to `inbound_emails`.
- Tenant email triggers (S8: `inbox` = connection UUID or `address_id`), plan-gated; OAuth (send-only) connections are not offered as a source.
- Plan OFF (§9.14): hosted-address mail stored with `rejected_reason = plan_off`; addresses and history kept and resume.
- Same verdict rule and rate limit (§9.7: ≤ 50 routed/day per sender domain per target org, else `rate_limited`) as system inbound.
- Retention (§9.15): tenant inbound rows follow the system `inbound_emails` retention (register in `app/cron_retention.py` if the existing entry does not already cover them); hosted addresses deleted with their trigger or on revoke.
- Per-org Unmatched emails for that org's admins only; *Org settings > Email* Inbound/Addresses tabs (C7's detail page).
Pinned seams (build against these exactly; other children build against them too):
- 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`.
- S7 tenant send (C7 → C8/C9): `TenantOutboundEmail(to: list[str], cc: list[str] = [], subject: str, text: str, html: str | None = None)` (1–50 recipients total); `tenant_gmail.send(session, *, org_id: UUID, connection_id: UUID | None, message: TenantOutboundEmail, actor: Actor, task_id: UUID | None = None, automation_run_id: UUID | None = None) -> SendResult`; `SendResult(send_id: UUID, gmail_message_id: str, from_address: str)`. `connection_id=None` = the org's only send-capable connection, else `AmbiguousConnection` (→ 409). Errors: `NoConnection` (→ 409), `PlanOff` (→ 403), `GmailRefused(.status)` (→ 502). C7 creates `tenant_email_sends` and writes it inside `send`; C8/C9 only pass `task_id`/`automation_run_id`. Both builders feed `mail_message.build(sender, to: list[str], cc: list[str], ...)`.
- 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 |
|---|---|---|
| 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 |
| AC10 (non-operator 403), AC17 tenant side | `org/console/dev-orgb-login` (org B admin) | **verified** — `credential_get` ok (its recorded base_url is legacy; use `https://app.dev.jaah.ai`) |
| 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 |
| AC14/AC15/AC16/AC17(a) live (AC16 needs a connected Gmail too) | Jaah Google OAuth client (gmail.send) + a test Workspace SA with DWD | **deferred** — operator: "Build now, verify later" (I4) |
| AC5, AC18, unit/integration suites | worktree only | **verified** — nothing beyond the worktree |
Out of scope:
- Do NOT change system inbound routing for org zero (C3) beyond registering the resolver.
- Do NOT request any restricted Gmail OAuth scope; OAuth connections stay send-only.
- 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:
- **AC17 (R12) Tenant receive.** → verify: (a) with the service-account connection, mail to the tenant mailbox creates a task via a tenant email trigger; (b) create a tenant trigger address on `mail.dev.jaah.ai`, mail it → task in that org only; the operator org and other orgs see nothing of it; unmatched tenant mail is listed to that org's admins only. An OAuth (send-only) connection is not offered as a trigger source. ((a) deferred — live: operator "Build now, verify later" (I4); now: poll tests against recorded `users.history.list` responses. (b) verified live on dev: as `org/console/dev-orgb-login` create the address; send with `aws sesv2 send-email` from jaah-dev via `me/jaah/system/bootstrap-admin`; task appears in org B; operator (`me/jaah/debug/main/operator-login`) sees nothing of it.)
- Plan-OFF edge (§9.14): with the override OFF, hosted-address mail is stored `plan_off` and not routed; poll skips the org; turning it on resumes.
- Fan-out test (§9.4): one message to two tenant addresses → two rows, each visible only in its own org.
- Rate-limit test: the 51st message/day from one sender domain to one org is stored `rate_limited`.
- 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