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
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
4401so 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). 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
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/setrefuses an explicitlastReadMessageIdolder than the stored one withinvalidProperties, before any write, so a refused entry leaves both the cursor and the unread badge untouched. To make a room unread again, usedestroy— 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
ChatReadReceiptstate 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 thanmaxReadReceiptLiveMembers(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 andChatReadReceipt/getstill 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
ChatRoomcarriesreadReceiptsVisible:falseby 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— aChatMessage/setcreate may carryscheduledAt; the message holds with asendStatusuntil 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:
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.
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.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 (
/changescan never miss a committed message). - Mentions are structural (
@principalas 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.