JMAP feasibility — 2026-09
Scope: a research artifact assessing what it would take to add a JMAP server surface (RFC 8620 core, RFC 8621 mail, and the eight published extension RFCs) to this stack. It records what the specs demand, what the storage kernel can answer today, where the design collides with sealing mail to the recipient, how widely JMAP is actually deployed, and what a build would cost. The recommendations are inputs to a future planning decision, not decisions — nothing on this page changes code, and no adoption is settled by its appearing here. It is the structural sibling of RFC-updates recon — 2026-08 and the two dependency audits.
Method: the spec inventory came from jmap.io/spec/index.html and was
cross-checked against the IETF datatracker for the two documents still in
draft. Every requirement quoted below was read from the RFC text itself
(rfc-editor.org/rfc/rfc8620.txt, rfc8621.txt), not from a summary. Every
claim about this repo traces to a trait definition, migration or route read
on 2026-09-01; the two absence claims that carry the most weight were
established through scripts/verify-absent.sh with a passing positive
control rather than a bare grep, per the repo’s verification discipline.
Cost language is ordinal — no wave was planned and no scheduling analysis
was run.
The verdict in three sentences
JMAP is technically feasible here and the HTTP substrate is a genuinely good fit, but roughly half the work is not JMAP at all — it is backend capability the storage kernel has never needed, principally an account-scoped change log with tombstones. One part is not an engineering cost at all: JMAP assumes the server can parse every message, which for sealed-at-rest accounts is only true inside a live session holding a reading secret. That is a question about what SithBit is, and it has to be answered before any schema is designed.
The specification inventory
Ten JMAP documents are published as RFCs. Two more are still Internet-Drafts as of 2026-09-01, verified against the datatracker rather than trusting jmap.io’s own labelling.
| Document | Capability URN | Status |
|---|---|---|
| RFC 8620 — JMAP core | urn:ietf:params:jmap:core | Published |
| RFC 8621 — JMAP for Mail | urn:ietf:params:jmap:mail | Published |
| RFC 8887 — JMAP over WebSocket | urn:ietf:params:jmap:websocket | Published |
| RFC 9007 — MDN handling | urn:ietf:params:jmap:mdn | Published |
| RFC 9219 — S/MIME signature verification | urn:ietf:params:jmap:smimeverify | Published |
| RFC 9404 — Blob management | urn:ietf:params:jmap:blob | Published |
| RFC 9425 — Quotas | urn:ietf:params:jmap:quota | Published |
| RFC 9610 — Contacts (with RFC 9553 JSContact) | urn:ietf:params:jmap:contacts | Published |
| RFC 9661 — Sieve scripts management | urn:ietf:params:jmap:sieve | Published |
| RFC 9670 — Sharing | urn:ietf:params:jmap:principals | Published |
| JMAP for Calendars | not yet assigned | draft-ietf-jmap-calendars-28 |
| JSCalendar 2.0 | n/a (data format) | draft-ietf-calext-jscalendarbis-18 |
An eleventh published RFC belongs in any adoption discussion even though jmap.io does not list it, because it is an IMAP document: RFC 9698, the JMAPACCESS extension for IMAP (Standards Track, January 2025, authored at ICANN and Fastmail). It lets an IMAP server advertise that the same messages are reachable over JMAP with the same credentials — “intended for clients that want to migrate gradually to JMAP or use JMAP extensions within an IMAP client”. It matters here twice: it is the cheapest possible first step for a stack that already ships an IMAP server, and its existence is itself an adoption signal (see below).
How widely JMAP is actually used
Worth stating plainly, because it bears directly on whether the work is worth doing. JMAP is a real, finished, actively-extended IETF standard with a healthy implementation ecosystem — and it has almost no presence at the two places that would make it a compatibility requirement: large mail providers and mainstream mail clients.
Providers. Fastmail is the origin and the flagship: it offers full JMAP access alongside IMAP/POP/SMTP, and its people author most of the specs. Beyond it, deployment is essentially self-hosted. No hyperscale provider offers JMAP — Gmail, Outlook.com and Yahoo are IMAP-only for third-party access, and Proton’s JMAP request remains an open item on its public feedback forum, still being asked about in 2026.
Servers. The ecosystem here is genuinely healthy. jmap.io lists seven server implementations, of which the substantial ones are Stalwart (Rust, JMAP-native rather than bolted on), Apache James (Java, JMAP in the 3.x series), Cyrus IMAP (JMAP in its 3.x series), and atmail / Group-Office / shipmail / tmail-backend. Two proxies also exist in both directions — a JMAP server fronting an IMAP store, and an IMAP-to-JMAP proxy — which is a fair indicator of where the demand actually sits.
Clients. This is the weak link, and it is the one that matters for the
interoperability argument. jmap.io lists seventeen clients, and the list is
almost entirely FOSS and niche: aerc, meli, Ltt.rs, Sterna Mail, Aria,
Boogie, Cypht, Bulwark, Twake, Mailtemi, Pimsync. Thunderbird, Apple Mail,
Outlook and the Gmail apps are all absent. Thunderbird has JMAP on its
public roadmaps — iOS exploration reported around 80% complete, Android in
exploration, desktop planned after Exchange — but in every case IMAP is being
implemented first and JMAP is explicitly deferred; Thundermail (the Pro
service) is stated to support it from launch. Fifteen client libraries and
SDKs exist across Go, Java, TypeScript, Rust, Python and Perl, so building
a JMAP client is easy. That is not the same as users already having one.
The honest read. JMAP’s install base today is Fastmail plus self-hosters,
and RFC 9698 exists precisely because the migration path everyone expects is
gradual and IMAP-anchored. For SithBit the practical consequence is that
adding JMAP would not in the near term let a user point Thunderbird or
Apple Mail at a mailbox and have it work better — those clients would still
use IMAP. What it would buy is a standards-shaped replacement for the
bespoke /v1/mail REST API that the webmail PWA, the Outlook add-in and the
Thunderbird and Chrome extensions all ride today, and a future-proofed
position for when the mainstream clients do land their JMAP support. Whether
that is worth the cost below is a product judgement, not a technical one.
What a conforming server owes
JMAP is JSON over HTTPS with essentially no ABNF; the difficulty is entirely in the data-model contract, and it lands in three places.
The Session resource (RFC 8620 §2) has a fixed shape: capabilities,
accounts, primaryAccounts, username, apiUrl, downloadUrl,
uploadUrl, eventSourceUrl, state. Three of those are URI Templates
whose variable lists are MUST-carry — downloadUrl must contain
accountId, blobId, type and name; eventSourceUrl must contain
types, closeafter and ping.
The API endpoint takes a batch of methodCalls triplets and returns
methodResponses. Back-references (#-prefixed arguments resolved by JSON
Pointer, plus JMAP’s * flatten rule) let call N consume call N−1’s
actual result — which forces the dispatcher to be a strictly sequential
interpreter over a growing response buffer, never a parallel fan-out.
Six method archetypes apply to every data type: Foo/get,
Foo/changes, Foo/set, Foo/copy, Foo/query, Foo/queryChanges. The
sharp edges are in the archetypes rather than the types. Foo/set is atomic
per record and explicitly not per call, so a batch half-commits into
notCreated/notUpdated/notDestroyed without poisoning what already
landed; its update argument is a PatchObject whose JSON-Pointer paths may
not overlap or point inside an array, with null meaning reset-or-delete;
queryChanges models a result-set delta as an ordered removed/added
list, and its upToId shortcut is sound only when both filter and sort touch
exclusively immutable properties.
The load-bearing requirement is the state-string contract. Each type carries
an opaque state string covering, in the RFC’s words, “all the data of this
type in the account”. If the data changes it MUST change; if not, the server
SHOULD return the same one. Foo/changes then reconstructs from any such
past string which of three buckets each id falls in, with defined collapse
rules for create-then-update, update-then-destroy and create-then-destroy.
RFC 8620 §5.2 is blunt about the escape hatch:
Maintaining state to allow calculation of “Foo/changes” can be expensive for the server, but always returning “cannotCalculateChanges” severely increases network traffic and resource usage for the client. To allow efficient sync, servers SHOULD be able to calculate changes from any state string that was given to a client within the last 30 days.
A server may legally answer every Foo/changes with cannotCalculateChanges
and force a full resync. It would also have discarded the entire reason to
prefer JMAP over the REST API that already exists.
The capability gap
Eleven capabilities checked against the workspace on 2026-09-01.
| Capability | Status | What is there today |
|---|---|---|
Per-account, per-type state + Foo/changes | absent | Two per-mailbox counters, both solid: MailboxRow.highest_modseq (RFC 7162) and change_seq for IDLE. Neither is account-scoped; MailRepo has no “changes since N” method, and IMAP CHANGEDSINCE is an in-memory filter over an already-materialised snapshot (imap_session/src/session/selected.rs). |
| Destroyed-id recovery (tombstones) | absent | MailRepo::expunge deletes the row. Sixteen tables in the schema, none an expunge log. Already a known gap — BACKLOG.md’s QRESYNC entry lists “expunged-UID tombstones” as remaining work. |
Immutable per-message id, mailboxIds as a set | absent | Identity is per copy: MessageRow exposes no id, and copy mints a fresh row (MESSAGE_COPY_ONE) while move_messages preserves one. blob_key is the only cross-mailbox handle, and it is shared across wallets on a multi-recipient delivery — so it cannot be Email.id unmodified without leaking identity between accounts. |
| Threading | absent | No thread ids, no RFC 5256/JWZ threading, no IMAP THREAD. Message-ID / In-Reply-To / References are re-parsed per request into mail_message’s MailSummary. The one persisted Message-ID index, reply_locators, exists for on-chain reply bounties and is never joined to messages. |
| Server-side body search | partial | Real but unindexed: GET /v1/mail/search scans 500 rows newest-first in one mailbox with a resume cursor; imap_session/src/search.rs parses lazily per message. No FTS index anywhere. Email/query is cross-mailbox, sortable and delta-able on top of that. |
| Blob download | present | Three routes already serve bytes, including /v1/mail/messages/{uid}/parts/{part} with a decoded content type and filename. |
| Blob upload | absent | Nothing accepts bytes. Compose has no attachments field, the global body cap is a deliberate 2 MiB constant, BlobStore has no ranged read and stores no content type, and blob keys are global UUIDs with no wallet scoping — a blobId namespace needs authz designed, not just exposed. |
| Push transport | partial | In-process WatchRegistry plus a 2-second change_seq poll for split deployments. Verified absent across the server crates: no SSE, no text/event-stream, no WebSocket. JMAP wants an EventSource endpoint and outbound PushSubscription webhooks — the latter a new outbound-caller class in the security model, with an SSRF guard the RFC mandates. |
| Queued / undoable submission | absent | Spooling is immediate; local rows are visible to IMAP before the response returns. No EmailSubmission object, no undoStatus, no sendAt. The job queue’s visible_at column is the mechanism a delayed send could ride. |
| Identities and vacation responder | absent | Send-as is hardwired to wallet@<first local domain>; chain aliases are readable but not sendable-as, and alias logins were rejected on record. The DND/away schedule is an advisory signal for senders and never sends an auto-reply, which is what VacationResponse means. |
| HTTP + auth substrate | present | axum 0.8, six independently-composed routers merged in main, and SessionAuthed yields wallet + jti + reading secret in one decode. Two frictions: the 2 MiB body constant needs a route-scoped override, and the 413→422 remap in error.rs would fight JMAP’s own urn:ietf:params:jmap:error:limit shape. |
Two of those rows are the whole schedule. An append-only change log per
account and type, retained around 30 days, is new schema on all six storage
backends and a hard prerequisite for both Foo/changes and any push payload,
since a StateChange is a map of per-type state strings. Query-state
tracking for queryChanges is a different problem again — not “did this
record change” but “did the order of an arbitrary client-defined filter and
sort change”, answered incrementally. Nothing in IMAP SEARCH/SORT is an
analogue.
One prior decision bears on this and should be read carefully. RFC 8474
(OBJECTID) — the IMAP extension that would have supplied exactly the stable
EMAILID/THREADID the table twice calls absent — is recorded declined,
final in the RFC-updates recon, which even
names it “the JMAP-era resync feature set”. But the stated reason was that it
is fork-blocked on imap-types, and that reason does not transfer to JMAP,
which would touch none of those crates. The decline is not a precedent
against this work. It does mean the underlying storage capability has never
been built.
The collision: sealed bodies against a spec that parses everything
This is the part that is not an estimate. RFC 8621’s Email is a parsed
object: the server is expected to hand clients typed headers, a MIME body
structure, attachment part lists and a text preview. Its Security
Considerations never contemplate a server holding ciphertext it cannot open;
that case is outside the document’s threat model.
SithBit’s at-rest storage is per-account dual-mode, resolved at spool time by
mail_submit’s at_rest_mode, into AtRest::Plaintext, AtRest::Sealed
or AtRest::OperatorSealed. So “can the server parse this message” is
answered per account, not per deployment. A plaintext-mode account is fully
parseable and the JMAP work over it is ordinary. A sealed account’s body is
an SBd DEK envelope the daemon can open only by unwrapping with a
reading secret — the 32-byte value that arrives with the login, lives in
memory keyed by the JWT’s jti, is zeroized on drop, and dies with the
session.
The consequence is narrower than “JMAP cannot work”, and worth stating
precisely. Inside a live keyed session the server can decrypt, and
Email/get can be served. What is impossible is anything outside such a
session: no background index, no server-side full-text search, no threading
pass over historical mail, and no push payload richer than “something
changed”, because the worker computing it holds no reading secret. One
nuance decides how much this bites: Email/get’s default property list
includes textBody, htmlBody, attachments, hasAttachment and
preview, so a bare Email/get needs the MIME structure even though body
text only ships when the caller sets fetchTextBodyValues. Structure, not
just content, sits behind the seal.
Three ways through, in ascending cost. This is a decision, not an estimate, and it gates the change-log design — a schema built on the assumption that a background worker may read message content is a different schema.
- Serve JMAP for plaintext-mode accounts only. Advertise the mail
capability per account, exactly as RFC 8620’s
accountCapabilitiesis designed to allow, and omit it for sealed accounts, who keep the trustless webmail path that already unseals in the page. Honest, conformant, and it ships — at the price of JMAP being a second-class surface for the accounts the product pitch is built around. - Keep headers outside the seal. Seal only the body. Threading,
Email/queryover from/to/subject, sorting and most of the default property list all become computable in the background. This narrows what sealing protects, and is a privacy-posture change owing its own decision — metadata is most of what surveillance wants. - Client-side index, stored sealed. The keyed client builds the search and thread index and republishes it sealed; the server stores and serves an artifact it cannot read. Preserves the guarantee completely, and is a materially different architecture from “the server implements JMAP” — closer to a sync protocol wearing a JMAP-shaped façade.
Build or adopt
Standing rule 1 is ecosystem-first, so the crate landscape decides much of the cost. Checked live against the crates.io API on 2026-09-01.
| Crate | Version | Downloads | Verdict |
|---|---|---|---|
jmap-server + jmap-mail-server (MarkAtwood/crate-jmap) | 0.1.3 | 444 / 108 | Backend-agnostic, MIT/Apache-2.0, 26 RFC 8621 methods plus optional MDN. The right shape; first published 2026-05. |
mailrs-jmap (goliajp/mailrs) | 1.1.3 | 761 | Store trait is IMAP-shaped and would nearly drop in here — but it implements seven methods and no Foo/changes or Foo/queryChanges at all, which is precisely the half worth having. |
jmap-client (stalwartlabs) | 0.4.2 | 73,688 | Mature, widely used, and a client. Useful for conformance testing, not for serving. |
| Stalwart’s own server JMAP | — | — | Inside the Stalwart monorepo, not published standalone. A reference implementation to read, not a dependency. |
The crate-jmap family is the only credible adoption target, and its
integration surface is small enough to quote in full. JmapBackend requires
six methods — account_exists, get_objects, get_state, get_changes,
query_objects, query_changes — and MailBackend adds ten:
create_object, update_object, destroy_object, import_email,
find_thread_by_message_ids, blob_exists, parse_email, copy_email,
search_snippets, supports_type.
Sixteen methods is a tractable adapter, but note which ones. get_state,
get_changes, query_changes, find_thread_by_message_ids and
search_snippets are all capabilities the gap table marks absent. The
crate does not reduce the backend work; it removes the protocol work and
leaves the backend work fully intact. That is still worth a great deal —
the dispatcher, back-reference resolution, patch semantics and 26 method
handlers are the fiddly, conformance-critical half — but it should not be
mistaken for a shortcut past the schema.
Two frictions belong in any adoption record. jmap-mail-server depends on
mime-tree, a different MIME parser from the adopted mail-parser, so
taking it means either two parsers in the tree or an adapter that skips the
crate’s own. And at 108 downloads, four months old, single-author and pre-1.0
with an explicit “may break across minor versions” note, this is a larger bet
than imap-codec was: the pinned-alpha precedent exists, but that crate had
an established author and a large user base.
The extensions, ranked
Costs assume core and mail already work — the assumption doing most of the work in this table, since today neither does.
| RFC | Cost | For a mail-only server |
|---|---|---|
| 9007 MDN | small | Best value. Its dependencies are the Identity and parse plumbing mail already needs. |
| 8887 WebSocket | small | Valuable, and cheap once push exists — but push does not. |
| 9425 Quotas | small | Valuable; MailRepo::wallet_bytes already computes the number, which needs a per-account read route. |
| 9404 Blob management | medium | Valuable, and gated behind building the upload path at all. |
| 9749 VAPID push | medium | Webmail-PWA only, and large once Web Push infrastructure itself is counted. |
| 9670 Sharing | large | Out of scope — needs a principals/ACL model that does not exist. |
| 9219 S/MIME verification | large | Out of scope near-term; S/MIME is a separate product line here. |
| 9661 Sieve management | very large | Out of scope. The JMAP surface is the cheap part; the Sieve interpreter is the project. |
| 9610 + 9553 Contacts | large | Out of scope for mail. |
| Calendars + JSCalendar 2.0 | very large | Out of scope; both still drafts, on a data model that does not exist. |
A defensible ordering, if it goes ahead
The dependency structure is real — nothing after the first item is worth starting before it lands, because everything later consumes the change log.
- The change log, alone, with no JMAP in the tree. Account-scoped, per-type, append-only, tombstoned, ~30-day retention, across all six backends, with the existing counter-allocation conformance group extended to cover it. Independently valuable: it also unblocks part of the QRESYNC wave, which is stalled partly on the same missing tombstones. If only one thing is ever built from this page, this is it.
- Message identity and threading. A stable per-message id distinct from
(mailbox, uid)and namespaced per wallet, plus a thread id computed at spool time — the patternSealedPin.parent_rfc822_idalready establishes for sealed rows. - The JMAP core surface. Session resource, API endpoint, back-reference
resolution,
/getand/setover Mailbox and Email. - Upload/download and blob authz. Wallet-scoped
blobIds, a route-scoped body limit, ranged reads, a stored content type. This is what finally lets webmail send an attachment. - Query and queryChanges. The hardest piece, and the one most affected by the sealing decision.
- Push. EventSource first; PushSubscription webhooks with the SSRF guard only if a client actually needs them.
Deliberately excluded: identities, vacation responder, submission objects, and every extension except possibly MDN — real work with no dependents.
There is also a much cheaper first move that is not on this list: RFC 9698 JMAPACCESS advertises a JMAP endpoint from the existing IMAP server. It is worthless without a JMAP endpoint to advertise, so it cannot come first, but it is the natural closing step of any JMAP program here and costs almost nothing once one exists.
The decisions this page does not make
- Why JMAP, concretely? The strongest case is not standards coverage but
that every SithBit client today rides a bespoke
/v1/mailREST surface. Given the adoption picture above, JMAP would not in the near term make Thunderbird or Apple Mail work better against a SithBit mailbox — those clients still speak IMAP. If third-party interoperability is not actually wanted, the cost/benefit inverts sharply. - Which sealing posture? The three options above. This gates the schema design and cannot be deferred into the build.
- Is the change log worth building on its own merits? It is the largest item, a prerequisite for everything else, and valuable independently of JMAP — it may deserve to be a work item whether or not JMAP is adopted.
- Adopt a four-month-old, 108-download crate family, or hand-roll?
Ecosystem-first is standing rule 1 and a pinned-pre-release precedent
exists, but that precedent carried a different risk profile. Either way it
would need its own adoption-table row in
DURABLE-RECORD.md.
Known softness
The RFC 8621 analysis behind the collision section was assembled through a
summarizing fetch tool that caps verbatim quotes, so a few of its finer
points are paraphrase-confidence rather than citation-grade. The
classifications it drove are sound and the load-bearing ones were re-read
from the RFC text directly — the Email/get default property list, the
§5.2 retention text, the Session field list and the cannotCalculateChanges
semantics. Its worked examples were not fully captured and should be
re-fetched narrowly before anyone uses them as test fixtures.