Migrating Postfix from local mbox to virtual Maildir without losing a single message
The mbox-to-Maildir conversion is trivial. The dangerous part is the window where nobody is sure whether the old spool or the new store is authoritative. Here is a zero-loss migration discipline: freeze delivery, drain the queue, convert a copy, reconcile counts before you flip anything, and keep rollback one config line away.
EvilMail TeamJuly 10, 202613 min read
The failure mode nobody plans for
The classic way to lose mail during an mbox migration is not a bad conversion tool. It is cp -r /var/mail /backup run at 14:03 while local(8) is still delivering into /var/mail/alice. Postfix appends a message to the spool, your copy grabs it mid-write, and now you have a truncated final message and a From line that landed inside a body. Do it while two processes hold the same mbox without a dotlock and it gets worse: interleaved writes, three users' mail smeared into one file, and a corruption you will not notice until someone complains their inbox "went weird" a week later.
The conversion itself — mbox to Maildir — is a solved problem. The risk lives entirely in the window between "the old spool is authoritative" and "the new Maildir is authoritative." Everything below is built around one invariant: exactly one writer, delivery frozen, message counts verified on both sides before anything irreversible happens.
Scope for this walkthrough: you are on Postfix today with local(8)
Migrate Postfix mbox to Virtual Maildir Users (Zero Mail Loss) — EvilMail Blog
writing mbox files to
/var/mail/$USER
. The target is Dovecot
virtual users
storing Maildir under
/var/mail/vhosts/<domain>/<user>/
, owned by a single
vmail
account (uid/gid 5000). No mail lost, no read/unread state lost, and a rollback that is one
main.cf
line.
Where your mail actually lives right now
Before you touch anything, establish the source of truth. On a legacy box "the mailbox" can be in more than one place, and if you convert the wrong one you will silently drop whatever the IMAP server was actually serving.
bash
# What does Postfix think it's doing?
postconf mail_spool_directory home_mailbox mailbox_command mailbox_transport local_transport
# Enumerate real mailboxes with message counts (crude but honest)
for u in $(cut -d: -f1 /etc/passwd); do
[ -s "/var/mail/$u" ] && echo "$u $(grep -c '^From ' "/var/mail/$u")"
done
mail_spool_directory empty and home_mailbox = Maildir/ means delivery already went to ~/Maildir and the /var/mail spool is stale — convert the homes, not the spool. A non-empty mailbox_command means procmail or a Sieve wrapper sits in the path and may already be writing Maildir. Figure out who the actual delivery agent is: plain local(8), procmail, or an existing Dovecot LDA.
grep -c '^From ' counts messages by counting mbox separator lines. It is only correct if the mboxes are well-formed. Sanity-check for corruption before you trust any count:
bash
# A body line that looks like "From " but wasn't escaped = corrupted separator
grep -nE '^From [^ ]+ ' /var/mail/alice | head
Record these counts. They are the numbers you will reconcile against after conversion, and if you skip this step you have nothing to prove the migration was lossless.
Design the target: virtual users, not Unix users
This is the part people misread as a format change. It is an architecture change. With local(8) mbox, mailbox identity is /etc/passwd — to add a mailbox you useradd, and every mailbox is a shell account with a uid. With virtual users, identity lives in a database (SQL or a passwd-file) and Postfix stops caring who the recipient is at the OS level. One vmail user owns all storage; Dovecot owns authentication and the message store; Postfix just hands the message off over a socket.
Handing delivery to Dovecot over LMTP is a deliberate choice, not incidental plumbing. Once Dovecot is the delivery agent you get Sieve filtering, per-mailbox quota enforcement, and full-text indexing for free, all keyed on the virtual user — none of which local(8) can do. The main.cf shape you are aiming for:
Here is where most guides go wrong: they tell you to postfix stop. Don't. A stopped Postfix refuses connections, and sending servers that hit connection-refused during your maintenance window can hard-bounce depending on their retry policy — you have converted "brief delay" into "sender got a bounce." The correct move is to keep accepting mail but stop *delivering* it locally, so it piles up safely in the deferred queue and senders never see an error.
bash
# Stop local + virtual delivery; keep accepting inbound
postconf -e defer_transports=local,virtual
postfix reload
# Push everything already queued into HOLD so nothing sneaks through
postsuper -h ALL
# Prove the active/incoming queues are empty before you convert
postqueue -p
mailq
defer_transports tells Postfix to accept the message normally — the sending server gets its 250 OK — but hold delivery on those transports in the deferred queue and retry on Postfix's own schedule. From the sender's side the mail is delivered; it simply has not hit disk yet. postsuper -h ALL holds the current queue so a message that arrived a millisecond before your reload cannot land in an mbox you are about to convert. When postqueue -p shows an empty active queue, you have a frozen, consistent source. That is the only state in which it is safe to touch /var/mail.
Convert mbox to Maildir without losing flags
Now convert — and convert a copy, never the live spool. The single most common way to lose read/unread state here is a formail -s procmail re-delivery loop: it re-runs every message through your filters and, worse, discards the mbox Status: and X-Status: headers, so every message in every inbox comes back marked unread. To a user, thousands of messages flipping to bold unread is indistinguishable from "my mail got scrambled."
Use a tool that maps the flag headers into Maildir filename suffixes. mb2md does this correctly; so does Dovecot's own doveadm import from an mbox namespace. The mapping you care about: mbox Status: RO (Read, Old) and the X-Status bits become the Maildir :2, suffix, where S=Seen, R=Replied, F=Flagged, T=Trashed, D=Draft. A read message ends up as a file in cur/ named ...:2,S. An unread one lands in new/ with no suffix.
bash
# Work on a copy of the frozen spool, not the original
install -d -o vmail -g vmail /var/mail/vhosts/evilmail.pro/alice
mb2md -s /var/mail/alice -d /var/mail/vhosts/evilmail.pro/alice/Maildir
# Fix ownership and perms — Dovecot refuses group/other-readable Maildirs
chown -R vmail:vmail /var/mail/vhosts
chmod -R 700 /var/mail/vhosts
After conversion a valid Maildir has exactly three subdirs — tmp/, new/, cur/ — and every message is one file. If new/ and cur/ are both empty but the mbox had messages, your source path was wrong. The chmod 700 matters: Dovecot treats a world- or group-readable Maildir as a security problem and will error out rather than serve it.
Wire up the Dovecot and Postfix handoff
Point Dovecot's storage at the virtual layout and expose the LMTP socket exactly where Postfix expects it. This infra uses the PLAIN password scheme with vmail uid/gid 5000, so keep those consistent.
ini
# dovecot: mail_location and the LMTP listener Postfix talks to
mail_location = maildir:/var/mail/vhosts/%d/%n
service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp {
mode = 0600
user = postfix
group = postfix
}
}
The socket permissions are the silent killer of this whole setup. Get the mode or owner wrong and Postfix cannot open the socket; mail does not bounce and does not deliver — it just defers, and the only evidence is a log line:
postfix/lmtp: connect to private/dovecot-lmtp: Permission denied (deferred)
If mail mysteriously stacks up in the deferred queue after cutover, this is the first thing to check. user = postfix on the unix_listener is what lets Postfix's lmtp client actually connect.
Verify before you flip
This is the gate. Nothing destructive has happened yet — the original mboxes are untouched and delivery is still deferred. You do not un-defer until message counts reconcile on both sides.
bash
# Old side (mbox separators) vs new side (Dovecot's authoritative count)
grep -c '^From ' /var/mail/alice
doveadm mailbox status -u [email protected] messages INBOX
# Rebuild the index without touching a single message, then check again
doveadm force-resync -u [email protected] INBOX
If the numbers match, do a real end-to-end test: send a message in, watch it land in cur/ (or new/) with the expected flags, and log in with an actual IMAP client to confirm read/unread state survived. Only then release the freeze:
bash
postconf -e defer_transports= # clear the deferral
postfix reload
postsuper -H ALL # release held mail back to the queue
If the counts *don't* match, you stop. Nothing is lost: you converted a copy, and the original mboxes sit untouched on disk. Fix the conversion and re-run the gate.
Rollback and the cutover checklist
Rollback is genuinely one line: set virtual_transport back, re-enable local, postfix reload. Your original mboxes were never modified — the conversion only ever wrote to /var/mail/vhosts — so reverting is instant and lossless. That property is the whole reason this procedure is safe, and it is why "convert a copy" is non-negotiable.
Backup + snapshot counts — grep -c '^From ' every mailbox, write the numbers down.
Freeze — defer_transports=local,virtual, postfix reload, postsuper -h ALL.
Drain — confirm postqueue -p active queue is empty.
Convert the copy — mb2md into /var/mail/vhosts/..., originals untouched.
Dovecot config — mail_location, LMTP unix_listener at mode 0600 user postfix.
Reconcile — doveadm mailbox status ... messages INBOX must equal the mbox count.
Test — live send lands with correct flags, IMAP login works.
Un-defer + release — clear defer_transports, postsuper -H ALL.
Watch logs 24h — grep mail.log for dovecot-lmtp permission errors and deferrals.
The operational payoff shows up the day after. Adding a new mailbox is no longer useradd plus a home directory plus a shell you have to lock down — it is a single INSERT into the virtual-users table. Mailbox identity has left /etc/passwd for good, which is exactly where it belongs once you are running more than one domain.