Secure Debian Mail Server

Postfix + Dovecot + MariaDB + Amavis + SpamAssassin + OpenDKIM/OpenDMARC + SPF policy + TLS/SNI

This page is a copy/paste ready walkthrough to build a secure mail server on Debian. It follows a privacy-first design: strict TLS defaults, reduced fingerprinting, Postscreen on port 25, and a clean content-filter pipeline (Amavis + SpamAssassin) for inbound and outbound mail.

This setup uses:

  • Postfix (SMTP) with Postscreen on port 25 and authenticated submission on 587/465
  • Dovecot (IMAP + LMTP) with SQL auth and SNI-based TLS for multiple domains
  • MariaDB for virtual domains/users/aliases
  • Amavis as content filter (SpamAssassin only, no antivirus)
  • SpamAssassin with Bayes + TxRep + custom rules
  • OpenDKIM verify + sign (split for inbound/outbound behavior)
  • OpenDMARC validation and optional header stamping
  • postfix-policyd-spf-python for SPF policy checks

No Sieve is used in this guide.

Note: this page references a wrapper tool to manage mail domains/users/aliases. You can find it in scripts.html (look for mailctl).

0) Short checklist

Minimal sequence to get a working system

  • Set hostname + PTR/HELO alignment.
  • Publish DNS: A/AAAA, MX, SPF, DKIM, DMARC. Optional: MTA-STS + TLS-RPT + TLSA (DANE).
  • Install packages: Postfix, Dovecot, MariaDB, Amavis, SpamAssassin, OpenDKIM, OpenDMARC, policyd-spf.
  • Initialize MariaDB schema and SQL users.
  • Create vmail UID/GID + maildir root.
  • Configure Dovecot: SQL auth, IMAPS only, LMTP socket for Postfix.
  • Configure TLS certificates (DNS-01 recommended) and SNI for IMAP/SMTP.
  • Configure Postfix: postscreen on 25, submission 587/465, content filter via Amavis, milters.
  • Configure OpenDKIM (verify inbound, sign outbound) and OpenDMARC (enforce inbound, optional stamping).
  • Run end-to-end tests (SMTP/IMAP), review logs, then enable fail2ban.

1) Variables (replace these)

Use your own values consistently

Replace these example values across this page:

MAILHOST="mail.example.nl"          # server hostname (PTR/HELO)
PRIMARY_DOMAIN="example.nl"         # primary mail domain (used by /etc/mailname)

DOMAINS="example.nl example2.nl"    # your hosted mail domains

IPV4="203.0.113.10"
IPV6="2001:db8::10"

VMAIL_UID="5000"
VMAIL_GID="5000"
VMAIL_HOME="/var/mail/vhosts"

DBNAME="mailserver"
DBUSER="mailuser"
DBHOST="127.0.0.1"

Mail service hostnames (recommended):

imap.example.nl
smtp.example.nl
autoconfig.example.nl
autodiscover.example.nl

Keep the PTR record aligned with MAILHOST. This matters for deliverability.

2) Installed packages (check and install)

Debian packages used by this setup

Show installed packages on Debian:

dpkg -l
apt list --installed
apt-cache policy postfix dovecot-core mariadb-server amavisd-new spamassassin opendkim opendmarc postfix-policyd-spf-python

Install packages:

sudo apt update

sudo apt install -y \
  postfix postfix-mysql \
  dovecot-core dovecot-imapd dovecot-lmtpd dovecot-mysql \
  mariadb-server \
  amavisd-new spamassassin spamc \
  opendkim opendkim-tools \
  opendmarc \
  postfix-policyd-spf-python \
  ca-certificates

Optional (mail/DNS/testing tooling):

sudo apt install -y \
  fail2ban \
  dnsutils \
  swaks \
  curl \
  certbot python3-certbot-dns-transip

During Postfix install: choose "Internet Site" and set the system mail name to your primary domain.

sudo sh -c 'echo "example.nl" > /etc/mailname'
sudo hostnamectl set-hostname mail.example.nl

3) Generate a strong password (STRONG_PASSWORD)

Use high-entropy secrets for SQL users and API keys

Generate a strong random password (64 chars base64-ish):

openssl rand -base64 48

Alternative (hex, 64 chars):

openssl rand -hex 32

Keep secrets out of shell history where possible. Prefer password managers.

4) MariaDB database

Virtual domains, users and aliases

Login as root and create database + user:

sudo mariadb
CREATE DATABASE mailserver
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;

CREATE USER 'mailuser'@'127.0.0.1'
  IDENTIFIED BY 'STRONG_PASSWORD';

GRANT SELECT ON mailserver.* TO 'mailuser'@'127.0.0.1';
FLUSH PRIVILEGES;

USE mailserver;

Tables:

CREATE TABLE virtual_domains (
  id INT UNSIGNED NOT NULL AUTO_INCREMENT,
  name VARCHAR(255) NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uq_domain_name (name)
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;

CREATE TABLE virtual_users (
  id INT UNSIGNED NOT NULL AUTO_INCREMENT,
  domain_id INT UNSIGNED NOT NULL,
  email VARCHAR(255) NOT NULL,
  password VARCHAR(255) NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uq_user_email (email),
  KEY idx_user_domain (domain_id),
  CONSTRAINT fk_user_domain
    FOREIGN KEY (domain_id)
    REFERENCES virtual_domains(id)
    ON DELETE CASCADE
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;

CREATE TABLE virtual_aliases (
  id INT UNSIGNED NOT NULL AUTO_INCREMENT,
  domain_id INT UNSIGNED NOT NULL,
  source VARCHAR(255) NOT NULL,
  destination TEXT NOT NULL,
  PRIMARY KEY (id),
  KEY idx_alias_source (source),
  KEY idx_alias_domain (domain_id),
  CONSTRAINT fk_alias_domain
    FOREIGN KEY (domain_id)
    REFERENCES virtual_domains(id)
    ON DELETE CASCADE
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;

Optional: create a local MariaDB login profile for scripts (recommended). Store this file as root-only.

sudo tee /root/.my.cnf > /dev/null <<'EOF'
[client]
user=mailctl
password=STRONG_PASSWORD
EOF

sudo chmod 600 /root/.my.cnf

If you prefer to use /root/.my.cnf for scripts, create the user:

sudo mariadb <<'EOF'
CREATE USER 'mailctl'@'localhost' IDENTIFIED BY 'STRONG_PASSWORD';
GRANT SELECT, INSERT, UPDATE, DELETE ON mailserver.* TO 'mailctl'@'localhost';
FLUSH PRIVILEGES;
EOF

If you keep the scripts read-only, then SELECT-only is enough. If scripts should create users/aliases, you need INSERT/UPDATE/DELETE.

5) vmail user and maildir storage

Dedicated UID/GID and protected mail storage

Create the vmail group and user:

sudo groupadd -g 5000 vmail || true
sudo useradd -g vmail -u 5000 vmail -d /var/mail/vhosts -m || true

Create the mail root and secure permissions:

sudo install -d -m 0750 -o vmail -g vmail /var/mail/vhosts

6) Dovecot (IMAP + LMTP + SQL auth + SNI TLS)

No POP3, no IMAP plaintext, LMTP delivery from Postfix

Dovecot SQL config:

sudo tee /etc/dovecot/dovecot-sql.conf.ext > /dev/null <<'EOF'
driver = mysql
connect = host=127.0.0.1 dbname=mailserver user=mailuser password=STRONG_PASSWORD
default_pass_scheme = SHA512-CRYPT

password_query = SELECT email AS user, password \
  FROM virtual_users \
  WHERE email='%u';
EOF

Enable SQL auth in auth-sql.conf.ext:

sudo tee /etc/dovecot/conf.d/auth-sql.conf.ext > /dev/null <<'EOF'
passdb {
  driver = sql
  args = /etc/dovecot/dovecot-sql.conf.ext
}

userdb {
  driver = static
  args = uid=5000 gid=5000 home=/var/mail/vhosts/%d/%n
}
EOF

In /etc/dovecot/conf.d/10-auth.conf ensure SQL is enabled and system auth is disabled for virtual hosting:

sudo vi /etc/dovecot/conf.d/10-auth.conf

Make sure it includes:

!include auth-sql.conf.ext

Disable IMAP plaintext (143) and POP3 completely:

sudo tee /etc/dovecot/conf.d/99-disable-imap-plain.conf > /dev/null <<'EOF'
service imap-login {
  inet_listener imap {
    port = 0
  }
}
EOF

sudo tee /etc/dovecot/conf.d/99-disable-pop3.conf > /dev/null <<'EOF'
service pop3-login {
  inet_listener pop3 {
    port = 0
  }
  inet_listener pop3s {
    port = 0
  }
}
EOF

Postfix integration (SASL socket + LMTP socket):

sudo tee /etc/dovecot/conf.d/99-postfix.conf > /dev/null <<'EOF'
service auth {
  unix_listener /var/spool/postfix/private/auth {
    mode = 0660
    user = postfix
    group = postfix
  }
}

service lmtp {
  unix_listener /var/spool/postfix/private/dovecot-lmtp {
    mode = 0600
    user = postfix
    group = postfix
  }
}
EOF

LMTP protocol tuning:

sudo tee /etc/dovecot/conf.d/99-lmtp.conf > /dev/null <<'EOF'
protocol lmtp {
  auth_username_format = %{user}
  auth_verbose = no
  mail_debug = no
}
EOF

Force vmail Maildir storage:

sudo tee /etc/dovecot/conf.d/99-vmail-maildir.conf > /dev/null <<'EOF'
mail_location = maildir:%{home}
EOF

Logging:

sudo tee /etc/dovecot/conf.d/99-logging.conf > /dev/null <<'EOF'
log_path = /var/log/dovecot.log
log_timestamp = "%Y-%m-%d %H:%M:%S "
EOF

TLS base (default cert for server hostname, plus SNI for IMAP domains):

sudo vi /etc/dovecot/conf.d/10-ssl.conf

Minimal safe defaults:

ssl = required
ssl_min_protocol = TLSv1.2

SNI cert mapping (example):

sudo tee /etc/dovecot/conf.d/11-ssl-sni.conf > /dev/null <<'EOF'
local_name imap.example.nl {
  ssl_server_cert_file = /etc/letsencrypt/live/example.nl/fullchain.pem
  ssl_server_key_file  = /etc/letsencrypt/live/example.nl/privkey.pem
}

local_name imap.example2.nl {
  ssl_server_cert_file = /etc/letsencrypt/live/example2.nl/fullchain.pem
  ssl_server_key_file  = /etc/letsencrypt/live/example2.nl/privkey.pem
}
EOF

Validate Dovecot config:

sudo dovecot -n
sudo systemctl restart dovecot
sudo systemctl status dovecot --no-pager

7) Password hashing: SHA512-CRYPT vs BLF-CRYPT

Keep SHA512-CRYPT for compatibility, BLF-CRYPT is optional

Setup uses:

default_pass_scheme = SHA512-CRYPT

The difference:

  • SHA512-CRYPT is widely compatible and supported across deployments.
  • BLF-CRYPT is bcrypt-based and is generally stronger against GPU cracking.

Important: changing the scheme affects newly generated hashes. Existing users keep their stored hash format. If you switch, re-hash only during password changes.

Generate a SHA512-CRYPT hash:

doveadm pw -s SHA512-CRYPT

Generate a BLF-CRYPT hash (bcrypt-based):

doveadm pw -s BLF-CRYPT

Recommendation for this guide: keep SHA512-CRYPT as default unless you have a migration plan.

8) Postfix (main.cf + master.cf + policy maps)

Postscreen on port 25, strict submission on 587/465, Amavis pipeline, and privacy cleanup

This Postfix section is copy/paste ready and contains the full SMTP pipeline: postscreen on port 25, submission on 587 (STARTTLS), optional smtps on 465 (TLS wrapper), content filtering via Amavis, and a dedicated post-amavis DKIM signing stage.

It also implements outbound privacy cleanup by stripping MUA fingerprint headers and removing local scanning hops (submission hop + amavis hop), while keeping security-relevant headers such as DKIM signatures and server Authentication-Results.

Compatibility note: This configuration is intended for Postfix 3.10 and Dovecot 2.4.0.


8.1) Postfix MySQL maps

Create Postfix MySQL map files (read-only queries). These files are used in main.cf.

sudo tee /etc/postfix/mysql-virtual-mailbox-domains.cf > /dev/null <<'EOF'
user = mailuser
password = STRONG_PASSWORD
hosts = 127.0.0.1
dbname = mailserver
query = SELECT 1 FROM virtual_domains WHERE name='%s'
EOF

sudo tee /etc/postfix/mysql-virtual-mailbox-maps.cf > /dev/null <<'EOF'
user = mailuser
password = STRONG_PASSWORD
hosts = 127.0.0.1
dbname = mailserver
query = SELECT 1 FROM virtual_users WHERE email='%s'
EOF

sudo tee /etc/postfix/mysql-virtual-alias-maps.cf > /dev/null <<'EOF'
user = mailuser
password = STRONG_PASSWORD
hosts = 127.0.0.1
dbname = mailserver
query = SELECT destination FROM virtual_aliases WHERE source='%s'
EOF

Optional: sender-login maps (recommended when you run many aliases). This allows you to enforce: reject_authenticated_sender_login_mismatch.

sudo tee /etc/postfix/mysql-sender-login-maps.cf > /dev/null <<'EOF'
user = mailuser
password = STRONG_PASSWORD
hosts = 127.0.0.1
dbname = mailserver

# Example logic: map sender address to login email.
# Adjust this query to match your own alias/login policy.
query = SELECT email FROM virtual_users WHERE email='%s'
EOF
sudo chown root:root /etc/postfix/mysql-*.cf
sudo chmod 0640 /etc/postfix/mysql-*.cf

8.2) Postscreen access list (CIDR)

Postscreen runs on port 25 and can allowlist local/infrastructure ranges. This file is referenced by postscreen_access_list in main.cf.

sudo tee /etc/postfix/postscreen_access.cidr > /dev/null <<'EOF'
127.0.0.0/8 permit
::1/128 permit

# Optional: allowlist Microsoft 365 inbound ranges (only if you explicitly trust them):
40.92.0.0/15 permit
40.107.0.0/16 permit
52.100.0.0/14 permit
52.96.0.0/14 permit
2a01:110::/29 permit
EOF

8.3) Client allowlist (CIDR)

This allowlist is referenced from smtpd_client_restrictions. Use it for your own infrastructure IPs, monitoring, or trusted relays.

sudo tee /etc/postfix/client_whitelist.cidr > /dev/null <<'EOF'
127.0.0.0/8 permit
::1/128 permit

# Example: trusted monitoring host
# 203.0.113.55/32 permit
EOF

8.4) Client checks (PCRE) and HELO checks (PCRE)

These PCRE files are referenced from your restriction classes: check_client_access pcre:/etc/postfix/client_checks.pcre and check_helo_access pcre:/etc/postfix/helo_checks.pcre.

Client reverse-DNS pattern checks:

sudo tee /etc/postfix/client_checks.pcre > /dev/null <<'EOF'
/static\./     REJECT Generic PTR hostname
/dsl\./        REJECT Generic PTR hostname
/broadband\./  REJECT Generic PTR hostname
EOF

HELO validation and spoof protection. Replace mail.example.nl / example.nl / example2.nl with your own domains.

sudo tee /etc/postfix/helo_checks.pcre > /dev/null <<'EOF'
# PCRE files are evaluated top-down.
# postfix reload is required after changes.

# Rare explicit exceptions:
#/^some-legacy-host.example$/ OK

/^[^.]+$/                    501 5.5.2 Invalid HELO hostname (need FQDN)
/^(localhost|localdomain)$/i 501 5.5.2 Invalid HELO hostname
/^\[?\d+\.\d+\.\d+\.\d+\]?$/ 554 5.7.1 HELO must not be an IP literal
/^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/ 554 5.7.1 HELO uses private IP
/\.local$/i                  554 5.7.1 Invalid HELO TLD
/\.lan$/i                    554 5.7.1 Invalid HELO TLD

# Nobody except you may use your mail domains as HELO:
/^mail\.example\.nl$/i       554 5.7.1 Spoofed HELO (you are not me)
/^example\.nl$/i             554 5.7.1 Spoofed HELO (you are not me)
/^example2\.nl$/i            554 5.7.1 Spoofed HELO (you are not me)
EOF

8.5) Outbound privacy cleanup (submission_header_cleanup)

This PCRE header_checks file is applied only on authenticated submission (587/465). It removes MUA fingerprints, client-originating headers, and strips the local submission Received hop. It also prepends one neutral Received header to keep a minimal trace without leaking client metadata.

This file is used by this master.cf line: -o cleanup_service_name=submission-cleanup

sudo tee /etc/postfix/submission_header_cleanup > /dev/null <<'EOF'
# ==========================================================
# OUTBOUND PRIVACY CLEANUP - submission_header_cleanup (PCRE)
# ==========================================================
# Goal:
# - Remove MUA fingerprinting headers
# - Remove client-originating metadata headers
# - Remove the submission "Received ... with ESMTPSA" hop (folded)
#
# Intentionally NOT removed:
# - Authentication-Results (server)
# - ARC-* (downstream)
# - DKIM-Signature (your signer)
# ==========================================================

##############################################################################
# 1) REMOVE SUBMISSION RECEIVED HOP (CLIENT -> YOUR POSTFIX, ESMTPSA)
##############################################################################

# First line variants:
# a) client as [IP] (most common)
/^Received:\s+from\s+\[(?:IPv6:)?[0-9A-Fa-f:.]+\](?:\s+\([^)]+\))?.*$/        IGNORE

# b) client as hostname (sometimes)
/^Received:\s+from\s+[A-Za-z0-9_.-]+(?:\s+\([^)]+\))?.*$/                    IGNORE

# Continuation lines belonging to the same hop:
/^[ \t]+by\s+mail\.example\.nl\s+\(Postfix\)\s+with\s+ESMTPSA\b.*$/           IGNORE
/^[ \t]+with\s+ESMTPSA\b.*$/                                                 IGNORE
/^[ \t]+id\s+[A-F0-9]+\b.*$/                                                 IGNORE
/^[ \t]+for\s+<[^>]+>;\s+.*$/                                                IGNORE
/^[ \t]+[A-Z][a-z]{2},\s+.*$/                                                IGNORE

# Add 1 neutral Received (once per message) using a stable anchor:
/^From:\s/ PREPEND Received: from authenticated-user by mail.example.nl with ESMTPSA;

##############################################################################
# 2) MUA FINGERPRINTS
##############################################################################
/^User-Agent:.*$/                                        IGNORE
/^X-Mailer:.*$/                                          IGNORE

# Thunderbird
/^X-Mozilla-Status:.*$/                                  IGNORE
/^X-Mozilla-Status2:.*$/                                 IGNORE
/^X-Mozilla-Keys:.*$/                                    IGNORE

# Outlook
/^X-MS-TNEF-Correlator:.*$/                              IGNORE
/^Thread-Index:.*$/                                      IGNORE
/^Thread-Topic:.*$/                                      IGNORE
/^X-MimeOLE:.*$/                                         IGNORE

# Apple Mail
/^X-Mailer:\s*Apple Mail.*$/                             IGNORE

##############################################################################
# 3) CLIENT ORIGIN / AUTH META (from MUAs)
##############################################################################
/^X-Originating-IP:.*$/                                  IGNORE
/^X-Client-IP:.*$/                                       IGNORE
/^X-Authenticated-Sender:.*$/                            IGNORE
/^X-Env-Sender:.*$/                                      IGNORE
/^X-Original-Sender:.*$/                                 IGNORE

##############################################################################
# 4) RARE CLIENT-INJECTED BLOAT
##############################################################################
# Only strip client-injected Authentication-Results, keep server AR:
/^Authentication-Results:.*client.*$/i                   IGNORE

##############################################################################
# 5) OTHER
##############################################################################
/^Auto-Submitted:.*$/                                    IGNORE
EOF

8.6) Outbound cleanup after Amavis (outbound_after_amavis_header_cleanup)

This cleanup removes local scanning metadata from Amavis and the local reinjection hop. Apply it on the internal post-filter stage (for example your 10027 DKIM signing stage).

sudo tee /etc/postfix/outbound_after_amavis_header_cleanup > /dev/null <<'EOF'
# OUTBOUND - strip scanner metadata (post-amavis)

# --- amavis hop ---
/^Received: from mail\.example\.nl \(\[127\.0\.0\.1\]\)/   IGNORE
/^[ \t]+by localhost .* \(amavis, port 10028\)/            IGNORE
/^[ \t]+with ESMTP id .* for <.*/                          IGNORE
/^[ \t]+[A-Z][a-z]{2}, .*$/                                IGNORE

# --- reinject hop ---
/^Received: from localhost \(localhost \[127\.0\.0\.1\]\)/ IGNORE
/^[ \t]+by mail\.example\.nl \(Postfix\)/                  IGNORE
/^[ \t]+with ESMTP id .* for <.*/                          IGNORE
/^[ \t]+[A-Z][a-z]{2}, .*$/                                IGNORE

# --- scanner metadata ---
/^X-Spam-Checker-Version:/                                 IGNORE
/^X-Virus-Scanned:/                                        IGNORE
/^X-Amavis-.*/                                             IGNORE
/^X-Quarantine-ID:/                                        IGNORE
EOF

8.7) TLS policy exceptions (tls_policy)

If you use DANE for outbound SMTP (smtp_tls_security_level = dane), you may still need per-domain exceptions for legacy targets. This file should contain exceptions only (no defaults).

sudo tee /etc/postfix/tls_policy > /dev/null <<'EOF'
# ============================================================
# Postfix TLS policy exceptions
#
# Global defaults in main.cf, for example:
#   smtp_tls_security_level = dane
#   smtp_dns_support_level  = dnssec
#
# This file contains exceptions only.
# ============================================================

# Legacy domains without TLSA but working TLS
example-gov.nl encrypt
example-ministry.nl encrypt

# Domains with unreliable CA configuration
example-problem-domain.nl encrypt

# ============================================================
# After changes:
#   sudo postmap /etc/postfix/tls_policy
#   sudo systemctl reload postfix
# ============================================================
EOF

sudo postmap /etc/postfix/tls_policy

8.8) TLS SNI map for SMTP (vmail_ssl.map)

Postfix SNI for submission: one map with domain-specific certs. Compile the map with postmap -F (fingerprint-safe hash format).

sudo tee /etc/postfix/vmail_ssl.map > /dev/null <<'EOF'
# Compile with:
#   sudo postmap -F hash:/etc/postfix/vmail_ssl.map

# --- SNI TLS per domain ---
smtp.example.nl     /etc/letsencrypt/live/example.nl/privkey.pem     /etc/letsencrypt/live/example.nl/fullchain.pem
smtp.example2.nl    /etc/letsencrypt/live/example2.nl/privkey.pem    /etc/letsencrypt/live/example2.nl/fullchain.pem

# --- Optional apex fallback ---
example.nl          /etc/letsencrypt/live/example.nl/privkey.pem     /etc/letsencrypt/live/example.nl/fullchain.pem
example2.nl         /etc/letsencrypt/live/example2.nl/privkey.pem    /etc/letsencrypt/live/example2.nl/fullchain.pem
EOF

sudo postmap -F hash:/etc/postfix/vmail_ssl.map

8.9) /etc/postfix/main.cf

This is a hardened Postfix configuration with postscreen, submission, milters, and DANE outbound. Replace values like mail.example.nl and example.nl with your own.

sudo tee /etc/postfix/main.cf > /dev/null <<'EOF'
# ===========================
# Postfix main.cf
# ===========================

# Host/domain
myhostname = mail.example.nl
mydomain = example.nl
myorigin = $myhostname
mydestination = $myhostname, localhost.$mydomain, localhost
relayhost =
inet_interfaces = all
inet_protocols = all
mynetworks = 127.0.0.0/8,[::1]/128
mynetworks_style = host
mailbox_size_limit = 0
recipient_delimiter = +

# ===========================
# TLS
# ===========================
smtp_tls_cert_file = /etc/letsencrypt/live/mail.example.nl/fullchain.pem
smtp_tls_key_file  = /etc/letsencrypt/live/mail.example.nl/privkey.pem
smtpd_tls_cert_file = $smtp_tls_cert_file
smtpd_tls_key_file  = $smtp_tls_key_file

smtp_tls_protocols  = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1, TLSv1.2, TLSv1.3
smtpd_tls_protocols = $smtp_tls_protocols

tls_preempt_cipherlist = yes
smtp_tls_ciphers = high
smtpd_tls_mandatory_ciphers = medium
smtp_tls_mandatory_ciphers = medium

tls_ssl_options = NO_COMPRESSION, NO_RENEGOTIATION

smtp_tls_session_cache_database  = btree:${data_directory}/smtp_scache
smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_scache
smtp_tls_session_cache_timeout = 1800s
smtpd_tls_session_cache_timeout = $smtp_tls_session_cache_timeout

smtp_tls_note_starttls_offer = yes
smtp_tls_loglevel = 1
smtpd_tls_loglevel = $smtp_tls_loglevel

# Outbound DANE (optional; requires DNSSEC-validating resolver)
smtp_dns_support_level = dnssec
smtp_tls_security_level = dane
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt
smtp_tls_policy_maps = hash:/etc/postfix/tls_policy

# Inbound TLS
smtpd_tls_security_level = may
smtpd_tls_auth_only = yes

# ===========================
# SASL / Authentication
# ===========================
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_auth_enable = yes
broken_sasl_auth_clients = no
smtpd_sasl_security_options = noanonymous
smtpd_sasl_tls_security_options = noanonymous

# ===========================
# Restrictions
# ===========================
smtpd_helo_required = yes

smtpd_client_restrictions =
  permit_mynetworks,
  permit_sasl_authenticated,
  check_client_access cidr:/etc/postfix/client_whitelist.cidr,
  check_client_access pcre:/etc/postfix/client_checks.pcre,
  reject_unknown_reverse_client_hostname,
  reject_unknown_client_hostname

smtpd_helo_restrictions =
  permit_mynetworks,
  permit_sasl_authenticated,
  check_helo_access pcre:/etc/postfix/helo_checks.pcre,
  reject_invalid_helo_hostname,
  reject_non_fqdn_helo_hostname,
  reject_unknown_helo_hostname

smtpd_recipient_restrictions =
  permit_mynetworks,
  permit_sasl_authenticated,
  reject_non_fqdn_recipient,
  reject_unknown_recipient_domain,
  reject_unlisted_recipient,
  check_policy_service unix:private/policyd-spf,
  reject_unauth_destination

smtpd_sender_restrictions =
  permit_mynetworks,
  permit_sasl_authenticated,
  reject_non_fqdn_sender,
  reject_unknown_sender_domain

smtpd_relay_restrictions =
  permit_mynetworks,
  permit_sasl_authenticated,
  reject_unauth_destination

# ===========================
# Privacy / Fingerprinting
# ===========================
smtpd_client_port_logging = no
smtpd_banner = $myhostname ESMTP
smtpd_tls_received_header = no
smtpd_discard_ehlo_keywords = etrn

# ===========================
# Postscreen (port 25 frontdoor)
# ===========================
postscreen_access_list = cidr:/etc/postfix/postscreen_access.cidr
postscreen_greet_action = enforce
postscreen_greet_wait = 3s
postscreen_greet_ttl = 1d

postscreen_dnsbl_action = enforce
postscreen_dnsbl_threshold = 2
postscreen_dnsbl_sites = zen.spamhaus.org*2
postscreen_dnsbl_ttl = 1h

# Dovecot LMTP and SMTPUTF8
smtputf8_enable = no

# ===========================
# Virtual / Maps
# ===========================
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases

virtual_mailbox_domains = mysql:/etc/postfix/mysql-virtual-mailbox-domains.cf
virtual_mailbox_maps = mysql:/etc/postfix/mysql-virtual-mailbox-maps.cf
virtual_alias_maps = mysql:/etc/postfix/mysql-virtual-alias-maps.cf

virtual_transport = lmtp:unix:private/dovecot-lmtp

# ===========================
# Timeouts / Limits
# ===========================
smtpd_timeout = 30s
smtp_helo_timeout = 15s
smtp_rcpt_timeout = 15s
smtpd_recipient_limit = 40

# Reject Codes
unknown_client_reject_code = 450
unknown_hostname_reject_code = 450
unknown_address_reject_code = 450
invalid_hostname_reject_code = 501
non_fqdn_reject_code = 501
access_map_reject_code = 554
maps_rbl_reject_code = 554
unknown_virtual_mailbox_reject_code = 550
unknown_virtual_alias_reject_code = 550
unknown_local_recipient_reject_code = 550

# Milters (inbound verify + DMARC enforce)
milter_default_action = accept
milter_protocol = 6
smtpd_milters = inet:127.0.0.1:8891,inet:127.0.0.1:8893
non_smtpd_milters = $smtpd_milters

smtp_helo_name = mail.example.nl
compatibility_level = 3.6
EOF

8.10) /etc/postfix/master.cf

This includes: postscreen on port 25, submission on 587, optional smtps on 465, Amavis filter pipeline (inbound/outbound), SPF policy daemon, and a post-amavis DKIM signing stage.

sudo tee /etc/postfix/master.cf > /dev/null <<'EOF'
##############################################################################
# Postfix master process configuration - CLEAN & PRIVACY-ENHANCED
##############################################################################

smtp      inet  n       -       n       -       1       postscreen

smtpd     pass  -       -       n       -       -       smtpd
  -o smtpd_helo_required=yes
  -o disable_vrfy_command=yes
  -o smtpd_data_restrictions=reject_unauth_pipelining
  -o smtpd_relay_restrictions=permit_mynetworks,permit_sasl_authenticated,reject_unauth_destination
  -o smtpd_milters=inet:127.0.0.1:8891,inet:127.0.0.1:8893
  -o non_smtpd_milters=inet:127.0.0.1:8891,inet:127.0.0.1:8893
  -o smtpd_proxy_filter=127.0.0.1:10024
  -o smtpd_proxy_options=speed_adjust
  -o receive_override_options=no_header_body_checks

##############################################################################
# SUBMISSION (587) - authenticated mail, mandatory TLS
##############################################################################
submission inet n       -       n       -       -       smtpd
  -o syslog_name=postfix/submission
  -o smtpd_tls_security_level=encrypt
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_reject_unlisted_recipient=no
  -o smtpd_client_restrictions=permit_sasl_authenticated,reject
  -o smtpd_sender_login_maps=mysql:/etc/postfix/mysql-sender-login-maps.cf
  -o smtpd_sender_restrictions=reject_authenticated_sender_login_mismatch,permit_sasl_authenticated,reject
  -o milter_macro_daemon_name=ORIGINATING
  -o cleanup_service_name=submission-cleanup
  -o tls_server_sni_maps=hash:/etc/postfix/vmail_ssl.map
  -o content_filter=smtp-amavis:[127.0.0.1]:10028
  -o smtpd_milters=
  -o non_smtpd_milters=

##############################################################################
# SMTPS (465) - authenticated mail, TLS wrapper
##############################################################################
smtps inet n       -       n       -       -       smtpd
  -o syslog_name=postfix/smtps
  -o smtpd_tls_wrappermode=yes
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_client_restrictions=permit_sasl_authenticated,reject
  -o smtpd_sender_login_maps=mysql:/etc/postfix/mysql-sender-login-maps.cf
  -o smtpd_sender_restrictions=reject_authenticated_sender_login_mismatch,permit_sasl_authenticated,reject
  -o milter_macro_daemon_name=ORIGINATING
  -o cleanup_service_name=submission-cleanup
  -o tls_server_sni_maps=hash:/etc/postfix/vmail_ssl.map
  -o content_filter=smtp-amavis:[127.0.0.1]:10028
  -o smtpd_milters=
  -o non_smtpd_milters=

##############################################################################
# Amavis content filter (inbound/outbound)
##############################################################################
smtp-amavis unix -       -       n       -       2       smtp
  -o smtp_data_done_timeout=1200
  -o smtp_send_xforward_command=yes
  -o max_use=20

127.0.0.1:10025 inet n   -       n       -       -       smtpd
  -o content_filter=
  -o local_recipient_maps=
  -o relay_recipient_maps=
  -o smtpd_restriction_classes=
  -o smtpd_delay_reject=no
  -o smtpd_client_restrictions=permit_mynetworks,reject
  -o smtpd_helo_restrictions=
  -o smtpd_sender_restrictions=
  -o smtpd_recipient_restrictions=permit_mynetworks,reject
  -o receive_override_options=no_header_body_checks,no_unknown_recipient_checks
  -o smtpd_milters=inet:127.0.0.1:8891,inet:127.0.0.1:8894
  -o non_smtpd_milters=inet:127.0.0.1:8891,inet:127.0.0.1:8894

##############################################################################
# PRIVACY cleanup services
##############################################################################
submission-cleanup unix n - n - 0 cleanup
  -o header_checks=pcre:/etc/postfix/submission_header_cleanup

after-amavis-cleanup unix n - n - 0 cleanup
  -o syslog_name=postfix/afterclean
  -o header_checks=pcre:/etc/postfix/outbound_after_amavis_header_cleanup

##############################################################################
# SPF policy service
##############################################################################
policyd-spf unix -      n       n       -       0       spawn
  user=policyd-spf argv=/usr/bin/policyd-spf

##############################################################################
# POST-AMAVIS DKIM SIGNING SMTPD (OUTBOUND ONLY)
##############################################################################
127.0.0.1:10027 inet n  -  n  -  -  smtpd
  -o syslog_name=postfix/dkim
  -o content_filter=
  -o local_recipient_maps=
  -o relay_recipient_maps=
  -o smtpd_delay_reject=no
  -o smtpd_client_restrictions=permit_mynetworks,reject
  -o smtpd_helo_restrictions=
  -o smtpd_sender_restrictions=
  -o smtpd_recipient_restrictions=permit_mynetworks,reject
  -o receive_override_options=no_unknown_recipient_checks
  -o cleanup_service_name=after-amavis-cleanup
  -o smtpd_milters=inet:127.0.0.1:8892
  -o non_smtpd_milters=inet:127.0.0.1:8892

##############################################################################
# Standard postfix backend services
##############################################################################
pickup    unix  n       -       y       60      1       pickup
cleanup   unix  n       -       y       -       0       cleanup
qmgr      unix  n       -       n       300     1       qmgr
tlsmgr    unix  -       -       y       1000?   1       tlsmgr
rewrite   unix  -       -       y       -       -       trivial-rewrite
dnsblog   unix  -       -       n       -       0       dnsblog
tlsproxy  unix  -       -       n       -       0       tlsproxy
bounce    unix  -       -       y       -       0       bounce
defer     unix  -       -       y       -       0       bounce
trace     unix  -       -       y       -       0       bounce
verify    unix  -       -       y       -       1       verify
flush     unix  n       -       y       1000?   0       flush
proxymap  unix  -       -       n       -       -       proxymap
proxywrite unix -       -       n       -       1       proxymap
smtp      unix  -       -       y       -       -       smtp
relay     unix  -       -       y       -       -       smtp
showq     unix  n       -       y       -       -       showq
error     unix  -       -       y       -       -       error
retry     unix  -       -       y       -       -       error
discard   unix  -       -       y       -       -       discard
local     unix  -       n       n       -       -       local
virtual   unix  -       n       n       -       -       virtual
lmtp      unix  -       -       y       -       -       lmtp
anvil     unix  -       -       y       -       1       anvil
scache    unix  -       -       y       -       1       scache
EOF

8.11) Reload Postfix and validate maps

sudo postfix check
sudo systemctl restart postfix
sudo systemctl status postfix --no-pager

Validate map files (where applicable):

# MySQL lookups
sudo postmap -q "example.nl" mysql:/etc/postfix/mysql-virtual-mailbox-domains.cf
sudo postmap -q "user@example.nl" mysql:/etc/postfix/mysql-virtual-mailbox-maps.cf
sudo postmap -q "alias@example.nl" mysql:/etc/postfix/mysql-virtual-alias-maps.cf

# Hash maps
sudo postmap -q "smtp.example.nl" hash:/etc/postfix/vmail_ssl.map || true
sudo postmap -q "example-gov.nl" hash:/etc/postfix/tls_policy || true

9) TLS certificates (Certbot DNS-01 with static keys)

Separate certs per domain + separate key path

Static keys stored under /etc/letsencrypt/keys and reused on renewals.

Create key directory:

sudo install -d -m 0700 -o root -g root /etc/letsencrypt/keys

Generate ECDSA keys (recommended):

sudo openssl ecparam -name prime256v1 -genkey -noout \
  -out /etc/letsencrypt/keys/example.nl.key
sudo chmod 600 /etc/letsencrypt/keys/example.nl.key

sudo openssl ecparam -name prime256v1 -genkey -noout \
  -out /etc/letsencrypt/keys/mail.example.nl.key
sudo chmod 600 /etc/letsencrypt/keys/mail.example.nl.key

TransIP credentials file:

sudo install -d -m 0700 -o root -g root /etc/letsencrypt/transip
sudo vi /etc/letsencrypt/transip/api.ini
sudo chmod 600 /etc/letsencrypt/transip/api.ini

Issue certificate for a domain (example):

sudo certbot certonly \
  --authenticator dns-transip \
  --dns-transip-credentials /etc/letsencrypt/transip/api.ini \
  --dns-transip-propagation-seconds 30 \
  --cert-name example.nl \
  --key-path /etc/letsencrypt/keys/example.nl.key \
  --reuse-key \
  -d example.nl \
  -d www.example.nl \
  -d imap.example.nl \
  -d smtp.example.nl \
  -d autoconfig.example.nl \
  -d autodiscover.example.nl

Issue certificate for the server hostname (separate cert):

sudo certbot certonly \
  --authenticator dns-transip \
  --dns-transip-credentials /etc/letsencrypt/transip/api.ini \
  --dns-transip-propagation-seconds 30 \
  --cert-name mail.example.nl \
  --key-path /etc/letsencrypt/keys/mail.example.nl.key \
  --reuse-key \
  -d mail.example.nl

Validate certificate files exist:

sudo ls -l /etc/letsencrypt/live/example.nl/fullchain.pem
sudo ls -l /etc/letsencrypt/live/mail.example.nl/fullchain.pem

10) Amavis (inbound + outbound policy)

SpamAssassin only, quarantine spam for forensics

Debian uses /etc/amavis/conf.d. This guide keeps inbound scanning on port 10024 and adds a separate outbound Amavis instance on port 10028.


10.1) Amavis inbound defaults (conf.d/20-debian_defaults)

sudo tee /etc/amavis/conf.d/20-debian_defaults > /dev/null <<'EOF'
use strict;

$daemon_user  = 'amavis';
$daemon_group = 'amavis';

$mydomain   = 'example.nl';
$myhostname = 'mail.example.nl';

$virus_admin               = "postmaster\@$myhostname";
$mailfrom_notify_admin     = $virus_admin;
$mailfrom_notify_recip     = $virus_admin;
$mailfrom_notify_spamadmin = $virus_admin;

$recipient_delimiter = '+';

$notify_method  = 'smtp:[127.0.0.1]:10025';
$forward_method = 'smtp:[127.0.0.1]:10025';

$inet_socket_port = [10024];

$log_recip_templ  = undef;
$DO_SYSLOG        = 1;
$syslog_ident     = 'amavis';
$syslog_facility  = 'mail';

$enable_db           = 0;
$enable_global_cache = 0;

$sa_spam_subject_tag = '***SPAM*** ';
$sa_tag_level_deflt  = -999;
$sa_tag2_level_deflt = 5.0;
$sa_kill_level_deflt = 6.5;
$sa_dsn_cutoff_level = 9.0;

$sa_mail_body_size_limit = 200 * 1024;
$sa_local_tests_only     = 0;

$final_virus_destiny      = D_REJECT;
$final_banned_destiny     = D_REJECT;
$final_spam_destiny       = D_DISCARD;
$final_bad_header_destiny = D_PASS;

$final_destiny_by_ccat{+CC_UNCHECKED}      = D_DISCARD;
$quarantine_to_maps_by_ccat{+CC_UNCHECKED} = ['banned-quarantine'];
$quarantine_method_by_ccat{+CC_UNCHECKED}  = $banned_files_quarantine_method;

$QUARANTINEDIR            = "$MYHOME/virusmails";
$quarantine_subdir_levels = 1;

$clean_quarantine_method = 'local:clean-%m.gz';

$banned_quarantine_to = 'local:banned-%m';
$virus_quarantine_to  = 'local:virus-%m';
$spam_quarantine_to   = 'local:spam-%m.gz';

$banned_files_quarantine_method = 'local:banned-%m';
$bad_header_quarantine_method   = 'local:badh-%m';
$virus_quarantine_method        = 'local:virus-%m';
$spam_quarantine_method         = 'local:spam-%m.gz';

$warnbannedrecip = 1;
$warnvirusrecip  = 1;

$allowed_added_header_fields{lc($_)} = 1 for qw(
  Received
  Authentication-Results
  DKIM-Signature
  VBR-Info
  X-Quarantine-ID
  X-Amavis-Alert
  X-Amavis-Hold
  X-Amavis-Modified
  X-Amavis-PolicyBank
  X-Spam-Status
  X-Spam-Level
  X-Spam-Flag
  X-Spam-Score
  X-Spam-Report
  X-Spam-Checker-Version
  X-Spam-Tests
  X-Bogosity
);

$prefer_our_added_header_fields{lc($_)} = 1 for qw(
  X-Spam-Status
  X-Spam-Level
  X-Spam-Flag
  X-Spam-Score
  X-Spam-Report
  X-Spam-Checker-Version
);

$interface_policy{'10024'} = 'INBOUND';

$enable_dkim_verification = 0;
$X_HEADER_LINE = "The Cleaner Team $myhostname";

1;
EOF

10.2) User overrides (conf.d/50-user)

Quarantine spam even when discarding, and set outbound policy (forward to DKIM stage).

sudo tee /etc/amavis/conf.d/50-user > /dev/null <<'EOF'
use strict;

our %insert_header_fields;

$max_servers = 4;

$allowed_added_header_fields{'x-sa-tests'} = 1;
$allowed_added_header_fields{'x-sa-bayes'} = 1;
$allowed_added_header_fields{'x-sa-txrep'} = 1;

$prefer_our_added_header_fields{'x-sa-tests'} = 1;
$prefer_our_added_header_fields{'x-sa-bayes'} = 1;
$prefer_our_added_header_fields{'x-sa-txrep'} = 1;

for my $h (qw(
  x-sa-tests
  x-sa-bayes
  x-sa-txrep
  x-sa-txrep-msgid
  x-sa-txrep-ip
  x-sa-txrep-domain
)) {
  $allowed_added_header_fields{$h} = 1;
  $prefer_our_added_header_fields{$h} = 1;
}

$insert_header_fields{'X-Trace-Stage'} = 'amavis-10024';

$quarantine_to_maps_by_ccat{+CC_SPAM}   = ['spam-quarantine'];
$quarantine_method_by_ccat{+CC_SPAM}    = $spam_quarantine_method;

$policy_bank{'ORIGINATING'} = {
  originating => 1,

  remove_existing_x_scanned_headers => 1,
  remove_existing_spam_headers      => 1,

  forward_method => 'smtp:[127.0.0.1]:10027',

  smtpd_discard_ehlo_keywords => ['8BITMIME'],
};

$sa_debug = 0;

1;
EOF

10.3) Notifications policy (conf.d/50-notify-from)

sudo tee /etc/amavis/conf.d/50-notify-from > /dev/null <<'EOF'
$notify_method = 'smtp:[127.0.0.1]:10025';

$virus_admin = 'postmaster@mail.example.nl';

$spam_admin  = undef;

$mailfrom_notify_admin     = 'postmaster@mail.example.nl';
$mailfrom_notify_recip     = undef;
$mailfrom_notify_spamadmin = undef;

$terminate_dsn_on_notify_success = 1;

$notify_virus_recips_templ = undef;
$notify_spam_recips_templ  = undef;

$notify_virus_sender_templ = undef;
$notify_spam_sender_templ  = undef;

1;
EOF

10.4) Enable SpamAssassin checks (conf.d/15-content_filter_mode)

Edit:

sudo vi /etc/amavis/conf.d/15-content_filter_mode

Ensure this is uncommented (no leading #):

@bypass_spam_checks_maps = (
\%bypass_spam_checks, \@bypass_spam_checks_acl, \$bypass_spam_checks_re);

Optional safe uncomment helper:

sudo sed -i 's/^[[:space:]]*#\([[:space:]]*@bypass_spam_checks_maps\)/\1/' /etc/amavis/conf.d/15-content_filter_mode

10.5) Separate outbound Amavis instance (amavis-out on port 10028)

This is the running outbound design: outbound mail is scanned by a dedicated Amavis instance (amavisout) on port 10028, using a separate state directory (/var/lib/amavis-out).

Create outbound user:

sudo adduser --system --group --home /var/lib/amavis-out --disabled-login amavisout

Create state directories:

sudo mkdir -p /var/lib/amavis-out/{tmp,virusmails,log,.spamassassin}
sudo chown -R amavisout:amavisout /var/lib/amavis-out
sudo chmod 750 /var/lib/amavis-out
sudo chmod 700 /var/lib/amavis-out/.spamassassin

Disable TXREP for outbound instance only:

sudo tee /var/lib/amavis-out/.spamassassin/user_prefs > /dev/null <<'EOF'
use_txrep 0
score TXREP 0
EOF

sudo chown amavisout:amavisout /var/lib/amavis-out/.spamassassin/user_prefs
sudo chmod 600 /var/lib/amavis-out/.spamassassin/user_prefs

Create outbound override config:

sudo tee /etc/amavis/amavis-out.override > /dev/null <<'EOF'
use strict;

# Ensure temp files are group-readable for clamd (dirs 750, files 640)
umask 0027;

$daemon_user  = 'amavisout';
$daemon_group = 'amavisout';

# ============================================================
# Amavis OUT override (minimal)
# Only what we really need for outbound instance
# ============================================================

# Separate state/home
$MYHOME = '/var/lib/amavis-out';
$QUARANTINEDIR = "$MYHOME/virusmails";

# Separate helper socket (avoid permission clash with main instance)
$unix_socketname = "/var/lib/amavis-out/amavisd.sock";

# Clear log identity
$syslog_ident = 'amavis-out';

# OUT listens only here
$inet_socket_port = [10028];
$interface_policy{'10028'} = 'ORIGINATING';

# ensure OUT instance has no inbound mappings from base config
delete $interface_policy{'10024'};
delete $interface_policy{'10026'};

# No notifications from OUT
$virus_admin = undef;
$spam_admin  = undef;

# OUT should never discard mail based on spam score
$final_spam_destiny = D_PASS;

# Still run SA (for scoring/reporting), but don't "kill" outbound
$sa_tag_level_deflt  = -999;  # always run SA
$sa_tag2_level_deflt = 5.0;   # add headers from here
$sa_kill_level_deflt = 20.0;  # effectively never triggered in normal outbound

# Outbound policy: strip fingerprints + forward to DKIM service
$policy_bank{'ORIGINATING'} = {
  originating => 1,

  remove_existing_x_scanned_headers => 1,
  remove_existing_spam_headers      => 1,

  forward_method => 'smtp:[127.0.0.1]:10027',

  # DKIM-safe SMTP behaviour
  smtpd_discard_ehlo_keywords => ['8BITMIME'],
};

1;
EOF

Generate the effective outbound config file (/etc/amavis/amavis-out.conf) by concatenating /etc/amavis/conf.d + the override (this is what you actually run).

sudo tee /usr/local/sbin/amavis-out-rebuild > /dev/null <<'EOF'
#!/bin/bash
set -euo pipefail

TMP="/etc/amavis/amavis-out.conf"

: > "$TMP"
for f in $(ls -1 /etc/amavis/conf.d | sort -V); do
  [ -f "/etc/amavis/conf.d/$f" ] || continue
  echo "# ===== FILE: $f =====" >> "$TMP"
  cat "/etc/amavis/conf.d/$f" >> "$TMP"
  echo >> "$TMP"
done

echo "# ===== FILE: amavis-out.override =====" >> "$TMP"
cat /etc/amavis/amavis-out.override >> "$TMP"
echo >> "$TMP"

# hard fail if config is invalid
sudo -u amavisout /usr/sbin/amavisd -c "$TMP" test-config >/dev/null

systemctl restart amavis-out
systemctl --no-pager -l status amavis-out | sed -n '1,40p'
EOF

sudo chmod 0755 /usr/local/sbin/amavis-out-rebuild
sudo /usr/local/sbin/amavis-out-rebuild

10.6) systemd unit for amavis-out

sudo tee /etc/systemd/system/amavis-out.service > /dev/null <<'EOF'
[Unit]
Description=Amavis OUT instance (separate SA state)
After=network.target

[Service]
User=amavisout
Group=amavisout
RuntimeDirectory=amavis-out
ExecStart=/usr/sbin/amavisd -H /var/lib/amavis-out -T /var/lib/amavis-out/tmp -c /etc/amavis/amavis-out.conf foreground
ExecReload=/usr/sbin/amavisd -H /var/lib/amavis-out -T /var/lib/amavis-out/tmp -c /etc/amavis/amavis-out.conf reload
Restart=on-failure
PrivateTmp=true
ProtectSystem=full
ProtectHome=true

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now amavis-out
sudo systemctl status amavis-out --no-pager -l | sed -n '1,80p'

10.7) ClamAV permissions (if you use clamd socket scanning)

sudo usermod -aG amavisout clamav
id clamav

sudo systemctl restart clamav-daemon
sudo systemctl status clamav-daemon --no-pager -l | sed -n '1,30p'

sudo install -d -o amavisout -g amavisout -m 2750 /var/lib/amavis-out/tmp

10.8) Postfix outbound content_filter must target 10028

Confirm master.cf uses port 10028 for submission/smtps:

sudo grep -nE 'submission inet|smtps inet|content_filter=.*1002[678]' /etc/postfix/master.cf

If you still have 10026 anywhere, replace it:

sudo sed -i 's/\[127\.0\.0\.1\]:10026/[127.0.0.1]:10028/g' /etc/postfix/master.cf
sudo systemctl reload postfix

10.9) Restart and verify

sudo systemctl restart amavis
sudo systemctl status amavis --no-pager -l | sed -n '1,60p'

sudo systemctl restart amavis-out
sudo systemctl status amavis-out --no-pager -l | sed -n '1,80p'

sudo ss -lntp | egrep ':(10024|10025|10027|10028)\b' || true

11) SpamAssassin (local.cf + custom rules)

SpamAssassin reads /etc/spamassassin/local.cf and also loads every *.cf file in /etc/spamassassin/ automatically (including your 99-*.cf and local-seo.cf).


11.1) Full /etc/spamassassin/local.cf

sudo tee /etc/spamassassin/local.cf > /dev/null <<'EOF'
###########################################################################
# SpamAssassin - local.cf (optimized)
# integration: Amavis + Postfix + Dovecot
###########################################################################

###########################################################################
# BASE SETTINGS
###########################################################################

required_score        5.0
fold_headers          1

ok_languages          nl en
ok_locales            nl en

###########################################################################
# BAYES CONFIG (Amavis user)
###########################################################################

use_bayes             1
use_bayes_rules       1
bayes_auto_learn      1
bayes_auto_expire     1

bayes_expiry_max_db_size     1500000

bayes_path            /var/lib/amavis/.spamassassin/bayes

bayes_auto_learn_threshold_nonspam   -3.0
bayes_auto_learn_threshold_spam       6.0

bayes_ignore_header X-Spam-Flag
bayes_ignore_header X-Spam-Status
bayes_ignore_header X-Spam-Checker-Version
bayes_ignore_header X-Spam-Report

###########################################################################
# NETWORK CHECKS
###########################################################################

use_pyzor             0
skip_rbl_checks       0

###########################################################################
# TXREP
###########################################################################

use_txrep             1
txrep_factor          0.3

txrep_learn_penalty   20
txrep_learn_bonus     20

txrep_autolearn       0
txrep_track_messages  1

add_header all X-SA-TxRep         _TXREP_
add_header all X-SA-TxRep-MsgId   _TXREPMSGID_

add_header all X-SA-TxRep-IP      "_TXREPIP_ mean=_TXREPIPMEAN_ cnt=_TXREPIPCOUNT_ pre=_TXREPIPPRESCORE_ unk=_TXREPIPUNKNOWN_"
add_header all X-SA-TxRep-Domain  "_TXREPDOMAIN_ mean=_TXREPDOMAINMEAN_ cnt=_TXREPDOMAINCOUNT_ pre=_TXREPDOMAINPRESCORE_ unk=_TXREPDOMAINUNKNOWN_"

###########################################################################
# DEBUGGING HEADERS
###########################################################################

add_header all Score        _SCORE_ (_REQD_)
add_header all Tests        _TESTSSCORES_
add_header all Autolearn    _AUTOLEARN_
add_header all Bayes        _BAYES_
add_header all Spam-Flag    _YESNO_

###########################################################################
# SAFE NEGATIVE SCORES
###########################################################################

body      GOOD_EMAIL    /(debian|ubuntu|linux mint|centos|red hat|rhel|opensuse|fedora|arch linux|raspberry pi|kali linux)/i
describe  GOOD_EMAIL    Likely Linux/tech mail
score     GOOD_EMAIL    -3.0

body      BOUNCE_MSG /(Undelivered Mail Returned to Sender|Undeliverable|Auto-Reply|Automatic reply)/i
describe  BOUNCE_MSG Auto-reply/bounce
score     BOUNCE_MSG -1.5

###########################################################################
# LIGHT SUSPICIOUS SIGNALS
###########################################################################

score MISSING_FROM               3.0
score MISSING_DATE               3.0
score MISSING_HEADERS            2.0
score PDS_FROM_2_EMAILS          2.0
score EMPTY_MESSAGE              3.0
score FREEMAIL_DISPTO            1.5
score FREEMAIL_FORGED_REPLYTO    2.0
score DKIM_ADSP_NXDOMAIN         2.5
score DKIM_INVALID               1.0

header    CUSTOM_DMARC_FAIL   Authentication-Results =~ /dmarc=fail/i
describe  CUSTOM_DMARC_FAIL   DMARC fail
score     CUSTOM_DMARC_FAIL   2.5

header   FROM_SAME_AS_TO   ALL=~/\nFrom: ([^\n]+)\nTo: \1/sm
describe FROM_SAME_AS_TO   From equals To
score    FROM_SAME_AS_TO   2.0

header    EMPTY_RETURN_PATH    ALL =~ /<>/i
describe  EMPTY_RETURN_PATH    Empty return-path
score     EMPTY_RETURN_PATH    2.5

###########################################################################
# COUNTRY FILTERS (requires X-Relay-Countries header from your stack)
###########################################################################

header RELAYCOUNTRY_RU  X-Relay-Countries =~ /RU/
describe RELAYCOUNTRY_RU Relayed via Russia
score RELAYCOUNTRY_RU   4.0

header RELAYCOUNTRY_CN  X-Relay-Countries =~ /CN/
describe RELAYCOUNTRY_CN Relayed via China
score RELAYCOUNTRY_CN   4.0

header RELAYCOUNTRY_BR  X-Relay-Countries =~ /BR/
describe RELAYCOUNTRY_BR Relayed via Brazil
score RELAYCOUNTRY_BR   3.5

###########################################################################
# WHITELIST (adjust to your needs)
###########################################################################

whitelist_from *@example.org
whitelist_from *@example.net

###########################################################################
# Authentication-Results header
###########################################################################

add_header all Authentication-Results _AUTHRESULTS_

###########################################################################
# Sender reputation
###########################################################################

header  BAD_SENDER_LOCALPART  From =~ /^(bulkmail|offers|cheapbenefits|earnmoney|foryou)@/i
describe BAD_SENDER_LOCALPART Suspicious bulk-style sender name
score   BAD_SENDER_LOCALPART  3.5

###########################################################################
# UCEPROTECT LEVEL 1
###########################################################################

header          RCVD_IN_UCEPROTECT1     eval:check_rbl_txt('uceprotect1','dnsbl-1.uceprotect.net')
describe        RCVD_IN_UCEPROTECT1     Listed in dnsbl-1.uceprotect.net
tflags          RCVD_IN_UCEPROTECT1     net
score           RCVD_IN_UCEPROTECT1     1.8

###########################################################################
# SPAMMY TLD RULES
###########################################################################

header SPAMMY_TLD_IN_RCVD Received =~ /(\.net\.ae|\.net\.id|\.ro|\.cz|\.com\.co|\.su|\.co\.ke|\.ac\.za|\.co\.in|\.com\.vn|\.vn|\.cc|\.ua|\.com\.br|\.gr|\.hr|\.dk|\.win|\.bid|\.tw|\.br|\.pk|\.top|\.club|\.date|\.stream|\.xyz|\.trade|\.icu|\.press|\.pro|\.pet|\.kim|\.casa|\.bond|\.store|\.business|\.red)\s/i
score SPAMMY_TLD_IN_RCVD 4.0
describe SPAMMY_TLD_IN_RCVD Spammy TLD in Received

header SPAMMY_TLD_IN_FROM From =~ /(\.net\.ae|\.net\.id|\.ro|\.co\.jp|\.co\.ke|\.com\.co|\.su|\.ac\.za|\.co\.in|\.com\.vn|\.vn|\.cc|\.ua|\.com\.br|\.gr|\.hr|\.cz|\.win|\.bid|\.tw|\.br|\.pk|\.top|\.club|\.date|\.stream|\.xyz|\.trade|\.icu|\.press|\.pro|\.pet|\.bond|\.casa|\.kim|\.store|\.business|\.red)>$/i
score SPAMMY_TLD_IN_FROM 4.0
describe SPAMMY_TLD_IN_FROM Spammy TLD in From

###########################################################################
# HIGH-SPAMMY COMBO TLD
###########################################################################

header __HIGH_SPAMMY_TLD_RCVD Received =~ /\.(win|bid|top|club|date|stream|xyz|store|bond|su|icu)\//i
header __HIGH_SPAMMY_TLD_FROM From     =~ /\.(win|bid|top|club|date|stream|xyz|store|bond|su|icu)\//i
uri    __HIGH_SPAMMY_TLD_URI  /\.(win|bid|top|club|date|stream|store|bond|xyz)\/.+/i

meta   HIGH_SPAMMY_TLD (__HIGH_SPAMMY_TLD_RCVD && __HIGH_SPAMMY_TLD_FROM && __HIGH_SPAMMY_TLD_URI)
score  HIGH_SPAMMY_TLD 6.1
describe HIGH_SPAMMY_TLD Highly suspicious TLD combination

###########################################################################
# PHP / CMS SPAM INDICATORS
###########################################################################

header EVALED_PHP X-PHP-Originating-Script =~ /eval\(\)\'d code/i
score  EVALED_PHP 6.0

header OUTDATED_PHP X-Mailer =~ /PHP v?5\.[1234].*/i
score  OUTDATED_PHP 4.0

header __WP_X_PHP_ORIG_SCRIPT X-PHP-Originating-Script =~ /(post|gallery|user)\.php/i
header __WP_X_PHP_SCRIPT      X-PHP-Script             =~ /(post|gallery|user)\.php/i
header __WP_X_SOURCE          X-Source                 =~ /php-cgi/i
header __WP_X_SOURCE_ARGS     X-Source-Args            =~ /(post|gallery|user)\.php/i
header __WP_PATH_X_SOURCE_ARGS X-Source-Args           =~ /\/wp\-(content|includes)\//i
header __JO_COMP_X_SOURCE_ARGS X-Source-Args           =~ /components\/com_/i
header __JO_X_SOURCE_ARGS      X-Source-Args           =~ /\/joomla\//i

meta   CMS_MAIL (__WP_X_PHP_ORIG_SCRIPT || __WP_X_PHP_SCRIPT || __WP_X_SOURCE || __WP_X_SOURCE_ARGS || __WP_PATH_X_SOURCE_ARGS || __JO_COMP_X_SOURCE_ARGS || __JO_X_SOURCE_ARGS)
score  CMS_MAIL 6.0
describe CMS_MAIL Probably hacked CMS sending mail

###########################################################################
# PATCHES (score tuning)
###########################################################################

score DKIM_NONE           1.5
score DKIM_INVALID        2.0
score DMARC_MISSING       2.5

score SPF_PASS            0.0
score SPF_HELO_NONE       1.2

score HTML_IMAGE_ONLY_12  4.0
score HTML_IMAGE_ONLY_24  2.2
score HTML_IMAGE_RATIO_02 1.0
score HTML_MESSAGE        0.5

score T_TVD_MIME_EPI      1.0

###########################################################################
# END
###########################################################################
EOF

11.2) Custom rules loaded automatically (99*.cf + local-seo.cf)

Your active rules are split into these files (avoid duplication, keep it clean and predictable):

  • /etc/spamassassin/99_local_disable_lagacy.cf (disable legacy rules that expect DCC)
  • /etc/spamassassin/99-spam-header-body.cf (migrated header/body checks + a few local signals)
  • /etc/spamassassin/99-strong-auth-discount.cf (safe auth discount)
  • /etc/spamassassin/99-tlsrpt-auth-discount.cf (TLSRPT special-case discount)
  • /etc/spamassassin/local-seo.cf (SEO spam ruleset)

11.2.1) Disable legacy DCC-dependent meta rules

sudo tee /etc/spamassassin/99_local_disable_lagacy.cf > /dev/null <<'EOF'
# DCC is off; disable meta rules that expect DCC_CHECK so --lint stays clean
meta DIGEST_MULTIPLE 0
meta FSL_BULK_SIG 0
score DIGEST_MULTIPLE 0
score FSL_BULK_SIG 0
EOF

11.2.2) Migrated Postfix header/body checks

sudo tee /etc/spamassassin/99-spam-header-body.cf > /dev/null <<'EOF'
###########################################################################
# Migrated from Postfix header_checks + body_checks
# File: /etc/spamassassin/99-spam-header-body.cf
###########################################################################

###########################################################################
# HEADER checks (formerly /etc/postfix/header_checks)
###########################################################################

header   LOCAL_FEEDBACK_ID_SUSPECT   Feedback-ID =~ /mailing_id=/i
describe LOCAL_FEEDBACK_ID_SUSPECT   Suspicious Feedback-ID pattern (mailing_id=)
score    LOCAL_FEEDBACK_ID_SUSPECT   2.5

header   LOCAL_SUBJECT_BIDI          Subject =~ /[\x{200E}\x{200F}\x{202A}-\x{202E}\x{2066}-\x{2069}]/i
describe LOCAL_SUBJECT_BIDI          Subject contains Unicode bidi control characters
score    LOCAL_SUBJECT_BIDI          4.0

header   LOCAL_SES_RETURNPATH        Return-Path =~ /<[0-9A-F]{20,}[^>]*@[^>]*amazonses\.com>/i
describe LOCAL_SES_RETURNPATH        AWS SES-style Return-Path pattern detected
score    LOCAL_SES_RETURNPATH        1.0

header   LOCAL_BRAND_IMPERSONATION_MS From =~ /(microsoft|office)/i
describe LOCAL_BRAND_IMPERSONATION_MS Possible Microsoft/Office brand impersonation
score    LOCAL_BRAND_IMPERSONATION_MS 1.2

header   LOCAL_FROM_BAD_SYNTAX       From =~ /\@\[/i
describe LOCAL_FROM_BAD_SYNTAX       Invalid sender address syntax in From
score    LOCAL_FROM_BAD_SYNTAX       6.0

header   LOCAL_UNDISCLOSED_RCPT      To =~ /undisclosed/i
describe LOCAL_UNDISCLOSED_RCPT      Message to undisclosed recipients
score    LOCAL_UNDISCLOSED_RCPT      0.8

header   LOCAL_SUBJECT_INVOICE_ZIP   Subject =~ /invoice.*\.zip/i
describe LOCAL_SUBJECT_INVOICE_ZIP   Suspicious invoice.zip subject pattern
score    LOCAL_SUBJECT_INVOICE_ZIP   3.5

header   LOCAL_PHPMailer_52          X-Mailer =~ /PHPMailer 5\.2\./i
describe LOCAL_PHPMailer_52          Outdated PHPMailer 5.2 detected
score    LOCAL_PHPMailer_52          1.5

###########################################################################
# BODY checks (formerly /etc/postfix/body_checks)
###########################################################################

body     LOCAL_PHARMA_VIAGRA         /\bviagra\b/i
describe LOCAL_PHARMA_VIAGRA         Pharmaceutical spam keyword (viagra)
score    LOCAL_PHARMA_VIAGRA         6.0

body     LOCAL_PHARMA_VALIUM         /\bvalium\b/i
describe LOCAL_PHARMA_VALIUM         Pharmaceutical spam keyword (valium)
score    LOCAL_PHARMA_VALIUM         6.0

body     LOCAL_PHARMA_LEVITRA        /\blevitra\b/i
describe LOCAL_PHARMA_LEVITRA        Pharmaceutical spam keyword (levitra)
score    LOCAL_PHARMA_LEVITRA        6.0

body     LOCAL_PHARMA_CIALIS         /\bcialis\b/i
describe LOCAL_PHARMA_CIALIS         Pharmaceutical spam keyword (cialis)
score    LOCAL_PHARMA_CIALIS         6.0

body     LOCAL_PORN_NAKED_PHISH      /caught ?(you|me)? ?naked/i
describe LOCAL_PORN_NAKED_PHISH      Porn-based phishing phrase
score    LOCAL_PORN_NAKED_PHISH      6.0

body     LOCAL_BITCOIN_ADDRESS       /\b[13][a-km-zA-HJ-NP-Z1-9]{25,34}\b/
describe LOCAL_BITCOIN_ADDRESS       Possible bitcoin address present
score    LOCAL_BITCOIN_ADDRESS       2.0

body     LOCAL_FR_MEDICAL_SPAM       /\b(douleur|études cliniques|soulage la douleur)\b/i
describe LOCAL_FR_MEDICAL_SPAM       French medical spam phrase
score    LOCAL_FR_MEDICAL_SPAM       2.5

body     LOCAL_FIN_ADVANCE_AMOUNT    /\badvance amount[: ]?\s*1[,\.]?475\b/i
describe LOCAL_FIN_ADVANCE_AMOUNT    Financial scam pattern (advance amount 1475)
score    LOCAL_FIN_ADVANCE_AMOUNT    4.0

body     LOCAL_FIN_CREDIT_REPAIR     /\brepair your credit\b/i
describe LOCAL_FIN_CREDIT_REPAIR     Credit repair spam phrase
score    LOCAL_FIN_CREDIT_REPAIR     3.5

body     LOCAL_FIN_MORTGAGE_QUOTE    /\bmortgage quote\b/i
describe LOCAL_FIN_MORTGAGE_QUOTE    Mortgage spam phrase
score    LOCAL_FIN_MORTGAGE_QUOTE    3.0

body     LOCAL_REPLICA_PRODUCTS      /\b(replica|luxury)\s+(watch|shoes|boots|footwear)\b/i
describe LOCAL_REPLICA_PRODUCTS      Replica product spam phrase
score    LOCAL_REPLICA_PRODUCTS      3.5

body     LOCAL_SEO_GUARANTEED        /\bguaranteed results\b/i
describe LOCAL_SEO_GUARANTEED        SEO spam phrase (guaranteed results)
score    LOCAL_SEO_GUARANTEED        1.0

body     LOCAL_MARKETING_BEST_DEAL   /\bbest deal\b/i
describe LOCAL_MARKETING_BEST_DEAL   Suspicious marketing phrase (best deal)
score    LOCAL_MARKETING_BEST_DEAL   0.8

###########################################################################
# Extra local signals
###########################################################################

header   LOCAL_DKIM_RSA_SHA1  DKIM-Signature =~ /a=rsa-sha1/i
score    LOCAL_DKIM_RSA_SHA1  0.6
describe LOCAL_DKIM_RSA_SHA1  DKIM uses rsa-sha1

body LOCAL_NL_AUTO_OPKOOP_1 /heeft u plannen om uw auto|bedrijfswagen te verkopen/i
score LOCAL_NL_AUTO_OPKOOP_1 2.5
describe LOCAL_NL_AUTO_OPKOOP_1 NL auto-opkoop cold marketing

body LOCAL_NL_AUTO_OPKOOP_2 /\bwhatsapp\b|\+31[0-9]{9}/i
score LOCAL_NL_AUTO_OPKOOP_2 1.5
describe LOCAL_NL_AUTO_OPKOOP_2 WhatsApp/telefoon CTA
EOF

11.2.3) Strong-auth discount (safe and narrow)

sudo tee /etc/spamassassin/99-strong-auth-discount.cf > /dev/null <<'EOF'
###########################################################################
# Strong-auth discount (narrow and safe)
###########################################################################

# Only apply discount when multiple independent trust signals agree
meta LOCAL_STRONG_AUTH (DMARC_PASS && DKIM_VALID && DKIM_VALID_EF && DKIMWL_WL_HIGH)
score LOCAL_STRONG_AUTH -1.5
describe LOCAL_STRONG_AUTH Strong authentication signals (DMARC+DKIM aligned + DKIMWL high)
EOF

11.2.4) TLSRPT reports discount

sudo tee /etc/spamassassin/99-tlsrpt-auth-discount.cf > /dev/null <<'EOF'
###########################################################################
# TLSRPT reports: allow strong-auth to outweigh HELO-SPF noise
###########################################################################

header LOCAL_TLSRPT_CT Content-Type =~ /multipart\/report/i
header LOCAL_TLSRPT_RT Content-Type =~ /report-type=tlsrpt/i
header LOCAL_TLSRPT_HDR exists:TLS-Report-Domain

score  LOCAL_TLSRPT_CT 0.0
score  LOCAL_TLSRPT_RT 0.0
score  LOCAL_TLSRPT_HDR 0.0

meta LOCAL_TLSRPT_STRONG_AUTH (LOCAL_TLSRPT_CT && LOCAL_TLSRPT_RT && LOCAL_TLSRPT_HDR && DMARC_PASS && DKIM_VALID && DKIM_VALID_EF)
score LOCAL_TLSRPT_STRONG_AUTH -2.0
describe LOCAL_TLSRPT_STRONG_AUTH TLSRPT report with strong aligned authentication
EOF

11.2.5) SEO spam ruleset (local-seo.cf)

sudo tee /etc/spamassassin/local-seo.cf > /dev/null <<'EOF'
###
## Basic SEO rules (custom)
###

header PP_LOCAL_SEO_1 Subject =~ /\srankings?/i
describe PP_LOCAL_SEO_1 google seo ranking spam
score PP_LOCAL_SEO_1 2

header PP_LOCAL_SEO_2 Message-ID =~ /servicehubmail\.com/
describe PP_LOCAL_SEO_2 spammy headers (servicehubmail)
score PP_LOCAL_SEO_2 2

body   PP_LOCAL_SEO_3 /(Business Owner,|Hello Team)/i
describe PP_LOCAL_SEO_3 Generic greeting
score PP_LOCAL_SEO_3 2

body   __PP_LOCAL_SEO_01 /\s(SEO|Search Engine Optimization)/i
body   __PP_LOCAL_SEO_02 /\s(1st page of|page one of|first page of|top of|front page of) google/i
body   __PP_LOCAL_SEO_03 /\s(analysis|analyze)\W/i
body   __PP_LOCAL_SEO_04 /\s(optimize|optimizing|optimization|visibility)\W/i
body   __PP_LOCAL_SEO_05 /\s(Ethical|Organic)\W/i
body   __PP_LOCAL_SEO_06 /\s(ROI|sales|proposal|strategy)/i
body   __PP_LOCAL_SEO_07 /\s(website|keywords?|ranking)/i
body   __PP_LOCAL_SEO_08 /\s(higher|more|better) traffic/i
body   __PP_LOCAL_SEO_09 /\s(google|bing|yahoo)/i

meta PP_LOCAL_SEO_4 (( __PP_LOCAL_SEO_01 + __PP_LOCAL_SEO_02 + __PP_LOCAL_SEO_03 + __PP_LOCAL_SEO_04 + __PP_LOCAL_SEO_05 + __PP_LOCAL_SEO_06 + __PP_LOCAL_SEO_07 + __PP_LOCAL_SEO_08 + __PP_LOCAL_SEO_09 )) > 5
describe PP_LOCAL_SEO_4 Body contains too many SEO optimisation words
score    PP_LOCAL_SEO_4 2

rawbody __PP_LOCAL_TOO_MUCH_SEO /\b(SEO)\b/i
meta PP_LOCAL_TOO_MUCH_SEO __PP_LOCAL_TOO_MUCH_SEO > 3
describe PP_LOCAL_TOO_MUCH_SEO Too many uses of SEO (>3)
score PP_LOCAL_TOO_MUCH_SEO 5
tflags __PP_LOCAL_TOO_MUCH_SEO multiple maxhits=5

meta PP_LOCAL_SEO_SUSPICIOUS (( __PP_LOCAL_SEO_01 + __PP_LOCAL_SEO_02 + __PP_LOCAL_SEO_07 + __PP_LOCAL_SEO_09 )) > 2
describe PP_LOCAL_SEO_SUSPICIOUS Probably SEO spam
score PP_LOCAL_SEO_SUSPICIOUS 3
EOF

11.3) Plugin activation (*.pre) check + enable with sed

List active plugin loads:

sudo grep -Rni --fixed-string "loadplugin" /etc/spamassassin/*.pre

If you want X-Relay-Countries support from SpamAssassin itself, enable RelayCountry (requires Geo::IP Perl module).

# Enable RelayCountry in init.pre (uncomment the loadplugin line)
sudo sed -i 's/^[[:space:]]*#[[:space:]]*\(loadplugin Mail::SpamAssassin::Plugin::RelayCountry\)/\1/' /etc/spamassassin/init.pre

# Verify Geo::IP module exists (should exit 0 if present)
perl -MGeo::IP -e 1
echo "exit=$?"

If you want the AuthRes plugin enabled (parses Authentication-Results), uncomment it in v400.pre.

sudo sed -i 's/^[[:space:]]*#[[:space:]]*\(loadplugin Mail::SpamAssassin::Plugin::AuthRes\)/\1/' /etc/spamassassin/v400.pre

11.4) Validate and restart

# Config sanity check (must return OK)
sudo spamassassin --lint

# Restart services (Amavis calls SA internally, but keep services clean)
sudo systemctl restart spamassassin
sudo systemctl restart amavis

12) OpenDKIM

Inbound VERIFY-only + Outbound SIGN-only (two-instance design)

This setup runs two separate OpenDKIM instances:

  • VERIFY-only (inbound) on 127.0.0.1:8891 (validates DKIM signatures, adds Authentication-Results)
  • SIGN-only (outbound) on 127.0.0.1:8892 (signs mail after Amavis, adds Authentication-Results)

This design matches a strict mail pipeline where inbound mail is verified early, and outbound mail is signed only after content filtering.

Install packages

sudo apt update
sudo apt install -y opendkim opendkim-tools

Important (Debian): disable the default single-instance service

Debian ships opendkim.service. When running two custom instances, disable the default service to avoid port/socket conflicts.

sudo systemctl disable --now opendkim || true
sudo systemctl mask opendkim || true

Postfix integration points

In this mailserver build, Postfix calls OpenDKIM via milters:

  • Inbound port 25 / receive layer uses VERIFY: inet:127.0.0.1:8891
  • Outbound post-amavis stage uses SIGN: inet:127.0.0.1:8892

Trusted hosts file (shared)

Both instances use the same TrustedHosts file.

sudo install -d -m 0755 /etc/opendkim

sudo tee /etc/opendkim/TrustedHosts > /dev/null <<'EOF'
127.0.0.1
localhost
::1
EOF

sudo chown opendkim:opendkim /etc/opendkim/TrustedHosts
sudo chmod 0644 /etc/opendkim/TrustedHosts

Outbound SIGN-only configuration

This instance signs outbound messages only. It requires a KeyTable and SigningTable.

sudo tee /etc/opendkim-sign.conf > /dev/null <<'EOF'
# OpenDKIM SIGN-ONLY (outbound)
Syslog                  yes
SyslogSuccess           yes
LogWhy                  yes

UserID                  opendkim:opendkim
PidFile                 /run/opendkim-sign/opendkim.pid

Mode                    s
Socket                  inet:8892@127.0.0.1

Canonicalization        relaxed/relaxed
SignatureAlgorithm      rsa-sha256
MinimumKeyBits          2048

KeyTable                refile:/etc/opendkim/KeyTable
SigningTable            refile:/etc/opendkim/SigningTable
InternalHosts           refile:/etc/opendkim/TrustedHosts

AlwaysAddARHeader       yes
AuthservID              mail.example.nl

# Keep OversignHeaders minimal and stable
OversignHeaders         From
EOF

Inbound VERIFY-only configuration

This instance verifies DKIM only. It does not sign and therefore does not use KeyTable/SigningTable.

sudo tee /etc/opendkim-verify.conf > /dev/null <<'EOF'
# OpenDKIM VERIFY-ONLY (inbound)
Syslog                  yes
SyslogSuccess           yes
LogWhy                  yes

UserID                  opendkim:opendkim
PidFile                 /run/opendkim-verify/opendkim.pid

Mode                    v
Socket                  inet:8891@127.0.0.1

AlwaysAddARHeader       yes
AuthservID              mail.example.nl

# Allow legacy 1024-bit inbound keys (verification only)
MinimumKeyBits          1024

On-BadSignature         accept
On-SignatureError       accept

InternalHosts           refile:/etc/opendkim/TrustedHosts
EOF

KeyTable and SigningTable (outbound signing)

Generate DKIM keys per domain. Example below uses selector mail.

sudo install -d -m 0750 -o opendkim -g opendkim /etc/opendkim/keys/example.nl

sudo opendkim-genkey \
  -D /etc/opendkim/keys/example.nl \
  -d example.nl \
  -s mail

sudo chown opendkim:opendkim /etc/opendkim/keys/example.nl/mail.private
sudo chmod 0600 /etc/opendkim/keys/example.nl/mail.private

Create KeyTable and SigningTable:

sudo tee /etc/opendkim/KeyTable > /dev/null <<'EOF'
mail._domainkey.example.nl example.nl:mail:/etc/opendkim/keys/example.nl/mail.private
EOF

sudo tee /etc/opendkim/SigningTable > /dev/null <<'EOF'
*@example.nl mail._domainkey.example.nl
EOF

sudo chown opendkim:opendkim /etc/opendkim/KeyTable /etc/opendkim/SigningTable
sudo chmod 0640 /etc/opendkim/KeyTable /etc/opendkim/SigningTable

DKIM DNS record

Extract the public key and publish it as a TXT record:

sudo cat /etc/opendkim/keys/example.nl/mail.txt

Publish this DNS record:

mail._domainkey.example.nl. IN TXT "v=DKIM1; k=rsa; p=PASTE_PUBLIC_KEY_HERE"

Systemd services (two instances)

Create two systemd units: one for verify-only and one for sign-only.

sudo tee /etc/systemd/system/opendkim-verify.service > /dev/null <<'EOF'
[Unit]
Description=OpenDKIM (verify-only)
After=network-online.target nss-lookup.target
Wants=network-online.target
ConditionPathExists=/etc/opendkim-verify.conf

[Service]
Type=forking
User=opendkim
Group=opendkim

RuntimeDirectory=opendkim
RuntimeDirectoryMode=0750
RuntimeDirectoryPreserve=yes

PIDFile=/run/opendkim/opendkim-verify.pid
ExecStart=/usr/sbin/opendkim -x /etc/opendkim-verify.conf
ExecReload=/bin/kill -USR1 $MAINPID
Restart=on-failure
RestartSec=2

# lichte hardening (veilig voor OpenDKIM)
UMask=0077
NoNewPrivileges=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target
EOF

sudo tee /etc/systemd/system/opendkim-sign.service > /dev/null <<'EOF'
[Unit]
Description=OpenDKIM (sign-only)
After=network-online.target nss-lookup.target
Wants=network-online.target
ConditionPathExists=/etc/opendkim-sign.conf

[Service]
Type=forking
User=opendkim
Group=opendkim

RuntimeDirectory=opendkim
RuntimeDirectoryMode=0750
RuntimeDirectoryPreserve=yes

PIDFile=/run/opendkim/opendkim-sign.pid
ExecStart=/usr/sbin/opendkim -x /etc/opendkim-sign.conf
ExecReload=/bin/kill -USR1 $MAINPID
Restart=on-failure
RestartSec=2

# lichte hardening (veilig voor OpenDKIM)
UMask=0077
NoNewPrivileges=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now opendkim-verify.service opendkim-sign.service

Validation checks

sudo systemctl status opendkim-verify.service opendkim-sign.service --no-pager

sudo ss -lntp | grep -E '(:8891|:8892)\b' || true

sudo journalctl -u opendkim-verify.service -b --no-pager -n 80
sudo journalctl -u opendkim-sign.service -b --no-pager -n 80

Optional: validate keys (DNS must be published first):

sudo opendkim-testkey -d example.nl -s mail -vvv

Tip: keep SIGN-only strict (2048+ keys) while VERIFY-only can accept older inbound signatures without rejecting mail.

13) OpenDMARC

DMARC validation (inbound) + stamp-only (post-filter)

This setup uses OpenDMARC as a milter in two roles: inbound email is validated and (optionally) enforced via opendmarc, and after the content-filter stage (for example after Amavis) a second instance only stamps headers via opendmarc-stamp.

Why two instances? Inbound enforcement belongs on the first SMTP receive layer (port 25), but post-filter stamping should happen on the internal after-filter flow (for example 10025) without re-enforcing policy.

1) Install

sudo apt update
sudo apt install -y opendmarc

2) Host ignore list

Hosts in this list are considered trusted internal sources (for example localhost). This prevents internal mailflows from triggering DMARC enforcement.

sudo tee /etc/opendmarc/ignore.hosts > /dev/null <<'EOF'
127.0.0.1
::1
EOF

3) OpenDMARC (inbound verify/enforce)

This is the primary instance that checks inbound mail. Link it as a milter on Postfix port 25 (and optionally on 587/465 if you want).

sudo tee /etc/opendmarc.conf > /dev/null <<'EOF'
# OpenDMARC (INBOUND) - verify/enforce

AuthservID mail.example.nl
AuthservIDWithJobID false
TrustedAuthservIDs mail.example.nl

PidFile /run/opendmarc/opendmarc.pid
Socket inet:8893@127.0.0.1

Syslog true
SyslogFacility mail
SoftwareHeader false

RequiredHeaders true

# Enforce DMARC policy on inbound (set false if you only want stamping)
RejectFailures true

IgnoreAuthenticatedClients true
IgnoreHosts /etc/opendmarc/ignore.hosts

# SPF handling: rely on results from Postfix/policyd-spf and the Received chain
SPFIgnoreResults false
SPFSelfValidate false

# History database
HistoryFile /run/opendmarc/opendmarc.dat

# Forensic reports (RUF) can be privacy-sensitive. Default: off.
FailureReports false
EOF

4) OpenDMARC stamp-only (post-filter)

This instance only stamps headers and does not enforce policy. Typical usage is on your after-filter SMTPD (for example 127.0.0.1:10025 after Amavis).

sudo tee /etc/opendmarc-stamp.conf > /dev/null <<'EOF'
# OpenDMARC STAMP-ONLY (post-filter)

AuthservID mail.example.nl
AuthservIDWithJobID false
TrustedAuthservIDs mail.example.nl

PidFile /run/opendmarc-stamp/opendmarc.pid
HistoryFile /run/opendmarc-stamp/opendmarc.dat

Socket inet:8894@127.0.0.1

Syslog true
SyslogFacility mail
SoftwareHeader false

RequiredHeaders true

# No enforcement on post-filter flow
RejectFailures false

IgnoreAuthenticatedClients true
IgnoreHosts /etc/opendmarc/ignore.hosts

SPFIgnoreResults false
SPFSelfValidate false

# No reporting from stamp instance
FailureReports false
EOF

5) Systemd services (2 instances)

Debian provides opendmarc.service. For the stamp-only instance we create a separate unit so pidfiles and runtime state do not conflict.

Enable the standard service:

sudo systemctl enable --now opendmarc
sudo systemctl status opendmarc --no-pager

Create the systemd unit for opendmarc-stamp:

sudo tee /etc/systemd/system/opendmarc-stamp.service > /dev/null <<'EOF'
[Unit]
Description=OpenDMARC (stamp-only instance)
After=network.target
Wants=network.target

[Service]
Type=forking
User=opendmarc
Group=opendmarc
PIDFile=/run/opendmarc-stamp/opendmarc.pid
ExecStart=/usr/sbin/opendmarc -c /etc/opendmarc-stamp.conf
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=2s
RuntimeDirectory=opendmarc-stamp
RuntimeDirectoryMode=0755

[Install]
WantedBy=multi-user.target
EOF

Enable the stamp service:

sudo systemctl daemon-reload
sudo systemctl enable --now opendmarc-stamp
sudo systemctl status opendmarc-stamp --no-pager

6) Postfix linking (milters)

Inbound (port 25) typically uses: OpenDKIM verify + OpenDMARC enforce.

smtpd_milters = inet:127.0.0.1:8891, inet:127.0.0.1:8893
non_smtpd_milters = inet:127.0.0.1:8891, inet:127.0.0.1:8893

Post-filter (for example on 127.0.0.1:10025 after Amavis) uses: OpenDKIM sign + OpenDMARC stamp.

-o smtpd_milters=inet:127.0.0.1:8892,inet:127.0.0.1:8894
-o non_smtpd_milters=inet:127.0.0.1:8892,inet:127.0.0.1:8894

7) Testing

sudo journalctl -u opendmarc --no-pager -n 80
sudo journalctl -u opendmarc-stamp --no-pager -n 80

sudo ss -lntp | grep -E ':8893|:8894' || true

14) postfix-policyd-spf-python

SPF policy enforcement (MAIL FROM + HELO) with enhanced status codes

This setup uses postfix-policyd-spf-python as a policy daemon in Postfix. It evaluates SPF when a remote server attempts to use an envelope sender. The checks are applied via: check_policy_service unix:private/policyd-spf


1) Install

sudo apt update
sudo apt install -y postfix-policyd-spf-python

2) Postfix master.cf service (policy daemon)

Add (or verify) this service in /etc/postfix/master.cf:

policyd-spf unix -      n       n       -       0       spawn
  user=policyd-spf argv=/usr/bin/policyd-spf

Reload Postfix:

sudo postfix check
sudo systemctl reload postfix

3) Postfix main.cf policy hook

SPF belongs in recipient restrictions (during SMTP transactions), for example:

smtpd_recipient_restrictions =
  permit_mynetworks,
  permit_sasl_authenticated,
  reject_non_fqdn_recipient,
  reject_unknown_recipient_domain,
  reject_unlisted_recipient,
  check_policy_service unix:private/policyd-spf,
  reject_unauth_destination

4) policyd-spf.conf

Below is a practical hardened configuration. It is strict on SPF FAIL, but avoids hard-rejecting on temporary DNS failures to reduce false positives.

sudo tee /etc/postfix-policyd-spf-python/policyd-spf.conf > /dev/null <<'EOF'
# postfix-policyd-spf-python - minimal hardened config

debugLevel = 1
TestOnly = 0

# Reject on SPF Fail (hard fail)
HELO_reject = Fail
Mail_From_reject = Fail

# PermError: usually sender misconfig (optional reject)
PermError_reject = False

# TempError: temporary DNS issues; avoid mail loss (optional defer)
TempError_Defer = False

# Never evaluate SPF for local system addresses
skip_addresses = 127.0.0.0/8,::ffff:127.0.0.0/104,::1

# Whitelist: trusted relays (example values)
Whitelist = 203.0.113.10/32,2001:db8::10/128

# Use RFC-compliant enhanced status codes
SPF_Enhanced_Status_Codes = Yes

# Show receiver hostname in added headers (privacy choice)
Hide_Receiver = No

# Authentication-Results: header identity
Authserv_Id = mail.example.nl

# Header type:
# AR = Authentication-Results (preferred)
# SPF = legacy Received-SPF
Header_Type = AR
EOF

5) Testing

sudo systemctl status postfix-policyd-spf-python --no-pager
sudo journalctl -u postfix-policyd-spf-python --no-pager -n 80

sudo grep -i "policyd-spf" /var/log/mail.log | tail -n 80

15) DNS records

MX, SPF, DKIM, DMARC, Autoconfig/Autodiscover, SRV, and optional DANE + MTA-STS/TLS-RPT

These DNS records support a secure mailserver (Postfix + Dovecot + OpenDKIM + OpenDMARC), with separate hostnames for IMAP and SMTP.


1) A / AAAA records

Publish DNS A/AAAA records for your mail hostnames. In this example mail.example.nl is the server hostname (MX target), and IMAP/SMTP use separate hostnames.

# Mailserver hostname (MX target)
mail.example.nl.          A       YOUR_IPV4
mail.example.nl.          AAAA    YOUR_IPV6

# Mail protocols
imap.example.nl.          A       YOUR_IPV4
imap.example.nl.          AAAA    YOUR_IPV6

smtp.example.nl.          A       YOUR_IPV4
smtp.example.nl.          AAAA    YOUR_IPV6

# Autoconfig/autodiscover hostnames (clients)
autoconfig.example.nl.    CNAME   example.nl.
autodiscover.example.nl.  CNAME   example.nl.

2) MX record

The MX points to your mailserver hostname.

example.nl.               MX 10   mail.example.nl.

3) SPF record

SPF limits who may send mail on behalf of your domain. This example says: only your MX may send.

example.nl.               TXT "v=spf1 mx -all"

4) DKIM record

DKIM publishes a public key. Choose a selector, for example mail. Insert your public DKIM key (no whitespace).

mail._domainkey.example.nl.  TXT "v=DKIM1; k=rsa; p=PASTE_PUBLIC_KEY_HERE"

5) DMARC record

DMARC policy: reject (strict). Use a report address that exists.

_dmarc.example.nl.        TXT "v=DMARC1; p=reject; sp=reject; rua=mailto:dmarc-reports@example.nl; adkim=s; aspf=s; pct=100; fo=0;"

6) Autoconfig + SRV records

SRV records help clients discover IMAP and Submission automatically. Autoconfig/autodiscover can provide full client configuration.

# Autoconfig / autodiscover
autoconfig               CNAME   example.nl.
autodiscover             CNAME   example.nl.

# IMAP + Submission SRV records
_imaps._tcp.example.nl.       SRV 0 1 993 imap.example.nl.
_submission._tcp.example.nl.  SRV 0 1 587 smtp.example.nl.

7) Optional: DANE / TLSA (requires DNSSEC)

DANE/TLSA is only useful if your zone is DNSSEC-signed. The common and safe choice for SMTP is TLSA usage 3 1 1 (DANE-EE, SPKI, SHA256).

Example TLSA records (adjust hostnames/ports to your setup):

# SMTP (inbound) - port 25
_25._tcp.mail.example.nl.   TLSA 3 1 1 HASH

# Submission (outbound) - port 587 (optional)
_587._tcp.smtp.example.nl.  TLSA 3 1 1 HASH

# IMAPS - port 993 (optional)
_993._tcp.imap.example.nl.  TLSA 3 1 1 HASH

SPKI hash command (run on the host that has the certificate for the relevant name):

openssl x509 -in /etc/letsencrypt/live/mail.example.nl/cert.pem -noout -pubkey \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256 -binary \
  | xxd -p -c 256

8) MTA-STS + TLS-RPT (DNS + policy content)

MTA-STS publishes an HTTPS policy (RFC 8461) and a TXT record that signals policy changes. TLS-RPT publishes a TXT record for TLS failure reporting (RFC 8460).

8.1) DNS records

# MTA-STS policy hostname must exist and be HTTPS reachable
mta-sts.example.nl.       A     YOUR_WEBSERVER_IPV4
mta-sts.example.nl.       AAAA  YOUR_WEBSERVER_IPV6

# MTA-STS TXT record (change id when policy changes)
_mta-sts.example.nl.      TXT  "v=STSv1; id=20251212"

# TLS-RPT TXT record
_smtp._tls.example.nl.    TXT  "v=TLSRPTv1; rua=mailto:tls-report@example.nl"

8.2) MTA-STS policy file (content)

Your HTTPS server must serve this exact file at: https://mta-sts.example.nl/.well-known/mta-sts.txt

version: STSv1
mode: enforce
mx: mail.example.nl
max_age: 604800

Notes:
- mx: must match your real MX hostname(s).
- For multiple MX, add multiple mx: lines.


9) Thunderbird autoconfig XML (content)

Place this file on your webserver so clients can fetch it via: https://autoconfig.example.nl/mail/config-v1.1.xml

<?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1">
  <emailProvider id="example.nl">
    <domain>example.nl</domain>
    <displayName>Example Mail</displayName>

    <incomingServer type="imap">
      <hostname>imap.example.nl</hostname>
      <port>993</port>
      <socketType>SSL</socketType>
      <authentication>password-cleartext</authentication>
      <username>%EMAILADDRESS%</username>
    </incomingServer>

    <outgoingServer type="smtp">
      <hostname>smtp.example.nl</hostname>
      <port>587</port>
      <socketType>STARTTLS</socketType>
      <authentication>password-cleartext</authentication>
      <username>%EMAILADDRESS%</username>
    </outgoingServer>

  </emailProvider>
</clientConfig>

10) Validation commands (DNS only)

# MX / SPF / DMARC / DKIM
dig MX example.nl +short
dig TXT example.nl +short
dig TXT _dmarc.example.nl +short
dig TXT mail._domainkey.example.nl +short

# MTA-STS / TLS-RPT
dig TXT _mta-sts.example.nl +short
dig TXT _smtp._tls.example.nl +short

16) Fail2ban (mail-only)

This server uses Fail2ban to automatically block brute-force, enumeration and protocol abuse on Postfix and Dovecot. The configuration is strict for abuse/scanning and conservative for normal mailflow.

Install

sudo apt update
sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban

Files

  • /etc/fail2ban/jail.local (jails + defaults)
  • /etc/fail2ban/filter.d/postfix.conf (inbound SMTP protocol/abuse filter, mode=more)
  • /etc/fail2ban/filter.d/postfix-enum.conf (RCPT enumeration: "User unknown in virtual mailbox table")
  • /etc/fail2ban/filter.d/postfix-postscreen-abuse.conf (postscreen NON-SMTP / bare-newline / pregreet abuse)
  • /etc/fail2ban/filter.d/postfix-sasl.conf (SMTP AUTH brute-force)
  • /etc/fail2ban/filter.d/dovecot.conf (Dovecot auth: sql password mismatch/unknown user)

/etc/fail2ban/jail.local

Defaults: long bans for repeated abuse (10 days), short bans for protocol noise on SMTP, and an ignore list for local/trusted networks.

sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF'
[DEFAULT]
bantime   = 10d
findtime  = 1d
maxretry  = 3

action    = %(action_mwl)s
banaction = iptables-multiport

ignoreip =
  127.0.0.1/8
  ::1

# ----------------------------------------------------------------------
# POSTFIX: protocol abuse / relay probing / invalid recipients
# ----------------------------------------------------------------------
[postfix]
enabled   = true
filter    = postfix
port      = smtp,submission
logpath   = /var/log/mail.log
mode      = more
maxretry  = 10
findtime  = 1h
bantime   = 1h

# ----------------------------------------------------------------------
# POSTFIX: SMTP AUTH brute-force (SASL failures)
# ----------------------------------------------------------------------
[postfix-sasl]
enabled   = true
filter    = postfix-sasl
port      = submission
logpath   = /var/log/mail.log
maxretry  = 3
findtime  = 10m
bantime   = 10d

# ----------------------------------------------------------------------
# POSTFIX: RCPT enumeration (unknown virtual mailbox table)
# ----------------------------------------------------------------------
[postfix-enum]
enabled   = true
filter    = postfix-enum
port      = smtp
logpath   = /var/log/mail.log
maxretry  = 3
findtime  = 10m
bantime   = 10d

# ----------------------------------------------------------------------
# POSTFIX: postscreen NON-SMTP / pregreet / bare newline
# ----------------------------------------------------------------------
[postfix-postscreen-abuse]
enabled   = true
filter    = postfix-postscreen-abuse
port      = smtp
logpath   = /var/log/mail.log
maxretry  = 1
findtime  = 10m
bantime   = 10d

# ----------------------------------------------------------------------
# DOVECOT: IMAP auth failures
# ----------------------------------------------------------------------
[dovecot]
enabled  = true
filter   = dovecot
port     = imap,imaps
logpath  = /var/log/dovecot.log
bantime  = 24h
maxretry = 3
EOF

Postfix filters

/etc/fail2ban/filter.d/postfix.conf

sudo tee /etc/fail2ban/filter.d/postfix.conf > /dev/null <<'EOF'
# Fail2Ban filter - Postfix (SMTP inbound / protocol abuse)
#
# Intended for pre-auth abuse:
# - protocol errors
# - relay probing
# - invalid recipients
#
# Not intended for:
# - SMTP AUTH brute-force (use postfix-sasl.conf)
# - Dovecot IMAP auth (use dovecot.conf)

[INCLUDES]
before = common.conf

[Definition]
_daemon = postfix(-\w+)?/\w+(?:/smtp[ds])?

mdre-normal =
  ^%(__prefix_line)s from [^[]*\[<HOST>\](?::\d+)?: [45][50][04] [45]\.\d\.\d+ (?:(?:<[^>]*>)?: )?(?:(?:Helo command|(?:Sender|Recipient) address) rejected: )?(?:Service unavailable|(?:Client host|Command|Data command) rejected|Relay access denied|(?:Host|Domain) not found|need fully-qualified hostname|match(?:User unknown|Undeliverable address))\b

mdre-rbl =
  ^%(__prefix_line)s from [^[]*\[<HOST>\](?::\d+)?: [45]54 [45]\.7\.1 Service unavailable; Client host \[\S+\] blocked\b

mdre-ddos =
  ^%(__prefix_line)s from [^[]*\[<HOST>\](?::\d+)?:?

failregex =
  <mdre-normal>
  <mdre-rbl>
  <mdre-ddos>

ignoreregex =
mode = more
EOF

/etc/fail2ban/filter.d/postfix-enum.conf

sudo tee /etc/fail2ban/filter.d/postfix-enum.conf > /dev/null <<'EOF'
# Ban on RCPT enumeration:
# repeated "User unknown in virtual mailbox table"

[Definition]
failregex = ^.* postfix/(?:smtpd|submission/smtpd|smtps/smtpd)(?:/smtpd)?\[\d+\]: (?:NOQUEUE: )?reject: RCPT from [^\[]+\[<HOST>\]: 550 5\.1\.[0-9] .*Recipient address rejected: User unknown in virtual mailbox table;.*$
ignoreregex =
EOF

/etc/fail2ban/filter.d/postfix-postscreen-abuse.conf

sudo tee /etc/fail2ban/filter.d/postfix-postscreen-abuse.conf > /dev/null <<'EOF'
# Postscreen abuse: NON-SMTP, bare newline, pregreet payload.

[Definition]
failregex =
  ^.* postfix/postscreen\[\d+\]: NON-SMTP COMMAND from (?:\S+\[)?<HOST>\]?:\d+ .*$

  ^.* postfix/postscreen\[\d+\]: BARE NEWLINE from (?:\S+\[)?<HOST>\]?:\d+ .*$

  # Optional (more aggressive): pregreet payload before 220 banner
  ^.* postfix/postscreen\[\d+\]: PREGREET \d+ after .* from (?:\S+\[)?<HOST>\]?:\d+: .*$
ignoreregex =
EOF

/etc/fail2ban/filter.d/postfix-sasl.conf

sudo tee /etc/fail2ban/filter.d/postfix-sasl.conf > /dev/null <<'EOF'
# Fail2Ban filter - Postfix SASL authentication
#
# Goal:
# - Ban real SMTP AUTH brute-force attempts
# - Do not ban on network errors, TLS issues or backend outages

[Definition]
_daemon = postfix/(submission/)?smtp(d|s)

failregex =
  (?i): warning: [-._\w]+\[<HOST>\]: SASL (?:LOGIN|PLAIN|(?:CRAM|DIGEST)-MD5) authentication failed(: [A-Za-z0-9+/ ]*)?$

ignoreregex =
  authentication failed: Connection lost to authentication server$
EOF

Dovecot filter

/etc/fail2ban/filter.d/dovecot.conf

sudo tee /etc/fail2ban/filter.d/dovecot.conf > /dev/null <<'EOF'
# Fail2Ban filter - Dovecot (IMAP)
#
# Goal:
# - Ban real backend authentication failures (SQL/PAM)
# - Do not ban on TLS disconnects or client noise

[INCLUDES]
before = common.conf

[Definition]
_auth_worker = (?:dovecot: )?auth(?:-worker)?
_daemon      = (?:dovecot(?:-auth)?|auth)

prefregex = ^%(__prefix_line)s(?:%(_auth_worker)s(?:\([^\)]+\))?: )?(?:%(__pam_auth)s(?:\(dovecot:auth\))?: |(?:imap)-login: )?(?:Info: )?<F-CONTENT>.+</F-CONTENT>$

failregex =
  ^conn unix:auth-worker \(pid=\d+,uid=\d+\): auth-worker<\d+>: sql\(.*,<HOST>(,<[0-9A-Za-z]+>)?\): ((P|p)assword mismatch|(U|u)nknown user)

mode = normal
ignoreregex =

datepattern = {^LN-BEG}TAI64N
              {^LN-BEG}
EOF

Reload and checks

sudo fail2ban-client reload
sudo fail2ban-client status

sudo fail2ban-client status postfix
sudo fail2ban-client status postfix-sasl
sudo fail2ban-client status postfix-enum
sudo fail2ban-client status postfix-postscreen-abuse
sudo fail2ban-client status dovecot

Firewall verification

sudo iptables -S | grep -i fail2ban || true
sudo ip6tables -S | grep -i fail2ban || true

17) Testing and validation

Confirm all moving parts (local + end-to-end)

This chapter is a practical validation checklist. Run the commands on the mailserver and verify: services are healthy, ports listen, TLS works, auth works, milters stamp correctly, and mail flow is clean.


1) Service health (systemd)

Verify all relevant daemons are active and not restarting:

sudo systemctl status \
  postfix dovecot mariadb amavis spamassassin \
  opendkim-verify opendkim-sign \
  opendmarc opendmarc-stamp \
  fail2ban --no-pager

Get recent logs per service (useful during troubleshooting):

sudo journalctl -u postfix -b --no-pager -n 200
sudo journalctl -u dovecot -b --no-pager -n 200
sudo journalctl -u amavis -b --no-pager -n 200
sudo journalctl -u opendkim-verify -b --no-pager -n 200
sudo journalctl -u opendkim-sign -b --no-pager -n 200
sudo journalctl -u opendmarc -b --no-pager -n 200
sudo journalctl -u opendmarc-stamp -b --no-pager -n 200

2) Confirm ports are listening

Confirm the mail protocols and milters are listening on the expected ports. Adjust hostnames/ports if you do not use SMTPS (465) or if your internal ports differ.

sudo ss -lntp | grep -E ':(25|465|587|993)\b' || true
sudo ss -lntp | grep -E ':(8891|8892|8893|8894)\b' || true

Optional: verify local firewall rules (only if you manage firewall on this host):

sudo iptables -S | head -n 200
sudo ip6tables -S | head -n 200

3) Postfix sanity checks

Validate Postfix configuration, permissions, and map files:

sudo postfix check
sudo postconf -n

Queue visibility (must normally be small and stable):

mailq
sudo postqueue -p

Confirm Postfix does not have obvious warnings/errors:

sudo grep -iE "warning|error|fatal|panic" /var/log/mail.log | tail -n 100

4) MariaDB connectivity and schema sanity

If you use MariaDB for virtual domains/users/aliases, confirm local DB access and content. This assumes local root socket auth is allowed (default on Debian).

sudo mariadb -e "SHOW DATABASES;"
sudo mariadb -e "SHOW TABLES FROM mailserver;"

Optional: quick counts (adjust table names to your schema):

sudo mariadb mailserver -e "SELECT COUNT(*) AS domains FROM virtual_domains;"
sudo mariadb mailserver -e "SELECT COUNT(*) AS users   FROM virtual_users;"
sudo mariadb mailserver -e "SELECT COUNT(*) AS aliases FROM virtual_aliases;"

5) Postfix MySQL map lookups

Confirm Postfix map queries work (must return expected results):

sudo postmap -q "example.nl" mysql:/etc/postfix/mysql-virtual-mailbox-domains.cf
sudo postmap -q "user@example.nl" mysql:/etc/postfix/mysql-virtual-mailbox-maps.cf
sudo postmap -q "alias@example.nl" mysql:/etc/postfix/mysql-virtual-alias-maps.cf

Test sender login map (if you use it for authenticated sender control):

sudo postmap -q "user@example.nl" mysql:/etc/postfix/mysql-sender-login-maps.cf

6) Dovecot sanity + authentication tests

Check active Dovecot configuration (effective config):

sudo doveconf -n

Confirm SASL auth via Dovecot:

sudo doveadm auth test "user@example.nl" "PASSWORD"

Optional: verify mailbox visibility (if vmail storage is used):

sudo doveadm user "user@example.nl"
sudo doveadm mailbox list -u "user@example.nl"

Optional: quota status (only if quota is enabled):

sudo doveadm quota get -u "user@example.nl" || true

7) TLS validation (SMTP + IMAP)

Validate TLS handshake, certificate chain, and SNI. Replace hostnames with your real ones. These tests are critical for client compatibility and deliverability.

7.1) SMTP STARTTLS on submission (587)

openssl s_client -starttls smtp -connect smtp.example.nl:587 -servername smtp.example.nl </dev/null

7.2) SMTP STARTTLS on port 25 (inbound)

openssl s_client -starttls smtp -connect mail.example.nl:25 -servername mail.example.nl </dev/null

7.3) SMTPS (465) if enabled

openssl s_client -connect smtp.example.nl:465 -servername smtp.example.nl </dev/null

7.4) IMAPS (993)

openssl s_client -connect imap.example.nl:993 -servername imap.example.nl </dev/null

Extract and inspect certificate details (SAN, issuer, validity):

openssl s_client -connect imap.example.nl:993 -servername imap.example.nl </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName

8) OpenDKIM validation

Confirm both instances are running and reachable. Verify-only should listen on 8891, sign-only should listen on 8892.

sudo ss -lntp | grep -E ':(8891|8892)\b' || true
sudo systemctl status opendkim-verify opendkim-sign --no-pager

Validate outbound DKIM keys against DNS (per domain/selector):

sudo opendkim-testkey -d example.nl -s mail -vvv

Check for OpenDKIM milter activity in logs:

sudo grep -iE "opendkim|dkim" /var/log/mail.log | tail -n 200

9) OpenDMARC validation

Confirm both sockets are listening and services are healthy:

sudo ss -lntp | grep -E ':(8893|8894)\b' || true
sudo systemctl status opendmarc opendmarc-stamp --no-pager

Check DMARC decisions and stamping in logs:

sudo grep -iE "opendmarc|dmarc" /var/log/mail.log | tail -n 200

10) SPF policy daemon validation (postfix-policyd-spf-python)

Confirm the policy service exists in master.cf and is being hit in logs:

sudo postconf -n | grep -i "check_policy_service" || true
sudo postconf -n | grep -i "policyd-spf" || true
sudo grep -i "policyd-spf" /var/log/mail.log | tail -n 200

11) Amavis + SpamAssassin validation

Confirm Amavis is running and processing mail:

sudo systemctl status amavis --no-pager
sudo grep -iE "amavis|Passed|Blocked|INFECTED|SPAM" /var/log/mail.log | tail -n 200

SpamAssassin lint (must return no fatal issues):

sudo spamassassin --lint

Optional: verbose lint/debug (useful when you add custom rules):

sudo spamassassin --lint -D 2>/tmp/sa-lint-debug.log || true
tail -n 200 /tmp/sa-lint-debug.log

Check Bayes database status (as Amavis user, based on your bayes_path):

sudo -u amavis sa-learn --dbpath /var/lib/amavis/.spamassassin --dump magic
sudo -u amavis sa-learn --dbpath /var/lib/amavis/.spamassassin --dump bayes

Optional: test a saved RFC822 message against SpamAssassin (if you have a sample file):

spamassassin -t < /path/to/sample.eml | head -n 50

12) End-to-end SMTP tests with swaks (recommended)

swaks is the fastest way to test SMTP dialogs (AUTH, STARTTLS, envelope sender/recipient, and headers). Install it on the mailserver or a client machine.

sudo apt install -y swaks

12.1) Test port 25 (inbound, STARTTLS)

swaks --server mail.example.nl --port 25 --tls --ehlo test.example.nl \
  --from test-sender@example.nl --to user@example.nl

12.2) Test submission 587 with AUTH (client style)

swaks --server smtp.example.nl --port 587 --tls --auth LOGIN \
  --auth-user user@example.nl --auth-password 'PASSWORD' \
  --from user@example.nl --to someone@external-domain.tld

12.3) Force a specific envelope sender (useful for SPF/DMARC alignment tests)

swaks --server smtp.example.nl --port 587 --tls --auth LOGIN \
  --auth-user user@example.nl --auth-password 'PASSWORD' \
  --mail-from user@example.nl --h-From: user@example.nl \
  --to someone@external-domain.tld

12.4) Show full SMTP transcript (debug)

swaks --server smtp.example.nl --port 587 --tls --auth LOGIN \
  --auth-user user@example.nl --auth-password 'PASSWORD' \
  --from user@example.nl --to someone@external-domain.tld --data "Subject: swaks debug test" \
  --quit-after DATA --timeout 30 --dump

After delivery, verify the received message contains: DKIM-Signature (outbound signing), Authentication-Results, and DMARC/SPF results.


13) DNS validation (MX/SPF/DKIM/DMARC/TLS-RPT/MTA-STS)

Run these from any host with DNS tools. If you use DNSSEC, also verify DNSSEC status separately.

dig +short A mail.example.nl
dig +short AAAA mail.example.nl

dig +short MX example.nl
dig +short TXT example.nl
dig +short TXT mail._domainkey.example.nl
dig +short TXT _dmarc.example.nl

dig +short TXT _smtp._tls.example.nl
dig +short TXT _mta-sts.example.nl

Optional: DANE TLSA records (only if you use DNSSEC + DANE):

dig +short TLSA _25._tcp.mail.example.nl
dig +short TLSA _587._tcp.smtp.example.nl
dig +short TLSA _993._tcp.imap.example.nl

14) Fail2ban validation

Confirm jails are enabled and counters are sane:

sudo fail2ban-client status
sudo fail2ban-client status postfix
sudo fail2ban-client status postfix-sasl
sudo fail2ban-client status postfix-enum
sudo fail2ban-client status postfix-postscreen-abuse
sudo fail2ban-client status dovecot

Test filter patterns against real logs (excellent for tuning):

sudo fail2ban-regex /var/log/mail.log /etc/fail2ban/filter.d/postfix.conf
sudo fail2ban-regex /var/log/mail.log /etc/fail2ban/filter.d/postfix-sasl.conf
sudo fail2ban-regex /var/log/mail.log /etc/fail2ban/filter.d/postfix-enum.conf
sudo fail2ban-regex /var/log/mail.log /etc/fail2ban/filter.d/postfix-postscreen-abuse.conf

sudo fail2ban-regex /var/log/dovecot.log /etc/fail2ban/filter.d/dovecot.conf

15) Live log monitoring (real-time debugging)

Use these during tests to confirm the exact pipeline path:

sudo tail -f /var/log/mail.log
sudo tail -f /var/log/dovecot.log

Quick filters while testing:

sudo grep -iE "smtpd|submission|postscreen|amavis|spamassassin|opendkim|opendmarc|policyd-spf" /var/log/mail.log | tail -n 200

16) Deliverability verification (recommended external checks)

For a final validation, send a real outbound message to a large provider mailbox (Gmail/Outlook/etc) and verify in the received headers:

  • SPF = pass (policy/auth results)
  • DKIM = pass (signature valid and aligned)
  • DMARC = pass (aligned and policy-compliant)
  • No "via" or "on behalf of" anomalies

If results differ from expectations, collect the full headers and compare: envelope-from, header-from, Authentication-Results, Received chain, and DKIM selector/domain.


17.1) Common failure signatures (what it looks like + what to do)

This is a fast troubleshooting map. For each symptom: run the grep, confirm the exact error, then apply the minimal fix (restart/reload only what is needed).

A) Postfix not accepting mail / port 25 issues

Symptom: clients cannot connect, or mail is not received.

Check:

sudo ss -lntp | grep -E ':(25|587|465)\b' || true
sudo systemctl status postfix --no-pager
sudo tail -n 200 /var/log/mail.log

Common log signatures:

# Postfix not running or cannot bind
fatal: bind 0.0.0.0 port 25: Address already in use
fatal: the Postfix mail system is not running

Actions:

sudo systemctl restart postfix
sudo ss -lntp | grep -E ':(25|587|465)\b' || true

B) Milter failures (OpenDKIM/OpenDMARC) causing tempfails

Symptom: inbound SMTP sessions are deferred (4xx) due to milter connectivity.

Check:

sudo grep -iE "milter|opendkim|opendmarc" /var/log/mail.log | tail -n 200
sudo ss -lntp | grep -E ':(8891|8892|8893|8894)\b' || true

Common log signatures:

# Postfix cannot connect to milter
warning: connect to Milter service inet:127.0.0.1:8891: Connection refused
warning: connect to Milter service inet:127.0.0.1:8893: Connection refused

# Postfix tempfail because milter is unavailable
NOQUEUE: reject: RCPT from ...: 451 4.7.1 Service unavailable - try again later;

Actions:

sudo systemctl restart opendkim-verify opendkim-sign
sudo systemctl restart opendmarc opendmarc-stamp
sudo systemctl restart postfix

sudo ss -lntp | grep -E ':(8891|8892|8893|8894)\b' || true

C) Outbound DKIM is not signing (sign-only instance)

Symptom: outbound mail has no DKIM-Signature header, or has "DKIM=none".

Check:

sudo systemctl status opendkim-sign --no-pager
sudo journalctl -u opendkim-sign -b --no-pager -n 200
sudo grep -iE "opendkim.*(sign|key|signingtable|keytable)" /var/log/mail.log | tail -n 200

Common log signatures:

# No SigningTable match
opendkim[...] no signing table match for 'user@example.nl'

# Key cannot be read / wrong permissions
opendkim[...] KeyTable entry ... private key ... not readable
opendkim[...] key data is not secure: ...

Actions (typical):

# Validate the key against DNS (selector=mail, domain=example.nl)
sudo opendkim-testkey -d example.nl -s mail -vvv

# Check permissions (private keys must be readable only by opendkim)
sudo find /etc/opendkim/keys -type f -name '*.private' -exec ls -l {} \;
sudo systemctl restart opendkim-sign

D) DMARC rejections on inbound (strict enforce)

Symptom: inbound messages get rejected due to DMARC policy enforcement.

Check:

sudo systemctl status opendmarc --no-pager
sudo journalctl -u opendmarc -b --no-pager -n 200
sudo grep -iE "opendmarc|dmarc" /var/log/mail.log | tail -n 200

Common log signatures:

# DMARC policy enforcement hit
opendmarc[...] rejecting due to DMARC policy
NOQUEUE: reject: ...: 550 5.7.1 ... DMARC policy ...

Actions:

# Validate DKIM + SPF alignment in received headers.
# If you see DMARC failures, confirm:
# - Header From domain
# - SPF result domain (MAIL FROM)
# - DKIM d= domain
# - Authentication-Results chain

E) SASL AUTH failures (submission brute force or wrong creds)

Symptom: users cannot authenticate on port 587, or fail2ban bans clients.

Check:

sudo grep -iE "SASL|authentication failed" /var/log/mail.log | tail -n 200
sudo journalctl -u dovecot -b --no-pager -n 200

Common log signatures:

# Postfix sees SASL failures
warning: unknown[1.2.3.4]: SASL LOGIN authentication failed: authentication failure

# Dovecot reports backend auth failure
dovecot: auth: sql(user@example.nl,1.2.3.4): Password mismatch
dovecot: auth: sql(user@example.nl,1.2.3.4): Unknown user

Actions:

# Validate credentials directly against Dovecot auth backend
sudo doveadm auth test "user@example.nl" "PASSWORD"

# Check if the user exists in Postfix maps / DB
sudo postmap -q "user@example.nl" mysql:/etc/postfix/mysql-virtual-mailbox-maps.cf

# If fail2ban banned the client, confirm and unban carefully:
sudo fail2ban-client status postfix-sasl
sudo fail2ban-client status dovecot

F) TLS handshake failures (SMTP/IMAP)

Symptom: clients fail STARTTLS or IMAPS handshake.

Check certificates with SNI:

openssl s_client -starttls smtp -connect smtp.example.nl:587 -servername smtp.example.nl </dev/null
openssl s_client -connect imap.example.nl:993 -servername imap.example.nl </dev/null

Common log signatures:

# Dovecot TLS handshake issue
dovecot: imap-login: Disconnected (no auth attempts): user=<>, rip=1.2.3.4, TLS: SSL_read() failed

# Postfix TLS negotiation problems
warning: TLS library problem: error:...:SSL routines:...
warning: TLS handshake failure

Actions:

# Verify full chain + SANs
openssl s_client -connect imap.example.nl:993 -servername imap.example.nl </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName

G) SpamAssassin lint errors / custom rules break parsing

Symptom: spamassassin --lint fails, or amavis logs show SA errors.

Check:

sudo spamassassin --lint
sudo journalctl -u amavis -b --no-pager -n 200
sudo grep -iE "spamassassin|sa-|lint|rules|config" /var/log/mail.log | tail -n 200

Common log signatures:

# Broken rule / unknown symbol
lint: 1 issues detected, please rerun with debug enabled for more information
config: failed to parse line, skipping: ...

Actions:

# Run lint debug once to locate the exact file/line
sudo spamassassin --lint -D 2>/tmp/sa-lint-debug.log || true
tail -n 200 /tmp/sa-lint-debug.log

H) Amavis content-filter issues (timeouts, blocked, not scanning)

Symptom: mail stuck in queue, deferred, or not being scanned.

Check:

sudo systemctl status amavis --no-pager
sudo journalctl -u amavis -b --no-pager -n 200
sudo grep -iE "amavis|content filter|timed out|timeout|deferred" /var/log/mail.log | tail -n 200

Common log signatures:

# Postfix cannot reach amavis or times out
warning: connect to transport amavis: Connection refused
warning: conversation with amavis timed out while sending ...

Actions:

sudo systemctl restart amavis
sudo systemctl restart postfix

I) SPF policyd issues (TempError / PermError / DNS issues)

Symptom: inbound mail gets unexpected SPF results or deferrals.

Check:

sudo grep -iE "policyd-spf|spf" /var/log/mail.log | tail -n 200
sudo systemctl status postfix-policyd-spf-python --no-pager || true

Common log signatures:

# SPF query failures
policyd-spf: ... result=TempError ...
policyd-spf: ... result=PermError ...

Actions:

# Validate DNS reachability from server and resolver behavior
dig +short TXT example.nl
dig +short MX example.nl

J) Mail loops, bounces, or queue growth

Symptom: queue grows rapidly or repeated bounces appear.

Check:

mailq
sudo postqueue -p
sudo grep -iE "bounce|deferred|status=deferred|status=bounced" /var/log/mail.log | tail -n 200

Common log signatures:

# Deferred due to remote policy / TLS failures
status=deferred (host ... said: 450 4.7.1 ...)

# Bounced due to local mapping errors
status=bounced (User unknown in virtual mailbox table)

Actions:

# Always identify the queue ID first, then inspect its log trail:
# (replace QUEUEID)
sudo grep -F "QUEUEID" /var/log/mail.log

K) Fail2ban: verify it bans only what you intend

Symptom: legitimate clients are banned, or bans never trigger.

Check jail status and recent bans:

sudo fail2ban-client status
sudo fail2ban-client status postfix
sudo fail2ban-client status postfix-sasl
sudo fail2ban-client status dovecot

Validate filters against real logs:

sudo fail2ban-regex /var/log/mail.log /etc/fail2ban/filter.d/postfix.conf
sudo fail2ban-regex /var/log/mail.log /etc/fail2ban/filter.d/postfix-sasl.conf
sudo fail2ban-regex /var/log/dovecot.log /etc/fail2ban/filter.d/dovecot.conf

Rule of thumb: only ban on clear auth failures or obvious protocol abuse. Avoid banning on transient TLS errors.

Mailserver sections

Operational notes

This build is intentionally strict (RFC aligned, authentication first). Keep outbound signing after content-filtering, and keep inbound checks early on port 25.

Resources

Checklist

Always check your logs for troubleshooting.