A small, single-user JMAP server that wraps IMAP accounts (Gmail + generic IMAP) and serves enough of RFC 8620 (core) and RFC 8621 (mail) for a browser JMAP client — e.g. Bulwark webmail — to list mailboxes and read messages.
This is a Rust replacement for jmap-perl.
It is not a line-by-line port: we reuse the Perl project's shape (two-layer
cache, Gmail-label-as-mailbox projection, UIDVALIDITY-aware reconciliation)
but redesign around async IMAP and idiomatic Rust. Storage is PostgreSQL;
the typed query layer is generated by
cornucopia from queries/*.sql
validated against the live schema.
Status: feature-complete for mail, with optional CalDAV/CardDAV-backed JMAP Calendars and Contacts. Mail supports flags, moves, deletes, folder create/rename/delete, compose, and SMTP submission. DAV resources support cached reads, queries, changes, and remote-first create/update/move/delete. Push uses EventSource (SSE). Gmail + generic IMAP, CalDAV, and CardDAV servers are supported.
┌────────┐ ┌──────────────────────┐ ┌──────────┐
│ Bulwark│ HTTPS/CORS │ jmapper (this crate) │ │ Gmail │
│ (JS) │◀─────────▶│ ┌────────────────┐ │IMAPS │ Fastmail │
│ │ JMAP │ │ axum /api │ │ │ … │
└────────┘ │ │ session doc │ │ └──────────┘
│ │ bearer auth │ │
│ └────────▲───────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ PostgreSQL │ │
│ │ (shared pool) │ │
│ └────────▲────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ per-account │ │
│ │ IMAP + DAV │ │
│ │ sync tasks │ │
│ └─────────────────┘ │
└──────────────────────┘
Crates in the workspace:
| crate | role |
|---|---|
jmap-protocol |
serde types for RFC 8620 / RFC 8621 requests & responses |
imap-sync |
async-imap client, PostgreSQL cache, per-account sync loop |
dav-sync |
CalDAV/CardDAV transport, conversion, cache, and sync engine |
jmap-server |
axum HTTP layer: session doc, /api, auth, CORS |
jmapper |
CLI binary: loads TOML config, spawns sync tasks, runs server |
jmapper-codegen |
cornucopia-generated typed statements (from queries/*.sql) |
Data flow for a read request:
- Bulwark sends
GET /.well-known/jmap→ session doc. - Bulwark sends
POST /apiwith a batch of JMAP method calls. jmap-serverauthenticates the bearer token, dispatches each call.- Method handlers query the PostgreSQL cache (envelopes, flags, folders).
- If
Email/getrequests bodyValues for an uncached message, the handler sends aFetchBodyrequest through an mpsc to the per-account sync task. - The sync task exits IDLE, does
UID FETCH <uid> (BODY.PEEK[]), parses withmail-parser, writes toraw_messages, and signals back. - The handler reads the cached body, builds the JMAP Email object, returns.
cargo build --release
# produces target/release/jmapperRuntime dependency: a PostgreSQL server (16+ tested). On first startup,
jmapper initializes an empty database from schema.sql; the configured role
needs to own its database. The NixOS module provisions a peer-authenticated
local database automatically.
nix develop provides cargo, postgres, and the cornucopia CLI. Local helper
scripts are intentionally untracked; use cargo test for the standard suite
and regenerate crates/jmapper-codegen with Cornucopia after schema or query
changes.
DB-backed tests skip when JMAPPER_TEST_DB_URL is unset;
JMAPPER_REQUIRE_DB_TESTS=1 (used in CI) turns skips into failures.
The shortest setup uses a Google app password. Create one at
https://myaccount.google.com/apppasswords and put it in [accounts.gmail].
jmapper accepts the displayed spaces and removes them before login.
OAuth remains available if preferred. Create a Google OAuth Desktop client,
then use client_id and client_secret instead of app_password and run the
bootstrap command below.
See jmapper.example.toml for a starter. The minimum is:
[server]
bind = "127.0.0.1:8765"
session_url = "http://127.0.0.1:8765" # what is advertised as apiUrl/etc.
cors_origins = ["http://localhost:3000"] # Bulwark origin(s)
database_url = "host=/run/postgresql dbname=jmapper"
dav_sync_interval_seconds = 60
[[accounts]]
id = "me-gmail"
email = "you@gmail.com"
display_name = "Personal"
provider = "gmail"
bearer_token = "REPLACE" # `openssl rand -hex 32`
backfill_days = 0 # full history
[accounts.gmail]
app_password = "abcd efgh ijkl mnop"Clients with a username/password login form use the account email as the
username and bearer_token as the password. The upstream Gmail app password
belongs only in jmapper's config.
For a generic IMAP account (Fastmail, Proton Bridge, Dovecot, …):
[[accounts]]
id = "me-fastmail"
email = "you@fastmail.com"
display_name = "Work"
provider = "imap"
bearer_token = "REPLACE"
[accounts.imap]
host = "imap.fastmail.com"
port = 993
tls = "implicit" # or "starttls" for port 143
username = "you@fastmail.com"
password = "app-specific-password"
# Optional; required if you want EmailSubmission/set (sending) on a generic
# IMAP account. Gmail accounts default to smtp.gmail.com:465 automatically.
[accounts.smtp]
host = "smtp.fastmail.com"
port = 465
tls = "implicit" # or "starttls" for port 587CalDAV and CardDAV are optional per account. Authentication may be basic,
bearer, or none; credentials must be separate from the URL. For example:
[accounts.caldav]
url = "https://dav.example.com/"
auth = "basic"
username = "you@example.com"
password = "app-specific-password"
[accounts.carddav]
url = "https://dav.example.com/"
auth = "bearer"
token = "DAV_ACCESS_TOKEN"Bearer credentials are currently static. If the DAV provider issues
short-lived OAuth access tokens, refresh them externally and reload jmapper;
the Gmail IMAP OAuth bootstrap does not refresh these separate DAV fields.
Set server.dav_sync_interval_seconds = 0 to disable periodic DAV refreshes
while retaining on-demand initial sync and JMAP writes.
backfill_days = 0 (the default) ingests the full folder history. Set a
positive day count to bound the first sync. Switching a partially synced
account to zero safely fills the older UID range without discarding its cache.
For OAuth, replace the app-password table with:
[accounts.gmail]
client_id = "…apps.googleusercontent.com"
client_secret = "…"Run this on a machine with a browser (your laptop, not the headless server):
jmapper bootstrap --account me-gmail --config jmapper.tomlIt binds http://127.0.0.1:<ephemeral>, opens the Google auth page, and on
success writes the refresh token into the oauth_tokens table. It also
prints the refresh token to stdout so you can seed a remote database when
bootstrapping from a different machine.
jmapper --config jmapper.toml run
# or just:
jmapper --config jmapper.tomljmapper logs to stderr via tracing. Set JMAPPER_LOG=debug for verbose
per-request output; JMAPPER_LOG=imap_sync=trace for IMAP wire-level noise.
These bypass the bearer-auth layer so a local Prometheus / monitoring agent can scrape without a credential. Lock them behind nginx / firewall if the listener is exposed.
| endpoint | purpose |
|---|---|
/healthz |
always 200 "ok" — liveness. Useful for orchestrator probes. |
/readyz |
200 once every account has ingested ≥1 message; 503 otherwise. |
/metrics |
Prometheus text exposition: accounts, cached envelopes/bodies, HTTP request counts, body fetch outcomes, account-task reconnect count. |
kill -HUP $(pidof jmapper) re-reads the config and reconciles accounts:
- removed accounts are stopped (Shutdown sent to the task, join awaited);
- new accounts have their sync task spawned;
- accounts with changed credentials (
bearer_token, email, Gmail auth, IMAP/SMTP/DAV endpoint or credentials, etc.) are stopped and respawned.
Changes to [server] — bind, session_url, cors_origins,
database_url — are not hot-reloaded. Restart jmapper for those.
The NixOS service wires ExecReload to SIGHUP, so systemctl reload jmapper
is the intended UX.
- NixOS:
nix/module.nix—services.jmapper.*options; keeps secrets out of the Nix store viaaccountsFile. - CI:
.github/workflows/ci.ymlrunscargo fmt/clippy/test/build+ optionalcargo-denyadvisories scan.
Bulwark reads its endpoint from JMAP_SERVER_URL. Also set
ALLOW_CUSTOM_JMAP_ENDPOINT=true so it connects to a non-Stalwart backend:
JMAP_SERVER_URL=https://mail.example.com \
ALLOW_CUSTOM_JMAP_ENDPOINT=true \
bulwarkEnsure cors_origins in jmapper.toml includes Bulwark's origin, and that any
reverse proxy in front of jmapper propagates the Authorization header.
On Bulwark's login screen:
- Server URL:
https://mail.example.com - Username:
you@gmail.com(or any label) - Password: the
bearer_tokenyou generated
- Reading:
Mailbox/get/query/changes,Email/get/query/queryChanges/changes(includingcollapseThreads, anchor pagination, allheader:*property forms, andbodyStructure),Thread/get/changes(RFC 5322 References / In-Reply-To threading, upgraded to Gmail'sX-GM-THRIDwhen the server advertisesX-GM-EXT-1),SearchSnippet/get, blob download (whole messages and individual MIME parts), blob upload. - Writing:
Email/set— create (compose: textBody/htmlBody + uploaded-blob attachments), update (keywords,mailboxIdspatches), destroy.Email/import.Mailbox/set— create/rename/destroy (rename keeps the JMAP mailbox id stable across the IMAP RENAME). - Sending:
EmailSubmission/setrelays over SMTP (implicit TLS or STARTTLS; AUTH PLAIN or XOAUTH2 with the same token as IMAP) and honorsonSuccessUpdateEmail/onSuccessDestroyEmailwith the implicitEmail/setresponse the spec requires. Delayed send (sendAtup tomaxDelayedSend= 7 days) queues the message with its bytes staged, a background scheduler relays it when due, andundoStatus: "canceled"withdraws it any time before the relay claims it.deliveryStatuscarries the smarthost's real accept reply per recipient.EmailSubmission/get/query/changes,Identity/get,Quota/get,VacationResponse/getround out the capability surface. - Push:
/eventsource(SSE, RFC 8620 §7.3) with per-type state change events. - Calendars and contacts: optional CalDAV/CardDAV discovery and incremental
sync;
Calendar,CalendarEvent,AddressBook, andContactCardget/changes/query/queryChanges/set methods; ParticipantIdentity and the empty notification collection required by the calendar capability. DAV writes use ETag preconditions and update the cache from authoritative remote bytes. - Filters on
Email/query: full AND/OR/NOT operator trees overinMailbox,inMailboxOtherThan,before/after,minSize/maxSize,subject,from,to,cc,bcc,text,body,hasKeyword,notKeyword,hasAttachment, and the thread-scopedallInThreadHaveKeyword/someInThreadHaveKeyword/noneInThreadHaveKeyword. Sorting byreceivedAt,sentAt,size,subject,from,to;positionoranchor/anchorOffsetpagination.
- The
bodyfilter searches lazily-cached bodies plus envelope previews, not a full mirror of the account — messages whose bodies were never fetched only match on their preview text. - The
headerfilter (arbitrary header search) returnsunsupportedFilter. - IDLE-only sync. jmapper refuses to start accounts on IMAP servers without IDLE capability (universal in 2026; Gmail / Fastmail / Dovecot all support).
- Single-user. Config may have multiple
[[accounts]]but there is no per-user isolation layer — each account's bearer token unlocks only that account, but everything shares one database. - Calendar date filters expand recurrence rules, exclusions, additions, and
overrides through calcard. Expanded query results with synthetic occurrence
ids remain unavailable (
maxExpandedQueryDuration = PT0S); pathological rules exceeding the expansion bound returncannotCalculateOccurrences. - Calendar/AddressBook collection creation, renaming, and deletion are read-only until the DAV layer supports MKCOL/PROPPATCH. Event/contact resources themselves are writable.
- No iTIP/iMIP scheduling, sharing/ACL model, managed attachments, or
VTIMEZONE synthesis. CalendarEventNotification is consequently an empty
collection.
sendSchedulingMessages = trueis rejected without mutation, andutcStart/utcEndare read-only computed properties.
MIT OR Apache-2.0, at your option. Same as jmap-perl.