MailVault 2.4.0: Ein Stabilitäts-Release
Der größte Teil der Arbeit an MailVault 2.4.0 ist unsichtbar. Die Oberfläche sieht fast identisch aus. Die Funktionsliste ist bescheiden — eine einstellbare Sendeverzögerung, eine Kopie im Gesendet-Ordner, ein paar umsortierte Einstellungen. Unter der Haube geht es in diesem Release aber vor allem darum, die Art von Fehlern zu beheben, die das Vertrauen in eine Mail-App still zersetzen: die falsche E-Mail beim Klicken, veraltete Nachrichten, die zwischen Konten durchsickern, ein Hintergrunddienst, der nicht mit sich selbst reden konnte, und eine Release-Mechanik, die ein kaputtes Binary ausliefern konnte.
Das ist ein Stabilitäts-Release. Hier steht, was kaputt war und was wir dagegen getan haben.
Der Fehler, wegen dem wir diesen Beitrag geschrieben haben
Bei Outlook- und Microsoft-365-Konten konnte ein Klick auf eine E-Mail den Inhalt einer völlig anderen E-Mail anzeigen. Die Betreffzeile in der Liste und die Betreffzeile oben im Viewer passten zusammen — aber Text, Absender und Header gehörten zu einer ganz anderen Nachricht. Selten gemeldet, auf Zuruf nicht reproduzierbar, beim ersten Mal erschreckend.
Die Ursache war ein Unfall daraus, wie wir zwei verschiedene Mailprotokolle miteinander verbunden haben. Klassisches IMAP gibt jeder Nachricht eine stabile UID, die sich im Leben eines Postfachs nie ändert. Die Microsoft-Graph-API tut das nicht — sie hat stattdessen undurchsichtige Nachrichten-IDs als Zeichenketten. Damit der Rest von MailVaults Code einheitlich bleibt, erzeugte unsere Graph-Transportschicht laufend UIDs aus der Position: den Ordner indizieren und 1, 2, 3 und so weiter vergeben.
Das funktioniert, bis sich die Reihenfolge ändert. Eine neue Nachricht kommt an, ein Thread wandert in einen anderen Ordner, die serverseitige Sortierung verschiebt sich um eine Millisekunde — und die positionsbasierte UID, die auf E-Mail A zeigte, zeigt jetzt auf E-Mail B. Die Headerliste war in Ordnung, weil wir sie frisch geholt haben. Aber der Cache der Inhalte, der über dieselbe driftende UID adressiert wurde, lieferte jetzt den falschen Inhalt.
Die Lösung ist architektonisch: Wir hängen die echte Graph-Nachrichten-ID direkt an jeden E-Mail-Header und nutzen sie als kanonische Kennung im gesamten Inhalts-Cache, im Content-Loader und in den Aktionspfaden. Die synthetische UID gibt es weiterhin, damit die IMAP-förmigen Schnittstellen in der App weiter funktionieren, aber sie ist nicht mehr die Wahrheit für „welche Nachricht ist das wirklich“.
Durchsickern zwischen Konten
Eine verwandte Art von „falsche E-Mail“-Fehler: von Konto A zu Konto B wechseln, in B einen Ordner öffnen, der zufällig leer war, und kurz noch E-Mails aus Konto A in der Liste sehen.
Das war ein Problem der Cache-Invalidierung. Der Kontowechsel hat einen Teil des E-Mail-Zustands geleert, aber nicht alles. Die sortierte E-Mail-Liste, der Inhalts-Cache im Speicher und der Thread-Cache wurden zu leicht unterschiedlichen Zeitpunkten aktualisiert. Hatte der Zielordner neue E-Mails, kamen die frischen Daten schnell genug, um die veralteten Einträge zu überdecken. War der Zielordner leer, sah man die veraltete Liste, bis das Ergebnis „leer“ endlich durchkam.
Die Lösung war unspektakulär: Jede Stelle, die beim Wechsel von Konto oder Postfach den E-Mail-Zustand leert, leert jetzt alles davon, in einem Zug, bevor die Daten des neuen Kontos zu laden beginnen. Keine halb geleerten Caches mehr, kein Rennen mehr zwischen „leerer Ordner ist eingetroffen“ und „die E-Mails des vorherigen Kontos wurden verdrängt“.
Der gesandboxte Daemon, der seinen eigenen Socket nicht fand
MailVault hat einen Hintergrunddienst — einen kleinen Sidecar-Daemon, der deine Post weiter synchronisiert, auch wenn das Hauptfenster geschlossen ist. Unter macOS laufen sowohl die App als auch der Daemon in der App Sandbox, was bedeutet, dass beide eine andere Sicht auf das Dateisystem haben. Sie brauchen einen gemeinsamen Treffpunkt, um über einen Unix-Domain-Socket zu sprechen.
Der Fehler: Die App löste den Socket-Pfad auf eine Weise auf, der Daemon auf eine andere, und bei gesandboxten Builds zeigten diese beiden Pfade auf unterschiedliche Container-Verzeichnisse. Der Daemon startete, band seinen Socket und wartete dort. Die App versuchte zu verbinden, traf nicht und schloss daraus, dass der Daemon nicht läuft.
Wir nutzen jetzt auf beiden Seiten dirs::home_dir(), um ~/.mailvault/mv.sock aufzulösen. Unter Sandboxing wird das für beide Prozesse zum selben Pfad relativ zum Container, der Client findet also immer den Server, von dem er gerade gestartet wurde.
Die Berechtigungen des Helfers bekamen dieselbe Aufräumaktion. Das Daemon-Binary erbte app-sandbox aus der Signierkonfiguration der App, was eigenständige Binaries ohne eigene Info.plist zum Absturz bringt. Auch application-groups war gesetzt, ein Überbleibsel eines früheren Plans, IPC über App-Gruppen zu machen — unnötig, seit beide Prozesse dasselbe Container-Home teilen. Beides wurde entfernt. Der Helfer startet auf jeder macOS-Version sauber, gegen die wir testen, auch bei frisch installierten Sandbox-Builds.
Die Entitlements, die richtig aussahen und es nicht waren
Das ist die beunruhigendste Sorte Fehler: Alles sieht richtig aus, und es funktioniert stillschweigend nicht.
In macOS-Berechtigungsdateien lässt sich das Team-Präfix des Entwicklers als $(AppIdentifierPrefix) referenzieren. Xcode setzt diese Variable ein, bevor es die Datei an codesign weitergibt. Unsere Release-Strecke ist kein Xcode — sie ruft codesign direkt auf — und es zeigt sich: codesign setzt diese Variable nicht ein. Die wörtliche Zeichenkette $(AppIdentifierPrefix) landete im signierten Binary.
Das Symptom: Die gemeinsame Keychain-Nutzung von App und Daemon war stillschweigend kaputt. Die App konnte Zugangsdaten aus dem Keychain lesen, der Daemon sah sie aber nicht — weil die Zugriffsgruppen-Kennung, nach der er fragte, wörtlich $(AppIdentifierPrefix).com.mailvault... lautete statt der echten Kennung mit Team-Präfix. Der Daemon fiel darauf zurück, nach Zugangsdaten zu fragen, die er nicht hatte, und gab auf.
Die Lösung ist ein Erweiterungsschritt mit sed, der die Berechtigungsdatei mit dem echten Team-Präfix neu schreibt, bevor codesign aufgerufen wird. Wir haben ihn neben unsere bestehende Versionsprüfung gestellt, damit sich Fehler dieser Art, bei denen eine Konfiguration auf den ersten Blick richtig aussieht, künftig in der CI abfangen lassen.
Snap-Veröffentlichung mit Freigabe
Apropos CI: Bis 2.4.0 hat das Pushen eines Release-Tags das entstandene Snap automatisch im Stable-Kanal veröffentlicht. Ging nach dem ersten Rauchtest etwas kaputt — und das kam vor, weil sich Snap-Confinement anders verhält als ein Dev-Build, auf Weisen, die sich erst nach der Installation zeigen —, war die kaputte Version schon live, als wir es merkten.
Die Release-Strecke hält jetzt beim Schritt „Snap hochgeladen“ an. Ein eigener Workflow Promote Snaps schiebt einen Build vom Edge-Kanal auf Stable, und zwar erst, nachdem wir ihn aus dem Edge-Kanal auf einer sauberen Maschine installiert und das Wesentliche geprüft haben: Konto hinzufügen, E-Mails laden, Backup. Das ist ein zusätzlicher Klick bei einem gelungenen Release und eine deutlich ruhigere Rücknahme bei einem misslungenen.
Die leiseren Korrekturen
Eine Handvoll kleinerer Korrekturen fällt unter dasselbe Motto: „Die App soll sich nicht selbst widersprechen.“
Ungültigmachen von Modul-Caches. Mehrere träge angelegte Caches auf Modulebene — etwa gemerkte Ordner- und Deskriptor-Nachschlagevorgänge — wurden nicht geleert, wenn sich die Daten darunter änderten. Jetzt werden sie das, und der Cache für gemeinsame Ordner hat eine Größengrenze, damit er nicht unbegrenzt wachsen kann.
Überflüssige Deskriptor-Schreibvorgänge. Beim Aktivieren eines Kontos wurde der Kontodeskriptor mehr als einmal pro Aktivierung auf die Festplatte geschrieben, bei schnellen Wechseln manchmal im Rennen mit sich selbst. Die doppelten Schreibvorgänge sind weg.
Endlose Rekursion beim Aktivieren eines Kontos. Ein seltener Pfad in activateAccount — der Rückfallweg zum Wiederherstellen des Deskriptors nach einem Cache-Fehltreffer — konnte sich selbst erneut aufrufen, wenn der wiederhergestellte Deskriptor eine weitere Wiederherstellung auslöste. Bisher deckelte das ein Wiedereintritts-Schutz; die sauberere Lösung entfernt die Rekursion ganz.
Store-Architektur. Wir hatten eine Reihe von „Domain-Store“-Hüllen zwischen den Komponenten und dem Haupt-Zustand-Store, die den Zustand in lokale Kopien duplizierten. Sie sollten eine sauberere Schnittstelle bieten, brachten in der Praxis aber zwei Quellen der Wahrheit je Zustandsstück mit sich und gelegentlich Fehler in der Reihenfolge von Aktualisierungen. Jetzt sind sie dünne Selektor-Weiterexporte — dieselbe Handhabung, eine Quelle der Wahrheit.
Architektur des Hintergrundhelfers. Der Rust-Code, der den Hintergrundhelfer verwaltete, machte zwei Aufgaben in einem Modul: entscheiden, wie der Daemon gestartet wird (welches Binary, mit welchen Argumenten, unter welcher Sandbox), und den RPC-Transport abwickeln, sobald er läuft. Wir haben das getrennt in helper.rs für die Startstrategie und ein eigenes Transportmodul. Einen Startfehler zu debuggen heißt nicht mehr, sich durch den Anfrage-/Antwort-Code zu grepen, und umgekehrt. Die Texte für Nutzer wurden bei der Gelegenheit umformuliert — aus „Systemdienst“ wurde „Hintergrundhelfer“, was ehrlicher beschreibt, was es tatsächlich ist.
Warum dieses Release gedauert hat
Stabilitäts-Releases sind seltsam schwer zu planen. Jede einzelne Korrektur ist klein, aber die Fläche, die sie abdecken, ist riesig — der Graph-Transport, der Kontowechsel, gesandboxte IPC, die Codesignierung und die Release-Infrastruktur wurden alle angefasst. Die meisten dieser Fehler waren selten genug, dass wir sie nicht auf Zuruf reproduzieren konnten; wir mussten aus Traces, Meldungen und dem Lesen des Codes schließen, bis wir sehen konnten, wie der schlechte Zustand entsteht, dann die Korrektur schreiben und warten, bis die Meldungen aufhörten.
Der Lohn ist die Art von Änderung, die man erst bemerkt, wenn man plötzlich einen Fehler nicht mehr sieht, der einen früher einmal pro Woche erwischt hat. Wenn dich eines der oben beschriebenen Verhalten getroffen hat — falscher E-Mail-Inhalt bei Outlook, veraltete E-Mails beim Kontowechsel, ein Hintergrunddienst, der nicht verbunden blieb —, ist 2.4.0 das Release, das sie verschwinden lässt.
Als Nächstes auf der Liste: das fünfphasige Refactoring zu Ende bringen, die übrigen Punkte der Nachliste zu v2.4 abarbeiten (Erkennung von IMAP-Konten mit nur einem Postfach bei Hostinger, Sonderfälle beim Ordnerwechsel, die Entwurfsblase beim Verfassen) und die Neufassung des Klassifizierers weiter in Form bringen. Die Arbeit an Funktionen geht weiter — aber nur auf einem Fundament, dem wir trauen.