diff --git a/specs/mail/gmail-push.md b/specs/mail/gmail-push.md new file mode 100644 index 0000000..f8df29b --- /dev/null +++ b/specs/mail/gmail-push.md @@ -0,0 +1,206 @@ +# 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, +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) + +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; + **`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 + 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, 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. + +## 3. History reconciliation and raw fetch (HT-41) + +**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 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). +- Hand each message to the ingest pipeline as `{ raw, mailboxId, providerMessageId = + , 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 §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. + +## 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. (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()` 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). +- **`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. +- **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.) +- **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). + +## 7. What this transport does not own + +- **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 + 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 (correct `aud`, service-account `email`, `email_verified`, + 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, 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. +- 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 +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..8a087e9 --- /dev/null +++ b/specs/mail/inbound-ingestion.md @@ -0,0 +1,222 @@ +# 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, 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). 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`). + +> **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, 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. **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. +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. +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 + `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). + - The store write **and** the ledger row's `received → stored` transition (recording the + resulting `conversationId`/`threadId`) commit in **one transaction** — see §4. + +**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 + +**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)` +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** +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 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 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 +> (`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). +- 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.