Skip to content

feat(inbound): Gmail history sync + raw message fetch (HT-41) - #40

Merged
zaridan merged 1 commit into
mainfrom
feat/ht-41-gmail-history-sync
Jul 14, 2026
Merged

feat(inbound): Gmail history sync + raw message fetch (HT-41)#40
zaridan merged 1 commit into
mainfrom
feat/ht-41-gmail-history-sync

Conversation

@zaridan

@zaridan zaridan commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Implements HT-41 [H] — Gmail history sync + raw message fetch, under the HT-33 epic. This is the consumer of the reconcile job HT-39 enqueues: it turns a mailbox's stored cursor into the raw RFC822 bytes of everything added since, and feeds them to the HT-37 ingest pipeline. No new spec — gmail-push.md §3–§5 (written in HT-34) is the contract.

What's here

  • src/mail/gmail-reconcile.tscreateGmailReconcileHandler: the QueueMessageHandler<GmailReconcileJob>. Re-checks mailbox status → acquires a token → reads the stored cursor (never the notification's historyId) → history.listmessages.get?format=raw each id → ingest → advances the cursor.
  • src/providers/adapters/gmail/history.tscreateGmailHistoryClient: listAddedMessageIds (paginated, id-deduped, 404→{kind:'expired'}) and getRawMessage (base64url→bytes, 404→null). Mirrors sender.ts (injectable fetch, AbortSignal.timeout, token never logged, throw-on-unexpected-non-2xx).
  • src/store/gmail-watch-state.tscreateGmailWatchStateStore: the per-mailbox history_id cursor. setCursor upserts (the baseline row is normally seeded by watch(), HT-42).
  • src/store/mailboxes.ts — adds getMailboxById + markPaused (the 404-expired transition).

Sacred boundaries (charter §2) — held

  • Parse exactly once, NOT here. The transport moves raw bytes only — zero MIME parsing, zero attachment extraction. messages.get?format=rawBuffer.from(raw, 'base64url') → handed off untouched to the pipeline's single parseInboundEmail. (This is why the Jira ticket's stale "attachments to blob store" line was not implemented — attachments are the pipeline's job, deferred to HT-46; doing them here would force a second parser and break parse-once.)
  • Never drop a message. The cursor advances to the new watermark only when every message in the batch reached a terminal, durably-ledgered outcome; any failed/in-progress blocks the advance and the whole batch retries (dedup makes re-fetch free). Worst case is redundant work, never a skipped message.

⚠️ Two calls flagged for your explicit review

  1. dead-letter advances the cursor — gmail-push.md §4's prose names only stored/suppressed, but the ledger also has a terminal dead-letter state. Blocking the cursor on it would wedge the mailbox forever on one poison message (and starve every healthy message behind it in history order). Since dead-letter is durably recorded (never-drop holds), the handler treats it as cursor-advancing too. Documented at length in gmail-reconcile.ts's module doc. This extends the spec's literal wording — want a spec footnote, or is the reasoning sound as-is?
  2. DEFAULT_MAX_INLINE_RAW_BYTES = 1 MB — not spec'd. Raw messages ≤1 MB go to ingest inline; larger ones are written to the BlobStore first (mailbox-namespaced key inbound/raw/{mailboxId}/{messageId}) and handed over as a blobRef — the ticket's "OOM guard," applied to the raw message (not attachments), which is exactly what RawMessageContent.blobRef was designed for. Injected/retunable.

Review notes (mine)

I read the correctness-critical surface end-to-end rather than trusting the implementer's summary. One real defect found and fixed:

  • Layering violation — the handler originally defaulted createHistoryClient to the concrete createGmailHistoryClient, which meant engine core did a runtime import of an adapter — against src/providers/README.md's rule and contrary to HT-39's own verifySignature (required, no default). Fixed: createHistoryClient is now a required injected dependency, the concrete client wired at the composition root (HT-43); only the interface type is imported (type-only, erased at runtime).

Verified: cursor never advances past unpersisted mail; 404→pause; token never in a log line or thrown error; base64url decode matches sender.ts's encode side. Gates: typecheck + biome clean, 582/582 tests pass locally.

Scope

Handler + client + cursor store only. Out: watch() arm/renewal + the initial baseline cursor (HT-42), the daily reconciliation sweep + per-mailbox lease (HT-42), and the composition-root queue-consumer HTTP wiring (HT-43). Acceptance is against a faked Gmail API per gmail-push.md §8.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added Gmail history reconciliation driven by push notifications, including ordered ingestion of newly added messages.
    • Introduced a Gmail history/raw message client with pagination, de-duplication, and “expired cursor” handling.
    • Added persistent per-mailbox Gmail watch-state tracking to resume safely.
    • Supports large messages via external storage, keeping smaller messages inline.
  • Bug Fixes
    • Pauses mailboxes when Gmail history cursors expire and safely advances or retries based on ingest outcomes.
    • Skips messages missing at fetch time without retry and prevents duplicate notifications from blocking sync.
    • Ensures access tokens are not included in error output.
  • Tests
    • Added/expanded comprehensive unit and integration-style test coverage for the new Gmail reconciliation flow and stores.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 54578d0e-2937-494b-8786-08d0c2a20339

📥 Commits

Reviewing files that changed from the base of the PR and between 51a12ca and 9b27451.

📒 Files selected for processing (12)
  • src/api/gmail-webhook.test.ts
  • src/api/index.test.ts
  • src/mail/gmail-reconcile.test.ts
  • src/mail/gmail-reconcile.ts
  • src/providers/adapters/gmail/history.test.ts
  • src/providers/adapters/gmail/history.ts
  • src/providers/adapters/gmail/index.ts
  • src/store/gmail-watch-state.test.ts
  • src/store/gmail-watch-state.ts
  • src/store/index.ts
  • src/store/mailboxes.test.ts
  • src/store/mailboxes.ts
🚧 Files skipped from review as they are similar to previous changes (9)
  • src/store/index.ts
  • src/store/gmail-watch-state.test.ts
  • src/store/gmail-watch-state.ts
  • src/providers/adapters/gmail/history.test.ts
  • src/providers/adapters/gmail/index.ts
  • src/mail/gmail-reconcile.test.ts
  • src/api/gmail-webhook.test.ts
  • src/mail/gmail-reconcile.ts
  • src/api/index.test.ts

📝 Walkthrough

Walkthrough

Changes

Adds Gmail history and raw-message retrieval, mailbox watch-state persistence, and a queue reconciliation handler. The flow handles pagination, cursor expiry, deleted messages, token failures, retries, terminal ingest outcomes, oversized payload storage, and mailbox lifecycle transitions.

Gmail reconciliation flow

Layer / File(s) Summary
Mailbox and watch-state persistence
src/store/mailboxes.ts, src/store/gmail-watch-state.ts, src/store/index.ts, src/store/*.test.ts
Adds mailbox lookup, pause transitions, and per-mailbox Gmail cursor persistence with upsert behavior and tests.
Gmail history and raw-message client
src/providers/adapters/gmail/history.ts, src/providers/adapters/gmail/index.ts, src/providers/adapters/gmail/history.test.ts
Adds authenticated paginated history listing, raw-message retrieval, base64url decoding, expiry/deletion handling, timeout behavior, and error sanitization.
Queue reconciliation orchestration
src/mail/gmail-reconcile.ts, src/mail/gmail-reconcile.test.ts
Adds mailbox validation, token handling, ordered ingestion, inline/blob content selection, cursor advancement rules, structured logging, and outcome coverage.
Webhook test contract alignment
src/api/gmail-webhook.test.ts, src/api/index.test.ts
Updates mailbox-store fakes with the methods required by the push-webhook path.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Queue
  participant ReconcileHandler
  participant MailboxStore
  participant GmailHistoryClient
  participant Ingest
  participant GmailWatchStateStore
  Queue->>ReconcileHandler: Deliver GmailReconcileJob
  ReconcileHandler->>MailboxStore: getMailboxById
  ReconcileHandler->>GmailHistoryClient: listAddedMessageIds
  GmailHistoryClient-->>ReconcileHandler: Added IDs and newHistoryId
  ReconcileHandler->>GmailHistoryClient: getRawMessage
  ReconcileHandler->>Ingest: Ingest message content
  ReconcileHandler->>GmailWatchStateStore: setCursor
  ReconcileHandler-->>Queue: Return ack or retry
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: Gmail history sync and raw message fetching for inbound processing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-41-gmail-history-sync

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/mail/gmail-reconcile.test.ts`:
- Around line 634-646: Remove the test named “the default createHistoryClient
wires the real Gmail history client…” from the Gmail reconciliation tests. Do
not add replacement coverage; the existing paused-mailbox short-circuit test
already covers this behavior, and createHistoryClient is required and supplied
by baseDeps().

In `@src/providers/adapters/gmail/history.ts`:
- Around line 249-266: Validate body.raw as well-formed base64url in the
createGmailHistoryClient response handling before calling Buffer.from, rejecting
malformed values rather than allowing truncated RFC822 data; preserve the
existing missing/empty validation and error context. Add a malformed-base64url
fixture and assertion in src/providers/adapters/gmail/history.test.ts:211-225
covering the rejection behavior.

In `@src/store/gmail-watch-state.ts`:
- Around line 56-62: Make setCursor in src/store/gmail-watch-state.ts accept the
expected current cursor and perform a compare-and-set update, returning whether
the persisted cursor still matched and was advanced. In
src/mail/gmail-reconcile.ts, pass the cursor used for listing and treat a failed
CAS as a stale job. In src/store/gmail-watch-state.test.ts, add an
overlapping-worker test confirming a stale write cannot replace the newer
watermark; preserve existing mail semantics and provide fixture-based
equivalence or explicit justification for any affected behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3385d8cc-21a9-4394-b92b-b3e0f4745295

📥 Commits

Reviewing files that changed from the base of the PR and between eb8cf9c and 51a12ca.

📒 Files selected for processing (12)
  • src/api/gmail-webhook.test.ts
  • src/api/index.test.ts
  • src/mail/gmail-reconcile.test.ts
  • src/mail/gmail-reconcile.ts
  • src/providers/adapters/gmail/history.test.ts
  • src/providers/adapters/gmail/history.ts
  • src/providers/adapters/gmail/index.ts
  • src/store/gmail-watch-state.test.ts
  • src/store/gmail-watch-state.ts
  • src/store/index.ts
  • src/store/mailboxes.test.ts
  • src/store/mailboxes.ts

Comment thread src/mail/gmail-reconcile.test.ts Outdated
Comment thread src/providers/adapters/gmail/history.ts
Comment thread src/store/gmail-watch-state.ts
The consumer of the reconcile job HT-39 enqueues: history.list from the
mailbox's STORED cursor -> messages.get?format=raw -> the HT-37 ingest
pipeline -> transactional cursor advance. Raw bytes only: no MIME parse,
no attachment extraction (charter parse-once boundary). A 404-expired
cursor pauses the mailbox for manual rebaseline; the cursor advances only
when every message is terminally ledgered (stored/suppressed/dead-letter),
never past unpersisted mail.

- src/providers/adapters/gmail/history.ts  history.list + messages.get?format=raw client
- src/store/gmail-watch-state.ts           per-mailbox history cursor (upsert)
- src/mail/gmail-reconcile.ts              QueueMessageHandler<GmailReconcileJob>
- src/store/mailboxes.ts                    getMailboxById + markPaused

createHistoryClient is injected (no default) so engine core never imports a
concrete adapter (src/providers/README.md), matching HT-39's verifySignature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant