#!/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
