Mail is not that hard

I have hosted my own mail for frimin.fr since 2019, today on a single FreeBSD machine. Friends told me self-hosted mail is too hard. Setting it up takes some work, but running it takes almost none.

I started after reading this post by Gilles Chehade, which convinced me to stop giving my mail to a big provider. My first setup followed his next post with OpenSMTPD, Dovecot and Rspamd. Thanks to him!

OpenSMTPD did a great job for a start. But when I wanted to improve my configuration, I could not find enough posts and mailing list archives to learn from, so I moved to Postfix. At that time, I also had the impression, from the outside and without any official word, that Gilles was taking a break from OpenSMTPD, and that convinced me for good. It was a personal choice, and I am sure OpenSMTPD can do today most of what I do with Postfix. Dovecot and Rspamd are still here.

This post is a tour of my setup, not a step-by-step guide: the configurations are excerpts, and I assume you already run a server and know your way around DNS. Like Gilles did for me, I hope it helps people who want to self-host their mail.

To start, you only need Postfix, Dovecot, Rspamd and a correct DNS zone. Everything else here (PostgreSQL, full-text search, DANE, MTA-STS, the reports) came over the years, one piece at a time.

The stack:

The path of a mail

  1. Postfix accepts the SMTP connection on port 25.
  2. Postfix looks up the recipient in PostgreSQL.
  3. Postfix passes the message to Rspamd through a milter.
  4. Postfix delivers the message to Dovecot with LMTP.
  5. A global Sieve script files the spam in Junk.

Users and aliases in PostgreSQL

I started with files to manage my domains, users and aliases. The first thing I learned is that you quickly have more addresses to manage than you think: postmaster, abuse, the DMARC and TLS reports, plus your own aliases.

So I moved them to PostgreSQL, and it is much easier to manage. Postfix queries the database for each mail, so when I need a new alias today, I add a row, without redeploying or reloading anything.

All the domains are virtual domains. There is no local delivery, everything goes to Dovecot through LMTP:

virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = proxy:pgsql:/usr/local/etc/postfix/pg-virtual-mailbox-domains.cf
virtual_mailbox_maps = proxy:pgsql:/usr/local/etc/postfix/pg-virtual-mailbox-maps.cf
virtual_alias_maps = proxy:pgsql:/usr/local/etc/postfix/pg-virtual-alias-maps.cf,
        proxy:pgsql:/usr/local/etc/postfix/pg-virtual-email2email.cf

Dovecot uses the same database to authenticate the users.

Postfix

Port 25 is only for the other servers: SASL authentication is disabled on it. My clients use the submission ports (587 and 465), where authentication is required. A user can only send with an address they own (reject_sender_login_mismatch).

submission inet n       -       n       -       -       smtpd
  -o smtpd_tls_security_level=encrypt
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_client_restrictions=permit_sasl_authenticated,reject
  -o smtpd_sender_restrictions=reject_sender_login_mismatch,permit_sasl_authenticated,reject
  -o smtpd_relay_restrictions=permit_sasl_authenticated,reject

On port 25, Postfix rejects the servers without a valid reverse DNS, and the clients that send commands before the server answers:

smtpd_client_restrictions =
  permit_mynetworks,
  permit_sasl_authenticated,
  reject_unknown_reverse_client_hostname,
  reject_unauth_pipelining

Postfix asks Dovecot whether the mailbox is full before accepting a message, so the sender gets the error during the SMTP session instead of a bounce later:

smtpd_recipient_restrictions =
  ...
  check_policy_service unix:private/quota-status

The outgoing mail only uses IPv4. Spamhaus lists the IPv6 range of my hosting provider because of other customers, so mail sent over IPv6 could be rejected. Incoming mail still works over IPv6.

Rspamd

Rspamd runs as a milter for Postfix with its proxy worker:

milter = yes;
bind_socket = "/var/run/rspamd/milter.sock mode=0660 owner=rspamd group=postfix";
upstream "local" {
  default = yes;
  self_scan = yes;
}

Of the messages Rspamd has scanned, it rejected 1.4%, sent 1.5% to Junk, and greylisted 2.5%. About 0.01% were spam that still reached my inbox.

I learned later to refuse the mail when Rspamd does not work. Rspamd sometimes goes down (restarts, upgrades), and my old configuration (milter_default_action = accept, as in many guides) accepted the mail without checking it. You get a bit of spam, not too much, but a bit. Now Postfix refuses the mail with a temporary error, and the sender retries later. That is fine.

smtpd_milters = unix:/var/run/rspamd/milter.sock
non_smtpd_milters = $smtpd_milters
milter_default_action = tempfail
internal_mail_filter_classes = bounce, notify

The last line also sends the bounces of Postfix through Rspamd, so they are signed with DKIM. Without it, DMARC would reject my own bounces.

Rspamd also signs the outgoing mail with DKIM. The selector is the date of the key (202610), so I rotate the key by publishing a new selector. I also enabled the Bayes classifier with Redis, DCC, and the phishing and URL reputation modules.

Dovecot

Dovecot stores the mail in mdbox format. Attachments bigger than 64 KB are stored once on disk, even when many messages contain the same file. Each user has a quota of 40 GB.

Spam is filed with a Sieve script that runs before the user scripts:

require ["fileinto"];
if header :is "X-Spam" "Yes" {
  fileinto "Junk";
  stop;
}

Without the stop, the user scripts still run on the message, and a spam that matches one of their rules (a mailing list, a +tag address) is filed a second time in that folder.

Dovecot removes the junk automatically after 30 days. It is an easy setting, and it keeps the mailbox small:

mailbox Junk {
  auto = subscribe
  special_use = \Junk
  autoexpunge = 30d
}

Rspamd learns from my corrections. With IMAPSieve, moving a message to Junk in my mail client trains it as spam, and moving it out of Junk trains it as ham:

mailbox Junk {
  sieve_script report-spam {
    type = before
    cause = copy
    path = /usr/local/etc/dovecot/sieve/report-spam.sieve
  }
}

imapsieve_from Junk {
  sieve_script report-ham {
    type = before
    cause = copy
    path = /usr/local/etc/dovecot/sieve/report-ham.sieve
  }
}

The scripts call rspamc learn_spam and rspamc learn_ham.

Sieve

Most of my mail never reaches the inbox. A personal Sieve script files the reports, the mailing lists (matched on List-Id), the GitHub notifications and the tagged addresses into folders: in the last 30 days, 99.8% of the messages went to a folder and 0.2% stayed in INBOX.

For a long time, I used Solr as the backend of the Dovecot full-text search, with a schema copied from the documentation that I never really understood. It worked for years without a major issue, but if it broke, I would not have known how to fix it.

I finally moved to fts-flatcurve, which runs inside Dovecot, so there is no separate service to run.

fts_autoindex = yes
fts flatcurve {
}

language fr {
  default = yes
}

language en {
  language_filters = lowercase snowball english-possessive
}

The search also finds the words inside the attachments. Dovecot hands each PDF or office document to a small script, decode2text.sh, which extracts its text with pdftotext for the PDFs and unzip for the OOXML and ODF documents, and indexes it with the message:

fts_decoder_driver = script
fts_decoder_script_socket_path = decode2text

service decode2text {
  executable = script /usr/local/libexec/dovecot/decode2text.sh
  user = vmail

  unix_listener decode2text {
    mode = 0666
  }
}

DNS

A lot of deliverability is DNS. My zone is signed with DNSSEC and publishes:

I never had a deliverability problem in seven years, even with old corporate mail systems. I think it comes down to the basics, done from day one: a clean IPv4 address, a reverse DNS and a HELO name that match the server, every message signed with DKIM, and a DMARC policy.

I have kept the DMARC reports since 2019. Before switching to p=reject, I checked them: 98.6% of the messages passed, and the failures were spoofing spam and some forwarders.

DANE and MTA-STS

DANE and MTA-STS are the most recent part of my setup, and the only part that needs some care. Both tell the other servers to always use TLS with my server, and to refuse a fake certificate. If they are wrong, the mail does not arrive, so they must be managed.

DANE publishes a hash of the key of my certificate in a TLSA record:

_25._tcp.mail1  IN  TLSA  3 1 1 ddc0c2eb586a7c57d4161c2e10857067a1b436d6dea619980f4906f184e92426

The record pins the key, not the certificate, so the Let’s Encrypt certificate can be renewed without touching the DNS, as long as the key stays the same. I tell lego to keep the key on renewal:

renew:
  # The key is pinned by the TLSA record of _25._tcp.mail1.frimin.fr,
  # publish the new key in DNS before changing it.
  reuseKey: true

To change the key one day, I must publish the new TLSA record first, wait for the DNS caches to expire, and only then switch the certificate. A script checks every hour that the key served by Postfix matches the TLSA record. A mismatch means the DANE senders stop sending me mail.

DANE also works for my outgoing mail, but only if the local resolver validates DNSSEC. For a while, Postfix was configured for DANE, but my resolver did not validate DNSSEC, so the checks did nothing. Now unbound validates, and Postfix uses DANE when the destination publishes TLSA records:

smtp_tls_security_level = dane
smtp_dns_support_level = dnssec

Gmail does not support DANE, so I also publish an MTA-STS policy. It is a small text file served over HTTPS on mta-sts.frimin.fr, announced by a TXT record in the zone:

version: STSv1
mode: enforce
mx: mail1.frimin.fr
max_age: 604800

I started in testing mode and switched to enforce once the TLS reports were clean. The senders cache the policy, so each time I change it, I must also change the id of the _mta-sts TXT record, or they will not fetch the new one.

Day to day

Every morning, the machine sends me a health report by email. It checks the mail queue, the deferred deliveries, the Dovecot errors, the quotas, the certificate expiration, the DANE record, the MTA-STS policy, and the blocklists (Spamhaus, SpamCop and Barracuda). An excerpt of today’s report, with the mail lines:

Health report of machine1.frimin.fr, Sunday 25 October 2026 15:02 UTC.

ACTION NEEDED

- 2001:41d0:303:ffc6::1 is listed on Spamhaus ZEN (127.0.0.3), the mail sent from it may be rejected

OK

- TLS certificate is valid for 88 days
- frimin.fr validates with DNSSEC
- The MX certificate matches its DANE TLSA record
- The MTA-STS policy is served in enforce mode
- 0 messages in the mail queue
- The pf table droplist blocks 1810 networks, refreshed 2 hours ago
- The pf table blocklistde blocks 24454 networks, refreshed 2 hours ago

The IPv6 listing is the one I mentioned earlier, and the reason my outgoing mail uses IPv4. If the email does not arrive, the machine or its mail is down.

Every month, a small tool on my laptop fetches the DMARC reports, the TLS reports and the postmaster notices over SSH, and writes a summary in Markdown. Most months, every message passed DMARC and no TLS connection failed.

The firewall does the rest. pf blocks for a day the clients that open too many connections to the submission and IMAP ports, and drops the public blocklists (Spamhaus DROP, blocklist.de).

Conclusion

Mail is not that hard. I spent time on the setup, mostly at the start and recently for DANE and MTA-STS, and most of what I know, I learned when something broke. Today, the work is reading one email each morning and one summary each month.

Start small, like I did with Gilles’s post, and add one piece at a time. Your mail stays on your machine, not with a big provider.

Also subscribe to the mailop mailing list: mail operators, from self-hosters to the big providers, discuss deliverability issues and outages there, and it is the best place to ask when a provider rejects your mail.