Skip to main content
JMAP (JSON Meta Application Protocol) is the API OxiMail speaks for mail, calendars, contacts, tasks, files, and chat. This page covers the Core layer from RFC 8620: how a client discovers the server, authenticates, batches method calls into one HTTP request, chains those calls with back-references, opts in to capabilities, and receives push notifications. Everything here is what OxiMail actually exposes at v0.30.0. The per-domain method sets build on top of it: see JMAP Mail, Calendars, Contacts, Tasks, Files, Sharing, and Chat.

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.
The session endpoint is authenticated — you must present a valid token to fetch it. The response is a JSON object (RFC 8620 §2) with these fields:

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:
  1. GET /.well-known/jmap once, cache the session object alongside its state string.
  2. On each run, use the cached templates directly.
  3. Every JMAP method response carries a sessionState — when it no longer matches the cached state (or a templated URL starts returning 404), refetch the session and replace the cache.
The 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:
The response returns the token and the account id:
Send that token on every subsequent request:

Token lifetime and refresh

Tokens expire 24 hours after they are issued. To stay logged in without re-entering a password, refresh the token:
If the token is still valid, or expired less than 7 days ago, the server deletes the old token and returns a fresh one with a new 24-hour expiry. The response shape matches login: { "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-Nonce header. A proof that includes a nonce claim is always verified against it; a stale nonce answers 401 with the error use_dpop_nonce and a fresh nonce, and the client retries (the standard RFC 9449 dance). With [auth] dpop_nonce_required = true the 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 the Authorization header, bound to that token via the ath claim — a stolen bearer then cannot call JMAP at all, not merely not refresh. "monitor" logs what require would reject, for a safe rollout. Sessions that never bound a key, and app passwords, are unaffected. The htu comparison normalizes default ports per RFC 3986 §6.2 (an explicit :443 on either side still matches).
Because media elements (<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 browser EventSource 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:
No body. The response is not cached (Cache-Control: no-store):
Pass it as ?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/eventsource and /jmap/push-patches only — presented anywhere else it is refused. Carrying the device session forward is not a detail: /jmap/push-patches registers its connection under that session id, and that binding is what makes the stream’s diff baselines the same ones the session’s /get responses 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.
A ticket that leaks into a log is inert within the minute; the 24-hour session token never enters a URL at all. The single-blob ?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:
  • using declares which capabilities this request relies on. A method whose capability is not listed in using is rejected with unknownCapability — the method is never run “anyway”.
  • methodCalls is an ordered array. Each call is a 3-element array: [methodName, arguments, callId]. The callId is your own label, echoed back so you can match responses to calls.
The response mirrors that shape with a 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:
The second call says: “for my 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 session capabilities 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 the urn: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 the urn: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 in using 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 the downloadUrl 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.
An uploaded blob is safe before you reference it. Between your upload and the /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 call Foo/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 the eventSourceUrl:
The server streams 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 a wss://.../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: the urn: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:
That refusal means: the delta is not computable, do a full resync. It is not a transient fault and retrying will not help — objects created and destroyed inside the purged span are unrecoverable from the log, and an object destroyed there would otherwise sit in the client’s cache forever with 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 single Foo/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