> ## Documentation Index
> Fetch the complete documentation index at: https://docs.oximail.ch/llms.txt
> Use this file to discover all available pages before exploring further.

# JMAP Core

> The RFC 8620 Core surface as OxiMail implements it: the session resource, authentication, batched requests, back-references, capabilities, and push.

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](https://www.rfc-editor.org/rfc/rfc8620): 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](/developer/jmap-mail), [Calendars](/developer/jmap-calendar), [Contacts](/developer/jmap-contacts), [Tasks](/developer/jmap-tasks), [Files](/developer/jmap-files), [Sharing](/developer/jmap-sharing), and [Chat](/developer/jmap-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.

```http theme={null}
GET /.well-known/jmap
Authorization: Bearer <token>
```

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:

| Field             | What it carries                                                                                                                                |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `capabilities`    | The server-level capability map: each supported capability URN mapped to its configuration object (limits, options).                           |
| `accounts`        | Every account this user can access, keyed by account id. Each entry has `name`, `isPersonal`, `isReadOnly`, and its own `accountCapabilities`. |
| `primaryAccounts` | For each capability URN, the account id that is "primary" for it.                                                                              |
| `username`        | The authenticated user's name (their email).                                                                                                   |
| `apiUrl`          | The endpoint to POST method calls to: `/jmap`.                                                                                                 |
| `uploadUrl`       | The blob upload endpoint template: `/jmap/upload/{accountId}`.                                                                                 |
| `downloadUrl`     | The blob download URL template: `/jmap/download/{accountId}/{blobId}/{name}?type={type}`.                                                      |
| `eventSourceUrl`  | The push (EventSource) URL template: `/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}`.                                   |
| `state`           | An opaque string that changes whenever the session object itself changes (e.g. an account is added).                                           |

### Never hardcode endpoint paths

The three `*Url` fields are **URL templates** ([RFC 6570](https://www.rfc-editor.org/rfc/rfc6570)): 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:

| Limit                   | Value at v0.30.0 |
| ----------------------- | ---------------- |
| `maxSizeUpload`         | 50 MB            |
| `maxConcurrentUpload`   | 4                |
| `maxSizeRequest`        | 10 MB            |
| `maxConcurrentRequests` | 8                |
| `maxCallsInRequest`     | 64               |
| `maxObjectsInGet`       | 500              |
| `maxObjectsInSet`       | 500              |

<Note>
  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](/developer/jmap-sharing) appears as an extra account entry (keyed `shared:{ownerId}`) carrying only the shared capabilities, and possibly `isReadOnly: true`.
</Note>

## Authentication

OxiMail uses **Bearer tokens** ([RFC 6750](https://www.rfc-editor.org/rfc/rfc6750)). Obtain a token by posting credentials to the login endpoint:

```http theme={null}
POST /auth/login
Content-Type: application/json

{ "email": "alice@example.com", "password": "..." }
```

The response returns the token and the account id:

```json theme={null}
{ "accessToken": "...", "accountId": "..." }
```

Send that token on every subsequent request:

```http theme={null}
Authorization: Bearer <token>
```

### Token lifetime and refresh

Tokens expire **24 hours** after they are issued. To stay logged in without re-entering a password, refresh the token:

```http theme={null}
POST /auth/refresh
Authorization: Bearer <current-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:

```http theme={null}
POST /jmap/stream-ticket
Authorization: Bearer <token>
```

No body. The response is not cached (`Cache-Control: no-store`):

```json theme={null}
{ "streamTicket": "...", "expiresAt": 1767225600 }
```

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.

<Note>
  `?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.
</Note>

## Making requests

All method calls go to a single endpoint as one batched POST. The request body (RFC 8620 §3.3) has three parts:

```http theme={null}
POST /jmap
Authorization: Bearer <token>
Content-Type: application/json
```

```json theme={null}
{
  "using": [
    "urn:ietf:params:jmap:core",
    "urn:ietf:params:jmap:mail"
  ],
  "methodCalls": [
    ["Mailbox/get", { "accountId": "a1", "ids": null }, "c0"],
    ["Email/get", { "accountId": "a1", "ids": ["e1", "e2"] }, "c1"]
  ]
}
```

* **`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`:

```json theme={null}
{
  "methodResponses": [
    ["Mailbox/get", { "accountId": "a1", "state": "...", "list": [ ... ] }, "c0"],
    ["Email/get", { "accountId": "a1", "state": "...", "list": [ ... ], "notFound": [] }, "c1"]
  ]
}
```

<Note>
  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.
</Note>

## 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:

```json theme={null}
{
  "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
  "methodCalls": [
    ["Email/query", { "accountId": "a1", "filter": { "inMailbox": "inbox" } }, "c0"],
    ["Email/get", {
      "accountId": "a1",
      "#ids": {
        "resultOf": "c0",
        "name": "Email/query",
        "path": "/ids"
      }
    }, "c1"]
  ]
}
```

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](https://www.rfc-editor.org/rfc/rfc8621)), Submission, Vacation Response, Sieve ([RFC 9661](https://www.rfc-editor.org/rfc/rfc9661)), Quota ([RFC 9425](https://www.rfc-editor.org/rfc/rfc9425)), Principals / Sharing ([RFC 9670](https://www.rfc-editor.org/rfc/rfc9670)), Contacts ([RFC 9610](https://www.rfc-editor.org/rfc/rfc9610)), Calendars, Files, and WebSocket ([RFC 8887](https://www.rfc-editor.org/rfc/rfc8887)).

### 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:

| Capability URN                               | What it adds                                                                                    |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `urn:oximail:params:jmap:v2:mail`            | A modernized mail surface (import, send, cancel, reindex) layered on the classic Email methods. |
| `urn:oximail:params:jmap:v2:threads`         | First-class `Thread` type with merge and convergence.                                           |
| `urn:oximail:params:jmap:v2:labels`          | Flat, shareable labels instead of nested folders.                                               |
| `urn:oximail:params:jmap:v2:rules`           | Typed mail filter rules with a per-rule execution time guarantee.                               |
| `urn:oximail:params:jmap:v2:calendar`        | First-class recurrence rules and attendees.                                                     |
| `urn:oximail:params:jmap:v2:contacts`        | A modernized contacts model with a documented mutability table.                                 |
| `urn:oximail:params:jmap:v2:streaming`       | Push catch-up and streaming query support.                                                      |
| `urn:oximail:params:jmap:v2:resource-limits` | Advertised conformance limits a client can check before sending.                                |

See [JMAP v2 modernized](/developer/jmap-v2) 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`.

<Note>
  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`.
</Note>

## 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](#device-bound-tokens-dpop-rfc-9449)).

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`:

| Response | `type`                   | What it means                                                                                                                                                            |
| -------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `404`    | `blobNotFound`           | No owning message is known for this hash. There is nothing to derive from — and nothing was lost either.                                                                 |
| `500`    | `blobMissing`            | An owning message is known, but its raw blob is missing or undecryptable. The source of truth itself is gone: this is a data-loss signal, and the server logs it as one. |
| `500`    | `partDerivationMismatch` | The owning messages loaded and parsed, but none of them derives a part with the requested hash — the locator rows are stale or corrupt.                                  |

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.

<Note>
  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.
</Note>

**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`:

```
GET /jmap/eventsource/?types=*&stream_ticket=<ticket>
```

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-ephemeral-stream-ticket)). 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:

```json theme={null}
["error", { "type": "cannotCalculateChanges" }, "c0"]
```

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.

<Note>
  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.
</Note>

### 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

* [JMAP Mail](/developer/jmap-mail) — the mailbox and message methods.
* [JMAP v2 modernized](/developer/jmap-v2) — the modernized surface in depth.
* [Concepts](/concepts) — the data model and vocabulary behind these methods.
* [Architecture at a glance](/architecture) — how the JMAP layer sits in the single binary.
