Account Sieve and ManageSieve
Each account can store 32 named Sieve scripts in mail.db, with one active script. An empty active name disables Sieve. Uploads compile the whole script before changing stored data, including replacements of active scripts.
The interpreter is Rust inside maild with no subprocess, filesystem or network access. The maintained Stalwart sieve-rs was evaluated first: its AGPL licence fails MixOS's permissive dependency policy. No suitable maintained permissive Rust interpreter was found. This implementation uses the existing MIME parser and native mail store without new dependencies.
Language
Base commands are require, if/elsif/else, stop, keep, discard and
redirect. Tests are address, header, exists, size, allof, anyof,
not, true and false. Quoted and text: multi-line strings, hash and
bracketed comments, :is/:contains/:matches, i;ascii-casemap and i;octet
are supported.
Extensions: fileinto, mailbox (fileinto :create and account-scoped
mailboxexists for writable mailboxes), envelope, imap4flags,
copy, body, variables, vacation, relational, comparator-i;ascii-numeric,
reject, ereject and index. Required capabilities must precede executable
statements. Unknown commands/tags/capabilities are refused, including in dead
branches. date/currentdate are deferred and not advertised.
require ["fileinto", "mailbox", "imap4flags", "body"];
if header :is "X-Mixos-Spam-Verdict" "SPAM" {
fileinto :create "Spam";
stop;
}
if body :text :contains "release announcement" {
fileinto :create :flags "\\Flagged" "Releases";
}
fileinto uses slash-separated mailbox paths; INBOX resolves by role.
:create creates missing parents. Non-writable destinations are treated as
missing, consistently with mailboxexists. Missing destinations without :create
keep the message at its default destination. Flags map to the same MDS flags
and validated user keywords as IMAP/JMAP. File actions snapshot the current
flags; repeated filings into one mailbox use the last flag list. Invalid,
unsupported and excess native keywords are ignored. :copy preserves implicit keep. Variables support substitution, wildcard
captures, case modifiers, wildcard quoting and length.
body uses decoded MIME text, strips HTML markup for :text, selects MIME types
with :content, and exposes the untransformed body with :raw. MIME selectors
support variables, and selected binary parts retain their decoded octets.
Body wildcards preserve earlier match captures. Relational
:value/:count support gt, ge, lt, le, eq, ne; :count requires
i;ascii-numeric. Header :index N selects one occurrence, with :last
counting from the end.
address parses every field named by the script, including custom address
headers. ASCII casemap ordering folds letters to uppercase before comparison.
Delivery and policy
SMTP classifies spam, runs the global Mix inbound_filter, then runs account
Sieve. This preserves the existing filter's spam inputs. The Mix destination,
or classifier Inbox/Junk default, is Sieve's keep destination. The stored message
has daemon-generated X-Mixos-Spam-Verdict and X-Mixos-Spam-Score headers;
incoming copies are removed. Verdicts are HAM, SPAM or UNKNOWN when
classification is disabled or fails. JMAP/native Bus submissions also run
recipient Sieve on local delivery, with Inbox as keep, without rerunning the
inbound classifier or global Mix filter. These local copies receive fresh
UNKNOWN/zero spam headers before the recipient's script runs.
Evaluation produces a complete action plan. Syntax/runtime errors,
missing destinations and refused redirects cancel the plan and keep the message.
Limits: 64 KiB per script, 10,000 evaluation/matching/allocation steps, 64
filing/redirect actions, 1 MiB of variables and four redirects. Matching charges
for scanned bytes as well as instructions. Header parsing is charged once for
the header section; attachments do not spend the header-test budget. Body
transforms charge only selected parts. Filing and mailboxexists read at
most 10,000 metadata records and 1 MiB of names/attributes, omitting message
counts. Their path processing also has a 10,000-visit, 1 MiB budget; exhaustion
keeps the message. Local redirects run the destination's
script; SMTP redirects also rerun the destination's classifier and global filter
using the original verified ingress. Local hop counts stay out of band; remote
loops are bounded by this host's own Received trace lines. A hop/action limit
drops only excess redirects, retaining other actions and restoring implicit keep
when the suppressed redirect was the only delivery action. External SMTP ingress
strips X-Mixos-Sieve-Hop; that field never supplies a trusted hop count.
Off-site redirects add a Received trace and strip the private hop marker and
all X-Mixos-Spam-* fields, including folded continuations.
Once a filing or redirect has committed, later secondary-action I/O failures are logged and the accepted delivery is retained. This follows maild's existing partial-delivery behaviour and avoids retrying an already accepted copy. Secondary actions have no durable replay across the separate MDS/mail.db stores.
reject/ereject return SMTP 550 5.7.1 after DATA only for one normal RCPT
that resolves to one mailbox; these refusals appear in the SMTP rejection log.
An alias with multiple local targets, mixed recipients or asynchronous delivery
instead queues a DSN for the rejecting target with a
null reverse path. DSNs to null/unverified senders are suppressed to prevent
backscatter. Discard is compatible with reject. Combining reject with filing, redirect or vacation is a
runtime error and keeps the original message.
Off-site redirects default to refused. sieve_external_domains lists exact
account domains allowed to redirect to non-local addresses. Vacation replies to
external senders are independently allowed by default; set
sieve_vacation_external: false to suppress them. These are operator
configuration, never script data. Local destinations must be account addresses.
Vacation supports :days (1..31, default 7), :addresses, :subject, :from
:handle and :mime entities. :from must be the account address. :addresses recognises
additional recipient identities; it does not authorise sender spoofing.
Recognised identities include the account and final envelope recipient, and must
appear in To/Cc/Bcc or Resent-To/Resent-Cc/Resent-Bcc. Replies require verified
ingress. Spam, null
senders, self-mail, list/bulk mail and automatic responses receive no reply.
A sender/response-handle stamp and its native queue row commit in one mail.db
transaction, permitting one reply per interval across concurrent deliveries and
restarts. Handles are stored as fixed-size hashes. Tracking retains at most
10,000 pairs per account and drops stamps past the maximum 31-day interval.
At capacity, new pairs receive no automatic reply until space expires; live
suppression records are preserved so existing senders cannot receive repeats.
Replies use a null reverse path,
Auto-Submitted: auto-replied, and the native outbound queue. Its worker
selects the account domain's DKIM signer; absent signing configuration retains
the existing unsigned behaviour. An active Sieve script takes ownership of
automatic responses; the older JMAP VacationResponse runs only without an
active Sieve script, preventing two automatic replies to the same message.
Without :subject, replies use Auto: followed by the decoded original
subject, or Automated reply if it is absent. Non-ASCII subjects are emitted
as encoded words; ASCII subjects remain plain text.
ManageSieve
Disabled by default. In the maild configuration:
sieve_listen: ["192.0.2.10:4190"]
sieve_implicit_tls: false
sieve_external_domains: []
sieve_vacation_external: true
false selects RFC 5804 STARTTLS, the normal mode on 4190; true selects
implicit TLS. Both share SMTP/IMAP's TLS identities, SNI resolver and hot-reload
slot. Enabling the listener without TLS material fails startup. Port 4190 needs
no new unit privileges. NodeConfig fallback leaves this optional listener off;
set the keys in the maild configuration used by the daemon.
Commands: CAPABILITY, AUTHENTICATE PLAIN/LOGIN, LISTSCRIPTS, GETSCRIPT,
PUTSCRIPT, CHECKSCRIPT, SETACTIVE, DELETESCRIPT, RENAMESCRIPT,
HAVESPACE, NOOP, LOGOUT. Authentication and script access require TLS.
Passwords use the shared account verifier. PLAIN authorisation identity must
be empty or equal the login identity. Only the authenticated account's scripts
are accessible. Uploads accept quoted strings and non-synchronising octet
literals ({N+}). When external vacation replies are disabled, valid PUTSCRIPT
and CHECKSCRIPT commands containing a vacation action return
OK (WARNINGS) "vacation replies to external senders are disabled"; scripts
are still accepted. An oversized PUTSCRIPT returns NO (QUOTA/MAXSIZE) and
closes the session without reading its non-synchronising literal. Other
malformed/oversized frames close the session. Limits: 128
connections, 180-second pre-authentication and 30-minute authenticated command
timeouts, 30-second TLS/authentication and response-write timeouts,
three failed logins. STARTTLS discards buffered plaintext commands.
Bus editing and versions
Like maild's existing operator commands, broker callers select an account; ManageSieve instead enforces authenticated account isolation.
| Command | Request | Response |
|---|---|---|
maild.sieve.list | account | scripts: name, version, active, size |
maild.sieve.get | account, name | name, version, active, source |
maild.sieve.put | account, name, source, optional expected_version | new version |
maild.sieve.activate | account, name (empty disables) | active name |
Account identifiers are email addresses. PUT's optional version guard uses zero
for a new script and refuses stale writes. Replacements preserve active state.
sieve_scripts stores current source; sieve_versions retains up to 16
revisions per name and 512 per account, including deleted/renamed names.
Versions increase across the account and are never reused after deletion. ManageSieve uses
the RFC's last-writer-wins editing semantics. Account deletion cascades to all
script/version/vacation-stamp rows.