Running my own mail server
Postfix, Dovecot and DKIM from scratch, to see how email fits together. Mostly a story about commands that did nothing and said nothing.
I work with email every day, from the template end. I wanted to know what happens at the other end, so I set up a mail server for dlk25.com from scratch on a small rented Linux box, with Claude as a patient tutor. It sends and receives as dan@dlk25.com, and passes SPF, DKIM and DMARC. It took two months, most of them waiting, and the lessons were not the ones I expected.
Two jobs, two programs
Email is not one job. It is two, with a protocol each:
- Postfix speaks SMTP and moves mail between servers. It takes mail in from strangers, sends it on, queues it and retries when the other end is down. It knows nothing about inboxes, folders or what has been read.
- Dovecot speaks IMAP and lets a person read what has arrived: folders, read flags, search, several clients on one mailbox. It never sends anything anywhere.
Around them: a TLS certificate from Let's Encrypt that both use, OpenDKIM to sign outgoing mail, and DNS.
MX tells the world which machine takes mail for the domain, and the reverse
lookup (PTR) proves the machine may call itself mail.dlk25.com. SPF, DKIM
and DMARC are not part of the server at all. They are DNS records the
receiving end looks up to decide whether mail that claims to be from
dlk25.com really is.
There are three doors. Port 25 is server to server, and has to take mail from anyone without a password, or nobody could send you anything. Port 587 is where my own mail client hands over mail to send, and demands a login. IMAP over TLS is where the client reads. Mix up the first two and you are an open relay: strangers sending spam through you.
One thing I liked: Postfix has no user database for mail, so it asks Dovecot over a socket whether a login is good. One password store, two services.
The interface is a directory
The part that taught me most is that Postfix and Dovecot do not talk to
each other about the mail. Postfix writes files into ~/Maildir/, Dovecot
reads files from ~/Maildir/. That is the whole integration: a folder on
disk, no protocol, no API.
It is elegant, and nothing checks that the two agree. For two months Postfix
delivered to ~/Maildir/ while Dovecot looked for mbox files somewhere
else. Both ran without a single error. Mail arrived, and the inbox was
empty.
Commands that did nothing
Four times the symptom was the same: a command that exited cleanly without doing anything.
- A setting that never landed. I told Postfix to use Dovecot for logins and spent an evening debugging Dovecot's password setup. The clue in the end was the server's greeting: it offered a long list of login methods that Dovecot does not have. Postfix was still using its built-in default, and the change I thought I had made was not in the config file. Every change on the Dovecot side made no difference, because Postfix was not asking Dovecot at all.
- A
sedthat matched nothing. A newer Dovecot renamed the setting for where mail lives. Mysedline searched for the old name, found nothing, changed nothing, and exited 0. That is the empty inbox above. - The same again, for the certificate. Another renamed setting, another
silent
sed, so Dovecot served a self-signed certificate while Postfix had the real one. 2>/dev/nullhid the one error message that would have said what was wrong.
What I do now: after any config change, check that it is actually in the file before debugging anything further down. If a symptom stays exactly the same whatever you change on one side of an integration, the other side is probably not using it.
And keep two questions apart. Is the config valid, and what will be
loaded? postconf and doveconf answer that, from disk. What is the
running process doing right now? Only connecting to the port from outside
answers that. At one point Dovecot's admin tool counted the messages
correctly, because it read the fixed config from disk itself, while the
running service had not been restarted and still used the old one.
The subdomain trap
Outgoing mail failed SPF and DKIM, and for a while I had written that off as
harmless. It wasn't. The command line mail tool sends as user@<the machine's name>, so the mail went out as dan@mail.dlk25.com. The SPF
record is on dlk25.com, and DKIM was set up to sign for dlk25.com, so
the subdomain matched neither. Postfix leaves addresses that already have a
domain alone.
The fix was to have Postfix rewrite subdomains to the bare domain, both the
envelope and the From: header. The header matters: DMARC wants the domain
DKIM signs for to match the one the reader sees in From:. A signature can
be perfectly valid and still fail DMARC because it signs the wrong domain.
mail.dlk25.com is the name of the machine. dlk25.com is who I send as.
One identity is enough.
Failing open, quietly
Postfix hands each outgoing message to OpenDKIM to sign. It is set to send the mail anyway if the signer cannot be reached, so a broken signer does not stop all mail.
Sensible, and it means a broken connection gives no error at all: the mail
just goes out unsigned. The only sign was a missing
DKIM-Signature header and silence in the signer's log. The cause was
writing localhost where an IP address was needed, because part of Postfix
runs in a chroot where looking up names can fail.
Things outside my control
The hosting company blocks outgoing port 25 on new accounts, against spam. No setting on my side could change that, and the block could not be lifted until the first month was paid. That was the two months of waiting. Testing port 25 against several hosts, next to ports that work, is what showed it was a block on that port and not my own config.
The server's address also turned up on a blocklist, almost certainly from whoever had it before me. Cloud address ranges are often listed in advance. A delisting request is the only fix.
Checking it
The most useful single tool was port25's authentication verifier: send it a
mail and it replies with SPF, DKIM, DMARC and reverse DNS, each with a
reason. Read the right part, though. The Authentication-Results header on
the reply is my server checking their signature and says pass even when
everything of mine is broken. The verdict on my mail is in the body.
Two more things I learned to read properly. A blocklist lookup can answer "refused" in a form that looks like a listing, so the codes matter. And a relay test from the server itself proves nothing, because the server trusts itself on purpose. Test from somewhere else.
It works now. Most days I am still at the template end, but I know a little more about what happens after send.