Saltar al contenido
Volver al blog

MailVault 2.4.0: una versión de estabilidad

7 min de lectura

Casi todo el trabajo que ha ido a MailVault 2.4.0 es invisible. La interfaz está prácticamente igual. La lista de funciones es modesta: un retardo de envío configurable, una copia en la carpeta Enviados, algún reordenamiento de los ajustes. Pero por dentro esta versión va sobre todo de arreglar la clase de fallos que erosionan en silencio la confianza en una app de correo: el correo equivocado apareciendo al pulsar, mensajes rancios filtrándose entre cuentas, un proceso auxiliar que no podía hablar consigo mismo y una infraestructura de publicación capaz de sacar un binario roto.

Esta es una versión de estabilidad. Esto es lo que estaba roto y lo que hicimos al respecto.

El fallo que nos hizo escribir esta entrada

En las cuentas de Outlook y Microsoft 365, pulsar un correo podía mostrar el contenido de otro completamente distinto. El asunto de la lista y el asunto en la parte superior del visor coincidían, pero el cuerpo, el remitente y las cabeceras pertenecían a otro mensaje. Se informaba pocas veces, era imposible de reproducir a voluntad y aterrador la primera vez que lo ves.

La causa de fondo fue un accidente de cómo unimos dos protocolos de correo distintos. El IMAP clásico da a cada mensaje un UID estable que no cambia en toda la vida del buzón. La API de Microsoft Graph no — en su lugar tiene identificadores de mensaje opacos, en forma de cadena. Para mantener uniforme el resto del código de MailVault, nuestra capa de transporte de Graph iba sintetizando UID por posición sobre la marcha: indexaba la carpeta y repartía 1, 2, 3, y así sucesivamente.

Eso funciona hasta que cambia el orden. Llega un mensaje nuevo, un hilo se mueve a otra carpeta, la ordenación del servidor se desplaza un milisegundo, y el UID posicional que apuntaba al correo A ahora apunta al correo B. La lista de cabeceras estaba bien porque la descargábamos de nuevo. Pero la caché de cuerpos, indexada por ese mismo UID a la deriva, empezaba a entregar el contenido equivocado.

El arreglo es arquitectónico: incrustamos el identificador de mensaje real de Graph directamente en cada cabecera de correo y lo usamos como identificador canónico en toda la caché de cuerpos, el cargador de contenido y las rutas de acción. El UID sintético sigue existiendo por compatibilidad con las interfaces con forma de IMAP dentro de la app, pero ya no es la fuente de verdad para "qué mensaje es este, de verdad".

Filtraciones entre cuentas

Un fallo emparentado de la familia "correo equivocado": cambiar de la cuenta A a la B, abrir en B una carpeta que estaba vacía y ver un instante los correos de la cuenta A todavía en la lista.

Era un problema de invalidación de caché. El cambio de cuenta limpiaba parte del estado del correo, pero no todo. La lista ordenada de correos, la caché de cuerpos en memoria y la caché de hilos se actualizaban con ritmos ligeramente distintos. Si la carpeta de destino tenía correos nuevos, los datos frescos llegaban lo bastante rápido como para tapar las entradas rancias. Si estaba vacía, lo que veías era la lista rancia hasta que por fin se propagaba el resultado "vacío".

El arreglo no tuvo nada de glamuroso: ahora todos los sitios que limpian el estado del correo al cambiar de cuenta o de buzón lo limpian entero, de forma atómica, antes de que empiecen a cargarse los datos de la cuenta nueva. Se acabaron las cachés limpiadas a medias y la carrera entre "ha llegado una carpeta vacía" y "se han desalojado los correos de la cuenta anterior".

El daemon en sandbox que no encontraba su propio socket

MailVault tiene un proceso auxiliar en segundo plano: un pequeño daemon acompañante que mantiene tu correo sincronizado aunque cierres la ventana principal. En macOS, tanto la app como el daemon se ejecutan dentro del App Sandbox, lo que significa que cada uno ve el sistema de archivos de forma distinta. Necesitan un punto de encuentro compartido para hablar por un socket de dominio Unix.

El fallo: la app resolvía la ruta del socket de una manera, el daemon de otra, y en las compilaciones con sandbox esas dos rutas apuntaban a directorios de contenedor distintos. El daemon arrancaba, ataba su socket y se quedaba esperando. La app intentaba conectar, fallaba y concluía que el daemon no estaba en marcha.

Ahora usamos dirs::home_dir() en los dos lados para resolver ~/.mailvault/mv.sock. Bajo el sandbox, eso se expande a la misma ruta relativa al contenedor en ambos procesos, así que el cliente siempre encuentra el servidor que acaba de lanzarlo.

Los permisos del helper recibieron una limpieza paralela. El binario del daemon heredaba app-sandbox de la configuración de firma de la app, algo que hace fallar a los binarios sueltos que no tienen su propio Info.plist. También estaba puesto application-groups, resto de un plan anterior de usar IPC con App Groups — innecesario ahora que los dos procesos comparten el mismo directorio de inicio dentro del contenedor. Quitamos los dos. El helper arranca limpio en todas las versiones de macOS con las que probamos, incluidas las compilaciones con sandbox recién instaladas.

Los permisos que parecían correctos y no lo eran

Este es el tipo de error más preocupante: todo parece correcto y, en silencio, no funciona.

Los archivos de permisos de macOS te dejan referirte al prefijo del equipo de desarrollo como $(AppIdentifierPrefix). Xcode expande esa variable antes de pasar el archivo a codesign. Nuestra cadena de publicación no es Xcode — llama a codesign directamente — y resulta que codesign no expande esa variable. La cadena literal $(AppIdentifierPrefix) se estaba incrustando en el binario firmado.

El síntoma: el uso compartido del llavero entre la app y el daemon estaba roto en silencio. La app podía leer las credenciales del llavero, pero el daemon no las veía — porque el identificador del grupo de acceso que pedía era la cadena literal $(AppIdentifierPrefix).com.mailvault... en lugar del identificador real con el prefijo del equipo. El daemon acababa pidiendo credenciales que no tenía y se rendía.

El arreglo es un paso de expansión con sed que reescribe el archivo de permisos sustituyendo el prefijo real del equipo antes de llamar a codesign. Lo añadimos junto a la comprobación de consistencia de versiones que ya teníamos, para que una clase parecida de error de "configuración que a primera vista parece correcta" se detecte en CI.

Publicación controlada de los snaps

Hablando de la integración continua: hasta la 2.4.0, publicar una etiqueta de versión subía automáticamente el snap resultante al canal estable. Si algo se rompía después de la primera comprobación rápida — y a veces pasaba, porque el confinamiento de Snap se comporta de forma distinta que una compilación de desarrollo y eso solo se ve tras instalar —, la versión rota ya estaba publicada cuando nos enterábamos.

La cadena de publicación ahora se detiene en el paso de "snap subido". Un flujo de trabajo aparte, Promote Snaps, mueve una compilación del canal edge al estable, y solo después de haberla instalado desde el canal edge en una máquina limpia y comprobado lo básico: añadir cuenta, cargar correo, copia de seguridad. Es un clic más en una publicación que sale bien, y una vuelta atrás mucho más tranquila en una que no.

Los arreglos más silenciosos

Un puñado de arreglos menores caen bajo el mismo tema de "que la app deje de contradecirse".

Invalidación de las cachés de módulo. Varias cachés de nivel de módulo que se inicializan de forma perezosa — cosas como las búsquedas memoizadas de carpetas y de descriptores — no se limpiaban cuando cambiaban los datos que hay debajo. Ahora sí, y la caché de carpetas unificadas tiene un tope de tamaño para que no crezca sin límite.

Guardados redundantes del descriptor. La activación de una cuenta escribía el descriptor de la cuenta en disco más de una vez por activación, y a veces competía consigo misma en los cambios rápidos. Esas escrituras duplicadas han desaparecido.

Recursión infinita en la activación de cuentas. Un camino poco frecuente de activateAccount — el recurso de restaurar el descriptor tras un fallo de caché — podía llamarse a sí mismo si el descriptor restaurado provocaba otra restauración. Estaba limitado por una guarda de reentrada; un arreglo más limpio elimina la recursión por completo.

Arquitectura del almacén. Teníamos un conjunto de envoltorios de "almacén de dominio" entre los componentes y el almacén principal de Zustand, que duplicaban el estado en copias locales. La idea era ofrecer una API más limpia, pero en la práctica introducían dos fuentes de verdad por cada pieza de estado y errores ocasionales de orden de actualización. Ahora son simples reexportaciones de selectores — la misma comodidad, una sola fuente de verdad.

Arquitectura del helper en segundo plano. El código en Rust que gestionaba el helper en segundo plano hacía dos trabajos en un mismo módulo: decidir cómo lanzar el daemon (qué binario, con qué argumentos, bajo qué sandbox) y encargarse del transporte RPC una vez en marcha. Los separamos en helper.rs, para la estrategia de lanzamiento, y un módulo de transporte aparte. Depurar un fallo de arranque ya no obliga a rebuscar entre el código de peticiones y respuestas, ni al revés. De paso reescribimos el texto que ve el usuario — "servicio del sistema" pasó a ser "helper en segundo plano", que es más honesto con lo que realmente es.

Por qué esta versión ha tardado

Las versiones de estabilidad son raramente fáciles de planificar. Cada arreglo por separado es pequeño, pero la superficie que cubren es enorme: se tocaron el transporte de Graph, el cambio de cuenta, la comunicación entre procesos con sandbox, la firma de código y la infraestructura de publicación. La mayoría de estos fallos eran lo bastante raros como para no poder reproducirlos a voluntad; tuvimos que razonar a partir de trazas, informes de usuarios y lectura del código hasta ver cómo surgía el estado erróneo, escribir después el arreglo y esperar a confirmar que dejaban de llegar informes.

La recompensa es de esas que no notas hasta que de pronto ya no ves un fallo que te mordía una vez por semana. Si te tocó alguno de los comportamientos de arriba — contenido equivocado en Outlook, correos rancios al cambiar de cuenta, un auxiliar que no se mantenía conectado —, la 2.4.0 es la versión que los hace desaparecer.

Lo siguiente en la lista: terminar la refactorización en cinco fases, cerrar lo que queda de la lista de seguimiento de la v2.4 (detección de buzón único por IMAP para Hostinger, casos límite al cambiar de carpeta, la burbuja de borrador al redactar) y seguir puliendo la reescritura del clasificador. El trabajo en funciones nuevas continúa — pero solo sobre unos cimientos en los que confiamos.