Skip to main content
One OxiMail server can host several organizations (called tenants in the storage model). Isolation is not a permission check bolted on top — it is built into the storage layer: tenant_id is a mandatory parameter on every store method and every SQL query filters on it (ADR-015). A request handler physically cannot read another organization’s data, even through a bug. RBAC sits on top of that as defense in depth, not as the only line. A single-organization install is simply the degenerate case: the wizard provisions one tenant named default and everything on this page still applies.

Organizations

tenant delete removes an empty organization only, and --force is the confirmation of what its dry run showed: an organization that still holds an account or an organization-wide blob is refused whatever the flags (exit 2; DELETE /admin/v1/tenants/{id} answers 409 with the counts). Accounts carry no foreign key to their organization, so deleting around them used to leave every account, message and blob of it in the base, reachable by no session and by no erasure path. Delete or erase the accounts first; an organization created by mistake and still empty is deleted with its domains and branding. Per-organization state includes its accounts, domains, quotas, its tenant master key (which wraps secrets such as app-password hashes and provider API keys, not content — see encryption at rest; every secret kept under it, the Drive app passwords’ NT hashes, the erasure reversal pre-images and the federation node key, is sealed one way, bound to its field and its row, so a blob copied onto another row does not open there), its runtime config overrides, and policy knobs such as:
  • Default time zone — the last tier of the calendar fallback chain (groupware).
  • Default language — en, fr, de or it (tenant update --default-language, --clear-default-language, or defaultLanguage on the admin Tenant/set), proposed by the setup wizard from the country. An account’s language follows one chain everywhere: the account’s own, then the organization’s, then English. Account creation used to fall back to French and every other reading to English; account create --language has no French default any more.
  • Client offline persistence — whether the organization allows client applications to persist data locally for offline use (offlinePersistenceAllowed, settable over the admin Tenant/set surface). An organization with strict data-residency rules can turn it off.
  • Journaling rules and the erasure reversal TTL — see compliance.
A secret kept under an organization’s key is sealed for its field and its row, and that is the only form read: a blob moved to another row, or written in any other form, is refused. Some texts leave the server toward people who have no account here, the code that confirms a forwarding destination first among them, and they owe their reader the name of who is responsible and where the address is processed (texts sent to third parties). Those are declared facts, never deduced from the network (ADR-164):
  • The organization declares its kind (individual, company, association), its legal name (optional for an individual), its postal address without the country, its country of establishment (an ISO 3166-1 code assigned today, not any two letters), the subdivision when the law or the authority is not the country’s alone (ISO 3166-2, CA-QC, DE-BY), its supervisory authority, and typed contacts (contact, dpo, representative-eu, representative-ch).
  • The instance declares who operates it, with the same fields, plus the countries where the data is processed (the main server’s first, then a backup or a backup MX abroad) and the basis of each transfer to them. Only the console writes it, never an organization’s administrator.
  • A field the organization leaves empty inherits the instance’s at read time. With no contact declared anywhere, the contact is derived: postmaster@ of the mail domain that carries most of the organization’s accounts (the host name is often the organization’s domain on a real box, and no postmaster@ exists there); doctor checks that postmaster@ reaches someone on every domain that carries accounts.
Each set replaces the fields it names and keeps the others; --clear <field> makes one undeclared again (operator-kind, legal-name, postal-address, establishment-country, establishment-subdivision, supervisory-authority, contacts, and for the instance transfer-bases). Both are dry runs without --apply and write one audit line in the tenant’s chain; the instance’s dry run lists the organizations whose effective identity would change. The setup wizard asks for the country (the server’s country defaults to it) and writes the identity from the [identity] section of a setup.toml. Over JMAP, Tenant/get serves legalIdentity, what the organization declared, and effectiveLegalIdentity, each value with its source (tenant, instance, derived); Tenant/set writes legalIdentity as a whole (null makes everything undeclared again) and refuses a hostile value by its field (legalIdentity/legalName), as well as the instance’s own facts (admin API). The country of establishment is also the organization’s federation jurisdiction: the identity ceremony (oximail federation identity) reads it out beside the fingerprints, oximail federation pair records what it told each peer, and doctor (federation_jurisdiction) says when the country changed since a pairing.

Accounts and roles

Roles are admin, member, restricted, readonly, disabled (ADR-028). A disabled account keeps its data but loses agency: no login, no SMTP submission (550), no vacation replies — mail still delivers to it. account enable --role checks the role before writing (exit 2 on a value the server does not hold): an unknown value used to be written, and the account reported enabled while it stayed blocked. Deletion is the erasure phase machine, not a row delete. A service account (a connector such as a practice-management tool, not a person) is declared, never deduced: oximail account create --service, or on an existing account oximail admin principal set-capability with --key service --value true on the account capability object (v2:account) of its principal, which sets service: true there. A connector is not a departure: an operator grant to it carries no expiry (account grant --until is refused), doctor does not count it among the departures and names an expiry already on one of its grants as an anomaly, and its role is member at most (--service with another role is refused, an admin cannot be declared one, and promoting one to admin is refused). It is not the relay role service of the outbound relay, which is another thing under the same word. Each individual account has a mail and a Drive quota (defaults 50 GiB and 1 TiB); shared accounts and system mailboxes have none, and the organization carries no storage quota. See quotas for --drive-mb, --unlimited and --default.

App passwords

Clients that cannot follow the web login flow (IMAP clients, DAV agents) authenticate with per-client app passwords, generated per account. They are independently revocable, and failures feed fail2ban like every listener. A new app password has eight groups of four characters (the familiar look, fine for Outlook, Apple Mail and SMB): the first two name its row, the other six are a 143-bit secret checked by one HMAC under a key derived from the organization’s master key. A client’s first request is therefore one read and one HMAC, where the older form cost at least five Argon2 hashes (about 187 ms measured); a secret that long needs no slow function, and looking the row up removes the loop over the account’s passwords whose length was the timing oracle. App passwords created before keep working unchanged, on the constant-work path, until they are replaced. Every app password carries a scope, chosen at creation: the CLI requires it (no silent full-access default), while POST /auth/app-password takes full when the body omits scope, so a client should always send one. The scopes are full, mail, calendar (which includes tasks — one scheduling credential covers CalDAV VTODO clients), contacts (the CardDAV-sync credential), drive, or a custom list of capability URNs as a JSON array. A named scope also grants the OxiMail capabilities of the surface it covers: mail grants urn:oximail:params:jmap:v2:mail, v2:labels and v2:threads, so a mail credential sends with Email/send over JMAP as it already could over SMTP; calendar grants v2:calendar, contacts grants v2:contacts. A surface of its own comes with full or a custom list only, never with a named scope: v2:rules stays out of mail for the reason Sieve does (a rule can redirect, and a leaked credential would buy a redirect that outlives its revocation), and so do v2:admin and v2:conversation. A custom list is validated at creation on both surfaces: every element must be a capability this server actually serves, and the refusal names the offending entries — a credential claiming a grant the dispatcher would never honour cannot be minted. The CLI (oximail app-password create) validates against the built-in vocabulary and says so; scoping a credential to an extension capability registered at boot goes through POST /auth/app-password, which also knows those. An app password can be read-only (oximail app-password create --read-only, or readOnly: true on POST /auth/app-password), the credential for an assistant that must not be able to delete, move or send. It opens JMAP only: IMAP, SMTP, CalDAV/CardDAV and SMB refuse it at login, since a read-only credential is enforced only where JMAP’s write refusal exists (a read-only drive credential gets no NT hash for SMB). On JMAP the session presents the account as read-only and every method that writes answers accountReadOnly, whatever the scope; the credential cannot mint app passwords either. A disabled account stays disabled: the flag only ever narrows. An administrator sees and cuts the app passwords and the device sessions of another account from the admin surface as well as from the console (app-password list|revoke); cutting an app password closes every bearer token it opened (admin API). Passkeys (WebAuthn) have the same management discipline: GET /auth/passkey and DELETE /auth/passkey/{id} let a signed-in user enumerate and revoke their credentials, and oximail passkey list|revoke is the operator’s out-of-band door — deliberate, because when the credential to revoke is the one that signs into the webmail, the revocation path must not be the webmail. The listing includes each credential’s discoverable hint (was it created usable by a usernameless login), recorded at enrolment.

Shared team accounts

A shared account is a non-loginnable team identity — accounting@, team@ — that owns a mailbox and team resources without being a person:
Nobody logs in to it; humans reach it through grants. Three invariants keep the model honest:
  • A shared account can never be a grantee. A shareWith entry whose principal is backed by a shared account is rejected at write time on every domain — a non-loginnable identity can never exercise a grant, so accepting one would create dead ACL entries.
  • Its drive always has a custodian. The shared account’s file root is not writable over JMAP; custody is an explicit CLI surface. oximail account custodian show lists the current custodians and whether the tenant human-admin fallback holds; custodian reattach --principal <p> [--replace] grants a human-backed principal full rights on the root — audited with the prior state kept in the audit entry, so a transfer is restorable. The account-role writers refuse to remove the organization’s last human admin while any shared drive lacks an explicit custodian.
  • In the directory it is typed honestly. A shared account’s principal is emitted as type: "other", not individual, so people-pickers do not offer it as a person.

Shared contact directories

An organization can publish shared contact sets — a client list, a staff list — that appear in every member’s address book without anyone copying a card around. They are an admin-plane object: they are created and named by a tenant admin, and day-to-day entries are maintained by the principals listed as editors, groups included. Two consequences matter for how you run this:
  • Members get contacts, not a folder. Entries reach a member as read-only cards marked as coming from the organization, on the same plane an external LDAP or SCIM feed would use. There is nothing for a user to accidentally rename, move or delete, and nothing to un-share when someone leaves.
  • The content is the organization’s, not the member’s. A directory is purged by removing its entries; it is not carried along by an individual account’s erasure, because it was never that person’s data.
The surface is served over JMAP for admin and editor sessions — there is no CLI verb for it, deliberately: it is directory content, not server configuration. The method and property details are on the contacts page.

Entities of an organization

A person’s directory card can name their employer. So that “ACME SA” is one thing rather than five spellings, the employer is an entity: a group the administrator declared as such, never deduced (every group has a backing account, so “has an account” would make every team and every sharing group an employer). A card cites an entity by reference and reads its name from the group, so renaming the group corrects every card; withdrawing the mark of an entity that cards still cite, or destroying it, is refused with the count.
  • create makes a group born an entity, with its own backing account through the common provisioning (the organization’s account cap, an owned domain, a unique address); a name a group of the organization already carries is refused by name. Without --email the address is generated (grp-<id>@<primary domain>) and receives nothing until its mail surface is laid (Principal/enableMail, sharing).
  • set-entity declares an existing group an entity (--value true) or withdraws the declaration (--value false), with the same refusals as the JMAP write and one audit line (principal/set-entity).
  • list sorts what the cards carry: free-text spellings to attach (each named by an eight-character fingerprint, never by its string nor a rank: one spelling measured in production carried three trailing spaces), sound references, degraded ones, and entities no card cites. It names the state of the account behind each card (active, closed with its purge date, or no account) and sets apart the spellings that only closed accounts carry. Read-only; exit 1 on a dangling reference or one to a group that is no longer an entity.
  • set attaches every card carrying exactly that spelling to the entity, closed accounts’ cards included, and says which accounts are closed. A closed account’s card is rewritten, so a reopening shows the entity, but not published (attached-not-published). It guesses no attachment: the decision is human, the verb applies it.
A closed account is not in the people directory: closing it (a disable, a policy hold, the start of an erasure) withdraws its card from every colleague’s address book, and reopening it publishes the card again, which stayed on the account. oximail doctor warns (directory_closed_accounts) while a closed account is still published, for instance one closed before this rule; oximail admin profile backfill then withdraws it (would-withdraw, then withdrawn with --apply), and publishes as before the cards of open accounts that have none. Every writing verb is a dry run without --apply. The JMAP side (isEntity, cardReferenceCount, the Principal/query filter) is on the sharing page.

Capacity caps

Chat capacity is bounded per organization via the [chat] TOML section, and the enforced values are advertised in the chat capability object so clients can respect them before hitting the error: These bound the fan-out write product (every room message writes one row per member).

One server, several organizations — the boundaries

  • Mail domains belong to organizations; an account’s address must be in a domain its organization owns.
  • Blobs are stored under per-organization paths, and the registry row that names the owning organization is the only authority a read consults; cross-organization deduplication does not exist by design.
  • Journaling destinations, share grants, and every JMAP object resolve strictly within the organization.
  • The admin API and CLI operate per organization (--tenant-id); there is deliberately no “all tenants” data view.

The instance operator

A few things describe the box, not an organization, and name several of them: the periodic doctor report (HealthReport/query|get, see the admin API) quotes addresses, domains and account ids of every tenant, and the account total of GET /health tells how many accounts the others hold. On a box where one organization is alone, its administrators read the reports taken while it was alone, with nothing to set. On a box with several, what names another organization is read by an instance operator, a privilege outside any organization, set at the console only:
  • The holder is a person who administers their own organization. grant refuses an account that is not a human admin of its tenant, or that has no second factor (passkey) enrolled, naming what is missing; --by is the person who decides (a name, never an address), written on the audit row under the holder. Both verbs are dry runs without --apply.
  • Checked at every call, never deduced. The privilege holds only while the account has the row, is still an admin and still has its second factor; an account disabled or demoted, or whose passkey is gone, keeps an inert row that opens nothing, and the erasure of the account removes the row. doctor (instance_admin) names each inert row with its reason, and warns on a box with several organizations and no active holder.
  • JMAP reads it and never writes it. No role value names it, so no route that writes a role can set it. A revocation takes effect within 30 seconds, the server’s capability cache.
  • The holder is protected from the administrators of their own organization. Setting their password, any Principal/setAdmin update of their principal, cutting their device sessions or app passwords from the admin surface are refused forbidden: act at the console, after revoking the privilege.
  • It opens a closed list: every HealthReport, and the box total in GET /health. It inherits nothing else, not the audit logs of the other organizations in particular.