MailVault 2.4.0: A Stability Release
Most of the work that went into MailVault 2.4.0 is invisible. The UI looks almost identical. The feature list is modest — a configurable send delay, a Sent folder copy, some settings reshuffling. But under the hood this release is mostly about fixing the kinds of bugs that quietly erode trust in a mail app: the wrong email showing up when you click, stale messages bleeding across accounts, a background helper that couldn't talk to itself, and release plumbing that could ship a broken binary.
This is a stability release. Here's what was broken, and what we did about it.
The Bug That Made Us Write This Post
On Outlook and Microsoft 365 accounts, clicking one email could display a completely different email's content. The subject line in the list and the subject line at the top of the viewer would match — but the body, the sender, and the headers belonged to some other message entirely. Reported rarely, impossible to reproduce on demand, terrifying the first time you see it.
The root cause was an accident of how we bridged two different mail protocols. Classic IMAP gives every message a stable UID that never changes for the life of a mailbox. The Microsoft Graph API doesn't — it has opaque string message IDs instead. To keep the rest of MailVault's codebase uniform, our Graph transport layer was synthesizing positional UIDs on the fly: index the folder, hand out 1, 2, 3, and so on.
That works until the order changes. A new message arrives, a thread moves to another folder, server-side sorting shifts by a millisecond — and the positional UID that pointed to email A now points to email B. The header list was fine because we fetched it fresh. But the cache of bodies, keyed by that same drifting UID, now handed out the wrong content.
The fix is architectural: we embed the real Graph message ID directly on each email header, and use that as the canonical identifier throughout the body cache, content loader, and action paths. The synthetic UID still exists for compatibility with the IMAP-shaped interfaces inside the app, but it's no longer the source of truth for "which message is this, really."
Cross-Account Bleed
A related class of "wrong email" bug: switching from Account A to Account B, opening a folder in B that happened to be empty, and briefly seeing emails from Account A still sitting in the list.
This was a cache invalidation problem. Account switching cleared some email state but not all of it. The sorted-email list, the in-memory body cache, and the thread cache were updated on slightly different schedules. If the target folder had new emails, the fresh data arrived fast enough to mask the stale entries. If the target folder was empty, the stale list was what you saw until the "empty" result finally propagated.
The fix was unglamorous: every place that clears email state on an account or mailbox switch now clears all of it, atomically, before the new account's data starts loading. No more half-cleared caches, no more race between "empty folder arrived" and "previous account's emails got evicted."
The Sandboxed Daemon That Couldn't Find Its Own Socket
MailVault has a background helper — a small sidecar daemon that keeps your mail synced even when the main window is closed. On macOS, both the app and the daemon run inside App Sandbox, which means each of them sees a different view of the filesystem. They need a shared rendezvous point to talk over a Unix domain socket.
The bug: the app resolved the socket path one way, the daemon resolved it another way, and on sandboxed builds those two paths pointed to different container directories. The daemon would start, bind its socket, and sit there waiting. The app would try to connect, miss, and conclude the daemon wasn't running.
We now use dirs::home_dir() on both sides to resolve ~/.mailvault/mv.sock. Under sandboxing that expands to the same container-relative path for both processes, so the client always finds the server it was just launched by.
The helper's entitlements got a parallel cleanup. The daemon binary was inheriting app-sandbox from the app's signing config, which crashes standalone binaries that don't have an Info.plist of their own. application-groups was also set, left over from an earlier plan to use App Group IPC — unnecessary now that both processes share the same container home. Both were removed. The helper launches cleanly on every macOS version we test against, including freshly installed sandboxed builds.
The Entitlements That Looked Right But Weren't
This one is the most worrying kind of bug: everything looks correct, and it silently doesn't work.
macOS entitlements files let you reference the developer team prefix as $(AppIdentifierPrefix). Xcode expands this variable before passing the file to codesign. Our release pipeline isn't Xcode — it invokes codesign directly — and it turns out codesign does not expand that variable. The literal string $(AppIdentifierPrefix) was being embedded into the signed binary.
The symptom: keychain sharing between the app and the daemon was silently broken. The app could read credentials from the keychain, but the daemon couldn't see them — because the access group identifier it was asking for was a literal $(AppIdentifierPrefix).com.mailvault... string rather than the real team-prefixed identifier. The daemon would fall back to asking for credentials it didn't have and give up.
The fix is a sed expansion step that rewrites the entitlements file with the real team prefix substituted in before the codesign call. We added it alongside our existing version-consistency check so that a similar class of "configuration that looks right at a glance" bug can be caught in CI.
Snap Publish Gating
Speaking of CI: until 2.4.0, pushing a release tag automatically published the resulting snap to the stable channel. If something broke after the initial smoke test — and sometimes it did, because Snap confinement behaves differently from a dev build in ways that only show up after install — the broken version was already live by the time we found out.
The release pipeline now stops at the "snap uploaded" step. A separate Promote Snaps workflow moves a build from the edge channel to stable, and only after we've installed it from the edge channel on a clean machine and verified the basics: account add, email load, backup. It's one extra button click on a successful release and a much calmer rollback story on an unsuccessful one.
The Quieter Fixes
A handful of smaller fixes fall under the same "stop the app from contradicting itself" theme.
Module cache invalidation. Several lazily-initialized module-level caches — things like memoized folder lookups and descriptor lookups — weren't being cleared when the data underneath them changed. Now they are, and the unified-folder cache has a size cap so it can't grow without bound.
Redundant descriptor saves. Account activation was writing the account descriptor to disk more than once per activation, sometimes racing with itself on fast switches. The duplicate writes are gone.
Infinite recursion in account activation. A rare path in activateAccount — the descriptor restore fallback after a cache miss — could call back into itself if the restored descriptor triggered another restore. It was capped by a re-entrancy guard; a cleaner fix removes the recursion entirely.
Store architecture. We had a set of "domain store" wrappers that sat between components and the main Zustand store, duplicating state into local copies. They were intended to provide a cleaner API, but in practice they introduced two sources of truth per piece of state and occasional update-order bugs. They're now thin selector re-exports — same ergonomics, one source of truth.
Background helper architecture. The Rust code that managed the background helper was doing two jobs in one module: deciding how to launch the daemon (which binary, with what arguments, under what sandbox) and handling the RPC transport once it was running. We split those into helper.rs for launch strategy and a separate transport module. Debugging a startup failure no longer requires grepping through the request/response code, and vice versa. User-facing text was reworded at the same time — "system service" became "background helper," which is more honest about what it actually is.
Why This Release Took a While
Stability releases are weirdly hard to plan. Each individual fix is small, but the surface area they cover is enormous — the Graph transport, account switching, sandboxed IPC, code signing, and release infrastructure all got touched. Most of these bugs were rare enough that we couldn't reproduce them on demand; we had to reason from traces, user reports, and reading the code until we could see how the bad state would arise, then write the fix and wait to confirm the reports stopped coming in.
The payoff is the kind of change you don't notice until you're suddenly not seeing a bug that used to bite you once a week. If you were hit by any of the behaviors above — wrong email content on Outlook, stale emails on account switch, a background helper that wouldn't stay connected — 2.4.0 is the release that makes them go away.
Next on the list: finishing the five-phase refactor, closing out the remaining items on the v2.4 follow-up list (IMAP single-mailbox detection for Hostinger, folder-switch edge cases, compose draft bubble), and continuing to push the classifier rewrite into shape. The feature work continues — but only on top of a foundation we trust.