Skip to main content
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

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). 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/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.
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:
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.
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 (/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.