Skip to main content
Meet is the call surface, requested with urn:oximail:params:jmap:meet. The division of labour is the thing to understand first: OxiMail is the authority on who may join and who is currently in a call, and it relays the WebRTC handshake between participants. In the default peer-to-peer mode the media never touches the server; when a media forwarder is attached and a call outgrows the mesh, the forwarder is a separate process and this server still carries only the control plane.

The capability object

Read the capability rather than hardcoding any of it — this is where the shipped limits are announced, and three of its fields are runtime facts written into the Session object per account:
  • stunServers and turnServers are what a server with nothing configured advertises. With [meet] configured (see the operator side), stunServers lists the operator’s STUN URLs and turnServers carries one TURN entry minted for the calling account, valid for a bounded window (urls, username, credential, credentialType: "password"). The server never advertises a third party’s STUN on the user’s behalf.
  • sfuAvailable and maxParticipantsSFU reflect whether a media forwarder was attached at boot and the seat cap it announced. Without one they stay false and null, and those are then facts, not placeholders.
  • reactionEmoji is the closed vocabulary MeetSignal/reaction accepts, in the render order a client should use. A reaction reaches every active participant, so the server accepts these values and nothing else.
  • recordingAvailable is false, and config.recordingAllowed is refused with invalidProperties: the server can only record media it can see, and in the mesh it sees none.

Objects

A MeetSession carries id, accountId, exactly one of chatRoomId or calendarEventId (the anchor, see below), an optional conversationRoomId (where the call’s chat lives, once someone has written), mode (p2p, or sfu once switched), mediaEndpoint (present only in sfu mode), config, participants, created, and the optional started and ended. participants is server-set: it is loaded from the store and never taken from a client. started is stamped the moment the second participant is active, which is when a session becomes a call rather than an invitation; someone standing in the waiting room has not started anything, the meeting begins when they are let in. config has audioOnly, maxParticipants, waitingRoom and recordingAllowed. audioOnly forces every participant’s video off at join. maxParticipants is the organizer’s cap, and it is enforced (see seats). waitingRoom opens the queue below. recordingAllowed is refused. A MeetParticipant is flat on the wire: id, exactly one of accountId or guestId, deviceId, displayName, a role of moderator or participant, a status of active, waiting, left or kicked, the media flags audioEnabled, videoEnabled, screenSharing, and handRaised with a server-set handRaisedAt while the hand is up. id is the only identifier the wire has for “someone else”, and the only one that can name a guest; the moderator verbs take it as participantId.

Methods

One answer for strangers, everywhere: a caller with no standing in a session (not a room member, not a participant, not invited) gets notFound with the same wording an invented id gets, whether the session is live or ended. Only a caller that demonstrably knows the session exists is told the finer refusal (forbidden: not a moderator, not active, session ended, removed by a moderator).

A session is anchored, and that is how it rings

A session hangs from exactly one of two things, and the anchor is what authorization keys on:
  • A chat room (chatRoomId): the room’s members may join. Creation fans a StateChanged for the MeetSession collection out to every member of the room, which is how a callee learns that a call exists without a dedicated notification type. The frame carries a state, not an id, so the second half of the loop is MeetSession/get with ids: null (RFC 8620 §5.1: every record the caller can see), which answers with the active sessions of the caller’s rooms, the running meetings the caller organizes, and the sessions the caller was invited into. Those two together are the incoming-call discovery loop; a client needs nothing else to ring.
  • A scheduled meeting (calendarEventId): the event’s internal participants may join, and every one of them moderates, so an external guest is never stuck in a queue only one named organizer can release. The id may be the caller’s own iTIP copy of the meeting; the anchor follows the uid to the organizer’s copy, so two attendees never open two calls for one meeting. The session of a scheduled meeting is born lazily, at the first arrival, and that arrival may be a guest following the link before the practitioner has joined. An attendee reaches the call through the event itself: MeetSession/create with calendarEventId is idempotent and returns the live session, which is why ids: null lists the running meetings the caller organizes but not every meeting they attend. See scheduled meetings.
A session anchored on a room converses in that room. A session anchored on a meeting has no room until someone writes in the call’s chat, at which point one is created and recorded as conversationRoomId. Bringing someone into a call already started. MeetSession/invitePrincipals {sessionId, principalIds} grants the named in-tenant principals the right to join this session, and nothing else: the room never changes (a direct room’s membership is immutable, and adding someone to a room for a ten-minute opinion would have made them a member forever), the right dies with the session, and re-inviting someone who missed the call makes their device ring again. Any active participant may invite, not only a moderator: presence in the call is the standing, and the moderator keeps the last word through kick and the waiting room. Every id must parse, name a principal of the tenant and resolve to an account, or the whole call is refused. Inviting is not joining: the seat is still claimed at join, under the same cap as everyone else.

Seats and the cap

The number of active seats a session may hold is min(config.maxParticipants, the mode's limit): maxParticipantsP2P (3) in the mesh, the forwarder’s cap frozen at the switch in sfu mode. The organizer’s cap is the law below the mode limit (a teleconsultation capped at 2 refuses the third person even though the mesh could carry them) and is capped, not honoured, above it. A cap of 0 or 1 is refused at creation, since the creator takes the first seat. The cap applies to every way a seat can be taken: a fresh join, a left device rejoining, and an admission from the waiting room. Someone waiting consumes no seat. A refused seat answers tooManyEntries with a description that names the limit that bound, maxParticipants (“the organiser chose this”) or the mode cap (“the technology does”). At the mesh cap with a forwarder attached, the seat is bought by switching the session instead of being refused.

The waiting room, and the moderator verbs

With config.waitingRoom: true, a join lands the caller in waiting and fans the change out so the moderator sees the queue grow. A moderator never waits, not even on a second device: the role is carried by the account, so the creator joining from a phone is still the moderator. Re-sending join while waiting stays exactly where it is, and an active participant re-joining is never demoted back into the queue. A waiting participant heartbeats with MeetSession/refresh like an active one and is reaped by the same sweep if its tab dies. The moderator verbs all take sessionId and participantId (except muteAll), share one preamble (resolve the session, prove the caller moderates it, resolve the target, refuse a target that already left) and are five distinct methods rather than one moderate with an action argument, so the read-only gate and any capability check read the verb instead of parsing an argument.
  • admit turns a waiting participant active, under the seat cap, and stamps started if this is the second active participant. reject sends them to left, drops their lease.
  • mute and muteAll are an order, not a request: the server writes audioEnabled: false on the target immediately and fans it out, so the room’s view of who is muted never depends on a client that may be wedged or slow. The muted participant may unmute itself afterwards with MeetParticipant/update: a moderator mute is an interruption, not a lock, and nothing in this product needs a censorship primitive.
  • kick sets kicked (never left: one is a decision made about you), stamps leftAt, drops the lease, and is enforced at the door: MeetSession/join refuses any account carrying a kicked row in the session, so the same person cannot walk back in from a second browser. Signaling stops on its own, since every MeetSignal/* requires both ends active.
  • readmit lifts the exclusion without teleporting anyone back: the target lands in left, rejoins normally, queues again where a waiting room is configured, and keeps its original leftAt. It exists because reject sits next to admit in a queue that moves while the moderator reads it, and the only remedy for a misclick used to be ending the meeting.
MeetSession/end remains a moderator’s verb; ending stamps ended, settles every participant and drops every lease, without rewriting history: a kicked row stays kicked and every leftAt keeps the moment it happened, so an exclusion at 14:02 in a meeting ended at 14:30 is still an exclusion at 14:02. An external person joins a call through an invitation link, redeemed into a guest credential. The two are different objects with different prefixes, and neither is ever an account. The link (MeetSession/invite {sessionId, expiresIn?}, moderator only) answers inviteId, token, url and expiresAt. The token (mi_…, about 190 bits of entropy) appears in that answer and nowhere else: only its hash is stored, so no later read can recover a live link. expiresIn defaults to 24 hours and is refused above 7 days, since a longer-lived link is an unattended door key and the moderator can always mint a fresh one. One link serves N guests: a teleconsultation with two external participants is one link forwarded twice. The url is {web origin}/meet/join/{token}, a client-side page. MeetSession/revokeInvite {inviteId} kills a link before its expiry without ending the meeting and without touching guests who already redeemed it; evicting a person is kick, a different decision about a different subject. Minting a link is not a MeetSession mutation and fans nothing out. Redemption is POST /meet/guest {token, displayName}, unauthenticated by nature. Its defences, in order: a per-token budget of 10 redemptions a minute before any read (the per-IP half rides the global HTTP limiter); one answer for every failure, 404 {"error": "invalid or expired invitation"} for an unknown, revoked or expired token, a session gone or ended, so a prober learns nothing about which check refused; and a display name that is bounded (128 bytes, no control characters) because it lands on every member’s roster. A successful redemption answers:
  • The guest is born waiting, whatever the session’s waitingRoom says, takes no seat, and the moderator sees it arrive through the ordinary fan-out. On a scheduled meeting, the guest may be the first person to show up: the session is opened in the same transaction, so a patient clicking before the practitioner lands in a waiting room that exists.
  • ICE rides the bootstrap: guests are disproportionately behind hostile NATs, so the answer carries the STUN list and a per-guest, time-limited TURN credential, the same minting as the member capability.
  • The credential (mg_…, 12 hours) opens the JMAP endpoint and the WebSocket. Its confinement is structural: a closed list of methods is consulted fail-closed, so a method added anywhere in the server is guest-unreachable until someone puts it there. A guest may call MeetSession/get, /refresh, /leave, /chatSend, /chat, MeetParticipant/update and the six MeetSignal/*; it can mint nothing, moderate nothing, and there is no join, since it was placed in waiting at redemption and there is no second door.
  • A reduced view while waiting. MeetSession/get for a guest sees exactly one session; while the guest is waiting it gets a redacted projection, its own row only, never the roster: who attends a meeting is data, and an unadmitted stranger holding a forwarded link is the last caller who should read it. A guest has no change cursor, state is the fixed string "guest", and the guest client polls get rather than syncing.
  • The guest’s deviceId is opaque and server-chosen. A member addresses a guest in MeetSignal/* by putting the participant’s guestId in toAccountId; the two id spaces cannot collide, and nothing else changes on the wire.
Creating a calendar event “with video” is one gesture: CalendarEvent/set accepts a meetLink instruction on create and on update, only when the request declares the meet capability, and refuses it otherwise even for false, because a client naming the property without the capability has expectations about the event that are already wrong. meetLink: true takes the server’s default lifetime; meetLink: {expiresIn: <seconds>} states one (default 365 days, at most two years), because how long a meeting link should stay usable is the organizer’s knowledge, not the server’s. The server mints the link at creation and writes it into virtualLocations under its own fixed key meet ({uri, features: {video, audio}}, RFC 8984 §4.2.6); the client’s other entries survive, and a client cannot pre-seed that key with a URL of its choosing. The uri then leaves as CONFERENCE in the outbound iTIP invitation and reaches an invitee on Outlook or Apple Calendar as a join link, and the attendee’s own copy carries virtualLocations too. On update, the same instruction adds a call to a meeting that already exists; it moves the sequence, so an updated REQUEST leaves with the door in it and the answers already received survive. A meetLink buried in a per-occurrence override is refused in full, naming the override: a series carries one room, and the video is added to the event, never to one of its occurrences. The session behind such a link is born at the first arrival, with the default MeetConfig; a guest present forces the waiting room regardless.

Membership is a lease, not a flag

Joining takes a lease keyed by (account, device). It expires 30 seconds after the last join or refresh, and MeetSession/refresh answers that window as expiresIn so a client can poll comfortably inside it rather than guessing. Sending a signal also counts as proof of life and touches the sender’s lease. Creating a session touches the creator’s, so a creator that crashes before its first join is reaped like anyone else. When a lease expires, a sweeper flips that participant to left in the store and fans the change out; when nobody is active any more, the session ends by itself. This is why a browser tab that disappears does not leave a phantom in the call, or in the waiting room. Leases live in memory and are never persisted. A server restart therefore drops all of them, and liveness is re-derived from whatever fresh join/refresh calls re-establish — the same semantics chat presence has, where everyone starts offline after a restart.

Media state and the raised hand

MeetParticipant/update {sessionId, deviceId, audioEnabled?, videoEnabled?, screenSharing?, handRaised?} publishes the caller’s own state; an absent field is left alone. It is a method of its own rather than arguments on refresh because media state must fan out and the heartbeat deliberately does not, and because a failed refresh and a failed mute mean different things. The raised hand is state, not an event: a hand raised while the moderator is looking elsewhere is still raised when they look back, a late joiner sees the hands already up, and handRaisedAt lets a client order speaking turns by the moment each hand went up. The transient sibling, a reaction, is a signal.

Signaling is relayed, never stored

The four MeetSignal/* methods take sessionId, toAccountId, toDeviceId and fromDeviceId, plus the signal payload — sdp, candidate, reason — which is forwarded verbatim and never read.
  • Nothing is persisted, and nothing is logged. There is no signaling mailbox, no queue and no retry: the relay is transitory by contract. A refusal logs the session and the signal kind, never the content.
  • delivered is an ack about connections, not about people. true means at least one open WebSocket of the recipient received the frame. false means they have no open connection right now — not an error, and not queued for later. Handle it in your UX (“appears offline”), do not treat it as a failure to retry.
  • The server owns the frame’s identity fields. type, signal and fromAccountId are stamped after the caller’s payload is spread into the frame, so a caller cannot overwrite them by putting those keys in their own payload.
  • Both ends must be active participants. The sender is checked by (accountId, fromDeviceId) and the recipient by toAccountId/toDeviceId (an account id or a guestId); anything else is forbidden, and any failure to resolve participants relays to nobody rather than to everybody.
  • Rate limited per (tenant, account, session) at 120 signals per minute by default — sized for trickle ICE, roughly two per second. Over budget answers the RFC 8620 §3.6.1 limit error.
MeetSignal/reaction {sessionId, fromDeviceId, emoji} is the one broadcast frame of the family: every field is named and validated (no opaque payload, since it goes to the whole room), emoji must be one of the capability’s reactionEmoji, and anything else is invalidProperties, never a silent drop or a coercion.

The call’s chat

MeetSession/chatSend {sessionId, text} writes into the call’s conversation, guests included. It is not ChatMessage/set with the guest allowed through: that handler resolves its author from an account and proves membership by a room row, and a guest has neither; the guest door is a separate verb whose authority is being an admitted participant of the session it names (a guest in the waiting room cannot write). It accepts text only, at most 8 KiB, so no link mark can be born on the guest path; the stored envelope and the per-account fan-out are the chat’s own, and the members read the message in their room like any other. The room of a meeting-anchored call is created at the first message. MeetSession/chat {sessionId, limit?} reads the conversation bounded to the caller’s own presence: a guest reads from its own arrival, never before (what was said before someone was let in is not theirs, and a forwarded link must not hand over the conversation that preceded them), and the window closes when they leave. Members are served by the same verb on the same rule, which makes “what the guest can see” answerable by asking the server; a member wanting the room’s full history has the general chat query. The answer carries roomId (null and an empty list while nobody has spoken), windowFrom, windowTo and entries with authorPrincipalId or authorGuestName, sentAt and text; the default page is 100.

Beyond the mesh

A call starts peer-to-peer and stays there up to maxParticipantsP2P. With a media forwarder attached, a join or an admission that would exceed the mesh switches the live session to mode: "sfu": the forwarder allocates the conference, the session gains mediaEndpoint (the forwarder’s endpoint, typically a wss:// signaling URL, carried verbatim and never parsed by this server), the forwarder’s seat cap is frozen on the session, and every participant learns of the switch through the ordinary StateChanged fan-out and renegotiates against the endpoint. If the forwarder refuses the allocation, the session stays p2p and the triggering join is refused exactly as it would have been with no forwarder: the ongoing call is never cut. There is no transition back: a call that outgrew the mesh once would only bounce between topologies on every leave and join if it were allowed to return. The forwarder is a separate process (it parses hostile pre-auth media from anonymous peers, so it never shares an address space with the mail server) plugged in through the server’s extension seam; the media path is peer to forwarder directly, and this server sees only lifecycle calls, session opened, arrival, departure, closed, made from inside the handlers in the exact order they committed. No forwarder attached means nothing changes: the community binary answers none, behaves bit for bit as the mesh-only phase, and the capability says so. The same seam lets a forwarder hand back an end-of-call artifact (a recording, a machine-written summary) as a blob owned by the session’s account and kept by a reference row; nothing in the community binary produces one.

The operator side

  • Empty by default, deliberately. An unconfigured server advertises no STUN and no TURN rather than borrowing a third party’s, which would hand the IP address of every caller to someone who is not the operator. The recommended deployment is a coturn on the same box serving both roles. turn_urls without turn_shared_secret_file refuses to boot: it would advertise a relay no client can authenticate against. So does an inline turn_shared_secret: whoever reads the TOML could mint valid relay credentials for any account, so the secret lives in a 0600 sidecar (see secrets outside the TOML). Credentials handed to clients are derived from the secret per account and expire.
  • Built-in STUN. stun_bind binds a UDP socket and answers RFC 5389 Binding requests from the server itself, Binding only, never a relay; malformed datagrams get no reply (amplification posture) and each source IP is rate-limited by stun_rate_per_minute. Binding a socket advertises nothing by itself: list the public stun: URL in stun_servers too, and the boot log says so.
  • Retention. A finished call’s rows say who attended which meeting, when, and from which device, and nothing deleted them before this setting existed; they are swept retention_days after the end. The store carries a per-tenant column the sweep reads (meet_retention_days, for a trade that must keep more), but no verb or method writes it yet, so the configured value is what every tenant gets today. Meet rows are also part of what an account erasure removes.
Two account-side preferences live in the meet section of Preferences: waitingRoom (an account policy the client reads and puts in config.waitingRoom when it starts a call) and devices, a map keyed by installation carrying cameraId/microphoneId, since a webcam choice only means something on the machine that carries the webcam. An unknown key in the section is refused and named.

What is deliberately not there

  • No recording: refused at config.recordingAllowed, recordingAvailable: false.
  • No moderator transfer: on a room anchor the creator is the moderator; on a meeting anchor every internal participant is. Erasing the creator’s account deletes the session as a whole rather than leaving an unadministrable one behind.
  • No group mode without a forwarder, and no way back to the mesh once switched.
  • No second door for guests: a guest is placed in waiting at redemption, cannot join, cannot moderate and cannot mint.
The chat surface these sessions anchor on is documented on the chat page; the calendar side of a scheduled meeting on the calendar page; the session and push mechanics they rely on are on JMAP Core.