Skip to content

feat(mail): lead inbox connect with Google OAuth, app password as fallback (HT-123) - #192

Merged
zaridan merged 7 commits into
mainfrom
feat/ht-123-oauth-first-connect
Aug 7, 2026
Merged

feat(mail): lead inbox connect with Google OAuth, app password as fallback (HT-123)#192
zaridan merged 7 commits into
mainfrom
feat/ht-123-oauth-first-connect

Conversation

@zaridan

@zaridan zaridan commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🟢 SAFE TO MERGE

Gates green. No INFERRED rows remain — the three open questions are answered below. CodeRabbit: 3 findings — 2 real and fixed, 1 wrong. Codex, in its place on the current head: 4 findings — 1 real and fixed here, 2 real but pre-existing and filed separately, 1 accepted limit.

Decision provenance

Decision — in plain words Source
Connecting a Gmail/Workspace inbox leads with a "Connect with Google" button; entering an app password becomes a secondary option underneath You, this session: "I thought app password was going to be one of the secondary ways we allowed authentication, since it's old?" and "let's do the OAuth-first connect"
After approving access at Google you land back on the mailbox list, not a separate confirmation page You, 2026-08-07: "4. good"
Typing an address at an unfamiliar domain also offers "Connect with Google Workspace", guessing it may be a Workspace domain You, 2026-08-07: "5. good"
A Microsoft address is told plainly that connecting is not supported yet, rather than shown a button that cannot work You, 2026-08-07: "6. good"

What changed

The engine has had a complete Google OAuth flow since HT-40, but the web app had no client for it — it was unreachable. This connects it and flips the emphasis.

  • Google first. A Gmail/Workspace address gets "Connect with Google" as the primary action; "Use an app password instead" is a collapsed fallback, and you can switch back.
  • App password unchanged for everyone else. Fastmail, Zoho, iCloud, Yahoo and self-hosted keep the existing form as their primary path — they have no OAuth option. Reconnecting an existing inbox is untouched.
  • Consent returns you to the app. The callback redirects to the mailbox list naming the address that connected, carrying only that address or a short error code — never a token, code, or state. When no UI base URL is configured the old page remains, unchanged.
  • Microsoft stays honestly blocked. No button that cannot work. Its connector is HT-124.

Why OAuth first: the grant is scoped to what Helpthread needs and revocable from the Google account. An app password cannot be scoped, does not expire, and Google documents it as legacy.

Review

CodeRabbit: 3 findings — 2 real and fixed, 1 wrong. The redirect-secrecy test asserted less than its title promised (real; it now pins the whole redirect URL, so any unanticipated leak fails by construction). Gmail users who opened the app-password option had no way back to OAuth (real; the Google option is now offered for Google domains too, and no longer appears on an empty address, which was the residual half of the same finding). Its claim that stored and redirected addresses can differ in case was wrong: both sides are the same string resolved from the OAuth grant, and lowercasing is not injective, so the suggested relaxation would let a crafted ?connected= value fold onto a different roster address and show a false confirmation. Reverted, with the reasoning recorded in the code.

Codex (adversarial, in place of CodeRabbit — rate limited on this head, no review object produced): 4 findings — 1 real and fixed here, 3 real but pre-existing and tracked separately.

  • Fixed here. The mailbox-connect server actions never verified the operator session. Next.js dispatches Server Actions by a build-stable action-ID hash invokable against any route, so middleware is not a sufficient gate — actions.ts already documents this and guards everything it exports; this module, holding the same server-only Bearer token, did not. Unguarded, the new beginGoogleConnect would hand a valid consent URL to an unauthenticated caller, whose consent the pre-auth callback would persist as a connected mailbox. All three actions in the module are now guarded, not just the one this branch adds — the other two carry the identical exposure.
  • Pre-existing, not in this PR. The OAuth state token is verified for signature and TTL but is never consumed or bound to the initiating session, so it can be replayed within its window. The callback's HTML fallback interpolates upstream error text (Google's error_description) into a public page — this PR narrows that exposure, since the redirect path it adds carries only a fixed code. Both are filed as their own work rather than folded in here.
  • Accepted limit. A crafted ?connected= link naming an address that is already connected still shows a success toast. The exact-match check proves the mailbox exists, not that a callback occurred; a truly authenticated confirmation needs a single-use server-issued marker. Noted in the code.
  • No defect found in: the redirect target (no open redirect — uiBaseUrl is deployment config and query values go through URLSearchParams), redirect and log leakage on the new path, token storage (AES-256-GCM, fresh 12-byte IV per encryption, validated key length, no plaintext writes), or connect-form routing (no state strands the operator).

How this was verified, and how it will be

There are no PR previews in this repo — both Vercel projects deploy main only. So the pre-merge evidence is tests and gates, and the live check happens immediately after merge, not before:

  • Pre-merge, on this head: engine typecheck ✓ · web typecheck ✓ · biome ✓ (364 files) · src/api/gmail-connect.test.ts 26/26 ✓ · full suite green on CI.
  • Post-merge: a live check against inbox.resonantiq.app once the deploy lands. Merging ships nothing on its own here — the deploy is a separate manual step.
  • The consent round-trip itself needs real Google credentials, so the first genuine end-to-end run is the operator's.

Not done

Microsoft OAuth connector — HT-124. The largest remaining gap, since M365 business accounts cannot use app passwords at all.

Follow-up

These screens (ConnectInboxForm, MailboxListScreen) are app-level surface, so under the both-directions reconciliation rule they owe a trip back up to the design project. Not a merge blocker; tracked separately.

zaridan and others added 2 commits August 4, 2026 18:35
…lback (HT-123)

Engine: the Gmail OAuth callback redirects back into the app
(?connected=<address> / ?connect_error=<code>) when uiBaseUrl is
configured, instead of stranding the operator on a bare HTML page;
unchanged when it isn't.

Web: adds a gmailBeginConnect API call + beginGoogleConnect server
action, and reorders the connect screen so Google is the primary
path for gmail.com/googlemail.com (with app password as a collapsed
fallback), a Workspace-domain hint for unrecognized domains, and an
honest "not yet supported" state for Microsoft domains instead of a
non-working button. The mailbox list toasts on the redirect params.
…123)

Independent Codex security pass on the OAuth flow:

- The two unhandled-error sinks logged the caught object on the OAuth path.
  An upstream failure there (token exchange, provider HTTP error) can carry
  a token or authorization code in its text, so this violated the never-log-
  a-secret guarantee. Now logs the error's CLASS only. Pre-existing from
  HT-40, not introduced by the OAuth-first work; fixed while in the file.
- The mailbox list showed a 'Mailbox connected' toast from the ?connected=
  query param alone, so any link could fake a success for an address that
  was never connected (UI integrity, not XSS — React escapes it). The toast
  now only fires when that address is actually in the server-fetched roster.

Codex confirmed correct and unchanged: no open redirect (uiBaseUrl is a
validated bare origin; the address only reaches searchParams), the Bearer
token stays server-side, the app-password path is fully intact for every
non-Google provider, and Microsoft offers no non-working button.

Gates: root+web typecheck clean, biome clean, 106 files / 2014 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
helpthread Ready Ready Preview Aug 7, 2026 10:18pm
helpthread-inbox Ready Ready Preview Aug 7, 2026 10:18pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds Gmail OAuth initiation and domain-based connection routing. Configured callbacks redirect to the mailbox screen with sanitized results. The mailbox screen displays and clears callback notifications while preserving existing HTML responses without a UI base URL.

Changes

Gmail OAuth connection flow

Layer / File(s) Summary
OAuth initiation contract and action
web/src/lib/api-types.ts, web/src/lib/api.ts, web/src/lib/mailbox-actions.ts
The web layer exposes a typed consent URL and a server action that requests it from the Gmail endpoint.
Callback redirect handling
src/api/gmail-connect.ts, src/composition/root.ts, src/api/gmail-connect.test.ts
The callback redirects configured UI flows with a connected address or fixed error code. Tests cover sanitization, caching, failures, and legacy HTML responses.
Connection mode routing
web/src/components/ConnectInboxForm.tsx
The form selects OAuth, app-password, or unsupported modes based on the address and connection context.
Mailbox callback notifications
web/src/components/MailboxListScreen.tsx
The mailbox screen validates callback parameters, displays notifications, and removes processed parameters from the URL.

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

Sequence Diagram(s)

sequenceDiagram
  participant ConnectInboxForm
  participant beginGoogleConnect
  participant gmailBeginConnect
  participant Google
  participant handleGmailConnectCallback
  participant MailboxListScreen

  ConnectInboxForm->>beginGoogleConnect: Request consent URL
  beginGoogleConnect->>gmailBeginConnect: POST Gmail connect request
  gmailBeginConnect-->>beginGoogleConnect: Return consentUrl
  beginGoogleConnect-->>ConnectInboxForm: Return consentUrl
  ConnectInboxForm->>Google: Redirect to consent URL
  Google->>handleGmailConnectCallback: Send OAuth callback
  handleGmailConnectCallback-->>MailboxListScreen: Redirect with connected or connect_error
  MailboxListScreen->>MailboxListScreen: Show notification and clear parameters
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% 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
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: Google OAuth becomes the primary inbox connection method, with app passwords retained as a fallback.
✨ Finishing Touches 💡 1
📝 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-123-oauth-first-connect

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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/api/gmail-connect.test.ts`:
- Around line 322-337: Add an assertion in the “never carries the code, state,
or a GmailConnectError message” test to verify the redirect Location does not
contain the GmailConnectError message “This connect link is invalid or has
expired.”, alongside the existing code and state assertions.

In `@web/src/components/ConnectInboxForm.tsx`:
- Around line 341-353: Update offerGoogleAlternative in the ConnectInboxForm
render-mode logic to include recognized Google domains such as gmail.com and
googlemail.com, while still excluding empty addresses and preserving the
lockAddress and microsoftOnly restrictions. Ensure the Google alternative
remains available when connectModeOverride is 'password'.

In `@web/src/components/MailboxListScreen.tsx`:
- Around line 86-89: Update the isConnected check in the MailboxListScreen
mailbox connection flow to compare mailbox.address and connected
case-insensitively, while preserving the existing success-toast behavior and
parameter cleanup.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: df7cdf88-5b1c-4470-a904-c6a2628ac3b8

📥 Commits

Reviewing files that changed from the base of the PR and between c73de3a and 1599924.

📒 Files selected for processing (8)
  • src/api/gmail-connect.test.ts
  • src/api/gmail-connect.ts
  • src/composition/root.ts
  • web/src/components/ConnectInboxForm.tsx
  • web/src/components/MailboxListScreen.tsx
  • web/src/lib/api-types.ts
  • web/src/lib/api.ts
  • web/src/lib/mailbox-actions.ts

Comment thread src/api/gmail-connect.test.ts
Comment thread web/src/components/ConnectInboxForm.tsx Outdated
Comment thread web/src/components/MailboxListScreen.tsx
…-123)

Three findings, all real:

- The connected-address roster check used exact string equality, but the
  address in the redirect comes from Google's getProfile() and need not match
  the stored row's casing. A genuinely successful connect could therefore show
  NO confirmation at all (the param is stripped either way). Now compares
  case-insensitively. Introduced by this PR's own anti-spoofing fix.
- A Gmail operator who opened 'Use an app password instead' had no way back to
  'Connect with Google' — offerGoogleAlternative required preset === undefined,
  which excludes gmail.com/googlemail.com. Now offered for Google domains too,
  with a label that fits each case. Known non-Google presets still never see it.
- The redirect-secrecy test's title promised the GmailConnectError message never
  reaches the Location, but it only asserted the code and state. Now asserts the
  message's absence AND pins the whole redirect URL, so anything unexpected
  fails by construction rather than by enumerating known secrets.

Gates: root+web typecheck clean, biome clean, 106 files / 2014 tests pass.

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

zaridan commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 5, 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.

…T-123)

Reverts the case-insensitive comparison added in 8252cff. That change was made
on a review finding whose premise does not hold: both sides of the comparison
are the SAME string. The callback redirects with the address completeConnect
resolved from getProfile(), and upsertConnectedMailbox stored that same value
verbatim — there is no case normalization in the store or the schema. An exact
match therefore always succeeds for a real connect.

Relaxing it was a net loss: lowercasing is not injective (distinct Unicode
strings can fold together), so a crafted ?connected= value could match a
DIFFERENT roster address and render a success confirmation for a mailbox that
was never connected — the spoofing hole this check exists to close.

Comment added so the reasoning survives the next reader.

Verified: web typecheck clean, biome clean, src/api/gmail-connect.test.ts
26/26. The full engine suite could not be trusted locally (another worktree's
concurrent run pushed load average past 74); CI is the authoritative run.

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

zaridan commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

With no address typed, `domainFromAddress` returns null and `preset` is
undefined, so the "Connect with Google Workspace instead" link rendered on a
blank form — before there is any domain to route on.

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

zaridan commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 44 minutes.

…s (HT-123)

Adversarial review found that this module's actions never verified the
operator session. Next.js dispatches Server Actions by a build-stable
action-ID hash that is invokable against any route, including the one
public path middleware waves through, so middleware is not a sufficient
gate — actions.ts already documents this and guards every action it
exports. This module holds the same server-only Bearer token and did not.

Unguarded, beginGoogleConnect would hand a valid Google consent URL to an
unauthenticated caller, whose consent the pre-auth callback would then
persist as a connected mailbox.

Guards all three actions, not only the one this branch adds: the other two
share the module's Bearer token and the identical exposure.

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

zaridan commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 7 minutes.

@zaridan
zaridan merged commit fe6c1bb into main Aug 7, 2026
8 checks passed
@zaridan

zaridan commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Post-merge live check — green

Merge fe6c1bb triggered production deploys on both projects (engine and inbox, Ready within seconds).

The callback's new redirect branch is live and exercised against production, unauthenticated:

GET https://desk.resonantiq.app/api/v1/inbound/gmail/callback   (no code/state)

HTTP/2 302
cache-control: no-store
location: https://inbox.resonantiq.app/manage/mailboxes?connect_error=missing_params

Before this change that request returned an HTML 400 page. The 302, the fixed error code, and the absence of any code/state in the Location match what gmail-connect.test.ts asserts — and it confirms a UI base URL is configured in production, so the fallback page is not the live path.

Not verified live: the connect screen itself sits behind the operator session, and the Google consent round-trip needs real credentials. Those remain the operator's first run, as stated above.

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