MailVault 2.4.0 : une version de stabilisation
L'essentiel du travail qui a mené à MailVault 2.4.0 est invisible. L'interface est presque identique. La liste des nouveautés est modeste — un délai d'envoi configurable, une copie dans le dossier Envoyés, quelques réglages réorganisés. Mais sous le capot, cette version corrige surtout le genre de bugs qui érodent discrètement la confiance dans une application de messagerie : le mauvais e-mail qui s'affiche au clic, des messages périmés qui débordent d'un compte à l'autre, un assistant d'arrière-plan incapable de se parler à lui-même, et une chaîne de publication capable de livrer un binaire cassé.
C'est une version de stabilisation. Voici ce qui était cassé, et ce que nous y avons fait.
Le bug qui nous a fait écrire ce billet
Sur les comptes Outlook et Microsoft 365, cliquer sur un e-mail pouvait afficher le contenu d'un tout autre e-mail. L'objet dans la liste et l'objet en haut du lecteur correspondaient — mais le corps, l'expéditeur et les en-têtes appartenaient à un autre message. Rarement signalé, impossible à reproduire à la demande, terrifiant la première fois qu'on le voit.
La cause profonde était un accident de la façon dont nous avions relié deux protocoles de messagerie différents. L'IMAP classique donne à chaque message un UID stable, qui ne change jamais de toute la vie d'une boîte. L'API Microsoft Graph, non — elle a des identifiants de message opaques, sous forme de chaînes. Pour garder le reste du code de MailVault uniforme, notre couche de transport Graph fabriquait des UID positionnels à la volée : indexer le dossier, distribuer 1, 2, 3, et ainsi de suite.
Cela marche jusqu'à ce que l'ordre change. Un nouveau message arrive, un fil part dans un autre dossier, le tri côté serveur bascule d'une milliseconde — et l'UID positionnel qui pointait vers l'e-mail A pointe maintenant vers l'e-mail B. La liste des en-têtes allait bien, parce que nous la récupérions à neuf. Mais le cache des corps, indexé sur ce même UID mouvant, servait désormais le mauvais contenu.
Le correctif est architectural : nous intégrons le véritable identifiant de message Graph directement dans chaque en-tête d'e-mail, et nous l'utilisons comme identifiant canonique dans le cache des corps, le chargeur de contenu et les chemins d'action. L'UID synthétique existe toujours, pour la compatibilité avec les interfaces de forme IMAP à l'intérieur de l'application, mais il n'est plus la source de vérité pour « de quel message s'agit-il, vraiment ».
Les fuites entre comptes
Une classe voisine de bug « mauvais e-mail » : passer du compte A au compte B, ouvrir dans B un dossier qui se trouvait être vide, et voir brièvement des e-mails du compte A encore dans la liste.
C'était un problème d'invalidation de cache. Le changement de compte vidait une partie de l'état des e-mails, mais pas tout. La liste triée, le cache des corps en mémoire et le cache des fils étaient mis à jour selon des calendriers légèrement différents. Si le dossier visé contenait de nouveaux e-mails, les données fraîches arrivaient assez vite pour masquer les entrées périmées. Si le dossier visé était vide, c'est la liste périmée que vous voyiez, jusqu'à ce que le résultat « vide » finisse par se propager.
Le correctif n'avait rien de glorieux : chaque endroit qui vide l'état des e-mails lors d'un changement de compte ou de boîte le vide désormais entièrement, de façon atomique, avant que les données du nouveau compte ne commencent à charger. Fini les caches à moitié vidés, fini la course entre « le dossier vide est arrivé » et « les e-mails du compte précédent ont été évincés ».
Le démon en bac à sable qui ne trouvait pas sa propre socket
MailVault a un assistant d'arrière-plan — un petit démon sidecar qui garde votre courrier synchronisé même quand la fenêtre principale est fermée. Sous macOS, l'application et le démon tournent tous deux dans l'App Sandbox, ce qui veut dire que chacun voit une vue différente du système de fichiers. Il leur faut un point de rendez-vous partagé pour dialoguer par une socket de domaine Unix.
Le bug : l'application résolvait le chemin de la socket d'une façon, le démon d'une autre, et sur les versions en bac à sable ces deux chemins pointaient vers des dossiers de conteneur différents. Le démon démarrait, ouvrait sa socket et attendait. L'application tentait de se connecter, ratait, et concluait que le démon ne tournait pas.
Nous utilisons désormais dirs::home_dir() des deux côtés pour résoudre ~/.mailvault/mv.sock. Sous bac à sable, cela donne le même chemin relatif au conteneur pour les deux processus : le client trouve donc toujours le serveur qui vient de le lancer.
Les entitlements de l'assistant ont eu droit au même nettoyage. Le binaire du démon héritait de app-sandbox de la configuration de signature de l'application, ce qui fait planter les binaires autonomes dépourvus d'Info.plist propre. application-groups était également défini, reste d'un projet antérieur d'utiliser une IPC par App Group — inutile maintenant que les deux processus partagent le même conteneur. Les deux ont été retirés. L'assistant se lance proprement sur toutes les versions de macOS que nous testons, y compris sur des builds sous bac à sable fraîchement installés.
Les autorisations qui avaient l'air correctes sans l'être
Celui-ci est le genre de bug le plus inquiétant : tout a l'air correct, et ça ne marche pas, sans un bruit.
Les fichiers d'entitlements macOS permettent de désigner le préfixe d'équipe du développeur par $(AppIdentifierPrefix). Xcode développe cette variable avant de passer le fichier à codesign. Notre chaîne de release n'est pas Xcode — elle appelle codesign directement — et il s'avère que codesign ne développe pas cette variable. La chaîne littérale $(AppIdentifierPrefix) se retrouvait embarquée dans le binaire signé.
Le symptôme : le partage de trousseau entre l'application et le démon était cassé, sans un bruit. L'application pouvait lire les identifiants dans le trousseau, mais pas le démon — parce que l'identifiant de groupe d'accès qu'il demandait était la chaîne littérale $(AppIdentifierPrefix).com.mailvault..., et non le véritable identifiant préfixé par l'équipe. Le démon se rabattait alors sur des identifiants qu'il n'avait pas, puis abandonnait.
Le correctif est une étape d'expansion avec sed qui réécrit le fichier d'entitlements en y substituant le vrai préfixe d'équipe avant l'appel à codesign. Nous l'avons ajoutée à côté de notre contrôle de cohérence des versions, pour qu'on puisse attraper en CI ce genre de bug, celui de la configuration « qui a l'air juste au premier coup d'œil ».
Le verrou de publication des snaps
À propos de CI : jusqu'à la 2.4.0, pousser une étiquette de version publiait automatiquement le snap produit sur le canal stable. Si quelque chose cassait après le test de fumée initial — et cela arrivait, parce que le confinement Snap se comporte autrement qu'une version de développement, d'une manière qui n'apparaît qu'après installation — la version cassée était déjà en ligne quand nous le découvrions.
La chaîne de release s'arrête désormais à l'étape « snap envoyé ». Un workflow séparé, Promote Snaps, fait passer un build du canal edge au canal stable, et seulement après que nous l'avons installé depuis le canal edge sur une machine propre et vérifié l'essentiel : ajout de compte, chargement du courrier, sauvegarde. Cela fait un clic de plus sur une release réussie, et une bien meilleure marche arrière sur une release ratée.
Les correctifs plus discrets
Une poignée de correctifs plus petits relèvent du même thème : empêcher l'application de se contredire elle-même.
Invalidation des caches de module. Plusieurs caches au niveau des modules, initialisés paresseusement — recherches de dossiers et de descripteurs mémoïsées, par exemple — n'étaient pas vidés quand les données sous-jacentes changeaient. Ils le sont maintenant, et le cache des dossiers unifiés a un plafond de taille, pour qu'il ne puisse plus grossir sans limite.
Écritures de descripteur redondantes. L'activation d'un compte écrivait le descripteur du compte sur le disque plus d'une fois par activation, parfois en concurrence avec elle-même lors de changements rapides. Les écritures en double ont disparu.
Récursion infinie à l'activation d'un compte. Un chemin rare d'activateAccount — le repli de restauration du descripteur après un échec de cache — pouvait se rappeler lui-même si le descripteur restauré déclenchait une nouvelle restauration. Il était borné par un garde-fou de réentrance ; un correctif plus propre supprime complètement la récursion.
Architecture du store. Nous avions un jeu d'enveloppes « store de domaine » placées entre les composants et le store Zustand principal, qui dupliquaient l'état dans des copies locales. Elles devaient offrir une API plus propre, mais en pratique elles introduisaient deux sources de vérité par élément d'état, et parfois des bugs d'ordre de mise à jour. Ce ne sont plus que de minces réexports de sélecteurs — même ergonomie, une seule source de vérité.
Architecture de l'assistant d'arrière-plan. Le code Rust qui gérait l'assistant d'arrière-plan faisait deux métiers dans un même module : décider comment lancer le démon (quel binaire, avec quels arguments, sous quel bac à sable) et gérer le transport RPC une fois qu'il tournait. Nous les avons séparés en helper.rs pour la stratégie de lancement, et un module de transport à part. Déboguer un échec au démarrage n'oblige plus à fouiller le code de requête/réponse, et inversement. Le texte visible par l'utilisateur a été revu au passage — « service système » est devenu « assistant d'arrière-plan », ce qui est plus honnête sur ce dont il s'agit réellement.
Pourquoi cette version a pris du temps
Les versions de stabilisation sont curieusement difficiles à planifier. Chaque correctif est petit, mais la surface qu'ils couvrent est énorme — le transport Graph, le changement de compte, l'IPC en bac à sable, la signature de code et l'infrastructure de publication y sont tous passés. La plupart de ces bugs étaient assez rares pour qu'on ne puisse pas les reproduire à la demande ; il a fallu raisonner à partir de traces, de signalements et de la lecture du code jusqu'à voir comment le mauvais état pouvait apparaître, puis écrire le correctif et attendre de confirmer que les signalements cessaient.
Le gain, c'est ce genre de changement qu'on ne remarque pas jusqu'au jour où l'on ne voit plus un bug qui vous mordait une fois par semaine. Si vous avez été touché par l'un des comportements ci-dessus — mauvais contenu d'e-mail sous Outlook, e-mails périmés au changement de compte, un assistant d'arrière-plan qui ne restait pas connecté — la 2.4.0 est la version qui les fait disparaître.
La suite du programme : terminer le refactoring en cinq phases, solder les points restants de la liste de suivi de la v2.4 (détection de boîte unique en IMAP pour Hostinger, cas limites au changement de dossier, bulle de brouillon en rédaction) et continuer à mettre la réécriture du classifieur en forme. Le travail sur les fonctionnalités continue — mais seulement sur des fondations en lesquelles nous avons confiance.