Skip to content
unicornopsPublic

About

Repo for the zmail project

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Zmail

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.

Current Status

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, and ZMAIL_WEB_RENDERER=0 turns it off. On Linux the same locked-down page renders in WebKitGTK 4.1 (the libwebkit2gtk-4.1-0 package, which the .deb recommends; 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-features to 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-screenshots CI artefact: every main UI state in light and dark plus narrow and wide windows, captured from seeded fixture data (see AGENTS.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

Workspace

Commands

Format and verify the workspace:

cargo fmt --all
cargo check --workspace
cargo test --workspace

Run the stricter CI-equivalent verification locally:

cargo clippy --workspace --all-targets -- -D warnings
cargo deny check licenses advisories bans sources
cargo audit

Run 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 down

Add and sync an account:

cargo run -p zmail-app -- add-imap
cargo run -p zmail-app -- add-jmap

Inspect 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 1

Only 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 42

Search 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-app

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

Terminal UI

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 memory

It 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):

  1. 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). E skips to typing the servers. With Proton built in, a Proton address (or Ctrl-P) goes to Proton's sign-in instead.
  2. 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.
  3. 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.
  4. 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.1 on the computer the browser runs on, so over SSH (SSH_CONNECTION or SSH_TTY set) 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.
  5. 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.
  6. Your identity: the name and address mail goes out with, prefilled, and a signature.
  7. 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).

Mail Harness

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

Platform Notes

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 MetalToolchain

GitHub 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-dev

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

Licensing

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.

About

Repo for the zmail project

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages