Spool is a native GNOME mail client under the bloom umbrella —
alongside bloom-boba, bloom-lisp, bloom-vt, bloom-terminal,
and bloom-telnet. C + GTK4 + libadwaita, built on top of
GNOME Online Accounts for account discovery, with provider-specific
backends for Microsoft Graph and IMAP.
A fast, HIG-correct mail reader that fits the GNOME desktop the same way Files, Calendar, or Contacts do — no Electron, no JavaScript, no per-provider GUI quirks. Accounts come from your GOA session; Spool discovers them, syncs each account's messages to a local Maildir + sqlite index, and renders the result with stock libadwaita widgets.
Storage is local-first (mutt / gnus-style). Each account's messages
live under ~/Mail/<identity>/ as a regular Maildir tree, and Spool's
UI always reads from disk. Remote providers are contacted only when
you explicitly trigger a sync from the account page. The on-disk
Maildir is authoritative — point mutt or another Spool instance at
the same ~/Mail and everything interoperates cleanly. If mutt marks
a message read while Spool is open, the boldness drops off the row in
real time; the reverse holds too.
- Browse folders. A toggleable sidebar lists every account from
GoaClientwith provider icons, and the folders under each account. Sidebar width is pinned to the widest row so it doesn't balloon on widescreen displays. - Filter the message list. A virtualizing list opens 10k-row folders instantly and scrolls smoothly. The header bar carries an unread-only toggle that hides read mail without reloading.
- Read messages three ways. Toggle group in the message-view header bar: Rendered (HTML in a sandboxed WebKit view, or the text/plain alternative per RFC 2046 §5.1.4), Plain (forced text/plain alternative; greyed out when none exists), and Source (raw RFC 5322). Unsupported content types surface an explanatory placeholder instead of dumping binary.
- Reply. A Reply button in the message-view header opens a compose
dialog pre-filled Gnus-style: attribution line plus
>-quoted text/plain body (or HTML→text fallback when no plain alternative exists). - Trigger a sync. Click an account row to see its sync page — "Sync now" in the idle state, a centered progress ring with a live status line, a sliding-window ETA ("About 3 minutes remaining"), and a Cancel button while a pass is running.
- Live disk updates. Reading a mail in Spool, or marking it read in a parallel mutt session, both flip the sidebar unread badge and the row boldness in the same frame. A per-account watcher with a 120 ms debounce reconciles disk → sqlite drift in real time and at startup, so changes accumulated while Spool was closed are picked up before you click anything.
Providers covered: Microsoft Graph (Outlook / Office 365) and IMAP via SASL XOAUTH2 (Gmail tested). Selection is automatic per-account based on what GOA reports.
Spool is an early prototype. SMTP send is not wired — the Send button
in the compose dialog appends to a debug Outbox.mbox in the account
root so you can read your draft back with mutt or less. Search,
threading, and push notifications are explicit follow-ups, not
held-back features.
Spool ships as source. On Fedora:
sudo dnf install gnome-online-accounts-devel libsoup3-devel \
json-glib-devel libetpan-devel libxml2-devel \
gtk4-devel libadwaita-devel gmime30-devel \
sqlite-devel webkitgtk6.0-devel
./autogen.sh
mkdir -p build && cd build
../configure --prefix=$HOME/.local --enable-debug
make -j$(nproc)
make install
spoolConfigure your mail accounts in GNOME Settings → Online Accounts first; Spool picks them up automatically.
These guide every code-level decision in the repo. They're listed first because they're how to evaluate a change, not just describe one.
-
Charm.land identity, bloom umbrella. The project name, CLI binary, and documentation voice follow charm.land's short, function-rooted naming aesthetic, and sit alongside the rest of the
bloomfamily (bloom-boba,bloom-lisp,bloom-vt,bloom-terminal,bloom-telnet). The visible UI is not charm.land — it follows the GNOME HIG exactly, per the next bullet. When the two pull in different directions, the HIG wins. -
Native GNOME, not a port.
org.gnome.Spoolapp id, GTK4 widgets, libadwaita layout primitives (AdwOverlaySplitView,AdwNavigationView,AdwHeaderBar,AdwWindowTitle,AdwStatusPage). The HIG is the reference; if a behaviour clashes with the HIG, the HIG wins. -
C, not Rust. The codebase is plain C against GLib / GTK / GIO / libsoup / json-glib / GMime / libetpan. Autotools builds. No new language runtimes pulled in for convenience.
-
Memory reuse over malloc/free on hot paths. Provider backends own a chunked arena (
MailArena) plus persistentGByteArray/GStringscratch buffers. Per-request parsing reuses them instead of allocating fresh. The arena is pointer-stable across grows — its blocks form a linked list and are neverrealloc'd, so callers can hold returned pointers across subsequent allocations within the same reset window. -
Documented lifetime contracts at the seams. Each
MailBackendvtable entry documents how long the pointers it returns stay valid. Folder pointers live until the nextlist_folders_async; message pointers live until the nextlist_messages_async;fetch_message_raw_asyncmust not invalidate either, because the message-list pane still holds prior message pointers. These contracts are pinned by tests, not just comments. -
Standards over heuristics. When the spec dictates behaviour (e.g. the text/plain alternative in
multipart/alternativeper RFC 2046 §5.1.4), follow it exactly. Deviations need a written reason. -
Every bug ships with a regression test. A fix must include a test that fails without it and passes with it, verified in-session. The test suite is the project's memory of why the code looks the way it does.
-
Provider-agnostic core. The UI talks only to
MailBackend. New providers are new vtable implementations; the existing widgets don't know about HTTP, IMAP, or OAuth. The same vtable is implemented bymail-backend-store(UI-facing, reads from the local sqlite + Maildir store) and by the per-provider backends (mail-backend-msgraph,mail-backend-imap) consumed byMailSync. Amail-backend-fakelives undertests/so regression tests run headless without network or GOA accounts. -
Maildir is the source of truth, sqlite is an optimization. Every state change writes the on-disk Maildir first and reflects to sqlite second. sqlite earns its place as an indexed cache for message-list rendering and unread counts, not as a second authority. When the two disagree, disk wins — a
MailMaildirWatcherper account reconciles drift in real time and at startup. -
Reuse the GNOME platform. GMime for MIME, libsoup for HTTP, json-glib for JSON, libetpan for IMAP, sqlite3 for the local index, GOA for OAuth refresh. We do not reinvent what the platform already maintains.
- Sync engine.
MailSyncis a per-account one-shot reconciler: list folders → list messages per folder → fetch new bodies → upsert into the local store. Triggered manually from the account page; no startup sync, no timer. Pagination runs to completion (no per-pass message cap); amessages.remote_idUNIQUE constraint keeps repeat passes idempotent. - Local store.
MailStoreowns a Maildir tree + sqlite index at~/Mail/<identity>/. The Maildir is authoritative; sqlite is an indexed cache over it. The UI reads throughmail-backend-store(aMailBackendimplementation that wraps the store), so the rendering path never blocks on the network and the sync engine is the only thing that talks to providers. - Disk → UI change-notification spine. A per-account
MailMaildirWatcherarms oneGFileMonitorper folder'scur/with a 120 ms debounce; watcher events run the disk → sqlite reconciler, which emits the same events as the local mark-read path. The reconciler also runs once at startup so drift accumulated while Spool was closed is corrected before the user clicks anything. - Providers. Microsoft Graph (functional, with
@odata.nextLinkpagination); IMAP via libetpan with SASL XOAUTH2 (Gmail tested), with cross-folder body deduplication keyed on the RFC 5322Message-IDheader — a message in INBOX and[Gmail]/All Mailis fetched once and the Maildir bodies hardlink-share an inode. Fetches are batched (oneUID FETCH BODY.PEEK[]per ~50 messages) and the GOA OAuth token is cached across the pass, so an initial-sync's per-message round-trip cost amortises into a near-constant overhead.
sudo dnf install gnome-online-accounts-devel libsoup3-devel \
json-glib-devel libetpan-devel libxml2-devel \
gtk4-devel libadwaita-devel gmime30-devel \
sqlite-devel webkitgtk6.0-devel
./autogen.sh
mkdir -p build && cd build
../configure --prefix=$HOME/.local --enable-debug
make -j$(nproc)
make check
./src/spoolTwenty-one test binaries under tests/, running under gtk_test_init
where they touch widgets: test-about, test-account-page,
test-accounts, test-arena, test-backend-contract,
test-backend-store, test-compose-window, test-eta,
test-html-to-text, test-imap-id, test-imap-retry,
test-maildir-watcher, test-message-list, test-message-view,
test-mime, test-outbox, test-quote, test-sidebar, test-store,
test-sync, test-window. Every bug fix lands with a regression test
(see the principles).
make format—clang-formaton the C tree,xmllint(2-space GNOME style) on the XML / GtkBuilder files underdata/, andprettieron Markdown. Each pass is skipped with a notice if the tool isn't on$PATH, so a partial dev environment still works.make bear— clean rebuild under bear, emitscompile_commands.jsonat the project root for clangd. Builds tests too so the database covers everything undersrc/andtests/.
Optional dev tools:
sudo dnf install clang-tools-extra libxml2 bear
npm install -g prettier