Dovecot Mailbox Quotas: Per-User and Per-Domain Limits That Don't Blackhole Mail
A full mailbox either degrades gracefully or silently eats mail and burns your sender reputation. Here is how to wire Dovecot quotas — count backend, per-domain defaults under per-user overrides, real warning thresholds, and an over-quota bounce that returns a clean 5.2.2 instead of blackholing — on a Postfix + Dovecot LMTP virtual-user stack.
EvilMail TeamJuly 9, 202612 min read
A mailbox that hits its ceiling has exactly two ways to behave. Either it rejects the next message cleanly, so the sender's server tells them "this box is full," or it swallows the message, hands a success code back to the delivering MTA, and drops the mail on the floor. The second behavior is a catastrophe: the sender believes the mail was delivered, your user never sees it, and there is no bounce to trace. Get the quota configuration wrong and you turn a storage-billing knob into a silent data-loss machine — one that also shreds your sender reputation once receivers notice your box accepts-then-drops.
Quota is not a billing feature. It is a deliverability control. This is how we run it on the evilmail.pro stack — Postfix handing off to Dovecot over LMTP, virtual users in MySQL, maildir under /var/mail/vhosts — and the specific choices that keep a full mailbox honest.
Where quota actually lives in a virtual-user stack
Enforcement belongs to Dovecot, not Postfix. Trace one message: Postfix smtpd accepts it, then hands it to Dovecot through the LMTP socket (virtual_transport = lmtp:unix:private/dovecot-lmtp). Dovecot's quota plugin checks the recipient's usage *before* it writes to the maildir. Postfix never counts bytes; it only ever sees the LMTP reply — a 250
The `quota` plugin — the enforcement engine that decides accept or reject.
`quota_rule` — the limits themselves (a default plus named per-folder rules).
`imap_quota` and the `quota-status` policy service — reporting to IMAP clients and answering Postfix policy queries respectively.
On disk: config lives in /etc/dovecot/conf.d/90-quota.conf, mailboxes sit at /var/mail/vhosts/%d/%n (domain then user, owned 5000:5000 — the vmail uid/gid), and every LMTP delivery decision lands in /var/log/mail.log.
Choosing a backend: count vs maildir++ vs dict
Use the count backend. Full stop, for any modern setup.
The count backend tracks size in Dovecot's own index (vsize.cache), so a quota lookup is a single cheap read — no walking the maildir and stat()-ing thousands of files on every delivery. On a busy box with users sitting on 40k-message folders, that difference is the gap between instant LMTP replies and delivery-latency storms.
The alternatives exist for narrow reasons:
maildir++ stores a maildirsize file next to the mail. It works, but it drifts, it re-scans when the file goes missing, and it is legacy in 2026. Don't reach for it.
dict keeps quota in a SQL/Redis dictionary. You only want this when a proxy or userdb needs to know quota without touching a real mailbox — a special-case tool, not a default.
quota_vsizes = yes counts the *virtual* (RFC822 wire) size of each message rather than the physical bytes on disk. That is what IMAP clients expect to see, and it keeps the numbers stable across compression and single-instance storage. quota_grace = 10%% lets a delivery that *starts* under quota finish even if it tips slightly over, so a 3 MB message isn't rejected because the box was sitting at 1.999 GB. (The doubled %% is not a typo: Dovecot's config parser treats a lone % as a variable prefix, so a literal percent must be escaped.)
The count backend's one real weakness: if you rebuild indexes, rsync a mailbox in from outside Dovecot, or move files by hand, the tracked usage diverges from reality. The guardrail is a scheduled recalc — covered below — not a reason to avoid the backend.
Per-domain defaults with per-user overrides
Real hosting needs three layers of precedence: a specific user's limit beats their domain's default, which beats a global fallback. You build that with a COALESCE in the userdb query so each mailbox row carries its own resolved limit.
Because virtual_users has a domain_id foreign key into virtual_domains, the SQL userdb query builds a quota_rule field per user:
sql
-- dovecot-sql.conf.ext (userdb query)
SELECT
u.email AS user,
'/var/mail/vhosts/%d/%n' AS home,
5000 AS uid, 5000 AS gid,
CONCAT('*:bytes=',
COALESCE(u.quota_bytes, d.default_quota_bytes, 2147483648)
) AS quota_rule
FROM virtual_users u
JOIN virtual_domains d ON u.domain_id = d.id
WHERE u.email = '%u';
Precedence falls out naturally: if u.quota_bytes is set on the row, that user gets it; otherwise the domain's default_quota_bytes; otherwise the hardcoded 2 GB (2147483648) floor, so a NULL never silently means "unlimited." The userdb-supplied quota_rule overrides whatever default sits in the plugin block, which stays as a belt-and-suspenders fallback:
The named rules matter more than they look. quota_rule2 = Trash:storage=+100M grants Trash its own 100 MB *on top of* the main quota — the + is critical. Without it, a user who fills their box can't empty it, because deleting moves mail into Trash, which is also full. Junk:ignore keeps spam out of the count entirely, so an aggressive spam run doesn't lock a legitimate box. These two rules are the difference between a full mailbox the user can recover from and a deadlock.
Warning users before they hit the wall
Warnings only help if they arrive with enough runway to act. Wire three thresholds — 85, 90, and 95 percent — each firing a script through a dedicated service:
Dovecot runs the script as vmail over a fifo socket, passing the percent and the user. The script templates a mail and pipes it back to the user through the local delivery agent:
bash
#!/bin/bash
# /usr/local/bin/quota-warning.sh — chmod 0755, owned root, run as vmail
PERCENT=$1
USER=$2
cat <<EOF | /usr/lib/dovecot/dovecot-lda -d "$USER" -o "plugin/quota=count:User quota:noenforcing"
From: [email protected]
Subject: Your mailbox is ${PERCENT}% full
Content-Type: text/plain; charset=UTF-8
Your mailbox ($USER) is now ${PERCENT}% full.
At 100% new mail will be rejected and senders will get a
"mailbox full" bounce. Delete large or old messages, and
empty Trash, to free space now.
EOF
The noenforcing override on that dovecot-lda call is not optional: without it the warning mail itself gets rejected for being over quota — exactly when the user most needs to hear from you.
The behavioral gotcha everyone trips on: a warning fires once, on the delivery that crosses the threshold — not on every subsequent message while the box stays above it. Cross 90% and you get one 90% mail; you won't get another until usage drops back below 90% (which re-arms the trigger) and climbs past it again. Write the copy accordingly. Don't promise "we'll keep reminding you" — you won't. Say what's about to happen and what to do about it, once.
The over-quota moment: bounce, don't blackhole
This is the section that actually protects your reputation. When a box is full, there are three possible outcomes, ranked best to disaster:
1.Reject at RCPT time via a policy service — the sender's MTA gets a 552 5.2.2 during the SMTP conversation and generates its *own* bounce to its *own* user. You emit zero backscatter.
2.Reject at LMTP time — Dovecot returns 552 5.2.2 after Postfix already accepted the message, so Postfix must generate the bounce. Correct, but you're now the one mailing a possibly-forged return path.
3.Accept then drop — mail is lost, sender thinks it landed, no trace. Never do this.
Aim for #1. Dovecot ships a quota-status service that answers Postfix policy queries, so the full/not-full decision happens while the sender is still connected at RCPT TO:
service quota-status {
executable = quota-status -p postfix
inet_listener {
address = 127.0.0.1
port = 12340
}
client_limit = 1
}
plugin {
quota_status_success = DUNNO
quota_status_nouser = REJECT Unknown recipient
quota_status_toolarge = 552 5.2.2 Mailbox is full
}
Setting quota_status_toolarge to a full 552 5.2.2 Mailbox is full action string matters: a bare REJECT makes Postfix emit its default 554 5.7.1, whereas the numeric action passes straight through as the SMTP reply. Now point Postfix at the service, ordered *after* reject_unauth_destination so you never leak quota state for domains you don't host:
An over-quota recipient now gets a clean 552 5.2.2 at RCPT, the sending server bounces to its own user, and no message ever enters your queue only to die. If you skip the policy service and rely on LMTP-time rejection alone, Dovecot still returns the correct 552 5.2.2 (or 452 4.2.2 for a temporary system-storage problem) and Postfix will bounce — functional, just not backscatter-free. The one outcome that is never acceptable is a 250 followed by a discard.
Verifying and operating quotas
Check what Dovecot thinks a box holds:
bash
$ doveadm quota get -u [email protected]
Quota name Type Value Limit %
User quota STORAGE 1876543 2097152 89
User quota MESSAGE 4213 - 0
STORAGE is in KiB here: alice is at 89% of a 2 GB box. If those numbers ever look wrong — usually after a restore or a manual file move — recalc from the real mailbox:
bash
$ doveadm quota recalc -u [email protected] # one user
$ doveadm quota recalc -A # every user
And make the recalc a nightly guardrail so count-backend drift can never accumulate:
17 4 * * * root doveadm quota recalc -A
To prove the whole chain end-to-end, fill a test box past its limit, send it one more message from an external account, and watch /var/log/mail.log — you want the policy service or LMTP to return 552 5.2.2 Mailbox is full and Postfix to act on it, not a 250.
The checklist to sign off a quota deployment:
quota plugin loaded globally, imap_quota under protocol imap.
Backend is count:User quota, with quota_vsizes = yes.
Userdb SQL returns a quota_rule field per mailbox.
Per-domain default resolves via COALESCE(u.quota_bytes, d.default_quota_bytes, 2147483648).
Trash:storage=+100M and Junk:ignore named rules in place so a full box is recoverable.
Three quota_warning thresholds wired at 85/90/95%.
quota-warning.sh executable and delivering as vmail with noenforcing.
quota-status policy service reachable from Postfix on 127.0.0.1:12340, listed after reject_unauth_destination, with quota_status_toolarge carrying a 552 5.2.2 action.
Over-quota path returns 552 5.2.2 — verified in mail.log, never a silent 250.
Nightly doveadm quota recalc -A cron installed and monitored.