Die Outlook-OAuth-Odyssee: IMAP ist kaputt, lang lebe Graph
Das ist die Geschichte davon, wie aus einem einzigen GitHub-Issue — „Anmeldung mit Microsoft-Konto funktioniert nicht“ — die Registrierung einer eigenen Azure-AD-Anwendung wurde, dazu eine vollständige Transportschicht für die Microsoft Graph REST API in Rust und das Debuggen von sieben aufeinanderfolgenden Fehlern, bevor endlich E-Mails geladen wurden. Was eine Korrektur an der Konfiguration hätte sein sollen, wurde zu einer Neuimplementierung von Grund auf, wie MailVault mit Microsoft spricht.
Der Fehlerbericht
Zwei Nutzer meldeten dasselbe aus verschiedenen Richtungen. Firmennutzer von M365 sahen AADSTS900971: No reply address provided, wenn sie sich anmelden wollten. Nutzer persönlicher Outlook.com-Konten kamen am Anmeldebildschirm vorbei, liefen aber in AUTHENTICATE failed, als die App die IMAP-Verbindung aufbauen wollte. Dasselbe Symptom — „Anmeldung geht nicht“ —, aber zwei völlig verschiedene Ursachen.
Problem 1: Die geborgte Client-ID
MailVaults ursprünglicher Microsoft-OAuth2-Ablauf nutzte Thunderbirds bekannte öffentliche Client-ID. Das ist ein verbreitetes Muster — Thunderbird registriert OAuth2-Apps bei Microsoft und Google, damit andere Mailclients mitfahren können. Das funktioniert prima, bis es das nicht mehr tut.
Bei Firmen-M365-Mandanten hat die Thunderbird-App unsere Weiterleitungs-URI localhost:19876/callback nicht registriert. Azure AD verlangt eine exakte Übereinstimmung, und Thunderbirds App führt nur Thunderbirds eigene Weiterleitungs-URIs. Die Lösung: eine eigene Azure-AD-App für MailVault mit unserer exakten Weiterleitungs-URI registrieren, eingerichtet als öffentlicher Client (kein Secret, nur PKCE), der sowohl Organisations- als auch persönliche Microsoft-Konten unterstützt.
Problem 2: Microsoft hat IMAP kaputt gemacht
Das Problem bei persönlichen Outlook.com-Konten war schlimmer. Seit Dezember 2024 hat Microsoft einen serverseitigen Rückschritt, der IMAP mit OAuth2 bei persönlichen Konten betrifft. Nach einem erfolgreichen OAuth2-Ablauf scheitert der XOAUTH2-SASL-Handshake mit „User is authenticated but not connected.“ Das ist kein Fehler im Client — es sind Microsofts IMAP-Server, die gültige Token ablehnen. Der Thread im Support-Forum von Microsoft ist seit Monaten offen, ohne Lösung in Sicht.
App-Passwörter funktionieren für private Konten weiterhin, aber das untergräbt den Sinn von OAuth2. Wir brauchten einen ganz anderen Ansatz.
Die radikale Option: die Microsoft Graph API
Wenn IMAP kaputt ist, nutz kein IMAP. Microsoft Graph ist eine REST-API, die direkten Zugriff auf Postfächer bietet, ganz ohne IMAP. Statt uns mit outlook.office365.com:993 zu verbinden und das IMAP-Protokoll zu sprechen, schicken wir HTTPS-Anfragen an graph.microsoft.com/v1.0/me/messages, mit einem Bearer-Token. Derselbe OAuth2-Token, völlig anderer Transportweg.
Einen Graph-Transport zu bauen hieß, fast jede Schicht der App anzufassen:
Rust-Backend — Ein neues Modul graph.rs mit einem Struct GraphClient, das list_folders(), list_messages(), get_message(), delete_message(), set_read_status() und get_mime_content() implementiert. Datenstrukturen für Graphs JSON-Antworten (GraphMessage, GraphMailFolder usw.) mit einer Umwandlungsmethode, die Graph-Nachrichten in unser bestehendes Struct EmailHeader übersetzt. Sechs neue Tauri-Befehle machen das alles im Frontend verfügbar.
Verzweigung bei den OAuth2-Scopes — IMAP-Konten brauchen die Scopes IMAP.AccessAsUser.All und SMTP.Send. Graph-Konten brauchen Mail.ReadWrite und Mail.Send. Der Scope wird in dem Moment gewählt, in dem jemand auf „Mit Microsoft anmelden“ klickt, und zwar danach, ob die E-Mail-Domain eine bekannte persönliche Microsoft-Domain ist (outlook.com, hotmail.com, live.com und 12 regionale Varianten).
Frontend-Store — Jede E-Mail-Operation im Zustand-Store — Kopfzeilen laden, eine E-Mail auswählen, gelesen/ungelesen markieren, löschen, suchen, vorab laden — brauchte einen Zweig if (isGraphAccount(account)). Graph-Nachrichten nutzen undurchsichtige Zeichenketten-IDs statt ganzzahliger UIDs, wir halten also eine Zuordnungsschicht (_graphIdMap) vor, die Graph-Nachrichten künstliche fortlaufende UIDs zuweist und beim API-Aufruf zurückübersetzt.
Hintergrund-Pipeline — Die Klasse AccountPipeline, die das Laden der Kopfzeilen im Hintergrund und das Zwischenspeichern der Inhalte übernimmt, brauchte für beide Phasen eigene Graph-Wege. Das Laden der Kopfzeilen blättert per Skip durch graphListMessages, statt Seiten per IMAP zu holen. Beim Zwischenspeichern der Inhalte wird das vollständige MIME über graphCacheMime geladen (ein einzelner Rust-Befehl, der die rohe .eml holt, ins Maildir speichert und parst) statt per IMAP-FETCH.
Die sieben Fehlschläge
Mit der fertigen Implementierung haben wir ausgeliefert und mit einem echten Outlook.com-Konto zu testen begonnen. Es folgte ein Debugging-Marathon, bei dem jede Korrektur das nächste Problem freilegte.
Fehler 1: „Invalid client secret provided“ — Unser CI-Build setzte MAILVAULT_MS_CLIENT_SECRET als Umgebungsvariable. Der Code schickte es brav an Azure, das es ablehnte, weil unsere App als öffentlicher Client registriert ist (kein Secret erlaubt). Lösung: client_secret: None für Microsoft fest verdrahten — ein öffentlicher PKCE-Client darf niemals ein Secret senden, Punkt.
Fehler 2: „AUTHENTICATE failed“ beim Verbindungstest — Nachdem das Secret-Problem behoben war, lief der OAuth2-Ablauf sauber durch. Aber die App versuchte sofort, die Verbindung per IMAP zu testen — also über genau das Protokoll, das kaputt ist. Die Funktion addAccount hatte einen fest verdrahteten IMAP-Verbindungstest ohne Graph-Zweig. Lösung: Graph-Konten testen, indem graphListFolders aufgerufen wird, statt eine IMAP-Sitzung zu öffnen.
Fehler 3: „Could not load email content“ — Die Kopfzeilen luden über Graph problemlos, aber der Klick auf eine E-Mail, um sie zu lesen, schlug fehl. Der Hook useChatBodyLoader — der die E-Mail-Texte sowohl in der Chat- als auch in der Thread-Ansicht nach und nach lädt — kannte nur den Weg über IMAPs fetchEmailLight. Für Graph-Konten musste er graphGetMessage mit der zugeordneten Graph-Nachrichten-ID verwenden. Lösung: einen Graph-Zweig ergänzen, der die Graph-ID nachschlägt und über die Graph-API lädt.
Fehler 4: E-Mails luden einmal und verschwanden dann — Beim ersten Mal luden die E-Mails einwandfrei, aber wer wegnavigierte und zurückkam, sah leere Threads. Der Graph-Zweig legte die E-Mail-Texte im LRU-Cache im Arbeitsspeicher ab, schrieb aber nie .eml-Dateien auf die Festplatte. Sobald der LRU-Cache sie verdrängte, waren sie weg. Lösung: nachdem die E-Mail über die schnelle JSON-Antwort von graphGetMessage angezeigt wurde, im Hintergrund graphCacheMime aufrufen, um das vollständige MIME zu laden und die .eml ins Maildir zu speichern.
Fehler 5: Endlose Ladeanzeige beim Klick auf eine E-Mail — Die erste Korrektur für Fehler 4 benutzte graphCacheMime als primäre Abrufmethode (MIME laden, auf die Festplatte schreiben, parsen, zurückgeben). Dabei wird die gesamte Roh-E-Mail samt aller Anhänge geladen, bevor überhaupt etwas erscheint. Bei einer 5 MB großen E-Mail mit Anhängen sind das Sekunden voller Ladeanzeige. Lösung: graphGetMessage (schnelles JSON mit nur dem HTML-Text) für die sofortige Anzeige verwenden, dann graphCacheMime im Hintergrund für die dauerhafte Speicherung. Zweistufiges Laden: sofort anzeigen, später speichern.
Fehler 6: Die Thread-Ansicht zeigte den Inhalt erst nach Zu- und wieder Aufklappen — E-Mails in der Thread-Unterhaltungsansicht zeigten „Could not load email content“, bis man sie von Hand zuklappte und wieder aufklappte. Das war ein Rennen beim Rendern in React. Der Hook useChatBodyLoader legte geladene Texte in einer useRef(new Map()) ab, die anfangs leer war. Die Komponente ThreadEmailItem las synchron während des Renderns aus dieser Map, aber der useEffect, der sie füllte, lief nach dem ersten Rendern. Als die Texte verfügbar waren, hatte die Komponente „Could not load.“ längst gerendert. Zu- und wieder Aufklappen löste ein erneutes Rendern aus, das die inzwischen gefüllte Map fand. Lösung: die Map synchron im Hook-Körper vorbefüllen (nicht in einem useEffect), indem der Cache im Arbeitsspeicher abgefragt wird. Der asynchrone useEffect holt weiterhin die nicht zwischengespeicherten Texte, aber die zwischengespeicherten stehen schon beim ersten Rendern bereit.
Fehler 7: Die Hintergrundsynchronisierung ging weiterhin über IMAP — Die Funktion refreshAllAccounts aktualisiert alle Konten nach einem Zeitgeber. Für nicht aktive Konten holte sie die Kopfzeilen ausnahmslos per IMAP, was bei Graph-Konten Verbindungsfehler und Log-Müll verursachte. Jeder andere Codepfad war angepasst worden, dieser eine wurde übersehen. Lösung: einen Zweig isGraphAccount ergänzen, der für die Hintergrundsynchronisierung graphListFolders + graphListMessages verwendet.
Was wir ausgeliefert haben
Das Endergebnis: Private Microsoft-Konten (outlook.com, hotmail.com, live.com und regionale Varianten) werden bei der Anmeldung automatisch erkannt und über den Graph-API-Transport geleitet. Man klickt auf „Mit Microsoft anmelden“, authentifiziert sich im Browser, und die App funktioniert. Kein IMAP beteiligt, keine „User is authenticated but not connected“-Fehler.
M365-Firmenkonten nutzen MailVaults eigene Azure-AD-App-Registrierung mit passender Unterstützung für die Redirect-URI. Für Organisationen mit strengen OAuth2-Richtlinien lassen sich Client-ID und Tenant-ID in einem Bereich „Erweitert“ der Kontoeinrichtung übersteuern.
Graph-Konten unterstützen das Lesen, Löschen und Markieren von E-Mails als gelesen/ungelesen. Der Versand läuft weiterhin über SMTP (was mit OAuth2-Token gut funktioniert). E-Mail-Inhalte werden für die sofortige Anzeige per schnellem JSON geholt, während das vollständige MIME im Hintergrund für Offline-Zugriff und Anhänge heruntergeladen wird.
Lehren
Die wichtigste Erkenntnis: Wenn du einem E-Mail-Client einen neuen Transport hinzufügst, fügst du nicht einen Codepfad hinzu — du gabelst jeden Codepfad. Jede Funktion, die das Netzwerk berührt, muss wissen, welchen Transport sie nutzen soll. Jeder Hintergrundjob, jeder Vorabruf, jeder Sync-Timer, jeder Verbindungstest. Auch nur einen zu übersehen erzeugt einen Fehler, der nur bei den Nutzenden des neuen Transports auftritt.
Die zweite Erkenntnis: Zweiphasiges Laden ist die Komplexität wert. Eine vollständige MIME-Nachricht herunterzuladen, bevor überhaupt etwas erscheint, erzeugt eine inakzeptable Verzögerung. Eine schnelle Vorschau aus der JSON-API zu zeigen und den vollständigen Inhalt träge im Hintergrund abzulegen, gibt sofortige Rückmeldung und baut trotzdem das Offline-Archiv auf.
Die dritte: Reacts Muster aus useRef + useEffect ist eine Fußangel für Daten, die schon beim ersten Rendern verfügbar sein müssen. Wenn eine Komponente während des Renderns aus einer Ref liest, die Ref aber in einem Effect gefüllt wird, sieht das erste Rendern immer veraltete Daten. Die Befüllung synchron im Hook-Körper laufen zu lassen statt in einem Effect behebt das, ohne zusätzliche Renderdurchläufe einzuführen.
Manchmal ist ein „Anmeldung funktioniert nicht“-Fehler eine Korrektur an der Konfiguration. Manchmal ist er eine ganz neue API-Transportschicht. Du kannst dir nicht aussuchen, welches von beidem es wird.