urn:ietf:params:jmap:principals): a Principal directory of the people, teams, resources, and locations of the organization, and a uniform grant model every collection type reuses.
Objects and methods
The grant model
Every shareable collection (Mailbox, Calendar, TaskList, FileNode, Label) carries the sameshareWith map: principal id → rights object (mayRead, mayWrite, mayAdmin, plus domain-specific rights like mayReadFreeBusy). Grants are:
- Written by the owner on the collection object; each grant change produces a
ShareNotificationfor the grantee and a recorded change for resync. - Exercised through the
shared:indirection: a grantee addresses the owner’s data with the granted account id in the standard JMAPaccountIdslot; the session’saccountsobject lists what is reachable. Capabilities resolve on the authenticated caller, data residency on the owner — usurpation is not representable in the request shape. - Fail-closed. Shared-account dispatch denies by default; a rights lookup error refuses rather than granting.
- A composed call is admitted on its own. A method that runs another inside it (the
/getbehindCore/queryStream’sfetchProperties, the push patches, the alert snoozes) passes the shared-account gate and the admission of that inner method, exactly as if the client had sent it: inheriting the outer call’s context is not inheriting its admission. A grantee of one shared folder used to read the owner’s rules or contacts throughCore/queryStream; the inner refusal now comes back asforbidden. The push patches no longer serveThread/get,VacationResponse/get,EmailSubmission/getorCalendarEventNotification/getto a grantee, who receives a content-freeStateChangeinstead. Each composed call also leaves its own row in the data access log, filed under the owner with the grantee as actor; on astrictorganization, an answer whose row cannot be written is withheld (serverFail). - A query stream stays on its account. Every page of a
Core/queryStreamopened onshared:<owner>, client-paced or pushed, is served from that account under the same admission as the first: a pushed stream used to serve page 1 from the owner and the following pages from the grantee’s own account. The cursor carries the account it was opened on and is refusedinvalidArgumentson another; a grant withdrawn during the stream stops it at the next page withstreamFailed, naming the error.
Account grants: a whole collection
oximail account grant is the operator’s delegation: one account reaches another account’s collection (Mailbox, Calendar, TaskList, FileNode, Notebook, or an extension’s), at a level, with an optional expiry, under the same shared:<owner> account id. It is the mechanism behind a departure’s mailbox grant and a connector’s access (multi-tenancy). What it opens, under [rights] enforce (before that, a grant is only logged):
- Every object of the collection, those created after the grant included: every mailbox, calendar, task list, notebook, the whole drive.
readOnlyreads;readWritereads, writes and destroys items, each destruction written to the owner’s audit chain with the grant, and never shares, administers, sends mail or sends a scheduling message (a write only the grant admits that would send an invitation is refused). A row whose level readsadmin, written outside the verb, is applied asreadWrite, never more. - Over JMAP only. IMAP, CalDAV and CardDAV open no object on an account grant; blob downloads, thumbnails and uploads follow the JMAP admission of the type.
- Never
Contact. JMAP serves no contacts on a shared account, so a contact grant would announce an access that opens nothing:account grant --collection Contactis refused and says why. A team shares an address book through a tenant directory. - Its states are its own. Every state served under a grant carries the grant’s fingerprint (method responses, composed calls, WebSocket, EventSource and push patches): a state obtained before the grant, under another grant or another level answers
cannotCalculateChanges, and the client resyncs instead of reading a delta computed for different rights. Without a grant, states are unchanged byte for byte.
account grant prints what the grant opens and where (opensObjects and faces in --json), rights explain --object <id> --account <email> lists the rights the grant gives on that object by the server’s own translation, rights audit flags a row written as admin with the level actually applied (appliedLevel), and oximail doctor counts the unexpired grants that open objects, by level (account_grants_open_objects).
Teams and their address
Every non-individual principal (group, resource, location) is provisioned with a backing shared account at creation, so a team can own mailboxes, a calendar and a drive without a person owning them. What needs an administrator is not the account but the name: choosing an address takes a local part in the organization’s space and reserves it, so nobody can take it in the meantime, while a generated address takes nothing anyone wants, which is what keeps “share this folder with these three people” self-service.
A team is born mute: its address is reserved and the SMTP door refuses mail to it (550) until its mail surface is laid. Principal/enableMail {ids} (admin capability, at most 20 ids per call) lays it: the six system mailboxes, the sending identity, a task list, the root folder and the sorting script. It is an act, not a property: there is no way to un-provision a mail surface, and a one-way switch must not be drawn as a toggle, so the method follows the /set idiom with per-id outcomes in promoted / notPromoted rather than a boolean on Principal/set. Four outcomes are distinct: promoted (with the identityId), alreadyLive (replaying is safe), repaired (an interrupted activation completed, naming what was laid), or the error mailSurfaceDamaged, a component half-laid and nothing touched, which asks for a human rather than a guess. Activations of one account are taken one at a time, so two administrators clicking together do not read each other’s half-state as damage. The activation records what it created on the team’s account and pushes it, so a connected client sees the mailbox arrive; it carries no ifInState (the engine writes in the backing account, not on the principal row) and is audited like its CLI twin.
The directory says whether an address receives: Principal.receivesMail, served when requested and never omitted (a client must tell “does not receive” from an older server that lacks the property), names exactly that question. It does not promise that this message will be accepted (a full mailbox defers, it does not refuse), and it does not say whether the team can reply, which is the operator’s question and is served by doctor. Distribution lists count as reachable. Writing it is refused by name, with the method that makes a team reachable.
Directory semantics worth knowing
- Principal types are honest. A shared team account’s principal is
type: "other"(non-loginnable), neverindividual— people-pickers can rely on the type. - Grantees must be human-backed, or a group. A grant to a principal backed by a non-loginnable account (a team’s own shared account, a resource, a location) is rejected at write time on every domain; a group is the one exception, since a grant keyed on a group resolves to its members at the access check, and membership is admin-only for exactly that reason.
- Destroying a principal purges its grants. A
Principal/setdestroy removes the grantee’s keys from everyshareWithmap — no dangling ACL entries — and warns or aborts if it would leave a shared drive without an eligible custodian. - Erasure leaves tombstones. An erased person’s principal is PII-scrubbed but kept as a graph anchor, and a tombstoned principal is refused as a new grantee (compliance).
- A frozen account keeps the organization’s mandates, not its own. A grant carries who authored it: one the owner wrote is a mandate the account gave, suspended while the account is frozen (
role: disabled), and reinstated with it; one the operator wrote in the owner’s name (account offboard --grant-mailbox-to,account grant, or the departure keys ofPrincipal/setAdmin, see the admin API) is the organization’s decision and stays. A grant with no author is read as the owner’s, the fail-closed direction. A departure’s grant carries a mandatory expiry, at most one year out;account grant --untilis optional, and refused toward a declared service account (service: truein the account capability object,v2:account, of the principal’scapabilities), a connector that never leaves (multi-tenancy). - Entities are declared, never deduced. A person’s
profileCardmay name their employer inorganizationsby reference to a group (the group’s id underoximail.ch:principalId); the name is read from the group each time, so renaming it fixes every card. Only a group a tenant admin markedisEntity: truemay be cited:Principal/settakes the mark at create and update, on a group only (invalidPropertiesotherwise,forbiddenfor a non-admin), and a card citing an unmarked group is refused naming the group.Principal/getalways servesisEntity, and on a groupcardReferenceCount, the cards that cite it, so a client knows before a rename or a destruction; withdrawing the mark of an entity cards still cite, or destroying it, isforbiddenwith the name and the count.Principal/query {filter: {isEntity: true}}is the employer picker’s source (false: everything but the entities). The console side is on the multi-tenancy page. - Destroying a group frees its shared account, its reserved address and its seat, as destroying a resource or a location already did.
- A service account acting under
shared:<owner>may patch only an allow-list of the principal,workingHours,photoBlobIdand one key of one capability object, and only when its grant names the collection that opens that channel, not any write delegation; name, email and time zone stay closed. A photo set by anyone other than its person (that channel, or an administrator) must be an organization-wide blob the writer uploaded (/jmap/upload-tenant/{tenantId}) or can read, elseinvalidPropertieswith one text for every case (naming “this blob is in the person’s account” would be an oracle); each such set or removal is an audit line under the authenticated account. Principal/getAvailability(free-busy) is documented on the calendar page.
ShareNotification/query returns a total order (newest first). Strictness rules are identical to the mail surface.
Across organizations: federation
Everything above shares data inside one organization. Sharing with a different organization is a separate surface,urn:oximail:params:jmap:federation, and it is deliberately not the same mechanism: a federated grant is neither a shareWith entry nor an account-access row. What it carries is availability (free-busy) — the grant widens who may ask, never what they may see. Since October 2026 the cross-node read is served on a server whose operator switched the federation door on: a paired server relays Principal/getAvailability for up to 50 addresses over at most 62 days, receives coalesced busy time only, and every person whose busy time an answer carries gets one FederationDisclosure row, written before the answer leaves. In the other direction, Event/proposeTimes asks the paired server that serves an attendee’s domain (calendar). With the door off, a grant authorizes nothing and no disclosure is recorded. Pairing, the DNS record and the operator side are on the federation page.
The capability object advertises the vocabulary rather than making clients discover it from rejections:
The grantee is an organization, not a person
supportedGranteeKinds is ["realm"], and the reason is not schedule. The identity a federated request proves today is the sending node, not which of its principals acted — so a grant naming one person at the far end would not actually be narrower than a grant naming their organization; it would only look narrower in an interface. The list exists so that the day a per-principal identity layer lands, "principal" is an addition a client can detect rather than a behaviour change it has to guess.
granteeValue must be a realm you have actually paired with. Pairing itself is a bilateral, out-of-band operator ceremony (it is not a JMAP method), which is exactly why FederationPeer exists: without a listing, a client had either a free-text field where almost every entry is rejected, or a selector with nothing to fill it.
FederationPeer is tenant-scoped and readable by any authenticated account of the organization, not administrators only — a peering is a relationship of the organization, with no per-subject view. It carries realm (the value to send as granteeValue), jurisdiction (a closed form, ISO 3166-1 alpha-2 in uppercase, CH, DE, FR: the field used to be free text, so “Suisse”, “Switzerland”, “CH” and “ch” were four jurisdictions for every query, frozen into every grant; the form is checked, never a list of live countries that would go stale), state, and pairedAt. It deliberately does not expose the pinned key, the pairing anchor, the key epoch, or the suspension cause: those are operator bookkeeping, and a public key on every consent screen is security surface bought for no feature.
FederationPeer has no changes method and its state is not a change-log counter — it is a digest of the served list. Pairing and suspension happen in operator ceremonies outside any JMAP request, so nothing writes a change-log row for them; a change-log state here would be a string that never moves while the data under it does.Consent is not delegable
- Only the subject may create or modify a grant, acting on their own account. An administrator may revoke — revocation removes access, so an operator who must cut a leak does not wait for the subject — but never grant on someone’s behalf. A grant an administrator can create for you is not consent. The check is stricter than a role test: a request carrying shared access is somebody acting for someone else, whatever level it holds.
- Each write is one transaction: the grant row, the
ShareNotificationthat tells the subject, and the audit row naming the writer commit together. The audit records who actually wrote it, not the account the request targeted, so an administrator’s revocation is never attributed to the subject. - An unsupported
granteeKindis refused loudly."principal"parses as a well-formed request and would look accepted; the row it created would be a realm grant wearing a person’s name.
granteeKind, granteeValue and expiresAt are settable. rights in particular is not: a client that could widen its own consent record past what the resolver honours would be a permission wearing a setting’s clothes. expiresAt: null means the subject explicitly chose no expiry, and jurisdiction is frozen at grant time — consent was given to a counterparty in a place, and a mutable field would let the place change without new consent.
Why a grant may stop working, and how the subject learns it
FederationGrant.state answers “why can that organization no longer see me?” directly: active, unpaired, expired, dormancy_exhausted, dormant, lapsed or reanchored. The store derives it from the very predicate the authorization read applies, so it cannot drift from the decision. FederationPeer.state (active, dormant, lapsed) uses the same words for the same failures, and only active accepts a grant — again derived from the predicate the write refuses on, so a realm the selector offers is a realm the write takes.
An expiry is announced before it bites. Consent expires by default, which is wanted, but a lapse nobody was warned about is an outage the subject cannot explain. An hourly pass files a ShareNotification for the subject fourteen days before a grant’s expiresAt, on the surface the grant was announced on (objectType: "FederationGrant", objectSubtype: "expiring", the rights unchanged since nothing has changed yet), once per grant: the mark is written in the same transaction as the notification. A renewal that pushes the expiry out keeps the mark, so a grant renewed and then nearing its new expiry is not warned again today. Support reads the gauge oximail_federation_grants_expiring{tenant}, the grants about to lapse, warned or not.
Two consequences worth designing around:
- Revocation is unilateral and instant, and forward-only. A peer never reads or writes your grants; it learns of a revocation by being refused. That is what keeps revocation out of distributed-consistency territory — and it is also why
revocationIsForwardOnlyis advertised as data: revoking removes the read path going forward and undoes nothing already disclosed. - The disclosure log is the other half of that sentence.
FederationDisclosureis what makes “revoking undoes nothing already disclosed” checkable instead of merely reassuring: it survives the grant, and there is noFederationDisclosure/setat all, not even destroy-only. Recording a disclosure advances the subject’s collection state in the same transaction, so a client watching its own log sees it move. Retention bounds the log; the subject cannot erase it. It records disclosures, never attempts, so a peer that probes five hundred addresses creates no rows about the four hundred and ninety-nine people who never consented. Each row names the peer realm, the identity layer that was verified, the jurisdiction the grant stood under at that moment, and when it happened.
Filters and sorts on the two
query methods are refused (unsupportedFilter / unsupportedSort) rather than silently ignored. A client that filtered peers on state: "active" and quietly received the dormant ones too would offer its user a grantee the write then rejects.