OxiMail filters mail server-side with Rules: typed filter objects (a condition, then actions) managed over JMAP. They are what a webmail settings screen edits: there is no script syntax, a client can inspect and order them, and the server says in its capability exactly which fields, operators and actions it executes.
Rules run at final delivery. That includes mail a colleague sends you from the same server: the local-delivery shortcut applies the recipient’s Rules and vacation response exactly as it would for a message received from outside (see the SMTP layer and ADR-117).
The account has no Sieve surface beside its Rules: no SieveScript JMAP methods and no ManageSieve listener. The Rule capability announces this as sieveCoexistence: "none".
Rules
A Rule pairs a condition tree (sender, recipients, subject, any header, size, attachments, keywords, spam verdict…) with actions (move, delete, keywords, forward, header edits, notification, automatic reply). Operationally, these properties matter:
-
Total, deterministic order. Rules evaluate in a defined order with no ties, so the same message always takes the same path. The order is part of the data, not an accident of storage.
-
A rule names a folder by id or by role.
move takes a mailboxId, or a role among inbox, archive, junk and important, resolved when the rule runs, so it survives renaming the folder and localised folder names. delete files into the Trash. A rule cannot name a folder that does not exist: Rule/set refuses it.
-
Apply to existing mail.
Rule/apply runs one rule over the messages a client names by id (typically the ids of a preceding Email/query), at most maxApplyEmailsPerCall per call (1000 by default, an instance setting, below). Naming the messages means a client pages with Email/query and never skips a message the rule has just moved out of a folder. The response counts the messages evaluated, matched, modified and failed, and lists modifiedIds and the ids it could not read (notFound). A message already in the state the rule asks for (the label already set, already set aside) is not modified and not counted. The filing actions of the rule are resolved together, the forwarding, notification and header actions are never run on stored mail (they are listed in skippedActions), and dryRun: true evaluates and counts without changing anything. Unknown arguments are rejected as invalidArguments rather than ignored, because on a method that changes mail in bulk a silently dropped dryRun is not acceptable. Every call that is not a dry run writes a rule/applied line in the account’s administration log, in the transaction that records the run on the rule: the rule and the evaluated, matched, modified, failed and not-found counts, never an Email id nor the rule’s name. A thousand messages moved to the Trash in one call leave a trace.
-
Testing a rule.
Rule/test evaluates a stored rule, a rule being edited (validated exactly as Rule/set would) or the whole enabled set in evaluation order, against stored messages, without running any action. Each result names the fields whose data was unavailable (unavailable), so a client can say why a rule did not match. isDuplicate is evaluated against the delivery’s duplicate cache, read and never written, and a stored message is not its own duplicate: the cache keeps, per identifier, its last eight deliveries and the Email each became. The SMTP envelope and the spam verdict are delivery-only data this server does not keep with the message: in Rule/test, Rule/apply and the CLI dry run they are unavailable (retainsDeliveryData: false).
-
When a rule fails. A rule fails when it exceeds its evaluation time (a pathological pattern, for instance), when it carries an action this server no longer executes, or when a forward cannot be handed to the queue. The rule records it (
lastExecutionFailed, lastErrorReason on Rule/get), the rules after it do not run, no automatic reply is sent, and the message is delivered to the Inbox as well as wherever the rules before it filed it: those stand. A filter never loses mail.
-
Forwarding. A forwarded copy carries the header edits made before it and a marker only this server can sign for the account (
X-OxiMail-Forwarded-By): a message this account already forwarded is not forwarded again, while a marker a sender made up is ignored. The copy is sent with an envelope sender this server receives bounces at (the SRS address, or the account itself), never the original sender unchanged. A message classified junk is never forwarded. An account’s rules name at most maxForwardDestinations (10) distinct destinations, and one delivery sends at most maxEmissionsPerDelivery (5) messages. Which destinations are accepted is the operator’s forwardPolicy:
"any" (the default) accepts every address, "internal" only the domains this server receives mail for, "verified" only an address whose holder confirmed it (below), "none" no new destination. Rule/set refuses a destination outside the policy with forbidden; a stored forward that a tighter policy no longer accepts stops firing, without editing anyone’s rules. Its owner is told once, at that moment, by a push notification (“Forwarding stopped”, naming the rule), since nothing in the mailbox shows a copy that is no longer sent.
-
A suppression is recorded, not a failure. A forward, notification or reply that a guard, the policy or a bound stops is a no-op; the rule’s other actions run, and
lastSuppression says why (loop, autoSubmitted, nullSender, unauthenticated, deduplicated, policy, bound, junk, list, notAddressed, rateLimited). It returns to null at the next emission.
-
A folder or label that disappears. When a mailbox a rule files into is destroyed (through JMAP, IMAP or the CLI), or the last mailbox with a role a rule names, or a label a rule sets, the rule records
mailboxNotFound, roleNotFound or labelNotFound in the same transaction, and that action does nothing until the rule’s actions are rewritten.
-
Notifications.
notify sends a short mailto: message (its to and cc fields add recipients), with Auto-Submitted: auto-notified, an owner token and an empty envelope sender. It is not sent about a message that is itself automatic; a bounce does notify. An account’s notify rules reach at most 30 recipients per hour (a sliding window, the example of RFC 5436 §5), announced as maxNotificationsPerHour in the Rule capability: past it the notification is suppressed with lastSuppression: "rateLimited" and the message is delivered as usual. A burst of matching mail used to decide alone how much the server sent to the rule’s address.
-
Telemetry is a change.
matchCount, lastMatchAt and lastExecutedAt are written in batches (about once a minute), the outcome of an evaluation at once and only when it differs from what the rule already holds. Each write advances the rules’ state; Rule/changes reports it with updatedProperties listing the telemetry properties when nothing else changed, so a client re-reads only those. A failure recorded on a rule stays until its condition or actions are rewritten; renaming it or switching it off does not clear it.
The bound of Rule/apply is the size of one request: each message it names is read, decrypted and evaluated inside that one HTTP call, on a machine every organization of the box shares. It is therefore an instance setting, announced as maxApplyEmailsPerCall, from 1 to 10 000 (1000 by default); any other value refuses the configuration rather than falling back in silence:
Forwarding only to a confirmed address
Under forward_policy = "verified", a rule forwards only to an address whose holder confirmed, for this account, that they accept the copies (ADR-162). What this protects is the recipient, and the sending reputation of the box: nobody receives another person’s mail because someone typed their address. It does not protect against a stolen account, whose thief confirms an address they hold as easily as the owner would; that is the role of the guard below.
Switch a box to "verified" only once the client it serves offers the confirmation screens. The setting applies at once to stored forwards: every destination not yet confirmed stops receiving copies, and its rule records lastSuppression: "policy".
How a destination is confirmed:
- The account asks for it:
ForwardConfirmation/set with create: {address}. The server sends the address a code, eight characters shown as XXXX-XXXX, valid seven days, and records a pending confirmation (id, address, state, createdAt, codeExpiresAt, confirmedAt, expiresAt).
- The holder of the address reads the message and, if they accept, gives the code back to the account. Replying to the message is enough: its
Reply-To names the requesting account, and the HTML part carries a “Send the code to …” button, a mailto: to the same address with the code as body, the only link of the message. The server reads no reply.
- The account enters the code:
ForwardConfirmation/confirm {id, code} answers confirmed, or reason (wrongCode with attemptsLeft, attemptsExhausted, expired, notFound, malformed). The code is read the Crockford way: case, hyphens and spaces do not matter, O reads as 0 and I or L as 1, and an entry that cannot be a code spends no attempt.
ForwardConfirmation/get, /set, /changes and /confirm live under the Rule capability. They answer only when the server’s policy is "verified" (otherwise forbidden, naming the policy), and only to the account itself, never under shared access nor to a meeting guest: the code goes out in the account’s name. There is no update: a confirmation is requested, confirmed or withdrawn (destroy), and withdrawing it stops the rules that name the address.
The bounds that make a short code safe:
- The code exists in clear only in the message. The server keeps an HMAC bound to the request and the address; it never returns the code to the account and never writes it to a log. A code that cannot be queued undoes the request.
- Five attempts per account and address over seven days, spent in the same transaction as the comparison, and counted in a trace that survives a withdrawal and a new request.
- One code per account and address per seven days, ten codes per account per day, three codes per address per day across the whole instance. The per-address trace keeps only an HMAC of the address.
- A request nobody answered is erased. A worker removes pending requests whose code has expired every ten minutes, records the destruction in the account’s
/changes and pushes it, so the third party’s address does not stay in clear in an account that forgot it. A new request replaces an expired one instead of being refused as a duplicate.
The message comes from postmaster@ of the account’s domain, on a null envelope sender, marked Auto-Submitted. Its text is fixed: only the requesting account’s address varies, never a display name nor a text the user wrote. It says what the code does, that nobody will ask for it to sign in, where the address came from and what becomes of it, that the server’s logs keep a trace, what the recipient may ask, the deadline in the organization’s time zone, and who to write to. It names the controller the organization declared, the countries where the address is processed and, when someone else runs the server, its operator; with nothing declared, “the operator of the domain” and postmaster@ of the account’s domain. It is written in the organization’s language, then in English (French then English when no language is set), and an operator can add a national variant to it (texts sent to third parties).
The holder of an address has no account here, so the operator answers them at the console:
list finds every confirmation of the address in every organization and prints the fingerprint the audit lines carry; revoke removes them (the rules that name the address stop forwarding) and blocks the address for good: no code is ever sent to it again. Both read the database directly, with the server running or not; revoke is a dry run without --apply.
A forward is guarded like a credential
A forwarding rule outlives a stolen password: whoever sets one keeps receiving the mail after the password is changed. So setting one is treated as a credential change (ADR-163):
- A recent passkey proof. A
Rule/set after which an active rule forwards to a new address (a create, actions replaced, a rule switched back on) answers stepUpRequired when the account has a passkey that this session has not proved in the last ten minutes. An account without a passkey passes. Removing, disabling or renaming a rule asks for nothing.
- Never from an app password. The same write is refused
forbidden for that rule when the request rides an app password.
- A password reset by someone else suspends the forwards. A password set by an administrator (
Principal/setPasswordAdmin, oximail account set-password, the admin REST route) disables, in its transaction, every active rule that forwards and marks failed every forwarded copy still waiting in the queue; the answer, the CLI output and the account.password_set audit line give both counts (forwardsSuspended, forwardCopiesCancelled). A person changing their own password suspends nothing.
- A withdrawn destination takes its queued copies with it. When no active rule forwards to an address any more (a rule destroyed, disabled or rewritten), or its confirmation is withdrawn or revoked, the copies still pending for it are marked failed, the reason written on the queue entry.
- Every forward set or removed is an audit line (
rule/forward-added, rule/forward-removed) in the account’s chain, carrying an HMAC fingerprint of the address, never the address.
Sieve scripts that already exist
A mailbox migrated from another server, or an OxiMail account from before Rules became the only filter, may hold a Sieve script. At every boot, the server imports each active script it has not seen yet into Rules:
- each branch of the script becomes a Rule named after the script, in the script’s order, with
stopAfter: true;
- a construct with no Rule equivalent is left out, and the import is logged as partial.
discard, for instance, becomes delete, which files the message into the Trash instead of dropping it;
- a script that does nothing (the placeholder older versions laid on every account) yields no Rule;
- the imported Rules appear in
Rule/changes, so a connected client sees them.
The script’s text stays in the database. The import runs once per script.
The operator’s global script
[sieve] global_script names an optional server-wide Sieve script that runs before the account’s Rules. If it ends with a terminal action (redirect, discard, reject with stop), the Rules are skipped. Use it for organization-wide policy that users must not override.
The interpreter supports exactly this set of extensions:
A require for anything else fails compilation loudly and names what is available (RFC 5228 §3.2: a script that requires an absent extension must not run). A script may also require a comparator: comparator-i;ascii-casemap, comparator-i;ascii-numeric and comparator-i;octet are the three the parser accepts and the runtime implements. A header test with several header names (["From", "Sender"]) matches any of them; the list is never truncated to its first element. The parser is fuzzed: a size suffix that overflows (size :over 17179869184G) is refused as number too large instead of silently becoming :over 0 and matching every message.
Automatic replies
A message receives at most one automatic reply: a matching replyWith rule first, otherwise the account’s JMAP VacationResponse within its dates. Every automatic reply follows RFC 3834:
- it goes to the envelope sender (the
Return-Path), with Auto-Submitted: auto-replied and an empty envelope sender;
- it is not sent to a null sender, to a message that is itself automatic, to list or bulk traffic, or to a message classified junk;
- it answers only a sender whose domain this server authenticated (an SPF pass on that domain, or a DKIM signature of that domain or its organizational domain), and only a message addressed to one of the account’s addresses. The organizational domain is the one DMARC finds by its DNS walk (RFC 9989), no longer the embedded public-suffix list: a suffix that publishes
psd=y no longer lets one of its customers authenticate another, and a subdomain without a DMARC record has no organizational domain above it. The walk runs only when SPF did not pass and the signature is on another name; a DNS failure sends nothing;
- each reply (each
replyWith rule, and the vacation) goes at most once per sender every 7 days; the record is written before the reply is sent, and a reply that cannot be recorded is not sent.
Automatic replies are suppressed for a disabled account (mail is still delivered), are not sent after a rule failed, and also answer local senders.
Where to look when a filter misbehaves
- The
oximail rule … CLI verbs list, show, write, delete and dry-run an account’s Rules; see the CLI reference.
- Delivery decisions are logged with the account and rule context; see Operations for the journald workflow.