Server-side mail rules that actually run: Dovecot Sieve for sorting, forwarding, and vacation replies
Client-side filters only run when the client is open. Sieve runs at delivery, inside Dovecot, once per message, for every device. Here is how to sort, forward, and auto-reply on the server without wrecking your deliverability.
EvilMail TeamJuly 9, 202613 min read
A client of ours had a Thunderbird rule that filed invoices into an Accounting folder. It worked great — on the desktop. On the phone, the same invoices piled up unread in the inbox, because the rule only fires when Thunderbird is open and syncing. Then the laptop got rebuilt, the rule config didn't survive the migration, and now there were three devices with three slightly different sets of filters, none of them authoritative. This is the normal failure mode of client-side mail rules: they run in the wrong place, at the wrong time, and they drift.
Server-side Sieve fixes this by construction. The rule executes exactly once, at delivery, inside Dovecot's local delivery path — before the message ever lands in a mailbox. A phone opening IMAP, a webmail session, and a desktop client all inherit the same already-sorted inbox, because the sorting happened on the server hours before any of them connected. Put your mail logic in Sieve, keep it in one place, and test it before it goes live. The rest is detail.
Where Sieve runs in the delivery path
Sieve is not a daemon you poll. It's a plugin that runs synchronously in the local delivery agent. The chain looks like this: Postfix accepts the message over SMTP, then hands it to Dovecot over LMTP (or the older LDA binary). Dovecot's Pigeonhole plugin evaluates the Sieve script, performs whatever actions it dictates — file, flag, redirect, discard — and only then commits the message to storage. If nothing matches, the implicit keep drops it into INBOX.
There are three script layers, and understanding their order is 90% of using Sieve well:
`sieve_before` — a global script that runs first. This is where org-wide policy lives: routing flagged spam, quarantining a known-bad sender, tagging everything from a partner domain. Users cannot override it.
The per-user active script — the user's own rules. On a classic setup this is ~/.dovecot.sieve.
`sieve_after` — a global script that runs last. This is your catch-all filing: "anything still in INBOX from a no-reply@ address, drop into Notifications."
Minimal working config
On Debian/Ubuntu the implementation is Pigeonhole: apt install dovecot-sieve dovecot-managesieved. The plugin has to be loaded on whichever delivery service you use. For LMTP (the modern default), in /etc/dovecot/conf.d/20-lmtp.conf:
The file:~/sieve;active=~/.dovecot.sieve syntax means user scripts live in ~/sieve/ and the active one is symlinked to ~/.dovecot.sieve. Pigeonhole compiles each script to a .svbin bytecode cache next to it (~/.dovecot.svbin) and recompiles automatically whenever the source .sieve mtime is newer. The one thing that trips people up here is permissions — the dovecot delivery user must be able to read and write both the .sieve and the .svbin, or every delivery pays the recompile cost and logs a warning.
Sorting: fileinto and folder creation
Here is a real sorting script. It files a mailing list, flags anything from your boss, and quarantines large attachments:
require ["fileinto", "mailbox", "imap4flags"];
# Mailing lists — match on List-Id, create the folder if missing
if header :contains "list-id" "<dovecot.dovecot.org>" {
fileinto :create "Lists/dovecot";
stop;
}
# VIP sender: file a copy but keep in INBOX too, and flag it
if address :is "from" "[email protected]" {
addflag "\\Flagged";
fileinto :copy "VIP";
}
# Big attachments out of the way
if size :over 10M {
fileinto :create "Large";
stop;
}
A few semantics worth internalizing. Tests come in flavours: address parses a header as an address and lets you test the :local/:domain part; header is a raw string test; envelope tests the SMTP-level MAIL FROM/RCPT TO rather than the message headers. Match types are :is (exact), :contains (substring), and :matches (wildcards * and ?). Combine conditions with allof( ), anyof( ), and not.
stop halts the script — nothing after it runs. fileinto cancels the implicit keep, so a message you fileinto "X" does *not* also land in INBOX. If you want both, use fileinto :copy (as in the VIP rule above) or an explicit keep;. The :create argument auto-creates the target folder. Enabling subaddress lets you test the :detail of user+tag@domain addresses, which is gold for per-signup tagging; imap4flags gives you setflag/addflag for \Seen, \Flagged, and friends. Every one of these extensions must be listed in sieve_extensions or the script won't compile.
Forwarding without wrecking deliverability
redirect "[email protected]"; resends the message. The catch — and it is a real one — is that a plain redirect keeps the original envelope sender. The forwarding hop is now sending mail claiming to be from the original sender's domain, from your server's IP, which that domain's SPF record does not authorize. The destination sees an SPF fail and may junk or reject it.
Two honest fixes. At the MTA, run SRS (Sender Rewriting Scheme) with something like postsrsd, which rewrites the envelope sender into a domain you control so SPF passes. Or, inside Sieve, set sieve_redirect_envelope_from = recipient so the redirected message uses the recipient address as the new envelope sender — pragmatic, and enough for most single-hop forwards. Either way, do not rewrite message headers on the way out: DKIM survives forwarding only if the signed body and headers are untouched, so a surviving DKIM signature is your fallback authentication when SPF breaks.
Loop protection is built in — Pigeonhole caps the number of redirects at sieve_max_redirects (default 4) and refuses to re-forward a message it has already redirected. And know when *not* to use Sieve: if the forward is unconditional ("send everything for sales@ to three people"), a Postfix virtual_alias_maps entry is cheaper, doesn't rewrite anything, and runs earlier. Reserve Sieve redirect for conditional forwarding that depends on headers or content.
Vacation auto-responders that don't get you blocklisted
This is the feature people get wrong most often, and the failure is expensive. A naive "reply to everything while I'm away" auto-responder will happily reply to a mailing list (annoying everyone), to a spam run with a forged sender (backscatter to an innocent third party), and to bounce messages (a loop). Backscatter to forged senders is a classic way to get your IP onto a blocklist. RFC 5230's vacation action exists precisely to stop all of that, and Pigeonhole implements the safety gates for you — but you have to let it.
require ["vacation"];
vacation
:days 7
:subject "Out of office"
:addresses ["[email protected]", "[email protected]"]
:handle "summer-2026"
"I'm away until Monday and will reply when I'm back.";
The :addresses list tells Sieve which of your addresses count as "me" — a reply is only sent if the incoming message was actually addressed (To/Cc) to one of them, which kills replies to list traffic that merely mentions you. :days 7 sets the de-dup window: each unique sender gets at most one auto-reply per 7 days, tracked in a small per-user database under the mail home. :handle groups the tracking so that editing the reply text later doesn't reset everyone's timer. On top of your settings, the engine unconditionally suppresses replies when the envelope sender is empty (MAILER-DAEMON, bounces), and when List-Id, Precedence: bulk/list/junk, or Auto-Submitted headers are present. Keep :days at 1 or more — there is no legitimate reason to auto-reply to the same person multiple times a day.
ManageSieve: letting users edit scripts safely
Hand-editing ~/.dovecot.sieve over SSH is fine for one mailbox you own. The moment you have real users, you want ManageSieve — the protocol on port 4190 (RFC 5804) that lets a client upload, validate, list, and activate scripts. Enable it by adding sieve to the protocols line and running the service:
protocols = imap lmtp sieve
Why it beats SSH file editing: the server compiles and validates each script *on upload*, so a syntax error is rejected before it can break delivery; activating a new script is atomic, so there's no window where a half-written script is live; and every user is isolated to their own scripts with the same passdb as IMAP. Make it TLS-only (ssl = required) — you're shipping credentials and mail logic over it. Clients include Thunderbird's Sieve add-on, the sieve-connect CLI, and KMail. On the server, doveadm sieve list/get/put/activate -u user@domain does the same operations for admins.
Testing before you trust it
The line between a professional and a cargo-culter is that the professional never activates an untested Sieve script. Three tools:
bash
# 1. Compile-check for syntax errors (writes .svbin, errors to stderr)
sievec ~/.dovecot.sieve
# 2. Dry-run against a real message — shows actions WITHOUT delivering
sieve-test -e -m INBOX ~/.dovecot.sieve message.eml
# 3. Reprocess an existing mailbox through a script
sieve-filter -e -W ~/scripts/sort.sieve INBOX
sieve-test is the one you'll live in. Feed it a real .eml and it prints the actions the script *would* perform — for example Performed actions: fileinto "Lists/dovecot" and store message in folder: Lists/dovecot — without touching the mailbox. Test your vacation script this way too: point it at a message with a List-Id header and confirm it reports no vacation reply.
Pre-flight checklist
Every extension you use (vacation, imap4flags, mailbox, subaddress, variables, editheader) is listed in sieve_extensionsandrequired in the script.
sievec compiles the active script clean, no warnings.
Ran sieve-test -e -m INBOX against a real message and confirmed the actions match intent.
Vacation has :addresses set to your real addresses and :days >= 1; you tested it against list mail and saw no reply.
Forwarding sets sieve_redirect_envelope_from = recipient or you run SRS at the MTA; no header rewriting that would void DKIM.
Org policy lives in sieve_before, catch-all filing in sieve_after, user rules in the active script — not all crammed into one file.
ManageSieve on 4190 is ssl = required; nothing plaintext.
The .sieve and .svbin are readable and writable by the dovecot delivery user.
Active scripts are backed up (or version-controlled) — a doveadm sieve get cron is enough.
You verified that unmatched mail still lands in INBOX via implicit keep, and that no stray discard; is silently eating messages.