Host your own mailserver with Dovecot, Postfix, Rspamd and PostgreSQL

Since around 2010 I have been running mail servers for myself and other organisations. In this post my notes on how I generally approach this are described. It shows how to set up a modern mail server capable of handling e-mail for multiple domains on Debian 12 “Bookworm”. It is also fully compatible with Debian 11 “Bullseye” using the following software packages:

It supports many modern e-mail standards such as:

  • SPF, DKIM and DMARC
  • DNSSEC
  • DANE SMTP server validation, both for receiving as well as sending e-mail.
  • MTA-STS, both for receiving as well as sending e-mail.
  • Secure and modern TLS ciphers. All user connections will use STARTTLS. TLS will be promoted in communications between other servers.
  • It sets up a global spam filter, which uses self-learning from e-mails in all mailboxes.
  • “Address tagging”, adding tags to existing e-mail addresses using the + sign. E.g.: youremailaddress+somewebsite@example.org or youremailaddress+newsletters@example.org. This allows you to easily define mailbox filters for different kinds of e-mail, for example, to automatically sort incoming newsletters into a newsletter folder instead of your inbox.

The setup also offers some convenient additional functionality such as:

  • Aliases (provided by Postfix)
    • With aliases, you can send e-mail from AND receive e-mail on multiple e-mail addresses which are linked to the same inbox.
    • This way, you can have multiple owners of one e-mail address.
  • Forwarding (provided by Dovecot Sieve)
    • With forwarding, you can only receive e-mail sent to one address on multiple e-mail addresses.
    • Dovecot Sieve allows you to set this up for all e-mails, but it can also restrict forwarding to e-mails which match certain characteristics, such as a specific “From” domain or e-mail address or with a specific e-mail subject.
  • Server-side filtering using the Sieve (RFC5228) standard, which can be managed using the ManageSieve protocol (RFC5804) in mail clients such as Roundcube. Forwarding to multiple e-mail addresses.
    • Apart from forwarding, Sieve also offers functionality such as automated archiving and flagging and sending notifications.
  • Send-only / no reply e-mail addresses, convenient to use with e.g. software which needs to be able to send transactional e-mail but which does not need to receive e-mail.

Prerequisites

This post assumes that you have already setup a basic Debian Bullseye install. It is recommended to use a separate VM, VPS or system to host the mail server on. It also assumes you have a static IP address available for which you can set the reverse DNS records.

Unbound DNS resolver

Install Unbound using these steps. It is also possible to point to another DNS resolver, but it will need to:

  • Support DNSSEC
  • Allow making a very large amount of queries

Doing this avoids using free services such as Cloudflare’s 1.1.1.1 or Googles 8.8.8.8 / 8.8.4.4. If you use these services, you will quickly hit their rate limits as Rspamd has to perform a lot of DNS queries to perform its analysis of messages.

Firewall settings

Allow connections on the following ports. When using IPv4 NAT, also forward connections to the following ports on the public IP to the mail server.

  • 25/TCP SMTP
  • 80/TCP HTTP
  • 443/TCP HTTPS
  • 443/UDP QUIC
  • 143/TCP IMAP
  • 993/TCP IMAPS
  • 587/TCP SMTP Submission
  • 4190/TCP Sieve

Opening port 993/TCP is strictly not needed, but still enabled to allow setting up mail accounts in Microsoft Outlook. Due to a bug in some versions of Outlook, this is otherwise impossible.

Install fail2ban

sudo apt install fail2ban

Install Nginx

sudo apt install curl gpg -y

curl -sS http://nginx.org/keys/nginx_signing.key | sudo apt-key add -

sudo tee -a /etc/apt/sources.list <<EOF

# Official nginx repositories
deb http://nginx.org/packages/debian/ bullseye nginx
#deb-src http://nginx.org/packages/debian/ bullseye nginx
EOF

sudo apt update
sudo apt install nginx -y

sudo sed -i "s/user  nginx;/user  www-data;/g" /etc/nginx/nginx.conf

sudo mkdir -p /var/www/acme

sudo nano /etc/nginx/conf.d/default.conf

Add to server {}

    listen       [::]:80;

and

    location /.well-known/acme-challenge/ {
                root /var/www/acme;
    }
sudo systemctl enable nginx
sudo systemctl restart nginx

sudo apt install apache2-utils

Letsencrypt TLS certificates

I’m currently writing an admin GUI for this setup in Go. Therefore, Lego will be used as the Letsencrypt client. This will make it easier to integrate things such as automatic certificate management, DNS updates and renewals.

Diffie-Hellman parameters

wget https://raw.githubusercontent.com/internetstandards/dhe_groups/master/ffdhe4096.pem
sudo mv ffdhe4096.pem /etc/ssl/

Lego

sudo apt install lego

sudo mkdir -p /etc/lego/

sudo chmod 600 /etc/lego/

Use lego --help for more information on available options.

If you want to use HTTP validation, use the following command for the main domain:

sudo lego -d mail.example.org -d imap.example.org -d smtp.example.org -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --http --http.webroot /var/www/acme --pem run
sudo lego -d rspamd.example.org -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --http --http.webroot /var/www/acme --pem run


sudo lego -d mta-sts.example.org -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --http --http.webroot /var/www/acme --pem run
sudo lego -d autoconfig.example.org -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --http --http.webroot /var/www/acme --pem run

Make sure to also request certificates for the mta-sts and autoconfig subdomain for other domains you use this mailserver for

sudo lego -d mta-sts.example.com -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --http --http.webroot /var/www/acme --pem run
sudo lego -d autoconfig.example.com -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --http --http.webroot /var/www/acme --pem run

Alternatively, for DNS validation, e.g., using Cloudflare.

sudo CLOUDFLARE_EMAIL=cloudflare@example.org CLOUDFLARE_API_KEY=REPLACE_WITH_YOUR_API_KEY lego -d mail.example.org -d imap.example.org -d smtp.example.org -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --dns cloudflare --pem run

sudo CLOUDFLARE_EMAIL=cloudflare@example.org CLOUDFLARE_API_KEY=REPLACE_WITH_YOUR_API_KEY lego -d rspamd.example.org -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --dns cloudflare --pem run

DANE TLSA records

sudo nano /usr/local/bin/lego-extract-tlsa.sh
#! /bin/sh
printf '_25._tcp.%s. IN TLSA 3 1 1 %s\n' mail.example.org $(openssl x509 -in /etc/lego/certificates/mail.example.org.crt  -noout -pubkey | openssl pkey -pubin -outform DER | openssl dgst -sha256 -binary |  hexdump -ve '/1 "%02x"')
exit
sudo chmod +x /usr/local/bin/lego-extract-tlsa.sh

sudo lego-extract-tlsa.sh

Add the output to your DNS settings. After doing this, verify this has been succesful by executing:

dig +dnssec +noall +answer +multi _25._tcp.mail.example.org. TLSA

Additionally, you may make use of the following online tools:

Set up renewals

sudo nano /usr/local/bin/lego-renew-hook-mail.sh
#! /bin/sh
/bin/systemctl restart dovecot nginx postfix

exit
sudo chmod +x /usr/local/bin/lego-renew-hook-mail.sh
sudo nano /usr/local/bin/lego-renew-hook.sh
#! /bin/sh
/bin/systemctl restart nginx

exit
sudo chmod +x /usr/local/bin/lego-renew-hook.sh
Option A: Install in crontab

This section assumes you have used HTTP validation in the previous steps. If you chose an alternative approach, alter these config settings as required.

sudo apt install cron -y

sudo crontab -e
## Mailserver domains mail.example.org, smtp.example.org, imap.example.org
@weekly /usr/local/bin/lego -d="mail.example.org" -d="imap.example.org" -d="smtp.example.org" -a -m="letsencrypt@example.org" -k="rsa4096" --path="/etc/lego/" --http --http.port 443 --http.webroot /var/www/acme --pem renew --reuse-key --renew-hook /usr/local/bin/lego-renew-hook-mail.sh

## Rspamd
@weekly /usr/local/bin/lego -d rspamd.example.org -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --http --http.port 443 --http.webroot /var/www/acme --pem renew --reuse-key --renew-hook /usr/local/bin/lego-renew-hook.sh

## MTA-STS
### Main domain
@weekly /usr/local/bin/lego -d mta-sts.example.org -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --http --http.port 443 --http.webroot /var/www/acme --pem renew --reuse-key --renew-hook /usr/local/bin/lego-renew-hook.sh
### Virtual domains
@weekly /usr/local/bin/lego -d mta-sts.example.com -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --http --http.port 443 --http.webroot /var/www/acme --pem renew --reuse-key --renew-hook /usr/local/bin/lego-renew-hook.sh

## Autoconfig
### Main domain
@weekly /usr/local/bin/lego -d autoconfig.example.org -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --http --http.port 443 --http.webroot /var/www/acme --pem renew --reuse-key --renew-hook /usr/local/bin/lego-renew-hook.sh

### Virtual domains
@weekly /usr/local/bin/lego -d autoconfig.example.com -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --http --http.port 443 --http.webroot /var/www/acme --pem renew --reuse-key --renew-hook /usr/local/bin/lego-renew-hook.sh

Option B: Use Systemd Timers
sudo nano /usr/local/bin/renew-certificates.sh
#!/bin/sh

## Mailserver domains mail.example.org, smtp.example.org, imap.example.org
/usr/local/bin/lego -d="mail.example.org" -d="imap.example.org" -d="smtp.example.org" -a -m="letsencrypt@example.org" -k="rsa4096" --path="/etc/lego/" --http --http.port 443 --http.webroot /var/www/acme --pem renew --reuse-key --renew-hook /usr/local/bin/lego-renew-hook-mail.sh

## Rspamd
/usr/local/bin/lego -d rspamd.example.org -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --http --http.port 443 --http.webroot /var/www/acme --pem renew --reuse-key --renew-hook /usr/local/bin/lego-renew-hook.sh

## MTA-STS
### Main domain
/usr/local/bin/lego -d mta-sts.example.org -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --http --http.port 443 --http.webroot /var/www/acme --pem renew --reuse-key --renew-hook /usr/local/bin/lego-renew-hook.sh
### Virtual domains
/usr/local/bin/lego -d mta-sts.example.com -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --http --http.port 443 --http.webroot /var/www/acme --pem renew --reuse-key --renew-hook /usr/local/bin/lego-renew-hook.sh

## Autoconfig
### Main domain
/usr/local/bin/lego -d autoconfig.example.org -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --http --http.port 443 --http.webroot /var/www/acme --pem renew --reuse-key --renew-hook /usr/local/bin/lego-renew-hook.sh

### Virtual domains
/usr/local/bin/lego -d autoconfig.example.com -a -m letsencrypt@example.org -k rsa4096 --path /etc/lego --http --http.port 443 --http.webroot /var/www/acme --pem renew --reuse-key --renew-hook /usr/local/bin/lego-renew-hook.sh
exit
sudo chmod +x /usr/local/bin/renew-certificates.sh 
sudo systemctl edit --full --force renew-certificates.service

```conf
[Unit]
Description=Renew Letsencrypt certificates

[Service]
Type=oneshot
ExecStart=/usr/local/bin/renew-certificates.sh
sudo systemctl edit --full --force renew-certificates.timer
[Unit]
Description=Weekly renewal of Letsencrypt certificates

[Timer]
OnCalendar=weekly
Persistent=true

[Install]
WantedBy=timers.target
sudo systemctl enable renew-certificates.timer --now

PostgreSQL Server

Perform the following commands on the server running your postgresql instance. In this example, PostgreSQL runs on host 10.10.10.1. You may opt to run PostgreSQL locally by executing sudo apt install postgresql -y.

On the PostgreSQL host, execute:

sudo -u postgres psql
CREATE DATABASE vmail
    ENCODING 'utf8';

CREATE USER vmail WITH password '____REPLACE_WITH_A_PASSWORD____';
GRANT ALL privileges ON DATABASE vmail TO vmail;

\q

OR

sudo -i -u postgres psql -c "CREATE DATABASE vmail ENCODING 'utf8';"
sudo -i -u postgres psql -c "CREATE USER vmail WITH password '____REPLACE_WITH_A_PASSWORD____';"
sudo -i -u postgres psql -c "GRANT ALL privileges ON DATABASE vmail TO vmail;"

On the mail server, execute:

sudo apt install -y postgresql-client

psql -h 10.10.10.1 -d vmail -U vmail -W
CREATE TABLE domains (
    id serial PRIMARY KEY,
    domain varchar(255) UNIQUE NOT NULL
);

CREATE TABLE accounts (
    id serial PRIMARY KEY,
    username varchar(64) NOT NULL,
    domain varchar(255) NOT NULL,
    password varchar(255) NOT NULL,
    quota numeric CHECK(quota >= 0) DEFAULT '0',
    enabled boolean DEFAULT '0',
    sendonly boolean DEFAULT '0',
    UNIQUE (username, domain),
    FOREIGN KEY (domain) REFERENCES domains (domain)
);

CREATE TABLE aliases (
    id serial PRIMARY KEY,
    source_username varchar(64),
    source_domain varchar(255) NOT NULL,
    destination_username varchar(64) NOT NULL,
    destination_domain varchar(255) NOT NULL,
    enabled boolean DEFAULT '0',
    UNIQUE (source_username, source_domain, destination_username, destination_domain),
    FOREIGN KEY (source_domain) REFERENCES domains (domain)
);

CREATE TYPE policy AS ENUM ('none', 'may', 'encrypt', 'dane', 'dane-only', 'fingerprint', 'verify', 'secure');
CREATE TABLE tlspolicies (
    id serial PRIMARY KEY,
    domain varchar(255) UNIQUE NOT NULL,
    policy policy NOT NULL,
    params varchar(255)
);

\q

Add vmail user

sudo mkdir /var/vmail
sudo addgroup --gid 5000 vmail
sudo adduser --disabled-login --system --disabled-password --home /var/vmail --gecos "Virtual Mail User" --uid 5000 --gid 5000 --shell /usr/sbin/nologin vmail
sudo mkdir /var/vmail/mailboxes
sudo mkdir -p /var/vmail/sieve/global

sudo chown -R vmail /var/vmail
sudo chgrp -R vmail /var/vmail
sudo chmod -R 770 /var/vmail

Install Dovecot

sudo apt install swaks
sudo apt install dovecot-core dovecot-imapd dovecot-lmtpd dovecot-pgsql dovecot-sieve dovecot-managesieved

Configure Dovecot

sudo mkdir /etc/dovecot.old
sudo mv /etc/dovecot/* /etc/dovecot.old/
cd /etc/dovecot

sudo nano /etc/dovecot/dovecot.conf
# Protocols
protocols = imap lmtp sieve

# Plugins
mail_plugins = 

# TLS configuration
# 
# generated 2022-01-11, Mozilla Guideline v5.6, Dovecot 2.3.9, OpenSSL 1.1.1d, intermediate configuration
# https://ssl-config.mozilla.org/#server=dovecot&version=2.3.9&config=intermediate&openssl=1.1.1d&guideline=5.6
ssl = required

ssl_cert = </etc/lego/certificates/mail.example.org.crt
ssl_key = </etc/lego/certificates/mail.example.org.key

ssl_dh = </etc/ssl/ffdhe4096.pem

ssl_min_protocol = TLSv1.2
ssl_cipher_list = ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
ssl_prefer_server_ciphers = no

# Dovecot services
service imap-login {
    inet_listener imap {
        port = 143
    }
    inet_listener imaps {
        port = 993
    }
}

service managesieve-login {
    inet_listener sieve {
        port = 4190
    }
}

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

    user = vmail
}

service auth {
    ## Auth socket for Postfix
    unix_listener /var/spool/postfix/private/auth {
        mode = 0660
        user = postfix
        group = postfix
    }

    ## Auth socket for LMTP service
    unix_listener auth-userdb {
        mode = 0660
        user = vmail
        group = vmail
    }
}

service stats {
    fifo_listener stats-mail {
        user = vmail
        mode = 0644
    }

    inet_listener {
        address = 127.0.0.1
        port = 24242
    }
}

# Protocol settings
protocol imap {
    mail_plugins = $mail_plugins quota imap_quota imap_sieve
    mail_max_userip_connections = 20
    imap_idle_notify_interval = 29 mins
}

protocol lmtp {
    postmaster_address = postmaster@example.org
    mail_plugins = $mail_plugins sieve notify push_notification
}

# Client authentication
disable_plaintext_auth = yes
auth_mechanisms = plain login
auth_username_format = %Lu

passdb {
    driver = sql
    args = /etc/dovecot/dovecot-sql.conf
}

userdb {
    driver = sql
    args = /etc/dovecot/dovecot-sql.conf
}

# Delimiter for "address tagging"
recipient_delimiter = +

# Mail location
mail_uid = vmail
mail_gid = vmail
mail_privileged_group = vmail

mail_home = /var/vmail/mailboxes/%d/%n
mail_location = maildir:~/mail:LAYOUT=fs

# Mailbox configuration
namespace inbox {
    inbox = yes

    mailbox Spam {
        auto = subscribe
        special_use = \Junk
    }

    mailbox Trash {
        auto = subscribe
        special_use = \Trash
    }

    mailbox Drafts {
        auto = subscribe
        special_use = \Drafts
    }

    mailbox Sent {
        auto = subscribe
        special_use = \Sent
    }
}

# Mail plugins
plugin {
    sieve_plugins = sieve_imapsieve sieve_extprograms
    sieve_before = /var/vmail/sieve/global/spam-global.sieve
    sieve = file:/var/vmail/sieve/%d/%n/scripts;active=/var/vmail/sieve/%d/%n/active-script.sieve

    ## Spam learning
    
    ### From elsewhere to Spam folder
    imapsieve_mailbox1_name = Spam
    imapsieve_mailbox1_causes = COPY
    imapsieve_mailbox1_before = file:/var/vmail/sieve/global/learn-spam.sieve

    ### From Spam folder to elsewhere
    imapsieve_mailbox2_name = *
    imapsieve_mailbox2_from = Spam
    imapsieve_mailbox2_causes = COPY
    imapsieve_mailbox2_before = file:/var/vmail/sieve/global/learn-ham.sieve

    sieve_pipe_bin_dir = /usr/local/bin/dovecot/sieve
    sieve_global_extensions = +vnd.dovecot.pipe

    quota = maildir:User quota
    quota_exceeded_message = User %u has exhausted allowed storage space.

    ## Stats
    
    ### how often to session statistics (must be set)
    stats_refresh = 30 secs
    ### track per-IMAP command statistics (optional)
    stats_track_cmds = yes
}
sudo nano /etc/dovecot/dovecot-sql.conf

In this config example, alter the connect parameters to match your setup.

driver=pgsql
connect = "host=10.10.10.1 dbname=vmail user=vmail password=____PASSWORD____"
default_pass_scheme = ARGON2ID

password_query = SELECT username AS user, domain, password FROM accounts WHERE username = '%Ln' AND domain = '%Ld' and enabled = true;
user_query = SELECT CONCAT('*:storage=', quota, 'M') AS quota_rule FROM accounts WHERE username = '%Ln' AND domain = '%Ld' AND sendonly = false;

# For using doveadm -A
iterate_query = SELECT username, domain FROM accounts WHERE sendonly = false;
sudo chmod 440 /etc/dovecot/dovecot-sql.conf

Global spam filter

sudo mkdir -p /usr/local/bin/dovecot/sieve
sudo nano /usr/local/bin/dovecot/sieve/rspamd-learn-spam.sh
#!/bin/sh
exec /usr/bin/rspamc -h /run/rspamd/worker-controller.socket learn_spam
sudo nano /usr/local/bin/dovecot/sieve/rspamd-learn-ham.sh
#!/bin/sh
exec /usr/bin/rspamc -h /run/rspamd/worker-controller.socket learn_ham
sudo chmod +x /usr/local/bin/dovecot/sieve/rspamd-learn-ham.sh
sudo chmod +x /usr/local/bin/dovecot/sieve/rspamd-learn-spam.sh
sudo nano /var/vmail/sieve/global/spam-global.sieve
require "fileinto";

if header :contains "X-Spam-Flag" "YES" {
    fileinto "Spam";
}

if header :is "X-Spam" "Yes" {
    fileinto "Spam";
}
sudo nano /var/vmail/sieve/global/learn-spam.sieve
require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"];

if environment :matches "imap.user" "*" {
  set "username" "${1}";
}

pipe :copy "rspamd-learn-spam.sh" [ "${username}" ];
sudo nano /var/vmail/sieve/global/learn-ham.sieve
require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"];

if environment :matches "imap.mailbox" "*" {
    set "mailbox" "${1}";
}

if string "${mailbox}" "Trash" {
    stop;
}

if environment :matches "imap.user" "*" {
  set "username" "${1}";
}

pipe :copy "rspamd-learn-ham.sh" [ "${username}" ];

Install Postfix

sudo apt install postfix postfix-pgsql rsyslog postfix-mta-sts-resolver

When prompted, about the Postfix configuration, choose “no configuration”.

Configure Postfix

sudo systemctl stop postfix
sudo nano /etc/postfix/main.cf
# Network settings
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
inet_interfaces = all
inet_protocols = all
myhostname = mail.example.org

## If you run a Postfix server behind a proxy or NAT, you need to configure the proxy_interfaces parameter and specify all the external proxy or NAT addresses that Postfix receives mail on. 
## In other words, if you use port forwarding from public IP addresses to a private IP address, enter those public IP addresses below.
proxy_interfaces = 

# Mail queue settings
maximal_queue_lifetime = 1h
bounce_queue_lifetime = 1h
maximal_backoff_time = 15m
minimal_backoff_time = 5m
queue_run_delay = 5m

# TLS settings
## Do not allow compression or client initiated renegotiation
tls_ssl_options = NO_COMPRESSION NO_RENEGOTIATION

## Enforce server enforced cipher preference
tls_preempt_cipherlist = yes

# Outbound SMTP connections (Postfix as sender / client)
smtp_tls_security_level = dane
smtp_dns_support_level = dnssec
## With this smtp_tls_policy_maps setting, Postfix first checks for policies in the database. If these are not available, the mta-sts policy resolver is contacted. 
smtp_tls_policy_maps = pgsql:/etc/postfix/sql/tls-policy.cf, socketmap:inet:127.0.0.1:8461:postfix
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtp_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1
smtp_tls_ciphers = high
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt

# Inbound SMTP connections (Postfix as receiver / server)
smtpd_tls_security_level = may
smtpd_tls_auth_only = yes
smtpd_tls_mandatory_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1
smtpd_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1
smtpd_tls_ciphers = high
smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_scache
smtpd_tls_exclude_ciphers = aNULL, eNULL, EXPORT, DES, RC4, MD5, PSK, aECDH, EDH-DSS-DES-CBC3-SHA, EDH-RSA-DES-CBC3-SHA, KRB5-DES, CBC3-SHA
smtpd_tls_mandatory_exclude_ciphers = aNULL, eNULL, EXPORT, DES, RC4, MD5, PSK, aECDH, EDH-DSS-DES-CBC3-SHA, EDH-RSA-DES-CBC3-SHA, KRB5-DES, CBC3-SHA
smtpd_tls_dh1024_param_file = /etc/ssl/ffdhe4096.pem
smtpd_tls_cert_file = /etc/lego/certificates/mail.example.org.crt
smtpd_tls_key_file = /etc/lego/certificates/mail.example.org.key

# Local mail delivery to Dovecot via LMTP
virtual_transport = lmtp:unix:private/dovecot-lmtp

# Spam filter and DKIM signatures via Rspamd
smtpd_milters = inet:localhost:11332
non_smtpd_milters = inet:localhost:11332
milter_protocol = 6
milter_mail_macros =  i {mail_addr} {client_addr} {client_name} {auth_authen}
milter_default_action = accept

# Server Restrictions for clients, recipients and relaying
## (concerning S2S-connections. Mailclient-connections are configured in submission-section in master.cf)

## Conditions in which Postfix works as a relay. (for mail user clients)
smtpd_relay_restrictions =      reject_non_fqdn_recipient
                                reject_unknown_recipient_domain
                                permit_mynetworks
                                reject_unauth_destination

## Conditions in which Postfix accepts e-mails as recipient (additional to relay conditions)
## check_recipient_access checks if an account is "sendonly" and rejects mails if this is the case, unless the sendonly recipient is also in aliases.
smtpd_recipient_restrictions = check_recipient_access pgsql:/etc/postfix/sql/recipient-access.cf

## Restrictions for all sending foreign servers ("SMTP clients")
smtpd_client_restrictions =     permit_mynetworks
                                check_client_access hash:/etc/postfix/without_ptr
                                reject_unknown_client_hostname

## Foreign mail servers must present a valid "HELO"
smtpd_helo_required = yes
smtpd_helo_restrictions =   permit_mynetworks
                            reject_invalid_helo_hostname
                            reject_non_fqdn_helo_hostname
                            reject_unknown_helo_hostname

## Block clients, which start sending too early
smtpd_data_restrictions = reject_unauth_pipelining

## Restrictions for MUAs (Mail user agents)
mua_relay_restrictions = reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_mynetworks,permit_sasl_authenticated,reject
mua_sender_restrictions = permit_mynetworks,reject_non_fqdn_sender,reject_sender_login_mismatch,permit_sasl_authenticated,reject
mua_client_restrictions = permit_mynetworks,permit_sasl_authenticated,reject

# Postscreen Filter

## Postscreen Whitelist / Blocklist
postscreen_access_list =        permit_mynetworks
                                cidr:/etc/postfix/postscreen_access
postscreen_blacklist_action = drop

## Drop connections if other server is sending too quickly
postscreen_greet_action = drop

## DNS blocklists
postscreen_dnsbl_threshold = 2
postscreen_dnsbl_sites =    ix.dnsbl.manitu.net*2
                            zen.spamhaus.org*2
postscreen_dnsbl_action = drop

# PostgreSQL queries
virtual_alias_maps = pgsql:/etc/postfix/sql/aliases.cf
virtual_mailbox_maps = pgsql:/etc/postfix/sql/accounts.cf
virtual_mailbox_domains = pgsql:/etc/postfix/sql/domains.cf
local_recipient_maps = $virtual_mailbox_maps

# Miscellaneous

## Maximum mailbox size (0=unlimited - is already limited by Dovecot quota)
mailbox_size_limit = 0

## Maximum size of inbound e-mails (50 MB)
message_size_limit = 52428800

## Do not notify system users on new e-mail
biff = no

## Users always have to provide full e-mail addresses
append_dot_mydomain = no

## Delimiter for "address tagging"
recipient_delimiter = +

## Disable the VRFY command to prevent leaking valid addresses to scanners
disable_vrfy_command = yes

## Disable backwards compatible default settings
### (http://www.postfix.org/COMPATIBILITY_README.html)
compatibility_level = 2

Make sure to customise myhostname, smtpd_tls_cert_file, smtpd_tls_key_file and proxy_interfaces (if necessary) in main.cf.

sudo nano /etc/postfix/master.cf
# ==========================================================================
# service type  private unpriv  chroot  wakeup  maxproc command + args
#               (yes)   (yes)   (no)    (never) (100)
# ==========================================================================
smtp      inet  n       -       y       -       1       postscreen
    -o smtpd_sasl_auth_enable=no
smtpd     pass  -       -       y       -       -       smtpd
dnsblog   unix  -       -       y       -       0       dnsblog
tlsproxy  unix  -       -       y       -       0       tlsproxy
submission inet n       -       y       -       -       smtpd
    -o syslog_name=postfix/submission
    -o smtpd_tls_security_level=encrypt
    -o smtpd_sasl_auth_enable=yes
    -o smtpd_sasl_type=dovecot
    -o smtpd_sasl_path=private/auth
    -o smtpd_sasl_security_options=noanonymous
    -o smtpd_client_restrictions=$mua_client_restrictions
    -o smtpd_sender_restrictions=$mua_sender_restrictions
    -o smtpd_relay_restrictions=$mua_relay_restrictions
    -o milter_macro_daemon_name=ORIGINATING
    -o smtpd_sender_login_maps=pgsql:/etc/postfix/sql/sender-login-maps.cf
    -o smtpd_helo_required=no
    -o smtpd_helo_restrictions=
    -o cleanup_service_name=submission-header-cleanup
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
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
submission-header-cleanup unix n - n    -       0       cleanup
    -o header_checks=regexp:/etc/postfix/submission_header_cleanup
sudo nano /etc/postfix/submission_header_cleanup

In order to improve sender privacy, we tell Postfix to clean up the headers which some Mail User Agents (MUAs) such as Mozilla Thunderbird, Apple Mail.app or Microsoft Outlook include from messages that are sent through this server. It is not necessary for a recipient of our messages to know which software vendors and versions are being used to compose and send messages.

### Removes headers of MUAs for privacy reasons

/^Received:/            IGNORE
/^X-Originating-IP:/    IGNORE
/^X-Mailer:/            IGNORE
/^User-Agent:/          IGNORE
sudo mkdir /etc/postfix/sql
cd /etc/postfix/sql/

In the following config examples, alter the database login parameters to match your setup.

sudo nano accounts.cf
user = vmail
password = ____PASSWORD____
hosts = 10.10.10.1
dbname = vmail
query = SELECT 1 AS found FROM accounts WHERE username = '%u' AND domain = '%d' AND enabled = true LIMIT 1;
sudo nano aliases.cf
user = vmail
password = ____PASSWORD____
hosts = 10.10.10.1
dbname = vmail
query = SELECT CONCAT(destination_username, '@', destination_domain) AS destinations FROM aliases WHERE source_username = '%u' AND source_domain = '%d' AND enabled = true;
sudo nano domains.cf
user = vmail
password = ____PASSWORD____
hosts = 10.10.10.1
dbname = vmail
query = SELECT domain FROM domains WHERE domain='%s';
sudo nano recipient-access.cf
user = vmail
password = ____PASSWORD____
hosts = 10.10.10.1
dbname = vmail
# Without aliases exception:
# query = SELECT CASE WHEN (sendonly = true) THEN 'REJECT' ELSE 'OK' END AS access FROM accounts WHERE username = '%u' AND domain = '%d' AND enabled = true LIMIT 1;
# With aliases exception
query = SELECT CASE WHEN (accounts.sendonly = true AND NOT EXISTS (SELECT FROM aliases WHERE aliases.source_username = '%u' AND aliases.source_domain = '%d' AND aliases.enabled = true)) THEN 'REJECT' ELSE 'OK' END AS access FROM accounts WHERE accounts.username = '%u' AND accounts.domain = '%d' AND accounts.enabled = true LIMIT 1;
sudo nano sender-login-maps.cf
user = vmail
password = ____PASSWORD____
hosts = 10.10.10.1
dbname = vmail
query = SELECT CONCAT(username, '@', domain) AS owns FROM accounts WHERE username = '%u' AND domain = '%d' AND enabled = true UNION SELECT CONCAT(destination_username, '@', destination_domain) AS owns FROM aliases WHERE source_username = '%u' AND source_domain = '%d' AND enabled = true;
sudo nano tls-policy.cf
user = vmail
password = ____PASSWORD____
hosts = 10.10.10.1
dbname = vmail
query = SELECT policy, params FROM tlspolicies WHERE domain = '%s';
sudo chmod -R 640 /etc/postfix/sql

cd
sudo touch /etc/postfix/without_ptr
sudo touch /etc/postfix/postscreen_access
sudo postmap /etc/postfix/without_ptr

Install Rspamd & ClamAV

Rspamd

sudo apt install -y lsb-release wget
wget -O- https://rspamd.com/apt-stable/gpg.key | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/rspamd.gpg >> /dev/null

sudo tee -a /etc/apt/sources.list <<EOF >> /dev/null

# Rspamd repository
deb http://rspamd.com/apt-stable/ $(lsb_release -c -s) main
#deb-src http://rspamd.com/apt-stable/ $(lsb_release -c -s) main
EOF

sudo apt update
sudo apt install rspamd

sudo systemctl stop rspamd

Redis

Redis is required as a storage for non-volatile data for Rspamd.

sudo apt install redis-server -y
sudo nano /etc/redis/redis.conf

We configure it to limit its maximum memory use and to not listen on TCP, instead we opt for a UNIX socket for increased performance by altering the following settings:

port 0
unixsocket /run/redis/redis-server.sock
unixsocketperm 770
maxmemory 500mb

Add the _rspamd user to the redis group to allow writing and reading from the redis unix socket.

sudo usermod -a -G redis _rspamd
sudo systemctl restart redis

Configure Rspamd

Run the configuration wizard

sudo rspamadm configwizard

Answer the questions as follows

  • Controller password is not set, do you want to set one?[Y/n]: y
  • Do you wish to set Redis servers?[Y/n]: y
  • Input read only servers separated by , [default: localhost]: /run/redis/redis-server.sock
  • Input write only servers separated by , [default: localhost]: /run/redis/redis-server.sock
  • Do you have any password set for your Redis?[y/N]: n
  • Do you have any specific database for your Redis?[y/N]: n
  • Do you want to setup dkim signing feature?[y/N]: y
  • How would you like to set up DKIM signing?
    1. Use domain from mime from header for sign
    2. Use domain from SMTP envelope from for sign
    3. Use domain from authenticated user for sign
    4. Sign all mail from specific networks
  • Enter your choice (1, 2, 3, 4) [default: 1]: 1
  • Do you want to sign mail from authenticated users? [Y/n]: y
  • Allow data mismatch, e.g. if mime from domain is not equal to authenticated user domain? [Y/n]: y
  • Do you want to use effective domain (e.g. example.com instead of foo.example.com)? [Y/n]: y
  • Enter output directory for the keys [default: /var/lib/rspamd/dkim/]: /var/lib/rspamd/dkim
  • Enter domain to sign: example.org
  • Enter selector [default: dkim]: 2021
  • Do you want to create privkey /var/lib/rspamd/dkim/example.org.2020.key[Y/n]: y
  • Do you wish to add another DKIM domain?[y/N]: choose applicable option

NB: Generate a DKIM key for every domain that is to be used with this mail server. This can also be done later, e.g., after setting up or when adding new domains. Do not create keys for subdomains. As described in the Rspamd Documentation: “The default global config (fallback mode) searches for keys at the defined path. The path is constructed using the eSLD normalized domain name of header from and the default selector defined with selector (dkim). So the search path for user@test.example.com would be /var/lib/rspamd/dkim/example.com.dkim.key. If a key is found the message will be signed.”

Enable ARC signatures

The configuration of the ARC module is very similar to the DKIM module, so we will make them share the same config file.

sudo ln -s /etc/rspamd/local.d/dkim_signing.conf /etc/rspamd/local.d/arc.conf

Rspamd logging

The following override can be used to debug specific Rspamd modules.

sudo nano /etc/rspamd/local.d/logging.inc
# Enable debug for specific modules (e.g. `debug_modules = ["dkim", "re_cache"];`)
debug_modules = [];
EOF

Spam headers

sudo tee -a /etc/rspamd/local.d/milter_headers.conf <<EOF >> /dev/null
use = ["x-spamd-bar", "x-spam-level", "authentication-results"];
authenticated_headers = ["authentication-results"];
extended_spam_headers = true;
EOF

Rspamd Clamav Antivirus integration

sudo tee /etc/rspamd/local.d/antivirus.conf <<EOF >> /dev/null
clamav {
  symbol = "CLAM_VIRUS";
  type = "clamav";
  action = "reject";
  servers = "av.example.org:3310";
  patterns {
    JUST_EICAR = "^Eicar-Test-Signature$";
  }
  whitelist = "/etc/rspamd/antivirus.wl";
}
EOF

Rspamd Bayes classifier

sudo tee /etc/rspamd/local.d/classifier-bayes.conf <<EOF >> /dev/null
backend = "redis";
autolearn = true;
EOF

Make the Rspamd web interface listen on a UNIX socket rather than a TCP socket

sudo tee -a /etc/rspamd/local.d/worker-controller.inc <<EOF >> /dev/null
bind_socket = "/run/rspamd/worker-controller.socket mode=0666 owner=_rspamd";
EOF

Enable Rspamd phishing protection

OpenPhish and PhishTank track known phishing URLs and publish these on free feeds. By enabling the Rspamd phishing module, these feeds are downloaded and used to filter out phishing attempts.

sudo nano  /etc/rspamd/local.d/phishing.conf
phishing {
        # Enable openphish support (default disabled)
        openphish_enabled = true;
        # URL of feed, default is public url:
        openphish_map = "https://www.openphish.com/feed.txt";
        # For premium feed, change that to your personal URL, e.g.
        # openphish_map = "https://openphish.com/samples/premium_feed.json";

        # Change this to true if premium feed is enabled
        openphish_premium = false;
}

phishtank_enabled = true

Enable Pyzor integration

Pyzor is a bulk email scanner similar to Razor2 and DCC, which doesn’t identify spam but instead identifies how often a message (hash) has been seen or how “bulky” a message is.

sudo apt install pyzor
sudo nano /etc/rspamd/local.d/external_services.conf
pyzor {
  # default pyzor settings
  servers = "localhost:5953"
}
sudo systemctl edit --force --full pyzor.socket
[Unit]
Description=Pyzor socket

[Socket]
ListenStream=127.0.0.1:5953
ListenStream=[::1]:5953
Accept=yes

[Install]
WantedBy=sockets.target
sudo systemctl edit --force --full pyzor@.service
[Unit]
Description=Pyzor Socket Service
Requires=pyzor.socket

[Service]
Type=simple
ExecStart=-/usr/bin/pyzor check
StandardInput=socket
StandardError=journal
TimeoutStopSec=10

User=_rspamd
NoNewPrivileges=true
PrivateDevices=true
PrivateTmp=true
PrivateUsers=true
ProtectControlGroups=true
ProtectHome=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectSystem=strict

[Install]
WantedBy=multi-user.target
sudo -u _rspamd mkdir /var/lib/rspamd/.pyzor

sudo systemctl enable pyzor.socket --now

Enable Razor integration

Razor is, like Pyzor and DCC, a bulk email scanner that doesn’t detect spam but how often a message (hash) has been seen, or how “bulky” a message is.

sudo nano /etc/rspamd/local.d/external_services.conf
razor {
  # default razor settings
  servers = "localhost:11342"
}
sudo systemctl edit --force --full razor.socket
[Unit]
Description=Razor socket

[Socket]
ListenStream=127.0.0.1:11342
ListenStream=[::1]:11342

Accept=yes

[Install]
WantedBy=sockets.target
sudo systemctl edit --force --full razor@.service
[Unit]
Description=Razor Socket Service
Requires=razor.socket

[Service]
Type=simple
ExecStart=/bin/sh -c '/usr/bin/razor-check && /usr/bin/echo -n "spam" || /usr/bin/echo -n "ham"'
StandardInput=socket
StandardError=journal
TimeoutStopSec=10

User=_rspamd
NoNewPrivileges=true
PrivateDevices=true
PrivateTmp=true
PrivateUsers=true
ProtectControlGroups=true
ProtectHome=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectSystem=strict

[Install]
WantedBy=multi-user.target
sudo systemctl enable razor.socket --now

Send DMARC reports to senders

sudo nano /etc/rspamd/local.d/dmarc.conf
  reporting {
    # Required attributes
    enabled = true; # Enable reports in general
    email = 'dmarc-reports@example.org'; # Source of DMARC reports
    domain = 'example.org'; # Domain to serve
    org_name = 'Example'; # Organisation
    # Optional parameters
#    bcc_addrs = ["postmaster@example.com"]; # additional addresses to copy on reports
#    report_local_controller = false; # Store reports for local/controller scans (for testing only)
    helo = 'rspamd.localhost'; # Helo used in SMTP dialog
    smtp = '127.0.0.1'; # SMTP server IP
    smtp_port = 25; # SMTP server port
    from_name = 'Rspamd'; # SMTP FROM
    msgid_from = 'rspamd'; # Msgid format
    max_entries = 1k; # Maximum amount of entries per domain
    keys_expire = 2d; # Expire date for Redis keys
    #only_domains = '/path/to/map'; # Only store reports from domains or eSLDs listed in this map
    # Available from 3.3
    #exclude_domains = '/path/to/map'; # Exclude reports from domains or eSLDs listed in this map
    #exclude_domains = ["example.com", "another.com"]; # Alternative, use array to exclude reports from domains or eSLDs
    # Available from 3.8
    #exclude_recipients = '/path/to/map'; # Exclude reports for recipients listed in this map
    #exclude_recipients = ["a@example.com", "b@another.com"]; # Alternative, use array to exclude reports for recipients
  }
sudo systemctl edit --force --full rspamd-dmarc-report.service
[Unit]
Description=Send DMARC Reports with Rspamd
Requires=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=_rspamd
Group=_rspamd
WorkingDirectory=/var/lib/rspamd
ExecStart=/bin/sh -c '/usr/bin/rspamadm dmarc_report -v $$(date -u -d "yesterday" "+%Y-%m-%d")'

# Security directives
NoNewPrivileges=true
PrivateDevices=true
PrivateTmp=true
PrivateUsers=true
ProtectControlGroups=true
ProtectHome=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectSystem=strict

[Install]
WantedBy=multi-user.target
sudo systemctl edit --full --force rspamd-dmarc-report.timer
[Unit]
Description=Timer for Daily DMARC Reports with Rspamd

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target

The Persistent=true option ensures that the timer will catch up if it missed any runs while the system was down.

sudo systemctl enable rspamd-dmarc-report.timer --now

Restart Rspamd

sudo systemctl restart rspamd

Configure Nginx as Rspamd reverse proxy

printf REPLACE_WITH_YOUR_RSPAMD_PASSWORD | sudo htpasswd -ic /etc/nginx/rspamd.htpasswd management

sudo nano /etc/nginx/conf.d/rspamd.conf
server {
    listen 80;
    listen [::]:80;
    server_name rspamd.example.org;

    location / {
        return 301 https://$server_name$request_uri;
    }
    location /.well-known/acme-challenge/ {
                root /var/www/acme;
    }
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;

    server_name rspamd.example.org;

    ssl_certificate /etc/lego/certificates/rspamd.example.org.crt;
    ssl_certificate_key /etc/lego/certificates/rspamd.example.org.key;
    ssl_dhparam /etc/ssl/ffdhe4096.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_ciphers "ECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";
    ssl_ecdh_curve secp384r1;
    ssl_session_cache shared:SSL:10m;
    ssl_session_tickets off;
    resolver 10.10.10.1 [2001:db8::2] valid=300s;
    resolver_timeout 5s;
    ssl_stapling on;
    ssl_stapling_verify on;
    add_header X-Content-Type-Options nosniff;
    add_header Strict-Transport-Security "max-age=63072000; preload";
    keepalive_timeout 300s;

    location /.well-known/acme-challenge/ {
                root /var/www/acme;
    }

    # Rspamd
    location / {
        auth_basic "Restricted Area";
        auth_basic_user_file /etc/nginx/rspamd.htpasswd;
        proxy_pass http://unix:/run/rspamd/worker-controller.socket:/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Optional: generate extra DKIM keys

sudo rspamadm dkim_keygen -d example.org -s 2023

Generate a DKIM key for every domain that is to be used with this mail server. Do not create keys for subdomains. As described in the Rspamd Documentation: “The default global config (fallback mode) searches for keys at the defined path. The path is constructed using the eSLD normalized domain name (-d) of header from and the default selector (-s) defined with selector (dkim). So the search path for user@test.example.com would be /var/lib/rspamd/dkim/example.com.dkim.key. If a key is found the message will be signed.”

Afterwards, ensure the filesystem permissions are still in good order

sudo chown -R _rspamd:_rspamd /var/lib/rspamd

Then, add each domain to /etc/rspamd/local.d/dkim_signing.conf

sudo nano /etc/rspamd/local.d/dkim_signing.conf
domain {
    example.org {
        path = "/var/lib/rspamd/dkim/example.org.2023.key";
        selector = "2023";
    }
    example.com {
        path = "/var/lib/rspamd/dkim/example.com.2019.key";
        selector = "2019";
    }
}

Alternatively, to avoid this hassle, use the same DKIM key for every domain on the server.

sudo rspamadm dkim_keygen -b 2048 -s 2023 -k /var/lib/rspamd/dkim/2023.key > /var/lib/rspamd/dkim/2023.key.pub

sudo chown -R _rspamd:_rspamd /var/lib/rspamd

sudo nano /etc/rspamd/local.d/dkim_signing.conf
path = "/var/lib/rspamd/dkim/$selector.key";
selector = "2023";
sudo systemctl restart rspamd

Set DNS records

Set the DKIM record in DNS

Add the DKIM record generated by Rspamd to the DNS records of your domain. To review the public DKIM keys that need to be added, execute

sudo ls /var/lib/rspamd/dkim
sudo cat /var/lib/rspamd/dkim/example.org.2020.key.pub

Set the other required DNS records as well

For the mail server

  • A and AAAA records
    • mail.example.org
    • imap.example.org
    • smtp.example.org
    • autoconfig.example.org
    • rspamd.example.org
    • mta-sts.example.org
  • Reverse DNS / PTR records
    • 2001:db8::5 mail.example.org
    • 203.0.113.205 mail.example.org
  • Main _mta-sts TXT record
    • format: v=STSv1; id=202201111500Z where id is the date on which you last changed the MTA-STS policy as YYYYMMDDHHMMTZ (year, month, day, hour, minute, timezone)
    • More details can be found in the MTA-STS section
  • TLSA records

For every domain which uses this mail server

  • CNAME records
    • autoconfig.example.com –> autoconfig.example.org
    • _mta-sts.example.com. IN CNAME _mta-sts.example.org.
    • mta-sts.example.com IN CNAME mta-sts.example.org.
  • MX records
    • example.org mx 0 mail.example.org
  • TXT records
    • SPF records
      • Some resources recommend a neutral record ?all, because the concept of SPF is flawed. The Dutch Internet Standards Platform recommends against this, referencing official NCSC documentation and encourages a softfail ~all or hardfail -all record. I personally chose compromise between the two opinions and use softfail.
      • v=spf1 mx ~all
    • DKIM record
      • Get your records from sudo cat /var/lib/rspamd/dkim/keyname.txt, where keyname is the name you chose while configuring Rspamd
    • DMARC records
      • _dmarc TXT v=DMARC1; p=reject; rua=mailto:dmarc-reports@example.org; ruf=mailto:dmarc-reports@example.org; fo=1;
      • First set the DMARC policy as p=none for testing purposes. After verifying for a while that this does not pose problems, change the DMARC policy to p=reject.
    • TLSRPT record
      • _smtp._tls TXT v=TLSRPTv1; rua=mailto:tlsrpt@example.com
  • SRV records. These are used by some e-mail clients to automatically configure the mail server settings when adding a new account.
    • _imap._tcp.example.org 0 1 143 mail.example.org
    • _submission._tcp.example.org 0 1 587 mail.example.org
    • _sieve._tcp.example.org 0 1 4190 mail.example.org

Setup domains, accounts & aliases

Generate password hashes for accounts

sudo doveadm pw -s ARGON2ID

Add domains, accounts & aliases to the database

sudo -u postgres psql
\c vmail;

INSERT INTO domains (domain) VALUES ('example.org');

INSERT INTO accounts (username, domain, password, quota, enabled, sendonly) VALUES ('it', 'example.org', '{ARGON2ID}$argon2id$v=19$m=65536,t=3,p=1$4oLPvatR15+v8T7XRUsRLQ$CQHMhBYh0cZRq6agxAg7j/nqmn+z3bNLdgkNNhXzN1s', 0, true, false);

Standard aliases query

INSERT INTO aliases (source_username, source_domain, destination_username, destination_domain, enabled) values ('postmaster', 'example.org', 'it', 'example.org', true);
INSERT INTO aliases (source_username, source_domain, destination_username, destination_domain, enabled) values ('abuse', 'example.org', 'it', 'example.org', true);
INSERT INTO aliases (source_username, source_domain, destination_username, destination_domain, enabled) values ('noc', 'example.org', 'it', 'example.org', true);
INSERT INTO aliases (source_username, source_domain, destination_username, destination_domain, enabled) values ('security', 'example.org', 'it', 'example.org', true);
INSERT INTO aliases (source_username, source_domain, destination_username, destination_domain, enabled) values ('hostmaster', 'example.org', 'it', 'example.org', true);
INSERT INTO aliases (source_username, source_domain, destination_username, destination_domain, enabled) values ('webmaster', 'example.org', 'it', 'example.org', true);
INSERT INTO aliases (source_username, source_domain, destination_username, destination_domain, enabled) values ('www', 'example.org', 'it', 'example.org', true);
INSERT INTO aliases (source_username, source_domain, destination_username, destination_domain, enabled) values ('dmarc-reports', 'example.org', 'it', 'example.org', true);
INSERT INTO aliases (source_username, source_domain, destination_username, destination_domain, enabled) values ('tlsrpt', 'example.org', 'it', 'example.org', true);

Restart all

sudo systemctl restart dovecot
sudo systemctl restart postfix
sudo systemctl restart rspamd
sudo systemctl restart nginx

Reboot

sudo reboot

Improving deliverability

Get notified

In order to improve deliverability and get early notices about possible spam mail being sent from our mail server, it is advisable to sign up to the following platforms.

Be careful

Be careful about what you send to others and follow best practices.

Resolving deliverability issues

Whether or not the e-mails you send end up in recipient’s inboxes instead of their junk folders depends, amongst other factors, on the reputation of the IP address of your mail server. When hosting your mail server on shared infrastructure you may suffer from ’noisy neighbours’, especially when the number of e-mails that are sent out through your mail server are low. If you encounter issues with deliverability to e-mail addresses hosted by specific providers, the following starting points may help in resolving these.

Microsoft: Outlook.com, Hotmail, Live, MSN

Make sure to follow Microsoft’s Postmaster Policies, Practices, and Guidelines. If deliverability issues persist, request support through Outlook.com Deliverability Support.

Microsoft: Office 365

If your e-mail address or IP is being blocked by Microsoft Office 365, contact the Office 365 Anti-Spam IP Delist Portal.

Apple: mac.com, me.com, iCloud.com

When Apple refuses e-mail from your server, refer to their Postmaster information for iCloud Mail and make sure the best practices they describe are being followed. Contact Apple using the provided contact details if necessary.

Using an SMTP relay service

Services such as Twilio Sendgrid and Amazon Simple Email Service (SES) allow you to outsource ensuring deliverability. However, using them also means giving up some privacy, as these services will scan any outgoing e-mail going through them.

At the moment of writing, Sendgrid is free to use for up to 100 messages per day and charges $14.95 for sending up to 50,000 messages per month. Amazon SES charges “$0.10 for every 1,000 emails you send” from a server outside of Amazon Web Services (AWS). When using Amazon EC2, you will be charged “$0 for the first 62,000 emails you send each month, and $0.10 for every 1,000 emails you send after that.” in the Europe (Frankfurt) region. In this section, this AWS region is used in all examples and links.

NB: Make sure to use a Chromium-based browser when using the AWS console, it is not entirely compatible and will behave strangely in Mozilla Firefox.

With both services, you will need to prove ownership of your domains. In Sendgrid, this can be done in the dashboard on the Sender Authentication page. On the AWS Console, this is done by Verifying identities. After validating each individual domain, add the following configuration to Postfix:

sudo nano /etc/postfix/relay_maps
@example.com                 [smtp.sendgrid.net]:587
@example.org                 [email-smtp.eu-central-1.amazonaws.com]:587
sudo nano /etc/postfix/sasl_passwd

In this file, paste the login details of the SMTP relayhost services. For AWS, a user can be created by following these instructions. For Sendgrid, an API key is needed which can be generated on this page.

[smtp.sendgrid.net]:587                         apikey:____YOUR_PASSPHRASE____
[email-smtp.eu-central-1.amazonaws.com]:587     ___YOUR_USERNAME___:____YOUR_PASSPHRASE____
sudo chmod 600 /etc/postfix/sasl_passwd

sudo postmap /etc/postfix/relay_maps
sudo postmap /etc/postfix/sasl_passwd
sudo nano /etc/postfix/main.cf

Add above # Outbound SMTP connections (Postfix as sender / client):

# Domain-based outgoing email relay policy
sender_dependent_relayhost_maps = hash:/etc/postfix/relay_maps

# SASL authentication settings for relayhosts
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
smtp_sasl_tls_security_options = noanonymous
header_size_limit = 4096000

sudo systemctl restart postfix

Mozilla Thunderbird Autoconfig

By default, e-mail software will try to guess the location of a domain’s mail server and the protocols it speaks. This is error-prone and often slow. Thunderbird and many other e-mail clients will try to retrieve autoconfig.example.org to prevent having to guess.

sudo mkdir -p /var/www/autoconfig/mail/

sudo nano /var/www/autoconfig/mail/config-v1.1.xml
<?xml version="1.0" encoding="UTF-8"?>

<clientConfig version="1.1">
  <emailProvider id="example.org">
    <domain>example.org</domain>
    <displayName>Example.org Mail Server</displayName>
    <displayShortName>Organisation example</displayShortName>
    <incomingServer type="imap">
      <hostname>mail.example.org</hostname>
      <port>143</port>
      <socketType>STARTTLS</socketType>
      <authentication>password-cleartext</authentication>
      <username>%EMAILADDRESS%</username>
    </incomingServer>
    <outgoingServer type="smtp">
      <hostname>mail.example.org</hostname>
      <port>587</port>
      <socketType>STARTTLS</socketType>
      <authentication>password-cleartext</authentication>
      <username>%EMAILADDRESS%</username>
    </outgoingServer>
    <documentation url="https://example.org/">
      <descr lang="en">Homepage</descr>
    </documentation>
  </emailProvider>
</clientConfig>
sudo nano /etc/nginx/conf.d/autoconfig.conf
server {
    listen 80;
    listen [::]:80;

    server_name autoconfig.example.org;

    location / {
        return 301 https://$server_name$request_uri;
    }

    location /.well-known/acme-challenge/ {
                root /var/www/acme;
    }
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;

    server_name autoconfig.example.org;

    ssl_certificate /etc/lego/certificates/autoconfig.example.org.crt;
    ssl_certificate_key /etc/lego/certificates/autoconfig.example.org.key;
    ssl_dhparam /etc/ssl/ffdhe4096.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_ciphers "ECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";
    ssl_ecdh_curve secp384r1;
    ssl_session_cache shared:SSL:10m;
    ssl_session_tickets off;
    resolver 10.10.10.1 [2001:db8::2] valid=300s;
    resolver_timeout 5s;
    ssl_stapling on;
    ssl_stapling_verify on;
    add_header X-Content-Type-Options nosniff;
    add_header Strict-Transport-Security "max-age=63072000; preload";
    keepalive_timeout 300s;

    location /.well-known/acme-challenge/ {
                root /var/www/acme;
    }

    location / {
        root   /var/www/autoconfig;
    }
}

MTA-STS

DNS Records

  • TLSRPT DNS TXT
    • _smtp._tls TXT v=TLSRPTv1; rua=mailto:tlsrpt@example.org
  • MTA-STS DNS TXT
    • _mta-sts TXT v=STSv1; id=202012141200Z
  • mta-sts A and AAAA or CNAME

Modes are: none, testing and enforcing. In the RFC8461 specification, we can read that these modes entail the following:

  • none: In this mode, Sending MTAs should treat the Policy Domain as though it does not have any active policy
  • enforce: In this mode, Sending MTAs MUST NOT deliver the message to hosts that fail MX matching or certificate validation or that do not support STARTTLS.
  • testing: In this mode, Sending MTAs that also implement the TLSRPT (TLS Reporting) specification [RFC8460] send a report indicating policy application failures (as long as TLSRPT is also implemented by the recipient domain); in any case, messages may be delivered as though there were no MTA-STS validation failure.
sudo mkdir -p /var/www/mta-sts/.well-known

sudo tee /var/www/mta-sts/.well-known/mta-sts.txt <<EOF >> /dev/null
version: STSv1
mode: enforce
max_age: 10368000
mx: mail.example.org
EOF
sudo nano /etc/nginx/conf.d/mta-sts.conf
server {
    listen 80;
    listen [::]:80;

    server_name mta-sts.example.org;

    location / {
        return 301 https://$server_name$request_uri;
    }
    location /.well-known/acme-challenge/ {
                root /var/www/acme;
    }
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;

    server_name mta-sts.example.org;

    ssl_certificate /etc/lego/certificates/mta-sts.example.org.crt;
    ssl_certificate_key /etc/lego/certificates/mta-sts.example.org.key;
    ssl_dhparam /etc/ssl/ffdhe4096.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_ciphers "ECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";
    ssl_ecdh_curve secp384r1;
    ssl_session_cache shared:SSL:10m;
    ssl_session_tickets off;
    resolver 10.10.10.1 [2001:db8::2] valid=300s;
    resolver_timeout 5s;
    ssl_stapling on;
    ssl_stapling_verify on;
    add_header X-Content-Type-Options nosniff;
    add_header Strict-Transport-Security "max-age=63072000; preload";
    keepalive_timeout 300s;

    location /.well-known/acme-challenge/ {
                root /var/www/acme;
    }

    location / {
        root   /var/www/mta-sts;
    }
}

sudo nginx -t
sudo systemctl restart nginx

Backup

It is important to make proper backups of your mail server. I chose to use Borgmatic for this. The following section should be regarded as pointers on how to do this. It is not a complete overview.

On both the mail server as well as the backup server, execute:

sudo apt install borgmatic

On the mail server, run:

sudo generate-borgmatic-config -d /etc/borgmatic/config.example.yaml

Look in /etc/borgmatic/config.example.yaml for configuration examples.

sudo nano /etc/borgmatic/config.yaml
location:
  # List of source directories to backup.
  source_directories:
    # Rspamd storage
    - /var/lib/rspamd
    - /etc/redis/redis.conf
    - /var/lib/redis/dump.rdb
    # Letsencrypt
    - /etc/lego
    # E-mail storage
    - /var/vmail
    # Web configuration pages such as autoconfig, mta-sts
    - /var/www/
    # Configuration files
    - /etc/dovecot
    - /etc/postfix
    - /etc/rspamd
    - /etc/nginx

  # Paths of local or remote repositories to backup to.
  repositories:
    - backupuseraccountc@server.example.org:/var/borgbackup/repositories/mail

retention:
  # Retention policy for how many backups to keep.
  keep_hourly: 24
  keep_daily: 7
  keep_weekly: 4
  keep_monthly: 6

consistency:
  # List of checks to run to validate your backups.
  checks:
    - repository
    - archives

hooks:
  # Databases to dump and include in backups.
  postgresql_databases:
    - name: vmail
      hostname: 10.10.10.1
      username: vmail
      password: YOUR____PASSWORD

storage:
  encryption_passphrase: "YOUR____PASSPHRASE"
  compression: auto,lzma,9

Secure the config file by changing file ownership to root.

sudo chmod 600 /etc/borgmatic/config.yaml
Add the SSH key of the root user to the backup host.

As root on the mail server, execute

borg init --encryption=keyfile-blake2 management@server.example.org:/var/borgbackup/repositories/mail

Make sure to export the borg backup key, which will be stored in /root/.config/borg/keys/ and will be named something like server_example_org_var_borgbackup_repositories_mail. You could for example choose to store it on the backup server in the home folder of the backup user under .config/borg/keys. In that case, remove the hostname from the key file. The aforementioned example key e.g. should be shortened to var_borgbackup_repositories_mail. Make sure to also securely store the passphrase you used to unlock this key.

On the backup server, you can then check if the key has been setup properly by executing borg check /var/borgbackup/repositories/mail --info if borg is installed.

sudo borgmatic --verbosity=1

Borgmatic, by default, automatically should run every 24 hours, as it configures a borgmatic.timer in systemd.

Restore backup

It is advisable to try restoring these backups regularly, so you can be absolutely sure they work when you really need them.

Mail clients

  • Thunderbird
    • Install the Sieve plugin in order to be able to set up message filters in Thunderbird. Filters are set up on the server in Dovecot, therefore they will work across all devices.

Changelog

  • 2024-04-10
    • Add mkdir .pyzor command based on reader feedback.
  • 2024-04-08
    • Rspamd
      • Add documentation for setting up Rspams’s phishing, razor and pyzor modules.
      • Added DKIM reporting functionality.
      • Add extended spam headers
    • Lego
      • Added Systemd timers for certificate renewal as an alternative to cron
    • Borgmatic
      • Removed superfluous borgmatic cron job, which was a leftover needed in previous Debian versions.
  • 2024-02-10
    • Fixed changed DANE tool links

Sources