Skip to main content
The drive is the JMAP File Storage draft surface (FileNode objects over the standard blob machinery): a tree of files and directories per account, shareable per node, with anonymous download links as a separate object.

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/query requires a fileNodeId filter. Versions are meaningless as an account-wide list, and requiring the anchor keeps the surface bounded by the retention cap. Newest first by default. Its queryState is the FileNode collection state — versions only change when a node’s content changes, which moves that state, so one cursor is coherent for both. canCalculateChanges is false: there is no separate version change log, so re-query when FileNode changes.
  • FileVersion/restore never destroys history. It goes through the same storage chokepoint as a FileNode/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. size and contentType are 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 optional ifInState for the same optimistic concurrency as FileNode/set, checked atomically with the write.
  • Access follows the node. Reading the version list needs mayRead on the owning node, restore needs mayWrite (a read-only shared mount answers forbidden). 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 ordinary FileNode 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_secs old (120 s by default); nothing of the owner is stored on this server. A mirror’s FileNode/changes state is <epoch>.<sequence>: a restart mints a new epoch, so a state from before it answers cannotCalculateChanges and the client resynchronizes. FileNode/queryChanges always answers cannotCalculateChanges: a mirror keeps no query log, the client queries again.
  • Read-only for every caller. FileNode/set on a mirror account is refused with forbidden, its description starting mirror-read-only, before anything is read; myRights grants 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/download serves a mirrored file by reading its content at the owner’s server, behind a short in-memory cache. An owner that cannot be reached answers 503 with type: "ownerUnreachable", a refusal from the owner 502 with type: "ownerRefused"; Range and If-None-Match behave as above. A mirrored file has no thumbnail (404, type: "noRendition").

Behaviours worth knowing

  • type is 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 contentType gets 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 leaves null rather than a guessed application/octet-stream.
  • ids: null is bounded. FileNode/get without ids returns every node only while their number, counted on what the caller would receive, does not exceed maxObjectsInGet; past it the call answers requestTooLarge (RFC 8620 §5.1), as Email/get does, and a client pages with FileNode/query (a parentId filter, say) then FileNode/get on 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, COM0 to COM9 and LPT0 to LPT9, in any case and with or without an extension (aux.txt, nul.tar.gz), are invalidProperties at 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: null in 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. isTrashed updates 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, its drive.trash_retention_days override or else [drive] trash_retention_days (30 by default, floor 1; an override of 0 purges nothing, see per-tenant horizons), through the same exit as oximail 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 a FileNode Destroyed change 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 no mayDelete: 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 as FileNode/set and with the same ShareNotification to the grantee; that notification carries objectSubtype (directory or file), 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 overQuota with 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 FileShareLink grants access to exactly the node it names, enforces maxDownloads exactly, 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.
Blob deduplication is per organization and content-addressed: uploading the same file twice stores it once. Strictness rules are identical to the mail surface.