feat: Google OAuth for Gmail and Workspace mailboxes - #359
Open
lesly wants to merge 3 commits into
Open
Conversation
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 withFailed to connect …: Command failed.The interesting part is that most of the work was already done.
makeClientCfg()inimapManager.jsandcreateAccountSmtpTransport()insmtpTransport.jsboth already acceptoauth_provider === 'google'and authenticate over XOAUTH2: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/googleandGET /oauth/google/callback, mirroring the Microsoft authorization-code flow: CSRF nonce held in session,id_tokenverified 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=consentso 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_granton 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 startuploadIntegrationConfigs()each had a duplicated per-providerifblock. Collapsed to a singlePROVIDER_ENVtable./integrations/statusnow reportsgoogle.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 intointegration_config. A provider configured via plain.envvars reportsconfigured: truefrom/integrations/statusbut 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 onemail_accountsare 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: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.comaccounts 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 aboveGOOGLE_SCOPE, and in the setup panel in the UI.Testing
googlein the integrations status payload.The
googlei18n block is added to all seven locales with real translations rather than English placeholders. Three brand/placeholder keys and one genuinefr/itcollision (ID client) are added toSAME_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:
oauth_token_expiryinto the past and triggering a reconnect:Refreshing google token for …→ reconnected, expiry moved forward, refresh token preserved by theCOALESCE(Google does not reissue one on refresh).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.