From aae5ef8fc36475de60380d977172fce4114f84b1 Mon Sep 17 00:00:00 2001 From: TJ Baker <1617679+zaridan@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:04:52 -0700 Subject: [PATCH 1/3] docs(spec): inbound ingestion + Gmail-push behavioral spec (HT-34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds specs/mail/inbound-ingestion.md (provider-agnostic ingest pipeline) and specs/mail/gmail-push.md (Gmail push transport) — the behavioral contract HT-35..HT-44 build against. Pins: raw-message provider boundary (parse once, by our code); idempotency on (mailboxId, providerMessageId) rather than the sender-controlled RFC Message-ID; transactional cursor advancement; at-least-once ingest with a dead-letter ledger; own-message loop suppression; and the dogfood expired-cursor policy (pause + manual rebaseline, not auto-resync). Flags one deliberate divergence per charter §2: does NOT suppress generic third-party Auto-Submitted/bulk mail by default (auto-submitted.json shows the reference system ingesting it) — left as an explicit open question, not a silent change. Co-Authored-By: Claude Opus 4.8 --- specs/mail/gmail-push.md | 150 +++++++++++++++++++++++++ specs/mail/inbound-ingestion.md | 193 ++++++++++++++++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 specs/mail/gmail-push.md create mode 100644 specs/mail/inbound-ingestion.md diff --git a/specs/mail/gmail-push.md b/specs/mail/gmail-push.md new file mode 100644 index 0000000..7d983e3 --- /dev/null +++ b/specs/mail/gmail-push.md @@ -0,0 +1,150 @@ +# Gmail push inbound transport + +Status: draft (HT-34). The Gmail-specific transport that feeds the provider-agnostic +[inbound-ingestion.md](./inbound-ingestion.md) pipeline. It implements the corrected +`InboundEmailProvider` seam (HT-35, inbound-ingestion.md §2) for Gmail: authenticate a +Cloud Pub/Sub push, reconcile it to the raw RFC822 messages that changed, and hand those +raw bytes to the ingest pipeline. It is the first realization of charter §4's "inbound mail +arrives via push webhooks (Gmail push through Pub/Sub) … not a process sitting in a loop" +and phase-1's "event-driven ingestion (bounded reconciliation fetches, never a long-running +poller)." + +This transport is the **workspace-native mode** (memory: inbound-email architecture +decision, 2026-07-13): the intended default for a Google Workspace org running Helpthread +against its own mailbox via an **Internal** OAuth app. The forwarding-address transport — +the external/GA default — is separate and later. + +## 1. Shape: push notification, then bounded reconciliation fetch + +Gmail push has two moving parts, and the split matters: + +1. `users.watch()` registers a mailbox to publish change notifications to a Cloud Pub/Sub + topic; a Pub/Sub **push subscription** POSTs each notification to our HTTPS endpoint. +2. A notification's payload is only `{ emailAddress, historyId }` (base64url in the Pub/Sub + envelope's `message.data`) — **it does not contain the message.** `historyId` is a + watermark, not a message id. + +So receipt is never "parse the webhook body into an email." It is: authenticate the push, +then **reconcile** from our own stored cursor via `users.history.list` to discover exactly +which messages changed, and fetch each as raw MIME. The notification is a *hint that +something changed*; the stored cursor is the source of truth. That is precisely the +charter's "bounded reconciliation fetch." + +## 2. Webhook receipt and security (HT-39) + +The endpoint — `POST /api/v1/inbound/gmail` — is the **second** unauthenticated surface in +the API (the first is the open-tracking pixel, `matchOpenTrackingPixel` / +`src/api/index.ts`). It carries no service Bearer token — Gmail/Pub/Sub cannot present ours +— so, exactly like the pixel, it MUST be matched and handled **before** the Bearer-auth +gate, and authenticated by its own mechanism: the Google-signed OIDC JWT that Pub/Sub +attaches to an authenticated push subscription. + +Required checks, all of them (a failure of any is a uniform rejection): + +- **Verify the OIDC JWT** on the request (`Authorization: Bearer `): signature against + Google's published certs; `iss` is Google; `aud` equals **our exact endpoint URL**; + `email` is the specific push service account we configured for the subscription; `exp` + not passed. +- **Bind to our subscription/project** — reject a notification that did not originate from + the Pub/Sub subscription we created. A valid Google JWT is necessary but not sufficient; + it must be *our* push identity. +- **Envelope limits** — `POST` + `application/json` only; a body-size cap; a uniform + response that does not leak *which* check failed; replay tolerance (a re-POST is safe + because ingestion is idempotent — inbound-ingestion.md §4 — but abusive repeats are + rate-capped). + +This surface is materially costlier than the pixel: a single accepted POST can trigger +Gmail API fetches, blob writes, and DB writes. So it does **no heavy work inline** — it +authenticates, records the notification (a durable "history advanced for mailbox X to +`historyId` Y" marker), acks Pub/Sub with a fast 2xx, and lets the reconciliation step (§3) +do the fetching. Returning 2xx quickly also prevents Pub/Sub's own redelivery from +amplifying load; a non-2xx tells Pub/Sub to redeliver, which idempotency (§4, +inbound-ingestion.md §4) makes safe but which we don't want to invite needlessly. + +## 3. History reconciliation and raw fetch (HT-41) + +From a recorded notification (a `mailboxId` and a new `historyId` watermark): + +- `users.history.list?startHistoryId=` — enumerate `messagesAdded` since + **our stored cursor**, not merely since the notification's `historyId`. Page through all + results. +- For each new message id: `users.messages.get?format=raw` → the raw RFC822 bytes. `raw` is + mandatory; a parsed/`full` fetch would reintroduce the second-parser problem + (inbound-ingestion.md §1). Attachments present in the raw MIME are written to the + `BlobStore` under a **mailbox-namespaced** key before hand-off (blob.ts). +- Hand each message to the ingest pipeline as `{ raw, mailboxId, providerMessageId = + , receivedAt }` (inbound-ingestion.md §2–3). The transport never parses. + +## 4. The cursor: monotonic, transactional with persistence + +Each mailbox stores a `historyId` cursor (HT-36). Its one rule: **it advances only after +the ingest pipeline confirms every message in the batch is `stored` or `suppressed`** +(inbound-ingestion.md §4). A crash mid-batch leaves the cursor where it was; the next +notification (or the watch-renewal re-baseline) re-lists from there, re-fetches, and the +pipeline dedups on `(mailboxId, providerMessageId)`. Advancing the cursor *before* +persistence would silently drop any message that failed to store — the one outcome +invariant #1 forbids — so we always bias to re-fetch, never to skip. + +## 5. Expired history cursor — the dangerous case, and a dogfood decision + +`users.history.list` returns **404** when `startHistoryId` is older than Gmail's retention +window (documented as "typically at least a week," but "in rare cases only a few hours"). +Once that happens there is no incremental path forward: the only API-level recovery is a +full re-list of the mailbox. + +**Decision (dogfood):** on a 404-expired cursor, **pause the mailbox and flag it for manual +rebaseline** — do **not** trigger an automatic full-mailbox resync. Rationale: an unbounded +resync would re-enumerate the entire mailbox, leaning on dedup to absorb mass duplicates and +doing work bounded only by mailbox size — exactly the kind of surprising, hard-to-bound +behavior the charter's serverless posture avoids, and a real risk to the sacred no-drop / +no-storm guarantees if dedup or blob writes hiccup at scale. For RIQ-watching-itself, a +paused mailbox is a visible, operator-resolvable state (re-baseline deliberately), not a +silent failure. + +> **OPEN QUESTION (deferred with the forwarding/GA work).** The external default likely +> needs an *automatic bounded* rebaseline — e.g. re-arm `watch()` for a fresh cursor and +> ingest only messages received after the pause timestamp, accepting a bounded gap rather +> than a full resync. Specced when GA onboarding is, not now. + +## 6. `watch()` lifecycle and renewal (HT-42) + +- `watch()` is called when a mailbox is connected (OAuth, HT-40) and returns the initial + `historyId` (the cursor's starting point) and an expiration (~7 days out). +- **`watch()` expires and MUST be re-armed at least every 7 days, or notifications silently + stop** — no error on either side, mail just keeps arriving with nothing telling us. A + daily `SchedulerProvider` cron (`registerCron`, `src/providers/scheduler.ts`) re-arms + `watch()` for every active mailbox. Daily (not every-6-days) buys a safety margin against + a missed run; `watch()` is idempotent, so re-arming early is free. This is the charter's + sanctioned low-frequency **cron trigger** (§4), not a polling loop — it fires once a day + regardless of mail volume and fetches nothing itself. +- On `watch()` failure (revoked/expired grant, admin change): mark the mailbox + **needs-reconnect** and surface it — never crash the cron for other mailboxes (OAuth + handling, HT-38/HT-40). + +## 7. What this transport does not own + +- **Parsing, threading, storage, idempotency, loop-suppression, observability** → + inbound-ingestion.md. This transport hands over raw bytes and provider metadata and stops. +- **OAuth token acquisition/refresh** → HT-38; the **connect/consent flow** → HT-40. +- **One-time GCP/Pub-Sub provisioning** (Internal OAuth app; enable the Gmail + Pub/Sub APIs; + create the topic; grant `gmail-api-push@system.gserviceaccount.com` the Pub/Sub Publisher + role; create the push subscription → our endpoint) is an **operator runbook** (HT-43), not + engine code — the engine assumes the topic/subscription exist and its credentials can call + `watch()`/`history.list`/`messages.get`. + +## 8. Acceptance + +Against a **faked** Gmail API + Pub/Sub push (no cloud): + +- A push with a valid OIDC JWT → `history.list` → `messages.get?format=raw` → the raw bytes + reach the ingest pipeline with correct `{ mailboxId, providerMessageId, receivedAt }`. +- A forged / wrong-`aud` / wrong-service-account / expired JWT, or a notification not bound + to our subscription → rejected, uniform response, no fetch triggered. +- A duplicate push (same `historyId`) → no duplicate ingestion (dedup, inbound-ingestion.md §4). +- A mid-batch failure → the cursor does not advance past the unstored message. +- A 404 on `history.list` → the mailbox is paused and flagged, no resync attempted. + +The **live** end-to-end proof against real Gmail — send via the Gmail API, assert the +delivered message carries our verbatim token-bearing `Message-ID`, reply from a real Gmail +account, assert the reply threads into the same conversation — is the sacred check owned by +**HT-44**, not this fake-backed suite. diff --git a/specs/mail/inbound-ingestion.md b/specs/mail/inbound-ingestion.md new file mode 100644 index 0000000..5160a22 --- /dev/null +++ b/specs/mail/inbound-ingestion.md @@ -0,0 +1,193 @@ +# Inbound ingestion pipeline + +Status: draft (HT-34). Companion to [threading.md](./threading.md) (which conversation +an inbound message joins) and [sending.md](./sending.md) (how an outbound reply is +minted and delivered). This spec is the orchestration those two repeatedly defer to +as "the mail-ingestion pipeline, not yet built" (threading.md §5; store/conversations.md) — +the **provider-agnostic** path that turns one received message into a stored +conversation/thread. It is transport-agnostic by construction: the Gmail-push transport +([gmail-push.md](./gmail-push.md)) feeds it today, and the future forwarding-address +transport will feed the *same* pipeline unchanged. + +## 1. Three invariants + +Everything below serves three rules, in priority order: + +1. **Parse exactly once, by our own code.** Inbound MIME is parsed by `parseInboundEmail` + (`src/mail/parse.ts`, postal-mime) and nothing else. No transport, provider, or SDK + parses the message into a shape the engine then threads on. This is charter §2's + "boringly faithful on mail semantics" applied to the front door: a second, provider- + specific parser in the ingest path is exactly the kind of divergence the charter's + origin story warns against, and it would make threading depend on how faithfully a + provider preserved headers we didn't control. +2. **Thread only on our token.** Which conversation a message joins is decided solely by + `decideThreading` (threading.md) — never re-derived here, never influenced by the + transport. +3. **At-least-once, idempotent, never dropped.** A received message is either stored, + deliberately suppressed (§5), or parked in the dead-letter ledger for manual review + (§4) — never silently lost (invariant #1). A re-delivery of a message we already + processed is a no-op, never a duplicate conversation. + +## 2. The provider boundary: raw bytes in, nothing pre-parsed + +An inbound transport implements `InboundEmailProvider` (`src/providers/inbound-email.ts`). +Its job is narrow: **authenticate a delivery, and produce, per message, the raw RFC822 +bytes (or a blob reference to them) plus provider metadata** — it does not parse the +message for the engine to consume. + +Provider metadata is the minimum the pipeline needs and the transport authoritatively +knows: + +- `mailboxId` — which connected mailbox this arrived at (the namespace anchor for + storage, blobs, dedup, and — later — tenancy; HT-36). +- `providerMessageId` — the transport's own stable id for the message (for Gmail, the + Gmail message id). This is the idempotency authority (§4), *not* the RFC `Message-ID`. +- `receivedAt` — when the transport recorded delivery (not a header-parsed `Date`). + +> **Correction (HT-35).** The interface as first drafted returns a `NormalizedInboundEmail` +> — headers and body already parsed, attachments already blob-referenced. That is wrong +> under invariant #1: it puts the parse *inside the provider*, before the engine, in a +> provider-specific place. HT-35 changes the seam to yield raw bytes + metadata; this +> spec describes the corrected contract, and every transport is written against it. + +## 3. The ingest procedure + +Ordered, applied to each received message. Idempotent by step 1, so a whole re-run is safe. + +1. **Dedup.** Look up `(mailboxId, providerMessageId)` in the delivery ledger (§4). If a + row already reached a terminal `stored`/`suppressed` state, **stop — this is a replay, + return the existing outcome.** (This is what makes at-least-once delivery from the + transport safe.) +2. **Record intent.** Upsert a ledger row at `received` (first attempt) or bump its + `attempts` (a retry). +3. **Parse.** `parseInboundEmail(raw) → ParsedEmail` (invariant #1). A message that cannot + be parsed at all is a ledger `failed`/dead-letter case (§4), never a guess. +4. **Loop/auto-responder gate (§5).** A suppressed message is recorded `suppressed` and + **creates and appends nothing** — but is not dropped (it is visible in the ledger). +5. **Decide.** `decideThreading(parsed, keyring) → { kind: 'new' } | { kind: 'append', + conversationId, threadId }` (threading.md §3). Never re-implemented here. +6. **Store.** + - `new` → `createConversation` (its first thread is this inbound message). + - `append` → `appendThread(conversationId, …)`. The store may answer `{ ok: false, + reason: 'deleted' | 'not-found' }` (threading.md §5): on **`deleted`**, fall back to + `createConversation` (a fresh conversation — the token pointed at a conversation an + operator intentionally removed, so we neither resurrect it nor drop the mail); on + **`not-found`**, likewise fall back to a fresh conversation (the token verified but no + such row exists — pathological, but the mail is still ingested, never lost). +7. **Commit outcome.** Mark the ledger row `stored` with the resulting + `conversationId`/`threadId`. + +**Attachments** are written to the `BlobStore` under a **mailbox-namespaced** key +(`src/providers/blob.ts` makes namespacing the caller's responsibility) *before* the +conversation write, and the stored thread carries blob references, never inline bytes. +A blob write that succeeds followed by a DB commit that fails is a §4 partial-failure, +retried; the ledger, not the blob store, is the source of truth for "did this message +land." + +## 4. Idempotency, the delivery ledger, and retries + +**The idempotency key is `(mailboxId, providerMessageId)` — deliberately not the RFC +`Message-ID`.** The inbound `Message-ID` is optional (`NewThread.messageId` permits +`null`, `src/store/conversations.ts`) and entirely sender-controlled, so it cannot be +the authority that decides "have we already ingested this." The transport's own message +id is stable and provider-issued. The RFC `Message-ID` is retained on the stored thread +as data and as a *secondary* duplicate signal, never as the dedup key. + +**The delivery ledger** (a table, HT-36) is one row per `(mailboxId, providerMessageId)`, +carrying `status` (`received` | `stored` | `suppressed` | `failed` | `dead-letter`), +`attempts`, `last_error`, and the resulting `conversationId`/`threadId`. It is +simultaneously the **idempotency record** (step 1) and the **retry queue**. + +**At-least-once, with honest partial-failure handling.** Ingest can fail partway — +parse-ok/store-fail, blob-written/DB-commit-fail, `append→deleted`/fallback-create-fail. +The pipeline mirrors the outbound delivery worker's discipline (sending.md §3a): the +per-message ingest is retryable as a unit, a re-delivery of the same key is a no-op once +`stored`, and a message that exhausts its retry budget lands in **`dead-letter`** for +manual review — visible and recoverable, never silently dropped (invariant #1). As with +sending (sending.md §3a), we cannot make ingestion *at-most-once*; we make it +at-least-once and idempotent, which for a support desk is the safe asymmetry (a rare +reprocessed message is deduped away; a dropped customer email is unacceptable). + +**Cursor advancement is transactional with persistence.** Where a transport keeps a +position cursor (Gmail's `historyId`, gmail-push.md §4), that cursor advances **only** +for messages this pipeline has confirmed `stored` or `suppressed`. The pipeline states +this as a contract the transport must honor: bias to re-fetch (dedup makes it free), +never to skip. + +## 5. Loops, auto-responders, and one deliberate divergence + +threading.md §5 left "Auto-Submitted mail creates conversations" cross-referenced to "a +future auto-responder spec." This is the ingest-gate half of that home. + +**Loop suppression — new, and contradicts no fixture.** Before threading, drop a message +that is one of *our own* outbound messages coming back — detected by our sending identity +in `From`/`Return-Path`, or by one of our own outbound reply-tokens appearing where it +indicates our mail was reflected (e.g. an auto-reply to a reply we sent). This is pure +loop-prevention: nothing in the observed fixtures speaks to it, so specifying it is +additive, not a divergence. A per-sender/window **rate cap** is a backstop against +floods and reflection storms. + +**Generic third-party auto-submitted / bulk mail — preserve the observed behavior.** +Here the sacred rule bites (charter §2: mail-behavior changes need fixture-proven +equivalence *or* explicit written justification). `fixtures/mail/observed/auto-submitted.json` +shows the reference helpdesk **ingesting** an `Auto-Submitted: auto-replied` message +normally — it created a conversation, it was **not** suppressed (threading.md §5). So the +**default is to ingest it**, matching the fixture: an out-of-office reply from a customer +is a real thing an agent may want to see. What Helpthread must never do is *auto-respond* +to such mail (RFC 3834) — but Helpthread has no auto-responder today, so there is nothing +to loop yet; the suppression that matters now is the own-message loop rule above. + +> **OPEN QUESTION (not blocking v1).** Should the pipeline *additionally* suppress +> third-party `Auto-Submitted != no` / `Precedence: bulk|list|junk` / mailing-list +> (`List-*`, RFC 2369/2919) mail from creating conversations? Doing so would **diverge +> from `auto-submitted.json`** and therefore needs its own written justification and, +> ideally, an acceptance fixture before it becomes load-bearing — it is not adopted here +> by default precisely because a fixture currently says the opposite. The likely resolution +> is a config-gated filter (route/label rather than hard-drop), decided alongside the +> auto-responder spec. Recorded here so the decision is explicit rather than smuggled in. + +A suppressed message is recorded in the ledger (`suppressed`, with the reason) — visible, +auditable, never a silent drop. + +## 6. Observability and the forged-token signal + +Each ingest emits a structured record: `mailboxId`, `providerMessageId`, the transport +cursor position, the threading decision (`new`/`append` + target ids), `forgedTokenCount`, +suppression reason (if any), parse size, attachment count, and final ledger outcome. + +`decideThreading` already emits `forgedTokenCount` (threading.md §3 rule 3, §5) but nothing +consumes it today. **This pipeline is where it is consumed:** a single forged token is +unremarkable; a burst against one conversation or sender is a security signal that must be +surfaced/alertable (the precise threshold remains threading.md §5's open question — this +spec provides the consumption point, not the threshold). + +## 7. Scope and deferrals + +- **Transport-specific concerns** — webhook authentication, Pub/Sub, history reconciliation, + `watch()` — live in the transport spec ([gmail-push.md](./gmail-push.md)), not here. +- **The forwarding-address transport** is deferred (the external/GA default); it will + implement the same §2 provider boundary and feed this pipeline **unchanged** — which is + the point of keeping the pipeline provider-agnostic (charter §4's owned interfaces). +- **HTML sanitization on render** is not this spec's concern; storage keeps bodies verbatim + (threading.md §5's `html-body.json` flag), and a sanitization spec owns the render-time + guarantee. Inbound HTML is already sanitized at *render* in the web client + (`SanitizedHtml`); the engine stores raw. +- **Multi-tenant enforcement** is out of scope; the schema carries `mailboxId` from day one + (HT-36) so nothing bakes in a global singleton, but behavior is single-tenant for the + dogfood. + +## 8. Acceptance + +Exercised end-to-end against the in-memory `InboundEmailProvider` fake (HT-35) and the +engine's existing store/keyring fakes — no cloud required: + +- A fresh message (no valid token) → a new conversation. +- A valid-token reply → appends to that conversation (drives `decideThreading`; the + threading.md §6 observed-fixture outcomes must still hold when reached *through* this + pipeline, not just in `decideThreading`'s own unit tests). +- A re-delivery of the same `(mailboxId, providerMessageId)` → a no-op (one conversation, + one thread; ledger shows a single `stored` row). +- A simulated partial failure (store throws after a blob write) → ledger `failed`, retried + to `stored`, no orphaned/duplicate conversation. +- An own-message loop → `suppressed`, nothing created. +- `append→deleted` → falls back to a fresh conversation, mail never lost. From 8635128e520fd0eaec853f19da85fd44d8a571b0 Mon Sep 17 00:00:00 2001 From: TJ Baker <1617679+zaridan@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:22:23 -0700 Subject: [PATCH 2/3] docs(spec): address CodeRabbit review on inbound-ingestion + Gmail-push (HT-34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 8 findings incorporated. gmail-push: require email_verified=true on the Pub/Sub OIDC JWT; bind to the push envelope's exact subscription field; resolve emailAddress->mailbox (reject on mismatch) before reconciling; add a bounded daily history.list reconciliation sweep so dropped/delayed pushes cannot leave a mailbox stale. inbound-ingestion: attachment extraction is the pipeline's job (post-parse), not the transport's; make the ledger claim atomic (unique-key get-or-insert) and commit the store write + 'stored' outcome in one transaction (closes the concurrent-delivery and partial-failure double-create windows); loop suppression now requires a verifiable correlation (own Message-ID / valid own token) — sender identity alone never drops mail (invariant #1); 'Agent' capitalization. Co-Authored-By: Claude Opus 4.8 --- specs/mail/gmail-push.md | 101 ++++++++++++++++-------- specs/mail/inbound-ingestion.md | 131 +++++++++++++++++++------------- 2 files changed, 149 insertions(+), 83 deletions(-) diff --git a/specs/mail/gmail-push.md b/specs/mail/gmail-push.md index 7d983e3..4410188 100644 --- a/specs/mail/gmail-push.md +++ b/specs/mail/gmail-push.md @@ -25,10 +25,16 @@ Gmail push has two moving parts, and the split matters: watermark, not a message id. So receipt is never "parse the webhook body into an email." It is: authenticate the push, -then **reconcile** from our own stored cursor via `users.history.list` to discover exactly -which messages changed, and fetch each as raw MIME. The notification is a *hint that -something changed*; the stored cursor is the source of truth. That is precisely the -charter's "bounded reconciliation fetch." +resolve which mailbox it is for, then **reconcile** from our own stored cursor via +`users.history.list` to discover exactly which messages changed, and fetch each as raw MIME. +The notification is a *hint that something changed*; the stored cursor is the source of +truth. That is precisely the charter's "bounded reconciliation fetch." + +Push is **best-effort, not guaranteed** — Gmail rate-limits notifications to ~1/second per +watched mailbox and may drop or delay them under load. Correctness therefore never rests on +push alone: a scheduled bounded reconciliation (§6) is the safety net Google's own guidance +requires, and the idempotent ingest pipeline (inbound-ingestion.md §4) makes the overlap +between push and sweep free of duplicates. ## 2. Webhook receipt and security (HT-39) @@ -43,11 +49,15 @@ Required checks, all of them (a failure of any is a uniform rejection): - **Verify the OIDC JWT** on the request (`Authorization: Bearer `): signature against Google's published certs; `iss` is Google; `aud` equals **our exact endpoint URL**; - `email` is the specific push service account we configured for the subscription; `exp` - not passed. -- **Bind to our subscription/project** — reject a notification that did not originate from - the Pub/Sub subscription we created. A valid Google JWT is necessary but not sufficient; - it must be *our* push identity. + `email` is the specific push service account we configured for the subscription; + **`email_verified` is `true`** (Google's push-auth guidance is explicit that the signed + `email` claim is only trustworthy when `email_verified` is set — a valid signature and + audience do not by themselves bind the identity); `exp` not passed. +- **Bind to our subscription** — compare the push envelope's top-level `subscription` field + (`projects/{project}/subscriptions/{name}`, present on every Pub/Sub push body) against + the exact subscription we provisioned, and reject anything else. A valid Google JWT is + necessary but not sufficient; the delivery must also be *our* subscription, not merely + some authenticated Pub/Sub push. - **Envelope limits** — `POST` + `application/json` only; a body-size cap; a uniform response that does not leak *which* check failed; replay tolerance (a re-POST is safe because ingestion is idempotent — inbound-ingestion.md §4 — but abusive repeats are @@ -55,32 +65,43 @@ Required checks, all of them (a failure of any is a uniform rejection): This surface is materially costlier than the pixel: a single accepted POST can trigger Gmail API fetches, blob writes, and DB writes. So it does **no heavy work inline** — it -authenticates, records the notification (a durable "history advanced for mailbox X to -`historyId` Y" marker), acks Pub/Sub with a fast 2xx, and lets the reconciliation step (§3) -do the fetching. Returning 2xx quickly also prevents Pub/Sub's own redelivery from -amplifying load; a non-2xx tells Pub/Sub to redeliver, which idempotency (§4, -inbound-ingestion.md §4) makes safe but which we don't want to invite needlessly. +authenticates, records the notification (a durable "history advanced for mailbox X" marker), +acks Pub/Sub with a fast 2xx, and lets the reconciliation step (§3) do the fetching. +Returning 2xx quickly also prevents Pub/Sub's own redelivery from amplifying load; a non-2xx +tells Pub/Sub to redeliver, which idempotency (§4, inbound-ingestion.md §4) makes safe but +which we don't want to invite needlessly. ## 3. History reconciliation and raw fetch (HT-41) -From a recorded notification (a `mailboxId` and a new `historyId` watermark): +**Resolve the mailbox first.** The notification carries `emailAddress`, **not** a +`mailboxId`. Before recording any cursor or calling `history.list`, resolve `emailAddress` +to a known, active connected mailbox and **reject the notification if it does not map to +one** — this stops a misrouted, stale, or spoofed push from advancing or querying the wrong +mailbox. Everything downstream keys off the resolved `mailboxId`, never the raw +`emailAddress`. (The JWT's `email` claim in §2 is the *push service account*; the payload's +`emailAddress` is the *watched mailbox* — two different identities, both checked.) + +Then, from the resolved mailbox and its stored cursor: - `users.history.list?startHistoryId=` — enumerate `messagesAdded` since - **our stored cursor**, not merely since the notification's `historyId`. Page through all - results. + **our stored cursor**, not the notification's `historyId` (which is the *new* watermark: + starting from it returns nothing, because there are no changes newer than the current + state — the stored cursor is the source of truth). Page through all results. - For each new message id: `users.messages.get?format=raw` → the raw RFC822 bytes. `raw` is mandatory; a parsed/`full` fetch would reintroduce the second-parser problem - (inbound-ingestion.md §1). Attachments present in the raw MIME are written to the - `BlobStore` under a **mailbox-namespaced** key before hand-off (blob.ts). + (inbound-ingestion.md §1). - Hand each message to the ingest pipeline as `{ raw, mailboxId, providerMessageId = - , receivedAt }` (inbound-ingestion.md §2–3). The transport never parses. + , receivedAt }` (inbound-ingestion.md §2–3). **The transport never + parses — and therefore never extracts attachments.** Parsing the MIME and writing + attachments to the `BlobStore` is the pipeline's job (inbound-ingestion.md §2–3), + downstream of the single `parseInboundEmail` call; the transport only moves raw bytes. ## 4. The cursor: monotonic, transactional with persistence Each mailbox stores a `historyId` cursor (HT-36). Its one rule: **it advances only after the ingest pipeline confirms every message in the batch is `stored` or `suppressed`** (inbound-ingestion.md §4). A crash mid-batch leaves the cursor where it was; the next -notification (or the watch-renewal re-baseline) re-lists from there, re-fetches, and the +notification (or the §6 reconciliation sweep) re-lists from there, re-fetches, and the pipeline dedups on `(mailboxId, providerMessageId)`. Advancing the cursor *before* persistence would silently drop any message that failed to store — the one outcome invariant #1 forbids — so we always bias to re-fetch, never to skip. @@ -99,14 +120,15 @@ doing work bounded only by mailbox size — exactly the kind of surprising, hard behavior the charter's serverless posture avoids, and a real risk to the sacred no-drop / no-storm guarantees if dedup or blob writes hiccup at scale. For RIQ-watching-itself, a paused mailbox is a visible, operator-resolvable state (re-baseline deliberately), not a -silent failure. +silent failure. (This is distinct from the §6 sweep, which reconciles from a *live* cursor; +here the cursor itself is unrecoverable.) > **OPEN QUESTION (deferred with the forwarding/GA work).** The external default likely > needs an *automatic bounded* rebaseline — e.g. re-arm `watch()` for a fresh cursor and > ingest only messages received after the pause timestamp, accepting a bounded gap rather > than a full resync. Specced when GA onboarding is, not now. -## 6. `watch()` lifecycle and renewal (HT-42) +## 6. `watch()` renewal and periodic reconciliation (HT-42) - `watch()` is called when a mailbox is connected (OAuth, HT-40) and returns the initial `historyId` (the cursor's starting point) and an expiration (~7 days out). @@ -114,17 +136,27 @@ silent failure. stop** — no error on either side, mail just keeps arriving with nothing telling us. A daily `SchedulerProvider` cron (`registerCron`, `src/providers/scheduler.ts`) re-arms `watch()` for every active mailbox. Daily (not every-6-days) buys a safety margin against - a missed run; `watch()` is idempotent, so re-arming early is free. This is the charter's - sanctioned low-frequency **cron trigger** (§4), not a polling loop — it fires once a day - regardless of mail volume and fetches nothing itself. + a missed run; `watch()` is idempotent, so re-arming early is free. +- **The same daily cron also runs a bounded reconciliation `history.list` from each active + mailbox's stored cursor.** This is not optional polish: because push is best-effort (§1), + a dropped or delayed notification — most damagingly the *last* one before a quiet spell — + can otherwise leave a mailbox stale indefinitely, since nothing else triggers a fetch. The + sweep is the charter's exact "bounded reconciliation fetch, never a long-running poller" + (§4, phase-1): it reuses the §3–§4 fetch/cursor path, is bounded per run, and fires on the + same once-daily tick — it is a scheduled catch-up, not a polling loop. It feeds the + identical idempotent ingest pipeline, so any message already delivered by push is deduped, + never doubled (inbound-ingestion.md §4). (Cadence is a tuning knob: daily bounds worst-case + staleness to ~24h for a dropped tail notification; a tighter interval trades quota for + freshness and can be revisited without changing the design.) - On `watch()` failure (revoked/expired grant, admin change): mark the mailbox **needs-reconnect** and surface it — never crash the cron for other mailboxes (OAuth handling, HT-38/HT-40). ## 7. What this transport does not own -- **Parsing, threading, storage, idempotency, loop-suppression, observability** → - inbound-ingestion.md. This transport hands over raw bytes and provider metadata and stops. +- **Parsing, threading, storage, idempotency, attachment extraction, loop-suppression, + observability** → inbound-ingestion.md. This transport hands over raw bytes and provider + metadata and stops. - **OAuth token acquisition/refresh** → HT-38; the **connect/consent flow** → HT-40. - **One-time GCP/Pub-Sub provisioning** (Internal OAuth app; enable the Gmail + Pub/Sub APIs; create the topic; grant `gmail-api-push@system.gserviceaccount.com` the Pub/Sub Publisher @@ -136,13 +168,18 @@ silent failure. Against a **faked** Gmail API + Pub/Sub push (no cloud): -- A push with a valid OIDC JWT → `history.list` → `messages.get?format=raw` → the raw bytes - reach the ingest pipeline with correct `{ mailboxId, providerMessageId, receivedAt }`. -- A forged / wrong-`aud` / wrong-service-account / expired JWT, or a notification not bound - to our subscription → rejected, uniform response, no fetch triggered. +- A push with a valid OIDC JWT (correct `aud`, service-account `email`, `email_verified`, + and matching `subscription`) → mailbox resolved from `emailAddress` → `history.list` → + `messages.get?format=raw` → the raw bytes reach the ingest pipeline with correct + `{ mailboxId, providerMessageId, receivedAt }`. +- A forged / wrong-`aud` / wrong-service-account / `email_verified:false` / expired JWT, or + a notification whose `subscription` isn't ours, or whose `emailAddress` resolves to no + known mailbox → rejected, uniform response, no fetch triggered. - A duplicate push (same `historyId`) → no duplicate ingestion (dedup, inbound-ingestion.md §4). - A mid-batch failure → the cursor does not advance past the unstored message. - A 404 on `history.list` → the mailbox is paused and flagged, no resync attempted. +- The daily reconciliation sweep re-lists from the stored cursor and ingests a message a + *dropped* push never delivered — with no duplication of messages push already delivered. The **live** end-to-end proof against real Gmail — send via the Gmail API, assert the delivered message carries our verbatim token-bearing `Message-ID`, reply from a real Gmail diff --git a/specs/mail/inbound-ingestion.md b/specs/mail/inbound-ingestion.md index 5160a22..8a087e9 100644 --- a/specs/mail/inbound-ingestion.md +++ b/specs/mail/inbound-ingestion.md @@ -33,13 +33,16 @@ Everything below serves three rules, in priority order: An inbound transport implements `InboundEmailProvider` (`src/providers/inbound-email.ts`). Its job is narrow: **authenticate a delivery, and produce, per message, the raw RFC822 bytes (or a blob reference to them) plus provider metadata** — it does not parse the -message for the engine to consume. +message, and it does not extract attachments (both require parsing the MIME, which is the +pipeline's single `parseInboundEmail` call, §3). Provider metadata is the minimum the pipeline needs and the transport authoritatively knows: - `mailboxId` — which connected mailbox this arrived at (the namespace anchor for - storage, blobs, dedup, and — later — tenancy; HT-36). + storage, blobs, dedup, and — later — tenancy; HT-36). The transport resolves this to a + known mailbox and rejects a delivery it cannot (gmail-push.md §3); the pipeline receives + an already-resolved `mailboxId`, never a raw provider address. - `providerMessageId` — the transport's own stable id for the message (for Gmail, the Gmail message id). This is the idempotency authority (§4), *not* the RFC `Message-ID`. - `receivedAt` — when the transport recorded delivery (not a header-parsed `Date`). @@ -47,26 +50,31 @@ knows: > **Correction (HT-35).** The interface as first drafted returns a `NormalizedInboundEmail` > — headers and body already parsed, attachments already blob-referenced. That is wrong > under invariant #1: it puts the parse *inside the provider*, before the engine, in a -> provider-specific place. HT-35 changes the seam to yield raw bytes + metadata; this -> spec describes the corrected contract, and every transport is written against it. +> provider-specific place, and hands attachment ownership to the transport. HT-35 changes +> the seam to yield raw bytes + metadata; this spec describes the corrected contract, and +> every transport is written against it. ## 3. The ingest procedure Ordered, applied to each received message. Idempotent by step 1, so a whole re-run is safe. -1. **Dedup.** Look up `(mailboxId, providerMessageId)` in the delivery ledger (§4). If a - row already reached a terminal `stored`/`suppressed` state, **stop — this is a replay, - return the existing outcome.** (This is what makes at-least-once delivery from the - transport safe.) -2. **Record intent.** Upsert a ledger row at `received` (first attempt) or bump its - `attempts` (a retry). -3. **Parse.** `parseInboundEmail(raw) → ParsedEmail` (invariant #1). A message that cannot +1. **Claim, atomically.** Insert a delivery-ledger row keyed by the unique + `(mailboxId, providerMessageId)` — `INSERT … ON CONFLICT (mailbox_id, + provider_message_id) DO NOTHING RETURNING *`, the same atomic get-or-insert + `appendThread` uses for outbound idempotency (sending.md §3a). A fresh insert means we + own processing; a **conflict** means a concurrent or prior delivery already owns it, so + we **stop and return that row's outcome** — a terminal `stored`/`suppressed` row is a + completed replay, an in-flight `received` row is another worker's claim (do not + double-process). A non-atomic read-then-insert would let two concurrent deliveries of + the same key both pass a dedup check and both create a conversation; the unique-key + claim is what closes that race. +2. **Parse.** `parseInboundEmail(raw) → ParsedEmail` (invariant #1). A message that cannot be parsed at all is a ledger `failed`/dead-letter case (§4), never a guess. -4. **Loop/auto-responder gate (§5).** A suppressed message is recorded `suppressed` and - **creates and appends nothing** — but is not dropped (it is visible in the ledger). -5. **Decide.** `decideThreading(parsed, keyring) → { kind: 'new' } | { kind: 'append', +3. **Loop/auto-responder gate (§5).** A suppressed message is recorded `suppressed` and + **creates and appends nothing** — but is not dropped (it stays visible in the ledger). +4. **Decide.** `decideThreading(parsed, keyring) → { kind: 'new' } | { kind: 'append', conversationId, threadId }` (threading.md §3). Never re-implemented here. -6. **Store.** +5. **Store and commit the outcome, atomically (§4).** - `new` → `createConversation` (its first thread is this inbound message). - `append` → `appendThread(conversationId, …)`. The store may answer `{ ok: false, reason: 'deleted' | 'not-found' }` (threading.md §5): on **`deleted`**, fall back to @@ -74,15 +82,13 @@ Ordered, applied to each received message. Idempotent by step 1, so a whole re-r operator intentionally removed, so we neither resurrect it nor drop the mail); on **`not-found`**, likewise fall back to a fresh conversation (the token verified but no such row exists — pathological, but the mail is still ingested, never lost). -7. **Commit outcome.** Mark the ledger row `stored` with the resulting - `conversationId`/`threadId`. + - The store write **and** the ledger row's `received → stored` transition (recording the + resulting `conversationId`/`threadId`) commit in **one transaction** — see §4. -**Attachments** are written to the `BlobStore` under a **mailbox-namespaced** key -(`src/providers/blob.ts` makes namespacing the caller's responsibility) *before* the -conversation write, and the stored thread carries blob references, never inline bytes. -A blob write that succeeds followed by a DB commit that fails is a §4 partial-failure, -retried; the ledger, not the blob store, is the source of truth for "did this message -land." +**Attachments belong to the pipeline, not the transport** (§2). After the parse (step 2), +attachment bytes are written to the `BlobStore` under a **mailbox-namespaced** key +(`src/providers/blob.ts` makes namespacing the caller's responsibility) as part of the +step-5 store, and the stored thread carries blob references, never inline bytes. ## 4. Idempotency, the delivery ledger, and retries @@ -93,20 +99,34 @@ the authority that decides "have we already ingested this." The transport's own id is stable and provider-issued. The RFC `Message-ID` is retained on the stored thread as data and as a *secondary* duplicate signal, never as the dedup key. -**The delivery ledger** (a table, HT-36) is one row per `(mailboxId, providerMessageId)`, -carrying `status` (`received` | `stored` | `suppressed` | `failed` | `dead-letter`), -`attempts`, `last_error`, and the resulting `conversationId`/`threadId`. It is -simultaneously the **idempotency record** (step 1) and the **retry queue**. - -**At-least-once, with honest partial-failure handling.** Ingest can fail partway — -parse-ok/store-fail, blob-written/DB-commit-fail, `append→deleted`/fallback-create-fail. -The pipeline mirrors the outbound delivery worker's discipline (sending.md §3a): the -per-message ingest is retryable as a unit, a re-delivery of the same key is a no-op once -`stored`, and a message that exhausts its retry budget lands in **`dead-letter`** for -manual review — visible and recoverable, never silently dropped (invariant #1). As with -sending (sending.md §3a), we cannot make ingestion *at-most-once*; we make it -at-least-once and idempotent, which for a support desk is the safe asymmetry (a rare -reprocessed message is deduped away; a dropped customer email is unacceptable). +**The delivery ledger** (a table, HT-36) is one row per `(mailboxId, providerMessageId)` +with a **unique constraint** on that pair, carrying `status` (`received` | `stored` | +`suppressed` | `failed` | `dead-letter`), `attempts`, `last_error`, and the resulting +`conversationId`/`threadId`. It is simultaneously the **idempotency record** (§3 step 1), +the **claim/lease**, and the **retry queue**. + +**The claim, the store write, and the outcome are one atomic unit.** The step-5 store write +(`createConversation`/`appendThread`) and the ledger's `received → stored` transition — +recording the resulting ids — commit in a **single transaction**, so the ledger row *is* +the idempotency record: a retry re-hits the §3-step-1 claim, finds a `stored` row, and +returns its recorded `conversationId` without re-writing. A crash *before* that commit +leaves the row at `received` and no conversation, and the retry redoes the whole unit +cleanly. This is what closes the "successful conversation write, then failed ledger update, +then duplicate conversation on retry" window — the write and its record are never +separately durable. It is the inbound mirror of the outbound get-or-insert in sending.md +§3a, keyed on `(mailboxId, providerMessageId)` rather than `(conversationId, +idempotencyKey)`. + +**At-least-once, with honest partial-failure handling.** Ingest can still fail partway — +an unparseable message, a blob write that succeeds then a transaction that aborts, an +`append→deleted` whose fallback-create then fails. The pipeline mirrors the outbound +delivery worker's discipline (sending.md §3a): the per-message ingest is retryable as a +unit, a re-delivery of the same key is a no-op once `stored`, and a message that exhausts +its retry budget lands in **`dead-letter`** for manual review — visible and recoverable, +never silently dropped (invariant #1). As with sending (sending.md §3a), we cannot make +ingestion *at-most-once*; we make it at-least-once and idempotent, which for a support desk +is the safe asymmetry (a rare reprocessed message is deduped away; a dropped customer email +is unacceptable). **Cursor advancement is transactional with persistence.** Where a transport keeps a position cursor (Gmail's `historyId`, gmail-push.md §4), that cursor advances **only** @@ -119,23 +139,29 @@ never to skip. threading.md §5 left "Auto-Submitted mail creates conversations" cross-referenced to "a future auto-responder spec." This is the ingest-gate half of that home. -**Loop suppression — new, and contradicts no fixture.** Before threading, drop a message -that is one of *our own* outbound messages coming back — detected by our sending identity -in `From`/`Return-Path`, or by one of our own outbound reply-tokens appearing where it -indicates our mail was reflected (e.g. an auto-reply to a reply we sent). This is pure -loop-prevention: nothing in the observed fixtures speaks to it, so specifying it is -additive, not a divergence. A per-sender/window **rate cap** is a backstop against -floods and reflection storms. +**Loop suppression — new, and bounded by invariant #1.** Before threading, drop a message +only when it is *verifiably* one of our own outbound messages reflected back — established +by a **verifiable correlation**: our exact outbound `Message-ID` (which we minted and can +recognise) appearing as this message's `Message-ID`, or a valid, signature-verified **own +reply token** in a position indicating our mail was bounced or auto-answered. Our sending +identity in `From`/`Return-Path` is **only a supporting signal, never sufficient on its +own** — those headers are sender-controlled, so suppressing on identity alone could +silently drop a legitimate customer message (someone mailing *from* an address that +resembles ours, or a forwarded copy), which violates the never-dropped invariant (§1). +This rule is additive (no fixture speaks to it), but it lives strictly inside invariant #1: +when the correlation isn't verifiable, ingest. A per-sender/window **rate cap** is a +backstop against floods and reflection storms; a rate-capped message is deferred or flagged +for review, not dropped. **Generic third-party auto-submitted / bulk mail — preserve the observed behavior.** Here the sacred rule bites (charter §2: mail-behavior changes need fixture-proven equivalence *or* explicit written justification). `fixtures/mail/observed/auto-submitted.json` shows the reference helpdesk **ingesting** an `Auto-Submitted: auto-replied` message normally — it created a conversation, it was **not** suppressed (threading.md §5). So the -**default is to ingest it**, matching the fixture: an out-of-office reply from a customer -is a real thing an agent may want to see. What Helpthread must never do is *auto-respond* -to such mail (RFC 3834) — but Helpthread has no auto-responder today, so there is nothing -to loop yet; the suppression that matters now is the own-message loop rule above. +**default is to ingest it**, matching the fixture: an out-of-office reply from a customer is +a real thing an Agent may want to see. What Helpthread must never do is *auto-respond* to +such mail (RFC 3834) — but Helpthread has no auto-responder today, so there is nothing to +loop yet; the suppression that matters now is the verifiable own-message loop rule above. > **OPEN QUESTION (not blocking v1).** Should the pipeline *additionally* suppress > third-party `Auto-Submitted != no` / `Precedence: bulk|list|junk` / mailing-list @@ -187,7 +213,10 @@ engine's existing store/keyring fakes — no cloud required: pipeline, not just in `decideThreading`'s own unit tests). - A re-delivery of the same `(mailboxId, providerMessageId)` → a no-op (one conversation, one thread; ledger shows a single `stored` row). -- A simulated partial failure (store throws after a blob write) → ledger `failed`, retried - to `stored`, no orphaned/duplicate conversation. -- An own-message loop → `suppressed`, nothing created. +- Two concurrent deliveries of the same key → exactly one conversation (the §3-step-1 + atomic claim; the second returns the first's outcome). +- A simulated partial failure (transaction aborts after a blob write) → ledger `failed`, + retried to `stored`, no orphaned/duplicate conversation. +- A verifiable own-message loop → `suppressed`, nothing created; a message that merely + *claims* our `From` without a verifiable correlation → **ingested**, not dropped. - `append→deleted` → falls back to a fresh conversation, mail never lost. From c7f4a722f6b5d60ff433e766559bdab0c0b8a885 Mon Sep 17 00:00:00 2001 From: TJ Baker <1617679+zaridan@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:59:16 -0700 Subject: [PATCH 3/3] docs(spec): address CodeRabbit nitpicks on gmail-push (HT-34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gmail-push §2: the webhook enqueues the reconcile job onto QueueProvider (a durable hand-off, not an unguaranteed serverless post-response continuation) so a dropped hand-off can't silently degrade push to the daily sweep. gmail-push §6: serialize reconciliation per mailbox via a lease (the inbound analogue of the outbound delivery lease, sending.md §3a) so push-triggered and swept reconciliation don't do redundant work; different mailboxes stay concurrent. Co-Authored-By: Claude Opus 4.8 --- specs/mail/gmail-push.md | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/specs/mail/gmail-push.md b/specs/mail/gmail-push.md index 4410188..f8df29b 100644 --- a/specs/mail/gmail-push.md +++ b/specs/mail/gmail-push.md @@ -65,8 +65,20 @@ Required checks, all of them (a failure of any is a uniform rejection): This surface is materially costlier than the pixel: a single accepted POST can trigger Gmail API fetches, blob writes, and DB writes. So it does **no heavy work inline** — it -authenticates, records the notification (a durable "history advanced for mailbox X" marker), -acks Pub/Sub with a fast 2xx, and lets the reconciliation step (§3) do the fetching. +authenticates, records the notification, acks Pub/Sub with a fast 2xx, and lets the +reconciliation step (§3) do the fetching. + +**Recording the notification is a durable enqueue, not an in-process continuation.** The +endpoint **enqueues a "reconcile mailbox X" job onto the `QueueProvider`** +(`src/providers/queue.ts`; Vercel Queues per charter §4), then acks. A `QueueProvider` +consumer runs §3. This is deliberate, and it is the near-real-time path — the §6 daily sweep +is the 24h-bounded *fallback*, not the primary trigger — so the hand-off must not rely on a +`waitUntil`/after-response continuation, which a serverless runtime does not guarantee to +execute: a dropped continuation would silently degrade push to "eventually caught by the +sweep" with no signal, whereas a durable queue job cannot vanish that way. It also keeps the +"no heavy work inline" property intact — the endpoint only enqueues and acks; the consumer +does the fetching. + Returning 2xx quickly also prevents Pub/Sub's own redelivery from amplifying load; a non-2xx tells Pub/Sub to redeliver, which idempotency (§4, inbound-ingestion.md §4) makes safe but which we don't want to invite needlessly. @@ -148,6 +160,13 @@ here the cursor itself is unrecoverable.) never doubled (inbound-ingestion.md §4). (Cadence is a tuning knob: daily bounds worst-case staleness to ~24h for a dropped tail notification; a tighter interval trades quota for freshness and can be revisited without changing the design.) +- **Serialize reconciliation per mailbox.** Push-triggered reconciliation (§2–§3) and this + sweep both advance the same mailbox's cursor, so a mailbox's reconciliation runs are + serialized by a **reconciliation lease** (the inbound analogue of the outbound delivery + lease, sending.md §3a); different mailboxes still reconcile concurrently. This is an + efficiency guard, not a correctness one — §4 already makes each run's cursor advance + independently safe — it only avoids redundant `history.list`/`messages.get` work when a + push lands mid-sweep. - On `watch()` failure (revoked/expired grant, admin change): mark the mailbox **needs-reconnect** and surface it — never crash the cron for other mailboxes (OAuth handling, HT-38/HT-40). @@ -169,12 +188,12 @@ here the cursor itself is unrecoverable.) Against a **faked** Gmail API + Pub/Sub push (no cloud): - A push with a valid OIDC JWT (correct `aud`, service-account `email`, `email_verified`, - and matching `subscription`) → mailbox resolved from `emailAddress` → `history.list` → - `messages.get?format=raw` → the raw bytes reach the ingest pipeline with correct - `{ mailboxId, providerMessageId, receivedAt }`. + and matching `subscription`) → mailbox resolved from `emailAddress` → a reconcile job + enqueued → the consumer runs `history.list` → `messages.get?format=raw` → the raw bytes + reach the ingest pipeline with correct `{ mailboxId, providerMessageId, receivedAt }`. - A forged / wrong-`aud` / wrong-service-account / `email_verified:false` / expired JWT, or a notification whose `subscription` isn't ours, or whose `emailAddress` resolves to no - known mailbox → rejected, uniform response, no fetch triggered. + known mailbox → rejected, uniform response, nothing enqueued, no fetch triggered. - A duplicate push (same `historyId`) → no duplicate ingestion (dedup, inbound-ingestion.md §4). - A mid-batch failure → the cursor does not advance past the unstored message. - A 404 on `history.list` → the mailbox is paused and flagged, no resync attempted.