Vai al contenuto
Torna al blog

MailVault 2.4.0: una release di stabilità

7 min di lettura

Quasi tutto il lavoro finito in MailVault 2.4.0 è invisibile. L'interfaccia sembra quasi identica. L'elenco delle novità è modesto — un ritardo di invio configurabile, una copia nella cartella Inviata, qualche riordino nelle impostazioni. Ma sotto il cofano questa release riguarda soprattutto la correzione del tipo di bug che erode in silenzio la fiducia in un'app di posta: l'email sbagliata che compare quando fai clic, messaggi vecchi che passano da un account all'altro, un helper in background che non riusciva a parlare con se stesso e un'infrastruttura di rilascio che poteva pubblicare un binario rotto.

Questa è una release di stabilità. Ecco che cosa era rotto e che cosa abbiamo fatto.

Il bug che ci ha fatto scrivere questo post

Sugli account Outlook e Microsoft 365, facendo clic su un'email poteva comparire il contenuto di un'email completamente diversa. L'oggetto nell'elenco e l'oggetto in cima al visualizzatore coincidevano — ma il corpo, il mittente e le intestazioni appartenevano a tutt'altro messaggio. Segnalato di rado, impossibile da riprodurre a comando, terrificante la prima volta che lo vedi.

La causa profonda era un incidente di percorso nel modo in cui avevamo messo insieme due protocolli di posta diversi. L'IMAP classico dà a ogni messaggio un UID stabile, che non cambia per tutta la vita della casella. La Microsoft Graph API no — ha invece ID di messaggio opachi in forma di stringa. Per mantenere uniforme il resto del codice di MailVault, il nostro livello di trasporto Graph sintetizzava al volo degli UID posizionali: indicizza la cartella, distribuisci 1, 2, 3 e così via.

Funziona finché l'ordine non cambia. Arriva un messaggio nuovo, un thread si sposta in un'altra cartella, l'ordinamento lato server slitta di un millisecondo — e l'UID posizionale che puntava all'email A ora punta all'email B. L'elenco delle intestazioni era a posto perché lo recuperavamo di nuovo. Ma la cache dei corpi, indicizzata su quello stesso UID alla deriva, restituiva ormai il contenuto sbagliato.

La correzione è architetturale: incorporiamo il vero ID di messaggio Graph direttamente su ogni intestazione email e lo usiamo come identificatore canonico in tutta la cache dei corpi, nel caricatore dei contenuti e nei percorsi delle azioni. L'UID sintetico esiste ancora per compatibilità con le interfacce a forma di IMAP dentro l'app, ma non è più la fonte di verità per "quale messaggio è questo, davvero".

Interferenze tra account

Una classe imparentata di bug "email sbagliata": passi dall'account A all'account B, apri una cartella di B che per caso è vuota e per un istante vedi ancora nell'elenco le email dell'account A.

Era un problema di invalidazione della cache. Il cambio account azzerava una parte dello stato delle email, ma non tutto. L'elenco ordinato delle email, la cache dei corpi in memoria e la cache dei thread venivano aggiornati con tempi leggermente diversi. Se la cartella di destinazione aveva email nuove, i dati freschi arrivavano abbastanza in fretta da coprire quelli vecchi. Se la cartella di destinazione era vuota, quello che vedevi era l'elenco vecchio, finché il risultato "vuoto" non si propagava.

La correzione è stata poco affascinante: ogni punto che azzera lo stato delle email al cambio di account o di casella ora lo azzera tutto, in modo atomico, prima che i dati del nuovo account comincino a caricarsi. Basta cache svuotate a metà, basta corse tra "è arrivata una cartella vuota" e "le email dell'account precedente sono state espulse".

Il daemon in sandbox che non trovava il proprio socket

MailVault ha un helper in background — un piccolo daemon sidecar che tiene la posta sincronizzata anche a finestra principale chiusa. Su macOS sia l'app sia il daemon girano dentro App Sandbox, il che significa che ciascuno vede il filesystem in modo diverso. Serve loro un punto d'incontro condiviso per parlarsi su un socket di dominio Unix.

Il bug: l'app risolveva il percorso del socket in un modo, il daemon in un altro, e nelle build in sandbox quei due percorsi puntavano a directory di container diverse. Il daemon partiva, apriva il suo socket e restava lì ad aspettare. L'app provava a collegarsi, mancava il bersaglio e concludeva che il daemon non era in esecuzione.

Ora usiamo dirs::home_dir() da entrambe le parti per risolvere ~/.mailvault/mv.sock. Sotto sandbox si espande nello stesso percorso relativo al container per tutti e due i processi, così il client trova sempre il server da cui è appena stato avviato.

Anche gli entitlement dell'helper hanno avuto una pulizia parallela. Il binario del daemon ereditava app-sandbox dalla configurazione di firma dell'app, cosa che fa crashare i binari a sé stanti privi di un proprio Info.plist. Era impostato anche application-groups, residuo di un piano precedente che prevedeva l'IPC via App Group — ormai inutile, dato che entrambi i processi condividono la stessa home del container. Sono stati rimossi tutti e due. L'helper si avvia senza intoppi su ogni versione di macOS su cui facciamo i test, comprese le installazioni pulite delle build in sandbox.

Gli entitlement che sembravano giusti e non lo erano

Questo è il tipo di bug più preoccupante: tutto sembra corretto, e in silenzio non funziona.

I file di entitlement di macOS permettono di indicare il prefisso del team di sviluppo come $(AppIdentifierPrefix). Xcode espande questa variabile prima di passare il file a codesign. La nostra pipeline di rilascio non è Xcode — invoca codesign direttamente — e salta fuori che codesign quella variabile non la espande. Nel binario firmato veniva incorporata la stringa letterale $(AppIdentifierPrefix).

Il sintomo: la condivisione del portachiavi tra l'app e il daemon era rotta in silenzio. L'app riusciva a leggere le credenziali dal portachiavi, ma il daemon non le vedeva — perché l'identificatore del gruppo di accesso che chiedeva era la stringa letterale $(AppIdentifierPrefix).com.mailvault... invece del vero identificatore con il prefisso del team. Il daemon ripiegava sul chiedere credenziali che non aveva e si arrendeva.

La correzione è un passaggio di espansione con sed che riscrive il file di entitlement sostituendoci il vero prefisso del team prima della chiamata a codesign. L'abbiamo aggiunto accanto al controllo di coerenza delle versioni che avevamo già, così una classe simile di bug da "configurazione che a prima vista sembra giusta" può essere intercettata in CI.

Pubblicazione degli snap sotto controllo

A proposito di CI: fino alla 2.4.0, pubblicare un tag di rilascio pubblicava automaticamente lo snap risultante nel canale stable. Se qualcosa si rompeva dopo lo smoke test iniziale — e a volte succedeva, perché il confinamento di Snap si comporta diversamente da una build di sviluppo in modi che emergono solo dopo l'installazione — la versione rotta era già online prima che ce ne accorgessimo.

Ora la pipeline di rilascio si ferma al passaggio "snap caricato". Un workflow separato, Promote Snaps, sposta una build dal canale edge a stable, e solo dopo che l'abbiamo installata dal canale edge su una macchina pulita e verificato le cose di base: aggiunta dell'account, caricamento delle email, backup. È un clic in più su un rilascio riuscito e una storia di rollback molto più tranquilla su uno andato male.

Le correzioni più silenziose

Una manciata di correzioni minori rientra nello stesso tema: "impedire all'app di contraddirsi".

Invalidazione delle cache dei moduli. Diverse cache a livello di modulo, inizializzate pigramente — cose come le ricerche di cartelle e di descrittori memoizzate — non venivano azzerate quando cambiavano i dati sottostanti. Ora lo sono, e la cache delle cartelle unificate ha un tetto di dimensione, così non può crescere all'infinito.

Salvataggi ridondanti del descrittore. L'attivazione di un account scriveva su disco il descrittore dell'account più di una volta per attivazione, a volte in corsa con se stessa nei cambi rapidi. Le scritture duplicate sono sparite.

Ricorsione infinita nell'attivazione dell'account. Un percorso raro dentro activateAccount — il ripiego di ripristino del descrittore dopo un cache miss — poteva richiamare se stesso se il descrittore ripristinato faceva scattare un altro ripristino. Era arginato da una protezione contro la rientranza; una correzione più pulita elimina del tutto la ricorsione.

Architettura degli store. Avevamo una serie di wrapper "store di dominio" che stavano tra i componenti e lo store Zustand principale, duplicando lo stato in copie locali. Dovevano offrire un'API più pulita, ma in pratica introducevano due fonti di verità per ogni pezzo di stato e ogni tanto bug nell'ordine degli aggiornamenti. Ora sono sottili riesportazioni di selettori — stessa comodità d'uso, un'unica fonte di verità.

Architettura dell'helper in background. Il codice Rust che gestiva l'helper in background faceva due lavori in un solo modulo: decidere come avviare il daemon (quale binario, con quali argomenti, sotto quale sandbox) e gestire il trasporto RPC una volta in esecuzione. Li abbiamo divisi in helper.rs per la strategia di avvio e in un modulo di trasporto a parte. Per fare il debug di un avvio fallito non serve più passare al setaccio il codice di richiesta e risposta, e viceversa. Nel frattempo abbiamo riformulato i testi rivolti agli utenti — "servizio di sistema" è diventato "helper in background", che è più onesto rispetto a ciò che è davvero.

Perché questa release ha richiesto tempo

Le release di stabilità sono stranamente difficili da pianificare. Ogni singola correzione è piccola, ma la superficie che coprono è enorme — il trasporto Graph, il cambio account, l'IPC in sandbox, la firma del codice e l'infrastruttura di rilascio sono stati toccati tutti. Quasi tutti questi bug erano abbastanza rari da non poterli riprodurre a comando; abbiamo dovuto ragionare su tracce, segnalazioni degli utenti e lettura del codice finché non abbiamo visto come nasceva lo stato sbagliato, poi scrivere la correzione e aspettare la conferma che le segnalazioni smettessero di arrivare.

Il risultato è il tipo di cambiamento che non noti finché all'improvviso non vedi più un bug che ti mordeva una volta a settimana. Se ti è capitato qualcuno dei comportamenti qui sopra — contenuto sbagliato su Outlook, email vecchie al cambio account, un helper in background che non restava connesso — la 2.4.0 è la release che li fa sparire.

Il prossimo passo: completare il refactoring in cinque fasi, chiudere i punti rimasti nella lista di verifica della v2.4 (rilevamento IMAP della casella singola per Hostinger, casi limite del cambio cartella, bolla della bozza in composizione) e continuare a mettere in forma la riscrittura del classificatore. Il lavoro sulle funzioni prosegue — ma solo sopra fondamenta di cui ci fidiamo.