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:
- Device code flow — the client shows a code, user goes to a URL on another device and enters it. Used for CLI-only environments and TVs.
- Out-of-band (deprecated) — Google used to allow
urn:ietf:wg:oauth:2.0:oobto display the code for manual paste. Removed in 2022.
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:
gmail.readonly— read messages and labelsgmail.modify— read + modify labels (archive, label, star)gmail.compose— create drafts (no send)gmail.send— send messagesgmail.labels— create/edit labelsgmail.metadata— only headers, no body contenthttps://mail.google.com/— full access (avoid; treated suspiciously)
mxr asks for readonly, modify, labels plus gmail.send if outbound is configured. Doesn't request the omnibus mail.google.com/ scope.
Refresh tokens and storage
Refresh tokens are sensitive — possession is access. Stored:
- macOS Keychain — system-managed, encrypted at rest.
- Linux Secret Service (gnome-keyring, kwallet) — system-managed.
- Encrypted file with a passphrase — fallback when system stores aren't available.
- Plain file — never. (Some clients do this anyway. Don't.)
mxr stores tokens at ~/.local/share/mxr/tokens/{token_ref}.json with file mode 0600. Future work moves them to a system keychain.
Revocation: user can revoke from Google's account settings anytime. Refresh fails with a 401; client should detect and prompt for re-auth.
App verification
Google requires OAuth apps using sensitive scopes (Gmail) to be verified for production use. Unverified apps:
- Limited to 100 user accounts
- Show a "Google hasn't verified this app" warning during consent
- Can't access certain scopes at all
Verification involves:
- Submitting the app for review
- Demonstrating actual functionality
- Hosting a privacy policy
- Sometimes a security assessment ($15K–$75K per scope per year for restricted scopes)
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 ships with its own Google OAuth client ID/secret baked into the binary at compile time. Users can override in config.toml. Tradeoff:
- Pro: User experience is "click here to sign in" — no Google Cloud setup.
- Con: All mxr users share one OAuth quota. The bundled credentials are subject to Google's verification rules; in practice, mxr is in the "100 users" bucket until verified.
- Power-user escape hatch: paste your own client ID/secret to skip the bundled quota.
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
- Token expiration in the middle of a long sync — refresh proactively, not reactively. Many clients refresh 5 minutes before expiry.
- Refresh token revoked by user — clean error path: detect 401 with
invalid_grant, prompt for re-auth, don't loop forever. - Clock skew — JWT tokens have
iat/expclaims; if the client's clock is wrong, validation fails. Sync NTP. - Multiple accounts, same provider — store per-account tokens separately; don't conflate.
- Test environment — Google's test accounts (test mode) work with unverified apps but have stricter quotas.
See also
- Gmail API — what OAuth gates access to
- SMTP — XOAUTH2 for sending
- How Email Actually Works — synthesis
- Mxr — concrete implementation
- RFC 6749 (OAuth 2.0): https://datatracker.ietf.org/doc/html/rfc6749
- RFC 8252 (OAuth 2.0 for Native Apps): https://datatracker.ietf.org/doc/html/rfc8252
- Google's OAuth docs: https://developers.google.com/identity/protocols/oauth2