Spool is a Mac email client written in Rust and drawn by the GPU, on gpui — the framework Zed is built with. No web view around the app, no Electron, and no point at which the mail you have kept starts costing you frames.
Still being built · macOS 13+ · universal binary
to wes@example.com
4 messages in this conversation — press t for all of them
The numbers moved about four percent after the change, which is inside the noise on a single run but not across nine of them.
Short version: it works, but the migration path is ugly. Two things I would like you to look at before I put it in front of anyone else:
•The index rebuild takes the table offline for about ninety seconds.
•Anything written during that window has to be replayed, and we have no replay.
We should decide by Thursday or it lands in the next cycle.
I pushed the branch and left two comments inline. Let me know if you would rather discuss live.
Thanks, Priya
That window is drawn, not screenshotted — pick a message, switch the theme, change the shape of the list. The mail is invented, from the same word lists spool --demo builds its synthetic corpus out of. The layout, the columns and both palettes are the app's.
Four crates and no runtime under them: the parser, the store, the IMAP client and the window are one program. There is no JavaScript in it and no browser inside it.
gpui draws into a Metal layer, so the sidebar, the list and the reader are shaped text and rectangles on the GPU. That is why a frame is measured in microseconds rather than milliseconds.
Remote images are blocked until you say otherwise, scripting is off, the reader never navigates, and passwords go to the keychain rather than into a file.
Measured
From spool --bench 250000 on an M-series MacBook, and from the app's own status bar while it is being used. The demo corpus is seeded, so a run is comparable to the last one and a regression shows up as a number rather than as a feeling.
| On an M-series MacBook | 100,000 messages | 250,000 messages |
|---|---|---|
| Frame build timemean · worst 117µs / 223µs | 76µs | 85µs |
| Open a message | 95µs | 120µs |
| Full-text search | 5.4ms | 15.2ms |
| Load the whole mailbox list | 100ms | 290ms |
| Ingest | 31,500/s | 29,000/s |
| On disk | 111 MB | 274 MB |
That white sliver is the whole of the app's drawing work. Everything else in the bar is time the machine has left over — about 99% of it — which is what “it does not get slower” has to mean if it is going to mean anything.
A taller window shows more rows, and more rows is the only thing that makes a frame cost more. Scrolling, selecting and typing in the search box do not get slower as mail accumulates, because the work per frame does not depend on how much there is.
Fifteen milliseconds across a quarter-million messages is more than five across a hundred thousand. It still lands inside one frame at 60Hz, which is why results update while you are still typing.
The app can show these numbers in its own status bar while you use it — View ▸ Toggle Status Bar, remembered between launches. It is off by default, because a readout nobody asked for is a strip of numbers along the bottom of every window forever. A client that claims to be fast should be willing to be measured, not insistent about it.
How
There is no trick in here. The whole of the speed is three rules, and all of the work is in not breaking them — every feature that follows had to be built inside them.
gpui's uniform_list knows the row height, so it builds elements only for the twenty rows on screen. Forty messages and four hundred thousand cost the same per frame. The rule that imposes is that a row may not do anything expensive — no scans, no database, no allocating the full list.
render never touches the databaseEvery query runs on a background thread and writes its result back into the workspace. A slow query can delay a sync; it cannot drop a frame. Each reload carries a generation number, so a slow query finishing after a newer one discards its own result rather than overwriting fresher data.
Snippets are computed at ingest, not at paint. Bodies are parsed into blocks when opened, not per frame. A flag change updates the in-memory envelope first so the row repaints immediately, and persists in the background.
Underneath is SQLite with an FTS5 index over subject, sender and body. The index is contentless — it stores postings rather than a second copy of every message — which is what keeps a six-figure mailbox's index small enough to stay in the page cache.
The list
Slim is the default: one line per message in columns — unread indicator, sender, subject, attachment, date. Each column can be turned off except the last one, because a row with no columns is a row with nothing in it. Detail is the older shape: sender and date, subject, and a preview of the body. ⌘⌥D swaps them, and the switch above the window is that setting.
⌘-click adds a row, shift-click and shift-↑/↓ extend a range, ⌘A takes the lot — and archive, trash, read and flag all work on the set.
Dragging a selection onto a folder in the sidebar moves it there. Mail cannot cross accounts, because IMAP has no move between servers.
⌘Z puts the last one back. Delete means move to Trash everywhere except inside Trash, so the destructive case is the one you have to go looking for.
Archiving, moving and flagging go into a queue that survives being offline, replayed in order at the front of the next sync before anything is fetched.
Row height is arithmetic on the density and the preview rather than a stored number, because uniform_list needs a fixed height to decide which rows are on screen and a second copy of that number is a second thing to keep in step. The column widths are computed the same way: gpui only ellipsizes text whose width is definite at measurement time, so a subject sized by flex_1 would be clipped by the pane instead of ending in an ellipsis.
Composing
Writing a message is a second thing you are doing, usually while looking at the first. A panel over the list means you cannot read the message you are answering, cannot start a second reply, and cannot put the draft on another display — and people do all three constantly.
So each draft gets a real window with the system's own title bar, titled with its subject as you type it, cascaded so a second one is visibly a second one. Answering the same message twice brings the open window forward rather than opening a rival draft. ⎋ and ⌘W save and close; ⌘Q saves every open composer on the way out.
Ninety seconds offline is fine on a Thursday night. The replay is the part I want to fix before we schedule it — if a write lands in that window and we lose it, we will not find out for a week. Let's do it after the freeze. -- Wes Edling · Joe Designs On Tuesday at 9:41 AM, Priya Nakamura wrote: > The numbers moved about four percent after the change, which is > inside the noise on a single run but not across nine of them.
The white body in a dark window is the one place the app does not follow its own palette. Writing pale text on a dark ground and posting it into a white one is how people send unreadable mail without knowing it.
Search
Words match subject, sender and body, quoted and prefix-wildcarded so results narrow as you type. An unbalanced quote, a stray colon, a date that is not a date — all of it comes back as a search that runs. An operator the parser does not recognise is not rejected; it falls through and becomes ordinary search text, so 10:30 and a pasted URL search for what you typed. A mail client that refuses to search while you are still typing is one you stop typing into.
from:anasender name or addressto:borecipient, including Ccsubject:“the roof”words in the subjectin:archivefolder nameis:unreadalso is:readis:flaggedalso is:unflaggedhas:attachmentcarries a fileafter:7dalso a date, or todaybefore:2026-01-31everything olderReading mail from strangers
On macOS the reader is a WKWebView, because no reduction of a newsletter to headings and paragraphs is going to look like the thing its sender laid out. Three things make that defensible rather than reckless — and the block renderer underneath is what everything else uses.
Scripts, frames, objects, event handlers and javascript: URLs are removed from the source text with their subtrees, so nothing survives as markup to be parsed again.
Turned off in the view's own configuration, so the sanitizer is the second line rather than the only one.
A delegate refuses every navigation but the initial load. A clicked link goes to the system browser, which is where a stranger's URL belongs.
An image the message points at costs a request to a server the sender chose, and a unique URL per recipient is how open detection works. So they are blocked, the reader says how many were withheld and what loading them would tell the sender, and consent is per message and never inferred. Nothing fetches from a render pass. Images declared at a couple of pixels are dropped outright — that is what a tracking pixel and a table spacer both are.
Marketing mail opens with a preheader in a display:none container followed by hundreds of ­ entities padding out the preview. Both are handled, so a newsletter does not open by saying the same sentence twice. The check is deliberately narrow: font-size:0 and opacity:0 also appear on ordinary wrappers, and treating those as hidden blanks the whole message.
Almost every receipt is built out of nested layout tables, so rendering all of them as grids turns mail into a spreadsheet and rendering none of them loses every receipt. The parser decides per table — a <th> settles it, and without one it wants two consistent rows two columns wide. A one-cell wrapper disappears; the receipt three levels inside it does not.
The bytes arrive with the message — under 12 MB they are stored at sync, and anything past the cap is fetched from the server when you ask for it rather than being a name with nothing behind it. A chip opens in Quick Look, which renders the file in the system's own previewer instead of handing it to whichever application claims the extension; the control on the chip writes it to Downloads and reveals it there, so opening it stays your decision.
A cid: reference resolves against the parts stored during sync, capped at 2 MB each and only kept when the HTML actually points at them, so an image the sender attached is decoded when the message opens and drawn like any other content — no request, and nothing to consent to.
A green RESOLVED badge and a red error count carry meaning, and dropping them leaves a message that is structurally right and says nothing. But mail is designed against the sender's own background, so the reader moves only lightness until a colour clears WCAG AA against the surface it landed on. A colour on a fill of the sender's own is judged against that fill instead, because the pair was chosen together.
⌘⌥P switches to the plain-text body, drawn by the block renderer — as is any message with no HTML, any conversation of more than one message, and everything on every platform without a web view. That renderer is also what search, snippets and threading read, so it is not a fallback that rots.
Themes
Both are neutral and low chroma, and in the dark theme the accent is white — so the only saturated things on screen are the green, the yellow and the red, and every one of those is carrying meaning. The page you are reading holds itself to the same rule, which is why it has no brand colour on it either.
The active theme is a gpui Global the whole window reads through cx.theme(). No call site constructs a palette and no call site writes a colour literal, which is why adding a theme is one function returning a struct — and why the window at the top of this page could be drawn in both from one copy of the markup.
Keys
Every one of these is a gpui Action. That is why the command palette is a list rather than a second implementation of the menu: keystrokes, palette entries, menu items and click handlers all dispatch the same thing, so adding a command makes it reachable four ways with no further wiring.
⌘K — forty-odd commands, each one the same action its keystroke fires.
Accounts
⌘, ▸ Accounts. Pick Gmail, name the account, press Sign in with Google — your browser opens, you approve, and that is the setup. Spool never sees your Google password, and access is revocable from your Google account's security settings at any time.
Host, port, SSL/TLS or STARTTLS, and a password. Most providers reject your normal login password over IMAP and want an app-specific one.
It authenticates against IMAP and SMTP before you commit, so a wrong port or a rejected password shows up here rather than three days later.
Passwords and OAuth refresh tokens are stored under the service “spool”. accounts.toml keeps only servers and a note that a credential exists.
The first pass pulls the newest messages per folder, not a decade of archive. Later passes ask only for UIDs above the highest one stored — usually one round trip and no messages.
It ships inside every copy of the app, so it cannot be kept — OAuth calls such a client public for exactly that reason. What protects the exchange is PKCE plus a loopback redirect: Spool opens a listener on an OS-assigned port, sends the browser to Google with an S256 challenge, and checks the state on the way back. The scope requested is https://mail.google.com/, the only one that reaches IMAP and SMTP. The refresh token goes to the keychain; the hour-long access token stays in memory and is never written down.
accounts.toml is still editable by hand, and spool --write-config writes a commented example. BODY.PEEK[] throughout, so syncing never marks mail read as a side effect. IDLE keeps a second connection open per account, reconnects when the laptop wakes, and is what makes new mail something that arrives rather than something you fetch.
Underneath
core has no dependency on a database or a UI framework, which is what makes threading and content parsing testable as pure functions — 294 of the 727 tests run against neither a server nor a window. net is quarantined because DNS, TLS and a server that stops responding mid-FETCH are the things that hang a mail client.
No database, no UI, no I/O — which is what makes threading and content parsing testable as pure functions.
The source of truth. The full-text index is contentless: postings, not a second copy of every message.
Quarantined on purpose. DNS, TLS and a server that stops answering mid-FETCH are what hang a mail client.
Every keystroke, menu item, palette entry and click dispatches the same Action, so a command is reachable four ways with no extra wiring.
Threading is a cut-down JWZ: messages link by Message-ID, In-Reply-To and References, with union-find so a parent arriving after its children — the normal case over IMAP, where mail arrives newest-first — merges the conversations rather than splitting them. Subject matching is a fallback used only when a message carries no reply headers at all, because applying it more eagerly is how clients end up merging every message titled “hello” into one thread.
Fine print
Better to read this now than to find out at the end of a thread.
It is free while it is in beta, and the version number is the honest description: this has not been through a beta with anyone else's mail in it. Mail is the thing you cannot re-download, so point it at an IMAP account whose server still has everything, and keep it that way for a while.
Archiving, moving and flagging are written back to the server through a queue that survives being offline. The other direction is not: sync only asks for UIDs above the highest one it has, so a message you archived on your phone stays in the inbox here until something else moves it.
The composer holds a single message and so does the store, so opening a reply while something unfinished is on screen replaces it. A drafts list, and syncing to the IMAP Drafts folder, are still to come.
They can be looked at and kept — Quick Look from the chip, ⌘⇧S to Downloads — and anything over 12 MB is fetched from the server when you ask for it. What a forward carries is still the text.
Deleting out of the Trash removes the row, its body and its attachments. Everywhere else, delete means move to Trash, which ⌘Z covers.
Contacts are real underneath — search, tags, CSV import and export — but the sidebar row that opened them is switched off, because a permanent row for something half-built is a standing promise the app does not keep. Show Contacts in the palette still reaches it.
gpui itself runs on Linux and Windows and everything else in the app is portable, but the reader's web view is a WKWebView and AppKit talk. Other platforms fall back to the block renderer.
The seams are there — a TRIAGED flag bit, a thread-safe Store, a retrieval API, and actions as the tool surface — but no agent code is written and none is stubbed. Promising otherwise on a product page is how that stays true forever.
It is still being built against my own mail, and it is not going near anyone else's until it has been boring for a long while. The numbers on this page are a claim until you have run spool --demo 250000 yourself, and that is the first thing you will be able to do with it.