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.