OAuth for Email

OAuth for Email

Modern email providers (Gmail, Microsoft 365, Yahoo) require OAuth2 for API access — passwords are deprecated. OAuth lets a third-party app act on behalf of a user without ever seeing their password, and lets the user revoke access anytime without changing it.

For email clients specifically, the relevant OAuth flow is the authorization code flow, sometimes via the installed app variant for desktop clients.

The flow at a glance

1. Email client opens browser → "Sign in to Gmail"
2. User authenticates with Google directly (client never sees password)
3. User approves: "MyEmailClient wants to read your email"
4. Google redirects browser back to client (e.g. http://localhost:8080/callback?code=AUTH_CODE)
5. Client exchanges AUTH_CODE for tokens:
   POST https://oauth2.googleapis.com/token
   Returns: { access_token, refresh_token, expires_in: 3600 }
6. Client uses access_token in API requests:
   Authorization: Bearer <access_token>
7. When access_token expires (~1h), client uses refresh_token to get a new one:
   POST https://oauth2.googleapis.com/token (grant_type=refresh_token)
   Returns: { access_token, expires_in: 3600 }

The refresh token is long-lived (Google: indefinite unless revoked). The access token is short-lived. The client juggles both.

Why the desktop variant is awkward

Web apps have a server with a known callback URL. Desktop apps don't.

Workaround: the client spins up a temporary HTTP server on localhost:RANDOM_PORT, opens the user's browser pointed at Google's authorize URL with redirect_uri=http://localhost:RANDOM_PORT. Google redirects the browser back to localhost; the client's server captures the code and shuts down.

This is the installed app flow (Google's name) or loopback redirect. Standardised in RFC 8252 (OAuth 2.0 for Native Apps).

Variants when no browser is available:

Scopes — what permissions to ask for

Less is more. OAuth's promise is fine-grained access; using it well means asking only for what you need.

Gmail scopes:

Current mxr code asks Gmail for readonly, modify, and labels. It does not request gmail.send separately; Gmail's messages.send endpoint accepts gmail.modify. SMTP sending is a separate provider path. mxr still avoids the omnibus mail.google.com/ scope.

Refresh tokens and storage

Refresh tokens are sensitive — possession is access. Good storage options:

Current mxr code wraps yup_oauth2 with KeychainTokenStorage. Gmail OAuth tokens use the OS credential store first: macOS Keychain or Linux Secret Service. A private disk cache under the active token directory can remain as a migration and noninteractive fallback, so the honest claim is not "there is never a token file." It is "keychain first, private local fallback when needed."

IMAP and SMTP passwords also stay out of config.toml; the config stores credential references, and mxr accounts repair NAME can re-prompt and overwrite the stored secret.

Revocation: user can revoke from Google's account settings anytime. Refresh fails with a 401; client should detect and prompt for re-auth.

Desktop and daemon lesson

OAuth is also a foregrounding problem. A browser redirect, device-code prompt, or keychain approval is user interaction, not background work. In a daemon-backed desktop app, the first-run surface has to own that moment visibly: show the account state, launch the auth flow, preserve the recovery path, and stop pretending a background sync loop can quietly fix missing consent.

This is the broader lesson from the mxr desktop reassessment. If auth is hidden behind a JSON setup screen or a daemon log, the app may be technically connected but still not launchable. See Credential Prompts Are Side Effects and First Run Is the Launch Surface.

App verification

Google requires OAuth apps using sensitive scopes (Gmail) to be verified for production use. Unverified apps:

Verification involves:

This is why most open-source email clients (Thunderbird, mxr, K-9 Mail) have users go through the warning, or use their own OAuth credentials.

Bundled credentials in mxr

mxr's v1 Gmail guidance is BYOC: create a Google OAuth Desktop app and paste the Client ID/Secret into mxr. Release binaries may also ship with app-level OAuth client identifiers baked into the binary at compile time when GMAIL_CLIENT_ID and GMAIL_CLIENT_SECRET are configured for the release build. That path is a fallback, not the main promise.

Tradeoff:

These identifiers are public desktop-app identifiers, not user secrets. PKCE and the provider-issued user tokens protect the real account access. Secret scanners may still dislike seeing them in a binary, so the security docs need to say this plainly.

The release lesson from mxr is that test credentials and bundled credentials are different things. Missing live Gmail test credentials should skip live smoke. Missing bundled Gmail build credentials changes the first-run path, so the release warning has to say exactly that: BYOC-only Gmail setup, not a broken release. A partial bundled credential pair should still block the build.

OAuth for SMTP (XOAUTH2)

Most modern SMTP servers support OAuth2 via the XOAUTH2 mechanism. The client builds an SMTP AUTH XOAUTH2 string from the user's email + access token and submits it. Server validates the token with the OAuth provider.

mxr's crates/provider-smtp supports XOAUTH2 when the account is configured with OAuth (Gmail, Microsoft 365). Otherwise falls back to PLAIN/LOGIN with a password.

Common pitfalls

See also