Warning
Zmail is alpha software and is in no way ready for real use. Expect bugs, missing features, breaking changes between releases and, at worst, lost or corrupted local data. Do not rely on it as your only mail client, keep your mail on the server, and keep backups of anything that matters. Native Proton Mail support is experimental on top of that (see below).
Zmail is a Rust desktop email client focused on speed, standards compliance, security, and large-mailbox performance. The UI is built on GPUI, unchanged Zed theme files are supported through a thin local compatibility layer, and the local data path is designed so network activity never blocks mailbox browsing.
Phases 1 through 4 are implemented in-repo, and most of Phase 5 hardening is in place:
- three-pane GPUI shell with local-first mailbox, thread, and preview browsing
- hydrated thread reader with safe HTML fallback rendering and remote-content blocking
- on macOS and Windows, HTML mail renders formatted in a locked-down web view inside the reader (
WKWebView, or the system's WebView2 runtime on Windows; ADR 0006): JavaScript off, every load blocked except Zmail's own, links confirmed in Zmail's dialog, Zmail's menus and dialogs drawn above it through GPUI's native window composition (Zed PR #62379); remote images load only when you allow them, and then Zmail fetches them itself (https only, never from private addresses) so the web view never connects anywhere; each message's More menu switches to the Simple (text) view, andZMAIL_WEB_RENDERER=0turns it off. On Linux the same locked-down page renders in WebKitGTK 4.1 (thelibwebkit2gtk-4.1-0package, which the.debrecommends; 2.52.0 or newer): live inside the reader on X11, and as a picture of the page with working links and selection on Wayland. Zmail loads WebKitGTK only when it shows a formatted message and never runs it without its bubblewrap sandbox: without the library, below the minimum version, inside a snap or Flatpak, or where the sandbox cannot start (Ubuntu 24.04 restricts the user namespaces it needs), messages show as text with the reason. Windows without the WebView2 runtime keeps the text view - SQLite-backed local mirror with migrations, paging, and persisted sync state
- local mailbox operations for read or unread, flag, move, archive, delete, and full-text search
- real IMAP and JMAP account sync behind a dedicated Tokio background runtime
- OAuth2 account setup in the settings dialog with provider presets (Gmail, Microsoft 365), background access-token refresh, and XOAUTH2 for IMAP sync, SMTP submission, and JMAP bearer authentication
- Gmail signs in with an app password by default; zmail has no Google OAuth client of its own yet, so
docs/gmail-oauth.md(and the guide in the Add Account flow) walks through creating your own - experimental native Proton Mail accounts (paid plans) that follow Proton Mail Bridge's behaviour without needing Bridge: sign-in with 2FA and human verification, sync (the newest mail first, older mail fetched in the background), live updates, actions and end-to-end encrypted sending (ADR 0004). zmail is an independent client on Proton's private API and signs in with Proton Mail Bridge's app version, because Proton's sign-in accepts no other client, so Proton may change or block it; build with
--no-default-featuresto leave it out - received attachment download and save-as for IMAP and JMAP accounts
- full-text search with query operators; IMAP message bodies are fetched and indexed in the background, newest first, within the account's disk budget, pausing on battery power (Linux) unless allowed
- compose, autosave, attachment handling, outbox retry, SMTP submission, and JMAP submission support
- multiple identities from day one, with JMAP identity discovery and manual identity setup for generic accounts
- OS keychain-backed credential storage
- per-account cache policy persisted with each configured account
- unchanged Zed theme file parsing and semantic theme mapping
- coalesced UI refresh polling so background sync and outbox jobs do not flood the GUI thread
- multi-platform GitHub Actions builds plus Release Please automation starting at
0.0.1 - Tagged releases also attach a Linux screenshot captured from the live mail harness by the Release Please workflow chain
- pull requests that change the UI get a
ui-screenshotsCI artefact: every main UI state in light and dark plus narrow and wide windows, captured from seeded fixture data (seeAGENTS.md) - release archives ship platform binaries plus a Linux
.deb, a macOS DMG (Developer ID signed and notarized when the org-wide Apple credentials are present), and a Windows MSI installer; Windows code signing remains opt-in pending work - a terminal UI for reading, triaging and writing mail,
zmail-app tui(ADR 0007; see "Terminal UI" below): Pine/Alpine-style screens on the same encrypted store, every string from mail filtered for escape sequences before it reaches the terminal; delete, archive, save to a folder, flag and mark read with undo, sharing the desktop app's mail actions; Pico-style compose, reply and forward with drafts, attachments, undo-send and an Outbox; and Setup → Accounts to add (with autodiscovery, a connection test, OAuth through the browser, and Proton sign-in in builds with Proton), change and delete accounts, through the same account setup code as the desktop app (zmail-setup) - screen-reader roles, names and announcements through AccessKit (VoiceOver, UI Automation, AT-SPI), Windows contrast themes and macOS Increase Contrast, reduced motion, text size up to 200% and 24px pointer targets; not yet checked with a real screen reader (see
docs/phase5-hardening.md) - Phase 5 hardening and release policy documentation
PLAN.md: master delivery plandocs/upstream/zed.md: Zed and GPUI upstream pin record (GPUI is normally pinned to a Zed stable release tag and bumped by a weekly workflow; for now it is pinned to Zed PR #62379 for the web renderer)docs/adr/0001-jmap-client-strategy.md: JMAP implementation decisiondocs/adr/0003-gpui-zed-stable-pin.md: GPUI pinned to Zed stable release tagsdocs/phase5-hardening.md: accessibility, performance, crash policy, packaging, and release processdocs/performance.md: performance budgets, how they are measured, and the latest numbersthemes/phase0-dark.json: sample unchanged Zed-format theme file
Format and verify the workspace:
cargo fmt --all
cargo check --workspace
cargo test --workspaceRun the stricter CI-equivalent verification locally:
cargo clippy --workspace --all-targets -- -D warnings
cargo deny check licenses advisories bans sources
cargo auditRun the live Docker-backed mail harness tests:
scripts/mail-harness.sh up
cargo test -p zmail-test-support --test live_mail_harness -- --ignored --nocapture
cargo test -p zmail-test-support --test live_jmap_harness -- --ignored --nocapture
scripts/mail-harness.sh downAdd and sync an account:
cargo run -p zmail-app -- add-imap
cargo run -p zmail-app -- add-jmapInspect configured accounts or trigger sync:
cargo run -p zmail-app -- list-accounts
cargo run -p zmail-app -- sync all
cargo run -p zmail-app -- sync 1Only one Zmail process syncs a store at a time (see "Terminal UI" below). While the desktop app or zmail-app tui syncs it, sync, index and process-outbox say so and exit with an error, and send, retry-outbox, compose, add-imap, add-jmap and empty-trash do their part (queue the message, store the account, empty the Trash on the server) and leave the syncing and sending to that Zmail, which does it within seconds. --force runs them here anyway, beside it. Commands that only read the store, such as list-accounts, search and read, work regardless; search --server asks the server too, and leaves fetching what it finds to the Zmail that syncs.
Search or operate on locally mirrored mail:
cargo run -p zmail-app -- search 1 incident
cargo run -p zmail-app -- read 42
cargo run -p zmail-app -- flag 42
cargo run -p zmail-app -- mark-read 42
cargo run -p zmail-app -- move 42 7
cargo run -p zmail-app -- archive 42
cargo run -p zmail-app -- delete 42Search queries (in the app's search field and the search command) combine
words, "quoted phrases" and operators, all of which must match:
from:, to:, subject:, has:attachment, is:unread, is:read,
is:flagged, in:<mailbox name or role>, and before:/after: with a
YYYY-MM-DD date or a relative one such as 7d, 2w, 3m or 1y. A
leading - negates any of them:
cargo run -p zmail-app -- search 1 'from:alice "budget review" -in:junk after:30d'In the app, a search starts in the folder the list shows (the Unified
Inbox counts as one folder: every account's inbox). The scope control under
the list header, the empty state and the Edit menu widen it to the folder's
account or to every account (Secondary+Alt+1, 2 and 3 pick folder, account
and all accounts). Each new search starts in the folder again. An in:
operator in the query names the folder itself and takes the folder's place
in the scope control. The search command searches one account.
The desktop app indexes bodies in the background; zmail-app index ID runs
one indexing pass for an account in the foreground and prints its progress.
Until every body is indexed, a search with free text also asks the server
(IMAP UID SEARCH, JMAP Email/query); its extra hits are listed after the
local ones and marked "found on server". The server search follows the
scope: in folder scope IMAP searches only that folder's mailboxes, and a
search across accounts asks each account's server separately, so one
offline account does not stop the others' results. zmail-app search --server ID QUERY
does the same from the command line.
Launch the desktop client:
cargo run -p zmail-appThe local store lives in a platform data directory so user data is independent of the launch directory:
- Linux:
$XDG_DATA_HOME/zmail(default~/.local/share/zmail) - macOS:
~/Library/Application Support/zmail - Windows:
%APPDATA%\zmail
Set the ZMAIL_DATA_DIR environment variable to override the store directory for development. A legacy .zmail-dev/zmail.sqlite3 store in the current directory is migrated to the platform data directory on first run; if the migration fails, the app keeps using the legacy store so no data is lost.
The desktop shell no longer creates demo accounts from inside the UI. Configure accounts through the CLI commands above, then use the app to browse and refresh the local mirror.
Read, triage and write mail, and set accounts up, in a terminal, on the same store as the desktop app (ADR 0007):
cargo run -p zmail-app -- tui
cargo run -p zmail-app -- launch-tui-fixture # synthetic mail, no sync, nothing saved, a keychain in memoryIt is in every build (the tui feature, on by default) and unlocks the store with its key from the system keychain, like the desktop app; over SSH that needs the keychain service running on that machine (Secret Service on Linux), or it says so and stops. With no account yet, the status line says so and S goes straight to adding one.
The screens work like Pine and Alpine: a title bar, the screen, a status line, and a command bar that lists the keys that work there (O shows more). Letters work in either case.
| Key | Does |
|---|---|
M |
Main Menu |
L |
Folder List (each account is a collection) |
I |
Message Index of the current folder |
< / >, Enter |
up a level, open |
P / N |
previous and next item (or message, in Message Text) |
- / Space |
previous and next page |
W |
WhereIs: search the index as you type (the search language above; Enter also asks the server), find a folder, or find text in a message |
/ |
list conversations or single messages |
V |
preview pane beside the index, in terminals 120 columns or wider |
| Tab, Enter | in Message Text, move to a numbered link and open it after seeing its real address |
V |
in Message Text, the Attachment Index: the conversation's attachments (name, type, size), to save |
C |
compose (wherever C is free); R reply and F forward in the Message Index and Message Text |
T |
the Outbox (from the Main Menu; also under Local in the Folder List) |
S |
Setup (from the Main Menu); A there opens Accounts |
: |
the command palette: every command of the screen with its key, filtered as you type (the desktop app's fuzzy matcher), Enter runs one; not in Compose or the account forms, where : is text |
Q |
quit, after asking |
Triage works in the Message Index on the message under the cursor and in Message Text on the open one. Each command acts on the whole conversation, as in the desktop app (also when the index lists single messages), and the status line says what it did with its undo key ("Conversation moved to Trash. U Undo").
| Key | Does |
|---|---|
D |
delete: to the Trash at once (for good, with no undo, when the account has no Trash); in Message Text, the next message opens |
E |
archive |
S |
save to a folder of the same account: Tab completes the name and then goes through the matches, Ctrl-T lists the folders to pick from |
* |
flag, or unflag |
K |
mark read, or unread |
U or Z |
undo the last of these, whichever screen it was done on |
; |
select the message under the cursor, or not (an X in the first column) |
A |
apply the next command (D, E, S, * or K) to every selected message; ; there unselects them all |
Sender-safety warnings appear above a message's text in Message Text (and the preview pane), with the desktop app's words and evidence from the same code (zmail_security::sender_warning): "Possible impersonation" for a sender's domain that imitates a well-known one, "Replies go somewhere else" for a Reply-To on an unrelated domain, and "Replies go to a related address" (left out with the desktop app's "Fewer warnings" setting). X (Looks safe, on the first page of the command bar while a warning shows) hides the warning in view for that message only, as the desktop app's button does, in both frontends.
Attachments are saved from the Attachment Index (V in Message Text). S or Enter saves the one under the cursor: the path starts in the folder last saved to (else Downloads, else the home folder) with the attachment's name made safe for a file (zmail_security::attachment_safety), Tab completes it, and a folder there saves into that folder. A file that is already there is replaced only after asking. A saves them all into a folder, numbering a name that is taken (report (1).pdf), as the desktop app's Save All does. The attachment is decrypted to a temporary file beside the destination and renamed into place (AttachmentBlob::save_to), so a failed save leaves nothing partial. Names and types from the mail are filtered before they are drawn. The terminal never opens or launches an attachment: save it, and open it yourself.
Opening a message in Message Text marks it read once it has been open for 1.5 seconds, as the desktop app's reader does. The changes go through the same mail actions as the desktop app (zmail-actions): each is written to the store at once and sent to the server by whichever Zmail syncs.
Writing mail works like Pico: header fields (From when the account has several addresses, To, Cc, Bcc, Attchmnt, Subject), then the message text in an editor (ratatui-textarea). Up and Down, Enter and Tab move between the fields; Tab in To, Cc and Bcc completes an address from the ones you have used (the desktop app's suggestions, best first), again to go through the matches. C asks "Continue postponed composition?" when there are postponed messages. R replies to the message (in Message Text, the newest one that starts on screen), asking "Reply to all recipients?" when others got it too; F forwards it. Replies and forwards start exactly as in the desktop app (zmail_actions::reply: recipients, Re:/Fwd:, the quote or the forwarded headers), with the quoted text filtered for the terminal before it enters the editor. As in the desktop app, a reply goes to the message's Reply-To when it has one, comes from the address the message was sent to (an alias included; change it in From), and names the message it answers in In-Reply-To and References, so it threads for the recipients; a forward takes the message's attachments along, to keep or remove.
| Key | Does |
|---|---|
Ctrl-X |
send, after asking; the message waits for the undo-send delay set in the desktop app, and until then U takes it back and opens it again |
Ctrl-O |
postpone: keep the draft for later |
Ctrl-C |
cancel: deletes the draft, after asking if you changed it (an unchanged postponed draft stays) |
Ctrl-K / Ctrl-U |
cut the line (a header field's text, or the attachment under the cursor on Attchmnt), put back what was cut |
Ctrl-J or Ctrl-A |
attach a file: its path, with Tab completion, or Ctrl-T to browse. Windows shows ^A on the command bar, where Ctrl-J may arrive as Enter |
Ctrl-R |
insert a file's text into the message |
Ctrl-W |
find text in the message |
Ctrl-T |
your addresses (most used first) in To, Cc and Bcc; the file browser on Attchmnt |
Ctrl-G |
help |
Ctrl-_ |
edit the message text in $VISUAL or $EDITOR (most terminals send the same key for Ctrl-7 and Ctrl-/), only when Setup turns it on |
The external editor is off by default. Setup says what turning it on means: while the editor is open, the draft is unencrypted in a temporary file, in a new folder only you can open (0700 on Unix, the file 0600; on Windows your own temporary folder), and Zmail overwrites every file in that folder and removes it when the editor exits, also when it fails or never starts.
Drafts autosave to the encrypted store a second after typing pauses, as in the desktop app, and attachments are copied into it encrypted, shown by their filtered names and sizes. Send goes through the same path as the desktop app (SyncService::queue_draft_and_send_after), so whichever Zmail holds the sync lock sends it from the outbox. The Outbox (T) lists messages waiting to send and those that couldn't be sent, in the desktop app's words: R sends a failed one again, D takes back one that hasn't started sending (it returns to the postponed messages); a refused sign-in or a changed recipient key is settled in the desktop app. The postponed messages are listed under Local in the Folder List too, where Enter continues one and D deletes it.
Accounts (S, then A) is Alpine's collection list for mail accounts: A adds one, C changes the one under the cursor (a new password, the browser sign-in again for OAuth accounts, the cache budget), D deletes it from this computer after asking (its local data and every keychain secret it has; the mail on the server stays). The steps of adding one are the desktop app's, from the same code (zmail-setup):
- Your email address. Gmail, Outlook.com, Fastmail and iCloud addresses use Zmail's own settings; any other domain is looked up step by step, each step's progress on screen (DNS records, the domain's own setup file, JMAP, Mozilla's database only after asking, since that sends the domain to Mozilla, the domain's mail servers, the usual server names).
Eskips to typing the servers. With Proton built in, a Proton address (orCtrl-P) goes to Proton's sign-in instead. - How you sign in: a password, an app password (with the provider's own hint and help page), or OAuth in the browser with a client of your own. Zmail ships no OAuth client; for Gmail the step lists how to make one in Google Cloud, as the desktop app's guide does.
- The servers, found or guessed, all editable: host, port and security for IMAP and SMTP, or the JMAP session URL. TLS is the default; an unencrypted connection needs its own opt-in, with a warning.
- For OAuth, the browser opens at the provider's sign-in page, and the address is always shown too, in full, to open by hand. The provider sends the sign-in back to
127.0.0.1on the computer the browser runs on, so over SSH (SSH_CONNECTIONorSSH_TTYset) it can't reach this Zmail; the terminal says so plainly and opens no browser: use an app password, or add the account on the computer you're at. Esc stops waiting and frees the port. - A connection test: Zmail signs in to the incoming server (IMAP, or the JMAP session) and the outgoing one (SMTP), sends nothing, and says which one failed and why, back on the step where it is fixed.
- Your identity: the name and address mail goes out with, prefilled, and a signature.
- Saved: the password goes to the system keychain and nowhere else, and the first sync starts here when this Zmail syncs, otherwise in the Zmail window that does (within seconds). The screen follows the first sync.
Proton sign-in (builds with Proton) asks for your Proton username and password, under the desktop app's note on what a third-party Proton client means (Zmail signs in as Proton Mail Bridge), then the two-factor code and the mailbox password when the account has them. When Proton asks for human verification, Zmail opens Proton's page in the browser (and shows its address) and signs in again once you press Enter.
In the forms, Up, Down and Tab move between fields, Enter goes to the next one or continues from the last, Space changes a choice, Ctrl-U clears a field, Esc goes back a step and Ctrl-C cancels. Password fields show [hidden] once typed, never the text or its length. Everything a server, DNS or Proton returns (discovery results and errors, connection errors) is filtered for the terminal before it is drawn.
The mouse works too: a click opens what it is on (a click on the index's first column selects), the wheel scrolls, and the command bar's entries can be clicked.
Copy and paste work as in Zellij. Drag to select text inside one pane (the preview pane and the index are separate); it is copied when you let go. Double-click selects a word, triple-click a line, and dragging past the top or bottom scrolls. Wrapped lines are copied as the line they came from. Without a mouse, Ctrl-Space sets a mark in Message Text, the arrow keys select, and y or Ctrl-C copies. Copies go to the clipboard with OSC 52 (iTerm2, kitty, WezTerm, Alacritty, foot, Windows Terminal; tmux needs set-clipboard on); on a Mac outside SSH they go through pbcopy, since Terminal.app has no OSC 52. Setup can set a copy command instead (wl-copy, xclip -selection clipboard; run without a shell), send OSC 52 to the primary selection, or turn the mouse off. Hold Shift while dragging to use the terminal's own selection. Pastes (bracketed paste) go only into text fields such as WhereIs and Compose's fields, as text (a list pasted into To, one address per line, becomes a comma list); anywhere else they are ignored, so pasted text never runs commands.
Only one Zmail process syncs a store at a time, through a lock beside it (<store>.sync.lock). When the desktop app is already open, the terminal UI shows what it syncs, says "Sync runs in another Zmail window", and takes sync over if the window closes first; the same holds the other way round. The command-line commands that sync or send respect the lock too (see above). New mail shows in the status line and rings the terminal bell (Setup turns the bell off).
Phase 2b adds a repo-owned Docker Compose harness at docker/mail-harness/docker-compose.yml. It uses GreenMail for IMAP/SMTP and Apache James behind a local proxy for JMAP, with deterministic seeded state and automated account provisioning.
- IMAP endpoint:
imap://127.0.0.1:3143 - SMTP endpoint:
127.0.0.1:3025 - JMAP endpoint:
http://127.0.0.1:4080 - mailbox login:
phase2 - mailbox password:
phase2-password - mailbox address:
phase2@localhost - sender login:
sender - sender password:
sender-password - sender address:
sender@localhost - JMAP login:
jmap@localhost - JMAP password:
jmap-password
The ignored integration tests start and stop the harness automatically. live_mail_harness verifies IMAP/SMTP round-tripping against GreenMail, live_jmap_harness verifies JMAP sync plus SMTP-delivered mail visibility against James, live_tui_send sends from the terminal UI's Compose, and live_tui_setup adds an account in the terminal UI's Setup → Accounts (connection test and first sync included).
On macOS, the first GPUI build may require Apple's Metal Toolchain. If cargo check -p zmail-app reports that the metal tool is missing, install it with:
xcodebuild -downloadComponent MetalToolchainGitHub Actions now builds zmail-app on ubuntu-latest, macos-latest, and windows-latest, and publishes tagged release archives from the same matrix.
On Linux, the desktop dependency stack also needs D-Bus, Fontconfig, and FreeType development headers. The same packages used in CI are:
sudo apt-get update && sudo apt-get install -y pkg-config libdbus-1-dev libasound2-dev libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libfontconfig1-dev libfreetype6-devHTML mail on Linux (ADR 0006) uses WebKitGTK 4.1 at runtime; nothing is needed to build. To see formatted mail, install libwebkit2gtk-4.1-0 (Debian 13, Ubuntu 24.04 and later; webkit2gtk4.1 on Fedora). Its sandbox, bubblewrap, needs unprivileged user namespaces: it works on Debian, Fedora and Ubuntu 26.04, but not in most containers, and Ubuntu 24.04 allows them only to programs with an AppArmor profile that grants them. The .deb installs one for /usr/bin/zmail-app (/etc/apparmor.d/zmail, which confines nothing and only allows user namespaces, like the profiles Ubuntu ships for Epiphany); a tarball install on Ubuntu 24.04 needs the same profile, from crates/zmail-app/packaging/apparmor/zmail, copied to /etc/apparmor.d/zmail and loaded with sudo apparmor_parser -r /etc/apparmor.d/zmail. The .deb loads it when it is installed (if AppArmor is present; on other systems nothing happens) and unloads it when Zmail is removed. Where the sandbox cannot start, Zmail shows mail as text and says why; it never runs the engine without it. ZMAIL_WEB_DEBUG=1 logs each step of the web renderer.
Window chrome: Zmail draws its own title bar with the toolbar inside it. On macOS the system title bar is transparent around the traffic lights; on Windows the caption buttons are drawn (Snap Layouts still work); on Linux Zmail draws a header bar, shadow and resize edges (client-side decorations) on GNOME and other desktops, and keeps the window manager's title bar on KDE and on X11 without a compositor or _GTK_FRAME_EXTENTS support. ZMAIL_WINDOW_DECORATIONS=server or =client overrides the choice (GNOME on Wayland has no server-side decorations, so it always gets the drawn ones).
GPUI draws through wgpu, which needs a Vulkan driver (the libvulkan1 loader plus Mesa or a vendor driver) or, as a fallback, EGL/OpenGL at runtime. Desktop distributions ship these already; headless runs such as the release screenshot use Mesa's lavapipe software driver (mesa-vulkan-drivers).
The Rust toolchain in rust-toolchain.toml follows the one Zed pins for the GPUI release tag, so rustup selects it automatically.
Zmail is licensed under GPL-3.0-only. Dependency policy is enforced through deny.toml and prepared for cargo-about via about.toml.
Linux builds embed the Inter UI font (4.1, SIL Open Font License 1.1, assets/fonts/inter/LICENSE.txt); the .deb installs its licence under /usr/share/doc/zmail-app/. All builds embed Lucide icons (ISC, with the Feather-derived icons also under MIT, assets/icons/LICENSE.txt), and code ported from GPUI is Apache-2.0 (crates/zmail-ui/LICENSE-APACHE). The local store is encrypted with SQLCipher (BSD-3-Clause, crates/zmail-store/LICENSE-SQLCIPHER), which on Linux and Windows links a statically built OpenSSL (Apache-2.0, crates/zmail-store/LICENSE-OPENSSL); see ADR 0005. Bundled non-crate assets and ported source files are listed in about.toml.