Dovecot Sieve & Quotas

Pigeonhole/Sieve from install to sieve_before vs sieve_after, ManageSieve on 4190, a working vacation/fileinto/spam-folder script, and per-user quotas backed by doveadm and a dict.

Know the three Sieve layers. sieve_before scripts are admin-enforced and run first — use them for global spam-folder and compliance rules users cannot disable. The user script (edited via Roundcube or ManageSieve) runs next. sieve_after is the final safety net — use it only for post-delivery logging or fileinto defaults.

Install Pigeonhole

Pigeonhole is the Dovecot implementation of Sieve. On RHEL-family:

dnf install -y dovecot dovecot-pigeonhole
systemctl enable --now dovecot

Debian-family:

apt install -y dovecot-core dovecot-imapd dovecot-sieve dovecot-managesieved

Two things to verify after install:

doveconf -n | head -30
doveadm mailbox list -u someuser@example.com      # confirms the user resolves

15-lda.conf and delivery

Sieve runs at delivery time — so it has to be wired into whichever LDA you use. Dovecot's own LDA (lmtp socket from Postfix) is the simplest path:

# /etc/dovecot/conf.d/15-lda.conf
protocol lda {
  mail_plugins = $mail_plugins sieve
  postmaster_address = postmaster@example.com
  hostname = mail.example.com
}

protocol lmtp {
  mail_plugins = $mail_plugins sieve
  postmaster_address = postmaster@example.com
}

Then in Postfix, hand off to LMTP rather than calling procmail:

# /etc/postfix/main.cf
mailbox_transport = lmtp:unix:private/dovecot-lmtp
virtual_transport = lmtp:unix:private/dovecot-lmtp

And in Dovecot:

# /etc/dovecot/conf.d/10-master.conf
service lmtp {
  unix_listener /var/spool/postfix/private/dovecot-lmtp {
    mode = 0600
    user = postfix
    group = postfix
  }
}

See Dovecot for the wider authentication and IMAP story and Postfix TLS & DKIM for the inbound path that feeds LMTP.

sieve_before / user / sieve_after

The Sieve plugin runs up to three scripts per message, in this order:

  1. sieve_before — admin-managed. You put global filters here. If any of them discards or explicitly keeps and stops, later scripts don't run.
  2. User script — editable by the user via ManageSieve or webmail. Default path ~/.dovecot.sieve.
  3. sieve_after — admin-managed post-processing. Most sites leave this empty.

The config:

# /etc/dovecot/conf.d/90-sieve.conf
plugin {
  sieve = file:~/sieve;active=~/.dovecot.sieve

  # Global admin rules that always run first:
  sieve_before = /var/lib/dovecot/sieve-before.d/

  # Post-delivery scripts that always run last:
  sieve_after  = /var/lib/dovecot/sieve-after.d/

  # Compilation and logging:
  sieve_extensions = +vacation-seconds +imap4flags +fileinto +mailbox +envelope +subaddress +copy +include +variables
  sieve_max_script_size = 1M
  sieve_quota_max_scripts = 20
  sieve_quota_max_storage = 5M
}

Put an active script under sieve_before per organisation-wide rule. The ordering is alphabetical, so name them with a leading number:

install -d -m 0755 /var/lib/dovecot/sieve-before.d
# /var/lib/dovecot/sieve-before.d/05-junk.sieve

ManageSieve on 4190

ManageSieve is the protocol users and webmail (Roundcube's managesieve plugin) use to upload and activate Sieve scripts. It lives on TCP 4190.

# /etc/dovecot/conf.d/20-managesieve.conf
service managesieve-login {
  inet_listener sieve {
    port = 4190
  }
}

protocol sieve {
  managesieve_max_line_length = 65536
  managesieve_implementation_string = Dovecot Pigeonhole
}

Firewall:

firewall-cmd --permanent --add-port=4190/tcp
firewall-cmd --reload

Test from the command line — no webmail needed:

openssl s_client -connect mail.example.com:4190 -starttls sieve
# Or plain:
nc mail.example.com 4190

# Logged-in example using the sieve-connect tool:
sieve-connect --user alice@example.com --server mail.example.com --port 4190 --tls
sieve> list
sieve> put vacation.sieve
sieve> activate vacation

Working example scripts

Global spam-folder rule (sieve_before)

# /var/lib/dovecot/sieve-before.d/05-junk.sieve
require ["fileinto", "mailbox", "imap4flags", "envelope"];

# Messages spamassassin tagged as spam: >= X-Spam-Flag: YES
if header :contains "X-Spam-Flag" "YES" {
    fileinto :create "Junk";
    setflag "\\Seen";
    stop;
}

# Subject-based quick triage — DO NOT rely on this as your only spam filter.
if header :contains "subject" ["*** SPAM ***", "[SUSPECTED JUNK]"] {
    fileinto :create "Junk";
    setflag "\\Seen";
    stop;
}

User vacation + fileinto + reject (~/.dovecot.sieve)

require ["vacation", "fileinto", "mailbox", "envelope", "imap4flags", "reject"];

# Reject anything from a known bad sender — bounces back.
if address :is "from" "harasser@example.org" {
    reject "Please do not contact this address.";
    stop;
}

# Mailing list folder:
if address :matches "list-id" "*.lists.example.com" {
    fileinto :create "Lists";
    stop;
}

# Only vacation during a set period, and only to real humans.
if allof (
    currentdate :value "ge" "date" "2026-07-01",
    currentdate :value "le" "date" "2026-07-21",
    not exists "list-id",
    not header :contains "auto-submitted" "auto-replied"
) {
    vacation
        :days 3
        :subject "Out of office until July 22"
        :addresses ["alice@example.com", "a.smith@example.com"]
        "I am away until 22 July 2026. For urgent issues contact ops@example.com.";
}
The :days 3 on vacation is the cooldown — the same correspondent gets one auto-reply every 3 days, not per message. The :addresses list is how Sieve knows which recipients to consider "me" so it does not reply to BCCs or list traffic.

Roundcube's typical generated syntax

When users click around in Roundcube's filter UI, the generated script looks like this — useful to know if you have to hand-edit after them:

require ["fileinto", "imap4flags"];

# rule:[From boss]
if header :contains "From" "boss@example.com" {
    addflag "\\Flagged";
    fileinto "INBOX/Work";
}

# rule:[Large attachments]
if size :over 20M {
    fileinto "INBOX/Archive/Big";
}

Quotas: mail_plugins, namespace, driver

Dovecot's quota subsystem has two halves: a backend (how it stores the count) and a policy (what happens when the count is over). Simplest working setup uses the count backend stored in dict:

# /etc/dovecot/conf.d/10-mail.conf
mail_plugins = $mail_plugins quota
# /etc/dovecot/conf.d/20-imap.conf
protocol imap {
  mail_plugins = $mail_plugins imap_quota
}
# /etc/dovecot/conf.d/90-quota.conf
plugin {
  quota = count:User quota
  quota_vsizes = yes

  quota_rule  = *:storage=1G
  quota_rule2 = Trash:storage=+200M
  quota_rule3 = SPAM:ignore

  # Warnings at 80% and 95%
  quota_warning  = storage=80%% quota-warning 80 %u
  quota_warning2 = storage=95%% quota-warning 95 %u

  quota_grace = 10%%
  quota_status_success = DUNNO
  quota_status_nouser = DUNNO
  quota_status_overquota = "452 4.2.2 Mailbox is full"
}

service quota-warning {
  executable = script /usr/local/libexec/dovecot/quota-warning.sh
  unix_listener quota-warning {
    user  = vmail
    group = vmail
    mode  = 0660
  }
}

Key fields:

doveadm quota commands

# Look at one user
doveadm quota get -u alice@example.com
# Quota name                         Type    Value     Limit  %
# User quota                         STORAGE 423912    1048576   40

# All users (slow on large installs)
doveadm -f table quota get -A

# Recalculate from the index / mailbox sizes (after crashes, manual moves, rsync
# restores — basically any time the number is wrong):
doveadm quota recalc -u alice@example.com

# Raise quota for a single user:
doveadm mailbox status -u alice@example.com messages INBOX

A common ops mistake is deleting mail outside Dovecot (with rm on the server) and then wondering why the user is still over quota. Always follow with:

doveadm force-resync -u alice@example.com INBOX
doveadm quota recalc -u alice@example.com

Per-user quota via dict

The default rule applies to everyone. For per-user overrides you have two options:

  1. A userdb field (LDAP, SQL) that sets quota_rule per user — cleanest.
  2. A Dovecot dict on disk / Redis / PostgreSQL that holds the override.

Option 1: per-user from SQL userdb

-- users table
CREATE TABLE mail_users (
  email     VARCHAR(255) PRIMARY KEY,
  password  VARCHAR(255) NOT NULL,
  home      VARCHAR(255) NOT NULL,
  uid       INT NOT NULL DEFAULT 5000,
  gid       INT NOT NULL DEFAULT 5000,
  quota_mb  INT NOT NULL DEFAULT 1024
);
# /etc/dovecot/dovecot-sql.conf.ext
user_query = \
  SELECT home, uid, gid, \
         CONCAT('*:storage=', quota_mb, 'M') AS quota_rule \
    FROM mail_users WHERE email = '%u'

Option 2: a Dovecot dict for counters

# /etc/dovecot/conf.d/90-quota.conf (override)
plugin {
  quota = dict:User quota::proxy::quota
}
dict {
  quota = pgsql:/etc/dovecot/dovecot-dict-quota.conf.ext
}
# /etc/dovecot/dovecot-dict-quota.conf.ext
connect = host=localhost dbname=dovecot user=dovecot password=SECRET
map {
  pattern = priv/quota/storage
  table = quota
  username_field = username
  value_field = bytes
}
map {
  pattern = priv/quota/messages
  table = quota
  username_field = username
  value_field = messages
}

The dict is useful when you have multiple Dovecot front-ends on NFS — they share the authoritative counter via the database.

quota_warning via doveadm pipe

The quota-warning.sh referenced above is how Dovecot informs users they are near the cap. The script runs as the user and delivers a message into their own INBOX using doveadm pipe:

#!/bin/sh
# /usr/local/libexec/dovecot/quota-warning.sh
PERCENT=$1
USER=$2

cat <<EOF | /usr/bin/doveadm -o "plugin/quota=count:User quota" \
  exec -u "$USER" dovecot-lda -d "$USER" -c /etc/dovecot/dovecot.conf
From: postmaster@example.com
To: $USER
Subject: Mailbox is ${PERCENT}% full

Your mailbox on mail.example.com is ${PERCENT}% full.
Above 95% new mail will be rejected. Please delete or archive older messages.
EOF
chown vmail:vmail /usr/local/libexec/dovecot/quota-warning.sh
chmod 750 /usr/local/libexec/dovecot/quota-warning.sh

Restart, then test:

systemctl restart dovecot
# Artificially push the user over 80% to see the warning fire:
doveadm quota recalc -u alice@example.com

Reseeding quotas after a restore

If you restore mail from a backup or rsync-move a user between servers, the stored quota count will not match reality. The fix is always the same three commands:

doveadm force-resync -u alice@example.com '*'
doveadm quota recalc -u alice@example.com
doveadm quota get -u alice@example.com

On Maildir-style storage the counts also live in maildirsize files inside each user's home:

find /var/vmail -name maildirsize -delete
# Then let dovecot rebuild them on next access:
doveadm -A force-resync '*'
Never edit maildirsize or the dict table by hand to "fix" the number. You will make the accounting diverge from reality and from any other Dovecot replica. Always go through doveadm quota recalc.

Troubleshooting

SymptomLikely causeFix
Sieve rules "do nothing" for a user User script never activated, or saved to ~/sieve/foo without a link to ~/.dovecot.sieve. ls -la ~user/ and check for the .dovecot.sieve symlink. From ManageSieve, activate <name>.
Sieve compile error on delivery: error: script compile failed Extension used but not listed in sieve_extensions. Add the extension (e.g. +variables). sievec locally to reproduce: sievec script.sieve.
Vacation replies never send Recipient is a mailing list or Auto-Submitted header present; Sieve's vacation logic correctly ignores these. Not a bug. Confirm by sending from an external address without list headers.
User gets "quota exceeded" but doveadm quota get shows 40% Phantom quota from an old maildirsize on NFS; dict and Maildir disagree. doveadm force-resync -u; delete stale maildirsize; doveadm quota recalc.
452 4.2.2 Mailbox is full bounces start after a restore Recalculation never ran; counter is still from pre-restore. See Reseeding.
ManageSieve login fails with AUTHENTICATE: Failed but IMAP login works auth_mechanisms in 10-auth.conf omits plain login for the managesieve protocol, or TLS not offered. Add disable_plaintext_auth = no inside a protocol sieve block while testing over localhost, then enable STARTTLS.
quota-warning never fires despite user at 90% Thresholds crossed in the wrong direction — warnings only fire on crossing a line, not on re-delivery when already over. Drop to 70% temporarily so a delivery crosses; test with a single delivery rather than bulk recalc.
Reusable. Once this page's sieve_before is in place your Junk-folder policy is homogeneous across all users forever. Pair with Postfix TLS & DKIM on the submission side and rsyslog for the actual filtering logs.