Objects and methods
Content moves over the standard JMAP blob endpoints (
/jmap/upload, /jmap/download) — a FileNode create references an uploaded blobId. FileNode declares its write vocabulary to the dispatcher: a property a client cannot write is refused by name, never silently dropped.
Downloading
A blob is immutable by construction (its id is the SHA-256 of the content), and the download door says so./jmap/download answers with ETag: "<blobId>" and Cache-Control: private, max-age=31536000, immutable (private: the resource is authenticated, never for a shared cache), and an If-None-Match carrying the right tag receives 304 without a body. A single Range: bytes= request is honoured, 206 with Content-Range, 416 when unsatisfiable; a malformed or multi-range spec is ignored and the full 200 served, as RFC 9110 permits. Since ADR-145 the body is streamed from the file itself, one window in memory at a time, so a large file costs the process nothing more than a small one. The public share door (/share/{token}) honours the same Range but keeps Cache-Control: no-store: its answer counts against a download quota, and must never be re-served by a cache without passing the counter.
Version history
Every content update to a node snapshots the previous blob into its version history, in the same transaction as the update. The current content always lives on the node itself;FileVersion is history only, and every one of its properties is immutable:
The
urn:ietf:params:jmap:files capability advertises maxVersionsPerNode: the effective value for the tenant, never a constant. It is the retention window and the pruning bound: versions past the newest N are pruned in the same transaction that created the newest one, and their blobs are released through the refcounted destroy path — never a direct delete, since a blob may be shared by deduplication. A client should gate its “version history” menu entry on the presence of that key rather than assuming the surface exists.
The retention is a tenant setting, applied hot: oximail tenant update --max-file-versions N (or --clear-max-file-versions), default 10, instance ceiling 100, and 0 disables history, which short-circuits the snapshot instead of inserting and pruning it in the same transaction and releases the existing versions at the next save. Tenant/get serves maxFileVersionsPerNode as the stored value (null for the default, so a console tells “default” from “set to 10”), Tenant/set accepts it, and a change moves sessionState, so an open client re-reads the session instead of keeping the old announcement until it reconnects. The effect lands at the next save of each file; there is no immediate pruning verb.
FileVersion/queryrequires afileNodeIdfilter. Versions are meaningless as an account-wide list, and requiring the anchor keeps the surface bounded by the retention cap. Newest first by default. ItsqueryStateis the FileNode collection state — versions only change when a node’s content changes, which moves that state, so one cursor is coherent for both.canCalculateChangesisfalse: there is no separate version change log, so re-query when FileNode changes.FileVersion/restorenever destroys history. It goes through the same storage chokepoint as aFileNode/set { blobId }content update, so the content you are currently on is snapshotted as a new version before being replaced — the version you came from stays reachable, and pruning applies as usual.sizeandcontentTypeare restored from the version record, which is authoritative because it captured them at snapshot time. Restoring the version whose blob is already current is a content no-op but still answers success. It takes an optionalifInStatefor the same optimistic concurrency asFileNode/set, checked atomically with the write.- Access follows the node. Reading the version list needs
mayReadon the owning node,restoreneedsmayWrite(a read-only shared mount answersforbidden). A node that does not exist and a node a shared caller may not read answer with identical wording — the version surface is not an existence oracle.
Locks: who holds a file open
A file can be held open by a protocol that has an “open”, plugged into the same binary through the server’s seam (an SMB engine, for instance). The rule is the protocol that has an open holds; the one that has none consults. The registry lives in memory, in the process, so a hold dies with the connection or the binary and needs no lease or sweep. JMAP never holds anything:FileNode/set refuses with forbidden any update, rename, trash or destroy of a held node, and FileVersion/restore refuses to replace its content, naming the holder and the age of the lock (locked by alice@x.test via SMB (12 min), and N other open(s) when several). FileNode/get serves lockedBy (String | null, the oldest holder’s label and protocol), read live and outside the /changes state: an indicator for the client to display, where the guarantee is the refusal at write. The grain is the node, not a byte range, since a JMAP writer replaces the whole blob. Without such a protocol the registry is empty and the property is always null.
Mirror accounts
A mirror account is a shared account whose Drive shows, read-only, the folders a paired organization shares with this server (federation). A client sees an ordinary shared account and the ordinaryFileNode methods; what differs is where the tree lives and who may write it.
- The tree is held in memory, copied from the owner’s server and kept at most
[federation] mirror_staleness_secsold (120 s by default); nothing of the owner is stored on this server. A mirror’sFileNode/changesstate is<epoch>.<sequence>: a restart mints a new epoch, so a state from before it answerscannotCalculateChangesand the client resynchronizes.FileNode/queryChangesalways answerscannotCalculateChanges: a mirror keeps no query log, the client queries again. - Read-only for every caller.
FileNode/seton a mirror account is refused withforbidden, its description startingmirror-read-only, before anything is read;myRightsgrants reading only. - A named state, never an empty folder. While the copy is in progress, the owner unreachable, the access revoked or the pairing inactive, the listing carries one file whose name says so (
(copy in progress from partner.example), …) instead of the tree. - Downloading reads at the owner.
/jmap/downloadserves a mirrored file by reading its content at the owner’s server, behind a short in-memory cache. An owner that cannot be reached answers503withtype: "ownerUnreachable", a refusal from the owner502withtype: "ownerRefused";RangeandIf-None-Matchbehave as above. A mirrored file has no thumbnail (404,type: "noRendition").
Behaviours worth knowing
typeis honored.type: "directory"creates a directory; the type field is validated, never guessed from the presence of a blob.- The name implies the type. A file created without
contentTypegets the MIME type its extension implies, from the same table the other doors use; an explicit type always wins, and a name that says nothing leavesnullrather than a guessedapplication/octet-stream. ids: nullis bounded.FileNode/getwithout ids returns every node only while their number, counted on what the caller would receive, does not exceedmaxObjectsInGet; past it the call answersrequestTooLarge(RFC 8620 §5.1), asEmail/getdoes, and a client pages withFileNode/query(aparentIdfilter, say) thenFileNode/geton its ids. The reads behind it go by batches of 500, so a Drive of tens of thousands of nodes no longer fails on a database parameter limit.- A name Windows reserves for a device is refused.
CON,PRN,AUX,NUL,COM0toCOM9andLPT0toLPT9, in any case and with or without an extension (aux.txt,nul.tar.gz), areinvalidPropertiesat creation and at rename: served as is on a file share, such a node could be neither opened, renamed nor deleted from the Windows Explorer. The other characters Windows refuses (< > : " | ? *, a trailing dot or space) stay allowed; translating them is the file-sharing face’s job. - Windows attributes are kept. A file-sharing protocol plugged into the binary (an SMB engine, for instance) can declare read-only, hidden, system and archive on a node; the server keeps them as declared, off the JMAP wire (a Windows notion no JMAP client needs), and enforces none of them itself. Any change of a file’s content, through any door (
FileNode/set, a version restored, a file share), sets archive again, so a Windows backup tool’s next incremental run picks the file up. - The tree is rooted; the wire uses
null. Every account has a storage root;parentId: nullin a query means “top level”. Directory moves are cycle-checked — a move that would create a parent cycle is rejected. - Trash rides the same transaction.
isTrashedupdates are atomic with the rest of the update (a recursive directory trash is one transaction), and trashed nodes restore from the visible trash (ADR-035). The trash sweep destroys what has been in the trash longer than the tenant’s horizon, itsdrive.trash_retention_daysoverride or else[drive] trash_retention_days(30 by default, floor 1; an override of0purges nothing, see per-tenant horizons), through the same exit asoximail drive purge-trash: an account with a message under legal hold keeps its files too, and each node dies in one transaction (blob released through the destruction outbox, row, versions, share links) with aFileNodeDestroyedchange announced, so a synchronised client’s trash never keeps what the server removed (storage). - Stars are per-viewer. Starring a shared file marks your view; the owner’s object is untouched.
- An absent right is “no”. A node shared
{mayRead, mayWrite}grants nomayDelete: a key missing from a stored grant reads as false, for every bit, in every vocabulary. The reading rule that once upgraded write grants without the key let a grantee trash the root of the share itself, in the owner’s drive. Rights on one node are also grantable from the CLI,oximail drive share --path Partages/Scan --grantee-email … --rights mayRead,mayWrite, through the same validation chain asFileNode/setand with the sameShareNotificationto the grantee; that notification carriesobjectSubtype(directoryorfile), so a revoked share, whose node the grantee can no longer resolve, is still named for what it was (CLI). - The quota is charged the exact net growth. A content update is charged the new size, plus the stored version of the old content, minus what the retention prunes: measured before and after the writes, and a growth that crosses the Drive quota is refused
overQuotawith the whole object rolled back. A patch that does not grow the Drive (a smaller file, a rename) is never refused; a new file is refused before its row exists (ADR-151, quotas). - Thumbnails are server-derived for previewable types — clients do not upload their own.
- Share links are scoped and accounted. A
FileShareLinkgrants access to exactly the node it names, enforcesmaxDownloadsexactly, and a failure after the counter moved (a missing blob, an unsatisfiable range) refunds the unit, so a probe never burns the recipient’s quota. A link on the account’s internal root answers like a nonexistent node, and a malformed token feeds fail2ban: the generator only ever minted 32 alphanumeric characters, so anything else is a probing signature. The blob served is the one the node references, resolved in a single join with the link; an anonymous reader reaches nothing else.