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
Message body and attachments
The body of a message or a room is content: the client’s own JSON envelope (text, attachments, any key of the client’s), sent as an array of bytes or a base64 string and served as an array of bytes, stored verbatim and round-tripped untouched, with the server-managed keys (reactions, transcript) merged in. It is plaintext: the server reads a message’s text for the room preview, search, mentions and link unfurls. Its former name, encryptedContent, described an encryption that was never built; it is no longer served, and a ChatMessage/set or ChatRoom/set that carries it is refused with invalidProperties naming the rename.
Attachments are standard JMAP blobs. What a room shares (a message’s images and files, the room avatar) is uploaded at the organization’s scope (/jmap/upload-tenant), but it is served to whoever holds it, not to the whole organization: its uploader, or a member one of whose own copies references it (a room or trashed room carrying it as avatar, a message flagged as carrying an attachment whose body names it under blobId). Everyone else gets the 404 of an absent blob, so a former member or a member of another room who kept the id reads nothing. Custom emoji, the organization logo and directory photos stay published to every member.
- A write can only cite a file its writer can read.
ChatMessage/set (create and update), ChatRoom/set avatarBlobId (create and update) and CustomEmoji/set create check every blob they name by the download rule above; citing someone else’s attachment by its id is refused with invalidProperties, in the same words whether the blob is unreadable or does not exist, so the answer tells nothing about which files the organization holds. An attachment the message already carried passes an edit; under a shared account the delegate’s own reach counts too.
- A file dropped in the composer and never sent does not stay. The daily sweep of never-attached uploads covers organization-scoped blobs: one referenced nowhere in the organization after 24 hours is released, whoever uploaded it.
A room destroyed by its owner goes to the trash
Until September a group or channel destroyed by its owner erased every member copy, message, receipt and key in one gesture, with no way back but a manual graft from a backup. ChatRoom/set destroy now moves the room to a trash: every member copy leaves the live tables at once and nothing is deleted. On the wire the owner keeps reading the room (Updated, isTrashed: true), every other member loses it (Destroyed), and everybody loses the messages, which nobody reads in the trash. The owner restores it with ChatRoom/set update {isTrashed: false} and nothing else; isTrashed: true is refused (the trash is reached through destroy), any other patch of a trashed room is refused, and a member who does not own it gets notFound as for any room they cannot see. The capability advertises roomTrash: true so a client tells a server that keeps the room from one that used to erase it.
Destroying asks for a typed confirmation (onDestroyConfirm, a map of room id to what the user typed): the room’s name as shown, or its member count when it has none, compared after NFC normalisation, trimmed edges and collapsed inner spaces. A wrong confirmation is refused; an absent one is recorded in the audit row and let through, this being a guard against a slip of the hand, not an authorization. Leaving or hiding a room never asks. A trashed room is restorable for the purge horizon the operator sets, and the automatic sweep never destroys anything after an unacknowledged restore nor during a backup (room trash, operator side).
Who did what is in the audit log. Destroying a room, removing a member, changing a role and moderating a message are acts that take something from others; each writes an administration-log line with its author, the address it came from and the session, where change_log carries no author and the log used to know nothing of chat (compliance).
The room directory
A group or channel whose owner sets allowKnocking: true accepts a request to enter: a person outside the room writes their own membershipStates/<their principal id>: "knocking" with ChatRoom/set, in an update of its own, and an owner accepts it (invited or joined) or refuses it by removing the entry. A knock used to need a room id learnt elsewhere, a door nobody could see. ChatRoomDirectory is where such a room can be found, and the one place a room’s existence reaches someone who is not in it.
- Which rooms. The
allowKnocking groups and channels of the caller’s organization, never a direct room, a trashed room, a room the caller is already in or one they are banned from.
- What it shows, and only that.
id (the one a knock names), roomType (group or channel), name (null when there is none), description (empty when there is none), memberCount (how many, never who) and knocking (the caller already asked). The last message, the participants, the roles and the membership map are member data and never appear; properties is a closed vocabulary, an unknown name being invalidArguments. It is a type of its own, not a widening of ChatRoom/query, whose result only ever holds the caller’s rooms.
- No oracle.
ChatRoomDirectory/get with ids: null returns the caller’s whole directory; an id absent from it is in notFound whatever the reason (closed to requests, banned, unknown, or already the caller’s room).
- Query.
ChatRoomDirectory/query filters on text (a case-insensitive substring of the name or the description) and roomType, and sorts by name only (then id, a room without a name last); any other comparator is unsupportedSort.
- No
/changes. The directory spans the organization and has no change log of its own, so its state and queryState are a digest of what it shows and canCalculateChanges is false: a client reads it again when it shows it.
The chat capability announces roomDirectory: true, so a client offers “browse rooms” only on a server that has it, instead of probing ChatRoomDirectory/get at every start and getting unknownMethod from an older one.
Voice message transcripts
A voice message is an ordinary message whose envelope carries exactly one audio/* attachment. Its transcript is carried by the server, not by each client: ChatMessage/transcribe {accountId, id} makes the server compute it once per message, and it is written under the server-managed transcript key of content on every member’s copy, the way reactions is. The room preview (when the message has no text), the notification body and full-text search read it, which a transcript made on one device could never feed.
- The response is
{accountId, id, transcript, submitted}. A done transcript is returned as is (submitted: false); otherwise the server writes pending on every copy (each member sees a ChatMessage state change), hands the audio to its engine once and answers submitted: true, and the final state arrives as another state change. Only done carries text; failed carries the engine’s error and may be asked again.
- Without an engine the instance says so. A server with no speech engine writes
failed with "no transcription engine", never silence; the provenance fields (provider, endpointHost, residencyTier) say where the audio went when one ran.
requestedBy names who asked by principal id, the identity every other chat field names people by, so any member can resolve it.
- Errors:
invalidArguments when the caller holds no such message or a transcription is already pending; invalidProperties (id) when the message is not a voice message; forbidden over a shared account, or when the instance policy is never.
- The instance policy is the operator’s
[chat] transcript_policy, advertised nowhere: on-demand (the default, the first member who asks), always (transcribed at ingestion, by an engine only an extended build carries) or never.
Threads and notifications
- A thread is followed or left explicitly.
ChatMessage/get serves threadFollowed on a thread root (false on a reply, which has no thread of its own). ChatMessage/set update {threadFollowed: true | false} on one’s own copy of the root, in an update of its own, follows or leaves it; on a reply it is refused with invalidProperties. Leaving forgets the thread’s unread state and takes it out of the myThreads view; replying, being mentioned or marking the thread unread follows it again.
- A reply notifies the thread’s followers only, by push and WebSocket frame alike; a root message notifies the whole room. A scheduled message follows the same rule when it goes out.
- A room’s notification preference is applied by the server.
ChatRoom.notificationPref (all, mentions or nothing, per member) gates the notification before it is emitted, so it holds for the Web Push of a closed app as for the in-app frame: nothing never notifies, mentions only when the message highlights for that member (a mention, a room-wide mention, a keyword rule, never from a muted sender), and a value the server does not know notifies rather than silence someone.
- A channel or group notification says who writes. The title is the sender, with the room in parentheses (
Alice (#general), Alice (Project X), Alice in a direct or unnamed room), so a client that hides the preview keeps the body private and still names the sender; entity.authorPrincipalId carries the author’s principal id.
- A thread query filters like any other.
ChatMessage/query {inThread} honours sendStatuses (by default sent only, so a scheduled reply does not show in the thread before its send time), text, isRoot and hasThread; the root stays in the result only when it matches.
Calls in the room
A call anchored on a room (Meet) leaves its trace there: the server writes one call message per session, a ChatMessage whose call carries the summary, so unread counts, the room list, offline reading, search and every device come from the message machinery a client already has.
- When it is written. When the session has ended; in a direct room, as soon as the ring of the only callee is over unanswered while the caller still waits, since a missed call must not wait for the caller to hang up (a later join does not rewrite it). A meeting whose conversation was never opened has no room, and gets no message rather than an invented conversation.
- What it says.
media is audio for an audio-only session and video otherwise; startedAt is when a second participant joined (null when nobody did) and durationSeconds is 0 then; answeredPrincipalIds holds who was in the call, the initiator included once someone else joined; missedPrincipalIds who was rung and never joined before the ring ended; declinedPrincipalIds who declined and did not join afterwards. Each reader learns from it whether they took the call, and a client writes “missed call” for a principal of missedPrincipalIds, the call and its duration for the others.
- Unread only for who missed it. The message raises the unread counts of the accounts in
missedPrincipalIds only; for everyone else it is read when it is written.
- The server’s, not the client’s. Its author is the call’s initiator and its
content is null; its content, mentions and poll cannot be changed (forbidden), it can be pinned or redacted like any message, and a client that sends call gets invalidProperties.
- The room list knows.
ChatRoom.lastMessageCall carries the same summary while the call message is the room’s last, and turns null with the next message. A call message leaves lastMessagePreview null, and a room list loads no message, so this is what lets a client write “missed call” on the room’s line, and tell a call from a last message whose body the server cannot read, instead of announcing a new message. It is server-set (refused on ChatRoom/set), always on the wire, null included, so a push patch can clear it, and projectable like every ChatRoom property. Rooms whose last message was already a call message got their summary at migration.
- An open tab learns that its ring ended. The Web Push that says so is skipped while a tab is open; the WebSocket carries its sibling instead, a
Notification of category call whose entity is {sessionId, ringing: false, outcome}, outcome being answered, declined or missed. The caller reads whose ring went unanswered in MeetSession.missed instead of ringing on.
Message queries
ChatMessage/query pages as RFC 8620 §5.5 says, whatever the scope (a room, inThread, a text search, isSaved, myThreads):
- An anchor must be in the result. An
anchor outside the result of the filter, or unknown, is anchorNotFound, where it used to be read as a cursor or end in a serverFail. anchorOffset is applied in full and clamped at 0, position in the answer is the index of the first id returned, and every page keeps the order of the sort.
- A thread read from an anchor lists that thread alone. With
inThread and anchor, the query used to page through the whole room around the anchor while its total counted the thread; and a text search read from an anchor failed. Every page and the total now select through one predicate.
calculateTotal is honoured on every scope. A search by room or across rooms (roomId, accessibleByMe) answers the total it was asked for, counted over every match and not only the page shown; it used to be absent in silence outside isSaved and myThreads. It is counted only when asked.
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.
- Presence carries a note. Next to the four-state status, a free-text note with an optional emoji (”🌴 back on the 12th”) is set by a
ChatCustomStatus frame of its own (text, emoji; an empty text and no emoji clears it), because folding it into the presence frame would make an absent field mean either “unchanged” or “clear me”. It is ephemeral like the rest of presence, lost on restart, served as customStatus (null when there is none) in ChatPresence broadcasts and ChatPresenceResponse alike. The chat capability announces it, customStatus: true with maxCustomStatusChars (100) and maxCustomStatusEmojiChars (16), referenced from the constants the validator applies, so a client negotiates rather than emitting and swallowing a refusal; a note too long is refused, never truncated.
- 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 envelope included; 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.
- A member can read without signalling, in direct and group rooms, reciprocally. The account preference
chat.sendReadReceipts (Preferences/set, true by default) switched off, the member’s cursor still advances (it is their unread state) but it is served to nobody and notifies nobody; in return, that member’s ChatReadReceipt/get returns their own cursors and nobody else’s. Channels keep readReceiptsVisible.
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:
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.
content stays an array of bytes on the wire for another reason: it is the client’s own envelope, stored verbatim so that keys the server does not know round-trip untouched. unfurls is data the server produces, so it is served as data.
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.
-
A reaction is bounded at 64 characters, composed emoji intact; every free-text input of the surface has a bound, and this one used to be the exception.
-
Creating a room refuses what updating one refused. Both
ChatRoom/set arms reject an unknown property by name; create used to read the keys it knew and report success over the rest. ChatMessage and ChatRoom each declare one write vocabulary, checked before the handler runs: an unknown property is refused by name, and the server-set sendStatus, sentAt and authorPrincipalId of a message are refused with their reason.
-
No end-to-end encryption is served. The
keySlots surface that once let a client deposit a room key wrapped for each member is retired (zero rows measured on six instances), and a ChatRoom or ChatMessage created with an encryptionScheme other than none is refused with invalidProperties, instead of being stored and indexed as clear text under a label that said otherwise.
Strictness rules are identical to the mail surface.