Plus
/etc/oximail/ — config, DKIM keys, and the root key material, which no longer guards content but still wraps secrets valid elsewhere (app-password hashes, provider API keys, the federation node identity). The three data locations plus /etc/oximail are the complete backup surface (operations).
How mail is laid out
A message is stored once, in two forms (ADR-009): the raw RFC 5322 bytes as a blob, and the parsed metadata (headers, structure,hasAttachment, preview, thread) as rows. Nothing re-parses on read. Blobs are addressed by the SHA-256 of their cleartext, so an attachment sent to ten colleagues is stored once per organization — deduplication happens below the mailbox level, and two copies of an email in two mailboxes are always two metadata rows pointing at one blob (never deduplicated at the message level; that class of bug is designed out).
The blob directory is namespaced per organization and per account ({tenant}/{account}/…, with a reserved _tenant/ area for organization-wide content), which is what makes per-organization isolation and per-organization backup physical rather than logical.
Folders
Folders are JMAP Mailboxes with RFC 8621 roles (inbox, sent, drafts, trash, junk, archive), provisioned per account in the account’s language. Hierarchy, per-folder counts, and sharing (shareWith) are all data. The IMAP bridge shows the same tree. Duplicate folders left over from old imports are repaired with oximail account dedupe-folders (migration).
Trash, undelete, and retention
Destruction is two-stage with a visible trash (ADR-035): a destroyed object lands indeleted_items and can be restored — this backs the trash surfaces in every client and the recovery of destroyed DAV resources. Independent of user-facing trash, two retention windows matter operationally:
change_log: 90 days. The delta-sync history (/changes,sync-collection) is pruned past 90 days; a client absent longer than that does a full resync instead of an incremental one. This is by design — the change log is a sync mechanism, not an audit trail. The purge keeps the newest row of each collection, so a collection that saw no change for 90 days keeps its state and its clients stay incremental. It used to be emptied, its state fell back to 0 and its numbering restarted below the floor it had just recorded: every/changeson it was refused withcannotCalculateChangesfor good, and a client resting between the two was served nothing. A schema migration repaired the collections already caught: an up-to-date client lost nothing, and any older cursor resyncs once rather than missing a change.audit_log: years. The administrative audit trail has its own long horizon with an enforcing retention worker (compliance).
retention.trash_days and retention.chat_days (per-tenant config overrides) take effect from 1 day, where a value under 7 used to be silently raised to 7 by the workers and refused by the admin API for the trash, so the server kept data longer than the controller asked; no value or 0 still means no purge. The trashes of tasks, contacts, events and notes go through the Drive’s exit, with the same respect for legal holds and the same announcement to synchronised clients, instead of a hard-coded 30 days destroyed in silence. Mind the difference: the mail trash is not purged until a tenant sets retention.trash_days, whereas the six other trashes (files, events, contacts, tasks, notes, chat rooms) are purged at the instance value (30 days by default) until a tenant overrides it (per-tenant horizons).
Blob garbage collection removes blobs no longer referenced by any row — with reference tracking that includes non-obvious holders like edit history, so GC never orphans content that something can still display.
Chat rooms
When the owner of a group or channel deletes it for everyone, the room goes to a trash instead of being erased. Every member’s copy of the room, its messages, read receipts, saved messages and polls move to trash tables, and the messages leave the search index. Messages scheduled and never sent are destroyed. Members stop seeing the room; its owners can still find it and restore it.- Purge. The trash sweep destroys a room trashed more than
trash_retention_daysago. A tenant can set its own horizon with thechat.trash_retention_daysoverride,0for no purge (per-tenant horizons). A client cannot purge: deleting a trashed room again answers “not found”, so a double click never erases a room for good. A room where one member account is under a legal hold is kept. A tenant chat retention policy (retention.chat_days) also reaches messages in the trash. - Purge by hand.
oximail chat purge-trashreports what the sweep would destroy, with each tenant’s horizon;--applydestroys it,--dayssets one horizon for the whole scope and--days 0empties the trash,--tenant <id>limits it to one tenant,--jsonis for scripts. Exit code 1 when some rooms could not be destroyed. It works with the server stopped. - Restore.
oximail chat restore-room --tenant <id> --room <id>reports what would come back;--applyrestores. The copy of a member whose account was deleted or disabled stays out, and the report names that member. The restore is refused, and nothing is written, if a live room already carries the same id. - Restore from a backup. A room deleted before the trash existed, or already purged, comes back with
--from-backup <snapshot data.db>, plus--blobs <snapshot blob directory>for attachments the instance no longer holds. The snapshot is opened read-only, and each recovered attachment is checked against its hash before it is registered. An attachment found nowhere is named in the report, and the command exits 1. - Owners. Through JMAP, an owner lists trashed rooms with
ChatRoom/queryandfilter.isTrashed: true, and restores one with aChatRoom/setpatch{"isTrashed": false}. The chat capability advertisesroomTrash: true. - Audit.
chat/room.trashed(its details say whether the owner typed the room name to confirm),chat/room.restored, andchat/room.destroyedfor the purge.
Quotas
Every individual account has two storage quotas: mail (default 50 GiB) and Drive (default 1 TiB, files plus their trash and stored versions). They have no quota at all:- a shared account (team mailbox, team drive), whatever its size;
- a system mailbox: the directory entries of type
other(postmaster,abuse,hostmaster,dmarc-reports, …), and any mailbox you reclassify withoximail admin principal set-type --account <email> --type other(a journaling archive, a report sink; it also leaves the people pickers); - an account the administrator declared unlimited.
--mb 0 is refused: say --unlimited or --default. The admin surface sets the same values through Principal/setAdmin (maxStorageBytes for mail, maxDriveBytes for Drive; null = default, 0 = unlimited).
What a full quota refuses.
- Inbound mail is deferred at
RCPTwith452 4.2.2. The sender retries, and the message arrives if space is freed before the sender’s queue gives up. A message the server already accepted is always delivered. - Distribution groups: a full member is skipped (logged and counted); the other members receive the message.
- Clients:
Email/set,Email/import,Email/copy,FileNode/setandFileVersion/restoreansweroverQuota; IMAPAPPENDanswersNO [OVERQUOTA]. - Drafts and Sent keep a margin of 1 GiB beyond a full mail quota, so a person can still send from a full mailbox.
- Drive: a save that makes a file smaller is never refused.
[quota]:
/jmap/upload-tenant, which chat attachments and room avatars use, applies the disk guard, the full-quota refusals and the 24-hour window exactly like /jmap/upload; it used to check only the unit size and the concurrency, so an account could fill the disk without sending a message.
Usage is one counter per account and resource, kept in the transaction of every write. oximail account recount compares each counter with the rows it accounts for (dry-run by default, --apply repairs), and oximail doctor reports the same comparison as quota_usage, plus quota_full for accounts at or past the soft threshold. Refusals are logged with the account and counted in oximail_quota_refused_total{resource,door}. oximail admin account-storage --json adds mail* and drive* fields per account.
The storage guard
Independently of any account, the server measures the free space of the volumes it writes to (blob store, database, search index) every 30 seconds. Under[storage] min_free_percent (default 5, 0 disables it) user additions are refused temporarily: inbound mail and submission are deferred with 452 4.3.1, queued local deliveries wait, clients get overQuota with a description that names the server (not their mailbox), and uploads answer 507. The guard lifts one point above the threshold. oximail doctor reports storage_free (warning under twice the threshold), and oximail_storage_free_ratio{path} exports the measurement.
Other bounds:
- Per collection: volume caps (ADR-072) bound what any single collection can grow to.
- Chat capacity caps are separate (multi-tenancy).
The search index is disposable
The Tantivy index carries no primary data: it holds the searchable text of mail, notes and chat messages, all of it recomposable from the database and the blobs. It lives at[storage] search_path, or beside the blob directory when that key is unset; the server, oximail reindex, oximail migrate, the storage guard and doctor all resolve that path by the same rule. Note the index is the one store whose content is not encrypted per account (it must be tokenized server-side to be searchable), which is why account erasure sweeps it explicitly. A removed document’s text stays in the segment files until the next compaction, which runs at the end of each run of removals; doctor search_index shows how many removed documents are still on disk and when the last compaction ran.
Every deletion leaves an order, even while the index is closed. A destroyed message, chat copy or note writes an index-purge order in the destroy outbox, in the transaction of the deletion; the worker carries it out once an index is open and nothing waiting is consumed in the meantime. Notes used to be removed only by a live index in the same process, so a page destroyed while the index was closed, or purged from the trash, stayed findable once the index came back and escaped an erasure.
The index rebuilds itself after an upgrade
The index has a generation number, raised when a release changes what it holds (generation 3 keeps the subject,From, To, Cc and body as five fields, where earlier generations held one concatenated text). At the first start of a binary that writes a newer generation:
- every row is marked due, the old index is moved aside to
<path>.previous-g<N>and an empty one is opened in state rebuilding; the listeners start without waiting; - the new index is seeded from the old one, so search is served full from the first minutes, without advancing anything: the text of the old generation was composed by the old rules;
- every message (from its raw file), note and chat copy is recomposed from the stored data, 256 rows a commit with a 2-second budget per slice, so live writes never wait long behind a commit; a row that cannot be projected is set apart after three attempts;
- the set-aside directory is removed once the seed is over. A crash at any step resumes from the beginning of the seed at the next start, recognising the documents already written.
doctor search_text_evidence counts those messages, data loss named rather than silent. An older binary refuses an index of a newer generation by its number, and the database migration of the same release refuses that binary anyway (upgrades). The removal of the set-aside index no longer logs a spurious ERROR line from the index library at that first start: the rebuild closes its read-only view of the old index before removing it.
An index the server cannot open
An unopenable index is never a silent fallback. Its state is published per collection byoximail_search_index_state{collection} (0 open, 1 rebuilding, 2 unopenable; alert on > 0 for longer than a rebuild), the boot journal writes one error line saying what the server does instead and the one remedy, and oximail doctor fails search_index with the same text. Meanwhile mail is still delivered and left due for a server that opens its index, index-purge orders wait, and nothing waiting for the index is consumed. Email/query answers text and body from the subject, the addresses and the preview and says so to clients of the vendor mail capability (fullTextIndexUnavailable); ChatMessage/query and Note/query refuse. The server opens its index only at start, so the remedy is: stop the server, rebuild, start it again.
oximail reindex is offline: it is refused (exit 2) while a server holds the index. It settles any index a rebuild set aside (carrying over the text of messages whose raw file is gone, then removing it), marks every row due, removes the index directory (sets it aside when unreadable) and runs the catch-up until nothing is due, in batches of 2 048 rows, since no live write waits behind its commits: about 3 300 documents a second were measured at that size, against about 400 at the served 256. It rebuilds mail, notes and chat; contacts are not in the index. --tenant <id> keeps the index and writes that tenant’s documents over it. Exit 0 complete, 1 when rows are still due or an unreadable set-aside index was kept, 2 refused or failed; --json gives {verb, state, detail, marked_due, seeded, indexed, left_due, evidence_recorded, set_aside{settled, kept}}. Beside a running server, oximail email reindex-bump --unindexed makes due the messages absent from the committed index, for the server’s own catch-up, without stopping anything.
doctor reads the index read-only, never creating one:
Database maintenance
None scheduled — this is deliberate. WAL checkpointing is automatic (bounded at ~64 MiB, checkpointed at shutdown), query-planner statistics are refreshed after migrations, and the connection pool needs no tuning below a few hundred concurrent users ([storage] pool_size, default 32, raise on read-heavy machines). The operational database story is: keep disk space, keep backups, and let the server run.
oximail sql exists as a guarded escape hatch for support scenarios; day-to-day inspection goes through the CLI verbs (oximail mailbox, oximail email) and the admin API’s per-account data views.
What an acknowledgement promises
The database runs in WAL mode withsynchronous = NORMAL: a commit survives a crash of the process, but a power loss or a crash of the host can roll back the last few seconds of commits. Most writes can live with that. Three replies cannot, because they tell the other side it may forget the data: the 250 after the data of a message (RFC 5321 §6.1 forbids losing a message after it, “because the host later crashes”), on the inbound port and on submission, and the reply to a FLUSH on the file share (which promises the data reached the underlying storage).
Before each of these replies the server runs a durability barrier: an fdatasync of the WAL file, outside SQLite’s write lock, which makes every commit written before it durable, whoever wrote it. Only the client that asked waits (2.5 ms per sync measured on a production disk). A failed barrier on SMTP answers 451 instead of 250: the sender retries, a duplicate rather than a loss. When an SMTP door is durable, the relay spool also syncs the message file and its directory.
oximail_durability_barrier_seconds{door,result} measures each barrier (metrics).