Skip to content

Repository files navigation

jmap-rust

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.

Architecture

┌────────┐          ┌──────────────────────┐       ┌──────────┐
│ 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:

  1. Bulwark sends GET /.well-known/jmap → session doc.
  2. Bulwark sends POST /api with a batch of JMAP method calls.
  3. jmap-server authenticates the bearer token, dispatches each call.
  4. Method handlers query the PostgreSQL cache (envelopes, flags, folders).
  5. If Email/get requests bodyValues for an uncached message, the handler sends a FetchBody request through an mpsc to the per-account sync task.
  6. The sync task exits IDLE, does UID FETCH <uid> (BODY.PEEK[]), parses with mail-parser, writes to raw_messages, and signals back.
  7. The handler reads the cached body, builds the JMAP Email object, returns.

Install

cargo build --release
# produces target/release/jmapper

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

Development

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.

Gmail authentication

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.

Config

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 587

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

Optional OAuth bootstrap (Gmail)

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

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

Run

jmapper --config jmapper.toml run
# or just:
jmapper --config jmapper.toml

jmapper logs to stderr via tracing. Set JMAPPER_LOG=debug for verbose per-request output; JMAPPER_LOG=imap_sync=trace for IMAP wire-level noise.

Operational endpoints

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.

Hot reload (SIGHUP)

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.

Deploy targets

  • NixOS: nix/module.nixservices.jmapper.* options; keeps secrets out of the Nix store via accountsFile.
  • CI: .github/workflows/ci.yml runs cargo fmt/clippy/test/build + optional cargo-deny advisories scan.

Point Bulwark at it

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 \
bulwark

Ensure 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_token you generated

What's implemented

  • Reading: Mailbox/get/query/changes, Email/get/query/queryChanges/changes (including collapseThreads, anchor pagination, all header:* property forms, and bodyStructure), Thread/get/changes (RFC 5322 References / In-Reply-To threading, upgraded to Gmail's X-GM-THRID when the server advertises X-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, mailboxIds patches), destroy. Email/import. Mailbox/set — create/rename/destroy (rename keeps the JMAP mailbox id stable across the IMAP RENAME).
  • Sending: EmailSubmission/set relays over SMTP (implicit TLS or STARTTLS; AUTH PLAIN or XOAUTH2 with the same token as IMAP) and honors onSuccessUpdateEmail / onSuccessDestroyEmail with the implicit Email/set response the spec requires. Delayed send (sendAt up to maxDelayedSend = 7 days) queues the message with its bytes staged, a background scheduler relays it when due, and undoStatus: "canceled" withdraws it any time before the relay claims it. deliveryStatus carries the smarthost's real accept reply per recipient. EmailSubmission/get/query/changes, Identity/get, Quota/get, VacationResponse/get round 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, and ContactCard get/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 over inMailbox, inMailboxOtherThan, before/after, minSize/maxSize, subject, from, to, cc, bcc, text, body, hasKeyword, notKeyword, hasAttachment, and the thread-scoped allInThreadHaveKeyword / someInThreadHaveKeyword / noneInThreadHaveKeyword. Sorting by receivedAt, sentAt, size, subject, from, to; position or anchor/anchorOffset pagination.

Limitations

  • The body filter 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 header filter (arbitrary header search) returns unsupportedFilter.
  • 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 return cannotCalculateOccurrences.
  • 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 = true is rejected without mutation, and utcStart/utcEnd are read-only computed properties.

License

MIT OR Apache-2.0, at your option. Same as jmap-perl.

About

Map IMAP, SMTP, CalDAV, and CardDAV accounts into JMAP.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages