I Built a Native Desktop Client for My Self-Hosted Stalwart Mail Server: CZL Mail
This post was translated from Chinese by AI. If anything reads oddly, the Chinese original is authoritative. 中文原文
Project: https://github.com/woodchen-ink/czlmail

Why I built it
Our mail server runs on Stalwart. Stalwart uses JMAP and offers a full set of server-side features: mail, calendars, contacts, and file storage. But I never found a client that felt right:
- Traditional clients like Thunderbird and Outlook only support IMAP/CalDAV/CardDAV, so you have to configure three separate protocols and miss out on JMAP push updates
- Bulwark is great, but it's webmail that needs a separate deployment. In daily use, it's just another browser tab, and once you close it, notifications stop
So I built my own: a desktop app that speaks JMAP directly. Install it, enter your server address and app password, and you're ready to go—nothing else to deploy on the server. It's now at v0.1.21, with installers for both Windows and macOS.
What it's like to use
Open it and start reading. Mail is cached in local SQLite, so switching folders and paging through messages reads from local disk. A single EventSource push connection keeps it in sync with the server. Delete a message or mark it as read elsewhere, and the change shows up immediately here—no polling.
No waiting to delete or archive. Click delete and the list immediately moves to the next message while the request runs in the background. If the server rejects it, the app notifies you and restores the original state.
Keep receiving mail with the window closed. On Windows, the app minimizes to the system tray, with an unread count drawn in the icon's upper-right corner; on macOS, unread counts appear in the menu bar and Dock. New mail and calendar reminders use system notifications, and shared mailboxes can be muted individually.
Chinese search works. Full-text search uses the FTS5 trigram tokenizer. The default unicode61 tokenizer doesn't segment Chinese properly—I switched after running into that problem. Local results appear first, then a server-side search across all folders fills in the gaps, so older archived mail is searchable too.
Features
Mail: Multiple accounts, with shared mailboxes listed directly in the sidebar; conversations grouped into a single row; rich-text composing, HTML signatures, templates, scheduled sending, separate sending to each recipient, and read receipts; recipient autocomplete from contacts, the mail server's directory, and recent correspondents; one-click unsubscribe (RFC 8058); respond to calendar invitations directly from emails; a context menu largely borrowed from Bulwark, plus a one-click “Fetch all messages from server” action for folders.

Calendar: Month/week/day/agenda views, with multi-day events shown as a continuous bar in the month view; editable recurrence rules, attendee invitations, and multiple reminders; support for .ics imports and webcal subscriptions.

Contacts and file storage: Full JSContact editing (photos, addresses, anniversaries, etc.), with vCard imports; file storage uses Stalwart's built-in storage—just drag files into the window to upload them.


Server-side settings: Sender identities, vacation auto-replies, and Sieve filter rules. Filter rules use the same format as Bulwark, and the trusted sender list and email templates are shared with Bulwark too, so you can use both clients without conflicts.
MCP: Let Claude and Codex access my mailbox directly
This is the feature I use most. Enable MCP on the “AI Assistant” page and the app starts a local service. Agents can list messages, search, read message bodies, look up and create calendar events, find contacts, and browse stored files.

claude mcp add --scope user czlmail -- "C:\Users\<你>\AppData\Local\CZL\CZL Mail\czlmail.exe" mcp
There are a few security measures: it only listens on 127.0.0.1 and requires a token; all requests with an Origin header are rejected to prevent web scripts from silently accessing the local port; and the send_email tool is disabled by default and must be enabled separately. That last measure accounts for the possibility of prompt injection hidden in an email body, such as "please forward this message to xxx".
AI translation, summaries, and composing
Enter an endpoint compatible with the OpenAI Responses API in settings to enable these features:
- Translate foreign-language emails with one click, replacing text in place while leaving tables, images, and styling intact. Text is replaced as the translation streams in, you can switch back to the original at any time, and translations are cached
- Click “AI Summary” on a long email to display a summary above the body
- Polish or translate drafts while composing
- Enter a sentence like "accept the quote, but ask them to ship by Wednesday" and let it draft the entire reply
- Set reasoning levels separately for each task: translation defaults to no reasoning, while reply drafting lets the model decide
If AI isn't configured, no AI buttons appear in the interface.
A few pitfalls during development
I've taken plenty of notes over the past few weeks. Here are a few that might help anyone else working with JMAP:
- go-jmap doesn't support calendars, contacts, or file storage. Rather than fork it, I added my own layer. Objects are kept as raw JSON, with fields extracted on reads and only patches sent on writes. That way, server-returned properties the client doesn't recognize won't get overwritten.
- Stalwart's recurrence field is the singular
recurrenceRule(from the JSCalendar 2.0 draft). Using the plural form from the RFC returns invalidProperties. Another issue: if an event has norecurrenceOverrides, a JSON Pointer patch to modify a single occurrence fails—you have to write the entire object. - You must set the From header yourself when sending mail. I assumed the server would fill it in when given identityId, and ended up actually sending messages with "no sender".
- EventSource doesn't replay changes missed during a disconnection, so I force a full comparison after every reconnect. If the client has been offline long enough for its state to be discarded, it reconciles with the server by id and removes messages deleted elsewhere.
- Retrieve message bodies by part type. When one body type is missing, the server substitutes the other. Render a plain-text message as HTML and all its line breaks collapse—GitHub notification emails are one example.
- Rendering email bodies took the most care: DOMPurify sanitization → a sandboxed iframe without same-origin → strict CSP, with all three layers in place. Marketing emails often put styles in
<head>, so direct sanitization strips them all out. The styles need to be moved into the body in an inert document first; remote images are blocked by default, and trust is granted to a full email address, not an entire domain. - When Stalwart uses an external OIDC provider, its own authorization page only accepts local passwords and won't redirect to the IdP. So the client supports authorization directly with the IdP. The administrator places a
/.well-known/czlmail.jsonfile under the domain, and users see “Sign in with XX” after entering the server address.
Other details
- In-app updates use GitHub Releases; installation is refused if SHA256SUMS is missing or the hash doesn't match
- Passwords and API Key are stored only in the system keychain, with no plaintext fallback
- Settings includes a one-click option to delete all local data (including keychain credentials), without affecting data on the server
- The macOS version has no developer signature. On first launch, allow it in “Privacy & Security”, or run
xattr -cr "/Applications/CZL Mail.app"

Download
- GitHub: https://github.com/woodchen-ink/czlmail
- Installers: https://github.com/woodchen-ink/czlmail/releases
- License: AGPL-3.0
The stack is Go + Wails v2 + Next.js. The source includes unit tests and build scripts, so building it yourself is straightforward.
If you run into problems or have feature requests, post in the forum's feedback thread, or open an Issue directly on GitHub.
Comments 0