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)
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:
- 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.
- Protected local file with mode
0600— acceptable as a documented fallback when the client would otherwise strand the user in a noninteractive daemon. - Plain world-readable file — never. (Some clients do this anyway. Don't.)
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:
- 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'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:
- BYOC: More setup, but the user controls consent, test users, quota, and client lifecycle.
- Bundled fallback: Less setup when it works, but all users share one unverified OAuth app and Google's warning screen.
- Broken middle state: setting only one of
GMAIL_CLIENT_IDorGMAIL_CLIENT_SECRETis an artifact bug. Setting neither is coherent if BYOC remains the documented production path.
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
- 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
invalid_grant, prompt for re-auth, don't loop forever. If several local processes can refresh the same credential, treat the refresh token as shared mutable state. See Refresh Token Rotation Is Shared State. - 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
- Refresh Token Rotation Is Shared State — cross-provider token-store pattern
- 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