Files
root 8f15646cf0 Add Tab completion to mailctl
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>
2026-08-07 10:48:06 +02:00

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/vmail shows a little more, because it counts allocated blocks rather than file sizes.
  • If a directory under /var/vmail has no matching mailbox in the database, status points 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 in mailctl 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:

  1. 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.
  2. 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.
  3. 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).
  4. 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 get prepends a sieve script: line — which ends up inside the script when writing it back. Only doveadm -f json sieve get returns it verbatim.
  • A newly created script is not active yet.
  • sieve-test assumes the permissions of vmail: 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