Spool

A quarter-million messages. Nothing slows down.

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.

Coming soon for Mac

Still being built · macOS 13+ · universal binary

Theme
List
Search
Inbox·All16
Priya Nakamurapriya.nakamura@corp.exampleToday at 9:41 AM

to wes@example.com

Performance regression — needs your sign-off

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

16 shown · 250,000 loadedsearch 15.2ms

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.

Rust, end to end.

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.

It is not a frame around a web page.

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.

Nothing is fetched you didn't ask for.

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

Fast is a claim. These are the numbers.

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 MacBook100,000 messages250,000 messages
Frame build timemean · worst 117µs / 223µs76µs85µs
Open a message95µs120µs
Full-text search5.4ms15.2ms
Load the whole mailbox list100ms290ms
Ingest31,500/s29,000/s
On disk111 MB274 MB
One frame at 120Hzwith 250,000 messages loaded
85µs to build the frame8,333µs available

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.

Frame time scales with the window, not the mailbox

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.

Search does grow — it is doing real work

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

Three decisions, none of them exotic.

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.

  1. 01

    The list is virtualized

    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.

  2. 02

    render never touches the database

    Every 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.

  3. 03

    Work happens once, at the right time

    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

Two shapes, and every filing action works on a set.

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.

01

Select several

⌘-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.

02

Drag onto a folder

Dragging a selection onto a folder in the sidebar moves it there. Mail cannot cross accounts, because IMAP has no move between servers.

03

Undo the move

⌘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.

04

It reaches the server

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

A draft is a window, not a mode.

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.

  • The body is monospace, because this is plain text and what you type is what arrives
  • The page is white in every theme — it is the page your recipient will read on
  • One signature per account, above the quote, under a “-- ” line with its trailing space
  • Autosave compares the draft against what the composer opened with, not against empty
  • Recipients complete from the address book and from who you actually write to, ranked as one list
Re: Performance regression — needs your sign-off
Topriya.nakamura@corp.example
Cc
SubjectRe: Performance regression — needs your sign-off
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.
Send⌘⏎Saved just now

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

Nothing you type into the box is an error.

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 address
to:borecipient, including Cc
subject:“the roof”words in the subject
in:archivefolder name
is:unreadalso is:read
is:flaggedalso is:unflagged
has:attachmentcarries a file
after:7dalso a date, or today
before:2026-01-31everything older

Reading mail from strangers

A newsletter is a document a stranger wrote in CSS.

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.

Sanitized before it loads

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.

Scripting is off

Turned off in the view's own configuration, so the sanitizer is the second line rather than the only one.

The view never navigates

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.

Remote images are the tracking

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.

What the sender hid stays hidden

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.

Tables are why mail looks the way it does

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.

An attachment is looked at before it is kept

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.

An inline image is already on disk

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.

The sender's colours are kept, and adapted

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

Two of them, and the dark one has no accent colour.

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

Modal-ish, in the sense that your hands stay put.

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.

Moving

Move down, upjk
Newestg g
Oldest⇧G
Page down, up⌃D⌃U
Next, previous mailbox⌃N⌃P
Toggle the sidebar⌘B

The message

Open
Toggle readu
Toggle flagf
Archivee
Move to Trash
Move to folder…v
Show the whole conversationt
Quick Look an attachment⌘Y
Save attachments to Downloads⌘⇧S
Copy the sender's address⇧C

Writing

New message⌘N
Replyr
Reply alla
Forward⌘⇧F
Send⌘⏎
Save the draft and close

Finding

Command palette⌘K
Search⌘F/
All / unread / flagged
Select every message⌘A
Extend the selection⇧↑⇧↓
Undo the last move⌘Z

The window

Formatted / plain body⌘⌥P
Slim / detail list⌘⌥D
Group into conversations⌘⌥T
Get new mail⌘⇧N
Settings⌘,
Back out one layer
Type a command
Search messages⌘F
New message⌘N
Reply all⌘⇧R
Get new mail — inboxes only⌘⇧N
Move to folder…v
Quick Look the first attachment⌘Y
Undo the last move⌘Z
Message list: slim / detail⌘⌥D
Import contacts from CSV…

⌘K — forty-odd commands, each one the same action its keystroke fires.

Accounts

Gmail is one button. Everything else is a server.

⌘, ▸ 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.

01

Or any IMAP server

Host, port, SSL/TLS or STARTTLS, and a password. Most providers reject your normal login password over IMAP and want an app-specific one.

02

Test connection first

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.

03

Secrets go to the keychain

Passwords and OAuth refresh tokens are stored under the service “spool”. accounts.toml keeps only servers and a note that a credential exists.

04

Sync is local-first

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.

PKCE

A desktop app's client secret is not a secret, and the flow is built for that.

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

Four crates, and the split is not ceremony.

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.

crates/coretypes, threading, HTML → blocks

No database, no UI, no I/O — which is what makes threading and content parsing testable as pure functions.

crates/storeSQLite + FTS5, demo corpus

The source of truth. The full-text index is contentless: postings, not a second copy of every message.

crates/netIMAP, SMTP, MIME, config

Quarantined on purpose. DNS, TLS and a server that stops answering mid-FETCH are what hang a mail client.

crates/appthe gpui application

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

What it will not do.

Better to read this now than to find out at the end of a thread.

It is version 0.1.0

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.

Changes made elsewhere do not arrive

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.

One draft at a time

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.

Attachments do not come along on a forward

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.

A permanent delete cannot be undone

Deleting out of the Trash removes the row, its body and its attachments. Everywhere else, delete means move to Trash, which ⌘Z covers.

The address book has no way in but the palette

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.

macOS is what has been tested

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.

No agents yet

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.

Not out yet.

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.

Coming soon for Mac
macOS 13+ · universal binaryTell me when it ships