An email archiver has exactly one way to fail that matters: doing something to your mail that you did not ask for. Marking a message read, filing it in the wrong folder, deleting the wrong thing. So every feature in MailVault ships the same way: only after a machine has opened the real application, performed the feature the way a person would, and read the result off the actual screen.
This is a progress note on how that works, because the setup turned out to be more interesting than the features.
The tests use the app. They do not simulate it.
MailVault is a Tauri application: a Rust backend doing the IMAP work, a React interface in a native webview. Tauri has an underused superpower here: build the app with its webdriver feature and the real application becomes remotely controllable, the same way browsers are. Our suite runs on WebdriverIO with the wry capability, through tauri-driver, against the actual compiled binary.
The distinction matters. A component test renders a list in an imaginary browser and asserts on virtual output. Our end-to-end tests launch the same window a user gets, wait for the same rows to paint, click the same buttons, and then check what the interface and the mail on the server actually say afterwards. An excerpt from a run this week, testing the selection action bar:
Selection Action Bar effects
✓ marks a selected row as read and repaints it
✓ marks a row back as unread
✓ marks several selected rows as read in one action
✓ archives a selected row and flips its source icon to local
✓ unarchives it again
✓ moves a selected row to another folder and drops it from the list
✓ deletes a selected row from the server and drops it from the list
7 passing (833ms)
Every line is a full round trip: a click in the interface, a Rust backend call, an IMAP conversation with a server, and a repaint the test verifies with its own eyes. When the suite says archiving flips the source icon to local, it means a machine watched that icon change.
The mail servers are born and die with the run
None of this touches a real account. Each run starts fresh mock IMAP servers on local ports and seeds them with two known accounts, so the suite can exercise account switching and the unified inbox rather than a single happy path. One account deliberately holds seven hundred messages, larger than both of the app's loading windows, so there is always a mailbox that is genuinely partially loaded, the state where pagination bugs live.
The app itself is pointed at a throwaway home directory created for the run. Its archive, its settings, its local database all land in a folder that did not exist a minute earlier and will be deleted when the run ends. No credentials, no network, no real mail anywhere near the process.
Determinism is the other reason. Real mail servers ration you. Gmail caps IMAP downloads at 2,500 MB per day, which we wrote about in the previous Field Note, and every provider is moody in ways a test suite cannot afford. A mock server does exactly what the scenario says, every time, in milliseconds. The suite's job is to catch our bugs, not Google's weather.
An email client tested like it holds something that matters. MailVault archives your mail to standard files on your own disk, and every release earns its way through this suite first.
Visit MailVault →The machine under the desk is not the machine we work on
Here is the part that changed daily life. A full end-to-end run is heavy: it builds the React frontend, compiles the Rust backend, then opens and closes the application dozens of times: one fresh session per spec file, fourteen spec files in the connected suite alone. Run that on the development machine and you can watch windows steal focus while the CPU forgets you exist. Nobody writes code during that.
So the runs do not happen there. A small Apple Silicon Mac mini sits on the local network doing nothing else. It is reachable only over SSH, only from inside the network, and it holds a clone of the repository and the toolchain. When a feature is ready to prove itself, the run is dispatched to the mini with one command, and the development machine goes back to building the next thing while the mini builds, boots and clicks through the current one.
The two activities overlap instead of alternating, which sounds like a small thing until you count how many times a day a suite runs while features are being developed. Each session on the mini starts the app cold, with fresh state and fresh servers, which is also an honest test of cold start, on hardware that is conveniently slower than the machine the app was written on. If a feature is sluggish on the mini, that is not the mini's problem. That is a preview of someone's four-year-old laptop.
The failure output comes back the same way it would locally: which spec, which assertion, what the interface actually showed. Fix, dispatch again. The loop is boring, and boring is the entire point.
What this catches that unit tests politely miss
The suite's best finds are never in the layer a unit test would cover. They live in the seams: a thread opened from the inbox showing the reply's body from the wrong mailbox, a moved message that left the server but not the list, an archive action that worked but repainted the wrong row's icon. Each of those is three subsystems agreeing to be individually correct and collectively wrong. The only test that catches them is the one that does what the user does.
That is why the specs are written as user sentences ("marks a selected row as read and repaints it") rather than function names. When one fails, the report reads as a bug a person would file.
Run the suite yourself
MailVault is open source, and the suite runs entirely against the mock servers: no accounts, no credentials, nothing to configure. If you have Node 18 or newer and a Rust toolchain installed:
git clone https://github.com/GraphicMeat/mail-vault-app
cd mail-vault-app
npm install
cargo install tauri-webdriver-automation
npm run test:e2e
The cargo install provides tauri-wd, the WebDriver bridge the suite spawns to control the app. The last command does the rest: builds the frontend in test mode, compiles the Rust backend with the webdriver feature, starts the mock IMAP servers, and drives the app through every spec. The first run spends most of its time in the Rust compiler; after that, npm run test:e2e:ui and npm run test:e2e:connected reuse the build and run their suites directly.
While it runs, the app window will open, click itself, and close, over and over. Which is either unsettling or the whole point, depending on how you feel about a robot reading email.
What we have not solved yet
Honesty is cheaper than marketing, so: the mock servers speak the IMAP we implemented, not every dialect Gmail and Outlook improvise, and OAuth sign-in against the real providers is still verified by a human hand. The visual-regression and backup suites still run manually rather than on every dispatch. And the mini runs one suite at a time, sequentially: sixteen gigabytes of memory is a budget, and we would rather have one trustworthy run than two flaky ones. All three are on the list, in that order.
Meanwhile the features keep arriving the slow way: written, dispatched, clicked through by a small computer that does not get bored, and shipped only when it stops finding things. Your mail deserves at least that much ceremony.
Your mail, archived locally, by an app that gets tested harder than it gets marketed. Free and open source for macOS and Linux.
Visit MailVault →