Merge pull request #594 from warmbly/feature/disable-google-oauth-new-mailboxes

feat: route new Gmail mailboxes through a guided app-password connect instead of Google sign-in, behind BOX_GOOGLE_OAUTH_CONNECT, leaving existing OAuth mailboxes sending and re-authorizable
This commit is contained in:
Matthew Meszaros
2026-09-19 06:11:15 +00:00
committed by GitHub
19 changed files with 607 additions and 365 deletions
+2
View File
@@ -399,6 +399,8 @@ These never accept an API key. They depend on a human-bound session: billing flo
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 `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)
- `GET /auth/providers`, `POST /auth/apple`, `POST /auth/google` (native-app social sign-in)
+17
View File
@@ -169,6 +169,23 @@ Returned when authenticated but lacking necessary permissions.
- Verify IP restrictions if configured
- Request additional permissions if needed
#### `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.
```json
{
"error": "Forbidden",
"message": "New Gmail mailboxes connect with an app password over IMAP and SMTP on this deployment, not with Google sign-in.",
"code": "mailbox_gmail_oauth_disabled",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}
```
**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
#### Registration and invitation refusals
Signup and invitation refusals carry their own `code`, so a client can branch on the specific condition instead of matching on text. They describe the deployment's policy and never say anything about whether a given address exists.
@@ -722,7 +722,7 @@ Begins an OAuth round trip for a Gmail or Outlook mailbox and returns the provid
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `provider` | string | yes | `gmail` or `outlook`. |
| `provider` | string | yes | `gmail` or `outlook`. `gmail` is refused with `403` `mailbox_gmail_oauth_disabled` unless `GET /auth/config` reports `gmail_oauth_connect: true`; new Gmail mailboxes go through [Connect SMTP/IMAP](#connect-smtpimap) with an app password instead. Re-authorizing an existing Gmail mailbox is not gated. |
```json
{
@@ -432,7 +432,8 @@ 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` | Connect Gmail and Google Workspace mailboxes. Redirect URI is your API base plus `/addresses/google/callback` | unset |
| `BOX_GOOGLE_CLIENT_ID`, `BOX_GOOGLE_CLIENT_SECRET` | The Google app used by Gmail mailboxes connected with Google sign-in: the ones already connected that way, and new ones once `BOX_GOOGLE_OAUTH_CONNECT` is on. 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_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.
@@ -449,7 +449,7 @@ There are three ways to attach a sending mailbox. Only the OAuth ones need setup
| Mailbox type | Setup needed | Variables |
|--------------|--------------|-----------|
| Any SMTP + IMAP provider | None | none |
| Gmail / Google Workspace | A Google Cloud OAuth client | `BOX_GOOGLE_CLIENT_ID`, `BOX_GOOGLE_CLIENT_SECRET` |
| Gmail / Google Workspace | None with an app password (the default). A Google Cloud OAuth client to offer Google sign-in | `BOX_GOOGLE_OAUTH_CONNECT`, `BOX_GOOGLE_CLIENT_ID`, `BOX_GOOGLE_CLIENT_SECRET` |
| Outlook / Microsoft 365 | An Entra ID app registration | `BOX_OUTLOOK_CLIENT_ID`, `BOX_OUTLOOK_CLIENT_SECRET` |
<Callout type="warn" title="These are not the sign-in variables">
@@ -462,6 +462,8 @@ Nothing to configure. Add the mailbox in the dashboard with its host, port, user
### Gmail and Google Workspace
Nothing to configure by default: the connect dialog walks users through a Google app password over `smtp.gmail.com` and `imap.gmail.com`, see [the mailbox guide](/guides/mailboxes/#gmail-and-google-workspace). Set up a Google OAuth client only if you want to offer Google sign-in for new Gmail mailboxes, which also needs `BOX_GOOGLE_OAUTH_CONNECT=true`; without it the client still serves the mailboxes already connected that way, both refreshing their tokens and re-authorizing them when Google invalidates a grant.
<Steps>
<Step>
@@ -490,6 +492,7 @@ Put the client id and secret in your root `.env`:
```bash
BOX_GOOGLE_CLIENT_ID=1234567890-abc123.apps.googleusercontent.com
BOX_GOOGLE_CLIENT_SECRET=GOCSPX-your-secret-here
BOX_GOOGLE_OAUTH_CONNECT=true
```
Then `make up` to recreate the containers with the new values.
+15 -14
View File
@@ -11,12 +11,12 @@ Open **Accounts** and choose **Add account**.
| Provider | Method | Notes |
|----------|--------|-------|
| Gmail / Google Workspace | OAuth (`gmail`) | Not recommended yet, see below. Connect Gmail over IMAP + SMTP with an app password for now |
| Gmail / Google Workspace | App password over IMAP + SMTP (`smtp_imap`) | The connect dialog walks you through it in three steps, see [Gmail and Google Workspace](#gmail-and-google-workspace) below. Google sign-in for new mailboxes is coming soon |
| Outlook / Microsoft 365 | OAuth (`outlook`) | Recommended. No password stored. Runs on Microsoft Graph |
| Any other server | IMAP + SMTP (`smtp_imap`) | Custom domains, self-hosted, or providers without OAuth. Any IMAP server works, including Outlook.com, Microsoft 365 over IMAP, Yahoo, Fastmail, Zoho, cPanel and self-hosted Dovecot |
<Callout type="warn" title="Google sign-in is not recommended yet">
Warmbly's Google app is still going through review for the Gmail access it needs, so a mailbox connected with Google sign-in can fail to send or lose its authorization without warning. Google also caps how many accounts an app in review may hold, so a mailbox connected now may be disconnected later if there is no capacity left, and you would have to connect it again. The connect dialog marks the Gmail row in red and explains this. Connect Gmail and Google Workspace mailboxes over IMAP and SMTP with an app password instead; see [Gmail over IMAP and SMTP](#gmail-over-imap-and-smtp) below. Nothing has to change once Google sign-in is ready.
<Callout title="Google sign-in is coming soon">
For now, new Gmail and Google Workspace mailboxes connect with an app password; the connect dialog says so and walks you through it. A mailbox you already connected with Google sign-in is not affected: it keeps sending and syncing as before, and if Google ever invalidates its token the **Re-authorize** button in its drawer still works. Nothing has to be reconnected. A self-hosted instance with its own Google app can turn sign-in back on for new mailboxes with `BOX_GOOGLE_OAUTH_CONNECT=true`, see [configuration](/development/configuration/#mailbox-connections).
</Callout>
**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.
@@ -52,23 +52,24 @@ With two-factor authentication on, generate an app password in your provider's s
Warmbly signs in with whichever method your server offers, preferring CRAM-MD5, then LOGIN, then PLAIN. Servers that accept only one of these, which includes Microsoft 365 relays and most appliance relays, work without any setting to change.
### Gmail over IMAP and SMTP
### Gmail and Google Workspace
Until Google sign-in is recommended, this is how to connect a Gmail or Google Workspace mailbox:
Choose **Add account**, then **Gmail / Google Workspace**. 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. Add an authenticator app if security keys are the only method on the account.
2. Create an app password at [myaccount.google.com/apppasswords](https://myaccount.google.com/apppasswords), name it Warmbly, and copy the 16 characters. Google shows them once.
3. There is nothing to turn on for IMAP. Google removed the IMAP setting in January 2025 and personal Gmail accounts have it on permanently. On Google Workspace it is the administrator's call: they can switch IMAP off for the organization under **Apps**, **Google Workspace**, **Gmail**, **End user access**, and can block app passwords as well.
4. In Warmbly choose **Add account**, then **Other (SMTP / IMAP)**, and fill in:
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.
2. **Create an app password** at [myaccount.google.com/apppasswords](https://myaccount.google.com/apppasswords): name it Warmbly, press **Create**, and copy the 16 characters. Google shows them once; the spaces between the groups do not matter, the dialog drops them.
3. **Connect**: enter your name, the full address (including `@gmail.com` or your Workspace domain) and the app password. Warmbly checks it against Google before saving anything.
The settings the dialog uses, in case you connect the same mailbox through **Other (SMTP / IMAP)** or the [CSV import](#connecting-many-mailboxes-at-once):
```text
SMTP host: smtp.gmail.com port: 465 security: SSL / TLS
IMAP host: imap.gmail.com port: 993 security: SSL / TLS
```
The username is the full address, including `@gmail.com` or your Workspace domain. The password is the app password, never the account password. Deleting the app password in the Google account disconnects the mailbox, which is the same revocation an OAuth token gives you.
The username is the full address on both. The password is the app password, never the account password. There is nothing to turn on for IMAP: Google removed that setting in January 2025 and personal Gmail accounts have it on permanently. Deleting the app password in the Google account disconnects the mailbox, which is the same revocation an OAuth token gives you.
Some accounts cannot have an app password at all: enrolling in Google's [Advanced Protection Program](https://landing.google.com/advancedprotection/) revokes them and hides the page, and a Workspace administrator can block them for the organization. Ask the administrator to allow app passwords, or connect the mailbox with Google sign-in and accept the limits in the warning above.
On Google Workspace the administrator decides. They can switch IMAP off for the organization under **Apps**, **Google Workspace**, **Gmail**, **End user access**, and control app passwords under **Security**, **Authentication**, **2-Step Verification**, where **Allow users to generate app passwords** is the setting and enforcing security keys as the only second step removes them regardless. Some accounts cannot have an app password at all: enrolling in Google's [Advanced Protection Program](https://landing.google.com/advancedprotection/) revokes them and hides the page. Ask the administrator to allow app passwords for the mailboxes you send from.
<Callout type="warn" title="Authentication runs on connect">
Credentials and both connections are validated when you add the account, so wrong settings fail immediately rather than silently at send time. Tokens and credentials are sealed with envelope encryption before they touch storage.
@@ -116,7 +117,7 @@ There is no daily cap on how many mailboxes you connect: a Business workspace ca
## Connecting many mailboxes at once
Pick **Bulk import from CSV** in the connect dialog to connect any number of SMTP and IMAP mailboxes from one file. Gmail and Outlook mailboxes sign in one at a time, because each needs its own consent.
Pick **Bulk import from CSV** in the connect dialog to connect any number of SMTP and IMAP mailboxes from one file. Gmail mailboxes qualify, with the [settings above](#gmail-and-google-workspace) and an app password per row. Outlook mailboxes sign in one at a time, because each needs its own consent.
**The file.** One mailbox per row. `email`, `smtp_host` and `imap_host` are required, plus a password; everything else has a default.
@@ -144,7 +145,7 @@ The same endpoint is available to the API as `POST /emails/onboarding/smtp-imap/
When the provider stops accepting a mailbox's stored credential (a password change, a revoked grant, an expired app password), the mailbox is taken out of sending and syncing and its drawer shows the reason under **Needs attention**, with the fix right on the error:
- **Gmail and Outlook**: a **Re-authorize** button re-runs the provider consent in a popup, preselecting the mailbox's own address. The consent must be for that same address; signing in with a different account is refused instead of quietly connecting the wrong mailbox.
- **Gmail (connected with Google sign-in) and Outlook**: a **Re-authorize** button re-runs the provider consent in a popup, preselecting the mailbox's own address. This works for an existing Gmail mailbox even while Google sign-in is paused for new ones. The consent must be for that same address; signing in with a different account is refused instead of quietly connecting the wrong mailbox.
- **SMTP / IMAP**: an **Update credentials** button opens a form for the new password (or new host and port). The replacement is validated against your server before it is saved, the same as at connect time.
A successful reconnect stores the new credential, clears the authentication error, reactivates the mailbox on its existing worker, and it resumes syncing from where it stopped. Nothing else changes: settings, history, warmup progress, and campaign membership all stay.
@@ -206,7 +207,7 @@ A signature carrying a `<style>` block, a whole HTML document, or an event handl
### Sending identity
A Gmail mailbox can send as more than one address. Anything you have added under "Send mail as" in Gmail and finished verifying, a role address like hello@ or an address on a second domain you own, is an address Warmbly can put on the From header.
A Gmail mailbox connected with Google sign-in can send as more than one address. Anything you have added under "Send mail as" in Gmail and finished verifying, a role address like hello@ or an address on a second domain you own, is an address Warmbly can put on the From header.
Pick it under **Sending identity** on the mailbox's Settings tab. The list is read from Google, so it holds exactly what Google will accept: an alias still waiting on its verification email is shown but cannot be chosen, because Google would refuse the send. Press **Refresh addresses** after adding one in Gmail. Leaving the selection on the mailbox address, which is where every mailbox starts, changes nothing.
+6
View File
@@ -55,6 +55,11 @@ type DeploymentAuthConfig struct {
// referral fields) that make no sense on someone's own server.
SelfHosted bool `json:"self_hosted"`
// GmailOAuthConnect is whether a new Gmail mailbox may be connected with
// Google sign-in. False routes the connect dialog through the app-password
// walkthrough; mailboxes already on Google sign-in are unaffected.
GmailOAuthConnect bool `json:"gmail_oauth_connect"`
// BillingEnabled mirrors the backend feature gate exactly: false when
// BILLING_PROVIDER=none, in which case every feature is unlocked and the
// dashboard must not present the org as being on a trial or free tier.
@@ -135,6 +140,7 @@ func (h *Handler) AuthConfig(c *gin.Context) {
Providers: providers,
ProviderLabels: h.AuthService.FederatedProviderLabels(),
SelfHosted: config.SelfHosted(),
GmailOAuthConnect: config.GoogleOAuthConnect(),
BillingEnabled: config.BillingProvider() != "none",
SetupRequired: h.BootstrapService != nil && h.BootstrapService.Required(c.Request.Context()),
InvitesRequired: registration == config.RegistrationInviteOnly,
+4
View File
@@ -24,6 +24,10 @@ import (
// 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) (*models.EmailOnboardingStartResponse, *errx.Error) {
// A new mailbox only; OAuthReauth renews an existing one and is not gated.
if provider == models.InboxProviderGoogle && !config.GoogleOAuthConnect() {
return nil, errx.ErrEmailOnboardGoogleOAuthDisabled
}
cfg, xerr := s.oauthConfigFor(provider)
if xerr != nil {
return nil, xerr
@@ -0,0 +1,53 @@
package email
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// A new Gmail mailbox is refused before any OAuth config is consulted, so an
// 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", "")
org := uuid.New()
svc := &emailService{}
_, xerr := svc.OAuthStart(context.Background(), uuid.NewString(), &org, models.InboxProviderGoogle)
if xerr != errx.ErrEmailOnboardGoogleOAuthDisabled {
t.Fatalf("expected ErrEmailOnboardGoogleOAuthDisabled, got %v", xerr)
}
}
// Enabled, the request falls through to the ordinary path; with no client
// 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")
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)
if xerr != errx.ErrEmailOnboardGoogleNotConfigured {
t.Fatalf("expected the gate to step aside (ErrEmailOnboardGoogleNotConfigured), got %v", xerr)
}
}
// Renewing an existing Gmail mailbox's tokens is never gated: the mailbox is
// already on Google sign-in and this is the only way to keep it working.
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)
if xerr == errx.ErrEmailOnboardGoogleOAuthDisabled {
t.Fatal("reauth of an existing Gmail mailbox must not be refused by the new-mailbox gate")
}
}
+10
View File
@@ -744,6 +744,16 @@ var table = []Entry{
DocsAnchor: docsWorkers,
Resolve: envValue("BOX_GOOGLE_CLIENT_SECRET"),
},
{
Key: "BOX_GOOGLE_OAUTH_CONNECT", Group: GroupWorkers, RuntimeChangeable: ChangeBootOnly,
Effect: "true lets new Gmail mailboxes connect with Google sign-in. Off, the connect dialog walks through an app password over IMAP and SMTP instead; mailboxes already on Google sign-in keep working and can be re-authorized either way.",
DocsAnchor: docsWorkers,
// Through the config helper, not boolOr: boolOr accepts yes/on and
// GoogleOAuthConnect parses with strconv.ParseBool, which does not, so
// reading the raw value here would report true on a setting the gate
// treats as false.
Resolve: func(*Runtime) string { return yesNo(config.GoogleOAuthConnect()) },
},
{
Key: "BOX_OUTLOOK_CLIENT_ID", Group: GroupWorkers, RuntimeChangeable: ChangeBootOnly,
Effect: "Your Microsoft 365 OAuth client for connecting Outlook mailboxes.",
+5
View File
@@ -10,6 +10,7 @@ import (
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"github.com/rs/zerolog/log"
"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"
@@ -78,6 +79,10 @@ func (s *service) StartOAuth(ctx context.Context, inst *models.PoolLinkInstance,
if req.Provider != models.InboxProviderGoogle && req.Provider != models.InboxProviderOutlook {
return nil, errx.ErrEmailOnboardProvider
}
// The cloud's own Google app decides here, not the linked instance's.
if req.Provider == models.InboxProviderGoogle && !config.GoogleOAuthConnect() {
return nil, errx.ErrEmailOnboardGoogleOAuthDisabled
}
if !returnURLAllowed(req.ReturnURL, inst.URL) {
return nil, ErrOAuthReturnURL
}
+12
View File
@@ -2,6 +2,8 @@ package config
import (
"os"
"strconv"
"strings"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
@@ -13,6 +15,16 @@ type Oauth2Inbox struct {
Outlook *oauth2.Config
}
// 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.
func GoogleOAuthConnect() bool {
b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("BOX_GOOGLE_OAUTH_CONNECT")))
return err == nil && b
}
func GoogleOauth2Inbox(baseURL string) *oauth2.Config {
return &oauth2.Config{
ClientID: os.Getenv("BOX_GOOGLE_CLIENT_ID"),
+6
View File
@@ -129,6 +129,12 @@ var (
"Gmail is not configured on this deployment. Set BOX_GOOGLE_CLIENT_ID and BOX_GOOGLE_CLIENT_SECRET in your .env, then restart. See https://docs.warmbly.com/development/deployment-guide/#connect-mailboxes")
ErrEmailOnboardOutlookNotConfigured = NewWithIdentifier(ServiceUnavailable, "mailbox_provider_not_configured",
"Microsoft 365 is not configured on this deployment. Set BOX_OUTLOOK_CLIENT_ID and BOX_OUTLOOK_CLIENT_SECRET in your .env, then restart. See https://docs.warmbly.com/development/deployment-guide/#connect-mailboxes")
// Raised when a new Gmail mailbox asks for Google sign-in while the
// deployment routes new Gmail mailboxes through an app password instead
// (config.GoogleOAuthConnect). Re-authorizing an existing mailbox never
// hits this.
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.")
ErrEmailOnboardCode = New(BadRequest, "Authorization code is missing or invalid.")
ErrEmailOnboardExchange = New(BadRequest, "Could not exchange the authorization code with the provider.")
+4 -4
View File
@@ -42,7 +42,7 @@ const scenarios = [
];
const providers = [
{ name: 'Google Workspace', tag: 'Gmail · OAuth 2.0', body: 'Gmail API for send. Threaded replies preserved via In-Reply-To and References. Postmaster Tools surfaced where the domain is enrolled.', accent: '#ea4335' },
{ name: 'Google Workspace', tag: 'Gmail · app password', body: 'A guided setup over smtp.gmail.com and imap.gmail.com, no Google app review in the way. Threaded replies preserved via In-Reply-To and References. Postmaster Tools surfaced where the domain is enrolled.', accent: '#ea4335' },
{ name: 'Microsoft 365', tag: 'Outlook · OAuth', body: 'Graph API for send with shared-mailbox support. IMAP and EWS for sync. Tenant-scoped admin consent.', accent: '#0078d4' },
{ name: 'Custom SMTP / IMAP',tag: 'BYOS · TLS', body: 'Bring your own server. TLS, STARTTLS, app passwords, and OAuth where the provider supports it. iCloud and Zoho work first-class.', accent: '#0284c7' },
];
@@ -240,7 +240,7 @@ const faq = [
318ms from queue to 200 OK.
</h2>
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">
One message, every handoff. Latencies here are illustrative for a Gmail send on a warm worker. Real traces are visible in the per-mailbox event view.
One message, every handoff. Latencies here are illustrative for a Gmail mailbox connected with Google sign-in, on a warm worker; one connected with an app password takes the SMTP path at the same points. Real traces are visible in the per-mailbox event view.
</p>
</div>
@@ -343,7 +343,7 @@ const faq = [
<div class="max-w-2xl mb-10">
<div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">Providers</div>
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
Native Gmail. Native Outlook. SMTP for the rest.
Gmail in three steps. Native Outlook. SMTP for the rest.
</h2>
</div>
@@ -422,7 +422,7 @@ const faq = [
<CTA
title="Connect a mailbox. Send your first campaign today."
description="OAuth in 30 seconds. Worker assignment is automatic."
description="Connect Gmail with an app password or Outlook with one sign-in. Worker assignment is automatic."
primaryLabel="Start free"
primaryHref="https://app.warmbly.com/register"
/>
@@ -0,0 +1,399 @@
// GmailAppPasswordPanel — the Gmail path of the connect modal.
//
// New Gmail and Google Workspace mailboxes connect with an app password over
// IMAP and SMTP, not with Google sign-in (the deployment decides, see
// gmail_oauth_connect on /auth/config). 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
// Verification, create the app password, connect. Steps slide like the rest
// of the multi-step flows, and nothing skips ahead of an incomplete one.
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
ArrowLeftIcon,
ArrowRightIcon,
CheckIcon,
ExternalLinkIcon,
KeyRoundIcon,
Loader2Icon,
ShieldCheckIcon,
SparklesIcon,
} from "lucide-react";
import toast from "react-hot-toast";
import { TextInput } from "@/components/ui/field";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import addEmail from "@/lib/api/client/app/emails/addEmail";
import { capture } from "@/lib/productAnalytics";
import useAuthConfig from "@/lib/api/hooks/auth/useAuthConfig";
import { cn } from "@/lib/utils";
const EASE = [0.32, 0.72, 0, 1] as const;
/** Gmail's own servers. The same for personal accounts and Workspace. */
const GMAIL_SMTP = { host: "smtp.gmail.com", port: 465 } as const;
const GMAIL_IMAP = { host: "imap.gmail.com", port: 993 } as const;
/** Google shows an app password as four groups of four letters. */
const APP_PASSWORD_LENGTH = 16;
const STEPS = ["2-Step Verification", "App password", "Connect"] as const;
function normalizeAppPassword(raw: string): string {
return raw.replace(/\s+/g, "");
}
export default function GmailAppPasswordPanel({
onDone,
onError,
}: {
onDone: () => void;
/** A refused connect (a full allowance) gets its own dialog upstairs. */
onError: (e: unknown) => void;
}) {
const [step, setStep] = React.useState(0);
const [dir, setDir] = React.useState<1 | -1>(1);
// Reached two ways: as the only Gmail route (sign-in is gated off), or by
// choice from the OAuth panel when it is not. Promising sign-in "soon"
// in the second case would be talking about something already on screen.
const gmailOAuth = useAuthConfig().config.gmail_oauth_connect === true;
const go = (next: number) => {
setDir(next > step ? 1 : -1);
setStep(next);
};
return (
<div className="flex flex-col min-h-0">
<div className="px-4 py-2 border-b border-slate-200/60 bg-sky-50/60 flex items-center gap-2 text-[11.5px] text-sky-800">
<SparklesIcon className="w-3.5 h-3.5 text-sky-600 shrink-0" />
<span className="min-w-0 truncate">
{gmailOAuth
? "An app password connects the same mailbox in about two minutes, with no Google consent screen."
: "Google sign-in is coming soon. Until then, an app password connects the same mailbox in about two minutes."}
</span>
</div>
<Progress step={step} />
<div className="relative overflow-hidden">
<AnimatePresence mode="wait" initial={false} custom={dir}>
<motion.div
key={step}
custom={dir}
initial={{ opacity: 0, x: dir * 16 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: dir * -16 }}
transition={{ duration: 0.18, ease: EASE }}
>
{step === 0 && <TwoStepStep onNext={() => go(1)} />}
{step === 1 && <AppPasswordStep onBack={() => go(0)} onNext={() => go(2)} />}
{step === 2 && <ConnectStep onBack={() => go(1)} onDone={onDone} onError={onError} />}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}
function Progress({ step }: { step: number }) {
return (
<div className="px-4 pt-3 pb-2 border-b border-slate-200/60">
<div className="flex items-center gap-1.5">
{STEPS.map((label, i) => (
<div key={label} className="flex-1 min-w-0">
<div
className={cn(
"h-1 rounded-full transition-colors",
i < step ? "bg-sky-600" : i === step ? "bg-sky-400" : "bg-slate-200",
)}
/>
<div
className={cn(
"mt-1.5 text-[10px] uppercase tracking-[0.14em] font-medium truncate",
i === step ? "text-slate-900" : i < step ? "text-sky-700" : "text-slate-400",
)}
>
{label}
</div>
</div>
))}
</div>
</div>
);
}
function TwoStepStep({ onNext }: { onNext: () => void }) {
return (
<StepFrame
title="Turn on 2-Step Verification"
sub="Google only issues app passwords on accounts that have it on."
footer={
<>
<span className="text-[11px] text-slate-500 min-w-0 truncate">Already on? Just continue.</span>
<NextButton onClick={onNext}>It's on, continue</NextButton>
</>
}
>
<ol className="space-y-2">
<Step n={1}>
Open the Google account's security page and find <strong className="font-medium text-slate-900">2-Step Verification</strong> under "How you sign in to Google".
</Step>
<Step n={2}>
Turn it on with a phone prompt, an authenticator app or a text message. A security key on its own is not enough: Google needs one of the other methods present before it offers app passwords.
</Step>
</ol>
<ExternalButton href="https://myaccount.google.com/security">Open Google security settings</ExternalButton>
<Note>
On Google Workspace an administrator can require or block this for the organization. An account enrolled in Advanced Protection cannot have an app password at all.
</Note>
</StepFrame>
);
}
function AppPasswordStep({ onBack, onNext }: { onBack: () => void; onNext: () => void }) {
return (
<StepFrame
title="Create an app password"
sub="A 16-letter password Google issues for one app. Warmbly stores it encrypted."
footer={
<>
<BackButton onClick={onBack} />
<NextButton onClick={onNext}>I have it, continue</NextButton>
</>
}
>
<ol className="space-y-2">
<Step n={1}>Open the app passwords page. Google asks you to sign in again.</Step>
<Step n={2}>
Type <strong className="font-medium text-slate-900">Warmbly</strong> as the app name and press <strong className="font-medium text-slate-900">Create</strong>.
</Step>
<Step n={3}>
Copy the 16 characters it shows. Google shows them once; the spaces between the groups do not matter.
</Step>
</ol>
<ExternalButton href="https://myaccount.google.com/apppasswords">Open app passwords</ExternalButton>
<Note>
No such page? 2-Step Verification is still off, or on Workspace the administrator has blocked app passwords under Security, Less secure apps. Nothing to turn on for IMAP: Google removed that setting in January 2025.
</Note>
</StepFrame>
);
}
function ConnectStep({
onBack,
onDone,
onError,
}: {
onBack: () => void;
onDone: () => void;
onError: (e: unknown) => void;
}) {
const [name, setName] = React.useState("");
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const [submitting, setSubmitting] = React.useState(false);
const cleaned = normalizeAppPassword(password);
const lengthOff = cleaned.length > 0 && cleaned.length !== APP_PASSWORD_LENGTH;
const missing = [
!name.trim() && "a name",
!email.trim().includes("@") && "the address",
!cleaned && "the app password",
].filter((m): m is string => Boolean(m));
const valid = missing.length === 0;
async function submit() {
if (submitting || !valid) return;
setSubmitting(true);
const address = email.trim();
try {
await toast.promise(
addEmail({
name: name.trim(),
email: address,
imap: { username: address, password: cleaned, host: GMAIL_IMAP.host, port: GMAIL_IMAP.port, security: "tls" },
smtp: { username: address, password: cleaned, host: GMAIL_SMTP.host, port: GMAIL_SMTP.port, security: "tls" },
}),
{
loading: "Checking the app password with Google…",
success: "Mailbox connected",
error: (e: AppError) => buildError(e),
},
);
capture("mailbox_connected", { provider: "gmail", method: "app_password" });
onDone();
} catch (e) {
onError(e);
} finally {
setSubmitting(false);
}
}
return (
<StepFrame
title="Connect the mailbox"
sub="The address, the app password, and nothing else to type."
footer={
<>
<BackButton onClick={onBack} />
<span className="flex-1 min-w-0 text-[11px] text-slate-500 truncate">
{valid ? "Verified with Google before saving." : `Still needed: ${missing.join(", ")}.`}
</span>
<motion.button
type="button"
onClick={submit}
disabled={!valid || submitting}
whileTap={valid && !submitting ? { scale: 0.97 } : undefined}
className="shrink-0 h-7 px-3 rounded-md text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors bg-slate-900 hover:bg-slate-800 text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? <Loader2Icon className="w-3 h-3 animate-spin" /> : <CheckIcon className="w-3 h-3" />}
Connect
</motion.button>
</>
}
>
<div className="space-y-2">
<Field label="Name">
<TextInput value={name} onChange={setName} placeholder="Alex Rivera" />
</Field>
<Field label="Email">
<TextInput value={email} onChange={setEmail} placeholder="alex@gmail.com or alex@yourdomain.com" />
</Field>
<Field label="App password">
<TextInput
value={password}
onChange={setPassword}
placeholder="xxxx xxxx xxxx xxxx"
type="password"
onKeyDown={(e) => {
if (e.key === "Enter") void submit();
}}
/>
</Field>
{lengthOff && (
<p className="pl-[76px] text-[11.5px] text-amber-700">
Google app passwords are {APP_PASSWORD_LENGTH} letters. This looks like something else, maybe the account password. It is checked on connect either way.
</p>
)}
</div>
<div className="rounded-md border border-slate-200 overflow-hidden">
<div className="px-3 h-8 flex items-center gap-1.5 border-b border-slate-200 bg-slate-50">
<KeyRoundIcon className="w-3 h-3 text-slate-500" />
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Server settings, set for you</span>
</div>
{[
{ label: "SMTP", ...GMAIL_SMTP },
{ label: "IMAP", ...GMAIL_IMAP },
].map((s) => (
<div key={s.label} className="px-3 py-2 flex items-center gap-3 text-[12px] border-b border-slate-100 last:border-b-0">
<span className="w-10 shrink-0 text-slate-500 font-medium">{s.label}</span>
<span className="font-mono text-slate-900 truncate">{s.host}</span>
<span className="font-mono tabular-nums text-slate-600">:{s.port}</span>
<span className="ml-auto text-slate-500 shrink-0">SSL / TLS</span>
</div>
))}
<div className="px-3 py-2 text-[11.5px] text-slate-500 border-t border-slate-100">
The username is the full address, on both. Deleting the app password in the Google account disconnects the mailbox.
</div>
</div>
</StepFrame>
);
}
function StepFrame({
title,
sub,
children,
footer,
}: {
title: string;
sub: string;
children: React.ReactNode;
footer: React.ReactNode;
}) {
return (
<div>
<div className="px-4 pt-4 pb-3 space-y-3.5">
<div className="flex items-start gap-3">
<div className="size-9 rounded-md border border-slate-200 bg-white flex items-center justify-center shrink-0">
<ShieldCheckIcon className="w-4 h-4 text-slate-700" />
</div>
<div className="min-w-0">
<div className="text-[13.5px] font-medium text-slate-900">{title}</div>
<div className="text-[11.5px] text-slate-500">{sub}</div>
</div>
</div>
{children}
</div>
<div className="px-4 py-2.5 border-t border-slate-200 bg-slate-50/60 flex items-center gap-2 min-w-0 sticky bottom-0">
{footer}
</div>
</div>
);
}
function Step({ n, children }: { n: number; children: React.ReactNode }) {
return (
<li className="flex items-start gap-2.5">
<span className="size-4 mt-0.5 shrink-0 rounded-full bg-slate-100 text-slate-600 text-[10px] font-medium inline-flex items-center justify-center tabular-nums">
{n}
</span>
<span className="text-[12.5px] text-slate-700 leading-[1.5]">{children}</span>
</li>
);
}
function Note({ children }: { children: React.ReactNode }) {
return <p className="text-[11.5px] text-slate-500 leading-[1.5]">{children}</p>;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 min-w-0">
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium w-16 shrink-0">{label}</span>
<div className="flex-1 min-w-0">{children}</div>
</div>
);
}
function ExternalButton({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="h-7 px-2.5 inline-flex items-center gap-1.5 rounded-md border border-slate-200 text-[12.5px] text-slate-700 hover:bg-slate-50 transition-colors"
>
<ExternalLinkIcon className="w-3.5 h-3.5" />
{children}
</a>
);
}
function BackButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="shrink-0 h-7 px-2 rounded-md text-[12px] text-slate-600 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center gap-1 transition-colors"
>
<ArrowLeftIcon className="w-3 h-3" />
Back
</button>
);
}
function NextButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<motion.button
type="button"
onClick={onClick}
whileTap={{ scale: 0.97 }}
className="ml-auto shrink-0 h-7 px-3 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
{children}
<ArrowRightIcon className="w-3 h-3" />
</motion.button>
);
}
@@ -1,245 +0,0 @@
// GoogleOAuthNotReadyDialog — why the Gmail OAuth row carries a red badge, and
// what to do instead.
//
// Google sign-in is still going through verification for the Gmail scopes
// Warmbly needs, so a consent on it either refuses outright or ends up with a
// mailbox that cannot send. Rather than let someone find that out at send
// time, the picker marks it and this explains the SMTP / IMAP route with the
// app-password steps, which is the path that works today.
//
// Layered above the connect modal: portalled to the body but still inside the
// modal's React tree, so it stops mousedown and click, and marks itself
// data-floating so the modal's own Escape leaves it alone.
import React from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import {
AlertTriangleIcon,
ArrowRightIcon,
ExternalLinkIcon,
KeyRoundIcon,
XIcon,
} from "lucide-react";
const EASE = [0.22, 1, 0.36, 1] as const;
/** Gmail's own server settings, which never change and are the same for Workspace. */
const GMAIL_SETTINGS: Array<{ label: string; host: string; port: string; security: string }> = [
{ label: "SMTP", host: "smtp.gmail.com", port: "465", security: "SSL / TLS" },
{ label: "IMAP", host: "imap.gmail.com", port: "993", security: "SSL / TLS" },
];
export default function GoogleOAuthNotReadyDialog({
open,
onClose,
onUseSmtp,
}: {
open: boolean;
onClose: () => void;
/** Switch the connect modal to the SMTP / IMAP form. */
onUseSmtp: () => void;
}) {
const cardRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!open) return;
const previous = document.activeElement as HTMLElement | null;
cardRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (document.querySelector("[role='alertdialog']")) return;
e.stopPropagation();
onClose();
};
document.addEventListener("keydown", onKey, true);
return () => {
document.removeEventListener("keydown", onKey, true);
previous?.focus?.();
};
}, [open, onClose]);
return createPortal(
<AnimatePresence>
{open && (
<motion.div
key="gmail-oauth-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onMouseDown={(e) => {
e.stopPropagation();
onClose();
}}
onClick={(e) => e.stopPropagation()}
className="fixed inset-0 z-[140] flex items-center justify-center bg-slate-900/40 backdrop-blur-[2px] px-4"
>
<motion.div
key="gmail-oauth-card"
ref={cardRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-label="Google sign-in is not ready"
data-floating
data-nested-modal
initial={{ y: 10, opacity: 0, scale: 0.98 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
exit={{ y: 10, opacity: 0, scale: 0.98 }}
transition={{ duration: 0.2, ease: EASE }}
onMouseDown={(e) => e.stopPropagation()}
className="w-full max-w-[540px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.22),0_8px_16px_-8px_rgba(15,23,42,0.12)] overflow-hidden flex flex-col max-h-[88dvh] outline-none"
>
<div className="h-12 px-4 border-b border-slate-200 flex items-center gap-2.5 shrink-0">
<AlertTriangleIcon className="w-3.5 h-3.5 text-rose-500" />
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
Google sign-in
</span>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="ml-auto size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
>
<XIcon className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="p-4 space-y-4">
<div className="rounded-md border border-rose-200 bg-rose-50 p-3">
<p className="text-[13px] font-medium text-rose-900">
Connecting Gmail with Google sign-in is not recommended yet
</p>
<p className="text-[12.5px] text-rose-800/90 mt-1.5">
Our Google app is still going through review for the Gmail access
Warmbly needs. Until that is finished, a mailbox connected this way can
fail to send or lose its authorization without warning. Google also caps
how many accounts an app in review may hold, so a mailbox connected now
may be disconnected later if there is no capacity left, and you would
have to connect it again. Connect it over SMTP and IMAP instead: it is
the same mailbox, it sends today, and nothing has to change once Google
sign-in is ready.
</p>
</div>
<div>
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium mb-2">
Connect Gmail with an app password
</div>
<ol className="space-y-2">
<Step n={1}>
Turn on 2-Step Verification on the Google account, under{" "}
<ExtLink href="https://myaccount.google.com/security">
Google Account, Security
</ExtLink>
. Google only offers app passwords once it is on, and only
alongside a method other than a security key. An account on
Advanced Protection cannot have one at all.
</Step>
<Step n={2}>
Create an app password at{" "}
<ExtLink href="https://myaccount.google.com/apppasswords">
myaccount.google.com/apppasswords
</ExtLink>
, name it Warmbly, and copy the 16 characters it shows. That is the
only time it is shown.
</Step>
<Step n={3}>
Nothing to turn on for a personal Gmail account: Google removed
the IMAP setting in January 2025 and IMAP is always on. On Google
Workspace an administrator decides, and can switch off IMAP or app
passwords for the organization.
</Step>
<Step n={4}>
Come back here, choose Other (SMTP / IMAP), and use the app password
as the password with the settings below.
</Step>
</ol>
</div>
<div className="rounded-md border border-slate-200 overflow-hidden">
<div className="px-3 h-8 flex items-center gap-1.5 border-b border-slate-200 bg-slate-50">
<KeyRoundIcon className="w-3 h-3 text-slate-500" />
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
Gmail server settings
</span>
</div>
{GMAIL_SETTINGS.map((s) => (
<div
key={s.label}
className="px-3 py-2 flex items-center gap-3 text-[12px] border-b border-slate-100 last:border-b-0"
>
<span className="w-10 shrink-0 text-slate-500 font-medium">{s.label}</span>
<span className="font-mono text-slate-900 truncate">{s.host}</span>
<span className="font-mono tabular-nums text-slate-600">:{s.port}</span>
<span className="ml-auto text-slate-500 shrink-0">{s.security}</span>
</div>
))}
<div className="px-3 py-2 text-[11.5px] text-slate-500 border-t border-slate-100">
Username is the full address, including{" "}
<code className="bg-slate-100 px-1 rounded">@gmail.com</code> or your
Workspace domain. Password is the app password, not the account
password.
</div>
</div>
<p className="text-[11.5px] text-slate-500">
No app password available, because of Advanced Protection or an
administrator's policy? Ask the administrator to allow them, or connect
with Google sign-in knowing the limits above.
</p>
</div>
</div>
<div className="px-4 py-3 border-t border-slate-200 flex items-center gap-2 shrink-0">
<a
href="https://docs.warmbly.com/guides/mailboxes/"
target="_blank"
rel="noreferrer"
className="h-7 px-2.5 inline-flex items-center gap-1.5 rounded-md border border-slate-200 text-[12.5px] text-slate-700 hover:bg-slate-50 transition-colors"
>
<ExternalLinkIcon className="w-3.5 h-3.5" />
Mailbox guide
</a>
<button
type="button"
onClick={onUseSmtp}
className="ml-auto h-7 px-3 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12.5px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
Use SMTP / IMAP
<ArrowRightIcon className="w-3.5 h-3.5" />
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body,
);
}
function Step({ n, children }: { n: number; children: React.ReactNode }) {
return (
<li className="flex items-start gap-2.5">
<span className="size-4 mt-0.5 shrink-0 rounded-full bg-slate-100 text-slate-600 text-[10px] font-medium inline-flex items-center justify-center tabular-nums">
{n}
</span>
<span className="text-[12.5px] text-slate-700 leading-[1.5]">{children}</span>
</li>
);
}
function ExtLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-sky-700 underline decoration-sky-300 hover:decoration-sky-600 transition-colors"
>
{children}
</a>
);
}
+59 -99
View File
@@ -6,11 +6,17 @@
// triptych.
//
// Flow:
// provider picker ──► gmail OAuth popup ─┐
// provider picker ──► gmail app-password walkthrough ─► /emails/onboarding/smtp-imap
// ──► gmail OAuth popup ─┐ (only when gmail_oauth_connect is on)
// ──► outlook OAuth popup ─┼─► /emails/onboarding/oauth/finish
// ──► smtp/imap form ──────────► /emails/onboarding/smtp-imap
// ──► CSV bulk import ─────────► /emails/onboarding/smtp-imap/bulk
//
// Gmail defaults to the app-password walkthrough: the deployment says whether
// a NEW Gmail mailbox may use Google sign-in (gmail_oauth_connect on
// /auth/config), and off is the default. Mailboxes already connected with
// Google sign-in are untouched and still re-authorize from their drawer.
//
// Every path can run into the workspace's mailbox allowance; that answer
// (code mailbox_allowance_reached) opens MailboxAllowanceDialog instead of a
// toast, and the picker shows the allowance up front so it is never a surprise.
@@ -24,7 +30,6 @@
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
AlertTriangleIcon,
ArrowLeftIcon,
CheckIcon,
ChevronRightIcon,
@@ -73,11 +78,11 @@ import useMailboxAllowance from "@/lib/api/hooks/app/emails/useMailboxAllowance"
import { allowanceFull } from "@/lib/api/models/app/emails/MailboxAllowance";
import type MailboxAllowance from "@/lib/api/models/app/emails/MailboxAllowance";
import MailboxAllowanceDialog from "@/components/app/emails/MailboxAllowanceDialog";
import GoogleOAuthNotReadyDialog from "@/components/app/emails/GoogleOAuthNotReadyDialog";
import GmailAppPasswordPanel from "@/components/app/emails/GmailAppPasswordPanel";
import BulkConnectPanel from "@/components/app/emails/BulkConnectPanel";
import { DitherMeter, type DitherTone } from "@/components/ui/dither";
type View = "pick" | "gmail" | "outlook" | "smtp_imap" | "bulk";
type View = "pick" | "gmail" | "gmail_app_password" | "outlook" | "smtp_imap" | "bulk";
/** The one answer every connect path shares: open the allowance dialog. */
function isAllowanceError(e: unknown): boolean {
@@ -162,9 +167,10 @@ export default function AddEmailModal() {
const allowance = useMailboxAllowance(user.addEmail);
const [allowanceOpen, setAllowanceOpen] = React.useState(false);
const [allowanceReached, setAllowanceReached] = React.useState(false);
// Why the Gmail row is marked red: Google sign-in is not through review
// for the Gmail scopes yet, so the picker points at SMTP / IMAP instead.
const [gmailWarningOpen, setGmailWarningOpen] = 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);
@@ -184,7 +190,6 @@ export default function AddEmailModal() {
setOauthBusy(null);
setNotConfigured(null);
setAllowanceOpen(false);
setGmailWarningOpen(false);
pendingState.current = null;
pendingCloud.current = null;
}
@@ -370,7 +375,7 @@ export default function AddEmailModal() {
<PickProvider
onPick={setView}
viaCloud={viaCloud}
onGmailWarning={() => setGmailWarningOpen(true)}
gmailOAuth={gmailOAuth}
onAdopted={() => {
qc.invalidateQueries({ queryKey: ["emails", "list"] });
user.setAddEmail(false);
@@ -387,11 +392,19 @@ export default function AddEmailModal() {
busy={oauthBusy === "gmail"}
viaCloud={viaCloud}
onConnect={() => startOAuth("gmail")}
onWarning={() => setGmailWarningOpen(true)}
onUseSmtp={() => setView("smtp_imap")}
onUseAppPassword={() => setView("gmail_app_password")}
/>
)
)}
{view === "gmail_app_password" && (
<GmailAppPasswordPanel
onDone={() => {
qc.invalidateQueries({ queryKey: ["emails", "list"] });
user.setAddEmail(false);
}}
onError={onConnectError}
/>
)}
{view === "outlook" && (
notConfigured === "outlook" ? (
<ProviderNotConfigured provider="outlook" selfHosted={pool.selfHosted} />
@@ -426,14 +439,6 @@ export default function AddEmailModal() {
</AnimatePresence>
</div>
</motion.div>
<GoogleOAuthNotReadyDialog
open={gmailWarningOpen}
onClose={() => setGmailWarningOpen(false)}
onUseSmtp={() => {
setGmailWarningOpen(false);
setView("smtp_imap");
}}
/>
<MailboxAllowanceDialog
open={allowanceOpen}
onClose={() => setAllowanceOpen(false)}
@@ -521,6 +526,7 @@ function Header({
const sub: Record<View, string> = {
pick: "Connect a sending account",
gmail: "Gmail or Google Workspace",
gmail_app_password: "Gmail or Google Workspace",
outlook: "Outlook or Microsoft 365",
smtp_imap: "Any provider via SMTP / IMAP",
bulk: "Many mailboxes from one CSV",
@@ -633,13 +639,14 @@ function ProviderNotConfigured({ provider, selfHosted }: { provider: OAuthProvid
function PickProvider({
onPick,
viaCloud,
gmailOAuth,
onAdopted,
onGmailWarning,
}: {
onPick: (v: View) => void;
viaCloud: boolean;
/** Google sign-in is open to new mailboxes; otherwise the app-password walkthrough. */
gmailOAuth: boolean;
onAdopted: () => void;
onGmailWarning: () => void;
}) {
const rows: Array<{
key: View;
@@ -647,16 +654,20 @@ function PickProvider({
title: string;
sub: string;
tone: "primary" | "neutral";
/** A red badge on the row, opening its own explanation. */
warn?: { label: string; onOpen: () => void };
/** A quiet badge after the title. */
badge?: string;
}> = [
{
key: "gmail",
key: gmailOAuth ? "gmail" : "gmail_app_password",
icon: <Google className="w-5 h-5" />,
title: "Gmail / Google Workspace",
sub: "Google sign-in is still in review. Connect Gmail over SMTP / IMAP for now.",
sub: gmailOAuth
? viaCloud
? "Sign in through Warmbly Cloud. Warmup included, no OAuth app needed."
: "OAuth via Google. Native sync for Gmail accounts."
: "With an app password, over IMAP and SMTP. About two minutes; we walk you through it.",
tone: "primary",
warn: { label: "Not recommended", onOpen: onGmailWarning },
badge: gmailOAuth ? undefined : "Google sign-in coming soon",
},
{
key: "outlook",
@@ -698,29 +709,13 @@ function PickProvider({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[13px] font-medium text-slate-900 truncate">{r.title}</span>
{r.warn && (
<span
role="button"
tabIndex={0}
aria-label={`${r.warn.label}: why?`}
onClick={(e) => {
e.stopPropagation();
r.warn?.onOpen();
}}
onKeyDown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
e.stopPropagation();
r.warn?.onOpen();
}}
className="shrink-0 h-[18px] px-1.5 rounded-full bg-rose-50 border border-rose-200 text-rose-700 text-[10px] font-medium inline-flex items-center gap-1 hover:bg-rose-100 transition-colors cursor-pointer"
>
<AlertTriangleIcon className="w-2.5 h-2.5" />
{r.warn.label}
{r.badge && (
<span className="shrink-0 h-[18px] px-1.5 rounded-full bg-sky-50 border border-sky-200 text-sky-700 text-[10px] font-medium inline-flex items-center">
{r.badge}
</span>
)}
</div>
<div className={cn("text-[11.5px] truncate", r.warn ? "text-rose-600" : "text-slate-500")}>{r.sub}</div>
<div className="text-[11.5px] text-slate-500 truncate">{r.sub}</div>
</div>
<ChevronRightIcon className="w-4 h-4 text-slate-300 shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
</motion.button>
@@ -789,16 +784,14 @@ function OAuthPanel({
busy,
viaCloud,
onConnect,
onWarning,
onUseSmtp,
onUseAppPassword,
}: {
provider: OAuthProvider;
busy: boolean;
viaCloud: boolean;
onConnect: () => void;
/** Present while this provider's sign-in is not recommended. */
onWarning?: () => void;
onUseSmtp?: () => void;
/** Gmail only: the same mailbox over IMAP and SMTP, for whoever prefers it. */
onUseAppPassword?: () => void;
}) {
const label = provider === "gmail" ? "Google" : "Microsoft";
const Icon = provider === "gmail" ? Google : Outlook;
@@ -834,66 +827,33 @@ function OAuthPanel({
</ul>
)}
{onWarning && (
<div className="rounded-md border border-rose-200 bg-rose-50 p-3">
<div className="flex items-start gap-2.5">
<AlertTriangleIcon className="w-4 h-4 text-rose-600 mt-0.5 shrink-0" />
<div className="min-w-0">
<p className="text-[12.5px] font-medium text-rose-900">
{label} sign-in is not recommended yet
</p>
<p className="text-[12.5px] text-rose-800/90 mt-1">
Our {label} app is still in review for the access Warmbly needs, so a
mailbox connected this way can stop sending without warning, and may be
disconnected later if the app runs out of capacity. Connect the same
mailbox over SMTP and IMAP with an app password instead.
</p>
<div className="mt-2 flex items-center gap-2">
{onUseSmtp && (
<button
type="button"
onClick={onUseSmtp}
className="h-7 px-2.5 rounded-md bg-rose-600 hover:bg-rose-700 text-white text-[12px] font-medium transition-colors"
>
Use SMTP / IMAP
</button>
)}
<button
type="button"
onClick={onWarning}
className="text-[12px] text-rose-800 underline hover:text-rose-900 transition-colors"
>
How do I set that up?
</button>
</div>
</div>
</div>
</div>
)}
<motion.button
type="button"
onClick={onConnect}
disabled={busy}
whileTap={busy ? undefined : { scale: 0.985 }}
className={cn(
"w-full h-9 rounded-md text-[12.5px] font-medium inline-flex items-center justify-center gap-2 transition-colors disabled:opacity-60",
onWarning
? "border border-slate-200 text-slate-600 hover:bg-slate-50"
: "bg-slate-900 hover:bg-slate-800 text-white",
)}
className="w-full h-9 rounded-md text-[12.5px] font-medium inline-flex items-center justify-center gap-2 transition-colors disabled:opacity-60 bg-slate-900 hover:bg-slate-800 text-white"
>
{busy ? (
<Loader2Icon className="w-3.5 h-3.5 animate-spin" />
) : (
<ShieldCheckIcon className="w-3.5 h-3.5" />
)}
{busy
? "Waiting for authorization…"
: onWarning
? `Continue with ${label} anyway`
: `Continue with ${label}`}
{busy ? "Waiting for authorization…" : `Continue with ${label}`}
</motion.button>
{onUseAppPassword && (
<p className="text-[11.5px] text-slate-500 text-center">
Prefer not to use Google sign-in?{" "}
<button
type="button"
onClick={onUseAppPassword}
className="text-sky-700 underline decoration-sky-300 hover:decoration-sky-600 transition-colors"
>
Connect it with an app password instead
</button>
</p>
)}
</div>
);
}
@@ -25,6 +25,9 @@ export const AUTH_CONFIG_FALLBACK: AuthConfig = {
passkeys: false,
providers: [],
self_hosted: true,
// The app-password walkthrough works on every deployment; Google sign-in
// for a new Gmail mailbox is only offered on a confirmed yes.
gmail_oauth_connect: false,
// Unconfirmed billing keeps the subscription-based gates in place rather
// than presenting an unreachable backend as an unlocked one.
billing_enabled: true,
@@ -20,6 +20,11 @@ export default interface AuthConfig {
* of their own. */
provider_labels?: Record<string, string>;
self_hosted: boolean;
/** Whether a NEW Gmail mailbox may be connected with Google sign-in. Off,
* the connect dialog walks through an app password over IMAP and SMTP
* instead. Mailboxes already on Google sign-in are unaffected. Absent on
* a backend that predates it, which reads as off. */
gmail_oauth_connect?: boolean;
/** False when BILLING_PROVIDER=none: the backend unlocks every feature and
* the org must not be presented as being on a trial or free tier. */
billing_enabled: boolean;