Files
root 2b84a8a493 Everything in English: documentation, comments, identifiers
The user works in German but wants the artefacts in English throughout.
Translated were roughly 1,500 lines: every comment in the configuration
files, all comments in the Go sources, both shell scripts, and the three
manuals.

Renamed along with it, so nothing is left half-translated:

- install/vorlagen-pruefen      -> install/verify-templates
- install/mailserver.conf.beispiel -> install/mailserver.conf.example
- docs/betrieb.md               -> docs/operations.md
- shell functions and variables (schritt/abbruch/einsetzen/ZIEL/BEHALTEN
  -> stage/die/deploy/DEST/KEEP), the ten installer stages, the Dovecot
  quota root "Postfach" -> "Mailbox" and the sieve_script names
  lernspam/lernham -> learnspam/learnham
- the comment headers that mailctl writes into the generated map files

Two things this dug up while translating:

- Perl treats $) and $/ as variables. A careless s{}{} put a NUL byte into
  the regular expression documented in lang.go and mangled a line in
  list.go. Both repaired; the sources were checked for NUL bytes and the
  generated maps compared against the previous ones - the expressions
  themselves are unchanged.
- The map files are only rewritten on a change, so their German headers
  survived the first pass. Regenerated and verified line by line.

The origin server was brought along in the same step: configuration
deployed, Sieve scripts recompiled, mailctl rebuilt, services reloaded,
maps regenerated. Checked afterwards: postfix check, doveconf -n,
nft -c, unbound-checkconf, rspamadm configtest all pass; block list and
language filter still fire; a test message went through the full chain
into the mailbox. install/verify-templates reports 37 files identical,
no differences at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:23:20 +02:00

638 lines
23 KiB
Bash
Executable File

#!/bin/bash
#
# Installs the complete mail server on a fresh Debian 13.
#
# Postfix + Dovecot + Rspamd + Redis + unbound + nftables + fail2ban
# administered through the command line tool mailctl.
#
# Usage:
# sudo install/mailserver-install # asks for what is missing
# sudo install/mailserver-install --config x.conf
# sudo install/mailserver-install --only packages # a single stage
#
# The script is repeatable: every stage first checks whether its work is
# already done. An abort halfway through can therefore simply be continued by
# calling it again.
set -euo pipefail
REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
STAMP=$(date +%Y-%m-%d_%H%M)
BACKUP="/var/backups/mailserver-install_$STAMP"
# ---------------------------------------------------------------- Output
if [ -t 1 ]; then
C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'; C_RED=$'\033[31m'
C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_OFF=$'\033[0m'
else
C_BOLD=""; C_DIM=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_OFF=""
fi
stage() { printf '\n%s══ %s%s\n' "$C_BOLD" "$*" "$C_OFF"; }
ok() { printf ' %s✓%s %s\n' "$C_GREEN" "$C_OFF" "$*"; }
info() { printf ' %s·%s %s\n' "$C_DIM" "$C_OFF" "$*"; }
warn() { printf ' %s!%s %s\n' "$C_YELLOW" "$C_OFF" "$*" >&2; }
die() { printf '\n%s✗ %s%s\n\n' "$C_RED" "$*" "$C_OFF" >&2; exit 1; }
# ask puts a question. Without a terminal the default applies.
ask() {
local text="$1" default="${2:-}" answer
if [ "$ASSUME_YES" = "1" ] || [ ! -t 0 ]; then
printf '%s\n' "$default"
return
fi
if [ -n "$default" ]; then
read -r -p " $text [$default]: " answer
printf '%s\n' "${answer:-$default}"
else
while [ -z "${answer:-}" ]; do
read -r -p " $text: " answer
done
printf '%s\n' "$answer"
fi
}
confirm() {
[ "$ASSUME_YES" = "1" ] && return 0
[ ! -t 0 ] && return 0
local answer
read -r -p " $1 [y/N] " answer
case "${answer,,}" in y|yes) return 0 ;; *) return 1 ;; esac
}
# ---------------------------------------------------------------- Arguments
CONFIG="$REPO/install/mailserver.conf"
ASSUME_YES=0
ONLY=""
SELF_SIGNED=0
while [ $# -gt 0 ]; do
case "$1" in
--config) CONFIG="$2"; shift 2 ;;
--only) ONLY="$2"; shift 2 ;;
--yes|-y) ASSUME_YES=1; shift ;;
--self-signed) SELF_SIGNED=1; shift ;;
--help|-h)
sed -n '2,16p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
printf '\nStages for --only:\n %s\n\n' "checks packages users config resolver firewall certificate mailctl services firstdomain"
exit 0 ;;
*) die "Unknown option: $1 (try --help)" ;;
esac
done
# ---------------------------------------------------------------- Settings
load_settings() {
if [ -f "$CONFIG" ]; then
# shellcheck source=/dev/null
. "$CONFIG"
info "Read settings from $CONFIG"
else
info "No $CONFIG - asking instead."
fi
MAILHOST=${MAILHOST:-}
MAILDOMAIN=${MAILDOMAIN:-}
SERVER_IP=${SERVER_IP:-}
SSH_PORT=${SSH_PORT:-}
ACME_EMAIL=${ACME_EMAIL:-}
POSTMASTER_QUOTA=${POSTMASTER_QUOTA:-1G}
ALLOWED_LANGUAGES=${ALLOWED_LANGUAGES-de en}
[ -n "$MAILHOST" ] || MAILHOST=$(ask "Fully qualified name of this server (e.g. mail.example.com)" "$(hostname -f 2>/dev/null || true)")
# The mail domain is almost always the name without its first label.
[ -n "$MAILDOMAIN" ] || MAILDOMAIN=$(ask "First mail domain (the part after the @)" "${MAILHOST#*.}")
[ -n "$SERVER_IP" ] || SERVER_IP=$(ask "Public IPv4 address of this server" "$(detect_ip)")
[ -n "$SSH_PORT" ] || SSH_PORT=$(ask "Port the SSH daemon listens on" "$(detect_ssh_port)")
[ -n "$ACME_EMAIL" ] || ACME_EMAIL="postmaster@$MAILDOMAIN"
case "$MAILHOST" in
*.*) : ;;
*) die "MAILHOST must be a fully qualified name, got: $MAILHOST" ;;
esac
case "$MAILDOMAIN" in
*.*) : ;;
*) die "MAILDOMAIN must contain a dot, got: $MAILDOMAIN" ;;
esac
}
# detect_ip asks the kernel for the address it speaks to the world with.
# No packet is sent.
detect_ip() {
ip -4 route get 198.51.100.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -1
}
# detect_ssh_port reads the port actually in use from the running
# configuration - more reliable than the file, which may pull in includes.
detect_ssh_port() {
local port
port=$(sshd -T 2>/dev/null | awk '/^port /{print $2; exit}' || true)
printf '%s' "${port:-22}"
}
show_settings() {
printf '\n%sSettings%s\n' "$C_BOLD" "$C_OFF"
printf ' Server name %s\n' "$MAILHOST"
printf ' Mail domain %s\n' "$MAILDOMAIN"
printf ' IPv4 address %s\n' "$SERVER_IP"
printf ' SSH port %s\n' "$SSH_PORT"
printf ' ACME contact %s\n' "$ACME_EMAIL"
printf ' Languages %s\n' "${ALLOWED_LANGUAGES:-(filter off)}"
printf '\n'
}
# ---------------------------------------------------------------- 1 Checks
stage_checks() {
stage "1/10 Checking the ground"
[ "$(id -u)" = "0" ] || die "This must run as root."
local version
version=$(cut -d. -f1 /etc/debian_version 2>/dev/null || echo "?")
if [ "$version" = "13" ]; then
ok "Debian $(cat /etc/debian_version)"
else
warn "Built and tested on Debian 13, found: $(cat /etc/debian_version 2>/dev/null || echo unknown)"
warn "Dovecot 2.4 changed its configuration language - 2.3 systems will NOT work."
confirm "Continue anyway?" || die "Stopped."
fi
# The single most important test: does the name point here in DNS?
# Without it Let's Encrypt fails later, with an error message that hides
# the actual cause rather well.
local resolved
resolved=$(getent ahostsv4 "$MAILHOST" 2>/dev/null | awk '{print $1; exit}' || true)
if [ "$resolved" = "$SERVER_IP" ]; then
ok "$MAILHOST resolves to $SERVER_IP"
elif [ -z "$resolved" ]; then
warn "$MAILHOST does not resolve at all."
warn "Publish the A record first, or Let's Encrypt will fail."
confirm "Continue anyway?" || die "Stopped."
else
warn "$MAILHOST resolves to $resolved, not to $SERVER_IP."
confirm "Continue anyway?" || die "Stopped."
fi
# Outbound port 25 is blocked at many hosting providers. Otherwise this
# only surfaces when the first message fails to arrive.
if command -v nc >/dev/null 2>&1; then
if nc -z -w5 gmail-smtp-in.l.google.com 25 2>/dev/null; then
ok "Outbound port 25 is open"
else
warn "Outbound port 25 appears blocked - ask the hosting provider to unblock it."
warn "Everything else works; this server just could not deliver mail."
fi
fi
# Port 80 has to be free for the ACME challenge.
if ss -lntH 2>/dev/null | awk '{print $4}' | grep -qE ':80$'; then
warn "Something already listens on port 80 - Let's Encrypt (standalone) needs it free."
fi
local ram
ram=$(awk '/MemTotal/{print int($2/1024)}' /proc/meminfo)
if [ "$ram" -lt 1800 ]; then
warn "Only ${ram} MB RAM. Rspamd and Redis want 2 GB or more."
else
ok "${ram} MB RAM"
fi
ok "Ground checks done"
}
# ---------------------------------------------------------------- 2 Packages
PACKAGES="postfix postfix-sqlite postfix-pcre
dovecot-core dovecot-imapd dovecot-lmtpd dovecot-managesieved dovecot-sieve dovecot-sqlite
rspamd redis-server unbound
nftables fail2ban
certbot
sqlite3 golang-go make
ca-certificates curl bind9-dnsutils netcat-traditional swaks"
stage_packages() {
stage "2/10 Installing packages"
export DEBIAN_FRONTEND=noninteractive
# Postfix would otherwise open a dialogue asking for server type and mail
# name.
debconf-set-selections <<EOF
postfix postfix/main_mailer_type select Internet Site
postfix postfix/mailname string $MAILHOST
EOF
apt-get update -qq
# shellcheck disable=SC2086
apt-get install -y -qq $PACKAGES
ok "Packages installed"
info "Go $(go version 2>/dev/null | awk '{print $3}'), Dovecot $(dovecot --version 2>/dev/null | awk '{print $1}')"
}
# ---------------------------------------------------------------- 3 Users
stage_users() {
stage "3/10 Users, groups and directories"
if ! getent group vmail >/dev/null; then
groupadd -g 5000 vmail
fi
if ! getent passwd vmail >/dev/null; then
useradd -g vmail -u 5000 -d /var/vmail -s /usr/sbin/nologin \
-c "Virtual mailboxes" -m vmail
fi
ok "User vmail (uid/gid 5000)"
# A dedicated group for read access to the database. Without it, mail.db
# would have to be world-readable - and it holds the password hashes.
if ! getent group mailauth >/dev/null; then
groupadd --system mailauth
fi
for service in postfix dovecot dovenull _rspamd; do
if getent passwd "$service" >/dev/null; then
usermod -aG mailauth "$service"
fi
done
ok "Group mailauth: $(getent group mailauth | cut -d: -f4)"
install -d -o vmail -g vmail -m 0750 /var/vmail
install -d -o root -g root -m 0755 /etc/mailserver
install -d -o _rspamd -g _rspamd -m 0750 /var/lib/rspamd/dkim
ok "Directories created"
}
# ---------------------------------------------------------------- 4 Configuration
# deploy copies a template, filling in the placeholders on the way. An
# existing file is backed up first - a fresh installation must not wipe out an
# existing setup without trace.
deploy() {
local source="$1" target="$2" owner="${3:-root:root}" mode="${4:-0644}"
if [ -f "$target" ] && ! cmp -s <(render "$source") "$target"; then
install -d -m 0700 "$BACKUP"
cp -a "$target" "$BACKUP/$(echo "${target#/}" | tr / _)"
fi
install -d "$(dirname "$target")"
render "$source" > "$target"
chown "$owner" "$target"
chmod "$mode" "$target"
}
render() {
sed -e "s|@@MAILHOST@@|$MAILHOST|g" \
-e "s|@@MAILDOMAIN@@|$MAILDOMAIN|g" \
-e "s|@@SERVER_IP@@|$SERVER_IP|g" \
-e "s|@@SSH_PORT@@|$SSH_PORT|g" \
"$1"
}
stage_config() {
stage "4/10 Configuration files"
printf '%s\n' "$MAILHOST" > /etc/mailname
# --- Database --------------------------------------------------
deploy "$REPO/config/mailserver/schema.sql" /etc/mailserver/schema.sql
# A pointer to the documentation. It is maintained exclusively in the
# repository - a second copy on the server would only drift apart.
deploy "$REPO/config/mailserver/README.md" /etc/mailserver/README.md
if [ ! -f /etc/mailserver/mail.db ]; then
sqlite3 /etc/mailserver/mail.db < /etc/mailserver/schema.sql
ok "Database created at /etc/mailserver/mail.db"
else
# Repeat run: add missing tables, delete nothing.
sqlite3 /etc/mailserver/mail.db < /etc/mailserver/schema.sql
ok "Database already present - schema brought up to date"
fi
chown root:mailauth /etc/mailserver/mail.db
chmod 0640 /etc/mailserver/mail.db
# Site data for mailctl and mailbackup. Deliberately in shell format so
# that both can read the same file.
cat > /etc/mailserver/server.conf <<EOF
# Site data of this mail server. Written by the installer on $STAMP.
# Read by mailctl (site.go) and by the scripts under install/.
MAILHOST="$MAILHOST"
MAILDOMAIN="$MAILDOMAIN"
SERVER_IP="$SERVER_IP"
SSH_PORT="$SSH_PORT"
EOF
chmod 0644 /etc/mailserver/server.conf
ok "Site data written to /etc/mailserver/server.conf"
# --- Postfix ---------------------------------------------------
deploy "$REPO/config/postfix/main.cf" /etc/postfix/main.cf
deploy "$REPO/config/postfix/master.cf" /etc/postfix/master.cf
deploy "$REPO/config/postfix/submission_header_checks" /etc/postfix/submission_header_checks
deploy "$REPO/config/postfix/postscreen_access.cidr" /etc/postfix/postscreen_access.cidr
install -d -m 0755 /etc/postfix/sqlite
for f in "$REPO"/config/postfix/sqlite/*.cf; do
# These hold the path to the database, but no passwords.
deploy "$f" "/etc/postfix/sqlite/$(basename "$f")"
done
newaliases 2>/dev/null || true
ok "Postfix"
# --- Dovecot ---------------------------------------------------
deploy "$REPO/config/dovecot/dovecot.conf" /etc/dovecot/dovecot.conf
install -d -o vmail -g vmail -m 0755 /etc/dovecot/sieve/bin
for f in "$REPO"/config/dovecot/sieve/*.sieve; do
deploy "$f" "/etc/dovecot/sieve/$(basename "$f")" vmail:vmail 0644
done
for f in "$REPO"/config/dovecot/sieve/bin/*.sh; do
deploy "$f" "/etc/dovecot/sieve/bin/$(basename "$f")" vmail:vmail 0755
done
# Dovecot's systemd unit runs with ProtectSystem=full, so /etc is
# read-only for Dovecot. It therefore cannot compile the scripts itself -
# that has to happen here, or "Read-only file system" errors turn up in
# the log later on.
for f in /etc/dovecot/sieve/*.sieve; do
sievec "$f"
chown vmail:vmail "${f%.sieve}.svbin"
done
ok "Dovecot, Sieve scripts compiled"
# --- Rspamd ----------------------------------------------------
install -d -m 0755 /etc/rspamd/local.d
for f in "$REPO"/config/rspamd/local.d/*; do
deploy "$f" "/etc/rspamd/local.d/$(basename "$f")"
done
ok "Rspamd"
info "options.inc and worker-controller.inc keep the .inc suffix on purpose -"
info "rspamd includes those two sections as .inc and silently ignores a .conf."
# --- fail2ban --------------------------------------------------
deploy "$REPO/config/fail2ban/jail.d/mailserver.local" /etc/fail2ban/jail.d/mailserver.local
deploy "$REPO/config/fail2ban/filter.d/dovecot.local" /etc/fail2ban/filter.d/dovecot.local
ok "fail2ban"
# --- Backup ----------------------------------------------------
install -m 0755 "$REPO/bin/mailbackup" /usr/local/sbin/mailbackup
deploy "$REPO/config/systemd/mailbackup.service" /etc/systemd/system/mailbackup.service
deploy "$REPO/config/systemd/mailbackup.timer" /etc/systemd/system/mailbackup.timer
install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
deploy "$REPO/config/10-reload-mail.sh" /etc/letsencrypt/renewal-hooks/deploy/10-reload-mail.sh root:root 0755
ok "Backup timer and certificate hook"
[ -d "$BACKUP" ] && info "Replaced files backed up to $BACKUP"
return 0
}
# ---------------------------------------------------------------- 5 Resolver
stage_resolver() {
stage "5/10 Local validating resolver"
deploy "$REPO/config/unbound/mailserver.conf" /etc/unbound/unbound.conf.d/mailserver.conf
systemctl enable --now unbound >/dev/null 2>&1 || true
systemctl restart unbound
# /etc/resolv.conf has to point at 127.0.0.1, otherwise the DNSBL queries
# keep going through the provider's resolver - and the operators of the
# blocklists block exactly those queries.
#
# dhcpcd rewrites the file on every lease; resolv.conf.head survives that.
deploy "$REPO/config/resolv.conf.head" /etc/resolv.conf.head
if ! grep -qE '^\s*nameserver\s+127\.0\.0\.1\s*$' /etc/resolv.conf; then
cp -a /etc/resolv.conf "/etc/resolv.conf.before-mailserver_$STAMP"
cat /etc/resolv.conf.head "/etc/resolv.conf.before-mailserver_$STAMP" > /etc/resolv.conf
info "Prepended 127.0.0.1 to /etc/resolv.conf (old copy kept alongside)"
fi
if dig +short +time=3 +tries=1 @127.0.0.1 debian.org A >/dev/null 2>&1; then
ok "unbound answers on 127.0.0.1"
else
warn "unbound is not answering - DNSBL checks and DANE will not work."
fi
}
# ---------------------------------------------------------------- 6 Firewall
stage_firewall() {
stage "6/10 Firewall"
# The most dangerous step of the whole installation: a wrong SSH port
# number locks the running session out, and immediately. Hence the check
# against the ports actually being listened on.
local listening
listening=$(ss -lntH 2>/dev/null | awk '{print $4}' | sed 's/.*://' | sort -un | tr '\n' ' ')
if ! printf '%s' " $listening" | grep -q " $SSH_PORT "; then
warn "Nothing is listening on port $SSH_PORT."
warn "Ports in use right now: $listening"
warn "Applying the firewall now would lock you out of this session."
confirm "Really continue?" || die "Stopped - fix SSH_PORT and run again."
else
ok "sshd is listening on port $SSH_PORT"
fi
deploy "$REPO/config/nftables.conf" /etc/nftables.conf root:root 0755
# Check first, then apply. nft -c loads the ruleset as a trial.
nft -c -f /etc/nftables.conf || die "The firewall ruleset has a syntax error - nothing applied."
systemctl enable nftables >/dev/null 2>&1 || true
systemctl restart nftables
ok "Firewall active: SSH $SSH_PORT, 25, 80, 143, 465, 587, 993, 4190"
systemctl enable fail2ban >/dev/null 2>&1 || true
systemctl restart fail2ban || warn "fail2ban did not start - check: journalctl -u fail2ban"
ok "fail2ban active"
}
# ---------------------------------------------------------------- 7 Certificate
stage_certificate() {
stage "7/10 TLS certificate"
if [ -f "/etc/letsencrypt/live/$MAILHOST/fullchain.pem" ]; then
ok "Certificate for $MAILHOST already present"
return 0
fi
if [ "$SELF_SIGNED" = "1" ]; then
# For lab setups only. Mail clients and foreign mail servers refuse a
# self-signed certificate.
install -d -m 0755 "/etc/letsencrypt/live/$MAILHOST"
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
-subj "/CN=$MAILHOST" \
-keyout "/etc/letsencrypt/live/$MAILHOST/privkey.pem" \
-out "/etc/letsencrypt/live/$MAILHOST/fullchain.pem" 2>/dev/null
chmod 0600 "/etc/letsencrypt/live/$MAILHOST/privkey.pem"
warn "Self-signed certificate created - for testing only."
warn "Replace it before going live: certbot certonly --standalone -d $MAILHOST"
return 0
fi
# Postfix does not occupy port 80, but a web server might. The standalone
# mode needs it free.
info "Asking Let's Encrypt for a certificate (needs port 80 reachable from outside)"
if certbot certonly --standalone --non-interactive --agree-tos \
--email "$ACME_EMAIL" -d "$MAILHOST"; then
ok "Certificate issued for $MAILHOST"
else
warn "certbot failed. The usual causes:"
warn " - the A record for $MAILHOST does not point here yet"
warn " - port 80 is not reachable from the internet"
warn "Run again later with: $0 --only certificate"
warn "Or for a lab setup: $0 --only certificate --self-signed"
die "Without a certificate Dovecot and Postfix will not start."
fi
}
# ---------------------------------------------------------------- 8 mailctl
stage_mailctl() {
stage "8/10 Building mailctl"
local go_bin
go_bin=$(command -v go || echo /usr/lib/go-1.24/bin/go)
[ -x "$go_bin" ] || die "No Go compiler found - is golang-go installed?"
install -d -m 0755 /usr/local/src
# Take the sources along so that later changes can be compiled on the
# server itself.
rm -rf /usr/local/src/mailctl
cp -a "$REPO/src/mailctl" /usr/local/src/mailctl
info "Fetching Go modules (needs access to proxy.golang.org)"
( cd /usr/local/src/mailctl && make GO="$go_bin" install ) \
|| die "mailctl did not build. See the output above."
ok "mailctl installed to /usr/local/sbin/mailctl"
info "Sources under /usr/local/src/mailctl - rebuild with: make install"
}
# ---------------------------------------------------------------- 9 Services
stage_services() {
stage "9/10 Starting services"
systemctl enable --now redis-server >/dev/null 2>&1 || true
# Rspamd first: otherwise Postfix defers every message with "tempfail",
# because the milter is missing (milter_default_action = tempfail).
systemctl enable rspamd >/dev/null 2>&1 || true
systemctl restart rspamd || warn "rspamd did not start - check: journalctl -u rspamd"
postfix check || die "Postfix reports a configuration error."
systemctl enable postfix >/dev/null 2>&1 || true
systemctl restart postfix
doveconf -n >/dev/null || die "Dovecot reports a configuration error."
systemctl enable dovecot >/dev/null 2>&1 || true
systemctl restart dovecot
systemctl enable --now mailbackup.timer >/dev/null 2>&1 || true
local failed=0
for service in postfix dovecot rspamd redis-server unbound nftables fail2ban; do
if systemctl is-active --quiet "$service"; then
ok "$service"
else
warn "$service is NOT running - check: journalctl -u $service"
failed=1
fi
done
[ "$failed" = "0" ] || warn "Some services did not come up. Fix those before sending mail."
}
# ---------------------------------------------------------------- 10 First domain
stage_firstdomain() {
stage "10/10 First domain and postmaster mailbox"
# Look in the database directly rather than searching mailctl's output:
# "grep -w example.com" would also fire on sub.example.com, because the
# dot counts as a word boundary.
row_exists() {
[ -n "$(sqlite3 /etc/mailserver/mail.db "$1" 2>/dev/null)" ]
}
if row_exists "SELECT 1 FROM domains WHERE name = '$MAILDOMAIN'"; then
ok "Domain $MAILDOMAIN already exists"
else
mailctl domain add "$MAILDOMAIN"
fi
if row_exists "SELECT 1 FROM users WHERE email = 'postmaster@$MAILDOMAIN'"; then
ok "Mailbox postmaster@$MAILDOMAIN already exists"
else
# A postmaster@ mailbox is not optional: RFC 5321 requires the
# address, and blocklist operators write to it.
mailctl user add "postmaster@$MAILDOMAIN" -g -q "$POSTMASTER_QUOTA"
fi
if [ -n "${ALLOWED_LANGUAGES:-}" ]; then
# shellcheck disable=SC2086
mailctl lang allow $ALLOWED_LANGUAGES
fi
}
# ---------------------------------------------------------------- Closing
closing() {
printf '\n%s══ Done%s\n\n' "$C_BOLD" "$C_OFF"
printf '%sPublish these DNS records now:%s\n' "$C_BOLD" "$C_OFF"
mailctl dns "$MAILDOMAIN" || true
cat <<EOF
${C_BOLD}Then, in order:${C_OFF}
1. Publish the records above with the DNS provider of $MAILDOMAIN.
2. Set the PTR record for $SERVER_IP to $MAILHOST.
That happens at the hosting provider, not in the domain's DNS.
Without it Gmail and Outlook treat every message as spam.
3. Ask the provider to unblock outbound port 25 if it is closed.
4. Wait for the records to spread, then check:
mailctl check $MAILDOMAIN
5. Create the real mailboxes:
mailctl user add you@$MAILDOMAIN -g -q 5G
6. Send a test message to check-auth@verifier.port25.com from that
mailbox. The reply states whether SPF, DKIM and iprev pass.
${C_BOLD}Day-to-day:${C_OFF}
mailctl status services, totals, queue, certificate
mailctl help every command
docs/operations.md the operating manual
EOF
}
# ---------------------------------------------------------------- Sequence
load_settings
show_settings
if [ -n "$ONLY" ]; then
case "$ONLY" in
checks|packages|users|config|resolver|firewall|certificate|mailctl|services|firstdomain)
"stage_$ONLY"
printf '\n' ;;
*) die "Unknown stage: $ONLY" ;;
esac
exit 0
fi
confirm "Install the mail server with these settings?" || die "Stopped."
stage_checks
stage_packages
stage_users
stage_config
stage_resolver
stage_firewall
stage_certificate
stage_mailctl
stage_services
stage_firstdomain
closing