Skip to content
Back to Blog

The Outlook OAuth Odyssey: IMAP Is Broken, Long Live Graph

8 min read

This is the story of how a single GitHub issue — "Microsoft account login doesn't work" — turned into registering our own Azure AD application, building a complete Microsoft Graph REST API transport layer in Rust, and debugging seven cascading failures before emails finally loaded. What should have been a configuration fix became a ground-up reimplementation of how MailVault talks to Microsoft.

The Bug Report

Two users reported the same thing from different angles. Corporate M365 users saw AADSTS900971: No reply address provided when trying to sign in. Personal Outlook.com users got past the login screen but hit AUTHENTICATE failed when the app tried to connect via IMAP. Same symptom — "login doesn't work" — but two completely different root causes.

Problem 1: The Borrowed Client ID

MailVault's original Microsoft OAuth2 flow used Thunderbird's well-known public client ID. This is a common pattern — Thunderbird registers OAuth2 apps with Microsoft and Google so other email clients can piggyback. It works great until it doesn't.

For corporate M365 tenants, the Thunderbird app doesn't have our localhost:19876/callback redirect URI registered. Azure AD requires an exact match, and Thunderbird's app only lists Thunderbird's own redirect URIs. The fix: register MailVault's own Azure AD app with our exact redirect URI, configured as a public client (no secret, PKCE only), supporting both organizational and personal Microsoft accounts.

Problem 2: Microsoft Broke IMAP

The personal Outlook.com problem was worse. Since December 2024, Microsoft has had a server-side regression affecting IMAP OAuth2 for personal accounts. After a successful OAuth2 flow, the XOAUTH2 SASL handshake fails with "User is authenticated but not connected." This isn't a client bug — it's Microsoft's IMAP servers rejecting valid tokens. The thread on Microsoft's support forum has been open for months with no fix in sight.

App passwords still work for personal accounts, but that defeats the purpose of OAuth2. We needed a different approach entirely.

The Nuclear Option: Microsoft Graph API

If IMAP is broken, don't use IMAP. Microsoft Graph is a REST API that provides direct access to mailboxes without any IMAP involvement. Instead of connecting to outlook.office365.com:993 and speaking the IMAP protocol, we make HTTPS requests to graph.microsoft.com/v1.0/me/messages with a Bearer token. Same OAuth2 token, completely different transport.

Building a Graph transport meant touching almost every layer of the app:

Rust backend — A new graph.rs module with a GraphClient struct implementing list_folders(), list_messages(), get_message(), delete_message(), set_read_status(), and get_mime_content(). Data structures for Graph's JSON responses (GraphMessage, GraphMailFolder, etc.) with a conversion method to translate Graph messages into our existing EmailHeader struct. Six new Tauri commands to expose it all to the frontend.

OAuth2 scope branching — IMAP accounts need IMAP.AccessAsUser.All and SMTP.Send scopes. Graph accounts need Mail.ReadWrite and Mail.Send. The scope selection happens at the moment the user clicks "Sign in with Microsoft" based on whether their email domain is a known personal Microsoft domain (outlook.com, hotmail.com, live.com, and 12 regional variants).

Frontend store — Every email operation in the Zustand store — loading headers, selecting an email, marking read/unread, deleting, searching, prefetching — needed an if (isGraphAccount(account)) branch. Graph messages use opaque string IDs instead of integer UIDs, so we maintain a mapping layer (_graphIdMap) that assigns synthetic sequential UIDs to Graph messages and translates back when making API calls.

Background pipeline — The AccountPipeline class that handles background header loading and content caching needed Graph-specific paths for both phases. Header loading uses skip-based pagination through graphListMessages instead of IMAP page fetching. Content caching downloads full MIME via graphCacheMime (a single Rust command that fetches the raw .eml, saves it to Maildir, and parses it) instead of IMAP FETCH.

The Seven Failures

With the implementation done, we deployed and started testing with a real Outlook.com account. What followed was a debugging marathon where each fix revealed the next problem.

Failure 1: "Invalid client secret provided" — Our CI build was setting MAILVAULT_MS_CLIENT_SECRET as an environment variable. The code dutifully sent it to Azure, which rejected it because our app is registered as a public client (no secret allowed). Fix: hardcode client_secret: None for Microsoft — a public PKCE client should never send a secret, period.

Failure 2: "AUTHENTICATE failed" on connection test — After fixing the secret issue, the OAuth2 flow completed successfully. But the app immediately tried to test the connection via IMAP — the exact protocol that's broken. The addAccount function had a hardcoded IMAP connection test with no Graph branch. Fix: test Graph accounts by calling graphListFolders instead of opening an IMAP session.

Failure 3: "Could not load email content" — Headers loaded fine via Graph, but clicking on an email to read it failed. The useChatBodyLoader hook — which progressively loads email bodies in both the chat view and the thread view — only knew how to fetch bodies via IMAP's fetchEmailLight. For Graph accounts, it needed to use graphGetMessage with the mapped Graph message ID. Fix: add a Graph branch that looks up the Graph ID and fetches via the Graph API.

Failure 4: Emails loaded once, then disappeared — Emails loaded fine the first time, but navigating away and coming back showed empty threads. The Graph branch was caching email bodies in the in-memory LRU cache but never writing .eml files to disk. When the LRU cache evicted them, they were gone. Fix: after displaying the email via the fast graphGetMessage JSON response, fire a background graphCacheMime call to download the full MIME and save the .eml to Maildir.

Failure 5: Endless spinner on email click — The initial fix for Failure 4 used graphCacheMime as the primary fetch method (download MIME, save to disk, parse, return). This downloads the entire raw email including all attachments before showing anything. For a 5 MB email with attachments, that's seconds of spinner. Fix: use graphGetMessage (fast JSON with just the HTML body) for immediate display, then graphCacheMime in the background for disk persistence. Two-phase loading: instant display, lazy persistence.

Failure 6: Thread view required collapse/expand to see content — Emails in the thread conversation view showed "Could not load email content" until manually collapsed and re-expanded. This was a React rendering race condition. The useChatBodyLoader hook stored loaded bodies in a useRef(new Map()) that started empty. The ThreadEmailItem component read from this map synchronously during render, but the useEffect that populated it ran after the first render. By the time bodies were available, the component had already rendered "Could not load." Collapsing and expanding triggered a re-render, which found the now-populated map. Fix: pre-populate the bodies map synchronously during the hook body (not inside useEffect) by checking the in-memory cache. The async useEffect still handles fetching uncached bodies, but cached ones are available immediately on first render.

Failure 7: Background sync still hitting IMAP — The refreshAllAccounts function refreshes all accounts on a timer. For non-active accounts, it was unconditionally using IMAP to fetch headers, causing connection errors and log spam for Graph accounts. Every other code path had been updated, but this one was missed. Fix: add an isGraphAccount branch that uses graphListFolders + graphListMessages for background sync.

What We Shipped

The final result: personal Microsoft accounts (outlook.com, hotmail.com, live.com, and regional variants) are auto-detected during sign-in and routed through the Graph API transport. Users click "Sign in with Microsoft," authenticate in their browser, and the app works. No IMAP involved, no "User is authenticated but not connected" errors.

Corporate M365 accounts use MailVault's own Azure AD app registration with proper redirect URI support. For organizations with strict OAuth2 policies, users can override the client ID and tenant ID in an "Advanced" section of the account setup.

Graph accounts support reading, deleting, and marking emails as read/unread. Sending still goes through SMTP (which works fine with OAuth2 tokens). Email bodies are fetched via fast JSON for instant display, with full MIME downloaded in the background for offline access and attachment support.

Lessons

The biggest takeaway: when you add a new transport to an email client, you're not adding one code path — you're forking every code path. Every function that touches the network needs to know which transport to use. Every background job, every prefetch, every sync timer, every connection test. Missing even one creates a bug that only surfaces for users of the new transport.

The second takeaway: two-phase loading is worth the complexity. Downloading a full MIME message before showing anything creates unacceptable latency. Showing a fast preview from the JSON API and lazily persisting the full content in the background gives users instant feedback while still building up the offline archive.

The third: React's useRef + useEffect pattern is a footgun for data that needs to be available on first render. If a component reads from a ref during render but the ref is populated in an effect, the first render always sees stale data. Moving the population logic to run synchronously during the hook body (not in an effect) fixes it without introducing extra re-renders.

Sometimes a "login doesn't work" bug is a configuration fix. Sometimes it's a whole new API transport layer. You don't get to choose which one it'll be.