Skip to content

feat: Google OAuth for Gmail and Workspace mailboxes - #359

Open
lesly wants to merge 3 commits into
maathimself:mainfrom
lesly:feat/google-oauth
Open

feat: Google OAuth for Gmail and Workspace mailboxes#359
lesly wants to merge 3 commits into
maathimself:mainfrom
lesly:feat/google-oauth

Conversation

@lesly

@lesly lesly commented Aug 7, 2026

Copy link
Copy Markdown

Why

Gmail and Google Workspace mailboxes can currently only be added with an app password. That works for consumer @gmail.com, but Workspace admins can disable app passwords org-wide, and many do. When they have, there is no way to add that mailbox to MailFlow at all — the connect simply fails with Failed to connect …: Command failed.

The interesting part is that most of the work was already done. makeClientCfg() in imapManager.js and createAccountSmtpTransport() in smtpTransport.js both already accept oauth_provider === 'google' and authenticate over XOAUTH2:

// imapManager.js
// OAuth2 XOAUTH2 for Gmail and Microsoft
if ((account.oauth_provider === 'google' || account.oauth_provider === 'microsoft')
    && account.oauth_access_token) {

But nothing ever produced those tokens. The only OAuth routes were Microsoft's, and both refresh gates were hardcoded to 'microsoft', so even a manually-inserted Google account would have died at the first token expiry. This PR supplies the missing half.

What changed

Google OAuth flow (routes/oauth.js)

  • GET /oauth/google and GET /oauth/google/callback, mirroring the Microsoft authorization-code flow: CSRF nonce held in session, id_token verified against Google's JWKS with audience and issuer checks, advisory-locked account upsert to avoid duplicate rows on concurrent callbacks.
  • refreshGoogleToken() with the same per-account in-flight dedup map, since the IMAP and SMTP paths can both trip the 5-minute expiry window at once.
  • access_type=offline + prompt=consent so a refresh token is always issued. If one is missing the grant is rejected outright rather than persisting an account that would silently stop working an hour later.
  • invalid_grant on refresh is surfaced as a reconnect prompt instead of a generic failure, since no retry can fix a revoked grant.

Refresh gates generalised (imapManager.js, smtpTransport.js)

Replaced account.oauth_provider !== 'microsoft' equality checks with a provider lookup table, so adding a third provider later doesn't mean finding every branch again.

Integrations config generalised (routes/integrations.js)

save, delete, and the startup loadIntegrationConfigs() each had a duplicated per-provider if block. Collapsed to a single PROVIDER_ENV table. /integrations/status now reports google.

Connect UI (AdminPanel.jsx)

A Gmail / Google Workspace card alongside the Microsoft one: setup steps, credential form, connect button, non-admin capability note.

Drive-by fix, affects Microsoft too

The connect button was gated on configs.<provider>?.clientId, which only reflects config written through the admin UI into integration_config. A provider configured via plain .env vars reports configured: true from /integrations/status but has no DB row — so on an env-only install, an admin saw a permanently disabled connect button while a non-admin saw a working one. Capability status is now fetched for admins too and both sources are consulted.

No schema change

The oauth_* columns on email_accounts are already provider-agnostic. Nothing to migrate.

A note on Google's restricted scope

https://mail.google.com/ is a restricted scope, and the consent screen configuration decides whether this is actually usable. Worth stating plainly because the obvious choice is the wrong one:

Consent screen Verification Refresh token lifetime
Internal (project owned by a Workspace org) none permanent
External + Testing none revoked after 7 days
External + In production full review incl. security assessment permanent

External + Testing looks like the easy path for self-hosters and is what I initially assumed, but Google revokes refresh tokens after 7 days in that mode, so every account would need reconnecting weekly. Internal is the configuration that works. Consumer @gmail.com accounts cannot be covered by an Internal app, but those can still use an app password, and this flow is aimed squarely at the Workspace case where that option has been taken away.

This is documented in .env.example, in a comment above GOOGLE_SCOPE, and in the setup panel in the UI.

Testing

  • Backend: 995 passing. Added coverage for the Google refresh branch, the still-valid-token no-op, and google in the integrations status payload.
  • Frontend: suite passing, including the i18n key-coverage, source-coverage and value-uniqueness checks.
  • Both lints clean, frontend builds.

The google i18n block is added to all seven locales with real translations rather than English placeholders. Three brand/placeholder keys and one genuine fr/it collision (ID client) are added to SAME_VALUE_ALLOWED.

Verified against a live Workspace mailbox

Not just unit tests — this was run against a real Google Workspace account whose admin has app passwords disabled:

  • Account connects and syncs over XOAUTH2, no password stored anywhere.
  • Refresh path exercised deliberately by forcing oauth_token_expiry into the past and triggering a reconnect: Refreshing google token for … → reconnected, expiry moved forward, refresh token preserved by the COALESCE (Google does not reissue one on refresh).
  • An existing app-password Gmail account on the same instance was unaffected.

Happy to adjust anything — particularly if you'd rather the Google config live somewhere other than alongside Microsoft's, or if you want the consent-screen guidance worded differently.

lesly added 3 commits August 7, 2026 12:41
The IMAP and SMTP layers already accepted oauth_provider === 'google' and
authenticated over XOAUTH2, but nothing ever produced those tokens: the only
OAuth routes were Microsoft's, and both refresh gates were hardcoded to
'microsoft'. Gmail was therefore reachable only via an app password, which
Workspace admins can disable outright.

- Add GET /oauth/google and /oauth/google/callback, mirroring the Microsoft
  authorization-code flow (CSRF nonce in session, id_token verified against
  Google's JWKS, advisory-locked account upsert).
- Add refreshGoogleToken with the same per-account in-flight dedup, and
  surface invalid_grant as a reconnect prompt rather than a generic failure.
- Generalise the refresh gates in imapManager and smtpTransport to a provider
  lookup table instead of an equality check on 'microsoft'.
- Request access_type=offline and prompt=consent so a refresh token is always
  issued; reject the grant outright if one is missing, rather than persisting
  an account that silently dies in an hour.
- Document GOOGLE_CLIENT_ID / SECRET / REDIRECT_URI in .env.example.

No schema change: the oauth_* columns on email_accounts are already
provider-agnostic.

Tests: 994 passing (2 new, covering the Google refresh branch and the
still-valid-token no-op).
The restricted https://mail.google.com/ scope has real deployment constraints
that the first pass understated. External+Testing does avoid verification, but
Google revokes refresh tokens after 7 days, so it is not viable for daily use.
Spell out the three consent screen configurations and which one actually works
for self-hosting (Internal, under a Workspace org).
Exposes the Google OAuth flow in the UI instead of requiring users to visit
/oauth/google by hand, mirroring the existing Microsoft card: setup steps,
credential form, connect button, and the non-admin capability note.

Also fixes a pre-existing bug the new card would otherwise inherit. The
connect button was gated on 'configs.<provider>?.clientId', which only ever
reflects DB-backed config written through this UI. A provider configured via
plain .env vars reports configured=true from /integrations/status but has no
integration_config row, so admins on an env-only install saw a permanently
disabled connect button. Capability status is now fetched for admins too, and
both sources are consulted. This affects Microsoft equally.

Backend:
- Generalise integrations.js to a PROVIDER_ENV table instead of duplicated
  per-provider if-blocks across save, delete, and startup load.
- Report google in /integrations/status.

i18n:
- Add the google block to all seven locales with real translations.
- Allowlist the brand/placeholder values that are legitimately identical
  across locales, and the fr/it 'ID client' collision.

Tests: backend 995 passing, frontend suite passing (including i18n key
coverage, source coverage, and value-uniqueness checks).
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