Skip to content

feat(mail): Gmail OAuth disconnect admin action (HT-47) - #46

Merged
zaridan merged 3 commits into
mainfrom
feat/ht-47-gmail-oauth-disconnect
Jul 16, 2026
Merged

feat(mail): Gmail OAuth disconnect admin action (HT-47)#46
zaridan merged 3 commits into
mainfrom
feat/ht-47-gmail-oauth-disconnect

Conversation

@zaridan

@zaridan zaridan commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Implemented HT-47: the Gmail OAuth disconnect admin action, the inverse of HT-40's connect flow. Added POST /api/v1/inbound/gmail/disconnect (Bearer-gated, an ordinary route with no pre-auth carve-out) that: (1) revokes the mailbox's stored refresh token at Google's RFC 7009 revoke endpoint via a new revokeToken helper; (2) calls the new GmailWatchClient.stop() (users.stop) to unarm the Gmail push watch, added to src/providers/adapters/gmail/watch.ts mirroring its existing watch/getProfile style; (3) deactivates the mailbox locally by marking it 'disconnected' (a new 4th lifecycle status added via migration 017, which drops/re-adds mailboxes_status_check) and deleting its mailbox_oauth_tokens and gmail_watch_state rows in one DB transaction. The orchestration lives in a new src/mail/gmail-disconnect.ts (GmailDisconnectService), composed the same way gmail-connect.ts is: injected createWatchClient, injected fetchImpl, typed GmailDisconnectError. The new route is wired through src/api/gmail-disconnect.ts (JSON body {address} → JSON result), src/api/router.ts (new GMAIL_DISCONNECT route), src/api/index.ts (new optional gmailDisconnect dep, absent-by-default 404s), and src/composition/root.ts (constructs and wires the real service). Store layer gained MailboxStore.markDisconnected, MailboxTokenStore.deleteTokens, and GmailWatchStateStore.deleteState, all accepting an optional tx for the atomic disconnect transaction, matching the existing upsertConnectedMailbox/upsertTokens/seedBaseline tx pattern. Added a disconnect section (§8) to specs/mail/gmail-connect.md documenting the route, the best-effort ordering decision, the disconnected status, and idempotency. Tests cover: revoke/stop adapter behavior (fake fetch, no real network), the full service (real PGlite stores, faked Google) for happy path, idempotent repeat, revoke failure, stop failure, both failing, paused/needs_reconnect mailboxes, no-stored-tokens edge case, and never-leaks-the-token; API handler tests for body validation and error-code mapping; router tests for the new route; wiring tests in src/api/index.test.ts and src/composition/root.test.ts proving Bearer-gating and dep-absence 404s end-to-end.

Design decisions

Key decisions, all recorded in the module doc of src/mail/gmail-disconnect.ts and in specs/mail/gmail-connect.md §8:

  1. Default status-preservation choice taken as specified — keep the mailboxes row, add a 'disconnected' status via migration 017, delete token/watch-state rows.
  2. Mailbox identification for the disconnect route: chose request-body {"address": "..."} rather than an internal mailboxId, mirroring how MailboxStore.getMailboxByAddress is already the resolution key elsewhere (the push webhook) and how an operator would actually name a mailbox — this wasn't explicitly specified in the ticket brief so I made this call and documented it in the spec and route doc comments.
  3. Best-effort ordering: stop() runs BEFORE revoke (not after), because revoking the refresh token can invalidate access tokens issued under that grant, which would make a post-revoke stop() call fail against an already-dead token.
  4. Best-effort semantics: revoke and stop failures are caught, recorded on the response (revoked/watchStopped booleans), and never abort the local deactivation transaction — local state always wins, per the ticket's explicit lean. This makes the disconnect response always 2xx once the mailbox is found (except a genuine DB failure), which I judged correct: the ticket calls for local deactivation to be authoritative, so a "partial success" is still a success from the local system's point of view; the response body's revoked/watchStopped flags let an operator know a manual Google-side follow-up may be needed.
  5. Idempotency: an already-disconnected mailbox short-circuits before any remote call is attempted (its tokens are already gone, so there's nothing meaningful to revoke/stop) and returns alreadyDisconnected: true with revoked/watchStopped both false.
  6. A mailbox with no stored tokens at all (an edge case, e.g. a row created without ever completing OAuth) skips both remote calls and still deactivates locally, rather than attempting a call guaranteed to fail.
  7. Migration id 17 used exactly as assigned; existing migrations 1-13 unmodified, ids 14-16 deliberately left as gaps for concurrent sibling branches per the orchestrator's stated merge process.

Review

0 adversarial findings raised, 0 actionable, all addressed.

Verification

Independent gate: typecheck 0, lint 0, tests 0 (exit codes), clean tree.

Plus implementer evidence:

  • npm run typecheck (tsc --noEmit -p tsconfig.json): exit 0, no errors — confirmed clean on the final state of the tree.
  • npm run lint (biome check .): exit 0, "Checked 183 files ... No fixes applied" — confirmed clean.
  • Targeted test runs, each executed standalone (not under concurrent load) and each fully green:
    • npx vitest run src/db/migrate.test.ts → 21/21 passed.
    • npx vitest run src/providers/adapters/gmail/watch.test.ts src/store/mailboxes.test.ts src/store/mailbox-tokens.test.ts src/store/gmail-watch-state.test.ts → 74/74 passed (pre-new-tests baseline) then re-run after adding markDisconnected/deleteTokens/deleteState coverage → 66/66 passed for the three store files.
    • npx vitest run src/mail/gmail-disconnect.test.ts → 17/17 passed.
    • npx vitest run src/api/gmail-disconnect.test.ts → 9/9 passed.
    • npx vitest run src/api/router.test.ts → 17/17 passed.
    • npx vitest run src/api/index.test.ts (run standalone, no other vitest process competing) → 101/101 passed, 408s.
    • npx vitest run src/composition/root.test.ts (run standalone) → 9/9 passed, 7.9s — this is the true signal for that file; an earlier attempt run CONCURRENTLY with the index.test.ts background job produced 6 spurious failures, every one a beforeEach PGlite-construction hook timeout (10s) under CPU contention, not a real regression — confirmed by the clean standalone re-run.
    • I then started npm test (the full suite) in the background to get one final combined signal; it did not finish inside this session — ps aux showed the shared machine was simultaneously running full vitest suites for at least two OTHER worktrees (feat-ht-46-attachment-blob-persistence, fix-ht-45-stuck-received-reclaim) at the same time, which is why every heavy PGlite-based run here was slow/contended. The partial log before I had to stop showed one failure, in src/mail/send.test.ts ("ON: the html body ... carries a pixel whose token verifies and binds THIS thread") — a file this ticket never touches (open-tracking pixel/send path, unrelated to Gmail OAuth disconnect); given the demonstrated contention-induced flakiness pattern on this machine, I cannot confirm from this run alone whether that is a genuine pre-existing flake or contention noise, and I did not get a clean full-suite result before being required to conclude. Every file this ticket actually changed has been verified individually and passes; I was not able to complete one uncontended full-repo npm test pass in the time available and am reporting that honestly rather than claiming a result I didn't observe.

Link https://resonantiq.atlassian.net/browse/HT-47

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a Gmail admin disconnect endpoint (POST /api/v1/inbound/gmail/disconnect) to stop watch notifications, revoke OAuth refresh tokens, and remove local connection data by email address.
    • Introduced the new terminal mailbox lifecycle status: disconnected.
    • Extended routing and app wiring to expose the new endpoint.
  • Bug Fixes

    • Made disconnect idempotent (already-disconnected is a safe no-op for remote actions, with local cleanup still applied).
    • Prevented disconnected mailboxes’ token data from being resurrected during concurrent OAuth refreshes.
    • Improved validation, status-code mapping, and ensured sensitive token values aren’t leaked.

@coderabbitai

coderabbitai Bot commented Jul 16, 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: 10fc1a1b-00f2-4b4d-841b-51059564dd5a

📥 Commits

Reviewing files that changed from the base of the PR and between 1594e42 and cc4a063.

📒 Files selected for processing (7)
  • specs/mail/gmail-connect.md
  • src/mail/gmail-disconnect.test.ts
  • src/mail/gmail-disconnect.ts
  • src/mail/gmail-oauth.ts
  • src/store/mailbox-tokens.test.ts
  • src/store/mailbox-tokens.ts
  • src/store/mailboxes.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/store/mailboxes.test.ts
  • src/mail/gmail-disconnect.ts
  • specs/mail/gmail-connect.md
  • src/mail/gmail-disconnect.test.ts

📝 Walkthrough

Walkthrough

Adds an authenticated Gmail disconnect endpoint and service that stops watches, revokes OAuth tokens, and performs transactional local cleanup. Introduces the disconnected mailbox status, cleanup store operations, Gmail watch stopping, application wiring, refresh-race protection, and comprehensive tests.

Changes

Gmail Disconnect

Layer / File(s) Summary
Mailbox lifecycle and cleanup contracts
src/db/migrate.ts, src/store/mailboxes.ts, src/store/mailbox-tokens.ts, src/store/gmail-watch-state.ts, specs/mail/gmail-connect.md
Adds the disconnected status, migration 17, transactional mailbox deactivation, guarded token persistence, and idempotent deletion of OAuth tokens and watch state.
Remote disconnect orchestration
src/mail/gmail-disconnect.ts, src/providers/adapters/gmail/watch.ts, src/mail/gmail-oauth.ts, src/mail/gmail-disconnect.test.ts
Stops Gmail watches before revoking refresh tokens, records best-effort outcomes, prevents token leakage and token resurrection, and always performs local cleanup.
Authenticated disconnect API
src/api/router.ts, src/api/gmail-disconnect.ts, src/api/index.ts, src/composition/root.ts
Routes authenticated POST /api/v1/inbound/gmail/disconnect requests, validates addresses, maps errors, and wires the service into the application.
Integration and compatibility coverage
src/api/*test.ts, src/composition/root.test.ts, src/db/migrate.test.ts, src/store/*test.ts, src/mail/*test.ts
Covers routing, authorization, idempotency, database cleanup, migration registration, remote failure handling, disconnected mailbox rejection, and updated store/client fakes.

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

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant InboxAPI
  participant GmailDisconnectService
  participant GmailWatchClient
  participant Google
  participant Database
  Operator->>InboxAPI: POST disconnect with address
  InboxAPI->>GmailDisconnectService: disconnect(address)
  GmailDisconnectService->>GmailWatchClient: stop watch
  GmailWatchClient->>Google: POST users.stop
  GmailDisconnectService->>Google: revoke refresh token
  GmailDisconnectService->>Database: mark disconnected and delete credentials/watch state
  GmailDisconnectService-->>InboxAPI: disconnect result
  InboxAPI-->>Operator: JSON response
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: adding the Gmail OAuth disconnect admin action for HT-47.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-47-gmail-oauth-disconnect

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-disconnect.ts`:
- Around line 204-254: Serialize the lifecycle transition in disconnect around
the visible disconnect flow, including mailbox/token reads, network cleanup, and
the final transaction, using the same mailbox-level lock or atomic lifecycle
claim used by connect. Ensure concurrent reconnects cannot have fresh tokens or
watch state deleted, and ensure disconnect cannot be overwritten by a reconnect;
add a test covering both commit interleavings.
- Around line 103-114: Remove response-body extraction and concatenation from
the non-OK branch of revokeToken, so the thrown error includes only the status
and statusText. Add a fixture with a token-containing error body and verify that
neither the resulting exception nor logs expose the token, preserving the
module’s token-secrecy guarantee.

In `@src/store/mailboxes.test.ts`:
- Around line 247-259: The markDisconnected transaction test must verify
rollback participation rather than only the committed status. Update the test
around store.markDisconnected to throw after invoking it within the supplied
transaction, assert the transaction rejects, then query the mailbox and confirm
its status remains needs_reconnect.
🪄 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: 1e7768c3-bc82-40ee-a6b1-43899680cf61

📥 Commits

Reviewing files that changed from the base of the PR and between f69ba48 and a83f2a5.

📒 Files selected for processing (25)
  • specs/mail/gmail-connect.md
  • src/api/gmail-disconnect.test.ts
  • src/api/gmail-disconnect.ts
  • src/api/gmail-webhook.test.ts
  • src/api/index.test.ts
  • src/api/index.ts
  • src/api/router.test.ts
  • src/api/router.ts
  • src/composition/root.test.ts
  • src/composition/root.ts
  • src/db/migrate.test.ts
  • src/db/migrate.ts
  • src/mail/gmail-connect.test.ts
  • src/mail/gmail-disconnect.test.ts
  • src/mail/gmail-disconnect.ts
  • src/mail/gmail-reconcile.test.ts
  • src/mail/gmail-watch-maintenance.test.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/mailbox-tokens.test.ts
  • src/store/mailbox-tokens.ts
  • src/store/mailboxes.test.ts
  • src/store/mailboxes.ts

Comment thread src/mail/gmail-disconnect.ts Outdated
Comment thread src/mail/gmail-disconnect.ts
Comment thread src/store/mailboxes.test.ts

@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: 1

🤖 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-oauth.ts`:
- Around line 345-353: Make the token persistence in the mailbox token-update
flow atomic with the mailbox status check: replace the separate
getMailboxById/upsertTokens sequence with an existing transaction, mailbox-row
lock, or conditional write that only persists tokens when the mailbox is not
disconnected. Add a barrier test that pauses after the status read, performs the
disconnect, resumes persistence, and verifies no token row remains.
🪄 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: aa6e60c7-d9dc-430e-9f64-8165bd7681f1

📥 Commits

Reviewing files that changed from the base of the PR and between a83f2a5 and 1594e42.

📒 Files selected for processing (9)
  • specs/mail/gmail-connect.md
  • src/api/gmail-webhook.test.ts
  • src/mail/gmail-disconnect.test.ts
  • src/mail/gmail-disconnect.ts
  • src/mail/gmail-oauth.test.ts
  • src/mail/gmail-oauth.ts
  • src/mail/gmail-reconcile.test.ts
  • src/store/mailboxes.test.ts
  • src/store/mailboxes.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/mail/gmail-reconcile.test.ts
  • src/mail/gmail-disconnect.ts
  • src/mail/gmail-disconnect.test.ts

Comment thread src/mail/gmail-oauth.ts Outdated
@zaridan

zaridan commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Adversarial review + re-gate (orchestrator)

Independent gate (re-run from a clean git status, this session): npm run typecheck exit 0, npm run lint (biome) exit 0, targeted test files covering every path touched by the disconnect flow and the HT-47 re-gate commit all pass (0 failures) once run clear of this machine's concurrent-worktree CPU contention — first pass showed 8 spurious PGlite setup timeouts under load from two other worktrees' test runs; a clean re-run with a longer per-test timeout was 107/107 green. Working tree is clean (no uncommitted changes).

Adversarial review of record: this PR carries two CodeRabbit review passes, not one. Flagging the actual count rather than the briefed one, since they differ:

  • Pass 1 (pre-fix): 3 actionable findings.
  • Pass 2 (post-fix, triggered by commit 1594e42): 1 additional actionable finding.
  • Total: 4 actionable findings, of which CodeRabbit's own re-review marks 1 as resolved and 3 as still open.
# Finding Status
1 gmail-disconnect.ts revokeToken: non-OK branch includes Google's raw response-body snippet in the thrown error/log; if Google's error body ever reflects the submitted refresh token, it leaks into logs, violating the module's token-secrecy guarantee. Open — not touched by commit 1594e42. MAX_ERROR_BODY_CHARS bounds the snippet's length but doesn't redact a reflected token.
2 gmail-disconnect.ts disconnect flow: mailbox/tokens are read and two network calls made before the final transactional mutation, with no mailbox-level lock — a concurrent reconnect (or refresh) can interleave and leave the mailbox in the wrong state. Resolved, per CodeRabbit's own re-review annotation ("✅ Addressed in commit 1594e42"). Fixed via a different mechanism than the suggested lock: gmail-oauth.ts's refresh() now re-checks mailbox status immediately before persisting a token and skips the write if disconnected; gmail-disconnect.ts's already-disconnected idempotent path now re-runs the transactional deletes so any resurrected row is cleaned up on retry; mailboxes.ts's markNeedsReconnect/markPaused now guard with AND status <> 'disconnected'.
3 mailboxes.test.ts markDisconnected tx test only asserts the committed status — it would pass even if markDisconnected ignored the supplied tx and wrote through the base db handle, so it doesn't actually prove transaction participation. Open — no rollback-forcing test was added; the existing test is unchanged in this regard.
4 (New in pass 2) gmail-oauth.ts refresh()'s own fix still has a gap: a disconnect can commit in the window between the new getMailboxById status check and the upsertTokens write, resurrecting a token row with no barrier/lock preventing it. Open — this is CodeRabbit assessing the pass-1 mitigation itself, flagging that the narrowed race window wasn't fully closed, only shrunk (a tradeoff the commit's own doc comments acknowledge and defer to a disconnect retry to clean up).

Net: 1 of 4 actionable findings is resolved; 3 remain open, including a security finding (#1, possible token leakage into logs) and the residual race CodeRabbit itself flagged after reviewing the fix (#4). I did not apply further fixes beyond re-confirming the gate — this comment is reporting status, not closing out the remaining findings. Recommend a human call on whether #1/#3/#4 block merge or are accepted as follow-up (#3 in particular is a five-minute test change with a ready-made diff in CodeRabbit's own comment).

🤖 Generated with Claude Code

zaridan added a commit that referenced this pull request Jul 16, 2026
… SQL-fenced refresh guard, rollback test

Addresses the three open CodeRabbit findings on PR #46:

- revokeToken no longer reads a non-2xx response body into the thrown
  error at all (the endpoint can echo the submitted refresh token back;
  a length cap bounds, it does not redact). Error is the status line
  only. Fixture added where the error body echoes the token, asserting
  neither the exception nor the disconnect failure logs contain it.
- The refresh-vs-disconnect resurrection race is now closed at the SQL
  layer instead of narrowed in JS: new
  MailboxTokenStore.upsertTokensUnlessDisconnected runs the status
  check and token write as ONE guarded INSERT..SELECT..FOR UPDATE..ON
  CONFLICT statement (zero rows = fence held, nothing written), and the
  disconnect transaction now flips the mailbox status FIRST so both
  sides serialize on the mailboxes row lock — same fence discipline as
  the outbound queue's attempts-generation fence (PR #44).
- markDisconnected's tx test now proves rollback PARTICIPATION: a
  forced transaction rollback takes the status write with it.

specs/mail/gmail-connect.md §8 updated to match (§8b step 3 flip-first
ordering, §8d race now closed rather than narrowed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zaridan

zaridan commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Review round 2 fixes (orchestrator follow-up)

Commit cc4a063 addresses the three open CodeRabbit findings:

  • revokeToken token reflection (security): the non-2xx response body is no longer read into the thrown error at all — structural redaction, not a length bound; the error is the HTTP status line only. Added a fixture where the revoke error body echoes the submitted refresh token and asserted neither the exception nor the disconnect failure logs contain it (the service-level log assertion now renders Error args properly instead of JSON.stringify, which hid messages).
  • refresh-vs-disconnect race (non-atomic read-then-write): closed at the SQL layer rather than narrowed in JS. New MailboxTokenStore.upsertTokensUnlessDisconnected runs the status check and token write as ONE guarded INSERT..SELECT..FOR UPDATE..ON CONFLICT statement (zero rows = fence held, nothing written), and the disconnect transaction now flips the mailbox status FIRST so both sides serialize on the mailboxes row lock — same fence discipline as PR fix(queue): fence stale outcome writes with attempts-generation (HT-43) #44's attempts-generation fence. refresh()'s getMailboxById re-check is gone. Spec §8b/§8d updated to match.
  • markDisconnected rollback-participation test: added — a forced transaction rollback is asserted to take the status write with it (status stays needs_reconnect), alongside the existing commit-path test.

Gate (this session, worktree clean at cc4a063): npm run typecheck exit 0, npm run lint exit 0, full npm test exit 0 (43 files, 812 tests, 0 failures).

🤖 Generated with Claude Code

zaridan and others added 3 commits July 16, 2026 15:58
Adds the inverse of HT-40's connect flow: POST /api/v1/inbound/gmail/disconnect
(Bearer-gated, ordinary route) revokes the mailbox's OAuth grant at Google
(RFC 7009 revoke), stops its Gmail push watch (users.stop), and deactivates
it locally. Migration 017 widens mailboxes.status to add 'disconnected';
disconnect deletes the mailbox_oauth_tokens and gmail_watch_state rows in
one transaction. Revoke and stop are best-effort (local deactivation always
wins per the ticket's explicit ordering decision); disconnecting an
already-disconnected mailbox is an idempotent no-op, an unknown address 404s.

Adds disconnect coverage to specs/mail/gmail-connect.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ards

Address code review findings on the Gmail OAuth disconnect action:

- gmail-oauth.ts's refresh() now re-checks the mailbox's current status
  before persisting a refreshed token, and skips the write when the
  mailbox is disconnected — closing the race where a concurrent refresh
  in flight during a disconnect could resurrect a token row for a
  mailbox an operator just took offline.
- gmail-disconnect.ts's already-disconnected idempotent path now still
  re-runs the step-3 transactional deletes instead of returning a bare
  no-op, so any resurrected row left by the residual race is cleaned up
  on a retried disconnect call.
- mailboxes.ts's markNeedsReconnect/markPaused now guard with
  `AND status <> 'disconnected'` so an in-flight pipeline failure can no
  longer silently overwrite an operator's explicit disconnect; a
  guarded, existing row is a silent no-op, a genuinely missing row still
  throws.
- Added regression tests pinning that a disconnected mailbox is rejected
  by the push webhook and skipped by the reconcile handler, mirroring
  the existing paused-mailbox cases.

Updated specs/mail/gmail-connect.md §8d/§8e to describe the revised
idempotent-disconnect behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… SQL-fenced refresh guard, rollback test

Addresses the three open CodeRabbit findings on PR #46:

- revokeToken no longer reads a non-2xx response body into the thrown
  error at all (the endpoint can echo the submitted refresh token back;
  a length cap bounds, it does not redact). Error is the status line
  only. Fixture added where the error body echoes the token, asserting
  neither the exception nor the disconnect failure logs contain it.
- The refresh-vs-disconnect resurrection race is now closed at the SQL
  layer instead of narrowed in JS: new
  MailboxTokenStore.upsertTokensUnlessDisconnected runs the status
  check and token write as ONE guarded INSERT..SELECT..FOR UPDATE..ON
  CONFLICT statement (zero rows = fence held, nothing written), and the
  disconnect transaction now flips the mailbox status FIRST so both
  sides serialize on the mailboxes row lock — same fence discipline as
  the outbound queue's attempts-generation fence (PR #44).
- markDisconnected's tx test now proves rollback PARTICIPATION: a
  forced transaction rollback takes the status write with it.

specs/mail/gmail-connect.md §8 updated to match (§8b step 3 flip-first
ordering, §8d race now closed rather than narrowed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zaridan
zaridan force-pushed the feat/ht-47-gmail-oauth-disconnect branch from cc4a063 to a7c2bdc Compare July 16, 2026 23:02
@zaridan
zaridan merged commit 6ff27fb into main Jul 16, 2026
4 checks passed
@zaridan
zaridan deleted the feat/ht-47-gmail-oauth-disconnect branch August 2, 2026 19:19
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