Vai al contenuto
Torna al blog

L'odissea OAuth di Outlook: IMAP è rotto, lunga vita a Graph

8 min di lettura

Questa è la storia di come una singola issue su GitHub — "l'accesso con l'account Microsoft non funziona" — si è trasformata nella registrazione di una nostra applicazione Azure AD, nella costruzione di un intero livello di trasporto REST per Microsoft Graph in Rust e nel debug di sette guasti a catena prima che le email si caricassero finalmente. Quella che doveva essere una correzione di configurazione è diventata una reimplementazione da zero del modo in cui MailVault parla con Microsoft.

La segnalazione

Due utenti hanno segnalato la stessa cosa da angolazioni diverse. Gli utenti M365 aziendali vedevano AADSTS900971: No reply address provided quando provavano ad accedere. Gli utenti Outlook.com personali superavano la schermata di accesso ma si scontravano con AUTHENTICATE failed quando l'app provava a collegarsi via IMAP. Stesso sintomo — "l'accesso non funziona" — ma due cause profonde del tutto diverse.

Problema 1: il client ID preso in prestito

Il flusso OAuth2 Microsoft originale di MailVault usava il client ID pubblico e noto di Thunderbird. È uno schema comune — Thunderbird registra app OAuth2 con Microsoft e Google così altri client di posta possono appoggiarcisi. Funziona benissimo finché non funziona più.

Per i tenant M365 aziendali, l'app di Thunderbird non ha registrato il nostro redirect URI localhost:19876/callback. Azure AD richiede una corrispondenza esatta, e l'app di Thunderbird elenca solo i redirect URI di Thunderbird. La correzione: registrare un'app Azure AD nostra, con il nostro redirect URI esatto, configurata come client pubblico (nessun secret, solo PKCE) e con supporto sia per gli account Microsoft aziendali sia per quelli personali.

Problema 2: Microsoft ha rotto IMAP

Il problema degli Outlook.com personali era peggiore. Da dicembre 2024 Microsoft ha una regressione lato server che riguarda IMAP con OAuth2 per gli account personali. Dopo un flusso OAuth2 riuscito, l'handshake SASL XOAUTH2 fallisce con "User is authenticated but not connected." Non è un bug del client — sono i server IMAP di Microsoft che rifiutano token validi. Il thread sul forum di supporto di Microsoft è aperto da mesi, senza una soluzione all'orizzonte.

Le password per app funzionano ancora per gli account personali, ma questo vanifica il senso di OAuth2. Ci serviva un approccio completamente diverso.

L'opzione nucleare: Microsoft Graph API

Se IMAP è rotto, non usare IMAP. Microsoft Graph è un'API REST che dà accesso diretto alle caselle di posta senza tirare in ballo IMAP. Invece di collegarci a outlook.office365.com:993 e parlare il protocollo IMAP, facciamo richieste HTTPS a graph.microsoft.com/v1.0/me/messages con un token Bearer. Stesso token OAuth2, trasporto completamente diverso.

Costruire un trasporto Graph ha voluto dire toccare quasi ogni livello dell'app:

Backend Rust — Un nuovo modulo graph.rs con una struct GraphClient che implementa list_folders(), list_messages(), get_message(), delete_message(), set_read_status() e get_mime_content(). Strutture dati per le risposte JSON di Graph (GraphMessage, GraphMailFolder, ecc.) con un metodo di conversione che traduce i messaggi Graph nella nostra struct EmailHeader esistente. Sei nuovi comandi Tauri per esporre il tutto al frontend.

Biforcazione degli scope OAuth2 — Gli account IMAP hanno bisogno degli scope IMAP.AccessAsUser.All e SMTP.Send. Gli account Graph hanno bisogno di Mail.ReadWrite e Mail.Send. La scelta dello scope avviene nel momento in cui l'utente fa clic su "Accedi con Microsoft", in base al fatto che il dominio della sua email sia un dominio personale Microsoft noto (outlook.com, hotmail.com, live.com e 12 varianti regionali).

Store del frontend — Ogni operazione sulle email dentro lo store Zustand — caricare le intestazioni, selezionare un'email, contrassegnarla come letta o da leggere, eliminarla, cercare, precaricare — aveva bisogno di un ramo if (isGraphAccount(account)). I messaggi Graph usano ID opachi in forma di stringa invece di UID interi, quindi manteniamo un livello di mappatura (_graphIdMap) che assegna ai messaggi Graph degli UID sequenziali sintetici e li ritraduce quando si fanno le chiamate all'API.

Pipeline in background — La classe AccountPipeline, che si occupa del caricamento delle intestazioni in background e della messa in cache dei contenuti, aveva bisogno di percorsi specifici per Graph in entrambe le fasi. Il caricamento delle intestazioni usa la paginazione a salti tramite graphListMessages invece del recupero a pagine di IMAP. La messa in cache dei contenuti scarica il MIME completo con graphCacheMime (un unico comando Rust che recupera il .eml grezzo, lo salva nel Maildir e lo analizza) invece della FETCH di IMAP.

I sette guasti

Con l'implementazione pronta, abbiamo distribuito e iniziato a provare con un account Outlook.com vero. Quello che è seguito è stata una maratona di debug in cui ogni correzione svelava il problema successivo.

Guasto 1: "Invalid client secret provided" — La nostra build di CI impostava MAILVAULT_MS_CLIENT_SECRET come variabile d'ambiente. Il codice lo inviava diligentemente ad Azure, che lo rifiutava perché la nostra app è registrata come client pubblico (nessun secret ammesso). Correzione: fissare nel codice client_secret: None per Microsoft — un client PKCE pubblico non deve mai inviare un secret, punto.

Guasto 2: "AUTHENTICATE failed" nel test di connessione — Dopo aver risolto la questione del secret, il flusso OAuth2 si completava correttamente. Ma l'app provava subito a testare la connessione via IMAP — esattamente il protocollo che è rotto. La funzione addAccount aveva un test di connessione IMAP fisso nel codice, senza alcun ramo per Graph. Correzione: testare gli account Graph chiamando graphListFolders invece di aprire una sessione IMAP.

Guasto 3: "Could not load email content" — Le intestazioni si caricavano bene via Graph, ma facendo clic su un'email per leggerla non succedeva nulla di buono. L'hook useChatBodyLoader — che carica progressivamente i corpi delle email sia nella vista chat sia nella vista thread — sapeva recuperare i corpi solo tramite fetchEmailLight di IMAP. Per gli account Graph doveva usare graphGetMessage con l'ID del messaggio Graph mappato. Correzione: aggiungere un ramo Graph che cerca l'ID Graph e recupera il messaggio tramite la Graph API.

Guasto 4: le email si caricavano una volta, poi sparivano — Le email si caricavano bene la prima volta, ma uscendo e rientrando i thread risultavano vuoti. Il ramo Graph metteva i corpi delle email nella cache LRU in memoria, ma non scriveva mai i file .eml su disco. Quando la cache LRU li espelleva, erano persi. Correzione: dopo aver mostrato l'email con la risposta JSON veloce di graphGetMessage, lanciare in background una chiamata graphCacheMime per scaricare il MIME completo e salvare il file .eml nel Maildir.

Guasto 5: rotella di caricamento infinita al clic su un'email — La prima correzione del Guasto 4 usava graphCacheMime come metodo di recupero principale (scarica il MIME, salva su disco, analizza, restituisce). Così si scarica l'intera email grezza, allegati compresi, prima di mostrare qualsiasi cosa. Per un'email da 5 MB con allegati sono secondi di rotella. Correzione: usare graphGetMessage (JSON veloce con il solo corpo HTML) per la visualizzazione immediata, poi graphCacheMime in background per la persistenza su disco. Caricamento in due fasi: visualizzazione istantanea, persistenza pigra.

Guasto 6: nella vista thread serviva comprimere ed espandere per vedere il contenuto — Le email nella vista conversazione mostravano "Could not load email content" finché non venivano compresse ed espanse a mano. Era una race condition del rendering di React. L'hook useChatBodyLoader conservava i corpi caricati in una useRef(new Map()) che partiva vuota. Il componente ThreadEmailItem leggeva da questa mappa in modo sincrono durante il render, ma la useEffect che la popolava girava dopo il primo render. Quando i corpi diventavano disponibili, il componente aveva già disegnato "Could not load". Comprimere ed espandere provocava un nuovo render, che trovava la mappa ormai popolata. Correzione: popolare la mappa dei corpi in modo sincrono nel corpo dell'hook (non dentro useEffect), controllando la cache in memoria. La useEffect asincrona continua a recuperare i corpi non in cache, ma quelli già in cache sono disponibili subito al primo render.

Guasto 7: la sincronizzazione in background continuava a passare da IMAP — La funzione refreshAllAccounts aggiorna tutti gli account a intervalli regolari. Per gli account non attivi usava IMAP senza condizioni per recuperare le intestazioni, causando errori di connessione e log invasi di messaggi per gli account Graph. Tutti gli altri percorsi di codice erano stati aggiornati, ma questo era sfuggito. Correzione: aggiungere un ramo isGraphAccount che usa graphListFolders + graphListMessages per la sincronizzazione in background.

Che cosa abbiamo rilasciato

Il risultato finale: gli account Microsoft personali (outlook.com, hotmail.com, live.com e le varianti regionali) vengono riconosciuti automaticamente durante l'accesso e instradati sul trasporto Graph API. L'utente fa clic su "Accedi con Microsoft", si autentica nel browser e l'app funziona. Niente IMAP di mezzo, niente errori "User is authenticated but not connected".

Gli account M365 aziendali usano la registrazione Azure AD di MailVault, con il supporto corretto per il redirect URI. Per le organizzazioni con policy OAuth2 rigide, l'utente può impostare a mano client ID e tenant ID in una sezione "Avanzate" della configurazione dell'account.

Gli account Graph supportano la lettura, l'eliminazione e il contrassegno delle email come lette o da leggere. L'invio passa ancora da SMTP (che con i token OAuth2 funziona bene). I corpi delle email vengono recuperati via JSON veloce per la visualizzazione immediata, con il MIME completo scaricato in background per l'accesso offline e per gli allegati.

Lezioni

La lezione più grande: quando aggiungi un nuovo trasporto a un client di posta non stai aggiungendo un percorso di codice — stai biforcando ogni percorso di codice. Ogni funzione che tocca la rete deve sapere quale trasporto usare. Ogni processo in background, ogni prefetch, ogni timer di sincronizzazione, ogni test di connessione. Dimenticarne anche solo uno crea un bug che emerge solo per chi usa il nuovo trasporto.

La seconda lezione: il caricamento in due fasi vale la complessità che aggiunge. Scaricare un intero messaggio MIME prima di mostrare qualsiasi cosa crea una latenza inaccettabile. Mostrare un'anteprima rapida dall'API JSON e salvare pigramente il contenuto completo in background dà un riscontro immediato e intanto costruisce l'archivio offline.

La terza: in React la combinazione useRef + useEffect è una trappola per i dati che devono essere disponibili al primo render. Se un componente legge da una ref durante il render ma la ref viene popolata in un effect, il primo render vede sempre dati vecchi. Spostare la logica di popolamento perché giri in modo sincrono nel corpo dell'hook (non in un effect) risolve il problema senza introdurre render aggiuntivi.

A volte un bug da "l'accesso non funziona" è una correzione di configurazione. A volte è un intero nuovo livello di trasporto API. Non puoi scegliere quale dei due sarà.