Aller au contenu
Retour au blog

L'odyssée OAuth d'Outlook : IMAP est cassé, vive Graph

8 min de lecture

Voici comment un seul ticket GitHub — « la connexion au compte Microsoft ne marche pas » — s'est transformé en enregistrement de notre propre application Azure AD, en construction d'une couche de transport REST Microsoft Graph complète en Rust, et en débogage de sept pannes en cascade avant que les e-mails ne se chargent enfin. Ce qui aurait dû être un correctif de configuration est devenu une réimplémentation de fond en comble de la façon dont MailVault parle à Microsoft.

Le signalement

Deux utilisateurs ont signalé la même chose sous deux angles différents. Des utilisateurs M365 d'entreprise voyaient AADSTS900971: No reply address provided en tentant de se connecter. Des utilisateurs Outlook.com personnels passaient l'écran de connexion, mais tombaient sur AUTHENTICATE failed quand l'application tentait de se connecter en IMAP. Même symptôme — « la connexion ne marche pas » — mais deux causes profondes totalement différentes.

Problème 1 : l'identifiant client emprunté

Le flux OAuth2 Microsoft d'origine de MailVault utilisait l'identifiant client public bien connu de Thunderbird. C'est un schéma courant — Thunderbird enregistre des applications OAuth2 chez Microsoft et Google pour que d'autres clients de messagerie puissent s'y greffer. Cela marche très bien jusqu'au jour où ça ne marche plus.

Pour les tenants M365 d'entreprise, l'application Thunderbird n'a pas notre URI de redirection localhost:19876/callback enregistrée. Azure AD exige une correspondance exacte, et l'application de Thunderbird ne déclare que les URI de redirection de Thunderbird. Le correctif : enregistrer notre propre application Azure AD pour MailVault, avec notre URI de redirection exacte, configurée comme client public (aucun secret, PKCE seulement), et acceptant à la fois les comptes d'organisation et les comptes Microsoft personnels.

Problème 2 : Microsoft a cassé IMAP

Le problème des comptes Outlook.com personnels était pire. Depuis décembre 2024, Microsoft traîne une régression côté serveur qui touche IMAP avec OAuth2 pour les comptes personnels. Après un flux OAuth2 réussi, la poignée de main SASL XOAUTH2 échoue avec « User is authenticated but not connected ». Ce n'est pas un bug client — ce sont les serveurs IMAP de Microsoft qui rejettent des jetons valides. Le fil sur le forum d'assistance de Microsoft est ouvert depuis des mois, sans correctif en vue.

Les mots de passe d'application fonctionnent encore pour les comptes personnels, mais cela ruine tout l'intérêt d'OAuth2. Il nous fallait une approche entièrement différente.

L'option nucléaire : Microsoft Graph API

Si IMAP est cassé, n'utilisons pas IMAP. Microsoft Graph est une API REST qui donne un accès direct aux boîtes mail, sans IMAP du tout. Au lieu de nous connecter à outlook.office365.com:993 et de parler le protocole IMAP, nous faisons des requêtes HTTPS vers graph.microsoft.com/v1.0/me/messages avec un jeton Bearer. Même jeton OAuth2, transport complètement différent.

Construire un transport Graph a demandé de toucher presque chaque couche de l'application :

Backend Rust — un nouveau module graph.rs avec une structure GraphClient qui implémente list_folders(), list_messages(), get_message(), delete_message(), set_read_status() et get_mime_content(). Des structures de données pour les réponses JSON de Graph (GraphMessage, GraphMailFolder, etc.), avec une méthode de conversion pour traduire les messages Graph vers notre structure EmailHeader existante. Et six nouvelles commandes Tauri pour exposer le tout au frontend.

Aiguillage des portées OAuth2 — les comptes IMAP ont besoin des portées IMAP.AccessAsUser.All et SMTP.Send. Les comptes Graph ont besoin de Mail.ReadWrite et Mail.Send. Le choix de la portée se fait au moment où l'utilisateur clique sur « Se connecter avec Microsoft », selon que son domaine de messagerie est un domaine Microsoft personnel connu (outlook.com, hotmail.com, live.com et 12 variantes régionales).

Store du frontend — chaque opération sur les e-mails dans le store Zustand — charger les en-têtes, sélectionner un e-mail, marquer lu/non lu, supprimer, chercher, précharger — a eu besoin d'une branche if (isGraphAccount(account)). Les messages Graph utilisent des identifiants opaques sous forme de chaînes, et non des UID entiers : nous maintenons donc une couche de correspondance (_graphIdMap) qui attribue des UID séquentiels synthétiques aux messages Graph et retraduit dans l'autre sens au moment des appels d'API.

Pipeline d'arrière-plan — la classe AccountPipeline, qui gère le chargement des en-têtes en arrière-plan et la mise en cache du contenu, avait besoin de chemins propres à Graph pour ces deux phases. Le chargement des en-têtes utilise une pagination par saut via graphListMessages au lieu de la récupération page par page d'IMAP. La mise en cache du contenu télécharge le MIME complet via graphCacheMime (une unique commande Rust qui récupère le .eml brut, l'enregistre dans Maildir et l'analyse) au lieu du FETCH d'IMAP.

Les sept échecs

L'implémentation faite, nous avons déployé et commencé à tester avec un vrai compte Outlook.com. Ce qui a suivi fut un marathon de débogage où chaque correctif révélait le problème suivant.

Échec 1 : « Invalid client secret provided » — notre build CI définissait MAILVAULT_MS_CLIENT_SECRET comme variable d'environnement. Le code l'envoyait consciencieusement à Azure, qui le rejetait parce que notre application est enregistrée comme client public (aucun secret autorisé). Correctif : coder en dur client_secret: None pour Microsoft — un client PKCE public ne doit jamais envoyer de secret, point final.

Échec 2 : « AUTHENTICATE failed » au test de connexion — une fois le problème de secret réglé, le flux OAuth2 aboutissait. Mais l'application testait aussitôt la connexion via IMAP — précisément le protocole qui est cassé. La fonction addAccount contenait un test de connexion IMAP codé en dur, sans branche Graph. Correctif : tester les comptes Graph en appelant graphListFolders au lieu d'ouvrir une session IMAP.

Échec 3 : « Could not load email content » — les en-têtes se chargeaient bien via Graph, mais l'ouverture d'un e-mail pour le lire échouait. Le hook useChatBodyLoader — qui charge progressivement les corps de message dans la vue discussion comme dans la vue fil de discussion — ne savait récupérer les corps que par le fetchEmailLight d'IMAP. Pour les comptes Graph, il fallait utiliser graphGetMessage avec l'identifiant de message Graph correspondant. Correctif : ajouter une branche Graph qui retrouve l'identifiant Graph et récupère le message via l'API Graph.

Échec 4 : les e-mails se chargeaient une fois, puis disparaissaient — les e-mails se chargeaient bien la première fois, mais en quittant la vue puis en y revenant, les fils étaient vides. La branche Graph mettait les corps de message dans le cache LRU en mémoire, sans jamais écrire de fichiers .eml sur le disque. Dès que le cache LRU les évinçait, ils étaient perdus. Correctif : après l'affichage de l'e-mail via la réponse JSON rapide de graphGetMessage, déclencher en arrière-plan un appel graphCacheMime pour télécharger le MIME complet et enregistrer le .eml dans Maildir.

Échec 5 : indicateur de chargement sans fin au clic sur un e-mail — le premier correctif de l'échec 4 utilisait graphCacheMime comme méthode de récupération principale (télécharger le MIME, l'enregistrer sur le disque, l'analyser, le retourner). Cela télécharge l'e-mail brut en entier, pièces jointes comprises, avant d'afficher quoi que ce soit. Pour un e-mail de 5 Mo avec pièces jointes, cela fait plusieurs secondes d'attente. Correctif : utiliser graphGetMessage (du JSON rapide, avec le seul corps HTML) pour un affichage immédiat, puis graphCacheMime en arrière-plan pour la persistance sur disque. Chargement en deux temps : affichage instantané, persistance différée.

Échec 6 : la vue fil de discussion exigeait de replier puis déplier pour voir le contenu — dans la vue conversation d'un fil, les e-mails affichaient « Could not load email content » jusqu'à ce qu'on les replie et les déplie à la main. C'était une situation de concurrence au rendu React. Le hook useChatBodyLoader rangeait les corps chargés dans un useRef(new Map()) initialement vide. Le composant ThreadEmailItem lisait cette map de façon synchrone pendant le rendu, mais le useEffect qui la remplissait s'exécutait après le premier rendu. Quand les corps devenaient disponibles, le composant avait déjà affiché « Could not load ». Correctif : pré-remplir la map des corps de façon synchrone dans le corps du hook, et non dans useEffect, en consultant le cache en mémoire. Le useEffect asynchrone continue de récupérer les corps absents du cache, mais ceux qui y sont déjà sont disponibles dès le premier rendu.

Échec 7 : la synchronisation en arrière-plan passait encore par IMAP — la fonction refreshAllAccounts rafraîchit tous les comptes sur minuterie. Pour les comptes non actifs, elle utilisait systématiquement IMAP pour récupérer les en-têtes, ce qui provoquait des erreurs de connexion et polluait les journaux pour les comptes Graph. Tous les autres chemins de code avaient été mis à jour, celui-ci était passé au travers. Correctif : ajouter une branche isGraphAccount qui utilise graphListFolders + graphListMessages pour la synchronisation en arrière-plan.

Ce que nous avons livré

Le résultat final : les comptes Microsoft personnels (outlook.com, hotmail.com, live.com et les variantes régionales) sont détectés automatiquement à la connexion et acheminés par le transport Graph API. L'utilisateur clique sur « Se connecter avec Microsoft », s'authentifie dans son navigateur, et l'application fonctionne. Aucun IMAP en jeu, aucune erreur « User is authenticated but not connected ».

Les comptes M365 d'entreprise utilisent l'enregistrement d'application Azure AD propre à MailVault, avec une prise en charge correcte de l'URI de redirection. Pour les organisations aux politiques OAuth2 strictes, l'utilisateur peut remplacer l'identifiant client et l'identifiant de tenant dans une section « Avancé » de la configuration du compte.

Les comptes Graph prennent en charge la lecture, la suppression et le marquage lu/non lu. L'envoi passe toujours par SMTP (qui fonctionne très bien avec des jetons OAuth2). Les corps de message sont récupérés en JSON rapide pour un affichage immédiat, le MIME complet étant téléchargé en arrière-plan pour l'accès hors ligne et les pièces jointes.

Les leçons

L'enseignement principal : quand vous ajoutez un nouveau transport à un client de messagerie, vous n'ajoutez pas un chemin de code — vous dédoublez chaque chemin de code. Chaque fonction qui touche au réseau doit savoir quel transport utiliser. Chaque tâche d'arrière-plan, chaque préchargement, chaque minuteur de synchronisation, chaque test de connexion. En oublier un seul crée un bug qui n'apparaît que pour les utilisateurs du nouveau transport.

Le deuxième enseignement : le chargement en deux temps vaut sa complexité. Télécharger un message MIME complet avant d'afficher quoi que ce soit crée une latence inacceptable. Afficher un aperçu rapide depuis l'API JSON et persister le contenu complet en arrière-plan donne un retour immédiat tout en construisant l'archive hors ligne.

Le troisième : le duo useRef + useEffect de React est un piège pour les données qui doivent être disponibles dès le premier rendu. Si un composant lit une ref pendant le rendu alors que cette ref est remplie dans un effet, le premier rendu voit toujours des données périmées. Déplacer le remplissage pour qu'il s'exécute de façon synchrone dans le corps du hook, et non dans un effet, règle le problème sans provoquer de rendus supplémentaires.

Parfois, un bug « la connexion ne marche pas » se règle par une correction de configuration. Parfois, c'est toute une nouvelle couche de transport d'API. On ne choisit pas lequel des deux ce sera.