The command tree lives in Go, in complete.go, next to the commands it describes: the hidden __complete subcommand is handed the words typed so far and answers with what may follow the last of them. The zsh function installed alongside the binary does nothing but pass the line in and hand the answer back, so it needs no change when a command is added, and it cannot describe a grammar the tool no longer has. Two rules hold throughout: - Completion never writes. It reads through completeValues, which opens the database read-only, and not through openDB, which would create it from schema.sql and run migrate() on every call. Checked: after repeated completions the mtime and size of mail.db are unchanged and no -wal or -shm file appears beside it. - Completion never fails out loud. Every error ends as an empty list, and __complete is dispatched before the root check, so as an unprivileged user the structure still completes, the values stay empty, nothing reaches stderr and the status is 0. The values come out of the database, so only what exists is offered: `user enable` lists the locked mailboxes and `user disable` the unlocked ones, `alias del <source>` only the targets of that alias, `sieve edit <address>` that mailbox's scripts via doveadm, and `lang allow` the languages Rspamd has a model for, minus those already on the line. For a path argument the answer is the single line ":files", which hands over to zsh's own file completion. Narrowing the answer down to what has been typed is left to the shell rather than done in Go, so that the matcher-list styles keep working - completing "MARTIN@" to "martin@example.com" is zsh's business, and a filter here would defeat it. Two details on the shell side are worth recording, both found by driving a real interactive zsh through a pseudo-terminal rather than by reading: - The binary is located with `whence -p mailctl`, not through $commands. That parameter is not populated inside a completion function, so the lookup yields nothing and every candidate list silently comes back empty. - zsh writes "--" between a value and its description. The function sets list-separator to a single hyphen, scoped to mailctl's own context so no other completion changes, and only when nothing has been set for it already. `make install` now also puts the function in /usr/local/share/zsh/site-functions. The installer needs no change for it: stage 8 already builds through `make install`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
26 KiB
Operating manual
For day-to-day running, once the installation is done. Everything is administered from the terminal.
Example values throughout: server mail.example.com, domain example.com,
address 203.0.113.10. Substitute your own everywhere.
1. Overview
| Component | Job |
|---|---|
| Postfix 3.10 | SMTP: acceptance from outside (25), submission by our own users (587/465), sending |
| Dovecot 2.4 | IMAP (143/993), delivery via LMTP, Sieve rules, ManageSieve (4190) |
| Rspamd 3.12 | spam filter as a milter, DKIM and ARC signing on the way out |
| Redis | storage for Bayes, greylisting and ratelimit |
| unbound | local validating DNS resolver on 127.0.0.1 |
| nftables | packet filter, default policy drop |
| fail2ban | bans repeated failed logins |
| certbot | certificate for mail.example.com, renews itself |
Domains, mailboxes, aliases and the sender lists live in one SQLite file,
/etc/mailserver/mail.db. Postfix and Dovecot read it directly — changes take
effect at once, no service restart needed.
The one administration tool is mailctl (/usr/local/sbin/mailctl, root
only). Editing the database by hand is possible but unnecessary and
error-prone: mailctl also creates Maildir directories, DKIM keys and the map
files for Rspamd along the way.
2. Administration with mailctl
mailctl help shows everything. The day-to-day part:
In zsh, <Tab> completes commands, options and values; the values come out of
the database, so only domains and mailboxes that exist are offered. The
completion is installed with the tool and asks it for the candidates, so a
newly built mailctl completes its new commands straight away. Only the very
first installation needs a new shell for it (or compinit).
Domains
mailctl domain add example.org # also creates the DKIM key
mailctl domain list
mailctl dns example.org # the DNS records to copy
mailctl check example.org # verifies they are published
mailctl domain del example.org # removes the domain and its mailboxes
After domain add, MX, A, SPF, DKIM and DMARC have to be entered with the DNS
provider of the domain, otherwise outgoing mail lands in the spam folder.
mailctl check says what is still missing.
Mailboxes
mailctl user add martin@example.com -g -q 5G # -g generates a password and shows it once
mailctl user list [domain] # with usage
mailctl user passwd martin@example.com -g
mailctl user quota martin@example.com 10G # 0 = unlimited
mailctl user disable martin@example.com # block login, keep the mail
mailctl user del martin@example.com --purge # --purge deletes the mail too
Only the password hash is stored (ARGON2ID). A forgotten password cannot be read back, only set anew.
Aliases
mailctl alias add info@example.com martin@example.com
mailctl alias add @example.com martin@example.com # catch-all for the whole domain
mailctl alias list [domain]
mailctl alias del info@example.com [target]
Several targets for the same source are allowed — the mail then goes to all of
them. An alias takes precedence over a mailbox of the same name; mailctl
warns in that case.
State
mailctl status # services, totals, queue, certificate
mailctl queue # waiting mail
mailctl queue -f # retry delivery
Under Contents, mailctl status also states how many messages are stored
in total and how much space they occupy:
Contents
Domains 1 Mailboxes 3 Aliases 0
Messages 20,685 Stored 2.3G (per mailbox: mailctl user list)
What is counted is whatever sits in a cur/ or new/ under /var/vmail —
that is, exactly the messages. tmp/ is left out (deliveries in progress), and
Dovecot's index files sit alongside anyway. The walk over some 20,000 files
takes about a tenth of a second.
Two subtleties:
- Stored sums the actual file sizes, index files included — the same value
the quota limits.
du -sh /var/vmailshows a little more, because it counts allocated blocks rather than file sizes. - If a directory under
/var/vmailhas no matching mailbox in the database,statuspoints that out separately. This happens when a mailbox or a domain was deleted without--purge; the mail then stays behind. Without the hint there would be no explaining, later on, why the total here is larger than the one inmailctl user list.
3. Mail client settings
| IMAP | mail.example.com, port 993, SSL/TLS |
| SMTP | mail.example.com, port 465, SSL/TLS |
| User name | the full mail address |
| Password | as set with mailctl user add/passwd |
Port 587 (STARTTLS) and 143 (IMAP with STARTTLS) work as well. No password is accepted unencrypted. Server-side rules can be maintained from the mail client through ManageSieve (port 4190).
Important: switch off the mail client's own junk filter. The server already filters, and every move into Junk trains Rspamd (see section 4) — a second filter that disagrees ruins the training.
4. Spam protection
Four stages, cheapest first:
- postscreen (port 25) checks whether the peer obeys the protocol and queries weighted blocklists (Spamhaus, Spamcop, PSBL; credit through dnswl). Whatever fails never reaches a mailbox.
- Greylisting from a score of 4: the first delivery is refused with a request to try again later. Real mail servers do; much spam software does not.
- Rspamd scores SPF, DKIM, DMARC, blocklists, message structure, the statistical filter (Bayes), our own sender lists (section 5) and the language of the message (section 6).
- Sieve files recognised spam into the Junk folder.
Thresholds (/etc/rspamd/local.d/actions.conf)
| Score | Consequence |
|---|---|
| from 4 | greylisting |
| from 6 | accepted, header X-Spam: Yes, filed into Junk by Sieve |
| from 15 | refused with an error message — never deleted silently |
Learning
Rspamd learns in two ways:
- automatically on clear-cut cases (from 12 points as spam, from -2 as wanted), but only while both training sets stay in balance;
- by moving messages in the mail client: moving a message into Junk learns it as spam, taking it out of Junk learns it as wanted.
Reading the training state
Bayes only kicks in once each class on its own has reached 200 learning events — not the sum of both. The quickest look:
mailctl status
→ Bayes Spam 813/200 Ham 954/200 in use
More detail, straight from Rspamd:
rspamc stat # "learned: N" per class
rspamc counters | grep BAYES # last column = hits; 0 means it has never had an effect
Most reliable is the log — Rspamd says which class is still short:
grep bayes_classify /var/log/rspamd/rspamd.log | tail -1
→ not classified as ham. The ham class needs more training samples.
Currently: 83; minimum 200 required
Once that line disappears, Bayes is contributing.
Targeted retraining
Over IMAP this only works in part: learning happens when a message is moved
within the server (IMAP COPY), not when it is copied in from another
account — that is an APPEND, and the rules do not listen for it. Measured:
APPEND 11 → 11, COPY 11 → 12.
For larger amounts, therefore, directly on the server:
rspamc learn_spam /path/to/spam-archive/
rspamc learn_ham /var/vmail/example.com/martin/Maildir/cur/
rspamc takes files and directories and reports every operation individually.
Messages with fewer than 11 tokens are silently refused by Bayes — very short
mail makes poor training material. There is no unlearn: what was learned
wrongly stays.
5. Block and allow lists
mailctl blacklist add '@*facebook*' # block a sender
mailctl whitelist add newsletter@example.com # always let through
mailctl blacklist list
mailctl blacklist test mail@ads.facebook.com # shows which patterns apply
mailctl blacklist del '@*facebook*'
whitelist understands the same four subcommands.
Patterns
The wildcards are * (any number of characters) and ? (exactly one). Three
shorthand forms are expanded; what is stored and displayed is always the long
form:
| Input | read as | matches |
|---|---|---|
@*facebook* |
*@*facebook* |
every sender whose domain contains "facebook" |
martin@* |
martin@* |
every Martin, at whatever domain |
example.com |
*@example.com |
every sender at that domain |
Quote them in the shell, or the shell will try to expand the * itself:
mailctl blacklist add '@*facebook*'
Patterns are anchored: *@ads.example.com does not match
info@ads.example.com.evil.ru. They are substring matches though —
@*facebook* also matches notfacebook-free.example. Use test before every
entry.
How it takes effect
The patterns live in the access_rules table. From there mailctl writes them
to /etc/mailserver/blacklist.map and whitelist.map; through
local.d/multimap.conf, Rspamd re-reads these files by itself within seconds —
no reload needed. Editing the files by hand is pointless, the next
mailctl call overwrites them.
Both the envelope sender and the From header are checked, because phishing usually forges only the visible header.
A block gives +20 points (reject starts at 15), an allow -30. So the
allow entry wins where they overlap: blocking @*facebook* and allowing
security@facebook.com comes to -10 in total and is delivered.
6. Language filter
Mail in languages that are not allowed is scored as spam.
mailctl lang allow de en # switch on, permit German and English
mailctl lang list
mailctl lang test message.eml # which language does Rspamd see?
mailctl lang del en # an empty list = filter off
Detected languages outside the list give +6 points — enough to clear the Junk threshold (6) but below the reject threshold (15). Foreign-language mail is therefore filed, not refused. If it is suspicious for other reasons too, it goes past 15 and is rejected.
When no language is detected, nothing happens. That is the most important part: of 104 real messages on the original server, 18 had no detectable language — all of them machine-generated reports without prose. Had missing detection been penalised, 17 % of the mail would have gone to Junk.
Exceptions go through the allow list from section 5:
mailctl whitelist add partner@example.cn # -30 points, cancels the language score
How reliable this is
Flawless on proper prose — measured with one test message each in German, English, Russian, Chinese, French, Turkish and Japanese. Pure HTML is detected correctly; in mixed-language mail the dominant language wins. Across all 104 real messages: not a single false positive.
The limits, also measured:
| Case | detected as | consequence |
|---|---|---|
| "Ok, thanks!" | en |
wrong, but harmless |
| Korean | en |
slips through — no model available |
| order confirmation, almost only article names and numbers | nl |
false positive |
The last case is the only dangerous one: text with almost no prose can land on any language at all. It is rare but it happens — which is why the consequence is Junk and not rejection.
Technically
Rspamd detects the language of each text part (46 n-gram models under
/usr/share/rspamd/languages/, plus script detection for Chinese, Japanese and
Greek) and exposes it as the languages selector. From the
allowed_languages table, mailctl writes a negative lookahead into
/etc/mailserver/languages.map:
/^(?!(de|en)(-|$)).+$/i
The (-|$) boundary lets sub-variants through: with de, de-AT is allowed
too, while zh-CN does not count as allowed just because zh appears nowhere.
While the table is empty the map holds no expression — the check is switched
off.
Alongside there is the symbol LANG_DETECTED with 0 points. It scores nothing
but writes the detected language into the log and into the X-Spamd-Result
header; mailctl lang test reads exactly that.
7. Sieve rules
Two separate levels:
Global rules under /etc/dovecot/sieve/ apply to everyone and run
before the personal ones. That is where the spam-to-Junk rule and the
learning live. They are edited as files and have to be compiled by hand
afterwards — see section 11.
Personal rules, per mailbox, are maintained through mailctl:
mailctl sieve list martin@example.com
mailctl sieve edit martin@example.com # the active script, otherwise "personal"
mailctl sieve edit martin@example.com holiday # a particular script
mailctl sieve test martin@example.com sample.eml # dry run
mailctl sieve del martin@example.com holiday
edit fetches the script, opens it in $EDITOR, compiles it as a trial on
exit, and saves only when the compilation is clean. On an error the lines
are shown with numbers and, on request, it goes back into the editor; the
stored version stays untouched either way. If it is the mailbox's first script
it is activated straight away — if another one is already active, you are
asked.
test runs the stored script over a real message without delivering
anything and shows which folder it would be filed into. That is the quickest
way to check a rule before relying on it.
Any stored message serves as test material — Maildir files are plain text files:
M=$(find /var/vmail/example.com/martin/Maildir/cur -type f | head -1)
mailctl sieve test martin@example.com "$M"
The file names contain commas and colons, so put them in quotes.
Only one script can be active at a time. If $EDITOR is not set, mailctl
tries sensible-editor, nano, vim, vi in that order.
Underneath sits doveadm sieve, which can also be used directly
(list|get|put|activate|deactivate|rename|delete). mailctl takes four
pitfalls off your hands:
- In its default output,
doveadm sieve getprepends asieve script:line — which ends up inside the script when writing it back. Onlydoveadm -f json sieve getreturns it verbatim. - A newly created script is not active yet.
sieve-testassumes the permissions ofvmail: it can neither read from a directory owned by root alone nor write the compiled version there.- An active script can only be deleted with
doveadm sieve delete -a.
Further tools: sieve-filter applies a rule retroactively to an existing
mailbox. Through ManageSieve (port 4190) mail clients can edit the rules
themselves; sieve-connect would be a terminal client for that, but it is not
installed.
8. Where things live
| Path | Contents |
|---|---|
/etc/mailserver/mail.db |
domains, mailboxes, aliases, sender lists |
/etc/mailserver/schema.sql |
layout of the database |
/etc/mailserver/server.conf |
name, address and SSH port of this server |
/etc/mailserver/*.map |
generated sender lists for Rspamd |
/var/vmail/<domain>/<name>/Maildir |
the mail itself |
/etc/postfix/main.cf, master.cf |
Postfix |
/etc/postfix/sqlite/*.cf |
Postfix's queries into the database |
/etc/dovecot/dovecot.conf |
Dovecot, complete in one file |
/etc/dovecot/sieve/ |
global Sieve rules (spam to Junk, learning) |
/etc/rspamd/local.d/ |
Rspamd |
/var/lib/rspamd/dkim/ |
private DKIM keys |
/usr/local/src/mailctl/ |
source of mailctl (Go) |
| systemd journal | Postfix and Dovecot — there is no /var/log/mail.log |
/var/log/rspamd/rspamd.log |
Rspamd |
Dovecot's conf.d/ is deliberately not included; its defaults are meant
for local system accounts and would get in the way here.
What distinguishes this server from any other
Everything site-specific sits in exactly one place:
# /etc/mailserver/server.conf
MAILHOST="mail.example.com"
MAILDOMAIN="example.com"
SERVER_IP="203.0.113.10"
SSH_PORT="22"
mailctl reads the file at startup and derives the certificate path from it as
well (/etc/letsencrypt/live/$MAILHOST/fullchain.pem). The format is
deliberately plain shell, so that scripts can pull it in with
. /etc/mailserver/server.conf.
If the file is missing, mailctl guesses: the name then comes from
/etc/mailname, the address from the route to the outside. Everything keeps
working, but mailctl dns warns visibly — a wrong address in a DNS
recommendation would otherwise only surface weeks later. Behind NAT the guessed
value is always wrong (it would be the private address), so there the file is
mandatory.
Changing mailctl
cd /usr/local/src/mailctl
make check # go vet and a trial build
make install # builds and puts it in /usr/local/sbin/mailctl
9. Backup
mailbackup runs daily from a systemd timer and writes to
/var/backups/mailserver/ (30 generations, mode 600):
-
the database (consistently, through
sqlite3 .backup) -
all configuration files
-
the private DKIM keys — regenerating them would mean changing DNS and waiting
systemctl list-timers mailbackup mailbackup # trigger by hand
Not included is /var/vmail, that is, the mail itself. That needs a scheme
of its own onto remote storage (restic or borg).
10. Troubleshooting
mailctl status # first look
mailctl check example.com # DNS complete?
mailctl queue # is mail piling up?
journalctl SYSLOG_FACILITY=2 -f # Postfix AND Dovecot together
journalctl -u dovecot -f # Dovecot only
tail -f /var/log/rspamd/rspamd.log # score per message
fail2ban-client status postfix # who is banned?
fail2ban-client set postfix unbanip 1.2.3.4
A mail client suddenly cannot get in
By far the most common cause: fail2ban has banned the IP. Typical after a
password change — the mail client keeps trying in the background with the old
password, and after five failures that is that. The ban applies per jail and
hits only that jail's ports, so postfix means 25/465/587: mail still arrives,
but nothing goes out.
fail2ban-client status postfix # look at the banned IP list
fail2ban-client status dovecot
nft list table inet f2b-table # what is actually blocked
fail2ban-client set postfix unbanip 1.2.3.4
Why the ban happened is in /var/log/fail2ban.log:
grep 1.2.3.4 /var/log/fail2ban.log
Then check whether the credentials are right at all — without network and without a client:
doveadm auth test martin@example.com # asks for the password without echoing it
Only if that fails is the password to blame rather than the ban.
Mail arrives but nothing goes out
Two candidates, and they look nothing alike in the log.
Outbound port 25 is blocked. Postfix keeps the mail in the queue and
retries; mailctl queue grows, the log shows connect to ...[...]:25: Connection timed out. Test it directly:
nc -zv -w5 gmail-smtp-in.l.google.com 25
If that hangs, the block is somewhere in front of the server, not in the configuration. See installation.md, section 2.
fail2ban has banned the sender. Then the mail never reaches the server in the first place — see the previous heading.
Blocklists and DANE stop working
Symptom: spam that used to be caught gets through, and
rspamc symbols <message> no longer shows any RBL_* symbol.
Almost always DNS. unbound here is a full resolver and asks the internet
directly; if a firewall in front redirects or blocks that, the DNSBL operators
answer with errors instead of results — silently, because a blocklist that says
nothing looks exactly like a clean sender.
dig +short 2.0.0.127.zen.spamhaus.org A # must give 127.0.0.x, not an error
dig +short +tcp @198.41.0.4 . NS # TCP 53 outbound as well
The ports required and the workaround if outbound DNS cannot be opened are in installation.md, section 2.
Further checks
After changing the configuration, verify before reloading:
postfix check
doveconf -n >/dev/null
rspamadm configtest
unbound-checkconf
nft -c -f /etc/nftables.conf
Rspamd does not log to the journal. journalctl -u rspamd therefore always
looks empty and pretends "nothing happened" — always look at
/var/log/rspamd/rspamd.log.
Why a particular message was scored the way it was is shown by the
rspamd_task_write_log line in that log, with all symbols and points.
11. Pitfalls
These points cost time during the build and are not obvious.
Dovecot 2.4 has a new, incompatible configuration language. Guides on the
net almost always show 2.3 and do not apply. Single-line blocks are forbidden;
lists have become blocks (mail_plugins { quota = yes }); the SQL connection
belongs inside the passdb/userdb block. Never write setting names from
memory — check them against a working configuration. With a broken
configuration, doveconf -a produces empty output and pretends "that setting
does not exist".
auth_failure_delay must not be 3s or more. Postfix waits only about three
seconds for the auth server and otherwise reports "454 temporary error" instead
of "535 wrong password". Mail clients then do not ask for the password but keep
retrying quietly — and fail2ban never sees a usable failed attempt.
Quota: storage_size must not sit inside the named block. A storage_size
set inside quota "Mailbox" { ... } wins over the value from the userdb — the
quota can then no longer be changed per mailbox, and nothing reports it.
doveadm user dutifully shows the new value while doveadm quota get applies
the old one. The default therefore belongs at the top level:
quota_storage_size = 2G # default, overridable through the userdb
quota "Mailbox" {
storage_grace = 10M
}
With quota trouble, always compare both commands — if they disagree, this is the trap:
doveadm user martin@example.com # what the userdb yields
doveadm quota get -u martin@example.com # what Dovecot applies
Sieve scripts have to be compiled by hand. The Dovecot unit runs with
ProtectSystem=full, so /etc is read-only for it. After every change below
/etc/dovecot/sieve/:
sievec /etc/dovecot/sieve/<file>.sieve
chown vmail:vmail /etc/dovecot/sieve/<file>.svbin
Rspamd: depending on the section, local.d needs .inc rather than
.conf. Modules are named .conf (multimap.conf, redis.conf, …), the
core sections however .inc: options.inc, logging.inc, worker-*.inc. A
file with the wrong suffix is never included, and rspamadm configtest
still reports "syntax OK". What actually gets included:
grep -rhoE 'local\.d/[A-Za-z0-9_.-]+' /etc/rspamd/ | sed 's|local.d/||' | sort -u
Whether a value really arrives is answered only by
rspamadm configdump <section>.
check_all_filters = true is required for allow entries to work. The
default is false: Rspamd stops scoring as soon as the reject threshold is
exceeded. A symbol with a high positive score thereby prevents a negative one
from being evaluated at all — the allow list would have no effect, without a
trace in the log.
The fail2ban filters that ship with the distribution do not match Dovecot
2.4. 2.4 logs Login aborted: Logged out (auth failed…) instead of
Aborted login (auth failed…). Without our own
/etc/fail2ban/filter.d/dovecot.local, no attack is detected. To check:
fail2ban-regex systemd-journal /etc/fail2ban/filter.d/dovecot.conf
12. DNS
The records currently required are produced by mailctl dns <domain>; the
actual state comes from mailctl check <domain>. Needed are MX, A, SPF, DKIM,
DMARC and PTR.
The PTR (reverse DNS pointing at mail.example.com) is not set in the
domain's DNS but at the hosting provider of the server. Without it, Google
classes the mail as spam.
13. Provenance
This setup was not designed on a drawing board; it grew on a running server over several days. The list below is here because every line explains why a setting is the way it is — most of it is dearly bought experience that a finished configuration file no longer shows.
| Date | What |
|---|---|
| 2026-07-31 | Initial build; mailctl first in Python, moved to Go the same day |
| 2026-08-01 | Block and allow lists (mailctl blacklist/whitelist) with wildcards; check_all_filters = true; options.conf and worker-controller.conf renamed to .inc, because they were otherwise never read |
| 2026-08-01 | `mailctl sieve list |
| 2026-08-02 | Documentation corrected: there is no /var/log/mail.log, everything is in the systemd journal |
| 2026-08-02 | Language filter `mailctl lang allow |
| 2026-08-02 | mailctl status shows the Bayes training state; the spam section gained "reading the training state" and "targeted retraining" |
| 2026-08-02 | Quota bug fixed: storage_size sat inside the named block quota "Mailbox" and won over the userdb — per-mailbox quota changes had no effect |
| 2026-08-02 | Bayes trained: 814 spam from Junk, 1346 ham from the filing folders and the inbox. State 813/954 — active. Blind test on 120 untrained messages: 117 recognised as ham, 1 as spam |
| 2026-08-03 | mailctl status shows the number and size of all stored messages; directories without a mailbox are reported separately |
| 2026-08-03 | The whole setup as a repository, with an installation script and templates. mailctl made portable for it: server name, address and certificate path come from /etc/mailserver/server.conf instead of the source. Go dependencies pulled back to versions that compile with the Go from Debian 13 |
| 2026-08-03 | The documentation now lives in the repository only. On the server, /etc/mailserver/README.md holds nothing but a pointer |
| 2026-08-03 | Everything — documentation, comments, identifiers — translated to English |