Refactoring MailVault: From Monolith to Modules
MailVault has been growing fast. Over the past few weeks we shipped keyboard shortcuts, unified inbox, email templates, sender insights, auto-cleanup rules, undo send, move to folder, and a full Microsoft Graph API transport layer. Features were landing, users were happy — and the codebase was starting to groan under the weight.
The central state store — a single Zustand file called mailStore.js — had grown to over 4,300 lines. It handled everything: account management, email loading, caching, search, threading, Graph API branching, CONDSTORE delta sync, bulk operations, and more. The Rust backend's main.rs was on a similar trajectory. Components like SettingsPage and EmailViewer had ballooned into monolithic walls of JSX. Things still worked, but adding anything new meant navigating a maze.
It was time to refactor — not because anything was broken, but because the speed of shipping new features was starting to slow down. This post covers what we did, why, and where we're headed.
The Problem with One Giant Store
Zustand is great for state management. But when a single store file grows past a few thousand lines, you start hitting practical problems: every feature touches the same file, merge conflicts become routine, and it's hard to reason about what depends on what. Our mailStore had email loading logic interleaved with Graph API transport code, LRU cache management sitting next to search state, and threading algorithms mixed in with account switching.
The same pattern repeated on the Rust side. main.rs contained Maildir path helpers, keychain operations, cache management, attachment handling, notification logic, and system utilities — all in one file. The IMAP module had connection pooling tangled with protocol-level operations.
Two-Stream Email Loading
The biggest architectural change in v2.2.0 was redesigning how emails load when you switch accounts or folders. The old approach had six overlapping phases that ran in a fragile sequence: check cache, show cached data, connect to IMAP, fetch headers, merge results, update UI. If you switched accounts quickly, these phases could interleave and you'd end up with emails from the wrong account showing briefly — the "empty emails" bug.
The new design is a clean two-stream model. When you activate an account, two independent streams fire in parallel:
Stream 1 (local) loads cached headers and locally saved emails from disk. This is nearly instant — the UI populates within milliseconds using the headers.json cache and a new local-index.json that eliminates the need to MIME-parse every .eml file on disk.
Stream 2 (server) connects to IMAP (or Graph API for Microsoft accounts), fetches the current server state, and merges any changes into the local view. New emails appear, deleted ones disappear, read/unread flags update — all without flickering or replacing what's already on screen.
Both streams are wrapped in an AbortController. If you switch accounts before either stream finishes, the in-progress work is cancelled cleanly. No race conditions, no stale data, no "empty emails."
Module Extraction
The first phase of the refactor focused on pulling out code that had no business living inside the store or inside main.rs.
On the JavaScript side, we extracted:
Graph config — the Microsoft Graph folder name mapping (Inbox → INBOX, Sent Items → Sent, etc.) was duplicated three times across the codebase. It now lives in a single graphConfig.js module.
LRU caches — the account cache (up to 8 accounts' worth of email state for instant switching) and body cache (email content with configurable memory limits) were extracted into a dedicated cache module with a shared LRU implementation.
Search — search state and logic were tangled into the main store despite being largely independent. Pulling it out made both the store and the search code easier to follow.
Performance tracing — we had ad-hoc console.time calls scattered through the loading code. These were replaced with a structured perfTrace utility that can be toggled on and off, making performance debugging much cleaner.
Component Decomposition
Two React components were doing far too much: SettingsPage and EmailViewer.
SettingsPage rendered every settings tab in one file — accounts, appearance, keyboard shortcuts, notifications, templates, auto-cleanup, and more. We split it into individual tab components, each living in a settings/ subdirectory. The parent component now just handles tab switching and renders the active tab.
EmailViewer had grown to handle single email display, thread conversation view, attachment rendering, DKIM verification badges, sender insights, and reply/forward actions — all in one component. We extracted ThreadView, EmailHeader, and AttachmentBar into focused components in an email/ subdirectory.
The result is smaller files that are easier to navigate, easier to test, and easier to modify without accidentally breaking something unrelated.
The Five-Phase Plan
What shipped in v2.2.0 was the beginning, not the end. We designed a five-phase refactor plan that we're executing incrementally:
Phase 1: Shared Foundations — extract shared constants and utilities (Graph config, Rust constants, performance tracing). Done.
Phase 2: Rust Backend — split main.rs into focused modules: Maildir operations, keychain access, cache management, attachment handling, notifications, and system utilities. The IMAP module gets split into protocol-level operations and connection pool management.
Phase 3: Store Decomposition — create dedicated useSearchStore and useThreadStore stores, extract Graph API transport, cache management, and email loading into service modules. The goal: mailStore shrinks from 4,300 lines to around 2,000.
Phase 4: Component Extraction — continue breaking down large components. AccountModal splits into server detection and OAuth setup sub-components. Any remaining oversized components get the same treatment.
Phase 5: Cleanup — remove dead code, consolidate duplicate utilities (like hasValidCredentials which existed in multiple places), and split the database utility module into focused files.
Refactoring Without Regression
The hardest part of any refactor is making sure nothing breaks. MailVault handles people's email — if account switching starts showing the wrong messages or a loading race condition reappears, that's a real problem.
Our approach: refactor one boundary at a time, verify everything still works, then move to the next boundary. The 600+ automated tests across four layers give us a safety net, but we also do manual testing after each phase — switching accounts rapidly, going offline mid-load, testing with the 17,000-email mailbox, verifying that Graph API accounts and regular IMAP accounts both behave correctly.
We also made a deliberate decision about store architecture. Rather than splitting everything into many tiny stores (which can create its own coordination problems), we chose a hybrid approach: independent stores for genuinely independent concerns (search and threading), while keeping account and email state together in mailStore because they're tightly coupled. The goal is clarity, not purity.
What Users Get
Refactoring is invisible to users by design — the app should look and feel exactly the same. But the effects are real:
Faster account switching — the two-stream architecture means you see your emails almost instantly when switching accounts, with server sync happening silently in the background.
No more loading glitches — the "empty emails" bug that could appear when switching accounts quickly is gone, along with other race conditions in the old sequential loading code.
Faster feature development — this is the big one. A cleaner codebase means new features can be built and shipped faster. The upcoming "download everything" feature, scheduled automatic backups, and other frequently requested improvements will land sooner because the code is easier to work with.
We're building MailVault to last. Email is decades-old technology that hasn't seen much love from modern tooling, and the existing backup solutions are either too technical or too clunky. Making the codebase sustainable is how we make sure the app keeps getting better — not just this month, but long-term.