JMAP
What is implemented, what is deliberately not, the id spaces and why each one is what it is,
how state tokens work and why calendars cannot calculate changes, and push subscriptions.
CLIENT_DEVELOPMENT.md in docs/ is the protocol-level reference for someone writing a
client; this page is why the server is shaped this way. For connecting a client, see
Other clients.
Surface
The server lives entirely under src/Jmap/, with its own Method/, Mapper/, Protocol/,
Query/, State/, Push/ and Session/. Endpoints:
| Route | Purpose |
|---|---|
/jmap/session and /.well-known/jmap |
the Session object (RFC 8620 §2) |
/jmap/api |
the method call endpoint |
/jmap/upload/{accountId} |
blob upload |
/jmap/download/{accountId}/{blobId}/{name} |
blob download |
/jmap/eventsource |
EventSource push |
Authentication is app passwords on a stateless firewall — see the Security model.
Methods implemented
Core — Core/echo, PushSubscription/get, PushSubscription/set.
Mail — Mailbox/get, Mailbox/query, Mailbox/changes, Mailbox/set; Email/get,
Email/query, Email/changes, Email/set; Thread/get, Thread/changes, Thread/set;
EmailSubmission/get, EmailSubmission/changes, EmailSubmission/set; Identity/get,
Identity/set; SearchSnippet/get.
Calendars — Calendar/get, CalendarEvent/get, CalendarEvent/query,
CalendarEvent/set.
App\Jmap\Method\MethodRegistry indexes every class tagged app.jmap_method by its name(),
so adding a method is a class implementing App\Jmap\Method\JmapMethod and nothing else.
App\Jmap\Protocol\JmapProcessor runs the calls in order, resolving back-references through
ReferenceResolver, and a failing call yields an inline error without aborting the rest.
What is deliberately not implemented
Calendar/set. The Session advertisesmayCreateCalendar: false. The two provisioned roles are created byCalendarProvisionerand a subscribed one by the subscribe flow, neither of which a JMAP create could stand in for.Calendar/changesandCalendarEvent/changes. Not an omission — see the state section below.VacationResponseandurn:ietf:params:jmap:calendars. Delayed send is no longer on this list: the submission capability advertisesmaxDelayedSend: 2592000and the FUTURERELEASE extension, and a held submission waits as a delayed Messenger envelope.- Participants, privacy, alerts and links in
CalendarEvent/set. Each is listed with its reason; see below.
Capabilities
App\Jmap\Protocol\Capability holds the URNs:
| Constant | URN | Advertised in using |
|---|---|---|
CORE |
urn:ietf:params:jmap:core |
yes |
MAIL |
urn:ietf:params:jmap:mail |
yes |
SUBMISSION |
urn:ietf:params:jmap:submission |
yes |
CALENDARS |
urn:plmail:params:jmap:calendars |
yes |
PUSH |
urn:plmail:params:jmap:push |
no — Session-only |
Two of those are vendor URNs, and both are vendor URNs on purpose.
CALENDARS is deliberately not urn:ietf:params:jmap:calendars. JMAP for Calendars is an
unratified draft whose object shape is still moving — properties have been renamed and
re-scoped between revisions — so advertising the IETF URN would promise a contract no client
could rely on, and a client that believed it would break on the revision after the one this
was written against. A vendor URN says what is true: this is plMail's calendar surface, and
only something written for plMail should use it. Switching when the draft is ratified is then
an addition rather than a breaking change, since both can be advertised while clients move
across.
PUSH carries the VAPID public key, because RFC 8620 defines no standard place for it and
a client cannot call pushManager.subscribe() without one. An empty key is a client's signal
not to offer push at all.
Capability::SUPPORTED is what a client may declare in using; anything else is an
UnknownCapabilityException. PUSH is not in that list because it is a Session-level fact
rather than something a request can be made under.
Session
App\Jmap\Session\SessionBuilder exposes one JMAP account per connected mail account, so
a single login enumerates all of the user's mail and a unified inbox is a client-side concern
— one Email/query per account, merged in the client.
Core limits: maxSizeUpload 50 MB, maxConcurrentUpload 4, maxSizeRequestObject 10 MB,
maxConcurrentRequests 4, maxCallsInRequest 32, maxObjectsInGet 500, maxObjectsInSet
500.
The calendars capability states three account-level facts and each is there because a client would otherwise discover it the hard way:
maxEventsInGet: 100, lower than the global 500, becauseCalendarEvent/getresolves one id at a time — the ownership-scoped lookup is the only oneCalendarEventRepositoryoffers, and a client obeying 500 would meet arequestTooLargeit was told not to expect.mayCreateCalendar: false, matching the absentCalendar/set.materialisedHorizon, read straight fromRecurrenceMaterialiser::HORIZON_PASTandHORIZON_FUTURE. Occurrences exist only within it, so a query outside answers from a partial index — stated rather than left for a client to discover as a recurring meeting that stops.
SessionBuilder is one of only three places coupled to the mail-account entity shape, and its
docblock names the three calls it makes so a rename has one place to look. The others are
App\Jmap\Account\AccountResolver and CalendarAccountResolver.
Id spaces
Every id JMAP hands out is a server-defined string a client must not parse, and every one of them in plMail is a decision about which table's autoincrement it is. That matters more here than it sounds: ids from different tables are all plain integers, so an untranslated id does not fail — it names a real, wrong object, which a client fetches and renders.
| JMAP object | plMail id | Why |
|---|---|---|
| Account | Account row id |
a JMAP account is a connected mail account |
| Mailbox | LabelBinding row id |
a Label is user-scoped; a JMAP Mailbox is per account |
Message row id |
||
| Thread | MessageThread row id |
|
| EmailSubmission | the Email id | a submission has no table of its own |
| Calendar | Calendar row id |
one account serves calendars, so there is nothing to translate |
| CalendarEvent | CalendarEvent row id — the series |
see below |
| CalendarEvent, expanded | <eventId>_<recurrenceId> — one dated instance |
only from a query that asked for it |
| blobId | m-<id> / p-<id> / u-<id> |
two independent sources of bytes, plus staged uploads |
Mailbox is a binding, not a label
App\Entity\Label\Label belongs to the user; App\Entity\Label\LabelBinding is where that
label is materialised on one account. A JMAP account is one mail account, so the per-account
row is the thing with a stable id there.
App\Jmap\Mapper\EmailMapper therefore translates on the way out. mailboxIds comes from
Message::$labels — the authoritative per-message assignment — never from thread_label,
which is the derived union ThreadLabelSynchronizer recomputes and would report a mailbox for
every message in a thread. Those rows hold label ids and the wire needs binding ids,
which is also what inMailbox and Email/set's mailboxIds patch consume. Emitting the
untranslated id does not fail loudly: it names some unrelated mailbox that happens to share
the number, which is exactly what shipped once and what EmailMapperTest now pins. A label
with no binding on the account is omitted rather than emitted as an id the client cannot
resolve.
EmailMapper also publishes two synthetic body parts with the fixed partIds "text" and
"html", because plMail stores a flattened body (bodyText / bodyHtmlSafe) rather than a
MIME part tree. Clients treat partId as opaque and these are stable per message, which is
all fetchTextBodyValues needs.
A CalendarEvent id is the series
This is the id-space decision the calendar surface turns on, and it is not the obvious one.
The query that finds events runs over calendar_event_occurrence, so the ids the database
hands back are occurrence ids and something has to translate them.
The series is the right unit because it is what a JSCalendar Event is (RFC 8984): one
object carrying recurrenceRules and recurrenceOverrides, from which a client expands
instances itself. An id per occurrence would name rows this application creates and destroys
on every write — the materialiser rewrites them wholesale — so a client's stored id would go
stale the moment somebody corrected a title.
The translation happens in exactly one place, App\Jmap\Query\CalendarEventQueryRunner, and
App\Jmap\Mapper\CalendarEventMapper carries the argument. The test that guards it puts every
emitted id back into a filter and checks it selects what it came from — one id space
everywhere: list[].id, /query's ids, /get's ids, /set's ids.
The published object is the stored canonical JSCalendar object plus the envelope JMAP adds
(id, calendarId, uid, sequence, created, updated, isRecurring). Nothing is
re-derived from the projected columns: CalendarEventWriter is the one place columns become
JSCalendar, and a second derivation in the mapper would be a second answer to what the event
is. An event whose jscalendar is empty is therefore published nearly empty, which is honest
— no writer made that row. isRecurring is published because a client cannot see it from the
rule alone: a rule this server could not convert is stored verbatim and expands to a single
occurrence, so recurrenceRules being present is not the same claim as "this recurs here".
…except when a query was asked to expand recurrences
The series is the right unit for an object, and the wrong one for the question "which days does this land on?". A collapsed query answers a month-long window with one id and says nothing about where inside it the instances are, so a client drawing a month had exactly one way to find out: ask about one day at a time and see which windows the series comes back in. For a month that is up to 31 round trips to place one weekly meeting, and clients are forbidden from expanding the rule themselves — the phone and the web would disagree at DST boundaries and on overridden instances.
expandRecurrences: true on CalendarEvent/query is the answer, as
draft-ietf-jmap-calendars defines it. It switches the unit from the series to the occurrence:
one entry per instance in the window, ordered by the instance's start, with position,
limit and total counting instances rather than series. It is a projection of a read that
already happened, not new computation — findInRange() returns occurrence rows and the
collapsed answer throws the extra ones away — which is the payoff of materialising occurrences
in the first place.
An instance is named by a synthetic id, App\Jmap\Calendar\OccurrenceId:
42_20260304T090000Z
The event id, an underscore, and the instance's original start as a UTC instant in ISO 8601 basic format. Three things about that spelling are decisions:
- It is opaque. That is the draft's own word for it. The pair it encodes is a server-side join, and a client that split it and reassembled its own would be expanding recurrence rules by hand.
- The separator is
_, not the;other implementations use. RFC 8620 §1.2 restricts an Id to the URL-safe base64 alphabet —A-Za-z0-9,-and_— so an id carrying a semicolon, or the colons of an ISO timestamp, is one a conforming client library is entitled to reject before this server's response is ever read. The draft says "opaque" rather than naming a separator for exactly that reason. - The timestamp is the recurrence id, never the moved start. Where the rule put the
instance is the only name it keeps once somebody has dragged it, which is why
recurrenceOverridesis keyed by it and whyCalendarEventOccurrenceRepository::findOneByRecurrence()looks it up by that column.
A one-off event keeps its plain series id even in an expanded answer: its single occurrence
is the event, and the plain id is the one CalendarEvent/set accepts back. So an account
with nothing recurring in the window answers an expanded query exactly as it answers a
collapsed one, and with the argument absent or false the response is byte-for-byte what it was
before any of this existed.
CalendarEvent/get resolves both kinds of id, which is what makes the feature usable at all —
a client pairs /query with a /get on #ids in one request, so ids the getter refused would
leave the expansion a list of strings nothing accepts. An instance object is the series with
its stored override applied, plus:
| Property | Value |
|---|---|
id |
the synthetic instance id |
seriesId |
the series' plain id — a plMail extension |
recurrenceId |
the instance's original start, as a LocalDateTime |
recurrenceIdTimeZone |
the zone that LocalDateTime is in — UTC for a floating series |
start, duration |
where the instance actually is, from the occurrence row |
recurrenceRules, recurrenceOverrides |
null — the draft says MUST |
seriesId is load-bearing rather than decorative. CalendarEvent/set refuses an instance
id by name, because writing one instance is a recurrenceOverrides patch on the series and
nothing here turns "update 42_20260304T090000Z" into that patch; the draft expects /set to
resolve these ids itself, and until it does, refusing out loud is the honest half. Answering
notFound instead would be wrong twice over — this server minted the id, and a client told
"no such event" would go looking for a bug in its own id handling.
Two refusals guard the expanded path, both for the reason every other refusal here exists:
- A window reaching past the materialised horizon answers
cannotCalculateOccurrences. Collapsed, an overrunning window is merely thin — the series is still named and its rule comes with it. Expanded, the answer is the list of instances, so a series that stops at the horizon comes back as a series that ends, and nothing in the response says otherwise. timeZoneis refused alongsideexpandRecurrences. The draft pairs it with expansion so a server can convert instance times for a simple client; this one does not convert, and a client told nothing would draw a whole month in the wrong zone with no way to notice.
The three cases worth knowing as a client: a moved instance is drawn and ordered at its new
time and still named by its old; an {"excluded": true} instance has no occurrence row, so it
is absent from the query and notFound from the getter; a status: cancelled instance keeps
its row, leaves the query, and still resolves — because the answer to "wasn't there something
today?" is more useful than a gap.
CalendarMapper publishes writability as myRights rather than an isReadOnly flag,
following RFC 8621's Mailbox — two spellings of "may I write here?" is how one of them ends up
not being consulted. isVisible is published rather than acted on: it is the web sidebar's
tick, and a JMAP client filtering on it would hide from a phone what its user had chosen to
hide in a browser.
Calendars are served from exactly one account
A Calendar is the user's — user-scoped like Label and MailRule, with a mail account
only ever an optional owner for the one calendar extraction files into. There is no
per-account identity for a calendar the way LabelBinding gives a label one.
Serving the list from every account would publish one calendar under three accountIds. A
client keys every object by (accountId, id), so it would draw the calendar three times, and
an event created on it would appear to exist three times over — with no way for the client to
tell the three are one.
So App\Jmap\Account\CalendarAccountResolver names exactly one: the account the Session
already lists in primaryAccounts, which is the user's first. Any other is refused with
accountNotSupportedByMethod, RFC 8620's error for precisely this. It resolves through
AccountResolver first, so an unknown or foreign accountId is still accountNotFound —
telling a stranger that an id they do not own is merely unsupported here would confirm the
id exists.
A user with no mail account has nowhere to serve calendars from and the Session advertises none. That is a real state — a user can delete their last account and keep a calendar — and it degrades to "this install has no calendar account" rather than to an error at some other account's expense.
Blob ids are namespaced
plMail has two independent sources of downloadable bytes — a whole Message (its RFC822
source) and a single MessagePart (an attachment) — in different tables with independent
autoincrement ids, plus staged uploads. Emitting a bare id makes blob 239049 ambiguous and
the download endpoint cannot resolve it. App\Jmap\Blob\BlobId prefixes m-, p- or u-,
which stays opaque to clients — RFC 8620 §1.6.3 requires that anyway — and parse() returns
null for anything malformed so callers answer notFound rather than trusting input.
State
App\Jmap\State\ChangeLog is an append-only log and its autoincrement primary key is the
state token. A client's state for an (accountId, objectType) pair is the highest sequence
recorded for it; /changes returns rows with sequence > sinceState, capped at
StateManager::DEFAULT_MAX_CHANGES (256, kept modest for mobile) with hasMoreChanges set
when there are more.
StateManager::changesSince() refuses a null sinceState with invalidArguments and refuses
a non-numeric one with cannotCalculateChanges, which is the correct degradation: a
client holding a token this server can no longer interpret is told to resync rather than
handed a wrong answer.
Everything that writes mail change rows goes through App\Service\Mail\MailChangeRecorder;
see Mail ingest for why that layer exists on top of StateManager and why
record() deliberately does not flush.
Why calendars cannot calculate changes
App\Jmap\Calendar\CalendarState::FIXED is the literal string 'fixed', returned by every
calendar method, and the class docblock is the argument in full.
Mail's token is trustworthy because the log is complete: every path that changes a
JMAP-visible mail property calls StateManager::record*, through MailChangeRecorder, which
exists exactly so five callers cannot each forget the same two things.
Calendars have no such recorder and could not be given one from inside src/Jmap/. An event
changes from four places — the sync engine pulling a remote calendar, extraction reading a
message, the web editor, and CalendarEvent/set — and only the last is in this directory.
A log that recorded a quarter of the writes would be worse than none: the token would sit
still while a pull replaced the whole day, and a client comparing states would conclude
nothing had changed and never refetch. A partial log is not a weaker version of a complete
one, it is a lie with a number on it.
So the state is fixed and the methods say so in the only other way the protocol offers:
canCalculateChanges is false, there is no Calendar/changes or CalendarEvent/changes, and
a client re-runs its query — which is what Email/query already asks for and is spec-legal.
The value is deliberately not a number. Should calendars later join the change log — a
CalendarChangeRecorder beside MailChangeRecorder, called by all four writers, and
JmapObjectType cases to match — tokens become sequences, and a client still holding
'fixed' fails the ctype_digit check in changesSince() and is told to resync. That is the
correct degradation, and it is free.
Queries
Email/query compiles filters through App\Jmap\Query\EmailFilterCompiler, which refuses an
unknown condition by name rather than ignoring it. A filter quietly dropped returns too
much, and the client has no way to tell.
CalendarEvent/query runs CalendarEventOccurrenceRepository::findInRange() — the same
tsrange && overlap against the GiST index every calendar view makes, rather than a second
query written for JMAP. Two deliberate refusals:
The window is required. An unbounded query cannot be answered from that index at all: occurrences are materialised only to the horizon, so "everything" would come back looking complete while stopping two years out, and a client cannot detect a truncation nobody reported. Refusing is the only answer that does not lie.
No FilterOperator. AND/OR/NOT over a range overlap would have to be evaluated outside the
index, which is the sequential scan the index exists to avoid; and an OR of two windows is two
queries a client can make. It is refused by name, for the same reason EmailFilterCompiler
refuses an unknown condition.
expandRecurrences: true answers the same window with one entry per occurrence instead of one
per series — see the id space, which is
where that shape and its refusals are argued.
Writing
CalendarEvent/set
App\Jmap\Calendar\JmapEventWriter handles the protocol — reading a JSCalendar object off the
wire, refusing what cannot be stored faithfully, and mapping a JMAP patch onto the writer's
parameter list — and touches no column. What an event is belongs to
CalendarEventWriter, shared with the web editor and the sync engine; a JMAP client that set
a title without jscalendar['title'] would produce an event that looked right in the app and
exported blank.
Unknown properties are refused, not dropped, with invalidProperties naming them. A
client whose participants were silently discarded would believe it had invited somebody with
no way to discover otherwise. The properties left out, and why:
| Property | Why not |
|---|---|
participants |
an RSVP is answered through the invite flow, which sends an iTIP reply; accepting an attendee list here would record answers nobody was told about |
privacy |
there is no writer parameter for it, and writing the column directly is exactly what this class must not do |
alerts, links |
nowhere to project them from — the writer rebuilds the canonical object from the columns on every write, so they would survive one save and vanish on the next |
Times are parsed as strict LocalDateTime (RFC 8984 §4.1.2): no offset, no trailing Z.
Accepting 2026-06-02T09:00:00Z looks like a courtesy and is not — it says UTC, the zone
beside it says Europe/Berlin, and guessing which the client meant moves a meeting by hours in
silence.
Thread/set — an extension
RFC 8621's Thread is read-only, because a thread is derived from its Emails and there is nothing on it to change. plMail's differs in exactly one way: a thread can be snoozed, and that state belongs to the thread rather than to any message in it.
It is deliberately narrow — create and destroy are refused outright, because threads come
into being when mail arrives and go away when their last message does, and a client that could
conjure one would be describing something the rest of the system has no meaning for. update
accepts one property.
Setting it goes through App\Service\Mail\ThreadSnoozeService, the same service the web UI
uses, so a snooze means the same thing whichever client set it: the conversation leaves the
Inbox, gains the Snoozed label, and that change propagates outward to the provider. The one
deliberate difference between the callers is named at both ends — a form post gets an "in 1
day" fallback on an unparseable date where ThreadSetMethod::snoozeDate() refuses it.
Standard clients neither know nor need this method; Thread/get still answers the spec's two
properties plus one they will ignore.
EmailSubmission/set
Delegates to the same SendMessageMessage / SendMessageHandler / MessageSendService
pipeline the web composer's send button uses. That service already performs the draft→sent
transition — adds Sent, removes Drafts, clears the \Draft flag, sets sentAt, re-points the
mailbox — so a client that omits onSuccessUpdateEmail still ends up correct.
A submission has no table of its own: its id is the Email id. That satisfies the object
model because plMail sends each draft at most once (MessageSendService is a no-op once
sentAt is set), so the mapping stays one-to-one and EmailSubmission/get can reconstruct
from the Message.
undoStatus is reported as pending: the send is queued on the bus and genuinely has not
happened when the call returns. The web composer's undo window is deliberately not applied
— a JMAP client asked to send now.
Push
Two mechanisms, and both are driven by the same drained set.
StateManager accumulates dirty (account, type) pairs in memory as changes are recorded,
and JmapPushSubscriber drains them once at the end of the request or handler. Tokens are
read after the caller's flush, so they are the values a client will actually see from
/changes — reading them at record time would push a state that does not exist yet. A Gmail
batch importing fifty messages therefore produces one notification, not fifty.
App\Jmap\Push\PushDispatcher turns that into one StateChange per subscribed device. It
resolves accounts back to their owners first, because a subscription belongs to a user while
changes are recorded per account, and filters each subscription to the object types it asked
for.
Two transports, chosen per subscription. App\Domain\Interface\PushSenderInterface has two
implementations — WebPushSender (RFC 8030/8291/8292, covering browsers, an installed PWA and
UnifiedPush distributors alike) and FcmSender (FCM HTTP v1, for a native Android app, which
has no push service of its own). PushSenderRegistry picks by the transport column on the
row rather than by a supports() scan, because the transport is a property of the
subscription and not an interpretation of it. A user with a phone on Firebase and a browser on
Web Push is told twice, in one pass; a transport that is unconfigured skips its own rows and
leaves the others alone, so turning Firebase off is a decision about Android rather than about
push.
FcmSender sends data messages only — a notification payload would be drawn by the
system tray before the app saw it, and JMAP pushes no content for anything to draw. The body
is the same JSON WebPushSender sends, carried as one string under data.payload because FCM
data maps are string-to-string. Collapse keys are per payload @type, which is a guard rather
than a refinement: one shared key would let an ordinary StateChange discard an undelivered
PushVerification, and the subscription would then wait forever for a code FCM threw away.
UNREGISTERED/NOT_FOUND destroy the subscription the way a 404/410 does; QUOTA_EXCEEDED
and 5xx are not even counted as failures, since an outage is not a broken endpoint.
The OAuth2 bearer token FCM needs is minted by FcmAccessTokenProvider — a service-account JWT
grant (RFC 7523) signed with ext-openssl and exchanged at Google's token endpoint, cached until
it expires. Hand-rolled at about fifty lines rather than pulled in from google/auth, which
would put four packages on a Raspberry Pi for one signature.
Firebase credentials live in the database (fcm_config, one row, admin-editable at
/admin/push) rather than in env vars: the key is pasted out of a console, rotated when it
leaks, and belongs to a project that may not exist when the container is built. The
service-account key is encrypted through EncryptedStringType; the google-services values
beside it are not, because they are published to every client in the Session.
PushSubscription/set (RFC 8620 §7.2.2) carries no accountId — subscriptions are per
authenticated user — and the verification handshake is the point. On create the server
immediately sends a PushVerification object to the address the client gave; the client reads
the code out of it and echoes it back via an update, and until it does the subscription
receives nothing. Without that, anyone with an account could register a stranger's address and
have plMail deliver to it on every state change. The code proves whoever registered the
address can also read what arrives there. FCM is not exempt — the verification travels as an
ordinary data message.
A create carrying fcmToken is the FCM shape and a create carrying url and keys is the Web
Push one; carrying both is refused rather than resolved by precedence. fcmToken is the one
address property an update may change, because Android reissues tokens on its own schedule —
and rotating it re-arms the handshake, exactly as re-creating with a new URL does.
/jmap/eventsource is the other half, for clients holding a connection rather than a push
endpoint; the Session advertises it with the types, closeafter and ping parameters.
Things that bite
Emitting a label id where a binding id is meant does not fail. Both are integers from different tables, so the client renders a real mailbox that happens to share the number. The same shape of bug is why occurrence ids are translated to event ids in exactly one place, and why both have a test that round-trips every emitted id back through a filter.
A calendar method called with the wrong accountId is accountNotSupportedByMethod, not
accountNotFound — but only after ownership has been proved. Reversing the two checks would
turn the error into an id oracle.
CalendarEvent/get is capped at 100, not 500. It resolves one id at a time because
findOneForUser() scopes on the owner, which makes somebody else's event indistinguishable
from one that does not exist. The Session states the cap so a client is not surprised by
requestTooLarge.
Making CalendarState a number without a complete recorder is the failure it was written to
prevent. A token that moves for a quarter of the writes is worse than one that never moves,
because a client will believe it.
jmap_change_log has a pruneOlderThan() and no caller. The primary key is a 32-bit
integer, so the log grows for the life of the install — one row per message per sync plus one
per touched thread. The entity's own comment names the two ways out and their consequences:
switching to bigint means retyping the property to ?string (Doctrine hydrates bigint as a
string), and adding a pruner means clients below the new floor get cannotCalculateChanges
and resync.
A new JMAP-visible mutation that skips MailChangeRecorder is invisible to clients until
something else touches the same thread. There is no test that notices a missing announcement;
that is the reason the recorder exists at all rather than each call site calling
StateManager twice.
Empty JMAP maps must serialise as {} and not []. SessionBuilder and every /set
method substitute a stdClass for an empty array explicitly, because PHP's json encoder does
not distinguish them and a client parsing an array where an object is specified fails on the
Session itself.