feat: Microsoft 365 OAuth2 IMAP support#228
Conversation
Covers XOAUTH2 SASL auth in IMAP client, Microsoft OAuth2 provider, add-o365 CLI command, and sync routing changes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
10 bite-sized tasks: XOAUTH2 SASL client, auth method config, token source in IMAP client, Microsoft OAuth manager, config section, add-o365 CLI, sync routing, account removal, dependency, and final verification. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… IMAP Implements the XOAUTH2 SASL mechanism (sasl.Client interface) needed by Microsoft Exchange Online IMAP, and adds the AuthMethod field to IMAP Config for routing between password and xoauth2 authentication. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bundles Microsoft OAuth2 browser flow + IMAP auto-configuration into a single command. Configures outlook.office365.com with XOAUTH2 auth method automatically after authorization succeeds. Also includes remove-account Microsoft token cleanup from concurrent task. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The MS Graph /me endpoint requires User.Read scope to return profile data. Without it, the token validation step after OAuth authorization would fail with HTTP 403. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
IMAP/O365 sources use imaps:// identifiers, so passing the bare email (e.g. matt@5.life) to sync-full/sync found no match and fell back to creating a fake Gmail source. Now falls back to display_name lookup, which add-o365 populates with the email address. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Azure AD cannot issue a single access token valid for both outlook.office365.com and graph.microsoft.com audiences. Using the IMAP-scoped token to call Graph returned 401 "Invalid audience". Switch to decoding the id_token JWT returned during the authorization code exchange — the openid+email scopes already embed the user's email claim, so no extra API call is needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Personal accounts (hotmail.com, outlook.com, live.com, etc.) require the outlook.office.com resource, not outlook.office365.com. Detect personal vs. org accounts by email domain and request the appropriate IMAP scope. Stored scopes are used for token refresh so existing tokens continue to work. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
roborev: Combined Review (
|
…crosoft OAuth Two fixes for the Microsoft IMAP OAuth flow: 1. (High) When the ID token lacks an "email" claim and only has "preferred_username" (UPN), the UPN may differ from the SMTP address for org accounts. Previously this caused a TokenMismatchError blocking account setup. Now we log a warning but proceed, since the user authenticated interactively with login_hint set. 2. (Medium) Use the "tid" (tenant ID) claim from the ID token to authoritatively determine personal vs org account type, and correct the IMAP scope via refresh token if the domain-based guess was wrong. Also expanded the consumer domain allowlist with regional variants. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
roborev: Combined Review (
|
Use source.DisplayName (the email address) when available, falling back to source.Identifier for sources without a display name. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…rection Three security fixes for the Microsoft OAuth flow: 1. (High) Account-binding bypass: When the ID token lacks an "email" claim and only has "preferred_username" (UPN), a mismatched UPN now returns TokenMismatchError instead of proceeding with a warning. Since login_hint is advisory, the prior behavior could bind a token to the wrong mailbox. 2. (Medium) Scope correction: Replace refresh-token-based scope correction with a second interactive browser flow. Refresh tokens cannot establish consent for a new IMAP resource, so the prior approach would fail for custom-domain consumer accounts. 3. (Medium) JWT validation: ID token claims are now validated using go-oidc (OIDC discovery + JWKS). Verifies signature, issuer, audience, expiry, and nonce. Adds nonce generation to the browser flow for replay protection. A verifyIDTokenFn hook allows tests to bypass OIDC validation with unsigned test JWTs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
roborev: Combined Review (
|
|
I'm closing my PR and will let this one be the definitive branch until it gets landed into main |
- Accept UPN/SMTP mismatch with a warning instead of hard error; Entra UPNs legitimately differ from mailbox SMTP addresses - Persist tenant_id in token files and validate IMAP scope on load to detect and reject stale tokens from before scope-correction - Treat ambiguous display-name matches as error in sync/sync-full Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Use tf.AccessToken instead of tf.Token.AccessToken (QF1008) - Check fmt.Fprintf return values with _, _ = (errcheck) - Lowercase OAuth error string (ST1005) - Use switch instead of if/else-if chain on callCount (QF1003) - Check tmpFile.Close() return value (errcheck) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
# Conflicts: # internal/api/handlers.go
roborev: Combined Review (
|
…ent safety
TokenSource was rebuilding the OAuth config from m.tenantID ("common")
instead of tf.TenantID (the actual tenant persisted per account). This
caused token refreshes to hit the wrong tenant endpoint for Entra accounts.
- Add oauthConfigWithTenant to build tenant-specific OAuth configs
- TokenSource now uses tf.TenantID when present, falls back to m.tenantID
for pre-migration tokens (backward compatible)
- Replace tf.Token mutation with a mutex-protected lastAccessToken variable,
eliminating the data race in the closure
- Add three tests: oauthConfigWithTenant unit test, persisted tenant override
test, and concurrent access test (run with -race)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…me query Fresh databases created by init-db were missing the oauth_app column in the sources table, causing "expected 10 destination arguments in Scan, not 11" when syncing Microsoft accounts. Also fixed GetSourcesByDisplayName which omitted oauth_app from its SELECT list. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
roborev: Combined Review (
|
Verifies that InitSchema() correctly adds the oauth_app column to existing databases created before the Microsoft IMAP feature, and that the source read paths (GetSourcesByIdentifier, GetSourcesByDisplayName) work after migration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
roborev: Combined Review (
|
…kups, messages-processed count - browserFlow: make channel sends non-blocking to prevent goroutine deadlock on duplicate HTTP requests (favicon, retries) to the callback endpoint - sync/syncfull: check EffectiveAuthMethod() before HasCredentials() so stale IMAP password files don't mask missing XOAUTH2 tokens for O365 sources - sync/syncfull: always run both identifier and display-name lookups, merge and deduplicate by source ID for reliable mixed Gmail+IMAP setups - incremental: count actual messages touched per history page, not records, so summary.MessagesFound and progress output reflect real message counts - buildAPIClient: guard against empty ClientID before creating Microsoft manager - sync.Options: remove CheckpointInterval field that was defined but never read Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Priority 1 – correctness: - store_attachment: safe two-value type assertion on sync.Map cache hit - deletion/executor: defensive nil check for manifest.Execution in finalizeExecution - build_cache: only trigger zero-row export error when count query succeeds (avoids false-positive failures when DuckDB can't verify) Priority 2 – resource / data safety: - build_cache: log warning instead of silently discarding attachment stats query error - deletion/executor: escalate checkpoint save failure from Warn to Error Priority 3 – error visibility: - repair_encoding: log tx.Rollback() failures at Warn instead of discarding - mbox_import: log warning when st.FailSync() itself fails (via failSync helper) Priority 4 – UX: - export_attachments: fail early with a clear error when output directory is not writable - addimap: link port flag help text to the package that owns the defaults Tests: - build_cache: assert state file is not written when buildCache returns an error - root: add TokenMismatchError test case verifying recovery instructions in error message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
roborev: Combined Review (
|
I do still need to make edits to README.md for the organizational O365 account. Personal hotmail account is easy.
Summary
- Microsoft 365 IMAP support: New add-o365 command sets up OAuth2 authentication for Microsoft accounts,
then syncs mail over IMAP using the XOAUTH2 SASL mechanism
- XOAUTH2 auth layer: Added AuthMethod config field and XOAUTH2 SASL client so IMAP connections can
authenticate with OAuth2 bearer tokens instead of passwords
- Microsoft OAuth2 manager: Full browser-based OAuth2 flow with PKCE, token persistence, automatic refresh,
and email validation via ID token claims
- Personal vs. org account support: Automatically detects personal Microsoft domains (hotmail.com,
outlook.com, live.com) and uses the correct IMAP scope (outlook.office.com vs outlook.office365.com)
- Account lookup by email: sync-full and sync now resolve IMAP accounts by display name when the bare email
is passed (e.g., sync-full matt@5.life finds imaps://matt@5.life@outlook.office365.com:993)
- Config: New [microsoft] section in config.toml for Azure AD client_id and tenant_id
Tested with
- Organizational O365 account (custom domain)
- Personal Hotmail account
- Gmail account (no regression)
- Full sync of ~10K messages over IMAP/XOAUTH2
Test plan
- msgvault add-o365 user@org.com --tenant — org account auth flow
- msgvault add-o365 user@hotmail.com — personal account auth flow (auto-detects scope)
- msgvault sync-full user@org.com --limit 100 — IMAP sync with XOAUTH2
- msgvault sync-full user@hotmail.com --limit 100 — personal account sync
- msgvault sync-full user@gmail.com --limit 100 — Gmail still works
- Unit tests pass (make test)