Skip to main content
Regulated organizations — finance, health, legal, public sector — face two obligations that pull in opposite directions: keep a record of business communications, and erase a person’s data on request, provably. OxiMail ships both as first-class server features: per-organization mail journaling, and an account-erasure engine that runs as a durable phase machine and leaves a non-identifying completion record you can produce months later.
This page describes what the server does, not what your regulator requires. Map these mechanisms onto your own legal obligations (GDPR, Swiss FADP, sector rules) with counsel.

Mail journaling

Journaling silently copies matching mail to a journal mailbox or an external archiver, per organization. It is the Postfix always_bcc / sender_bcc_maps / recipient_bcc_maps feature set, done as data rather than as config file edits — the typical use is an Art. 30 GDPR record-keeping duty or a sector archiving rule. Off by default. No rules means no journaling; nothing is copied until an administrator creates a rule.

Rules

A journaling rule has three dimensions: The two destination forms differ in custody:
  • Mailbox — the copy is re-ingested into a local journal mailbox, so it stays inside the organization’s storage boundary, under the same at-rest protection as all other mail.
  • External — a copy is relayed to an external archiver (a dedicated archiving service or another server you operate).

Invariants

  • Relay traffic is never journaled. Mail relayed for service accounts keeps the ephemeral-retention promise — a smarthost does not archive its clients’ mail.
  • A journal copy is never re-journaled. A copy archived to a mailbox is stored directly and never delivered again. A copy relayed to an external archiver carries an X-Oximail-Journaled header: when it comes back over port 25 from an address in trusted_ips, the header is believed and the copy is not journaled again. From any other address the header is removed on arrival, so a sender cannot opt out of archiving by typing it, and the RFC 5321 hop limit bounds a misconfigured round trip. Inside the server, a copy is recognized by the queue row that carries it, never by a header.
  • Destinations are organization-isolated. A rule can only target a mailbox inside its own organization (the storage-layer tenant isolation, ADR-015).
  • Journaling is best-effort and never blocks mail. A journaling failure is logged loudly but the delivered or submitted message is unaffected — record-keeping must not become a delivery outage (ADR-017).
  • One evaluation per message, on every path into a mailbox. Inbound rules apply to mail arriving over port 25, to internal mail (a member writing to a colleague), to local redirects and to delivery reports, once per message whatever the number of local recipients: a message to five colleagues gives one copy per destination. Outbound rules apply once after a non-relay submission is enqueued. Internal mail can therefore be journaled in both directions, once as the sender’s outbound mail and once as the recipients’ inbound mail.

Managing rules

By CLI:
Or over JMAP, for admin tooling: JournalingRule/get and JournalingRule/set under the urn:oximail:params:jmap:v2:admin capability (admin-only).

Account erasure (GDPR Art. 17 / DSAR)

Deleting an account is not a DELETE statement — it is a multi-step operation with legal weight, and a crash or an operator retry must never leave it half-done or run it twice. OxiMail runs every account erasure as a phase machine recorded in the database:
The properties that make it trustworthy:
  • At most one live erasure per account, by construction. A partial unique index makes a concurrent second erasure impossible across processes — an admin-API delete and a CLI delete on the same box cannot race.
  • Durable and resumable. Each phase records its progress. A driver that dies mid-flight (crash, proxy timeout) leaves a job that a maintenance worker adopts and resumes, using a lease with heartbeat; a boot-time sweep catches orphans. The pass is decoupled from the HTTP connection that requested it, so a proxy timeout cannot cancel it.
  • Visible when stuck. A job parked on a durable failure shows in oximail erasure list --stuck and in the oximail_erasure_jobs_stuck metric — an enqueued erasure reaches a terminal state or surfaces as stuck, never silently half-done.
  • Thin adapters. The admin API and the CLI drive the same orchestration; there is one erasure engine.

What each phase does

  1. Agency freeze. Before anything is erased, the account loses its ability to produce new data: it is disabled, its tokens are revoked, SMTP submission answers 550, vacation auto-replies are suppressed, and its scheduled / undo-window sends are cancelled at the source. The erasure then refuses to proceed while any undelivered outbound queue entry remains (fail-loud; the queue drains bounded by the retry lifetime, or an operator cancels entries via the queue API). Every path that emits mail on behalf of an account is named in a registry with the gate that stops it, enforced by a source audit (ADR-118).
  2. Residue redaction. Copies of mail the leaver sent to colleagues keep naming them in the survivors’ mailboxes — in structured columns and inside the raw message headers. The pass rewrites those copies through the normal ingestion path: identity headers collapse onto a non-routable sentinel (former-user@<domain>.invalid), address columns are redacted structure-aware (a homonymous recipient is never touched), and the message body is untouched — it is the recipients’ correspondence. Message-ID tokens, the survivors’ own To/Cc records, and trace-header-only appearances are documented out of scope. A copy under a colleague’s legal hold is left exactly as it is, since the hold freezes the piece as evidence (GDPR Art. 17(3)(e)): the erasure completes for everything else, the copy is recorded with the leaver’s addresses sealed under the organization’s key, and the erasure maintenance worker redacts it once the hold ends or is released. account delete --dry-run counts these copies beforehand, and oximail doctor (erasure_held_residue) names those still waiting and when the first hold ends.
  3. Identity strip. The subject’s directory principals are PII-scrubbed and kept as tombstones — graph anchors that prevent chat and notification history from being silently misattributed, while carrying no identity. Every grant of the leaver is stripped from every shareWith map (each surviving owner gets a recorded change to resync on), actor snapshots in survivors’ notifications lose name and email, and a tombstoned principal is refused as a new grantee, so a re-share cannot defeat the strip. Resource and location principals the leaver merely created (meeting rooms) survive intact — they belong to the organization.
  4. Data sweep. The account’s rows are deleted table by table, its documents are removed from the full-text search index, and its blob files are deleted from disk once nothing else references them (there is no crypto-erasure since ADR-148: content is written in clear, so erasure means deletion). The sweep fails loud on a real error instead of declaring success over surviving rows.
  5. Completion. One durable, non-identifying record is written: outcome counters (including held_residue, the copies a legal hold still keeps) plus an optional DSAR ticket reference, keyed to the identity tombstone. Any error text on the job row is cleared at terminal, so no failure message can carry a personal address into the durable record.

The reversal buffer

Erasure is deliberately hard to undo — but an operator error (wrong account) needs an honest recovery path. During the identity strip, each stripped share grant’s key-delta (the leaver’s own bits per object, never the whole map) is captured encrypted to the organization’s master key into a reversal buffer with a disclosed TTL:
  • Default 7 days, anchored at enqueue time; per-organization override erasure.reversal_ttl_days.
  • oximail erasure undo <job-id> merges each delta back into the current map under compare-and-set — an owner’s intervening edit is never clobbered.
  • --grantee <principal> restores the grants onto a different live principal: after a completed erasure this is the real recovery, re-provision the person and re-grant to their new identity (validated, provenance audited).
  • Past the deadline the restore is refused server-side, even while the rows physically remain; a reaper deletes expired rows unconditionally.

Following a request from the admin surface

An administrator deletes an account from the admin surface with Principal/setAdmin {status: "deleted", erasureRef} (admin API): the account is frozen, purgeAfter says when the erasure worker will order the purge, and status: "active" before then cancels it. ErasureJob/queryAdmin and ErasureJob/getAdmin (admin capability, read-only) follow the request from there to its completion record, where the console alone used to show it (erasure list). A request lives in two places over its life, and both are served:
  • Scheduled: no job exists yet. The object’s id is the account’s id, jobId is null, phase is "scheduled", and blocked says why and since when, if the worker found it due and refused it (a legal hold, orphaned custody).
  • Job: once enqueued, the request continues as its job (id = jobId, phase one of the phases above), carrying the same accountId and erasureRef; the scheduled object answers notFound from then on. An account can hold several jobs over time (a re-erasure after erasure undo), so the job id identifies a job.
An object carries principalId, email while the account row exists, purgeAfter, enqueuedAt, updatedAt, reversalDeadline, actor, error, stats and, once completed, completion ({auditId, at}), the record erasure prove resolves. The date of each intermediate phase is not served: the substrate keeps the current phase and the date of its last change, not a history. queryAdmin filters on phase, erasureRef and principalId, with no sort and no position: scheduled deletions first, soonest purge first, then the jobs, newest first. There is no write verb; proving stays at the console.

Proving it happened

Give the erasure a DSAR reference at delete time, and resolve it later:
prove resolves the reference to the non-identifying completion record — the durable evidence that the Art. 17 erasure was performed (Art. 5(2) accountability). The record lives as long as the administration log, 365 days; past it, it is purged and the tombstone remains, without a reference. Exit code 1 means no record, and the message names the three readings, so “purged” is not read as “never happened”: the erasure has not completed (oximail erasure list), it ran without --dsar, or it completed more than 365 days ago and its record is gone.

Erasures and restores

A backup is a copy of the past: restoring one taken before an erasure brings the erased person back (account, mail, name) and removes every trace in the database that the erasure happened. Nothing in the data can tell. The regulators’ answer is that a backup may keep erased data until it rotates out, provided a restore does not put it back to use. OxiMail makes that a property of the box rather than a line in a runbook:
  • The erasure ledger. Every completed erasure appends one line to erasure-ledger.jsonl, beside the database: organization, account, job, instant, DSAR reference. Identifiers only, no address, no name. The line is written before the job is marked complete, so an erasure the ledger does not know is not complete. The ledger lives in the data directory, so every backup carries it, whatever takes it (a hypervisor snapshot, oximail backup, another tool), and since it only grows, the newest copy covers the restore of any older backup. oximail restore keeps the union of the box’s copy and the backup’s.
  • A person a restore brought back is frozen, then erased again. An account the ledger names that the database carries again is frozen (no login, no delivery on its behalf, nothing sent in its name) by oximail restore, by the server at boot before it accepts connections, and at every maintenance tick. The oximail_erasure_resurrected_accounts metric counts them, and oximail doctor (erasure_ledger) fails. oximail erasure replay lists them; --apply runs the erasure again with its original DSAR reference. --from <copy> reads a newer copy of the ledger kept elsewhere, and with --apply adds its lines to the local one.
  • The window that remains is the erasures completed after the backup you restore. [erasure] ledger_hook closes it: a command of yours, run after each completed erasure with the ledger’s path as its last argument (scp to another host, rclone, restic). No shell, absolute path. Without a hook, doctor erasure_ledger warns while an erasure is newer than the last backup the box knows of; with one, while the hook has not shipped the latest lines.

The administration log

Every administrative act writes a row in the administration log, append-only and chained: each row’s hash covers the previous one, per (tenant, account) chain, so verify tells whether a chain was cut or altered. What the chain guarantees is tamper-evidence, not longevity: the retention is 365 days (AUDIT_LOG_RETENTION_DAYS, advertised as retentionDays on the auditlog capability), down from a seven-year figure no standard supported; a purge is anchored so the chain stays verifiable after it.
  • An act of the node itself has a home. Pairing an organization, suspending it, rotating the working key, enrolling or removing a root, switching the access log on: these are acts of nobody’s account, and the log used to leave them a service line only. They now live in the tenant’s own chain, with an origin record (genesis, purge or attestation, the point before which nothing is judged). oximail audit-log list --tenant reads it, --account reads a person’s chain; audit-log verify walks every chain of the tenant anchored on its origin (exit 0 valid, 1 degraded, an unanchored legacy chain or unhashed rows, 2 broken), takes --since for a window and --witness <hash> for a tail hash read outside the box, yesterday’s backup, which must still be in the chain; audit-log attest --by <name> --apply re-bases the reconciliation of state against history at now and appends a chained attestation row, refused unless the chain is valid. doctor audit_log walks the tenant chain and every account chain, and judges the head of each account chain against its origin at every round.
  • A chain is anchored from its first row. The write point lays a genesis origin with a subject’s first hashed row, in the same transaction; subjects older than that were caught up once. Before, only a retention purge wrote an account’s origin, which cannot happen before the 365-day horizon, so audit-log verify exited 1 on every healthy box through each account’s first year: unanchored is again the exception it claims to be.
  • An act on someone is filed under that person. Setting someone’s password (account.password_set, distinct from account.password_changed, a person changing their own), cutting one of their device sessions or app passwords, redirecting their address on departure: the row lives in the chain of the person it happened to, the administrator named as actor, because the question asked in an incident is “what happened to this account”. An account grant and its revocation (a departure’s included) are written in the owner’s chain, an organization-wide grant in the tenant’s (account/grant.set, account/grant.revoked, tenant/grant.set, tenant/grant.revoked, with the grantee and the previous state: collections, level, expiry), in the same transaction as the access row: a grant that cannot be journaled is not written, and an expiry that is not an RFC 3339 instant is refused before any write. By grantee, the rows are found by action (audit-log list --action) and the grantee field, not by a second row.
  • The actor is the account that speaks. A row names the authenticated account, never the owner a shared: call in the same batch pointed at: an administrator slipping a Principal/setAdmin into such a batch used to have the row’s actor and chain be the shared account’s owner, their own identity nowhere.
  • A console verb run by an agent says so. A binary can serve the console verbs to an agent on the box (one verb per tool, a mutator shows its dry run first); the launcher declares the channel in the environment (OXIMAIL_CLI_CHANNEL=agent, with OXIMAIL_CLI_DECIDED_BY, the name of the person who opened the session, required beside it; any other value refuses the command). Such a row is filed cli-agent under that person’s name (decidedBy), never cli like a command they typed, and a cli-agent row without a name is refused: “this person validated a dry run an agent presented” is the fact one looks for six months later.
  • A row written during a request says where it came from: the client address the transport resolved (proxy-aware) and the device session the credential belongs to, handed over by the request, never stated by a handler; no User-Agent, a claim the client chooses. Actor identity is reserved to the admin reader.
  • A legitimate rewrite is an act of the chain. The erasure scrub and a principal rekey used to rewrite hashed columns behind the chain’s back, so every touched row read as altered; they now write through the chain.
  • Erasure no longer touches the log. Identifiers in it are pseudonyms living at most twelve months, covered by the purpose (security and accountability), and the erased person is informed; the former de-identification pass is gone.
  • The registry of action names is closed, with a door. Three action names without an emitter were retired, a row carrying a causality vector is refused at write time, and an extension linked into the binary logs through the ext:<module>/<object>.<verb> prefix instead of failing every audited mutation with serverFail.
  • On the wire, AuditEvent/get|query|changes|verify under urn:ietf:params:jmap:auditlog. A tenant admin reads another account’s chain by naming it (accountId on get, query and verify); anyone else naming another account is forbidden, and an id that names no account of the organization reads as an empty list, never an existence oracle (verify answers accountNotFound). AuditEvent/get serves each event’s hash, prevHash, chainFormat, actorCommitment and subjectCommitment, never the nonces, so a verifier without the base can recompute the links. AuditEvent/verify names its scope: accountId is honoured, never silently replaced by the caller’s own; scope: "tenant" (admin) walks every chain the caller administers, the tenant’s own included, one after the other; without after or cursor the walk starts at the origin and judges the head (head: genesis, purged, attested, unanchored, removed, origin_invalid; windowed for a partial walk); witness {id, hash}, a tail kept from an earlier answer, must still be in the chain with that hash, else valid: false, reason: "witness_absent". It walks at most maxEventsPerVerify (2000) events: a wider range is truncated, never refused, the answer says truncated: true and carries nextCursor, opaque and exclusive, which encodes the junction the server re-checks on resumption (no event counted twice, no gap at the boundary; a junction gone or changed is refused by name; nextAfter is still sent for one cycle). The answer counts what it could not judge rather than hiding it (chainsWalked, chainsBroken, unhashedCount, deidentifiedCount, tailInFuture), names its last event with its chain (tail {id, hash, accountId}), and a break with its chain (brokenAtId, brokenAccountId), since under scope: "tenant" an id alone could not be opened; reason is a closed code, the verifier’s sentence is in description. A time bound on verify or query is re-stamped into the canonical form before it reaches SQL, or refused naming the value: it used to be compared as text, so 16/09/2026 let everything in, 10:30:00Z without milliseconds excluded the very instant it named, and both reported valid: true.

The data access log

Who read, listed, wrote, shared, exported or destroyed another person’s data (ADR-150): a second journal, in its own table and its own chain, served by the same AuditEvent methods under a separate capability, urn:oximail:params:jmap:accesslog, with the same 365-day retention. The subject is the owner of the data, the actor is who read it: Bob reading Alice’s shared mailbox is a row in Alice’s journal, actor Bob. An AI assistant reading for an account is actorKind: agent.
  • Third-party access only. An owner’s reads and writes on their own data are not traced (the stolen-token case shows through the device session, the address and the operations journal). A grantee, an administrator, an agent, a share link, an export are.
  • The grain is the nameable action, one row per admitted data-bearing invocation, never one per object: op in read, list, write, share, export, destroy, level in metadata, content, the resource type and a bounded list of the ids served, indexed so “who read X” has an answer; an IMAP FETCH 1:* is one row with a UID set. Structural lists (mailbox, date, keywords, ids) are exempt even cross-account; a list whose filter bears on content (text, body, subject, from, to) is traced as list/content, so “Bob searched X in Alice’s mailbox” is nameable. A refusal leaves a row too (outcome: denied). Out of scope, said plainly: Core/*, /changes, the directory, and every read with no human actor (Sieve, rules, indexing, anti-spam).
  • Written at the dispatcher seam after the handler, one site for some 150 methods, plus three doors outside JMAP: /jmap/download, IMAP FETCH, DAV GET/REPORT; the decision names a fourth, the subject data export (Art. 15 / Art. 20), which is not served yet: there is no DSAR export verb or method today, and the export deprovision mode is refused rather than deleting under a name that promises preservation. Where a shared calendar reduced or withheld events, the row carries reducedToTime and withheld counts.
  • Opt-in per tenant, a person decides. oximail tenant update --access-log on --by <name> --access-log-mode strict|best-effort: --by is a declared name written on the switch’s audit row, because a control measure has a person who answers for it, and the row is purged with the rest, so doctor warns at 185 days that the decision needs renewing. strict refuses an access it could not trace (nothing leaves untraced; the log’s failures become the tenant’s outages); best-effort serves it, counts the failure on oximail_access_log_append_failed_total and traces SERVED UNLOGGED. A mode the server cannot read is strict. The switch is read every 30 s; --access-log off needs --yes, is an audit row, never a purge, and exists only at the console. Disabled: nothing is written, nothing fails, the capability is not advertised. The cost, measured: zero on an owner’s own access, about a quarter of a millisecond per traced call at p50, a ceiling of a few hundred traced rows per second per box.
  • Reading. The tenant admin, and the subject for its own rows; a grantee never learns it was logged; sourceIp and the session are admin-only. oximail access-log list --account <subject> (the rows of one person), --object-type Email --object-id <id> (“who read X”), --actor <email> --since <ts> (“what did this collaborator do”, across subjects, refused without a window); access-log verify walks the chains and reports the first break (exit 2). No deletion verb exists, JMAP or CLI. doctor access_log reports the age of the last row, the mode and the chain over the last day, and warns on a tenant without the journal.
oximail journal list reads both journals together, newest first, for a person (--account), an object (--object-type + --object-id) or an actor (--actor --since), each line naming its journal, the accounts by address, the client address and the device session. It proves nothing: the two verify verbs do.

Texts sent to third parties

A few messages leave the server in the operator’s name toward people who have no account here: the code that confirms a forwarding destination is the first. Whoever receives one is owed the name of the controller, where their address is processed and whom to write to. Those facts are declared, never deduced from the network: the organization declares its controller and its country of establishment, the instance its operator and the countries where data is processed (the legal identity of an organization). The worldwide text cites no law. OxiMail runs in every jurisdiction, so the texts it ships say what happens (the controller, the processing countries, the contact, the deadline) without naming one country’s statute or authority. The departure reminder of oximail account offboard --grant-mailbox-to follows the same rule: it says that the grant opens every folder of the leaver’s mail, a private one included, that the leaver should take their private items out first, and that an item marked private is not opened before checking what the law where you operate allows (privacyReminder in --json). A national variant adds the paragraphs that name a law. An operator whose counsel wrote the text for a country installs it as a file; the server then writes its paragraphs after the worldwide text, in the same language. No variant ships with OxiMail: one will be published only once a practitioner has validated its content.
  • Two texts take a variant. [forward-confirmation] has ten slots, written in this order: law, purposeAndBasis, sourceAndCategories, recipients, retention, rights, objection (its own paragraph), transferSafeguards (a table keyed by transfer basis, one paragraph per processing country whose declared basis needs one), authority and dpo. [departure-reminder] has one, text, which account offboard prints beside the worldwide reminder (privacyReminderNational in --json).
  • A closed set of placeholders, filled from the declared identity only: {account}, {controller}, {hoster}, {contact}, {dpo}, {authority}, {countries}, {country}. A slot whose text names a value nobody declared falls back to its <slot>IfUndeclared companion, or is left out (a missing {dpo} quietly, since a data protection officer exists only where one is appointed).
  • A file is used whole or not at all. An unknown section, key or placeholder, an empty value or one carrying a control character refuses the file as a whole; the third party then receives the worldwide text and the server logs an error at each message. A variant is never used for an individual, nor for an organization without a declared name and a declared contact.
  • What the variant needs is declared at the console, never looked up: the subdivision of the establishment, the supervisory authority, and the basis of each transfer abroad (adequacy-decision, data-privacy-framework, standard-contractual-clauses, binding-corporate-rules).
oximail doctor says which text each organization’s third parties receive (legal_notice: skipped without a legal-notices/ directory, fail on a file that does not parse, warn on a slot that names an undeclared value or a file the identity cannot use), and which identity fields are still missing (legal_identity, names the fields, never their values; an individual without a name is fine).

CLI quick reference

See the CLI reference for every flag, and Operations for the metrics and workers involved.