Skip to content

Sieve Mail Filtering

Yarilo implements server-side mail filtering via the Sieve language (RFC 5228). Scripts are stored per-user and executed on every incoming LMTP delivery. Script management is available via the ManageSieve protocol (RFC 5804) on port 4190.

Supported extensions

fileinto, reject, ereject, vacation, vacation-seconds, imap4flags, copy, envelope, body, date, index, regex, mailbox, special-use, mailboxid, fcc, editheader, variables, include, duplicate, ihave, enotify, subaddress, spamtest, spamtestplus, virustest, foreverypart, mime, extracttext, replace, enclose, mboxmetadata, servermetadata, imapsieve, vnd.yarilo.debug, vnd.yarilo.environment, vnd.yarilo.pipe, vnd.yarilo.filter, vnd.yarilo.execute, vnd.yarilo.report

foreverypart / mime / extracttext (RFC 5703) provide per-MIME-part processing: foreverypart { … } walks the parts depth-first (with break [:name]), header/exists :mime inspect the current part's headers with the :type / :subtype / :contenttype / :param / :anychild selectors, and extracttext [:first N] [MODIFIER] var captures the current part's UTF-8 text (with the RFC 5229 value modifiers) into a variable. replace [:mime] [:subject s] [:from s] <text> rewrites the current part (or the whole message outside a loop); enclose [:subject s] [:headers list] <text> wraps the message in a multipart/mixed cover plus the original as message/rfc822. A rewritten message is delivered in place of the original. Octet-exact preservation of a pre-existing multipart/signed part across a replace is a known limitation.

mailboxid (RFC 9042) resolves folders by their stable MAILBOXID (RFC 8474 OBJECTID) rather than by name: fileinto :mailboxid "<id>" "<fallback>" delivers to the folder carrying <id> if it still exists, otherwise to the positional <fallback> mailbox (honouring :create); the mailboxidexists "<id>"... test is true only when every listed id resolves to an accessible folder. Ids survive RENAME, so a script keeps targeting the right folder after the user renames it.

spamtest / spamtestplus / virustest (RFC 5235) are backed by a configured status header (see sieve_spamtest_status_header / sieve_virustest_status_header below); with no header configured the tests report "not scanned".

imapsieve (RFC 6785) runs Sieve scripts on IMAP events — message APPEND, COPY/MOVE, and flag change (STORE) — not just LMTP delivery. A script is bound to a mailbox through the IMAP METADATA annotation /shared/imapsieve/script (SETMETADATA "<mailbox>" (/shared/imapsieve/script "<name>")), or server-wide under INBOX; the value names a script in imapsieve_script_dir. Admin imapsieve_global_before / imapsieve_global_after scripts wrap the bound one. Bound scripts live in imapsieve_script_dir; in the Helm chart, set sieve.imapsieve_scripts (a name: content map) and a ConfigMap is rendered and mounted read-only at imapsieve_script_dir in the IMAP pods. Scripts require ["imapsieve", "environment"] and branch on the event via environment "imap.cause" (APPEND / COPY / FLAG); the RFC 6785 items imap.mailbox, imap.email, imap.user, imap.changedflags and the vendor vnd.yarilo.mailbox-from / vnd.yarilo.mailbox-to (COPY source/destination) are also available. (Enable with imapsieve_enabled; the IMAP-side event hooks land incrementally — APPEND first.)

mboxmetadata / servermetadata (RFC 5490 §4) expose IMAP METADATA (RFC 5464) annotations to scripts: metadata "<mailbox>" "<entry>" "<value>" / metadataexists "<mailbox>" "<entry>"... read per-mailbox annotations, and servermetadata "<entry>" "<value>" / servermetadataexists "<entry>"... read server-scoped ones. Entry names are the wire-format /private/… or /shared/… paths; values come from the same dict the IMAP GETMETADATA/SETMETADATA commands use, so a script sees exactly what a client set. Delivery-time lookups are scoped to the recipient's personal namespace. extlists is not advertised yet — its backing data source is not wired.

Configuration

yarilo.yaml — top-level sieve: section

KeyTypeDefaultDescription
sieve_enabledboolfalseActivate Sieve execution on LMTP delivery
sieve_max_script_sizeint65536Maximum compiled script size in bytes. One limit for delivery and ManageSieve alike — the pre-beta duplicate protocol.managesieve.max_script_size is accepted as an alias of this key, and the two set to different values refuse startup.
sieve_max_redirectsint32Maximum redirect actions per message (RFC 5228 §6.2)
sieve_max_actionsint32Maximum total actions per script (fileinto/redirect/keep/...). 0 = unlimited. Guards runaway scripts
sieve_vacation_enabledbooltruePermit the vacation extension (RFC 5230)
sieve_spamtest_status_headerstring""Header carrying the spam score for spamtest (RFC 5235), e.g. X-Spam-Score. Empty = test reports "not scanned"
sieve_spamtest_max_valuefloat10Raw header value mapped to the top of the 0..10 (0..100 with :percent) scale
sieve_virustest_status_headerstring""Header carrying the virus verdict for virustest (RFC 5235). Empty = "not scanned"
sieve_virustest_max_valuefloat5Raw header value mapped to the top of the 0..5 virus scale
sieve_submission_hoststring""Upstream MTA for outbound mail (host[:port], default port 25). Empty = redirect and vacation are silently dropped
sieve_submission_sslstring"no"Transport security: no | smtps | starttls
sieve_submission_timeoutint30Connect and command timeout in seconds
sieve_submission_auth_secretstring""Name of a Kubernetes Secret containing user and password keys for SMTP AUTH. Leave empty for unauthenticated relay

All keys keep the sieve_ prefix even under the sieve: section, matching the config koanf tags.

Helm values.yaml — top-level sieve: section

The values.yaml keys are identical to the yarilo.yaml keys above (same sieve_ prefix):

KeyDefaultDescription
sieve.sieve_enabledfalseEnable Sieve
sieve.sieve_max_script_size65536Max script size (bytes)
sieve.sieve_max_redirects32Max redirect actions per message
sieve.sieve_max_actions32Max total actions per script (0 = unlimited)
sieve.sieve_vacation_enabledtrueEnable vacation extension
sieve.sieve_spamtest_status_header""Spam-score header for spamtest (empty = unbacked)
sieve.sieve_spamtest_max_value10Top of the spam-score scale
sieve.sieve_virustest_status_header""Virus-verdict header for virustest (empty = unbacked)
sieve.sieve_virustest_max_value5Top of the virus-score scale
sieve.sieve_submission_host""Upstream MTA address (host[:port])
sieve.sieve_submission_ssl"no"TLS mode: no / smtps / starttls
sieve.sieve_submission_timeout30Timeout in seconds
sieve.sieve_submission_auth_secret""Name of a Kubernetes Secret with user and password keys for SMTP AUTH. Leave empty for unauthenticated relay

Outbound mail — redirect and vacation

When sieve_submission_host is configured, yarilo dispatches outbound mail for:

  • redirect — forwards the original message verbatim to the redirect address, preserving the original envelope-from (RFC 5228 §4.2).
  • vacation — sends an RFC 5230 auto-reply to the original sender with a null envelope-from (<>) to prevent mail loops.

Vacation dedup (RFC 5230 §4.5)

Yarilo enforces the per-sender reply interval specified in the vacation action (:days or :seconds, default 7 days). The last-sent timestamp is stored in the user's dict under priv/sieve/vacation/<handle>/<sender>. Dict drivers with TTL support (Redis) expire the entry automatically; other drivers use the stored timestamp for manual comparison.

Vacation replies are skipped when:

  • The sender address is empty or <>.
  • The message has a List-Id header or Precedence: bulk/list/junk.
  • The message has Auto-Submitted: set to any value other than no.

Duplicate dedup (RFC 7352)

The duplicate test records tracking IDs with the action's TTL. The backend is chosen by sieve_duplicate_driver:

  • file (default) — a per-user file in the home directory (name: sieve_duplicate_file, default .yarilo.sieve-duplicate). The whole check-and-record runs under the per-home Sieve lock, so it is atomic; on shared storage the file is cross-pod.
  • memory — per-process; single-node / dev only.
  • redis — the dict named sieve_duplicate in dicts: (also cross-pod), keyed by priv/<user>/sieve/duplicate/<handle>/<sha256(id)>.

Defaults follow RFC 7352: the tracking id is the Message-ID header (absent → the test is false and records nothing); :header / :uniqueid / :handle / :seconds / :last override it. The period defaults to ~7 days and is capped at sieve_duplicate_max_period (default 7 days; a larger :seconds is silently clamped; 0 = no limit).

Notifications (RFC 5435 enotify)

The notify extension (RFC 5435) allows scripts to send notifications via an external method URI. Yarilo supports the mailto: method — the notification is sent as an email via the same sieve_submission_host as redirect and vacation.

sieve
require ["notify"];
notify :message "New mail arrived" "mailto:[email protected]";

The mailto: URI may include subject and body query parameters:

mailto:[email protected]?subject=Alert&body=You+have+mail
  • From: the delivery envelope recipient (your mailbox address).
  • Subject: from the URI subject= parameter; defaults to Notification if absent.
  • Body: ActionNotify.Message (:message argument in the script), or URI body= parameter if the script provides no message.
  • Envelope-from: <> (null) to prevent mail loops.
  • Auto-Submitted: auto-generated.

Methods other than mailto: (e.g. xmpp:, sms:) are logged at WARN level and silently dropped.

Kubernetes secret for SMTP AUTH

Create the secret once:

sh
kubectl create secret generic sieve-smtp-auth \
  --from-literal=user=relay_user \
  --from-literal=password=relay_password

Reference it in values.yaml:

yaml
sieve:
  sieve_enabled: true
  sieve_submission_host: "relay.example.com:587"
  sieve_submission_ssl: "starttls"
  sieve_submission_auth_secret: "sieve-smtp-auth"

Yarilo-specific extensions

Yarilo ships four proprietary Sieve extensions under the vnd.yarilo.* namespace. They must be listed in the require statement of any script that uses them.


vnd.yarilo.debug — script-level debug logging

Appends timestamped messages to .yarilo.sieve.log in the user's home directory. Intended for troubleshooting script logic without touching system logs.

sieve
require ["vnd.yarilo.debug"];
debug_log "fileinto triggered for ${subject}";

The log file is created on first write with mode 0600. Each line is <RFC 3339 UTC timestamp> <message>. No configuration required.


vnd.yarilo.environment — operator-defined environment items

Exposes delivery-time variables to scripts via the standard environment test. Built-in items:

Item nameValue
vnd.yarilo.usernameFull login name (user@domain)
vnd.yarilo.default-mailboxAlways INBOX
vnd.yarilo.config.<key>Operator-defined string from sieve.sieve_environment
sieve
require ["environment"];
if environment :is "vnd.yarilo.username" "[email protected]" {
    fileinto "VIP";
}

Operator config in yarilo.yaml:

yaml
sieve:
  sieve_environment:
    tenant: "acme"
    region: "eu-west-1"

Exposed as vnd.yarilo.config.tenant and vnd.yarilo.config.region.


vnd.yarilo.pipe — pipe message to an external program

Feeds the full RFC 5322 message to an external program. The program receives no output — exit code determines success. Useful for archiving, indexing, or side-effect triggering.

sieve
require ["vnd.yarilo.pipe"];
pipe "archive-mail" ["--folder" "inbox"];

Program resolution (tried in order):

  1. Unix socket <sieve_pipe_socket_dir>/<name> — if the path is a socket file, yarilo connects and writes the message; socket output is discarded.
  2. Executable <sieve_pipe_bin_dir>/<name> — launched as a subprocess.

World-writable executables are refused.

Environment variables injected into subprocesses:

VariableValue
USERDelivery recipient login name
SENDEREnvelope sender address
RECIPIENTEnvelope recipient address
HOMEProcess home directory
HOSTHostname

Configuration (yarilo.yaml / values.yaml):

KeyDefaultDescription
sieve_pipe_bin_dir/usr/lib/yarilo/sieve-pipeDirectory of allowed pipe executables
sieve_pipe_socket_dirsieve-pipeDirectory of allowed pipe sockets (searched first)
sieve_pipe_exec_timeout10Subprocess timeout in seconds
sieve_pipe_input_eolcrlfLine endings written to stdin: crlf or lf

vnd.yarilo.filter — rewrite message through an external program

Like pipe, but the program's stdout replaces the message body. If the program exits non-zero or produces no output, the original message is passed through unchanged.

sieve
require ["vnd.yarilo.filter"];
if filter "add-disclaimer" [] {
    fileinto "Filtered";
}

The filter action returns true if the program exited 0 and produced output. The same program resolution and environment variable rules as vnd.yarilo.pipe apply.

Configuration (yarilo.yaml / values.yaml):

KeyDefaultDescription
sieve_filter_bin_dir/usr/lib/yarilo/sieve-filterDirectory of allowed filter executables
sieve_filter_socket_dirsieve-filterDirectory of allowed filter sockets (searched first)
sieve_filter_exec_timeout10Subprocess timeout in seconds
sieve_filter_input_eolcrlfLine endings written to stdin: crlf or lf

vnd.yarilo.execute — run a program and capture its output

Runs a program with optional stdin and makes its stdout available to the script. Unlike pipe/filter, the program does not receive the full message unless the script explicitly passes content. Exit code is exposed as a boolean result.

sieve
require ["vnd.yarilo.execute", "variables"];
if execute :input "check" "quota-check" [] {
    # exit 0 — quota OK
} else {
    reject "Quota exceeded";
}

The execute action returns true on exit 0. For Unix socket targets, non-empty output implies success (sockets have no exit code).

The same program resolution and environment variable rules as vnd.yarilo.pipe apply.

Configuration (yarilo.yaml / values.yaml):

KeyDefaultDescription
sieve_execute_bin_dir/usr/lib/yarilo/sieve-executeDirectory of allowed execute programs
sieve_execute_socket_dirsieve-executeDirectory of allowed execute sockets (searched first)
sieve_execute_exec_timeout10Subprocess timeout in seconds
sieve_execute_input_eolcrlfLine endings written to stdin: crlf or lf

vnd.yarilo.report — send an ARF abuse report

Generates an RFC 5965 Abuse Reporting Format (ARF) message about the current message and submits it to a target address (via sieve_submission_host, the same path as redirect/vacation). The report is a multipart/report; report-type=feedback-report with a human-readable part, a message/feedback-report machine part, and the reported message (full, or headers-only with :headers_only).

sieve
require ["vnd.yarilo.report"];
report "abuse" "User reported this as spam" "[email protected]";
report :headers_only "not-spam" "False positive" "[email protected]";

The feedback-type is an RFC 5965 registry token (abuse, fraud, not-spam, …). report is a side-effect action: it does not cancel implicit keep, so the message is still delivered.

Configuration (yarilo.yaml / values.yaml):

KeyDefaultDescription
sieve_report_user_agentyariloUser-Agent field written into the message/feedback-report part

Default script

On first delivery for a new user, yarilo seeds a default yarilo.sieve script:

sieve
keep;

Operators can replace this via ManageSieve or by writing a script to the user's dict storage directly.