Postfix to Dovecot over LMTP: Rip Out procmail and Deliver the Right Way
procmail and pipe(8) delivery fork a shell per message and write to the mailbox behind Dovecot's back — stale indexes, drifting quota, no in-process Sieve. LMTP is the modern hand-off: a persistent Dovecot service that owns the mailbox, updates indexes atomically, runs Sieve in-process, and enforces quota exactly once. Here's the wire-level why plus the exact Postfix and Dovecot config to migrate.
EvilMail TeamJuly 10, 202611 min read
procmail and pipe delivery are the wrong hand-off in 2026
Run a real virtual-domain mailserver long enough and the delivery layer becomes the part that quietly rots. If your final hop still looks like mailbox_command = /usr/bin/procmail or a custom dovecot pipe transport in master.cf, you are paying for it in ways that don't surface until 2 a.m.
Three concrete failure modes:
A fork per message.pipe(8) and procmail spawn a fresh process for every single delivery — fork, exec, tear down. Under a burst (a newsletter blast, a retry storm, a temp-address domain getting hammered) you are competing for PIDs and scheduler time exactly when you can least afford it. A persistent service does none of that.
Writes behind Dovecot's back. procmail drops the message straight into the Maildir. Dovecot didn't do the write, so
are now stale. The next IMAP session Dovecot notices files it never indexed and forces a rebuild — extra I/O, and occasionally a corrupted index that needs
doveadm force-resync
.
Quota is a lie. The quota Dovecot enforces is derived from its own accounting. Deliver around it and quota is either uncounted (users blow past their limit) or double-counted after a recalc. Either way your numbers stop matching reality.
And Sieve, if it runs at all, runs as a separate LDA binary with its own config that drifts from your IMAP server's. The principle is simple: delivery should be owned by the same process that serves IMAP. procmail's last real release was 3.22 in 2001. It is effectively unmaintained, and upstream itself steers you away from new deployments. Stop shipping mail through it.
What LMTP actually is (RFC 2033)
LMTP — Local Mail Transfer Protocol, RFC 2033, from 1996 — is SMTP with the store-and-forward guarantees stripped out, purpose-built for the final hop. On the wire it looks almost identical to SMTP: LHLO replaces EHLO, then MAIL FROM, RCPT TO, DATA. The receiving side is explicitly forbidden from queueing; it must either accept a recipient into the mailbox or reject it, right now.
That constraint buys the one feature that matters most.
LMTP returns a separate reply code per recipient after DATA. Send a message to [email protected] and [email protected] in one transaction and you get back two independent replies — say 250 2.0.0 Saved for the first and 552 5.2.2 Over quota for the second. Postfix reads that, marks u1 done, and requeues only u2. A single pipe(8) exit code physically cannot express "one succeeded, one failed" — it's one integer for the whole transaction, so a multi-recipient partial failure means either duplicate deliveries or lost mail.
Because LMTP never queues, the MTA stays authoritative for retries — exactly where retry logic belongs. Dovecot just says yes or no per recipient, and Postfix owns the queue.
Configuring Dovecot's LMTP service
Start on the Dovecot side, because Postfix will refuse to talk to a socket that doesn't exist yet.
Enable the protocol in dovecot.conf (or 10-master.conf, depending on your layout):
conf
protocols = imap lmtp
Then define the service. For a single-host box where Postfix and Dovecot share a machine, expose a Unix socket inside Postfix's queue directory so the chrooted Postfix client can reach it. In 10-master.conf:
conf
service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp {
mode = 0600
user = postfix
group = postfix
}
# Split-host alternative — TCP on the standard LMTP port:
# inet_listener lmtp {
# address = 127.0.0.1
# port = 24
# }
}
The user/group/mode triplet is the single most common thing people get wrong, and we'll come back to it. Now wire up the delivery-time plugins. Quota is enabled globally; Sieve must be enabled specifically under the lmtp protocol or filters silently never run on delivery:
postmaster_address is not optional — if it's unset, the LMTP service logs postmaster_address setting not given and refuses to start. For virtual users (the evilmail stack keeps mailboxes at /var/mail/vhosts/{domain}/{username}/ owned by uid/gid 5000, users in the mailserver DB) the LMTP service also needs the auth-userdb socket so it can resolve RCPT TO addresses to a real mailbox path. That's the same auth-userdb listener your IMAP already uses; just confirm it's readable by the LMTP process.
Wiring Postfix to hand off over LMTP
One line in main.cf does the work for virtual mailboxes:
Lowercase `lmtp:` is the built-in Postfix LMTP client (lmtp(8)), not a custom transport name. Do not confuse it with a dovecot pipe entry you may have in master.cf — that entire pipe block gets deleted, not reused.
The socket path is relative to `queue_directory`.private/dovecot-lmtp resolves to /var/spool/postfix/private/dovecot-lmtp, which is exactly why the Dovecot listener lives under /var/spool/postfix — it works even though Postfix runs chrooted.
If you also deliver to local system users (non-virtual), point those at Dovecot too:
Now delete the old delivery path. Remove the procmail line from main.cf:
conf
# DELETE this:
# mailbox_command = /usr/bin/procmail -a "$EXTENSION"
and remove any custom dovecot pipe service from master.cf (the block that shells out to deliver/LDA). Then reload — no full restart needed:
bash
postfix reload
doveadm reload
Verifying the hand-off end to end
Don't trust config, trust output. First confirm the service is up:
bash
doveadm service status lmtp
# lmtp 0 0 ... (process_count client_count ...)
Then send a real message and watch the log. swaks speaking LMTP straight at the Unix socket is the cleanest smoke test — run it as root so it can open the 0600 socket:
After the final . you get two stacked replies, one per RCPT — that's the protocol feature that makes partial delivery safe.
Finally, confirm the two things procmail always got wrong — quota accounting and Sieve:
bash
doveadm quota get -u [email protected]
# Quota name Type Value Limit %
# User quota STORAGE 4213 1048576 0
The number moved by exactly the message size, once. And if the user has a fileinto rule, the message landed in the target folder — because Sieve ran in-process during delivery, not as a bolt-on binary.
The failure modes that actually bite
`Permission denied` on the socket. By far the number one issue. The Dovecot unix_listenermode/user/group must match the (chrooted) Postfix client. mode = 0600 with user = postfix is the safe combination. Check with ls -l /var/spool/postfix/private/dovecot-lmtp.
Wrong path relative to the chroot. If the socket lives outside /var/spool/postfix, the chrooted lmtp(8) can't see it. Keep it under the queue dir and reference it as private/dovecot-lmtp.
`lmtp` vs `lmtps`. You want plain lmtp. There's no TLS handshake on a local Unix socket, and pointing virtual_transport at an lmtps service will just hang.
Sieve set globally but not under `protocol lmtp`.mail_plugins = $mail_plugins sieve at the top level covers ManageSieve and IMAP, but delivery-time Sieve requires it inside the protocol lmtp { } block. Miss this and filters silently no-op on incoming mail.
Quota plugin ordering. Load quota before plugins that write mail so the accounting hooks are in place; a misordered plugin list is how you get "quota looks right but never enforces."
Don't keep `deliver`/LDA around. With LMTP owning delivery there's no reason to invoke the standalone dovecot-lda. Two delivery agents means two places for config to drift.
Migration checklist
1.Add lmtp to protocols in Dovecot.
2.Add the service lmtpunix_listener under /var/spool/postfix/private/ with mode = 0600, user = postfix.
3.Set postmaster_address and enable Sieve under protocol lmtp; enable quota globally.
4.Set virtual_transport = lmtp:unix:private/dovecot-lmtp (and mailbox_transport if you have local users).
5.Delete mailbox_command = /usr/bin/procmail ... and any dovecot pipe block in master.cf.
6.postfix reload and doveadm reload.
7.Send a two-recipient test; confirm two per-recipient status codes in the log.
8.Verify doveadm quota get moved once and Sieve fired.
9.apt remove procmail — burn the boats.
The payoff is not subtle: atomic index writes, quota enforced exactly once, Sieve running in the same process that serves your IMAP, and zero fork storms when a temp-address domain gets slammed. On a mailserver that has to stay honest about quota and stay fast under bursts — the whole point of the evilmail.pro stack — LMTP isn't the nicer option, it's the only defensible one.