The session resource
A JMAP client starts by fetching the session resource. It is the single discovery document that tells the client everything it needs: which capabilities the server supports, which accounts the user can access, and the URLs for every other operation.Never hardcode endpoint paths
The three*Url fields are URL templates (RFC 6570): a client — especially a stateless integration such as a serverless worker or a cron script — must read them from the session and substitute the variables ({accountId}, {blobId}, …), never reconstruct the paths from memory of another server’s layout.
This is not theoretical. OxiMail’s upload endpoint is /jmap/upload/{accountId} with no trailing slash (strict routing); other JMAP servers use /jmap/upload/{accountId}/. A client that hardcodes the slashed form gets a 404 on every upload, and the bug reads like a server fault. Reading uploadUrl from the session makes the same client code work against any RFC 8620 server.
A stateless client does not need to fetch the session on every invocation:
GET /.well-known/jmaponce, cache the session object alongside itsstatestring.- On each run, use the cached templates directly.
- Every JMAP method response carries a
sessionState— when it no longer matches the cachedstate(or a templated URL starts returning404), refetch the session and replace the cache.
Core capability object also advertises the server’s hard limits, which a client should respect before sending a request:
A user can see more than one account in the session. Besides their personal account, any folder, calendar, or address book shared with them via JMAP Sharing appears as an extra account entry (keyed
shared:{ownerId}) carrying only the shared capabilities, and possibly isReadOnly: true.Authentication
OxiMail uses Bearer tokens (RFC 6750). Obtain a token by posting credentials to the login endpoint:Token lifetime and refresh
Tokens expire 24 hours after they are issued. To stay logged in without re-entering a password, refresh the token:{ "accessToken": "...", "accountId": "..." }. Past the 7-day grace period the refresh fails with 401 and the user must log in again.
Device-bound tokens (DPoP, RFC 9449)
A client can opt in to proof-of-possession at login by sending a DPoP proof (a signed JWT over an ephemeral ES256 key) alongside its credentials. The session is then bound to that key:POST /auth/refresh requires a fresh proof signed by the same key, so a stolen bearer token alone can no longer be refreshed.
Two server-side hardening layers extend this, both configurable and off by default:
- Server nonce (§8). Every auth response carries a
DPoP-Nonceheader. A proof that includes anonceclaim is always verified against it; a stale nonce answers401with the erroruse_dpop_nonceand a fresh nonce, and the client retries (the standard RFC 9449 dance). With[auth] dpop_nonce_required = truethe server additionally rejects nonce-less proofs, replacing the clock-window freshness check with server-controlled freshness. - Resource proof-of-possession (§7). With
[auth] dpop_resource = "require", a DPoP-bound session must send a proof on every request that presents its token in theAuthorizationheader, bound to that token via theathclaim — a stolen bearer then cannot call JMAP at all, not merely not refresh."monitor"logs whatrequirewould reject, for a safe rollout. Sessions that never bound a key, and app passwords, are unaffected. Thehtucomparison normalizes default ports per RFC 3986 §6.2 (an explicit:443on either side still matches).
<video>, <audio>) cannot carry a DPoP header, a bound session under require mints a short-lived delegated credential instead: POST /jmap/media-token (itself DPoP-protected) returns a 5-minute token scoped to exactly one blob, passed as ?media_token= in the element’s src. It is refused on any other route, for any other blob, and after expiry.
The ephemeral stream ticket
The browserEventSource API cannot set custom request headers, so it cannot send Authorization: Bearer. RFC 6750 §2.3 permits the token in the query string instead, and OxiMail still accepts ?access_token= on its two streaming routes — but a token in a URL reaches reverse-proxy access logs and anything else that records URLs. The supported way to open a stream is therefore a dedicated ephemeral ticket, minted by a normal header-authenticated call:
Cache-Control: no-store):
?stream_ticket= on the stream URL in place of the session token. What the ticket is:
- Short-lived — 60 seconds. It authorizes opening a stream, not the stream’s lifetime: a stream already connected is not closed when its ticket expires. The window only has to cover mint → connect, plus the browser’s own reconnection retry inside that span. A drop later than that needs an application-level re-mint, which a client has to implement anyway.
- Scoped to
(organization, account, device session), and accepted on/jmap/eventsourceand/jmap/push-patchesonly — presented anywhere else it is refused. Carrying the device session forward is not a detail:/jmap/push-patchesregisters its connection under that session id, and that binding is what makes the stream’s diff baselines the same ones the session’s/getresponses seeded. A stream that lost it would work, and silently send full values forever instead of patches. - Delegated possession, so it carries no proof of its own. It was minted by a call that went through the normal extractor with the token in a header (and, under
dpop_resource = "require", with a resource proof), which is why the ticket itself is exempt from that gate. It also carries no identity beyond its scope — the two stream handlers read the organization, the account and the session, and nothing else.
?media_token= credential described above is a different, deliberately narrower token — one blob, five minutes, /jmap/download only.
?access_token= remains accepted on the two streaming routes for compatibility, including for DPoP-bound sessions under dpop_resource = "require" (the browser API cannot send headers, so this is the one documented residual of the resource-proof gate). New clients should mint a ticket instead.Making requests
All method calls go to a single endpoint as one batched POST. The request body (RFC 8620 §3.3) has three parts:usingdeclares which capabilities this request relies on. A method whose capability is not listed inusingis rejected withunknownCapability— the method is never run “anyway”.methodCallsis an ordered array. Each call is a 3-element array:[methodName, arguments, callId]. ThecallIdis your own label, echoed back so you can match responses to calls.
methodResponses array, in the same order, each tagged with the matching callId:
A single bad id never disappears silently. Anything an
/get call cannot find comes back in notFound; OxiMail never drops unparseable or unknown ids on the floor.Back-references (result references)
The point of batching is that one call can feed the next within the same request, so you avoid a round trip. This is a back-reference, also called a result reference (RFC 8620 §3.7). Instead of a literal argument value, you pass an object with a# prefix on the argument name:
ids argument, take the result of call c0 (which must be an Email/query), and pull the value at JSON Pointer /ids.” The server resolves the reference from the first call’s result before running the second.
A back-reference must name the correct previous callId, the correct method name, and a valid path. If any of those is wrong, OxiMail returns invalidResultReference rather than substituting null or an empty list.
Capabilities
The server advertises what it can do through capability URNs in the sessioncapabilities map. The client then opts in to the ones it intends to use by listing them in the request using array. OxiMail advertises two families.
Standard JMAP (v1)
The IETF-standardized capabilities, using theurn:ietf:params:jmap:* namespace — Core, Mail (RFC 8621), Submission, Vacation Response, Sieve (RFC 9661), Quota (RFC 9425), Principals / Sharing (RFC 9670), Contacts (RFC 9610), Calendars, Files, and WebSocket (RFC 8887).
OxiMail modernized JMAP (v2)
OxiMail also exposes a set of modernized capabilities under theurn:oximail:params:jmap:v2:* namespace. These are OxiMail’s modernized JMAP extensions (Internet-Drafts in progress) and cover, among others:
See JMAP v2 modernized for the full surface.
Mixing v1 and v2 in one request
At v0.30.0 the v1 and v2 mail, contacts, and calendar surfaces are served by the same handlers through a dual-capability mechanism: a handler that advertises a v1 capability as its primary also accepts the matching v2 capability as an additional one. A request may therefore list both a v1 capability and its v2 counterpart inusing at the same time; the wire shape returned is the union of v1 and v2 properties, and the client reads whichever it wants via the properties argument on /get.
The protocol still keeps a mutual-exclusion check in place for future use, but at v0.30.0 the set of mutually-exclusive v1/v2 pairs is empty: nothing is rejected for pairing a v1 capability with its v2 counterpart. If a future v3 surface reintroduces a hard split, a request that pairs the two excluded URNs would be rejected with
unknownCapability.Blob download, and what a miss means
Blobs are fetched from thedownloadUrl template — /jmap/download/{accountId}/{blobId}/{name}?type={type} — with the Authorization header, or with a ?media_token= for a media element (see above).
One property of the storage model is visible here. For a mail message, the raw RFC 5322 blob is the single source of truth; the per-part blobs a client downloads are a derived cache of it. Attachment parts are materialized at ingest; body parts are not materialized at all. So a download for a part blob that is not on disk is normal, and the server answers it by re-deriving: it locates the owning message, loads and decrypts the raw server-side, re-parses it, extracts the matching part, verifies that the part’s cleartext hash equals the requested blobId, and serves the bytes with 200. Unverified bytes are never served.
When that cannot happen, the response says which case it is rather than a bare 404:
The distinction is the point: a client (or an operator reading logs) can tell “this blob never existed here” from “this blob should exist and the message behind it is gone”, which a single
404 conflated.
Re-derivation applies to requests for your own account’s blobs. The shared-account and peer-avatar download doors deliberately keep indistinguishable
404 semantics — they must not become an existence oracle — and never re-derive./set that names the blob, it is unreferenced by protocol (RFC 8620 §6.1). The server’s blob collector never reclaims an unreferenced blob younger than a 24-hour grace window, read from the file’s own timestamp; a timestamp that is unreadable or in the future keeps the blob rather than collecting it. Upload, take the time you need, reference afterwards.
Push: knowing when state changes
Every JMAP type tracks an opaque state string. When you callFoo/get, the response includes the current state. Later you can ask Foo/changes with the state you last saw, and the server tells you exactly which objects were created, updated, or destroyed since then. You never poll for full lists; you sync deltas.
To learn when to call Foo/changes, OxiMail pushes a small StateChange notification whenever a type’s state advances. There are two transports, both advertised in the session:
EventSource (Server-Sent Events)
A standard SSE stream at theeventSourceUrl:
StateChange events. Each event names the account and the collection whose state moved, plus the new state string. Use types=Email,Mailbox to filter to specific collections, ping= for the keep-alive interval, and stream_ticket= for browser authentication (see above). The stream is scoped to the authenticated account and organization — you only receive your own changes.
WebSocket
OxiMail also speaks JMAP over WebSocket (RFC 8887). The session advertises awss://.../jmap/ws URL with supportsPush: true, so a client that already holds a WebSocket connection can both send method calls and receive push on the same channel.
Either way, the pattern is the same: a push notification is a hint that some collection’s state changed; the client follows up with Foo/changes to fetch the actual delta.
The replay window and refused deltas
The change log is retained for 90 days, and that figure is published, not internal: theurn:oximail:params:jmap:v2:streaming capability advertises it as replayWindowSeconds, derived from the same constant the retention worker purges by, so the announcement cannot drift from the mechanism.
A client whose sinceState points into the purged span is refused, with the RFC 8620 §5.2 error:
Foo/get answering notFound. Handle cannotCalculateChanges by discarding the cached state for that collection and re-querying it in full.
The floor that produces this refusal is recorded by the purge itself, in the same transaction as the deletion, per (organization, account, collection) — so it is a property of the store, and every consumer inherits it: all Foo/changes methods, Push/catchup, the push-patch replay, and CalDAV/CardDAV sync-collection (which answers the same case by requiring a full sync). A cursor at the floor is still answerable; only one below it is refused. An account that has never had a purge answers every cursor.
Being outside the window is expected for a phone that was reinstalled, a laptop back from a long absence, or a paused DAV client. Being outside the window silently was the defect: before this floor existed, such a client received a partial delta plus a fresh
newState and concluded it was fully synced.One id, one outcome
Within a singleFoo/changes response, an id appears in at most one of created, updated, destroyed, once (RFC 8620 §5.2). The store collapses raw log rows to the terminal outcome over the requested window: created-then-updated reports created; created-then-destroyed is omitted entirely (the client never saw the object); updated-then-destroyed reports destroyed; destroyed-then-recreated reports updated. A client therefore never has to reconcile an id that claims to be both updated and destroyed, and never needs the updated ∩ notFound heuristic to retire an object.
Where to go next
- JMAP Mail — the mailbox and message methods.
- JMAP v2 modernized — the modernized surface in depth.
- Concepts — the data model and vocabulary behind these methods.
- Architecture at a glance — how the JMAP layer sits in the single binary.