From f58a6c1ff9aa8aa9b3d742974af8b09ce676f40e Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 5 Oct 2026 12:16:34 +0000 Subject: [PATCH] feat: control Gmail mailbox OAuth per frontend deployment and bind callbacks to allowlisted dashboard origins Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .env.example | 4 + .../scripts/pages-production-config.test.mjs | 1 + docker-compose.yml | 2 + docs/content/docs/api/endpoints.mdx | 6 +- docs/content/docs/api/error-codes.mdx | 6 +- .../docs/development/configuration.mdx | 35 +++++++- docs/content/docs/guides/mailbox-import.mdx | 2 + docs/content/docs/guides/mailboxes.mdx | 14 +-- internal/api/handler/email_oauth_callback.go | 7 ++ .../api/handler/email_oauth_callback_test.go | 46 ++++++++++ internal/api/handler/email_onboarding.go | 8 +- internal/app/email/cache.go | 17 ++++ internal/app/email/onboarding.go | 6 +- .../app/email/onboarding_gmail_gate_test.go | 10 ++- internal/app/email/onboarding_origin_test.go | 86 +++++++++++++++++++ internal/app/email/reauth.go | 9 +- internal/app/email/reauth_test.go | 2 +- internal/app/email/service.go | 5 +- internal/config/dashboard_origin_test.go | 23 +++++ internal/config/endpoints.go | 17 ++++ internal/config/inbox.go | 15 ++-- internal/config/inbox_test.go | 29 +++++++ internal/errx/common.go | 1 + internal/models/email.go | 2 + web/.env.example | 1 + web/docker-entrypoint.sh | 1 + .../app/emails/GmailAppPasswordPanel.tsx | 2 +- .../components/app/emails/import/RunStep.tsx | 4 +- .../migration/SigninMigrationBanner.tsx | 12 +-- .../migration/SigninMigrationDialog.tsx | 4 +- .../emails/migration/SigninRetiringNotice.tsx | 8 +- .../components/app/modals/AddEmailModal.tsx | 46 ++-------- web/src/hooks/useGmailOAuthConnect.test.tsx | 41 +++++++++ web/src/hooks/useGmailOAuthConnect.ts | 7 ++ web/src/hooks/useMailboxOAuth.ts | 8 +- web/src/lib/information.ts | 2 + 36 files changed, 402 insertions(+), 87 deletions(-) create mode 100644 internal/app/email/onboarding_origin_test.go create mode 100644 internal/config/dashboard_origin_test.go create mode 100644 internal/config/inbox_test.go create mode 100644 web/src/hooks/useGmailOAuthConnect.test.tsx create mode 100644 web/src/hooks/useGmailOAuthConnect.ts diff --git a/.env.example b/.env.example index 7ba5c04ea..fac4b2c28 100644 --- a/.env.example +++ b/.env.example @@ -435,6 +435,10 @@ BILLING_PROVIDER=none # Redirect URI: /addresses/google/callback # BOX_GOOGLE_CLIENT_ID= # BOX_GOOGLE_CLIENT_SECRET= +# Optional backend kill switch; unset enables connects when both keys are set. +# BOX_GOOGLE_OAUTH_CONNECT=false +# Dashboard visibility, independent of backend availability. Off by default. +WARMBLY_GMAIL_OAUTH_CONNECT=false # Redirect URI: /addresses/outlook/callback # BOX_OUTLOOK_CLIENT_ID= # BOX_OUTLOOK_CLIENT_SECRET= diff --git a/.github/scripts/pages-production-config.test.mjs b/.github/scripts/pages-production-config.test.mjs index e049d4b23..d177ab3b7 100644 --- a/.github/scripts/pages-production-config.test.mjs +++ b/.github/scripts/pages-production-config.test.mjs @@ -107,6 +107,7 @@ test("renders the imported production settings with the real dashboard entrypoin runInNewContext(readFileSync(output, "utf8"), { window }); assert.equal(window.__WARMBLY_ENV__.API_URL, "production-WARMBLY_API_URL"); assert.equal(window.__WARMBLY_ENV__.TURNSTILE_KEY, "production-WARMBLY_TURNSTILE_KEY"); + assert.equal(window.__WARMBLY_ENV__.GMAIL_OAUTH_CONNECT, "production-WARMBLY_GMAIL_OAUTH_CONNECT"); assert.equal(window.__WARMBLY_ENV__.COMPANY_LOGOS, 'logos "quoted"\\path'); } finally { rmSync(work, { recursive: true, force: true }); diff --git a/docker-compose.yml b/docker-compose.yml index f2322bef1..9dcfa3646 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -374,6 +374,7 @@ services: # mailboxes). See the self-hosting docs for the Google Cloud setup. BOX_GOOGLE_CLIENT_ID: ${BOX_GOOGLE_CLIENT_ID:-} BOX_GOOGLE_CLIENT_SECRET: ${BOX_GOOGLE_CLIENT_SECRET:-} + BOX_GOOGLE_OAUTH_CONNECT: ${BOX_GOOGLE_OAUTH_CONNECT:-} BOX_OUTLOOK_CLIENT_ID: ${BOX_OUTLOOK_CLIENT_ID:-} BOX_OUTLOOK_CLIENT_SECRET: ${BOX_OUTLOOK_CLIENT_SECRET:-} # Whole Google Workspace domains: the service account key, inline or as a @@ -648,6 +649,7 @@ services: environment: WARMBLY_API_URL: ${API_PUBLIC_URL:-http://${PUBLIC_HOST:-localhost}:8080} WARMBLY_APP_URL: ${APP_URL:-http://${PUBLIC_HOST:-localhost}:5173} + WARMBLY_GMAIL_OAUTH_CONNECT: ${WARMBLY_GMAIL_OAUTH_CONNECT:-false} # Cloudflare's always-pass test key keeps the widget satisfied while # captcha is off server-side. Set WARMBLY_TURNSTILE_KEY to your site key # when you set CAPTCHA_PROVIDER=turnstile. diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 3c2c84e49..adef5c9e0 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -526,7 +526,7 @@ A completed sign-in counts as a confirmation for the first five minutes of the s Alongside them, `api_url` is this API's own public base (for a copyable example that names the right server), and `brand` is who the deployment says it is: `name`, and `website_url`, `website_label`, `terms_url`, `privacy_url` and `support_email`, each omitted when unset. On a self-hosted instance that configured no `EMAIL_BRAND_*` only `name` is present, and a client should render no link at all rather than substituting one of its own. See [configuration](/development/configuration/). -`GET /auth/config` also carries `gmail_oauth_connect` (boolean): whether a new Gmail mailbox may be connected with Google sign-in. It is `false` unless the deployment sets `BOX_GOOGLE_OAUTH_CONNECT=true`, and a client should then offer the app-password route (`POST /emails/onboarding/smtp-imap` against Gmail's servers) rather than start an OAuth round trip that returns `mailbox_gmail_oauth_disabled`. Mailboxes already connected with Google sign-in are unaffected either way. +`GET /auth/config` also carries `gmail_oauth_connect` (boolean): whether a new Gmail mailbox may be connected with Google sign-in. Both `BOX_GOOGLE_CLIENT_ID` and `BOX_GOOGLE_CLIENT_SECRET` are required. With them set, the capability defaults to `true`; explicit `BOX_GOOGLE_OAUTH_CONNECT=false` or an invalid value disables it. When `false`, offer the app-password route (`POST /emails/onboarding/smtp-imap` against Gmail's servers) rather than start an OAuth round trip that returns `mailbox_gmail_oauth_disabled`. The dashboard additionally requires its deployment-local [frontend opt-in](/development/configuration/#google-sign-in-per-dashboard-deployment) to show Google sign-in. Existing Google sign-in mailboxes are unaffected either way. `GET /auth/config` also carries `billing_enabled` (boolean). It is `false` when the deployment runs with `BILLING_PROVIDER=none`, which is the self-host default: every feature is unlocked server-side, so the dashboard shows the workspace as self-hosted instead of on a free trial and hides the billing and referral pages. `self_hosted` alone does not imply this, because a self-hosted install may still run Stripe. - `POST /auth/setup` (first-run claim: exchanges the one-time token printed at boot for the owner account. Refused once any account exists) @@ -537,7 +537,7 @@ Alongside them, `api_url` is this API's own public base (for a copyable example - `POST /auth/sso/link` (completes a provider sign-in that resolved to an existing password account: takes that account's password, attaches the identity and returns the session) - `POST /auth/logout`, `POST /auth/logout-all`, `GET /auth/me`, `PATCH /auth/me/onboarding` - `POST /auth/me/avatar`, `DELETE /auth/me/avatar` -- `POST /emails/onboarding/oauth/start`, `POST /emails/onboarding/oauth/finish`, `POST /emails/onboarding/smtp-imap`. `oauth/start` accepts an optional `login_hint` (an email address) that preselects that mailbox in the provider's sign-in, which is how an import's **Sign in** rows open +- `POST /emails/onboarding/oauth/start`, `POST /emails/onboarding/oauth/finish`, `POST /emails/onboarding/smtp-imap`. `oauth/start` accepts an optional `login_hint` (an email address) that preselects that mailbox in the provider's sign-in, which is how an import's **Sign in** rows open. For Google dashboard flows (`return: "web"`), the browser's `Origin` is validated against exact configured dashboard origins and bound to the OAuth state; an untrusted origin answers `400` `mailbox_oauth_return_origin`. Requests without an `Origin` retain primary-dashboard routing - `POST /emails/onboarding/smtp-imap/bulk` (up to `50` SMTP/IMAP rows in `accounts`, answered `200` with a per-row `status` of `connected`, `skipped` or `failed` and a `code`; rows past the workspace's [mailbox allowance](/guides/mailboxes/#mailbox-allowance) fail with `mailbox_allowance_reached` before any credential is dialled. Naturally retry-safe: an already connected mailbox is `skipped`, so it takes no `Idempotency-Key`) - The [mailbox import](/guides/mailbox-import/) routes under `/emails/imports`, all with JWT permission `MANAGE_EMAILS`. They carry passwords, so like onboarding they never accept an API key: - `POST /emails/imports/preview`: what an import of this input would do, row by row and domain by domain, without doing any of it @@ -563,7 +563,7 @@ Alongside them, `api_url` is this API's own public base (for a copyable example - [Admin grants](/guides/mailbox-import/#connect-a-whole-google-workspace-domain) under `/emails/grants`, JWT permission `MANAGE_EMAILS`. The four routes that record, use or remove a grant also need a [recent confirmation](#actions-that-need-a-recent-confirmation): - `GET /emails/grants/config`: whether this instance takes Google and Microsoft grants, the Google `google_client_id` and `google_scopes` an administrator authorizes, and `google_missing` and `microsoft_missing`, the names of the instance settings still unset (empty when that provider is ready) - `GET /emails/grants`, `GET /emails/grants/:id`: grants with their `provider`, `tenant`, covered `domains`, `status` and mailbox count - - `GET /emails/grants/migration`: the workspace's mailboxes still on per-mailbox Google sign-in, which is [being retired](/guides/mailboxes/#moving-off-per-mailbox-google-sign-in), grouped by domain: `data` is a list of `domain`, `kind` (`workspace`, which moves onto an admin grant, or `personal`, a shared address such as `gmail.com`, which moves to an app password), `grant_id` when the workspace has an active Google grant covering the domain, and `mailboxes` (`id`, `email`, `name`, `status`); `total` counts the mailboxes. Mailboxes whose sign-in Warmbly Cloud holds are not listed. Read only + - `GET /emails/grants/migration`: the workspace's mailboxes on per-mailbox Google sign-in that can [optionally switch connection methods](/guides/mailboxes/#moving-off-per-mailbox-google-sign-in), grouped by domain: `data` is a list of `domain`, `kind` (`workspace`, which moves onto an admin grant, or `personal`, a shared address such as `gmail.com`, which moves to an app password), `grant_id` when the workspace has an active Google grant covering the domain, and `mailboxes` (`id`, `email`, `name`, `status`); `total` counts the mailboxes. Mailboxes whose sign-in Warmbly Cloud holds are not listed. Read only - `POST /emails/grants/google/start`: `domain` and `admin_email` (on that domain). Answers how this workspace proves it controls the domain: `method` `signin` with a Google sign-in `url` and `state` (valid for 15 minutes) when the instance has a Google sign-in app, else `method` `dns`. Both carry `txt_name` (`_warmbly.`) and `txt_value` (`warmbly-verify=...`, unique to the workspace and domain), because the DNS proof always works. Safe to repeat - `POST /emails/grants/google/finish` (recent confirmation): either `state` and `code` from the Google sign-in, which must be `admin_email` itself on that domain, or `domain` and `admin_email` once the `TXT` record is published. The directory must list `admin_email` as a super administrator. Answers `201`. A `state` is single-use; repeating a DNS finish re-verifies and updates the same grant, one per domain - `POST /emails/grants/microsoft/start`: the admin consent `url` and `state` for a Global Administrator, valid for 15 minutes diff --git a/docs/content/docs/api/error-codes.mdx b/docs/content/docs/api/error-codes.mdx index d224919fc..6cc3cc6ee 100644 --- a/docs/content/docs/api/error-codes.mdx +++ b/docs/content/docs/api/error-codes.mdx @@ -433,7 +433,9 @@ Returned when authenticated but lacking necessary permissions. #### `mailbox_gmail_oauth_disabled` -A `403` whose `code` is `mailbox_gmail_oauth_disabled` comes from `POST /emails/onboarding/oauth/start` with `provider: "gmail"`. The deployment routes new Gmail mailboxes through an app password over IMAP and SMTP instead of Google sign-in, which is the default; `GET /auth/config` announces it as `gmail_oauth_connect: false`. Nothing about the caller's permissions is wrong. Re-authorizing an existing Gmail mailbox (`POST /emails/onboarding/oauth/reauth/:id`) is never refused this way. +A `400` with `code: "mailbox_oauth_return_origin"` instead means a Google mailbox connect or reauthorization came from a dashboard origin the backend does not allow. Add its exact HTTP(S) origin to `CORS_ALLOW_ORIGINS`, as in [shared-backend dashboard configuration](/development/configuration/#google-sign-in-per-dashboard-deployment). A wildcard is not an OAuth return allowlist. + +A `403` whose `code` is `mailbox_gmail_oauth_disabled` comes from `POST /emails/onboarding/oauth/start` with `provider: "gmail"`. The backend has no complete Google mailbox client credentials, or explicitly disables new Google OAuth connects through `BOX_GOOGLE_OAUTH_CONNECT`; `GET /auth/config` announces it as `gmail_oauth_connect: false`. Nothing about the caller's permissions is wrong. Re-authorizing an existing Gmail mailbox (`POST /emails/onboarding/oauth/reauth/:id`) is never refused this way. ```json { @@ -446,7 +448,7 @@ A `403` whose `code` is `mailbox_gmail_oauth_disabled` comes from `POST /emails/ **How to fix:** - Connect the mailbox through `POST /emails/onboarding/smtp-imap` with `smtp.gmail.com:465` and `imap.gmail.com:993`, both TLS, and a Google app password. See [Gmail and Google Workspace](/guides/mailboxes/#gmail-and-google-workspace) -- A self-hosted instance with its own Google app can set `BOX_GOOGLE_OAUTH_CONNECT=true` to allow Google sign-in for new mailboxes +- An instance with its own Google app must set both `BOX_GOOGLE_CLIENT_ID` and `BOX_GOOGLE_CLIENT_SECRET`. Leave `BOX_GOOGLE_OAUTH_CONNECT` unset or set it to `true`, then enable the [dashboard visibility flag](/development/configuration/#google-sign-in-per-dashboard-deployment) on the deployments that should offer Google sign-in #### Registration and invitation refusals diff --git a/docs/content/docs/development/configuration.mdx b/docs/content/docs/development/configuration.mdx index a38736647..f3fe8dbfd 100644 --- a/docs/content/docs/development/configuration.mdx +++ b/docs/content/docs/development/configuration.mdx @@ -107,7 +107,7 @@ Every emailed link (password reset, invitation, the first-run claim link) is bui | `FRONTEND_BASE_URL` | Alternative name for the same value, read when `APP_URL` is unset | unset | no | | `API_PUBLIC_URL` | The backend's public base. Frontends, blob URLs and the OIDC redirect derive from it | derived from `PUBLIC_HOST` under compose | yes | | `BACKEND_PUBLIC_URL` | The backend's public URL as third parties call it: generated worker configuration, the integration OAuth redirect and the [Slack app](/development/slack-app/) request URLs. Only needed when that differs from `API_PUBLIC_URL` | falls back to `API_PUBLIC_URL` | yes | -| `APP_ORIGIN` | The exact origin the mailbox and integration OAuth callback pages post the authorization code back to. Only needed when the dashboard is served somewhere other than `APP_URL`. With neither set, those pages deliver the code to nothing and say to set `APP_URL` | derived from `APP_URL` | yes | +| `APP_ORIGIN` | Default origin the mailbox and integration OAuth callback pages post back to. Only needed when the dashboard is served somewhere other than `APP_URL`. Google per-mailbox flows can instead return to their allowlisted initiating dashboard, as described below | derived from `APP_URL` | yes | | `API_HOST` | The listen address | `0.0.0.0:8080` | yes | | `PUBLIC_HOST` | Compose only. A hostname or LAN IP that every other URL derives from | `localhost` | yes | | `CORS_ALLOW_ORIGINS` | Comma separated origins allowed to call the API. Anything not listed gets `403` on preflight | derived from `PUBLIC_HOST` under compose | yes | @@ -461,12 +461,41 @@ Needed on the backend **and** every worker: the backend starts the OAuth flow, a | Variable | What it does | Default | |---|---|---| -| `BOX_GOOGLE_CLIENT_ID`, `BOX_GOOGLE_CLIENT_SECRET` | The Google app used by Gmail mailboxes connected with per-mailbox Google sign-in: the ones already connected that way, and new ones once `BOX_GOOGLE_OAUTH_CONNECT` is on. That method is [being retired](/guides/mailboxes/#moving-off-per-mailbox-google-sign-in), so keep these set while any mailbox still uses it. Also carries a Workspace administrator's domain proof when `GOOGLE_CLIENT_ID` is not set. Redirect URI is your API base plus `/addresses/google/callback` | unset | -| `BOX_GOOGLE_OAUTH_CONNECT` | `true` lets a new Gmail mailbox connect with Google sign-in. Off, the connect dialog walks through an app password over IMAP and SMTP instead, which needs no Google app; mailboxes already on Google sign-in keep working and can be re-authorized either way. Announced to clients as `gmail_oauth_connect` on `GET /auth/config` | `false` | +| `BOX_GOOGLE_CLIENT_ID`, `BOX_GOOGLE_CLIENT_SECRET` | The Google app used by Gmail mailboxes connected with per-mailbox Google sign-in. Both are required to enable new connects. Keep them set while any mailbox uses Google sign-in. Also carries a Workspace administrator's domain proof when `GOOGLE_CLIENT_ID` is not set. Redirect URI is your API base plus `/addresses/google/callback` | unset | +| `BOX_GOOGLE_OAUTH_CONNECT` | Optional backend kill switch for new Gmail OAuth connects. Unset enables the capability when both Google mailbox credentials are present; explicit `false` or an invalid value disables it. Existing Google sign-in mailboxes keep working and can re-authorize either way. Announced as `gmail_oauth_connect` on `GET /v1/auth/config` | enabled when configured | | `BOX_OUTLOOK_CLIENT_ID`, `BOX_OUTLOOK_CLIENT_SECRET` | Connect Outlook and Microsoft 365 mailboxes. Redirect URI is your API base plus `/addresses/outlook/callback` | unset | Plain SMTP and IMAP mailboxes need none of this. If a worker is missing these values, the mailbox connects fine and then silently stops about an hour later, when its first access token expires. +### Google sign-in per dashboard deployment + +The dashboard has a separate visibility flag. It affects only **per-mailbox Google sign-in** in the connect dialog and mailbox import, not Microsoft sign-in, Google dashboard login or Workspace whole-domain grants. + +| Variable | Where to set it | Default | +|---|---|---| +| `WARMBLY_GMAIL_OAUTH_CONNECT` | Dashboard container environment or Cloudflare Pages environment. Set to `true` to offer Google mailbox sign-in when the backend supports it | `false` | +| `VITE_GMAIL_OAUTH_CONNECT` | `web/.env` for local Vite or a direct Vite build. Runtime configuration takes precedence | `false` | + +Two dashboard deployments can share one backend and set this flag differently. For example: + +```dotenv +# Public dashboard environment +WARMBLY_GMAIL_OAUTH_CONNECT=false + +# Assessment dashboard environment (a separate deployment) +WARMBLY_GMAIL_OAUTH_CONNECT=true +``` + +Both dashboards point `WARMBLY_API_URL` at the same API and set `WARMBLY_APP_URL` to their own dashboard URL. Containers generate `/config.js` at startup, so no second image is needed. Cloudflare Pages renders it during `build:pages`; set the variable on the intended Pages project and redeploy. + +On the shared backend, keep `APP_URL` as the primary dashboard and add both exact dashboard origins to `CORS_ALLOW_ORIGINS`, for example `https://app.example.com,https://assessment.example.com`. A Google mailbox connect or reauthorization binds the request's browser origin to its server-side, single-use OAuth state. The callback posts only to that allowlisted origin, and a popup without an opener returns to its `/oauth-return` page. Wildcards are not accepted as OAuth return origins. Google's registered mailbox redirect URI stays on the same API. + +Existing Google OAuth mailboxes can still re-authorize with the frontend flag off. App-password and SMTP/IMAP options stay available. When linked to Warmbly Cloud, Google consent is brokered by Cloud rather than the local backend, but the frontend opt-in is still required. + + +Hiding the button does not prevent an authenticated client from calling the API. Use `BOX_GOOGLE_OAUTH_CONNECT=false` to disable new Google OAuth connects on the backend. A frontend flag does not change Google's restricted-scope verification or security assessment requirements, and a second dashboard on the same backend shares its data. Agree the assessment scope and reviewer account with your assessor before scanning. + + ### Whole-domain connects A workspace can connect every mailbox on a [Google Workspace domain or Microsoft 365 organization](/guides/mailbox-import/#connect-a-whole-google-workspace-domain) through one administrator's grant (**Add account > Google > Whole Workspace domain**, **Add account > Microsoft > Whole organization**). These are read by the backend only: it mints every token for those mailboxes itself and hands them to workers per use, so workers need nothing here. The complete setup, step by step, is in [whole-domain mailbox connects](/development/whole-domain-connect/). diff --git a/docs/content/docs/guides/mailbox-import.mdx b/docs/content/docs/guides/mailbox-import.mdx index 216243b34..b9074b508 100644 --- a/docs/content/docs/guides/mailbox-import.mdx +++ b/docs/content/docs/guides/mailbox-import.mdx @@ -95,6 +95,8 @@ Each mailbox is labelled with where it is hosted and how it signs in. The [API]( A Google row whose password is not a 16-letter app password is flagged in the preview before anything is tried: Google refuses the account password over IMAP and SMTP. On an instance that connects new Gmail mailboxes with Google sign-in (`gmail_oauth_connect` in `GET /auth/config`), such a row waits for sign-in instead, like a Microsoft row. +Google rows waiting for Google sign-in offer a **Sign in** action only on deployments where [Google mailbox OAuth is enabled](/development/configuration/#google-sign-in-per-dashboard-deployment). Otherwise use an app password or an admin grant. Microsoft sign-in is unaffected by that flag. + ### Microsoft Exchange Online no longer accepts passwords over IMAP, so Microsoft 365 and Outlook.com rows connect with Microsoft sign-in. The import parks them as **needs sign-in** and the rest of the file carries on. Each has a **Sign in** button that opens Microsoft's sign-in with the address already filled in. When that address connects, from the row or by any other route, the row closes and the import's settings are applied to the mailbox. diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index 4a338fcae..3f94bc5fb 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -13,7 +13,7 @@ Open **Accounts** and choose **Add account**. Google and Microsoft each have one |----------|--------|-------| | Google | **Whole Workspace domain**: an administrator's grant (`gmail`, sign-in method `delegated`) | Recommended for Google Workspace. A super admin authorizes Warmbly once, and every mailbox on the domain connects with no password and no sign-in each. Runs on the Gmail API. See [connect a whole Google Workspace domain](/guides/mailbox-import/#connect-a-whole-google-workspace-domain) | | Google | **App password** over IMAP + SMTP (`smtp_imap`, sign-in method `app_password`) | Works for personal Gmail and for Workspace. The dialog walks you through it in three steps, see [Gmail and Google Workspace](#gmail-and-google-workspace) below | -| Google | **Sign in with Google**: OAuth for one mailbox (`gmail`, sign-in method `oauth`) | Being retired, see [moving off per-mailbox Google sign-in](#moving-off-per-mailbox-google-sign-in). Offered only where the instance still allows it for new mailboxes | +| Google | **Sign in with Google**: OAuth for one mailbox (`gmail`, sign-in method `oauth`) | Offered where the dashboard deployment enables it and the backend supports it. No app password needed | | Microsoft | **Whole organization**: an administrator's grant (`outlook`, sign-in method `delegated`) | Recommended for Microsoft 365. A Global Administrator approves Warmbly once, and every licensed mailbox connects with no sign-in each. Runs on Microsoft Graph. See [connect a whole Microsoft 365 organization](/guides/mailbox-import/#connect-a-whole-microsoft-365-organization) | | Microsoft | **Sign in one mailbox**: OAuth (`outlook`, sign-in method `oauth`) | No password stored. Runs on Microsoft Graph. The way to connect Outlook.com and a single Microsoft 365 mailbox | | Any other server | IMAP + SMTP (`smtp_imap`) | Custom domains, self-hosted, or providers without OAuth. Any IMAP server that accepts a password works, including Yahoo, Fastmail, Zoho, Seznam.cz, cPanel and self-hosted Dovecot. Exchange Online (Microsoft 365 and Outlook.com) no longer accepts passwords over IMAP, so connect those with Microsoft | @@ -22,8 +22,8 @@ On a self-hosted instance the two whole-domain methods are shown switched off un Once you press **Connect**, the connect finishes on the server even if you refresh or close the page: the credentials are checked, and the mailbox is either saved, placed on a worker and added to your list, or not saved at all. It appears in the list on its own when it is done. Pressing **Connect** again meanwhile cannot add the address twice. - - A mailbox you already connected with **Sign in with Google** is not affected yet: it keeps sending and syncing, and if Google invalidates its token the **Re-authorize** button in its drawer still works. There is no cutoff date. The dashboard marks these mailboxes and offers to move each one, without losing anything, onto its domain's grant (Google Workspace) or an app password (personal Gmail). See [moving off per-mailbox Google sign-in](#moving-off-per-mailbox-google-sign-in). + + A mailbox connected with **Sign in with Google** keeps sending and syncing even when a deployment hides that option for new mailboxes. If Google invalidates its token, the **Re-authorize** button in its drawer still works. You can optionally switch to a domain grant (Google Workspace) or an app password without losing history. See [moving off per-mailbox Google sign-in](#moving-off-per-mailbox-google-sign-in). **OAuth** sends you to your provider's consent screen and returns a token instead of a password. Both OAuth providers use the provider's native API, never IMAP or SMTP, so consent asks to send mail and to read and organize your mailbox. Google additionally asks to read your mail settings, which is what lets Warmbly offer the addresses Google has verified you to send as and import the signature you already wrote there. It needs no app passwords or server settings. Note that Google revokes Gmail tokens when the account's password changes, so a password change there means [re-authorizing the mailbox](#reconnecting-an-account) once. @@ -86,6 +86,8 @@ Warmbly signs in with whichever method your server offers, preferring CRAM-MD5, ### Gmail and Google Workspace +Where your deployment enables it, choose **Add account > Google > Sign in with Google** to connect one mailbox through Google's consent screen. This option is deployment-dependent; an administrator can enable it with the [dashboard configuration flag](/development/configuration/#google-sign-in-per-dashboard-deployment). If it is hidden, use an app password or a Workspace whole-domain grant instead. Existing Google sign-in mailboxes keep working and can re-authorize even when new Google sign-in connects are disabled. + Choose **Add account**, then **Google**, then **App password**. The dialog walks through three steps, and the only things you type are the address and the app password; the server settings are Gmail's own and are filled in for you. 1. **Turn on 2-Step Verification** on the Google account, under [Google Account, Security](https://myaccount.google.com/security). Google only offers app passwords once it is on, and only alongside a second-step method other than a security key (a phone prompt, an authenticator app or a text message). Already on? Continue. @@ -109,7 +111,7 @@ On Google Workspace the administrator decides. They can switch IMAP off for the ### Moving off per-mailbox Google sign-in -Per-mailbox **Sign in with Google** is being retired. This is a notice, not a deadline: a mailbox on it keeps sending, syncing and warming, and re-authorizing it still works. The two methods that replace it need no per-mailbox Google token at all. The whole-domain grant is authorized once by a Workspace administrator and survives password changes, and an app password works for any Google account. +Switching from per-mailbox **Sign in with Google** is optional. A mailbox on it keeps sending, syncing and warming, and re-authorizing it still works. The alternatives need no per-mailbox Google token: a whole-domain grant is authorized once by a Workspace administrator and survives password changes, and an app password works for Google accounts where app passwords are available. The **Accounts** page shows a banner while any mailbox is still on per-mailbox Google sign-in, each such mailbox carries a chip in the list, and its drawer shows a notice with the move for it. Mailboxes whose sign-in is held by [Warmbly Cloud](/guides/warmbly-cloud/) are not included; the cloud keeps their sign-in. @@ -118,7 +120,7 @@ The **Accounts** page shows a banner while any mailbox is still on per-mailbox G 1. Choose **Add account > Google > Whole Workspace domain** and set up the grant for the domain, as in [connect a whole Google Workspace domain](/guides/mailbox-import/#connect-a-whole-google-workspace-domain). Skip this when the domain already has an active grant. 2. In the grant's user list, the mailboxes still on their own sign-in are marked as ones to move. Pick them, or connect everyone, which includes them. -Connecting the address converts the existing mailbox in place: it is the same mailbox, with the same history, campaigns, warmup progress and settings, and the stored Google token is deleted. Converting does not add a mailbox, so it never counts against the [mailbox allowance](#mailbox-allowance). The same works for a mailbox on per-mailbox Microsoft sign-in under a Microsoft 365 organization grant, although Microsoft sign-in is not being retired and that move is optional. +Connecting the address converts the existing mailbox in place: it is the same mailbox, with the same history, campaigns, warmup progress and settings, and the stored Google token is deleted. Converting does not add a mailbox, so it never counts against the [mailbox allowance](#mailbox-allowance). The same optional switch works for a mailbox on per-mailbox Microsoft sign-in under a Microsoft 365 organization grant. **A personal Gmail mailbox moves to an app password.** This works for a Workspace mailbox too, when a grant is not an option. @@ -169,7 +171,7 @@ There is no daily cap on how many mailboxes you connect: a Business workspace ca ## Connecting many mailboxes at once -Choose **Import mailboxes** in the connect dialog to connect up to 5,000 mailboxes from a CSV, TSV or XLSX file, a vendor export, or a pasted list of `address:password` pairs. Only an address and a password are needed: the columns are recognised and each domain's servers are found from DNS. Microsoft 365 and Outlook.com rows wait for a Microsoft sign-in each, and Google rows need an app password. The import runs on the server, verifies every credential before saving it, and groups whatever fails by cause with the fix. +Choose **Import mailboxes** in the connect dialog to connect up to 5,000 mailboxes from a CSV, TSV or XLSX file, a vendor export, or a pasted list of `address:password` pairs. Only an address and a password are needed: the columns are recognised and each domain's servers are found from DNS. Microsoft 365 and Outlook.com rows wait for a Microsoft sign-in each. Google rows use an app password, an admin grant, or Google sign-in where enabled. The import runs on the server, verifies every credential before saving it, and groups whatever fails by cause with the fix. The same dialog imports straight from an [inbox vendor](/guides/mailbox-import/#import-from-an-inbox-vendor) with an API key, and connects a whole [Google Workspace domain](/guides/mailbox-import/#connect-a-whole-google-workspace-domain) (**Google > Whole Workspace domain**) or [Microsoft 365 organization](/guides/mailbox-import/#connect-a-whole-microsoft-365-organization) (**Microsoft > Whole organization**) through one administrator's grant. diff --git a/internal/api/handler/email_oauth_callback.go b/internal/api/handler/email_oauth_callback.go index 9de46c47c..3ff554385 100644 --- a/internal/api/handler/email_oauth_callback.go +++ b/internal/api/handler/email_oauth_callback.go @@ -170,6 +170,12 @@ func (h *Handler) renderOAuthCallback(c *gin.Context, provider string) { Web: isDashboardState(state), NoOriginNotice: callbackNoOriginNotice, } + if provider == "gmail" && email.IsWebState(state) && h.EmailService != nil { + if origin := h.EmailService.OAuthReturnOrigin(c.Request.Context(), state); origin != "" && config.DashboardOrigin(origin) != "" { + data.AppOrigin = origin + data.Relay = origin + "/oauth-return" + } + } if strings.HasPrefix(state, delegation.GoogleStatePrefix) { data.Status = "Signed in. Finishing in Warmbly… this window will close." } @@ -188,6 +194,7 @@ func (h *Handler) renderOAuthCallback(c *gin.Context, provider string) { c.Header("Content-Security-Policy", "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'") c.Header("Cross-Origin-Opener-Policy", "unsafe-none") c.Header("Referrer-Policy", "no-referrer") + c.Header("Cache-Control", "no-store") c.Header("Content-Type", "text/html; charset=utf-8") c.Status(http.StatusOK) _ = callbackPage.Execute(c.Writer, data) diff --git a/internal/api/handler/email_oauth_callback_test.go b/internal/api/handler/email_oauth_callback_test.go index 827a6242b..2fa3fdc26 100644 --- a/internal/api/handler/email_oauth_callback_test.go +++ b/internal/api/handler/email_oauth_callback_test.go @@ -1,6 +1,7 @@ package handler import ( + "context" "net/http" "net/http/httptest" "regexp" @@ -8,8 +9,53 @@ import ( "testing" "github.com/gin-gonic/gin" + "github.com/warmbly/warmbly/internal/app/email" ) +type returnOriginService struct { + email.EmailService + origin string + state string +} + +func (s *returnOriginService) OAuthReturnOrigin(_ context.Context, state string) string { + s.state = state + return s.origin +} + +func TestGoogleCallbackUsesStateBoundDashboard(t *testing.T) { + t.Setenv("APP_URL", "https://app.example.com") + t.Setenv("APP_ORIGIN", "") + t.Setenv("CORS_ALLOW_ORIGINS", "https://app.example.com,https://assessment.example.com") + for _, tt := range []struct{ name, origin, want string }{ + {"assessment", "https://assessment.example.com", "https://assessment.example.com"}, + {"legacy state", "", "https://app.example.com"}, + {"untrusted origin", "https://evil.example.com", "https://app.example.com"}, + } { + t.Run(tt.name, func(t *testing.T) { + svc := &returnOriginService{origin: tt.origin} + h := &Handler{EmailService: svc} + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/addresses/google/callback?code=c&state=w.nonce", nil) + h.EmailOAuthCallbackGmail(c) + body := w.Body.String() + if w.Code != http.StatusOK || svc.state != "w.nonce" || !strings.Contains(body, `var origin = "`+tt.want+`"`) || !strings.Contains(body, `var relay = "`+tt.want+`/oauth-return"`) { + t.Fatalf("callback did not use the expected state-bound target: %d %s", w.Code, body) + } + if w.Header().Get("Referrer-Policy") != "no-referrer" || w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("callback must not leak through referrers or caches") + } + }) + } + // Microsoft remains on its existing primary-dashboard routing. + svc := &returnOriginService{origin: "https://assessment.example.com"} + w := callbackRecorder(t, &Handler{EmailService: svc}, "code=c&state=w.nonce") + if svc.state != "" || !strings.Contains(w.Body.String(), `var relay = "https://app.example.com/oauth-return"`) { + t.Fatal("Google callback routing must not change Microsoft OAuth") + } +} + func callbackRecorder(t *testing.T, h *Handler, query string) *httptest.ResponseRecorder { t.Helper() gin.SetMode(gin.TestMode) diff --git a/internal/api/handler/email_onboarding.go b/internal/api/handler/email_onboarding.go index 89f75084d..7b00a97d9 100644 --- a/internal/api/handler/email_onboarding.go +++ b/internal/api/handler/email_onboarding.go @@ -51,7 +51,11 @@ func (h *Handler) StartEmailOAuth(c *gin.Context) { return } - resp, xerr := h.EmailService.OAuthStart(c.Request.Context(), userID, orgID, models.InboxProvider(req.Provider), req.LoginHint, req.Return == "web") + returnOrigin := "" + if req.Provider == string(models.InboxProviderGoogle) && req.Return == "web" { + returnOrigin = c.GetHeader("Origin") + } + resp, xerr := h.EmailService.OAuthStart(c.Request.Context(), userID, orgID, models.InboxProvider(req.Provider), req.LoginHint, req.Return == "web", returnOrigin) if xerr != nil { errx.Handle(c, xerr) return @@ -134,7 +138,7 @@ func (h *Handler) ReauthEmailOAuth(c *gin.Context) { return } - resp, xerr := h.EmailService.OAuthReauth(c.Request.Context(), userID, orgID, id) + resp, xerr := h.EmailService.OAuthReauth(c.Request.Context(), userID, orgID, id, c.GetHeader("Origin")) if xerr != nil { errx.Handle(c, xerr) return diff --git a/internal/app/email/cache.go b/internal/app/email/cache.go index d847288a4..1f9e277c9 100644 --- a/internal/app/email/cache.go +++ b/internal/app/email/cache.go @@ -7,6 +7,7 @@ import ( "time" "github.com/redis/go-redis/v9" + "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/observability/errs" @@ -57,3 +58,19 @@ func (s *emailService) takeOnboardingState(ctx context.Context, state string) (* } return &out, nil } + +// OAuthReturnOrigin reads routing metadata without consuming the single-use state. +func (s *emailService) OAuthReturnOrigin(ctx context.Context, state string) string { + if s.r == nil || !IsWebState(state) { + return "" + } + raw, err := s.r.Get(ctx, onboardingStateKey(state)).Bytes() + if err != nil { + return "" + } + var data models.EmailOnboardingState + if json.Unmarshal(raw, &data) != nil || data.Nonce != state || data.Provider != string(models.InboxProviderGoogle) { + return "" + } + return config.DashboardOrigin(data.ReturnOrigin) +} diff --git a/internal/app/email/onboarding.go b/internal/app/email/onboarding.go index ab14ebc04..596c63626 100644 --- a/internal/app/email/onboarding.go +++ b/internal/app/email/onboarding.go @@ -43,7 +43,10 @@ func newState(web bool) (string, error) { // OAuthStart issues a fresh state nonce and returns the provider-specific authorization URL. // The caller is expected to redirect the user to the URL and post back to OAuthFinish on return. -func (s *emailService) OAuthStart(ctx context.Context, userID string, orgID *uuid.UUID, provider models.InboxProvider, loginHint string, web bool) (*models.EmailOnboardingStartResponse, *errx.Error) { +func (s *emailService) OAuthStart(ctx context.Context, userID string, orgID *uuid.UUID, provider models.InboxProvider, loginHint string, web bool, returnOrigin string) (*models.EmailOnboardingStartResponse, *errx.Error) { + if returnOrigin != "" && (!web || config.DashboardOrigin(returnOrigin) == "") { + return nil, errx.ErrEmailOnboardReturnOrigin + } // A new mailbox only; OAuthReauth renews an existing one and is not gated. if provider == models.InboxProviderGoogle && !config.GoogleOAuthConnect() { return nil, errx.ErrEmailOnboardGoogleOAuthDisabled @@ -76,6 +79,7 @@ func (s *emailService) OAuthStart(ctx context.Context, userID string, orgID *uui Provider: string(provider), Nonce: state, CodeVerifier: verifier, + ReturnOrigin: returnOrigin, }); xerr != nil { return nil, xerr } diff --git a/internal/app/email/onboarding_gmail_gate_test.go b/internal/app/email/onboarding_gmail_gate_test.go index 7aff68dba..05c4b15d5 100644 --- a/internal/app/email/onboarding_gmail_gate_test.go +++ b/internal/app/email/onboarding_gmail_gate_test.go @@ -14,11 +14,11 @@ import ( // instance with no Google client configured still gets the policy answer // rather than the setup one. func TestOAuthStart_GmailRefusedUnlessEnabled(t *testing.T) { - t.Setenv("BOX_GOOGLE_OAUTH_CONNECT", "") + t.Setenv("BOX_GOOGLE_OAUTH_CONNECT", "false") org := uuid.New() svc := &emailService{} - _, xerr := svc.OAuthStart(context.Background(), uuid.NewString(), &org, models.InboxProviderGoogle, "", false) + _, xerr := svc.OAuthStart(context.Background(), uuid.NewString(), &org, models.InboxProviderGoogle, "", false, "") if xerr != errx.ErrEmailOnboardGoogleOAuthDisabled { t.Fatalf("expected ErrEmailOnboardGoogleOAuthDisabled, got %v", xerr) } @@ -28,13 +28,15 @@ func TestOAuthStart_GmailRefusedUnlessEnabled(t *testing.T) { // configured that is the not-configured error, proving the gate stepped aside. func TestOAuthStart_GmailAllowedWhenEnabled(t *testing.T) { t.Setenv("BOX_GOOGLE_OAUTH_CONNECT", "true") + t.Setenv("BOX_GOOGLE_CLIENT_ID", "test-client") + t.Setenv("BOX_GOOGLE_CLIENT_SECRET", "test-secret") if !config.GoogleOAuthConnect() { t.Fatal("BOX_GOOGLE_OAUTH_CONNECT=true must enable Google sign-in for new mailboxes") } org := uuid.New() svc := &emailService{} - _, xerr := svc.OAuthStart(context.Background(), uuid.NewString(), &org, models.InboxProviderGoogle, "", false) + _, xerr := svc.OAuthStart(context.Background(), uuid.NewString(), &org, models.InboxProviderGoogle, "", false, "") if xerr != errx.ErrEmailOnboardGoogleNotConfigured { t.Fatalf("expected the gate to step aside (ErrEmailOnboardGoogleNotConfigured), got %v", xerr) } @@ -46,7 +48,7 @@ func TestOAuthReauth_GmailNotGated(t *testing.T) { t.Setenv("BOX_GOOGLE_OAUTH_CONNECT", "") svc, repo, _, _ := reauthFixture("gmail", "owner@example.com") - _, xerr := svc.OAuthReauth(context.Background(), repo.account.UserID, repo.account.OrganizationID, repo.account.ID) + _, xerr := svc.OAuthReauth(context.Background(), repo.account.UserID, repo.account.OrganizationID, repo.account.ID, "") if xerr == errx.ErrEmailOnboardGoogleOAuthDisabled { t.Fatal("reauth of an existing Gmail mailbox must not be refused by the new-mailbox gate") } diff --git a/internal/app/email/onboarding_origin_test.go b/internal/app/email/onboarding_origin_test.go new file mode 100644 index 000000000..6ea84fbb0 --- /dev/null +++ b/internal/app/email/onboarding_origin_test.go @@ -0,0 +1,86 @@ +package email + +import ( + "context" + "os" + "testing" + + "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/config" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/infrastructure/cache" + "github.com/warmbly/warmbly/internal/models" + "golang.org/x/oauth2" +) + +func TestOAuthStartRejectsUntrustedReturnOrigin(t *testing.T) { + t.Setenv("APP_URL", "https://app.example.com") + t.Setenv("APP_ORIGIN", "") + t.Setenv("CORS_ALLOW_ORIGINS", "https://assessment.example.com") + svc := &emailService{} + org := uuid.New() + for _, tt := range []struct { + origin string + web bool + }{ + {"https://evil.example.com", true}, + {"https://assessment.example.com/path", true}, + {"https://assessment.example.com", false}, + } { + if _, xerr := svc.OAuthStart(context.Background(), uuid.NewString(), &org, models.InboxProviderGoogle, "", tt.web, tt.origin); xerr != errx.ErrEmailOnboardReturnOrigin { + t.Fatalf("untrusted return origin should fail before storing state, got %v", xerr) + } + } +} + +func TestGoogleOAuthOriginStaysBoundToSingleUseState(t *testing.T) { + redisURL := os.Getenv("WARMBLY_TEST_REDIS") + if redisURL == "" { + t.Skip("WARMBLY_TEST_REDIS not set") + } + c, err := cache.New(redisURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.Close() }) + t.Setenv("APP_URL", "https://app.example.com") + t.Setenv("APP_ORIGIN", "") + t.Setenv("CORS_ALLOW_ORIGINS", "https://assessment.example.com") + t.Setenv("BOX_GOOGLE_CLIENT_ID", "client") + t.Setenv("BOX_GOOGLE_CLIENT_SECRET", "secret") + t.Setenv("BOX_GOOGLE_OAUTH_CONNECT", "") + svc, repo, _, _ := reauthFixture("gmail", "owner@example.com") + svc.r = c + svc.oauthInbox = &config.Oauth2Inbox{Google: &oauth2.Config{ClientID: "client", ClientSecret: "secret", Endpoint: oauth2.Endpoint{AuthURL: "https://accounts.google.com/o/oauth2/auth"}}} + ctx := context.Background() + for _, reauth := range []bool{false, true} { + var resp *models.EmailOnboardingStartResponse + var xerr *errx.Error + if reauth { + t.Setenv("BOX_GOOGLE_OAUTH_CONNECT", "false") + resp, xerr = svc.OAuthReauth(ctx, repo.account.UserID, repo.account.OrganizationID, repo.account.ID, "https://assessment.example.com") + } else { + resp, xerr = svc.OAuthStart(ctx, repo.account.UserID, repo.account.OrganizationID, models.InboxProviderGoogle, "", true, "https://assessment.example.com") + } + if xerr != nil { + t.Fatal(xerr) + } + t.Cleanup(func() { _ = c.Del(ctx, onboardingStateKey(resp.State)).Err() }) + if origin := svc.OAuthReturnOrigin(ctx, resp.State); origin != "https://assessment.example.com" { + t.Fatalf("state-bound origin = %q", origin) + } + if svc.OAuthReturnOrigin(ctx, "w.unknown") != "" || svc.OAuthReturnOrigin(ctx, "native") != "" { + t.Fatal("unknown or native state must not select a dashboard") + } + sess, xerr := svc.takeOnboardingState(ctx, resp.State) + if xerr != nil || sess.ReturnOrigin != "https://assessment.example.com" || sess.CodeVerifier == "" || sess.UserID != repo.account.UserID || (sess.EmailAccountID != nil) != reauth { + t.Fatalf("callback metadata lookup must preserve the finish state: %+v, %v", sess, xerr) + } + if svc.OAuthReturnOrigin(ctx, resp.State) != "" { + t.Fatal("consumed state must not select a dashboard") + } + if _, xerr := svc.takeOnboardingState(ctx, resp.State); xerr != errx.ErrEmailOnboardState { + t.Fatal("OAuth finish state must remain single-use") + } + } +} diff --git a/internal/app/email/reauth.go b/internal/app/email/reauth.go index 99a23fab1..3e7af4127 100644 --- a/internal/app/email/reauth.go +++ b/internal/app/email/reauth.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" "github.com/rs/zerolog/log" + "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/observability/errs" @@ -21,7 +22,7 @@ import ( // OAuthReauth issues an authorization URL that renews an existing mailbox's // tokens. Same round trip as OAuthStart, but the state carries the account id // so the finish leg updates in place instead of connecting a duplicate. -func (s *emailService) OAuthReauth(ctx context.Context, userID string, orgID *uuid.UUID, accountID uuid.UUID) (*models.EmailOnboardingStartResponse, *errx.Error) { +func (s *emailService) OAuthReauth(ctx context.Context, userID string, orgID *uuid.UUID, accountID uuid.UUID, returnOrigin string) (*models.EmailOnboardingStartResponse, *errx.Error) { if orgID == nil { return nil, errx.ErrNoOrganization } @@ -35,6 +36,11 @@ func (s *emailService) OAuthReauth(ctx context.Context, userID string, orgID *uu } provider := models.InboxProvider(account.Provider) + if provider != models.InboxProviderGoogle { + returnOrigin = "" + } else if returnOrigin != "" && config.DashboardOrigin(returnOrigin) == "" { + return nil, errx.ErrEmailOnboardReturnOrigin + } if provider == models.InboxProviderSMTPIMAP { return nil, errx.ErrEmailReauthProvider } @@ -72,6 +78,7 @@ func (s *emailService) OAuthReauth(ctx context.Context, userID string, orgID *uu Nonce: state, EmailAccountID: &accountID, CodeVerifier: verifier, + ReturnOrigin: returnOrigin, }); xerr != nil { return nil, xerr } diff --git a/internal/app/email/reauth_test.go b/internal/app/email/reauth_test.go index 657a6c221..b6215d013 100644 --- a/internal/app/email/reauth_test.go +++ b/internal/app/email/reauth_test.go @@ -181,7 +181,7 @@ func TestFinishReauth_KeepsErrorsWhenReactivationFails(t *testing.T) { func TestOAuthReauth_RefusesSMTPIMAPMailboxes(t *testing.T) { svc, repo, _, _ := reauthFixture("smtp_imap", "owner@example.com") - _, xerr := svc.OAuthReauth(context.Background(), repo.account.UserID, repo.account.OrganizationID, repo.account.ID) + _, xerr := svc.OAuthReauth(context.Background(), repo.account.UserID, repo.account.OrganizationID, repo.account.ID, "") if xerr != errx.ErrEmailReauthProvider { t.Fatalf("expected ErrEmailReauthProvider, got %v", xerr) } diff --git a/internal/app/email/service.go b/internal/app/email/service.go index 3031dfd19..13118bc45 100644 --- a/internal/app/email/service.go +++ b/internal/app/email/service.go @@ -82,7 +82,8 @@ type EmailService interface { // trip renewed an existing mailbox (OAuthReauth) rather than connecting // a new one, so the handler can audit and answer accordingly. // loginHint preselects an address in the provider's picker; "" for none. - OAuthStart(ctx context.Context, userID string, orgID *uuid.UUID, provider models.InboxProvider, loginHint string, web bool) (*models.EmailOnboardingStartResponse, *errx.Error) + OAuthStart(ctx context.Context, userID string, orgID *uuid.UUID, provider models.InboxProvider, loginHint string, web bool, returnOrigin string) (*models.EmailOnboardingStartResponse, *errx.Error) + OAuthReturnOrigin(ctx context.Context, state string) string // authorize runs before the code is exchanged, against the organization the // state names, so a caller removed mid-flow cannot finish it. OAuthFinish(ctx context.Context, userID, code, state string, authorize FinishAuthorizer) (*models.Email, bool, *errx.Error) @@ -93,7 +94,7 @@ type EmailService interface { OnboardSMTPIMAPBulk(ctx context.Context, userID string, orgID *uuid.UUID, rows []models.NewSMTPIMAPAccount) *models.MailboxBulkResult // OAuthReauth starts an OAuth round trip that renews the tokens of an // existing Gmail/Outlook mailbox after the provider invalidated them. - OAuthReauth(ctx context.Context, userID string, orgID *uuid.UUID, accountID uuid.UUID) (*models.EmailOnboardingStartResponse, *errx.Error) + OAuthReauth(ctx context.Context, userID string, orgID *uuid.UUID, accountID uuid.UUID, returnOrigin string) (*models.EmailOnboardingStartResponse, *errx.Error) // UpdateSMTPIMAPCredentials validates replacement credentials against a // live worker, stores them, and puts the mailbox back to work. UpdateSMTPIMAPCredentials(ctx context.Context, orgID *uuid.UUID, accountID uuid.UUID, creds *models.SmtpImap) (*models.Email, *errx.Error) diff --git a/internal/config/dashboard_origin_test.go b/internal/config/dashboard_origin_test.go new file mode 100644 index 000000000..e450b43f4 --- /dev/null +++ b/internal/config/dashboard_origin_test.go @@ -0,0 +1,23 @@ +package config + +import "testing" + +func TestDashboardOrigin(t *testing.T) { + t.Setenv("APP_URL", "https://app.example.com") + t.Setenv("APP_ORIGIN", "https://primary.example.com") + t.Setenv("CORS_ALLOW_ORIGINS", "https://app.example.com, https://assessment.example.com,http://localhost:5173") + for _, value := range []string{"https://app.example.com", "https://primary.example.com", "https://assessment.example.com", "http://localhost:5173"} { + if got := DashboardOrigin(value); got != value { + t.Errorf("DashboardOrigin(%q) = %q", value, got) + } + } + for _, value := range []string{"", "null", "*", "https://evil.example.com", "https://assessment.example.com.evil.test", "http://assessment.example.com", "https://assessment.example.com:444", "https://assessment.example.com/", "https://assessment.example.com/redirect", "https://assessment.example.com?x=1", "https://assessment.example.com?", "https://assessment.example.com#fragment", "https://user@assessment.example.com", "javascript:alert(1)"} { + if got := DashboardOrigin(value); got != "" { + t.Errorf("DashboardOrigin(%q) = %q, want empty", value, got) + } + } + t.Setenv("CORS_ALLOW_ORIGINS", "*") + if got := DashboardOrigin("https://evil.example.com"); got != "" { + t.Fatal("wildcard must not allow an OAuth return origin") + } +} diff --git a/internal/config/endpoints.go b/internal/config/endpoints.go index a2a06946b..20c2d5921 100644 --- a/internal/config/endpoints.go +++ b/internal/config/endpoints.go @@ -34,6 +34,23 @@ func AppBaseURL() string { return "https://app.warmbly.com" } +// DashboardOrigin allows exact configured origins only, never a CORS wildcard. +func DashboardOrigin(value string) string { + u, err := url.Parse(value) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.ForceQuery { + return "" + } + allowed := splitCSV(os.Getenv("CORS_ALLOW_ORIGINS")) + allowed = append(allowed, os.Getenv("APP_ORIGIN"), AppBaseURL()) + for _, candidate := range allowed { + v, err := url.Parse(strings.TrimSpace(candidate)) + if err == nil && v.User == nil && v.Scheme+"://"+v.Host == value { + return value + } + } + return "" +} + // inferredAppBaseURL reconstructs the dashboard origin from the rest of the // deployment's own configuration. CORS_ALLOW_ORIGINS is exact when it is set // (the dashboard is the first origin the browser calls the API from); diff --git a/internal/config/inbox.go b/internal/config/inbox.go index 333c6f1e7..459a622f5 100644 --- a/internal/config/inbox.go +++ b/internal/config/inbox.go @@ -16,12 +16,17 @@ type Oauth2Inbox struct { } // GoogleOAuthConnect reports whether a NEW Gmail mailbox may be connected with -// Google sign-in. Off by default: the connect dialog walks people through an -// app password over IMAP and SMTP instead, which needs no verified Google app. -// Mailboxes already connected with Google sign-in are untouched either way and -// can still be re-authorized. BOX_GOOGLE_OAUTH_CONNECT=true turns it on. +// Google sign-in. Configured clients default on; an explicit flag overrides it. +// Existing mailboxes can still be re-authorized regardless of this gate. func GoogleOAuthConnect() bool { - b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("BOX_GOOGLE_OAUTH_CONNECT"))) + if strings.TrimSpace(os.Getenv("BOX_GOOGLE_CLIENT_ID")) == "" || strings.TrimSpace(os.Getenv("BOX_GOOGLE_CLIENT_SECRET")) == "" { + return false + } + value := strings.TrimSpace(os.Getenv("BOX_GOOGLE_OAUTH_CONNECT")) + if value == "" { + return true + } + b, err := strconv.ParseBool(value) return err == nil && b } diff --git a/internal/config/inbox_test.go b/internal/config/inbox_test.go new file mode 100644 index 000000000..16ce7c704 --- /dev/null +++ b/internal/config/inbox_test.go @@ -0,0 +1,29 @@ +package config + +import "testing" + +func TestGoogleOAuthConnect(t *testing.T) { + for _, tt := range []struct { + name, flag, id, secret string + want bool + }{ + {"unconfigured", "", "", "", false}, + {"configured defaults on", "", "client", "secret", true}, + {"explicit enable", "true", "client", "secret", true}, + {"explicit disable", "false", "client", "secret", false}, + {"invalid fails closed", "invalid", "client", "secret", false}, + {"missing id", "true", "", "secret", false}, + {"missing secret", "true", "client", "", false}, + {"blank credentials", "true", " ", " ", false}, + {"trim flag", " true ", "client", "secret", true}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("BOX_GOOGLE_OAUTH_CONNECT", tt.flag) + t.Setenv("BOX_GOOGLE_CLIENT_ID", tt.id) + t.Setenv("BOX_GOOGLE_CLIENT_SECRET", tt.secret) + if got := GoogleOAuthConnect(); got != tt.want { + t.Fatalf("GoogleOAuthConnect() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/errx/common.go b/internal/errx/common.go index 9691e76a3..52b2f6ec9 100644 --- a/internal/errx/common.go +++ b/internal/errx/common.go @@ -142,6 +142,7 @@ var ( ErrEmailOnboardGoogleOAuthDisabled = NewWithIdentifier(Forbidden, "mailbox_gmail_oauth_disabled", "New Gmail mailboxes connect with an app password over IMAP and SMTP on this deployment, not with Google sign-in. Mailboxes already connected with Google sign-in keep working and can still be re-authorized. See https://docs.warmbly.com/guides/mailboxes/#gmail-and-google-workspace") ErrEmailOnboardState = New(BadRequest, "Invalid or expired onboarding state.") + ErrEmailOnboardReturnOrigin = NewWithIdentifier(BadRequest, "mailbox_oauth_return_origin", "This dashboard origin is not allowed for mailbox OAuth. Add its exact origin to CORS_ALLOW_ORIGINS.") ErrEmailOnboardCode = New(BadRequest, "Authorization code is missing or invalid.") ErrEmailOnboardExchange = New(BadRequest, "Could not exchange the authorization code with the provider.") ErrEmailOnboardUserInfo = New(BadRequest, "Could not read account details from the provider.") diff --git a/internal/models/email.go b/internal/models/email.go index 78374b286..a6c4c7510 100644 --- a/internal/models/email.go +++ b/internal/models/email.go @@ -620,6 +620,8 @@ type EmailOnboardingState struct { OrganizationID *uuid.UUID `json:"organization_id,omitempty"` Provider string `json:"provider"` Nonce string `json:"nonce"` + // ReturnOrigin binds the callback to the allowlisted dashboard that started it. + ReturnOrigin string `json:"return_origin,omitempty"` // EmailAccountID marks a re-authorization round trip: the finish leg // renews this mailbox's tokens instead of connecting a new one. EmailAccountID *uuid.UUID `json:"email_account_id,omitempty"` diff --git a/web/.env.example b/web/.env.example index 451bc99ac..f9b2ed7b4 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,3 +1,4 @@ VITE_APP_URL="http://localhost:5173" VITE_API_URL="http://localhost:8080" VITE_TURNSTILE_KEY="1x00000000000000000000AA" +VITE_GMAIL_OAUTH_CONNECT="false" diff --git a/web/docker-entrypoint.sh b/web/docker-entrypoint.sh index 639643828..fd42c4e67 100644 --- a/web/docker-entrypoint.sh +++ b/web/docker-entrypoint.sh @@ -23,6 +23,7 @@ window.__WARMBLY_ENV__ = { API_URL: "$(js "${WARMBLY_API_URL:-}")", APP_URL: "$(js "${WARMBLY_APP_URL:-}")", TURNSTILE_KEY: "$(js "${WARMBLY_TURNSTILE_KEY:-}")", + GMAIL_OAUTH_CONNECT: "$(js "${WARMBLY_GMAIL_OAUTH_CONNECT:-}")", BETA_NOTICE: "$(js "${WARMBLY_BETA_NOTICE:-}")", SENTRY_DSN: "$(js "${WARMBLY_SENTRY_DSN:-}")", SENTRY_ENVIRONMENT: "$(js "${WARMBLY_SENTRY_ENVIRONMENT:-}")", diff --git a/web/src/components/app/emails/GmailAppPasswordPanel.tsx b/web/src/components/app/emails/GmailAppPasswordPanel.tsx index 1e31cd0d0..beb82196a 100644 --- a/web/src/components/app/emails/GmailAppPasswordPanel.tsx +++ b/web/src/components/app/emails/GmailAppPasswordPanel.tsx @@ -1,7 +1,7 @@ // GmailAppPasswordPanel — the Gmail path of the connect modal. // // One Gmail or Google Workspace mailbox over IMAP and SMTP with an app -// password; per-mailbox Google sign-in is being retired, and a whole Workspace +// password; per-mailbox Google sign-in is deployment-dependent, and a whole Workspace // domain goes through an admin grant instead. The server settings never change, so // the only things a person has to produce are the app password and the // address, and the panel walks them to those in three steps: turn on 2-Step diff --git a/web/src/components/app/emails/import/RunStep.tsx b/web/src/components/app/emails/import/RunStep.tsx index 1f5dd78e3..e50d0c0f4 100644 --- a/web/src/components/app/emails/import/RunStep.tsx +++ b/web/src/components/app/emails/import/RunStep.tsx @@ -38,7 +38,6 @@ import { type ImportRowStatus, type MailboxImport, } from "@/lib/api/models/app/emails/MailboxImport"; -import useAuthConfig from "@/lib/api/hooks/auth/useAuthConfig"; import useMailboxOAuth from "@/hooks/useMailboxOAuth"; import useMicrosoftAdminConsent from "@/hooks/useMicrosoftAdminConsent"; import { useGrantConfig } from "@/lib/api/hooks/app/emails/useMailboxGrants"; @@ -103,7 +102,6 @@ export default function RunStep({ const job = useMailboxImport(importId); const retry = useRetryMailboxImport(importId); const cancel = useCancelMailboxImport(importId); - const gmailOAuth = useAuthConfig().config.gmail_oauth_connect === true; const [signingLine, setSigningLine] = React.useState(null); const [downloading, setDownloading] = React.useState(false); @@ -175,7 +173,7 @@ export default function RunStep({ const signInProvider = (row: ImportRow) => { const provider = mailHostOAuthProvider(row.mail_host); if (!provider) return null; - if (provider === "gmail" && !gmailOAuth && !oauth.viaCloud) return null; + if (provider === "gmail" && !oauth.gmailAvailable) return null; return provider; }; diff --git a/web/src/components/app/emails/migration/SigninMigrationBanner.tsx b/web/src/components/app/emails/migration/SigninMigrationBanner.tsx index 76c1739f2..8c875bfde 100644 --- a/web/src/components/app/emails/migration/SigninMigrationBanner.tsx +++ b/web/src/components/app/emails/migration/SigninMigrationBanner.tsx @@ -1,5 +1,5 @@ // The mailboxes page's notice that some mailboxes still sign in with Google on -// their own, which is being retired. A notice only: nothing stops working. +// their own, with optional ways to switch their connection method. // "Later" hides it for a week, per workspace. import React from "react"; import { AlertTriangleIcon, ArrowRightIcon } from "lucide-react"; @@ -43,8 +43,8 @@ export default function SigninMigrationBanner({ total, onOpen }: { total: number

- {total === 1 ? "1 mailbox connects" : `${total.toLocaleString()} mailboxes connect`} with Google sign-in, which is being - retired. Move {total === 1 ? "it" : "them"} to keep {total === 1 ? "it" : "them"} working. + {total === 1 ? "1 mailbox connects" : `${total.toLocaleString()} mailboxes connect`} with Google sign-in. + You can optionally switch to an admin grant or an app password without losing history.

@@ -77,15 +77,15 @@ export function SigninRetiringChip({ onClick, className }: { onClick: () => void e.stopPropagation(); onClick(); }} - title="Google sign-in is being retired. Move this mailbox to keep it working." - aria-label="Google sign-in retiring: move this mailbox" + title="This mailbox uses Google sign-in. You can optionally switch its connection method." + aria-label="Google sign-in: switch connection method" className={cn( "inline-flex items-center gap-1 h-[18px] pl-0.5 pr-0.5 md:pr-1.5 rounded-full border border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100 transition-colors shrink-0", className, )} > - Sign-in retiring + Google sign-in ); } diff --git a/web/src/components/app/emails/migration/SigninMigrationDialog.tsx b/web/src/components/app/emails/migration/SigninMigrationDialog.tsx index 31e9b24e9..755cd0ce6 100644 --- a/web/src/components/app/emails/migration/SigninMigrationDialog.tsx +++ b/web/src/components/app/emails/migration/SigninMigrationDialog.tsx @@ -190,8 +190,8 @@ export default function SigninMigrationDialog({ ) : (

- Google sign-in for single mailboxes is being retired. Nothing stops working today. Moving keeps - each mailbox, with its history, campaigns and warmup. + Switching from Google sign-in is optional. Moving keeps each mailbox, with its history, + campaigns and warmup.

{groups.map((g) => (
-

Google sign-in is being retired

+

Other connection methods

- This mailbox signs in with Google on its own. Nothing stops working today; move it to keep it working. It keeps - its history, campaigns and warmup. + This mailbox uses Google sign-in. You can keep it, or switch to an admin grant or an app password. + Switching preserves its history, campaigns and warmup.

diff --git a/web/src/components/app/modals/AddEmailModal.tsx b/web/src/components/app/modals/AddEmailModal.tsx index 92e23a280..72beff88d 100644 --- a/web/src/components/app/modals/AddEmailModal.tsx +++ b/web/src/components/app/modals/AddEmailModal.tsx @@ -8,7 +8,7 @@ // Flow: // provider picker ─► Google ─► whole Workspace domain ─► /emails/grants (GrantImportWizard) // │ ├► app password walkthrough ─► /emails/onboarding/smtp-imap -// │ └► Sign in with Google ────────┐ (retiring; only when gmail_oauth_connect is on) +// │ └► Sign in with Google ────────┐ (when frontend and backend allow it) // ├► Microsoft ─► whole organization ─► /emails/grants (GrantImportWizard) // │ └► sign in one mailbox ──────┴► /emails/onboarding/oauth/finish // ├► smtp/imap form ──────────► /emails/onboarding/smtp-imap @@ -17,9 +17,8 @@ // // Google and Microsoft each open a method chooser. The admin grant is the // recommended method wherever the instance has it set up, and is left out -// where it is not. Per-mailbox Google sign-in is being retired: it is offered -// only when the deployment allows it (gmail_oauth_connect on /auth/config), -// marked as retiring, and mailboxes already on it are asked to move. +// where it is not. Per-mailbox Google sign-in requires the frontend's opt-in +// and either a confirmed backend capability or a Warmbly Cloud connection. // // Every path can run into the workspace's mailbox allowance; that answer // (code mailbox_allowance_reached) opens MailboxAllowanceDialog instead of a @@ -158,10 +157,6 @@ export default function AddEmailModal() { const allowance = useMailboxAllowance(user.addEmail); const [allowanceOpen, setAllowanceOpen] = React.useState(false); const [allowanceReached, setAllowanceReached] = React.useState(false); - // Whether a new Gmail mailbox may use Google sign-in here. Anything but an - // explicit yes (an older backend, the unreachable fallback) takes the - // app-password walkthrough, which works on every deployment. - const gmailOAuth = useAuthConfig().config.gmail_oauth_connect === true; const openAllowance = React.useCallback((reached = false) => { setAllowanceReached(reached); setAllowanceOpen(true); @@ -181,6 +176,7 @@ export default function AddEmailModal() { }); const oauthBusy = oauth.busy; const viaCloud = oauth.viaCloud; + const gmailOAuth = oauth.gmailAvailable; const resetOAuth = oauth.reset; // Reset when the modal closes. @@ -678,8 +674,6 @@ interface Method { title: string; sub: string; pill?: { label: string; tone: "sky" | "amber" }; - /** Offered, but steered away from. */ - retiring?: string; } // The ways to connect a Google or Microsoft mailbox. The admin grant leads @@ -722,8 +716,6 @@ function MethodChooser({ icon: , title: "Sign in with Google", sub: viaCloud ? "One mailbox at a time, through Warmbly Cloud." : "One mailbox at a time, through Google's consent screen.", - pill: { label: "Being retired", tone: "amber" }, - retiring: "Still works for now. It will be discontinued, so prefer the whole domain or an app password.", }); } } else { @@ -753,7 +745,7 @@ function MethodChooser({

How should Warmbly connect?

- {provider === "google" ? "Personal @gmail.com? Use an app password." : "Personal @outlook.com or @hotmail.com? Sign in one mailbox."} + {provider === "google" ? (gmailOAuth ? "Connect one mailbox with Google sign-in or an app password." : "Personal @gmail.com? Use an app password.") : "Personal @outlook.com or @hotmail.com? Sign in one mailbox."}

@@ -770,24 +762,16 @@ function MethodChooser({ initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.04 + i * 0.05, duration: 0.18, ease: "easeOut" }} - className={cn( - "w-full rounded-md border px-3 py-3 flex items-start gap-3 text-left group transition-colors", - m.retiring - ? "border-dashed border-slate-200 bg-slate-50/40 hover:bg-slate-50" - : "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50", - )} + className="w-full rounded-md border px-3 py-3 flex items-start gap-3 text-left group transition-colors border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50" >
{m.icon}
- {m.title} + {m.title} {m.pill && (

{m.sub}

- {m.retiring &&

{m.retiring}

}
@@ -896,19 +879,6 @@ function OAuthPanel({ const Icon = provider === "gmail" ? Google : Outlook; return (
- {provider === "gmail" && ( -
-
- - Being retired - - Google sign-in for single mailboxes -
-

- Still works for now. It will be discontinued, so prefer the whole domain or an app password. -

-
- )}
diff --git a/web/src/hooks/useGmailOAuthConnect.test.tsx b/web/src/hooks/useGmailOAuthConnect.test.tsx new file mode 100644 index 000000000..b3b9609c4 --- /dev/null +++ b/web/src/hooks/useGmailOAuthConnect.test.tsx @@ -0,0 +1,41 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const auth = vi.hoisted(() => ({ gmail_oauth_connect: false })); +vi.mock("@/lib/api/hooks/auth/useAuthConfig", () => ({ + default: () => ({ config: auth }), +})); + +beforeEach(() => { + vi.resetModules(); + delete window.__WARMBLY_ENV__; + vi.stubEnv("VITE_GMAIL_OAUTH_CONNECT", undefined); + auth.gmail_oauth_connect = false; +}); + +afterEach(() => { + vi.unstubAllEnvs(); + delete window.__WARMBLY_ENV__; +}); + +describe("Google mailbox OAuth deployment capability", () => { + it.each([ + { name: "defaults off even with backend support", backend: true, expected: false }, + { name: "uses runtime opt-in with backend support", runtime: "true", backend: true, expected: true }, + { name: "respects backend disable", runtime: "true", backend: false, expected: false }, + { name: "uses Vite opt-in in development", build: "true", backend: true, expected: true }, + { name: "runtime off overrides a build-time opt-in", runtime: "false", build: "true", backend: true, expected: false }, + { name: "ignores an invalid frontend setting", runtime: "invalid", backend: true, expected: false }, + { name: "trims a true frontend setting", runtime: " TRUE ", backend: true, expected: true }, + { name: "requires frontend opt-in for Cloud", viaCloud: true, backend: false, expected: false }, + { name: "allows opted-in Cloud-brokered consent", runtime: "true", viaCloud: true, backend: false, expected: true }, + ])("$name", async ({ runtime, build, backend, viaCloud, expected }) => { + window.__WARMBLY_ENV__ = { GMAIL_OAUTH_CONNECT: runtime }; + vi.stubEnv("VITE_GMAIL_OAUTH_CONNECT", build); + auth.gmail_oauth_connect = backend; + const { default: useGmailOAuthConnect } = await import("./useGmailOAuthConnect"); + const { result, unmount } = renderHook(() => useGmailOAuthConnect(viaCloud)); + expect(result.current).toBe(expected); + unmount(); + }); +}); diff --git a/web/src/hooks/useGmailOAuthConnect.ts b/web/src/hooks/useGmailOAuthConnect.ts new file mode 100644 index 000000000..b7f5e3a5b --- /dev/null +++ b/web/src/hooks/useGmailOAuthConnect.ts @@ -0,0 +1,7 @@ +import useAuthConfig from "@/lib/api/hooks/auth/useAuthConfig"; +import { GMAIL_OAUTH_CONNECT } from "@/lib/information"; + +export default function useGmailOAuthConnect(viaCloud = false): boolean { + const { config } = useAuthConfig(); + return GMAIL_OAUTH_CONNECT && (viaCloud || config.gmail_oauth_connect === true); +} diff --git a/web/src/hooks/useMailboxOAuth.ts b/web/src/hooks/useMailboxOAuth.ts index 86a6fc3c5..e3f4e269b 100644 --- a/web/src/hooks/useMailboxOAuth.ts +++ b/web/src/hooks/useMailboxOAuth.ts @@ -19,6 +19,7 @@ import { finishCloudOAuth, startCloudOAuth } from "@/lib/api/client/app/cloudlin import type { CloudOAuthDoneMessage } from "@/app/cloud-oauth/done/page"; import { capture } from "@/lib/productAnalytics"; import useCloudPool from "@/hooks/useCloudPool"; +import useGmailOAuthConnect from "@/hooks/useGmailOAuthConnect"; import { BLOCKED_WAIT_MS, closePopup, navigatePopup, notifyPopupBlocked, reservePopup } from "@/lib/popup"; export type MailboxOAuthProvider = "gmail" | "outlook"; @@ -75,6 +76,7 @@ export default function useMailboxOAuth(options: MailboxOAuthOptions = {}) { const qc = useQueryClient(); const pool = useCloudPool(); const viaCloud = pool.connected; + const gmailAvailable = useGmailOAuthConnect(viaCloud); const [busy, setBusy] = React.useState(null); // Microsoft only: forwarded to an administrator when the organization requires approval. @@ -229,7 +231,7 @@ export default function useMailboxOAuth(options: MailboxOAuthOptions = {}) { // Call it straight from the click: Safari blocks a window opened after an await. const start = React.useCallback( async (provider: MailboxOAuthProvider, opts: { loginHint?: string } = {}) => { - if (busy) return; + if (busy || (provider === "gmail" && !gmailAvailable)) return; const name = `connect-${provider}`; const reserved = reservePopup(name); setBusy(provider); @@ -279,7 +281,7 @@ export default function useMailboxOAuth(options: MailboxOAuthOptions = {}) { toast.error(buildError(e)); } }, - [busy, viaCloud, launch], + [busy, viaCloud, gmailAvailable, launch], ); // Forget any popup still out, e.g. when the dialog that opened it closes. @@ -291,5 +293,5 @@ export default function useMailboxOAuth(options: MailboxOAuthOptions = {}) { popupRef.current = null; }, []); - return { busy, start, reset, viaCloud, selfHosted: pool.selfHosted, adminConsentUrl }; + return { busy, start, reset, viaCloud, gmailAvailable, selfHosted: pool.selfHosted, adminConsentUrl }; } diff --git a/web/src/lib/information.ts b/web/src/lib/information.ts index 1c14fd392..08f788cd4 100644 --- a/web/src/lib/information.ts +++ b/web/src/lib/information.ts @@ -8,6 +8,8 @@ export const API_URL = runtimeEnv("API_URL", import.meta.env.VITE_API_URL); // (no path), so this is the single place the /v1 prefix is applied. export const API_BASE_URL = `${API_URL}/v1`; export const TURNSTILE_KEY = runtimeEnv("TURNSTILE_KEY", import.meta.env.VITE_TURNSTILE_KEY); +// Visibility only; the backend still decides whether a new mailbox can connect. +export const GMAIL_OAUTH_CONNECT = runtimeEnv("GMAIL_OAUTH_CONNECT", import.meta.env.VITE_GMAIL_OAUTH_CONNECT, "false").trim().toLowerCase() === "true"; // Shown once in a dialog and then as a header pill. Empty means this is not a // preview deployment and nothing renders, which is what production wants