Skip to main content
Alongside the standard JMAP capabilities, OxiMail serves a family of v2 extension capabilities under urn:oximail:params:jmap:v2:*. This page describes the surface as built — what the server actually negotiates and serves at v0.30.0. The extensions are being written up as Internet-Drafts for IETF submission; see IETF drafts for the programme.

How v2 is negotiated

v2 is not a version switch — it is per-capability opt-in, exactly like any JMAP capability:
  • A client requests a v2 capability in using; the methods it unlocks become callable in that request. Nothing about v1 behaviour changes for clients that never ask.
  • Method names are dual-capability aliases where a v1 and a v2 capability cover the same handler: the registry maps both spellings onto one implementation, so there is exactly one behaviour per method, whichever capability unlocked it.
  • The mutual-exclusion set is empty at v0.30.0: pairing a v1 capability with its v2 counterpart in the same request is allowed (see JMAP Core for the mechanism).
  • As with everything else, the session object is the source of truth: each account’s accountCapabilities entry for a v2 URN carries that capability’s advertised limits and options. Feature-detect from the session, never from the server version.

The v2 capability objects

For each capability, the authoritative wire contract is what the session advertises plus the method behaviours — the strictness rules (mail page) apply to v2 methods identically: unknown arguments and properties are errors, never silently ignored.

What the capability objects advertise

The session’s capability objects are self-describing — clients build their UI from them instead of hardcoding server knowledge. The load-bearing entries as served at v0.30.0:
  • v2:rules carries the rule editor’s whole vocabulary: supportedFields (18 condition fields) and supportedActions (15 action types) — a client renders its rule builder from these lists — plus the enforced bounds (maxRulesPerAccount: 1000, maxActionsPerRule: 20, maxConditionDepth: 20, maxRuleNameLength: 256, perRuleExecutionTimeoutMs, maxApplyEmailsPerCall, the operator’s [rules] max_apply_emails_per_call, maxNotificationsPerHour: 30), and the operator’s forwardPolicy (any, internal, verified, none), so a client knows before it asks for a confirmation code whether the server will send one.
  • v2:admin is only present in accountCapabilities for admin-role accounts — its absence is how a client knows not to render an admin surface at all. It advertises supportedPrincipalRoles, the retention facts (softDeleteRetentionDays: 30, auditLogRetentionDays: 2557 — seven years), and mfaRequiredForSensitiveOperations: true: role changes and tenant mutations by an account that has enrolled a passkey require a passkey-authenticated session.
  • urn:ietf:params:jmap:auditlog (admin-gated like v2:admin) advertises the tamper-evident audit log: {hashAlgorithm: "sha-256", verificationSupported: true, retentionDays: 2557}. AuditEvent/verify recomputes the hash chain and reports the first altered or missing entry.
  • v2:mail advertises the enforced sending bounds — maxSizeEmail (50 MiB, also pre-announced through the SMTP SIZE extension), maxScheduleDelay (one year on Email/send sendAt) — and emailQuerySortOptions, the exact sort vocabulary Email/query accepts. The v2 Email object also carries expiresAt (auto-trash at a chosen instant) and Email/send accepts requestMdn (RFC 8098 read receipts) and tlsRequired (RFC 8689 REQUIRETLS, including scheduled sends). smimeSigner, the signer of a verified S/MIME signature (issuer, validity, addresses, validation profile, revocation checked or not), is served only with v2:mail in using, next to the RFC 9219 properties of smimeverify (signed messages).
  • v2:streaming advertises replayWindowSeconds (the real change-log retention horizon, 90 days — a Push/catchup from any state inside the window replays exactly what was missed), maxQueryStreamChunk: 1000, and the push-subscription verification pacing.
  • v2:push-patches advertises maxPatchSize (1 MiB), maxBatchEntries: 100, maxPatchesPerSecond: 10, supportedTransports: ["websocket", "eventsource"] (the EventSource transport is GET /jmap/push-patches), types, the emission names a subscription may follow, and maxConcurrentStreams: 4. A client opts in per connection with EnablePushPatches {types, properties}; updated entries then carry exactly the changed properties, and degraded cases fall back to a plain StateChange (reconcile via Push/catchup). A property that becomes null is a change: under an explicit per-type projection, a projected property present in the baseline and absent now is sent as null, once; without a projection the server cannot tell a cleared property from one the client never asked for, and that collection falls back to StateChange for the window instead of guessing. A patch used to say nothing, so a client kept the old value (a room list still showed its last text after a call message). On a patch-enabled WebSocket, Core/queryStream with pushChunks: true becomes a pushed stream: the response carries {accountId, sessionId} and the server follows with StreamChunk frames until the walk ends or the client cancels it with that sessionId; past maxConcurrentStreams the call degrades to the client-paced answer with a tooManyConcurrentStreams frame, and a failure mid-stream sends streamFailed, naming the error, so the client re-issues it client-paced. A stream opened on a shared account stays on that account (sharing).
  • v2:resource-limits now only advertises limits something actually enforces, from the same constants the handlers apply — including maxTotalRequestsPerMinute, injected from the operator’s effective rate-limit configuration (absent when no rate limiter runs). maxConcurrentRequests lives on the core capability (RFC 8620) and is enforced per account on both JMAP transports.
Threads and Conversations gained first-class organization surfaces during the same cycle: per-thread muted (inherited by newly ingested messages) and pinned, Thread/split, Thread/merge, and /queryChanges on every queryable v2 type.

The projection canon

Every /get validates its properties argument against a closed, per-type vocabulary, before it reads the store. An unknown name comes back as invalidArguments naming the offenders; id is always accepted (RFC 8621 makes it implicit), and * selects all top-level properties. It is an error rather than a shrug for a precise reason. Under the older serialize-then-filter pattern, a misspelled property produced a success with that property absent — indistinguishable from a property the server had chosen not to serve. A client could not tell a typo from a server limitation, and neither could anyone reading the logs. A closed vocabulary is only useful if you can read it, so the canon is published in machine form next to the generated TypeScript types: DERIVED_<Type> is the part a client cannot reconstruct on its own. mayEdit on a tenant directory is computed per caller from their tenant role and a server-side group expansion; no struct field carries it, so the TypeScript type alone says nothing about it. The registry lists only genuine out-of-struct injections — a property that is a struct field rewritten per viewer, such as myRights, is already typed and deliberately absent from it. The published canon is an additive-only contract, the same rule as the --json CLI surface. v2:projection on the session is the conformance marker that the model holds across every type.

The write vocabulary

The same discipline holds on the write side, declared type by type. A /set handler reads the keys it knows, one by one; a key it does not know is compared to nothing, triggers nothing, and the answer used to say updated. That is the first rule taken backwards: not a silent failure but a silent success, which is worse, because a failure eventually shows and a false green never does (timezone against timeZone was one such case). A type may now declare its settable canon to the dispatcher: an allow-list of the properties it accepts on create and update, plus the properties it knows and refuses anyway, each with its reason. The dispatcher then removes, from create and update, every object that names a property outside the canon, and merges the refusal into notCreated / notUpdated: the handler never sees the object, so the patch is all-or-nothing for it, and the other objects of the same call go through (RFC 8620 §5.3). Two refusals, because being wrong about a name and being wrong about a path are not corrected the same way:
  • an object of create carries ordinary properties; a name the type does not define is invalidProperties, naming it;
  • an object of update is a PatchObject whose keys are JSON pointers, and a pointer whose root is not a property of the type violates the RFC’s “every part before the last must already exist” — invalidPatch.
A property the type computes, or whose writing goes through another gesture, says which: Principal.receivesMail is refused with the name of the method that makes a team reachable, rather than as “unknown”, which would be exact and unusable. A whole object read back is a valid patch. RFC 8620 §5.3 lets a client send back an object it read, server-set properties included. The canon would have refused any update naming a server-set property; the dispatcher now sets those names aside and, after every authority check, re-reads them through the type’s own Foo/get: when each equals its current value the patch is admitted without them, otherwise the refusal stands, by name. Declaring is deliberate and per type, never in bulk: turning an ignored argument into an error breaks a deployed client exactly as a rename would, and no server test can see it, so each type declares once the sweep of its emitters is done. That sweep is now complete: the types of the JMAP surface declare their canon, Email and Thread included. Email accepts on a create, besides its named properties, the RFC 8621 §4.1.3 header:<Name>[:<form>] pseudo-properties, matched by prefix.
Wire stability: within the v0.30 line, v2 shapes evolve additively — new optional properties and new methods, no renames and no semantic changes to served fields. Hardening steps that turn a previously-ignored argument into an error are staged (log first, reject after clients are confirmed clean).