A odisseia do OAuth do Outlook: o IMAP está quebrado, viva o Graph
Esta é a história de como uma única issue no GitHub — "o login com conta Microsoft não funciona" — virou registrar nosso próprio aplicativo no Azure AD, construir uma camada de transporte completa da REST API do Microsoft Graph em Rust e depurar sete falhas em cascata até os e-mails finalmente carregarem. O que deveria ter sido um ajuste de configuração virou uma reimplementação do zero de como o MailVault fala com a Microsoft.
O relato do bug
Duas pessoas relataram a mesma coisa por ângulos diferentes. Quem usa M365 corporativo via AADSTS900971: No reply address provided ao tentar entrar. Quem usa Outlook.com pessoal passava da tela de login, mas esbarrava em AUTHENTICATE failed quando o aplicativo tentava conectar por IMAP. Mesmo sintoma — "o login não funciona" —, mas duas causas raiz completamente diferentes.
Problema 1: o client ID emprestado
O fluxo OAuth2 original do MailVault para a Microsoft usava o client ID público bem conhecido do Thunderbird. É um padrão comum — o Thunderbird registra aplicativos OAuth2 na Microsoft e no Google para que outros clientes de e-mail possam pegar carona. Funciona muito bem até não funcionar.
Nos tenants corporativos do M365, o aplicativo do Thunderbird não tem o nosso URI de redirecionamento localhost:19876/callback registrado. O Azure AD exige correspondência exata, e o aplicativo do Thunderbird só lista os URIs de redirecionamento do próprio Thunderbird. A correção: registrar um aplicativo do MailVault no Azure AD, com o nosso URI de redirecionamento exato, configurado como cliente público (sem segredo, só PKCE) e com suporte tanto a contas organizacionais quanto a contas pessoais da Microsoft.
Problema 2: a Microsoft quebrou o IMAP
O problema do Outlook.com pessoal era pior. Desde dezembro de 2024, a Microsoft tem uma regressão do lado do servidor que afeta o IMAP com OAuth2 nas contas pessoais. Depois de um fluxo OAuth2 bem-sucedido, o handshake SASL XOAUTH2 falha com "User is authenticated but not connected." Isso não é um bug de cliente — são os servidores IMAP da Microsoft rejeitando tokens válidos. A discussão no fórum de suporte da Microsoft está aberta há meses, sem correção à vista.
Senhas de aplicativo ainda funcionam em contas pessoais, mas isso anula o propósito do OAuth2. Precisávamos de uma abordagem completamente diferente.
A opção nuclear: Microsoft Graph API
Se o IMAP está quebrado, não use o IMAP. O Microsoft Graph é uma API REST que dá acesso direto às caixas postais, sem IMAP nenhum envolvido. Em vez de conectar em outlook.office365.com:993 e falar o protocolo IMAP, fazemos requisições HTTPS para graph.microsoft.com/v1.0/me/messages com um token Bearer. O mesmo token OAuth2, um transporte completamente diferente.
Construir um transporte do Graph significou mexer em quase todas as camadas do aplicativo:
Backend em Rust — Um novo módulo graph.rs, com uma struct GraphClient que implementa list_folders(), list_messages(), get_message(), delete_message(), set_read_status() e get_mime_content(). Estruturas de dados para as respostas JSON do Graph (GraphMessage, GraphMailFolder etc.), com um método de conversão para traduzir as mensagens do Graph na nossa struct EmailHeader já existente. Seis novos comandos Tauri para expor tudo isso ao frontend.
Ramificação dos escopos do OAuth2 — As contas IMAP precisam dos escopos IMAP.AccessAsUser.All e SMTP.Send. As contas Graph precisam de Mail.ReadWrite e Mail.Send. A escolha do escopo acontece no momento em que a pessoa clica em "Entrar com a Microsoft", com base em se o domínio do e-mail dela é um domínio pessoal conhecido da Microsoft (outlook.com, hotmail.com, live.com e 12 variantes regionais).
Store do frontend — Toda operação de e-mail no store do Zustand — carregar cabeçalhos, selecionar uma mensagem, marcar como lida/não lida, apagar, buscar, buscar antecipadamente — precisou de um ramo if (isGraphAccount(account)). As mensagens do Graph usam IDs opacos em texto, em vez de UIDs inteiros, então mantemos uma camada de mapeamento (_graphIdMap) que atribui UIDs sequenciais sintéticos às mensagens do Graph e faz a tradução de volta nas chamadas à API.
Pipeline em segundo plano — A classe AccountPipeline, que cuida do carregamento de cabeçalhos em segundo plano e do cache de conteúdo, precisou de caminhos específicos do Graph nas duas fases. O carregamento de cabeçalhos usa paginação por salto através do graphListMessages, em vez da busca de páginas por IMAP. O cache de conteúdo baixa o MIME completo pelo graphCacheMime (um único comando Rust que busca o .eml bruto, o salva no Maildir e o interpreta), em vez do FETCH do IMAP.
As sete falhas
Com a implementação pronta, publicamos e começamos a testar com uma conta real do Outlook.com. O que veio depois foi uma maratona de depuração em que cada correção revelava o problema seguinte.
Falha 1: "Invalid client secret provided" — Nosso build de CI estava definindo MAILVAULT_MS_CLIENT_SECRET como variável de ambiente. O código obedientemente mandava isso para o Azure, que rejeitava, porque nosso aplicativo está registrado como cliente público (nenhum segredo permitido). Correção: fixar client_secret: None para a Microsoft — um cliente PKCE público nunca deve mandar um segredo, ponto final.
Falha 2: "AUTHENTICATE failed" no teste de conexão — Depois de resolver o problema do segredo, o fluxo OAuth2 terminou com sucesso. Mas o aplicativo imediatamente tentou testar a conexão via IMAP — exatamente o protocolo que está quebrado. A função addAccount tinha um teste de conexão IMAP fixo no código, sem nenhum ramo para o Graph. Correção: testar contas Graph chamando graphListFolders em vez de abrir uma sessão IMAP.
Falha 3: "Could not load email content" — Os cabeçalhos carregavam bem pelo Graph, mas clicar em um e-mail para lê-lo falhava. O hook useChatBodyLoader — que carrega progressivamente os corpos das mensagens tanto na visão de chat quanto na visão de conversa — só sabia buscar corpos pelo fetchEmailLight do IMAP. Para contas Graph, ele precisava usar graphGetMessage com o ID de mensagem do Graph mapeado. Correção: acrescentar um ramo Graph que procura o ID do Graph e busca pela API do Graph.
Falha 4: os e-mails carregavam uma vez e depois sumiam — Os e-mails carregavam bem na primeira vez, mas sair da tela e voltar mostrava conversas vazias. O ramo Graph guardava os corpos das mensagens no cache LRU em memória, mas nunca gravava arquivos .eml no disco. Quando o cache LRU os descartava, eles sumiam. Correção: depois de exibir o e-mail pela resposta JSON rápida do graphGetMessage, disparar uma chamada graphCacheMime em segundo plano para baixar o MIME completo e salvar o .eml no Maildir.
Falha 5: indicador de carregamento sem fim ao clicar em um e-mail — A primeira correção da Falha 4 usava graphCacheMime como método principal de busca (baixar o MIME, salvar no disco, interpretar, devolver). Isso baixa o e-mail bruto inteiro, incluindo todos os anexos, antes de mostrar qualquer coisa. Para um e-mail de 5 MB com anexos, são segundos de espera. Correção: usar graphGetMessage (JSON rápido, só com o corpo HTML) para exibir na hora e depois graphCacheMime em segundo plano para gravar no disco. Carregamento em duas fases: exibição instantânea, persistência preguiçosa.
Falha 6: a visão de conversa exigia recolher/expandir para mostrar o conteúdo — Os e-mails na visão de conversa mostravam "Could not load email content" até serem recolhidos e expandidos manualmente. Era uma condição de corrida na renderização do React. O hook useChatBodyLoader guardava os corpos carregados em um useRef(new Map()) que começava vazio. O componente ThreadEmailItem lia desse mapa de forma síncrona durante a renderização, mas o useEffect que o preenchia rodava depois da primeira renderização. Quando os corpos ficavam disponíveis, o componente já tinha renderizado "Could not load." Correção: preencher o mapa de corpos de forma síncrona no próprio corpo do hook (não dentro do useEffect), consultando o cache em memória. O useEffect assíncrono continua buscando os corpos que não estão em cache, mas os que já estão ficam disponíveis logo na primeira renderização.
Falha 7: a sincronização em segundo plano ainda batia no IMAP — A função refreshAllAccounts atualiza todas as contas por um temporizador. Para as contas que não estavam ativas, ela usava IMAP incondicionalmente para buscar cabeçalhos, causando erros de conexão e enchendo o log de ruído nas contas Graph. Todos os outros caminhos de código tinham sido atualizados, mas esse passou batido. Correção: acrescentar um ramo isGraphAccount que usa graphListFolders + graphListMessages na sincronização em segundo plano.
O que lançamos
O resultado final: contas pessoais da Microsoft (outlook.com, hotmail.com, live.com e variantes regionais) são detectadas automaticamente no login e roteadas pelo transporte da Graph API. A pessoa clica em "Entrar com a Microsoft", autentica no navegador e o aplicativo funciona. Sem IMAP no meio, sem erros de "User is authenticated but not connected".
Contas corporativas de M365 usam o registro do nosso próprio aplicativo no Azure AD, com suporte adequado à URI de redirecionamento. Em organizações com políticas de OAuth2 rígidas, é possível informar outro client ID e tenant ID na seção "Avançado" da configuração da conta.
Contas do Graph permitem ler, apagar e marcar e-mails como lidos/não lidos. O envio continua por SMTP (que funciona bem com tokens OAuth2). Os corpos dos e-mails são buscados por JSON rápido para exibição instantânea, com o MIME completo baixado em segundo plano para acesso offline e suporte a anexos.
Lições
A maior lição: quando você acrescenta um novo transporte a um cliente de e-mail, não está acrescentando um caminho de código — está bifurcando todos eles. Toda função que toca a rede precisa saber qual transporte usar. Todo trabalho em segundo plano, todo pré-carregamento, todo temporizador de sincronização, todo teste de conexão. Esquecer um único caso cria um bug que só aparece para quem usa o transporte novo.
A segunda lição: o carregamento em duas fases vale a complexidade. Baixar uma mensagem MIME completa antes de mostrar qualquer coisa cria uma latência inaceitável. Mostrar uma prévia rápida vinda da API JSON e persistir o conteúdo completo em segundo plano dá retorno imediato a quem usa, sem deixar de construir o arquivo offline.
A terceira: o par useRef + useEffect do React é uma armadilha para dados que precisam estar disponíveis já na primeira renderização. Se um componente lê de uma ref durante a renderização mas a ref é preenchida em um efeito, a primeira renderização sempre vê dados desatualizados. Mover a lógica de preenchimento para rodar de forma síncrona no corpo do hook (e não em um efeito) resolve isso sem provocar renderizações extras.
Às vezes um bug de "o login não funciona" é um ajuste de configuração. Às vezes é uma camada de transporte de API inteira. Você não escolhe qual dos dois vai ser.