Route tenant inbound mail to tenant triggers

Task 9431 · Queued · Coder · created · updated

## 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