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

# Chat

> Chat over JMAP: rooms, messages, read receipts, reminders, polls and custom emoji — plus the WebSocket transport, typing indicators, link unfurls, and the capacity caps.

Chat is an OxiMail JMAP extension (`urn:oximail:params:jmap:chat`, with `polls`, `custom-emoji`, and `mentions` as sibling capability objects): the same request model, state strings, and push as every other domain, so a JMAP client gets chat with the machinery it already has. Real-time delivery rides the JMAP WebSocket.

## Objects and methods

| Object                 | Methods                                                             | Notes                                                              |
| ---------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `ChatRoom`             | `get`, `set`, `query`, `queryChanges`, `changes`                    | Direct and group rooms; membership is a list of Principal ids.     |
| `ChatMessage`          | `get`, `set`, `query`, `queryChanges`, `changes`                    | Messages, with editing (patch semantics) and structural mentions.  |
| `ChatReadReceipt`      | `get`, `set`                                                        | Per-member read state — see [read receipts](#read-receipts) below. |
| `ChatPersonalReminder` | `get`, `set`, `query`, `changes`                                    | "Remind me about this message" — personal, invisible to the room.  |
| `CustomEmoji`          | `get`, `set`, `query`, `changes`                                    | Organization-scoped emoji.                                         |
| `Poll` / `PollVote`    | `get`, `set`, `query`, `changes` (votes: `set`, `query`, `changes`) | Polls in rooms.                                                    |

Attachments are standard JMAP blobs; room-shared content is stored organization-wide so every member resolves it.

## Transport and liveness

* **WebSocket** (`/jmap/ws`): method calls and push over one socket. Typing indicators are WS frames outside the JMAP request model — scoped to the **room's members only** and throttled to one frame per 3 s per (room, account); stop events always pass so indicators clear.
* **Liveness is re-checked.** The socket's auth anchor (the device session) is re-validated periodically; a session that was revoked or expired closes with `4401` so the client reconnects cleanly, instead of lingering half-dead (chat flowing, HTTP fetches failing). Routine token rotation never disconnects a healthy client.

## Capacity caps

The chat capability object advertises the enforced quotas as data — `{maxRoomsPerAccount: 100, maxParticipantsPerRoom: 200, maxMessageSizeOctets: 65536, maxPinnedPerRoom: 50, maxReadReceiptLiveMembers: 20}` — so clients bound their behaviour before hitting `overQuota`/`tooLarge` ([multi-tenancy](../operator/multi-tenancy)). `maxPinnedPerRoom` caps pinned messages per room; message size bounds the serialized create object (`content` and `encryptedContent` uniformly); `maxReadReceiptLiveMembers` is explained under [read receipts](#read-receipts).

## Read receipts

`ChatReadReceipt` is a per-member read cursor — the id of the last message that member has read — and it is what a client renders as a row of reader avatars under a message.

* **A cursor only ever moves forward.** `ChatReadReceipt/set` refuses an explicit `lastReadMessageId` older than the stored one with `invalidProperties`, before any write, so a refused entry leaves both the cursor and the unread badge untouched. To make a room unread again, use `destroy` — that is the supported direction. When the cursor was *defaulted* rather than sent by the caller (which happens on its own when the newest message is destroyed), it is never an error: the stored cursor wins, and the response reports the cursor the server **holds**, not the older one it was handed.
* **A read notifies the other members, live.** Marking a room read queues a `ChatReadReceipt` state change for every *other* member, so their "seen by" surface follows the read instead of being correct on load and frozen afterwards. Two bounds apply: a channel that keeps its cursors hidden notifies nobody, and a room larger than `maxReadReceiptLiveMembers` (default 20) notifies nobody either — one read notifies every other member, so the volume grows with the square of the room size. Past that ceiling the cursors stay exact and `ChatReadReceipt/get` still returns them all; they simply refresh on the next fetch rather than within the second.
* **Direct and group rooms return every member's receipt. A channel does not, unless it opts in.** The reasoning behind closing a channel's cursors is that a large broadcast room would leak reading patterns — true there, and false for the small team channel where "who has seen this" is the point of posting. So `ChatRoom` carries `readReceiptsVisible`: `false` by default (privacy stays the default, and existing rooms keep their behaviour), settable by an **owner** only, and a *shared* room property rather than a per-member preference — it decides what the room discloses about its members, so one member cannot answer it for themselves. Setting it on a direct or group room is **refused** rather than stored, because those rooms already return every receipt and a stored value there would be a promise nothing keeps.

## Scheduled send and link unfurls

Two sibling capabilities advertise ChatMessage extension surfaces:

* **`urn:oximail:params:jmap:scheduled-send`** — a `ChatMessage/set` create may carry `scheduledAt`; the message holds with a `sendStatus` until the server-side scheduler delivers it (same scheduler as calendar alerts).
* **`urn:oximail:params:jmap:link-unfurl`** — the server populates link previews on messages (fetched server-side behind an SSRF guard; clients never fetch third-party URLs themselves).

`ChatMessage.unfurls` is an **array of typed objects**, appended to as each URL in the body resolves, and absent while a message has no URLs or no unfurl has resolved yet:

```json theme={null}
"unfurls": [
  {
    "url": "https://example.org/article",
    "title": "…",
    "description": "…",
    "imageUrl": "https://example.org/og.png",
    "siteName": "Example",
    "ogType": "article",
    "canonicalUrl": "https://example.org/article"
  }
]
```

Every field but `url` is optional and omitted when the source page did not provide it. Storage remains a JSON blob in one column, but that is an implementation detail the wire no longer leaks: this property used to be projected as the column *verbatim*, an array of raw byte values (`[91, 123, 34, …]`), which cost roughly four characters on the wire per useful byte and made every client reimplement bytes → UTF-8 → parse for data the server produces and understands entirely. Decoding now happens once, server-side, where a blob that cannot be parsed fails loudly instead of being silently replaced by an empty list.

<Note>
  The contrast with `encryptedContent` is deliberate: *that* one is a blob by design (the end-to-end-encryption trajectory) and opaque to the server. `unfurls` never was.
</Note>

## Behaviours worth knowing

* **Fan-out is materialized**: a room message writes one row per member, which is what the caps bound — and why membership, message, and change-log writes are transactional (`/changes` can never miss a committed message).
* **Mentions are structural** (`@principal` as data, not string scanning), so renames do not break them and clients render them reliably.
* **Message edits keep history**; edit-history references are tracked by blob GC, so an edited attachment never becomes a dangling reference.
* **Scheduled messages and reminders** run through the same server-side scheduler as calendar alerts.

Strictness rules are identical to the [mail surface](./jmap-mail).
