Skip to content

feat(inbound): Gmail OAuth connect/consent flow + initial watch arm (HT-40) - #41

Merged
zaridan merged 2 commits into
mainfrom
feat/ht-40-gmail-oauth-connect
Jul 14, 2026
Merged

feat(inbound): Gmail OAuth connect/consent flow + initial watch arm (HT-40)#41
zaridan merged 2 commits into
mainfrom
feat/ht-40-gmail-oauth-connect

Conversation

@zaridan

@zaridan zaridan commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

HT-40 — Gmail OAuth connect/consent flow + initial watch() arm

The write-side that gmail-push.md deliberately stubbed. Everything already on main (HT-34…HT-41) assumes a connected mailbox exists — a mailboxes row, an encrypted refresh token, an armed watch() with a baseline cursor. Nothing created any of those. This PR does: it runs Google's OAuth2 authorization-code flow, persists the refresh token encrypted at rest, arms the initial users.watch(), and seeds the baseline gmail_watch_state cursor HT-41's reconcile consumer reads.

Design decision (signed off before coding)

Consent-UX model was confirmed with the maintainer up front — Option A:

  • POST /api/v1/inbound/gmail/connectBearer-gated, returns { consentUrl } (the client redirects the browser; a top-level nav can't carry the Bearer header, so we don't redirect from a gated GET).
  • GET /api/v1/inbound/gmail/callback?code&statepre-auth carve-out (Google's redirect carries no Bearer token), exactly like the open-tracking pixel and push webhook. Authenticated by an HMAC-signed state off the existing Keyring (same stateless, no-session pattern as reply/view tokens; RFC 6749 §10.12 CSRF defence). Renders minimal text/html.

Spec-first

New sibling spec specs/mail/gmail-connect.md (not a gmail-push.md section — renumbering would break 6 existing §6/§7/§8 cross-refs, and the OAuth grant lifecycle is a distinct concern). gmail-push.md §7 xref updated.

Callback sequence (gmail-connect.md §4) — nothing persisted until the grant is proven

verify state → exchange code (require refresh_token) → getProfile (authoritative address) → watch() → persist (mailbox + encrypted tokens + baseline cursor). A failure before persist leaves no mailbox row / token / armed watch to clean up. Baseline cursor is watch()'s historyId, not getProfile's (a test guards this with a profile-hid-must-not-be-used fixture).

Surface

New Purpose
src/providers/adapters/gmail/watch.ts users.watch + users.getProfile client; mirrors history.ts (injectable fetch, Bearer, AbortSignal.timeout, token never logged)
src/mail/gmail-connect.ts consent URL, state mint/verify, authorization_code exchange, createGmailConnectService
src/api/gmail-connect.ts the two handlers (thin over the service)
MailboxStore.upsertConnectedMailbox idempotent-by-address insert/reactivate (reconnect → active)
GmailWatchStateStore.seedBaseline first writer of watch_expiration + baseline history_id

gmailConnect dep is ABSENT BY DEFAULT (like gmailPush) — a deployment without its Internal OAuth app configured 404s both routes; no real composition-root wiring is added here.

Sacred invariants — verified (not just claimed)

  • Refresh token encrypted at rest — persisted only via MailboxTokenStore.upsertTokens (AES-256-GCM); a test reads the raw refresh_token_ciphertext column and asserts it never contains the plaintext, while getTokens round-trips it back.
  • No secret ever logged/thrown — errors are built from HTTP status + OAuth error/error_description + ids only; the token/code/client_secret never enter a log line or Error (grep-verified across all three new files; the callback tests assert code/state never appear in the rendered HTML).
  • No adapter import in engine core — the watch client is injected (createWatchClient); only its interface type is imported (import type), matching gmail-reconcile.ts.
  • Mail semantics untouched — no change to parse/thread/send.

Judgment calls flagged for review

  1. Callback error page echoes a bounded, escaped upstream-error snippet (for watch_failed/exchange_failed). This is a mild departure from the JSON API's "never render an upstream body" rule, chosen deliberately: it only renders to the operator who legitimately minted the state, it's HTML-escaped + 500-char-bounded, carries no credential material, and showing why connect failed (e.g. redirect_uri_mismatch) is genuinely useful for HT-43/HT-44 setup. Happy to make it generic if preferred.
  2. A getProfile() failure maps to 500, not a typed 4xx (the GmailConnectError enum has no slot for it and the spec's acceptance list doesn't name it). It still persists nothing (it's pre-persist). watch() failure, by contrast, is a typed 4xx.

Corrects two already-merged comments

HT-41's gmail-reconcile.ts and gmail-watch-state.ts both attributed the baseline seed to HT-42. Per gmail-push.md §6 bullet 1 the initial arm+seed is HT-40; HT-42 only renews. Corrected here since this PR makes those comments wrong.

Deferred (unchanged scope boundaries)

watch() renewal + reconciliation lease → HT-42; one-time GCP/Pub-Sub + OAuth-client provisioning → HT-43; real consent + the live end-to-end proof → HT-44 / operator. This PR is exercised entirely against a faked Google (injected fetch) + in-memory stores — no cloud, no real grant.

Gates (re-run on this commit, watched green)

  • npm run typecheck → exit 0
  • npm run lint (biome, 164 files) → exit 0
  • npm run test34 files / 675 tests passed (~90 new)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added Gmail account connection via secure OAuth connect/consent (JSON consent URL) and callback (HTML success/error).
    • Automatically sets up Gmail push notifications during connection, with idempotent reconnect behavior that avoids duplicate mailboxes.
  • Security
    • Uses signed, expiring callback validation and encrypted credential/token storage.
    • Prevents sensitive values from appearing in responses or errors; hardens HTML rendering for user-visible messages.
  • Documentation
    • Added/clarified Gmail connection spec and clarified push transport responsibilities.
  • Tests
    • Added end-to-end and handler-level coverage for success, missing/invalid/expired inputs, and failure paths.

…HT-40)

The write-side that arms the Gmail push transport. Runs Google's OAuth2
authorization-code flow to obtain a mailbox's refresh token, persists it
encrypted at rest, arms the initial users.watch(), and seeds the baseline
gmail_watch_state cursor HT-41's reconcile consumer reads.

Routes (spec: specs/mail/gmail-connect.md):
- POST /api/v1/inbound/gmail/connect (Bearer-gated) -> { consentUrl }
- GET /api/v1/inbound/gmail/callback (pre-auth carve-out, signed-state CSRF
  off the existing Keyring) -> verify state -> exchange code -> getProfile
  -> watch() -> persist (mailbox + encrypted tokens + baseline cursor).
  Nothing is persisted until the grant is proven usable.

New surface:
- specs/mail/gmail-connect.md (sibling spec; gmail-push.md xref updated)
- MailboxStore.upsertConnectedMailbox (idempotent by address; reconnect
  reactivates -> active)
- GmailWatchStateStore.seedBaseline (first writer of watch_expiration)
- src/providers/adapters/gmail/watch.ts (users.watch + users.getProfile,
  mirrors history.ts: injectable fetch, Bearer, AbortSignal.timeout, token
  never logged)
- src/mail/gmail-connect.ts orchestration; src/api/gmail-connect.ts handlers
- gmailConnect dep is ABSENT BY DEFAULT (like gmailPush)

Internal Workspace app, no CASA; scopes gmail.readonly + gmail.send (least
privilege). Real consent + the live end-to-end proof are deferred to HT-44;
this ships against a faked Google + in-memory stores. Corrects two HT-41
comments that misattributed the baseline seed to HT-42 (it is HT-40's, per
gmail-push.md 6).

Refresh token encrypted at rest via MailboxTokenStore (verified by a test
asserting the raw ciphertext column never contains the plaintext); no token
or client_secret appears in any log line or thrown error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0df65d45-70c9-418d-9c84-9304fa84295c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds the Gmail OAuth connect/consent flow, Gmail watch/profile adapter, signed callback state, encrypted token and mailbox persistence, baseline watch-state seeding, HTTP routing, and comprehensive tests.

Changes

Gmail connection flow

Layer / File(s) Summary
Connection contracts and persistence
specs/mail/gmail-connect.md, src/store/mailboxes.ts, src/store/gmail-watch-state.ts, src/mail/gmail-reconcile.ts
Defines OAuth callback ordering and reconnect semantics, adds mailbox upsert and watch-baseline persistence, and updates related contracts and documentation.
Gmail watch and profile adapter
src/providers/adapters/gmail/watch.ts, src/providers/adapters/gmail/index.ts, src/providers/adapters/gmail/watch.test.ts
Adds Gmail watch() and getProfile() calls with fresh access tokens, timeout handling, response validation, and token-safe errors.
OAuth state and connection orchestration
src/mail/gmail-connect.ts, src/mail/gmail-connect.test.ts
Implements consent URL creation, signed state verification, code exchange, profile resolution, watch arming, encrypted token storage, baseline seeding, and reconnect behavior.
HTTP handlers and route wiring
src/api/gmail-connect.ts, src/api/index.ts, src/api/router.ts, src/api/*test.ts
Adds Bearer-gated consent and pre-auth callback routes with JSON/HTML responses, error handling, escaping, and routing tests.

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

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant InboxAPI
  participant GmailConnectService
  participant Google
  participant Stores
  Caller->>InboxAPI: POST /api/v1/inbound/gmail/connect
  InboxAPI->>GmailConnectService: beginConnect()
  GmailConnectService-->>InboxAPI: consentUrl
  InboxAPI-->>Caller: JSON consent URL
  Caller->>Google: authorize and redirect with code/state
  Google->>InboxAPI: GET /api/v1/inbound/gmail/callback
  InboxAPI->>GmailConnectService: completeConnect(code, state)
  GmailConnectService->>Google: exchange code and resolve profile
  Google-->>GmailConnectService: tokens and mailbox profile
  GmailConnectService->>Google: arm watch()
  Google-->>GmailConnectService: historyId and expiration
  GmailConnectService->>Stores: persist mailbox, tokens, and baseline
  InboxAPI-->>Caller: HTML success page
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: the Gmail OAuth connect/consent flow and initial watch arm.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-40-gmail-oauth-connect

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: 4

🤖 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/api/index.test.ts`:
- Around line 1945-1961: The expired-state test restores real timers before
invoking the callback, so the expiration check does not use the simulated time.
In the test “an expired state is rejected: 4xx html, nothing persisted,” keep
fake timers active through the api callback and its assertions, then restore
real timers afterward (or via the test cleanup hook).

In `@src/mail/gmail-connect.ts`:
- Around line 570-585: Make the Step 5 persistence flow atomic by introducing a
transaction-scoped database handle and threading it through
mailboxStore.upsertConnectedMailbox, tokenStore.upsertTokens, and
watchStateStore.seedBaseline. Execute all three operations in one transaction,
committing only when they all succeed and rolling back on any failure, while
preserving the existing values and ordering.

In `@src/store/mailboxes.test.ts`:
- Line 232: Remove the duplicate const rows declarations, retaining only one
declaration per affected block in src/store/mailboxes.test.ts (232-232) and
src/store/gmail-watch-state.test.ts (144-144, 176-176, and 195-195). Keep the
retained db.query result and existing test behavior unchanged.
- Around line 282-296: Update the test around upsertConnectedMailbox to exercise
a provider change: keep the seeded mailbox provider as one valid provider, then
reconnect using a different valid provider and assert the returned mailbox and
database row reflect the new provider. Ensure the assertion verifies the
provider is updated from EXCLUDED.provider rather than remaining unchanged.
🪄 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: 5941d180-e1f8-47e7-b816-ad8c7cd451df

📥 Commits

Reviewing files that changed from the base of the PR and between 320fecf and 98a1798.

📒 Files selected for processing (19)
  • specs/mail/gmail-connect.md
  • specs/mail/gmail-push.md
  • src/api/gmail-connect.test.ts
  • src/api/gmail-connect.ts
  • src/api/gmail-webhook.test.ts
  • src/api/index.test.ts
  • src/api/index.ts
  • src/api/router.ts
  • src/mail/gmail-connect.test.ts
  • src/mail/gmail-connect.ts
  • src/mail/gmail-reconcile.test.ts
  • src/mail/gmail-reconcile.ts
  • src/providers/adapters/gmail/index.ts
  • src/providers/adapters/gmail/watch.test.ts
  • src/providers/adapters/gmail/watch.ts
  • src/store/gmail-watch-state.test.ts
  • src/store/gmail-watch-state.ts
  • src/store/mailboxes.test.ts
  • src/store/mailboxes.ts

Comment thread src/api/index.test.ts
Comment thread src/mail/gmail-connect.ts Outdated
expect(mailbox.status).toBe('active')
expect(typeof mailbox.id).toBe('string')

const rows = await db.query<{ address: string; provider: string; status: string }>(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Remove duplicate const rows declarations.

These repeated declarations are in the same block and cause a TypeScript redeclaration error, preventing the test files from compiling.

  • src/store/mailboxes.test.ts#L232-L232: retain one const rows = await db.query(...) declaration.
  • src/store/gmail-watch-state.test.ts#L144-L144: retain one declaration.
  • src/store/gmail-watch-state.test.ts#L176-L176: retain one declaration.
  • src/store/gmail-watch-state.test.ts#L195-L195: retain one declaration.
📍 Affects 2 files
  • src/store/mailboxes.test.ts#L232-L232 (this comment)
  • src/store/gmail-watch-state.test.ts#L144-L144
  • src/store/gmail-watch-state.test.ts#L176-L176
  • src/store/gmail-watch-state.test.ts#L195-L195
🤖 Prompt for 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.

In `@src/store/mailboxes.test.ts` at line 232, Remove the duplicate const rows
declarations, retaining only one declaration per affected block in
src/store/mailboxes.test.ts (232-232) and src/store/gmail-watch-state.test.ts
(144-144, 176-176, and 195-195). Keep the retained db.query result and existing
test behavior unchanged.

Comment thread src/store/mailboxes.test.ts Outdated
Addresses CodeRabbit review on PR #41.

- Persist the mailbox row, encrypted token, and baseline watch-state seed in
  ONE Db.transaction, so a mid-persist failure rolls back instead of leaving a
  partial connect (an `active` mailbox with no cursor — which would silently
  no-op every push the already-armed watch() delivers, worse than no mailbox).
  The three stores gain an optional trailing Queryable param; the token
  encryption boundary is untouched (encryption still happens in the method;
  only the statement's execution target changes). Adds a rollback test.
- index.test.ts: the expired-state test kept fake timers active only until
  just before the request, so verifyConnectState checked wall-clock time and
  the 10-minute TTL boundary was never exercised. Wrap in try/finally so fake
  time stays active through the callback and is always restored.
- mailboxes.test.ts: the reconnect provider test seeded and reconnected with
  the same provider, so it couldn't prove EXCLUDED.provider does anything.
  Seed a different provider so the update is genuinely exercised.

CodeRabbit's "duplicate const rows" note was a false positive: each is in its
own it() scope, and typecheck is green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@zaridan

zaridan commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review in d0e4b07. Dispositions:

1. Expired-state test used real timers before the request (src/api/index.test.ts) — Fixed. The test restored real timers before the callback, so verifyConnectState checked wall-clock time and the 10-minute TTL boundary was never actually exercised (it only "passed" because the state was minted at a fake 2026-01-01 while the real clock is months later). Now wrapped in try/finally so fake time stays active through the callback + assertions and is always restored.

2. Step-5 persistence not atomic (src/mail/gmail-connect.ts) — Fixed. The mailbox row, encrypted token, and baseline watch-state seed now commit in one Db.transaction. A mid-persist failure rolls back rather than leaving a partial connect (an active mailbox with no cursor — which is worse than no mailbox, since the webhook would enqueue reconcile jobs that find nothing to resume from and silently no-op every push the already-armed watch() delivers). Implemented with an optional trailing Queryable param on upsertConnectedMailbox / upsertTokens / seedBaseline — the token encryption boundary is untouched (encryption still happens inside the method; only the statement's execution target changes). Added a rollback test asserting a failing final write leaves zero mailbox/token rows.

3. Duplicate const rows declarationsSkipped (false positive). Each flagged const rows is in its own separate it() block (mailboxes.test.ts:232 is the only one in its test; gmail-watch-state.test.ts:144/176/195 are three different tests). There's no same-scope redeclaration — npm run typecheck is green, which it wouldn't be if these were real duplicates.

4. Provider-sync test didn't exercise a change (src/store/mailboxes.test.ts) — Fixed. The test seeded and reconnected with the same provider, so it couldn't distinguish "updated from EXCLUDED.provider" from "unchanged." Now seeds a different provider (legacy-imap) and reconnects with gmail, asserting the row reflects the update.

Gates green on the new commit: typecheck ✓, lint ✓ (164 files), test ✓ (34 files / 676 tests).

@zaridan

zaridan commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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