mailrs-jmap
Server-side JMAP (RFC 8620 + RFC 8621) dispatcher and method handlers for Rust mail servers — framework-agnostic, BYO mail store via the MailStore trait.
Extracted from mailrs so any project that wants to expose a JMAP API can do so without re-implementing the dispatcher, method-call envelope, back-reference resolver, or the per-method shape conversions for Email / Mailbox / Thread / EmailSubmission.
This is, at the time of writing, the only standalone server-side JMAP library on crates.io.
Highlights
- Framework-free — no axum / actix / tower / hyper. The crate hands you
(method, args, callId) → (method, result, callId)and stays out of your HTTP layer. - Store-free — implement
MailStore(9 async methods + 1 sync parser) once and every method handler works. - Method back-references —
#key: { resultOf, name, path }resolved before each dispatch (RFC 8620 §3.7). One round-trip forEmail/query→Email/get. - Standard error envelopes —
JmapMethodErrormaps to the canonical{"type": "serverFail", "description": "..."}shape from RFC 8620 §3.6.2. - Pure helpers exposed —
flags_to_keywords,keywords_to_flags,parse_email_db_id,resolve_references,build_email_meta,parse_address_list. Use the dispatcher or grab the pieces.
Methods covered (1.0)
| Method | RFC section | Notes |
|---|---|---|
Mailbox/get |
8621 §2.4 | All standard properties; role inferred from name (INBOX/Sent/Drafts/Trash). |
Mailbox/query |
8621 §2.5 | Unsorted, unfiltered — full list. |
Email/get |
8621 §4.4 | Header + body + attachments; respects properties selector to skip disk reads. |
Email/query |
8621 §4.5 | inMailbox filter, limit + position. |
Email/set |
8621 §4.6 | update (keywords / mailboxIds) + destroy. create is rejected as forbidden — use Email/import (1.1, roadmap). |
Thread/get |
8621 §3.4 | Returns emailIds in chronological order. |
EmailSubmission/set |
8621 §7.5 | create only — submits a previously-stored draft via your store's outbound path. |
Quick start
use async_trait;
use ;
;
# async
How it slots into axum
use Arc;
use ;
use ;
async
The store impl is yours. The mailrs server uses a thin adapter that bridges its PostgreSQL/Maildir row types into the JMAP shapes in mailrs_jmap::types — about 200 LOC, worth a read as a reference implementation.
Tested
1.0.2 ships 98 tests — 36 inline unit tests over the pure helpers (flag bitmask conversions, id parsers, address-list splitter, back-reference resolver, error-envelope shaping) and 62 protocol-level integration tests that drive every dispatched method through an in-memory MailStore and assert on the response JSON:
| Suite | Tests | Surface |
|---|---|---|
tests/mailbox.rs |
8 | Mailbox/get + Mailbox/query |
tests/email_get.rs |
7 | Email/get — metadata-only, body, attachments, ownership |
tests/email_query.rs |
10 | Email/query — filters, sort, pagination, store-error mapping |
tests/email_set.rs |
14 | Email/set — full keywords replace, patch dialect, destroy, every error path |
tests/thread_get.rs |
5 | Thread/get — ownership filtering, store-error fallback |
tests/email_submission.rs |
11 | EmailSubmission/set — success shape, all 5 documented failure modes |
tests/dispatch_request.rs |
7 | envelope shape, ordering, back-reference resolution, unknown method |
The in-memory fixture lives at mailrs_jmap::fixtures::InMemoryStore — same return contracts as a real backend, per-method error injection so a single test can isolate a specific failure path. As of 1.1.0 it is a pub module, so downstream consumers building their own dispatcher tests can use it directly without re-implementing one.
Benchmarks
1.0.3 ships 23 criterion benchmarks in two suites — pure-helper microbenchmarks plus async dispatcher benchmarks against an inline in-memory store. Useful both as a regression baseline and as a quick way to compare your own store impl's overhead against the dispatcher floor.
benches/jmap.rs — sync helpers and composition paths:
flags_to_keywords/keywords_to_flags— bitmask ↔ JMAP keywordsparse_email_db_id/parse_mailbox_db_id— id parsersparse_address_list—From:/To:splitterepoch_to_utc_string— RFC 3339 renderingresolve_references— back-reference resolverbuild_email_meta_*— Email/get header / metadata composition (include-all vs narrow selector)extend_with_body_*— body field composition (full payload vs raw-missing fallback)wants_body_*— selector branching
benches/dispatch.rs — dispatch_method / dispatch_request against a minimal in-memory store:
dispatch_mailbox_get/dispatch_mailbox_querydispatch_email_get_meta_only/dispatch_email_get_with_bodydispatch_email_query/dispatch_email_set_updatedispatch_thread_get/dispatch_email_submission_setdispatch_request_single_call— full envelope with one methoddispatch_request_multi_call_back_ref— canonicalEmail/query → Email/getflow with back-reference resolution
Run with cargo bench -p mailrs-jmap. To verify your store hasn't introduced surprise overhead, plug your impl into a copy of benches/dispatch.rs and compare against the in-memory floor.
Headline numbers
Measured with criterion 0.8 on Apple Silicon (M-series), cargo bench, release profile. Dispatcher numbers run against the in-memory store from benches/dispatch.rs, isolating framework overhead from real backend cost.
| Operation | Median | Notes |
|---|---|---|
keywords_to_flags(["$seen", "$flagged"]) |
~5.6 ns | JMAP keyword names → flag bitmask |
flags_to_keywords(0b11) |
~91 ns | reverse direction, allocates Vec<String> |
parse_email_db_id("M1") / parse_mailbox_db_id("M1") |
~2-3 ns | id-format parsers |
parse_address_list("Alice <a@x>, Bob <b@y>") |
~1.2 µs | full mailbox-list tokenizer |
epoch_to_utc_string(1_700_000_000) |
~26 ns | RFC 3339 rendering |
resolve_references(<one back-ref>) |
~520 ns | JSON-pointer-style ref resolver |
wants_body_no / wants_body_yes |
~10 ns / ~760 ps | selector branching |
extend_with_body_full |
~2.0 µs | with body + raw fields populated |
dispatch Mailbox/query |
~900 ns | full dispatcher with in-memory store |
dispatch Email/query |
~2.4 µs | with sort + filter on 10-message store |
dispatch Email/get (meta only) |
~1.7 µs | metadata-only properties |
dispatch Email/get (with body) |
~1.6 µs | full body parse + extend |
dispatch_request single call |
~3.5 µs | envelope + serialize + dispatch |
dispatch_request multi-call with back-ref |
~10.4 µs | the canonical Email/query → Email/get flow |
Numbers are in-process, in-memory; production cost is dominated by the store backend (PostgreSQL / network).
Roadmap
1.0 is the minimum viable surface — enough to drive a webmail client with read, search, mark-read, send, and delete. Methods explicitly not yet implemented, in rough priority order for 1.x:
Identity/get,Identity/set— RFC 8621 §6. Send-as identities.Mailbox/set— RFC 8621 §2.5. Create / rename / delete mailboxes.Email/import— RFC 8621 §4.8. Upload .eml.EmailSubmission/get,EmailSubmission/query— track / cancel pending submissions.VacationResponse/get,VacationResponse/set— RFC 8621 §8. Out-of-office.
These will land as MailStore trait extensions (additive — default impls or feature-gated) so existing 1.0 consumers don't break.
What's intentionally not in this crate
- The session endpoint (
/.well-known/jmap). It's a small JSON blob driven by your hostname, account address, and which capabilities you advertise — there's nothing to share. - Push notifications (EventSource / WebSocket). The wire format is fixed by RFC 8620 §7 but the event-source plumbing is too coupled to your runtime to share cleanly.
- JMAP-Contacts / JMAP-Calendars — different specs. See mailrs-dav for CalDAV / CardDAV.
- The HTTP / authentication / routing layer. That's the framework job; the dispatcher takes a pre-resolved
userand gives you back the response envelope to serialize however you like.
Versioning
1.x follows semver. The public API surface is:
MailStoretrait method signaturesJmapMethodErrorenum variantsJmapRequest/JmapResponsefield shapesdispatch_method/dispatch_requestsignatures- The
JMAP_*_CAPcapability URI constants
Helper-module internals (e.g. the exact JSON shape build::extend_with_body produces, or the per-method handler signatures inside methods::*) may evolve within a minor version; consumers should drive through the dispatcher unless they have a reason not to.
License
Licensed under either Apache License, Version 2.0 or MIT license at your option.