New section 2 in docs/installation.md, split into inbound and outbound, because the outbound half is the one that gets forgotten. Inbound is the easy part and matches the nftables ruleset exactly: 25, 465/587, 993/143, 80, 4190 and the SSH port. Noted that port 80 is not needed for mail and therefore tends to get closed again after the installation - two months later the certificate renewal fails quietly. DNS-01 is the way out if it cannot stay open. Outbound: 25 for sending, 443 and 80 for Rspamd maps, certificates, packages and the Go build, UDP 11335 for fuzzy hashes, UDP 123 for the clock. And DNS on UDP *and* TCP 53 - TCP matters because DNSSEC answers are large and get truncated, which produces intermittent resolution failures that are hard to place. The trap gets its own block: unbound here is a full resolver and asks the root and authoritative servers directly. Corporate firewalls routinely permit DNS to the internal resolver only. That does not break the setup outright, it breaks it silently - DNSBL operators refuse queries from shared resolvers, and a blocklist that answers nothing looks exactly like a clean sender. The forward-zone workaround is documented together with what it costs. Also documented: behind NAT the A record must name the DNAT address and the PTR the SNAT address. If they differ, receiving works while sending fails on PTR and SPF - a miserable diagnosis, because mail arrives fine. Every verification command in the section was run on the live server first. Two matching entries in operations.md troubleshooting, for "mail arrives but nothing goes out" and for blocklists going quiet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mailserver
A complete mail server on Debian 13, administered entirely from the terminal.
Postfix accepts and sends, Dovecot stores and serves, Rspamd filters and signs.
Domains, mailboxes, aliases and sender lists live in one SQLite file that
both services read directly — changes take effect at once, with no restart.
The whole thing is operated through a single tool: mailctl.
No web interface. Nothing that opens ports by itself at night.
mailctl user add martin@example.com -g -q 5G
mailctl blacklist add '@*facebook*'
mailctl status
What is in it
| Component | Job |
|---|---|
| Postfix 3.10 | SMTP: acceptance (25) with postscreen, submission by our own users (587/465), sending with DANE |
| Dovecot 2.4 | IMAP (143/993), delivery via LMTP, Sieve, ManageSieve (4190), quotas |
| Rspamd 3.12 | spam filter as a milter, Bayes, greylisting, DKIM and ARC signing |
| Redis | storage for Bayes, greylisting and ratelimit |
| unbound | local validating resolver — a prerequisite for DNSBL and DANE |
| nftables | packet filter, default policy drop |
| fail2ban | bans repeated failed logins, reading the systemd journal |
| certbot | certificate from Let's Encrypt, renews itself |
| mailctl | the administration tool, in Go, with no runtime dependencies |
Spam detection in four stages: postscreen fends off botnets before a process is even created; greylisting from a score of 4; Rspamd scores SPF, DKIM, DMARC, blocklists, structure, language and the statistical filter; Sieve files the result into Junk. Rejection only starts at 15 points, and then with an error message — never a silent delete.
Quick start
These have to be in place first: a static A record pointing at the server, a PTR record for the same name, outbound port 25 unblocked. Without those three there is no point — details in docs/installation.md.
If the host sits behind a firewall of its own, section 2 of that document lists every port that has to be permitted — inbound and outbound. The outbound half is the one that gets forgotten: port 25 for sending, and DNS on UDP and TCP 53 out to the whole internet, because the local resolver does its own recursion.
git clone https://git.fhi.mpg.de/mike/mailserver.git
cd mailserver
cp install/mailserver.conf.example install/mailserver.conf
$EDITOR install/mailserver.conf # MAILHOST, MAILDOMAIN, SSH_PORT
sudo install/mailserver-install
Afterwards the script prints the DNS records still missing, ready to copy.
The one value where a mistake hurts:
SSH_PORT. The firewall lets exactly that port in. The script checks that ansshdreally is listening there and asks if not — even so, keep a second SSH session open during the installation.
Layout of the repository
install/
mailserver-install installation script, ten stages, repeatable
verify-templates compares config/ with the running server
mailserver.conf.example site data
config/ every configuration file, with placeholders
postfix/ dovecot/ rspamd/ unbound/ fail2ban/ systemd/
nftables.conf mailserver/schema.sql
src/mailctl/ sources of the administration tool (Go)
bin/mailbackup daily backup of the database and the keys
docs/
installation.md from Debian to the first message, migration included
operations.md operating manual: day-to-day, spam filter, troubleshooting
The placeholders in config/ are @@MAILHOST@@, @@MAILDOMAIN@@,
@@SERVER_IP@@ and @@SSH_PORT@@. The installer fills them in;
install/verify-templates factors them back out again for comparison.
mailctl
One tool, all in the terminal. mailctl help shows the full list.
domain add|list|del domains; the DKIM key is created automatically
user add|list|del|passwd|quota mailboxes, passwords, quotas
enable|disable
alias add|list|del forwardings, catch-all included
sieve list|edit|test|del personal rules per mailbox
lang allow|list|del|test language filter
blacklist|whitelist add|list block senders, or always let them through
del|test
dns <domain> every DNS record this domain needs
check <domain> verifies they are published
status services, totals, queue, certificate
queue [-f] the mail queue
Block and allow lists take wildcards: @*facebook* hits every sender whose
domain contains facebook, martin@* every Martin anywhere. Where they overlap,
the allow entry wins.
Build it with the Go from Debian 13:
cd src/mailctl && make check && sudo make install
The binary is statically linked (CGO_ENABLED=0, SQLite driver in pure Go) and
needs no libraries from the system.
Provenance and limits
This was extracted from a running server, not designed on a drawing board. Every configuration file is commented, and the comments do not explain what is written there but why — including the cases where the obvious route did not work. The chapter Pitfalls in the operating manual collects the most expensive of them.
It was verified against exactly one setup: Debian 13.6, Dovecot 2.4.1, Rspamd 3.12.1, Postfix 3.10. In particular:
- Dovecot 2.4 has a different configuration language from 2.3. On a system
with Dovecot 2.3,
config/dovecot/dovecot.confwill not run. Older guides found on the net do not apply either. - IPv6 is not set up. The original server had none. Anyone using IPv6 has
to extend
inet_protocols, unbound and nftables accordingly — and then also publish an AAAA record and a second PTR, or it does more harm than good. - No webmail client is included. Deliberately: the requirement was administration and access through terminal and mail client only.
Documentation, comments and identifiers are in English.