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
2026-08-07 10:48:06 +02:00
2026-08-07 10:48:06 +02:00
2026-08-07 10:48:06 +02:00

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 an sshd really 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)
  complete.go                the command tree behind Tab completion
  _mailctl                   zsh function that asks the tool for candidates
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.

Tab completion comes out of the tool itself. mailctl __complete is handed the words typed so far and answers with what may follow the last of them; the command tree and the providers behind its values live in complete.go, next to the commands they describe. make install puts a generic zsh function in /usr/local/share/zsh/site-functions/_mailctl that does nothing but pass the line in and hand the answer back, so it needs no change when a command is added.

The values come from the database, so only what exists is offered: mailctl user del <Tab> lists the mailboxes that are there, mailctl user enable <Tab> only the locked ones, mailctl sieve edit <address> <Tab> that mailbox's scripts. Completing opens the database read-only and never creates or migrates it.


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, on amd64. arm64 needs no changes — every package exists, mailctl compiles unchanged, and Rspamd keeps its regex acceleration through Vectorscan; see docs/installation.md, section

  1. In particular:
  • Dovecot 2.4 has a different configuration language from 2.3. On a system with Dovecot 2.3, config/dovecot/dovecot.conf will 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.

S
Description
Vollstaendiger Mailserver auf Debian 13: Postfix, Dovecot 2.4, Rspamd, verwaltet im Terminal ueber mailctl. Skripte, Konfiguration und Anleitung.
Readme 300 KiB
Languages
Go 73.9%
Shell 24%
NASL 0.6%
Makefile 0.6%
Sieve 0.5%
Other 0.4%