Saltar al contenido
Volver al blog

La odisea del OAuth de Outlook: IMAP está roto, larga vida a Graph

8 min de lectura

Esta es la historia de cómo una sola incidencia en GitHub — "el inicio de sesión con la cuenta de Microsoft no funciona" — acabó en registrar nuestra propia aplicación de Azure AD, construir una capa de transporte completa sobre la REST API de Microsoft Graph en Rust y depurar siete fallos en cascada antes de que los correos por fin cargaran. Lo que debería haber sido un ajuste de configuración se convirtió en reimplementar desde cero cómo habla MailVault con Microsoft.

El informe de fallo

Dos usuarios contaron lo mismo desde ángulos distintos. Los usuarios de M365 corporativo veían AADSTS900971: No reply address provided al intentar iniciar sesión. Los usuarios personales de Outlook.com pasaban la pantalla de inicio de sesión, pero se topaban con AUTHENTICATE failed cuando la app intentaba conectar por IMAP. El mismo síntoma — "el inicio de sesión no funciona" —, pero dos causas de fondo completamente distintas.

Problema 1: el ID de cliente prestado

El flujo original de OAuth2 con Microsoft de MailVault usaba el ID de cliente público conocido de Thunderbird. Es un patrón habitual: Thunderbird registra apps de OAuth2 con Microsoft y Google para que otros clientes de correo puedan ir a rebufo. Funciona de maravilla hasta que deja de hacerlo.

En los tenants corporativos de M365, la app de Thunderbird no tiene registrada nuestra URI de redirección localhost:19876/callback. Azure AD exige una coincidencia exacta, y la app de Thunderbird solo lista las suyas. La solución: registrar una app propia de MailVault en Azure AD con nuestra URI de redirección exacta, configurada como cliente público (sin secreto, solo PKCE) y compatible tanto con las cuentas de organización como con las personales de Microsoft.

Problema 2: Microsoft rompió IMAP

El problema de las cuentas personales de Outlook.com era peor. Desde diciembre de 2024, Microsoft arrastra una regresión del lado del servidor que afecta a IMAP con OAuth2 en las cuentas personales. Tras un flujo OAuth2 correcto, el saludo SASL XOAUTH2 falla con "User is authenticated but not connected." No es un error del cliente — son los servidores IMAP de Microsoft rechazando tokens válidos. El hilo del foro de soporte de Microsoft lleva meses abierto sin visos de arreglo.

Las contraseñas de aplicación siguen funcionando en las cuentas personales, pero eso desvirtúa el propósito de OAuth2. Necesitábamos un enfoque totalmente distinto.

La opción nuclear: la Microsoft Graph API

Si IMAP está roto, no uses IMAP. Microsoft Graph es una API REST que da acceso directo a los buzones sin IMAP de por medio. En lugar de conectarnos a outlook.office365.com:993 y hablar el protocolo IMAP, hacemos peticiones HTTPS a graph.microsoft.com/v1.0/me/messages con un token Bearer. El mismo token de OAuth2, un transporte completamente distinto.

Construir un transporte de Graph implicó tocar casi todas las capas de la app:

Backend en Rust — Un módulo nuevo, graph.rs, con una struct GraphClient que implementa list_folders(), list_messages(), get_message(), delete_message(), set_read_status() y get_mime_content(). Estructuras de datos para las respuestas JSON de Graph (GraphMessage, GraphMailFolder, etc.) con un método de conversión que traduce los mensajes de Graph a nuestra struct EmailHeader de siempre. Y seis comandos nuevos de Tauri para exponerlo todo al frontend.

Ramificación de los ámbitos de OAuth2 — Las cuentas IMAP necesitan los ámbitos IMAP.AccessAsUser.All y SMTP.Send. Las cuentas de Graph necesitan Mail.ReadWrite y Mail.Send. La elección del ámbito ocurre en el momento en que el usuario pulsa "Iniciar sesión con Microsoft", según si su dominio de correo es un dominio personal conocido de Microsoft (outlook.com, hotmail.com, live.com y 12 variantes regionales).

Almacén del frontend — Todas las operaciones con correo del almacén de Zustand — cargar cabeceras, seleccionar un correo, marcarlo como leído o no leído, borrarlo, buscar, precargar — necesitaban una rama if (isGraphAccount(account)). Los mensajes de Graph usan identificadores opacos en forma de cadena en lugar de UID enteros, así que mantenemos una capa de correspondencia (_graphIdMap) que asigna UID secuenciales sintéticos a los mensajes de Graph y los traduce de vuelta al hacer las llamadas a la API.

Pipeline en segundo plano — La clase AccountPipeline, que se ocupa de cargar cabeceras y cachear contenido en segundo plano, necesitaba caminos propios de Graph para las dos fases. La carga de cabeceras usa paginación por salto con graphListMessages en lugar de la descarga por páginas de IMAP. El cacheo de contenido descarga el MIME completo con graphCacheMime (un único comando de Rust que trae el .eml en bruto, lo guarda en Maildir y lo analiza) en lugar del FETCH de IMAP.

Los siete fallos

Con la implementación lista, la desplegamos y empezamos a probar con una cuenta real de Outlook.com. Lo que siguió fue un maratón de depuración en el que cada arreglo destapaba el siguiente problema.

Fallo 1: "Invalid client secret provided" — Nuestra compilación de CI definía MAILVAULT_MS_CLIENT_SECRET como variable de entorno. El código lo enviaba obedientemente a Azure, que lo rechazaba porque nuestra app está registrada como cliente público (sin secreto permitido). Solución: fijar client_secret: None para Microsoft — un cliente PKCE público no debe enviar nunca un secreto, y punto.

Fallo 2: "AUTHENTICATE failed" en la prueba de conexión — Tras resolver lo del secreto, el flujo OAuth2 se completaba correctamente. Pero la app intentaba de inmediato probar la conexión por IMAP — justo el protocolo que está roto. La función addAccount tenía la prueba de conexión IMAP fija en el código, sin rama para Graph. Solución: probar las cuentas de Graph llamando a graphListFolders en lugar de abrir una sesión IMAP.

Fallo 3: "Could not load email content" — Las cabeceras cargaban bien por Graph, pero al hacer clic en un correo para leerlo fallaba. El hook useChatBodyLoader — que carga progresivamente los cuerpos de los correos tanto en la vista de chat como en la de conversación — solo sabía obtener cuerpos con el fetchEmailLight de IMAP. Para las cuentas de Graph hacía falta usar graphGetMessage con el ID de mensaje de Graph correspondiente. Solución: añadir una rama para Graph que busque el ID de Graph y haga la petición a la API de Graph.

Fallo 4: los correos cargaban una vez y luego desaparecían — Los correos cargaban bien la primera vez, pero al salir y volver las conversaciones aparecían vacías. La rama de Graph guardaba los cuerpos en la caché LRU en memoria, pero nunca escribía archivos .eml en disco. Cuando la caché LRU los desalojaba, desaparecían. Solución: después de mostrar el correo con la respuesta JSON rápida de graphGetMessage, lanzar en segundo plano una llamada a graphCacheMime que descargue el MIME completo y guarde el .eml en Maildir.

Fallo 5: rueda de carga interminable al abrir un correo — El primer arreglo del fallo 4 usaba graphCacheMime como método principal de descarga (bajar el MIME, guardarlo en disco, analizarlo y devolverlo). Eso descarga el correo entero en bruto, con todos sus adjuntos, antes de mostrar nada. Para un correo de 5 MB con adjuntos, son segundos de rueda girando. Solución: usar graphGetMessage (JSON rápido, solo con el cuerpo HTML) para mostrarlo de inmediato y luego graphCacheMime en segundo plano para guardarlo en disco. Carga en dos fases: visualización instantánea, persistencia diferida.

Fallo 6: la vista de conversación exigía plegar y desplegar para ver el contenido — Los correos de la vista de conversación mostraban "Could not load email content" hasta que se plegaban y volvían a desplegar a mano. Era una condición de carrera al renderizar en React. El hook useChatBodyLoader guardaba los cuerpos cargados en un useRef(new Map()) que empezaba vacío. El componente ThreadEmailItem leía ese mapa de forma síncrona durante el render, pero el useEffect que lo rellenaba se ejecutaba después del primer render. Cuando los cuerpos ya estaban disponibles, el componente ya había pintado "Could not load." Plegar y desplegar provocaba un nuevo render, que sí encontraba el mapa ya lleno. Solución: rellenar el mapa de cuerpos de forma síncrona en el cuerpo del hook (no dentro del useEffect), consultando la caché en memoria. El useEffect asíncrono sigue encargándose de los cuerpos que no están en caché, pero los que sí lo están ya se pueden usar en el primer render.

Fallo 7: la sincronización en segundo plano seguía yendo por IMAP — La función refreshAllAccounts refresca todas las cuentas cada cierto tiempo. Para las cuentas no activas usaba IMAP sin condiciones para traer las cabeceras, lo que provocaba errores de conexión y ruido en los registros con las cuentas de Graph. Se habían actualizado todas las demás rutas del código, menos esta. Solución: añadir una rama isGraphAccount que use graphListFolders + graphListMessages para la sincronización en segundo plano.

Qué publicamos

El resultado final: las cuentas personales de Microsoft (outlook.com, hotmail.com, live.com y variantes regionales) se detectan automáticamente al iniciar sesión y se enrutan por el transporte de la Graph API. Se pulsa "Iniciar sesión con Microsoft", se autentica en el navegador y la app funciona. Sin IMAP de por medio, sin errores de "User is authenticated but not connected".

Las cuentas corporativas de M365 usan el registro de app de Azure AD propio de MailVault, con el URI de redirección correcto. En organizaciones con políticas estrictas de OAuth2, se pueden sobrescribir el ID de cliente y el ID de inquilino en una sección "Avanzado" de la configuración de la cuenta.

Las cuentas de Graph permiten leer, borrar y marcar correos como leídos o no leídos. El envío sigue yendo por SMTP (que funciona bien con tokens de OAuth2). Los cuerpos de los correos se descargan por JSON rápido para mostrarlos al instante, y el MIME completo se baja en segundo plano para el acceso sin conexión y los adjuntos.

Lecciones

La conclusión más importante: cuando añades un transporte nuevo a un cliente de correo, no estás añadiendo un camino de código — estás bifurcando todos los caminos. Cada función que toca la red tiene que saber qué transporte usar. Cada tarea de fondo, cada precarga, cada temporizador de sincronización, cada prueba de conexión. Saltarse una sola crea un fallo que solo aparece para quien use el transporte nuevo.

La segunda conclusión: la carga en dos fases compensa la complejidad. Descargar un mensaje MIME completo antes de mostrar nada genera una latencia inaceptable. Mostrar una vista previa rápida desde la API JSON y guardar el contenido completo de forma perezosa en segundo plano da respuesta inmediata mientras se construye igualmente el archivo sin conexión.

La tercera: el patrón useRef + useEffect de React es una trampa para los datos que tienen que estar disponibles en el primer render. Si un componente lee de una ref durante el render pero la ref se rellena en un efecto, el primer render siempre ve datos obsoletos. Mover la lógica de relleno al cuerpo del hook, para que se ejecute de forma síncrona y no en un efecto, lo resuelve sin provocar renders adicionales.

A veces un fallo de "el inicio de sesión no funciona" es un ajuste de configuración. A veces es una capa de transporte de API entera. No puedes elegir cuál de las dos será.