Skip to main content
The REST API under /admin/v1 is the operational surface: the endpoints scripts and monitoring call — deploy verification, bans, the queue, config overrides. Administrative data objects (organizations, audit log, journaling rules, admin principal management) live on admin-over-JMAP instead (urn:oximail:params:jmap:v2:admin, ADR-075) — new admin objects are added there, not here.

Authentication

Every endpoint requires the admin bearer token from [admin] token:
Bind the admin surface to a separate local port with [server] admin_bind if you prefer it off the public listener. GET /health is the one route below that is not under /admin/v1: it takes an ordinary session bearer token, not the admin token.

Endpoint map

Deploy & health

Accounts and organizations

Security

Mail operations

Configuration

What lives on admin-over-JMAP

The data objects an admin console binds to are JMAP methods under urn:oximail:params:jmap:v2:admin (ADR-075), gated on an admin session: Tenant/get|set, Principal/getAdmin|setAdmin|queryAdmin|changesAdmin, Principal/setPasswordAdmin, Principal/enableMail (sharing), AccountStorage/get, DeviceSession/queryAdmin|getAdmin|setAdmin, AppPassword/queryAdmin|getAdmin|setAdmin, ErasureJob/queryAdmin|getAdmin (compliance), HealthReport/query|get; the administration log, AuditEvent/*, has its own capability (compliance). The facts worth knowing before binding a form:
  • Tenant/set writes plan, offlinePersistenceAllowed, customEmojiEnabled, maxFileVersionsPerNode (UnsignedInt | null, refused by name above the instance ceiling of 100; files), defaultLanguage (en, fr, de, it) and legalIdentity, the controller the organization declares, written as a whole (null clears it; a hostile value is refused naming its field, legalIdentity/legalName; the instance’s processing countries are not the organization’s to write); Tenant/get projects them with maxPrincipals, resourceLimitsOverride, defaultTimezone and effectiveLegalIdentity, read-only, each value with its source (tenant, instance, derived), see the legal identity. The tenant carries no storage quota since ADR-151: a quota is set per account. A create or a destroy sent to Tenant/set (or to Preferences/set) is refused by name instead of being ignored. Every mutation is journaled, and a change moves sessionState, so an open client re-reads the session.
  • A principal id and its account id are different UUIDs. Principal/getAdmin serves derived properties: accountId, the join key toward AccountStorage, and the account’s two quotas, maxStorageBytes (mail) and maxDriveBytes (Drive; null = the deployment default, 0 = no limit), so a value written by Principal/setAdmin {maxStorageBytes} or {maxDriveBytes} reads back on the same object, null included. Role and status patches on a principal are two-phase (reads and account-plane writes first, then the row writes under one transaction), after a deadlock that had every such patch dying in sqlite busy on every box while the test suite stayed green on a mocked plane. Principal/getAdmin also serves isEntity (entities).
  • The Principal/setAdmin patch is a closed vocabulary. A key outside it is refused invalidProperties, every offending key named with the list of what is settable, instead of being dropped while the answer said updated; a password key is pointed to Principal/setPasswordAdmin. A non-empty permissions list is refused on update as on create (no authorization code reads it); the empty list older clients echo stays accepted.
  • A departure is a patch on a suspended principal. In the same patch as status: "suspended", or on a principal already suspended: forwardTo (the Principal id of the colleague the leaver’s address now reaches, as an exact alias of that address) with an optional forwardUntil, and mailboxGrantTo (the colleague who reads the leaver’s mailbox, a read-only grant the organization wrote) with a grantUntil that is required and at most one year out. null withdraws either, at any time and in any state. Every refusal (a colleague who is not a Principal of the organization, a date without its target, a target already disabled) is decided before the freeze, so a refused successor never leaves a suspended account without its redirect. Principal/getAdmin serves what stands as offboarding ({forwardTo, forwardUntil, mailboxGrantTo, grantUntil}, or null). It is the same engine as oximail account offboard, and it takes the same second-factor check as a role change.
  • Principal/setPasswordAdmin {id, password} sets someone else’s password without the old one. It is a dedicated method, never a patch key, and it is behind the strict second-factor gate: an administrator with no passkey enrolled is refused, not warned, and the session must have been opened with that passkey. The hash, the closing of every open access of the account and the audit line in the subject’s chain commit together or not at all. Closing every access means more than the tokens: every app password and every passkey of the account is deleted, and every active rule that forwards mail is disabled with the forwarded copies still queued marked failed, since a forward outlives a stolen password (forwards). The answer carries tokensRevoked, appPasswordsRevoked, passkeysRevoked, forwardsSuspended and forwardCopiesCancelled, never the password; oximail account set-password and PUT /admin/v1/accounts/{id} do the same. A person changing their own password closes none of that. A shared account never carries a password (forbidden). There is no forced change at first login: the same hash serves IMAP, SMTP, CalDAV and SMB, which cannot show a screen.
  • DeviceSession/*Admin and AppPassword/*Admin see and cut the device sessions and app passwords of another account: queryAdmin {filter: {principalId}} (the one filter, required) lists the ids, getAdmin {ids} serves the metadata, setAdmin {destroy} cuts; create and update do not exist, since a session is opened by authentication and an app password is minted by its holder, who alone sees the secret. A session carries deviceLabel, userAgent, authMethod, createdAt, lastSeenAt, expiresAt, and what support needs to answer “my phone gets no notifications”: hasPushSubscription (a verified, unexpired Web Push subscription for that device), pushLastDeliveredAt (when the push service last accepted a notification for it, recorded at most once every ten minutes) and pushEndpointOrigin (the scheme and host of the push service, Apple, Google or Mozilla, never the rest of the URL, which is a delivery secret; a subscription the service dropped is no longer live but keeps its origin); an app password name, scope (a custom list is served as scope: "custom" with the URNs in capabilities), readOnly, createdAt, lastUsedAt, never the secret nor its hash. Cutting an app password also closes every bearer token it opened. No second-factor gate: these methods only ever remove access. Each cut is an audit line in the chain of the person it happened to, with the administrator as actor.
  • An instance operator is changed at the console only. Principal/setPasswordAdmin, a Principal/setAdmin update and a destroy in DeviceSession/setAdmin or AppPassword/setAdmin that target the account of an instance operator are refused forbidden (the other ids of a batch go through): resetting that password would be reading the whole box. Revoke the privilege at the console first.
  • HealthReport/query|get serve the doctor round of the box. A timer (oximail-health.timer, every 15 minutes, run as the service user; oximail setup health-timer --apply lays it on a box installed before it existed) records oximail doctor --json --record, and reports older than 30 days are purged. A report carries id, takenAt, durationMs, exitCode, status (ok, degraded, failed), ageSeconds, stale (older than twice the cadence: a dead timer reads “last checked three days ago”, never “all is well”) and report, the exact --json array: check, status, detail, plus family (mail, backup, storage, security, accounts, system, extension), facts with the dated facts as UTCDates (observedAt of the last backup or recovery drill, expiresAt of the certificate) and, on a warn or a fail, remedy, the verb to type. query filters on after and before (UTCDates) and sorts on takenAt; get {ids: null} answers requestTooLarge beyond maxObjectsInGet, so the latest report is a query (takenAt descending, limit: 1) followed by a get. A tenant admin reads the reports taken while its organization was alone on the box, which is every report of a single-organization box; a report taken while the box carried two organizations stays out of its reach for good, and only an instance operator reads it. A member is refused accountNotSupportedByMethod at the dispatch gate; an account that carries the admin capability by an override without being an administrator gets forbidden. detail quotes values an organization writes (addresses, aliases): render it as plain text only.

Conventions

  • Responses are JSON; errors carry a machine-readable reason and an HTTP status that means what it says.
  • Everything here is also reachable from the CLI — both are thin adapters over the same orchestration, so behaviour is identical.
  • The API is per organization where data is organization-scoped; there is deliberately no cross-tenant data view.