Ansible Mailserver

Setting up a simple mailserver with ansible

Posted by Kai Pazdzewicz on 12 December 2019

Intro

Welcome to my simple mailserver setup via ansible.

This project is still work in progress, so maybe you will visit this site later on.

What we will setup:

  • Postfix SMTP Server with MySQL user authentication
  • Dovecot POP3/IMAP Server with MySQL user authentication
  • Amavis Virus Scanner with ClamAV and DKIM
  • Lets Encrypt Certbot for issuing SSL certificates

What we already have:

Inventory

So we need our inventory. I have a cheap Hetzner Cloud Server for this project (https://www.hetzner.com/cloud). This server has an internal and an external ip address and I have assigned the hostname "mail01.example.com".

hosts.yml:

---
all:
  hosts:
    mail01:
      ansible_host: 78.47.192.000
      private_ip: 10.0.10.3
      mail_hostname: mail01.example.com
  children:
    mailservers:
      hosts:
        mail01:

Group Variables

Our setup will support multiple mailservers we need to specify our group variables. Since we use an external database server, we need to specify these credentials. Also we want to sign our "mydomain.com" emails with DKIM and want to establish a secure connection over the alias "mail.mydomain.com".

group_vars/mailservers.yml:

---
mail_postmaster: postmaster@example.com
MYSQL_HOST: 10.0.10.6
MYSQL_USER: vimbadmin
MYSQL_PASSWORD: !!!CHANGE_ME!!!
MYSQL_DATABASE: vimbadmin
DKIM_DOMAINS:
  - mydomain.com
CERTBOT_EMAIL: certbot@example.com
ALIAS_DOMAINS:
  - mail.mydomain.com

Playbook

So we are setup so far and start writing our playbook. Since we complete rely on our role this playbook is very simple

mailservers.yml:

---
- hosts: mailservers
  remote_user: ansible
  become: yes
  vars_files:
    - group_vars/mailservers.yml
  roles:
     - mailserver

Writing our Role

So this segment is splitted into various parts. We need handlers, tasks and templates.

Handlers

So we use handlers to restart and enable our services. So they are only invoked when something is changed.

roles/mailserver/handlers/main.yml:

---
- name: restart dovecot
  service:
    name: dovecot
    state: restarted
    enabled: yes
- name: restart postfix
  service:
    name: postfix
    state: restarted
    enabled: yes
- name: restart amavis
  service:
    name: amavis
    state: restarted
    enabled: yes
- name: restart clamav-daemon
  service:
    name: clamav-daemon
    state: restarted
    enabled: yes
- name: restart clamav-freshclam
  service:
    name: clamav-freshclam
    state: restarted
    enabled: yes

Tasks

I've split the tasks in separate files so that the role will be simple to read:

Main

In the Main Tasks file we create the mail user and group for our virtual mailboxes. Then we include all other sub tasks.

roles/mailserver/tasks/main.yml:

---
- name: create vmail group
  group:
    name: vmail
    gid: 5000
    state: present
- name: create vmail user
  user:
    name: vmail
    group: vmail
    shell: /bin/bash
    append: yes
    home: /var/vmail/
    state: present
    uid: 5000
- name: setup dovecot
  import_tasks: dovecot.yml
- name: setup postfix
  import_tasks: postfix.yml
- name: setup amavis
  import_tasks: amavis.yml
- name: setup clamav
  import_tasks: clamav.yml
- name: setup certbot
  import_tasks: certbot.yml

Dovecot

Dovecot is our POP3/IMAP server and will handle the mail access for the users. We install all needed packages, create the general configuration file, the mysql configuration and diffie hellman parameters. The configuration files will be explained in the templates section of this document.

roles/mailserver/tasks/dovecot.yml:

---
- name: add dovecot repo key
  apt_key:
    url: https://repo.dovecot.org/DOVECOT-REPO-GPG
    state: present
- name: add dovecot repo
  apt_repository:
    repo: deb https://repo.dovecot.org/ce-2.3-latest/debian/{{ ansible_lsb["codename"] }} {{ ansible_lsb["codename"] }} main
    state: present
    filename: dovecot
    update_cache: yes
- name: update the repository cache and install dovecot packages
  apt:
    name: "{{ packages }}"
    state: latest
    update_cache: yes
  vars:
    packages:
    - dovecot-core
    - dovecot-imapd
    - dovecot-lmtpd
    - dovecot-managesieved
    - dovecot-mysql
    - dovecot-sieve
- name: copy dovecot config file
  template:
    src: dovecot/dovecot.j2
    dest: /etc/dovecot/dovecot.conf
  notify:
    - restart dovecot
- name: copy dovecot mysql config file
  template:
    src: dovecot/mysql.j2
    dest: /etc/dovecot/dovecot-mysql.conf
  notify:
    - restart dovecot
- name: create diffie hellman parameters (this takes a very long time)
  command: openssl dhparam -out /etc/ssl/dhparam4096.pem 4096
  args:
    creates: /etc/ssl/dhparam4096.pem
  notify:
    - restart dovecot

Postfix

To deliver our emails, we need postfix as an SMTP server. As with dovecot we copy the general config file and mysql config files for our virtual mailboxes.

roles/mailserver/tasks/postfix.yml:

---
- name: update the repository cache and install postfix packages
  apt:
    name: "{{ packages }}"
    state: latest
    update_cache: yes
  vars:
    packages:
    - postfix
    - postfix-mysql
    - postfix-pcre
    - postfix-sqlite
- name: copy postfix main config file
  template:
    src: postfix/main.j2
    dest: /etc/postfix/main.cf
  notify:
    - restart postfix
- name: copy postfix master config file
  template:
    src: postfix/master.j2
    dest: /etc/postfix/master.cf
  notify:
    - restart postfix
- name: create /etc/postfix/mysql/
  file:
    path: /etc/postfix/mysql/
    state: directory
    owner: root
    group: root
    mode: 0775
- name: copy postfix virtual alias maps config file
  template:
    src: postfix/postfix-mysql-virtual_alias_maps.j2
    dest: /etc/postfix/mysql/postfix-mysql-virtual_alias_maps.cf
  notify:
    - restart postfix
- name: copy postfix virtual domains maps config file
  template:
    src: postfix/postfix-mysql-virtual_domains_maps.j2
    dest: /etc/postfix/mysql/postfix-mysql-virtual_domains_maps.cf
  notify:
    - restart postfix
- name: copy postfix virtual mailbox maps config file
  template:
    src: postfix/postfix-mysql-virtual_mailbox_maps.j2
    dest: /etc/postfix/mysql/postfix-mysql-virtual_mailbox_maps.cf
  notify:
    - restart postfix
- name: copy postfix virtual transport maps config file
  template:
    src: postfix/postfix-mysql-virtual_transport_maps.j2
    dest: /etc/postfix/mysql/postfix-mysql-virtual_transport_maps.cf
  notify:
    - restart postfix

Amavis

To scan our emails for viruses and other malicious files we need an anti virus solution. Amavis uses ClamAV to achieve this goal. Also we use Spamassassin to analyze emails and block spam. A wide variety of DNS based block lists are used to help spam blocking.

roles/mailserver/tasks/amavis.yml:

---
- name: update the repository cache and install amavis packages
  apt:
    name: "{{ packages }}"
    state: latest
    update_cache: yes
  vars:
    packages:
    - amavisd-new
    - libdbi-perl
    - spamassassin
    - libdbd-mysql-perl
  notify:
    - restart amavis
- name: create amavis config file
  template:
    src: amavis/amavis.j2
    dest: /etc/amavis/conf.d/50-user
  notify:
    - restart amavis
- name: create dkim keys
  command: amavisd-new genrsa /var/lib/amavis/db/dkim_{{ item }}.key 2048
  args:
    creates: /var/lib/amavis/db/dkim_{{ item }}.key
  with_list: "{{ DKIM_DOMAINS }}"

ClamAV

As already mentioned, Amavis uses ClamAV to scan our mails after viruses, so we need to setup that tool too.

roles/mailserver/tasks/clamav.yml:

---
- name: Update the repository cache and install ClamAV packages
  apt:
    name: "{{ packages }}"
    state: latest
    update_cache: yes
  vars:
    packages:
    - clamav
    - clamav-base
    - clamav-daemon
    - clamav-freshclam
  notify:
    - restart clamav-daemon
    - restart clamav-freshclam
- name: Füge ClamAV User zur Amavis Gruppe hinzu
  user:
    name: clamav
    group: amavis
    append: yes
  notify:
    - restart clamav-daemon
    - restart clamav-freshclam
- name: Copy Clam Daemon Config File
  template:
    src: clamav/clamd.j2
    dest: /etc/clamav/clamd.conf
  notify:
    - restart clamav-daemon
- name: Copy Clam Freshclam Config File
  template:
    src: clamav/freshclam.j2
    dest: /etc/clamav/freshclam.conf
  notify:
    - restart clamav-freshclam

Lets Encrypt Certbot

To secure the client connections and send mails to TLS enabled mailservers we need valid ssl certificates. Lets Encrypt offers them for free and in a very automated way. Since we don't have a webserver installed, we use the standalone method.

As you may see we don't use the ansible module (because I don't like it), so there is a simple bash script which will be explained in the templates section.

roles/mailserver/tasks/certbot.yml:

---
- name: Update the repository cache and install certbot
  apt:
    name: certbot
    state: latest
    update_cache: yes
- name: Copy Create Certificate Script
  template:
    src: certbot/create-certificate.j2
    dest: /etc/letsencrypt/certificate.bash
    mode: 0755
- name: Install Certificates
  command: /etc/letsencrypt/certificate.bash
  args:
    creates: /etc/letsencrypt/live/{{ mail_hostname }}
- name: Create Renew Cronjob
  cron:
    name: "Renew Certificates"
    special_time: daily
    job: "certbot renew -n -q --agree-tos --email {{ CERTBOT_EMAIL }} --standalone && systemctl restart postfix dovecot"

Templates

In ansible we don't use config files, but templates which represents them.

Dovecot

For dovecot we use a general config file which includes the whole configuration for mailbox handling.

roles/mailserver/templates/dovecot/dovecot.j2:

# {{ ansible_managed }}
auth_mechanisms = plain login
listen = *
log_timestamp = "%Y-%m-%d %H:%M:%S "
login_log_format_elements = user=<%u> method=%m rip=%r lip=%l mpid=%e %c %k
mail_gid = vmail
mail_home = /var/vmail/%d/%n
mail_location = maildir:~/Maildir:LAYOUT=fs
mail_plugins = quota acl mail_log notify
mail_uid = vmail
managesieve_notify_capability = mailto
managesieve_sieve_capability = fileinto reject envelope encoded-character vacation subaddress comparator-i;ascii-numeric relational regex imap4flags copy include variables body enotify environment mailbox date index ihave duplicate mime foreverypart extracttext
namespace {
  list = yes
  location = maildir:%%h/Maildir:LAYOUT=fs:INDEXPVT=~/Maildir/Shared/%%u
  prefix = Shared/%%u/
  separator = /
  subscriptions = yes
  type = shared
}
namespace inbox {
  inbox = yes
  location =
  mailbox Archiv {
    special_use = \Archive
  }
  mailbox Archive {
    auto = subscribe
    special_use = \Archive
  }
  mailbox "Deleted Messages" {
    special_use = \Trash
  }
  mailbox Drafts {
    auto = subscribe
    special_use = \Drafts
  }
  mailbox Entwürfe {
    special_use = \Drafts
  }
  mailbox "Gelöschte Objekte" {
    special_use = \Trash
  }
  mailbox Gesendet {
    special_use = \Sent
  }
  mailbox Junk {
    auto = subscribe
    special_use = \Junk
  }
  mailbox Papierkorb {
    special_use = \Trash
  }
  mailbox Sent {
    auto = subscribe
    special_use = \Sent
  }
  mailbox "Sent Messages" {
    special_use = \Sent
  }
  mailbox Trash {
    auto = subscribe
    special_use = \Trash
  }
  prefix =
  separator = /
}
passdb {
  args = /etc/dovecot/dovecot-mysql.conf
  driver = sql
}
plugin {
  acl = vfile
  acl_anyone = allow
  acl_shared_dict = file:/var/vmail/shared-mailboxes.db
  mail_log_events = delete undelete expunge
  quota = maildir:User quota
  quota_rule = Sent:storage=+10%%
  quota_status_nouser = DUNNO
  quota_status_overquota = 552 5.2.2 Mailbox is over quota
  quota_status_success = DUNNO
  sieve = ~/sieve/dovecot.sieve
  sieve_before = /var/vmail/before.sieve
  sieve_dir = ~/sieve
  sieve_max_script_size = 1M
  sieve_quota_max_scripts = 0
  sieve_quota_max_storage = 0
}
protocols = imap sieve lmtp
service auth {
  unix_listener /var/spool/postfix/private/auth_dovecot {
    group = postfix
    mode = 0660
    user = postfix
  }
  unix_listener auth-master {
    mode = 0600
    user = vmail
  }
  unix_listener auth-userdb {
    mode = 0600
    user = vmail
  }
  user = root
}
service dict {
  unix_listener dict {
    group = dovecot
    mode = 0660
    user = vmail
  }
}
service lmtp {
  unix_listener /var/spool/postfix/private/dovecot-lmtp {
    group = postfix
    mode = 0600
    user = postfix
  }
  user = vmail
}
service managesieve-login {
  inet_listener sieve {
    address = {{ private_ip }}
    port = 4190
  }
  process_min_avail = 2
  service_count = 1
  vsz_limit = 128 M
}
service managesieve {
  process_limit = 256
}
service quota-status {
  client_limit = 1
  executable = quota-status -p postfix
  unix_listener /var/spool/postfix/private/quota-status {
    group = postfix
    mode = 0660
    user = postfix
  }
}
ssl_ca = </etc/letsencrypt/live/{{ mail_hostname }}/chain.pem
ssl_cert = </etc/letsencrypt/live/{{ mail_hostname }}/cert.pem
ssl_key = </etc/letsencrypt/live/{{ mail_hostname }}/privkey.pem
ssl_cipher_list = EDH+CAMELLIA:EDH+aRSA:EECDH+aRSA+AESGCM:EECDH+aRSA+SHA384:EECDH+aRSA+SHA256:EECDH:+CAMELLIA256:+AES256:+CAMELLIA128:+AES128:+SSLv3:!aNULL:!eNULL:!LOW:!3DES:!MD5:!EXP:!PSK:!DSS:!RC4:!SEED:!ECDSA:CAMELLIA256-SHA:AES256-SHA:CAMELLIA128-SHA:AES128-SHA
ssl_dh = </etc/ssl/dhparam4096.pem
#verbose_ssl = yes
userdb {
  args = /etc/dovecot/dovecot-mysql.conf
  driver = sql
}
protocol imap {
  mail_plugins = quota imap_quota imap_acl acl mail_log notify
}
protocol lmtp {
  auth_socket_path = /var/run/dovecot/auth-master
  mail_plugins = quota sieve acl notify
  postmaster_address = {{ mail_postmaster }}
}
protocol sieve {
  managesieve_logout_format = bytes=%i/%o
}
protocol lda {
  mail_plugins = sieve quota acl notify
  postmaster_address = {{ mail_postmaster }}
}

Also we need to specify the mysql configuration and statements in another file.

roles/mailserver/templates/dovecot/mysql.j2:

driver = mysql
connect = "host={{ MYSQL_HOST }} dbname={{ MYSQL_DATABASE }} user={{ MYSQL_USER }} password={{ MYSQL_PASSWORD }}"
default_pass_scheme = SHA512-CRYPT
password_query = SELECT username as user, password as password, \
        homedir AS home, \
        maildir AS mail, uid, gid, \
        concat('*:bytes=', quota) as quota_rule \
        FROM mailbox WHERE username = '%Lu' AND active = '1' \
        AND ( access_restriction = 'ALL' OR LOCATE( '%Us', access_restriction ) > 0 )
user_query = SELECT homedir AS home, \
        maildir AS mail, uid, gid, \
        concat('*:bytes=', quota) as quota_rule \
        FROM mailbox WHERE username = '%u'
iterate_query = SELECT username FROM mailbox;

Postfix

Postfix has a lot of configuration files, so we start with the main configuration file which includes the whole SMTP configuration.

roles/mailserver/templates/postfix/main.j2:

# {{ ansible_managed }}
# SMTPd greeting banner: You MUST specify $myhostname at the start of the text. This is required by the SMTP protocol.
smtpd_banner = $myhostname

# Disable local biff service
biff = no

# Do not append the string $mydomain to -locally- submitted email.
append_dot_mydomain = no

# Readme directory
readme_directory = /usr/share/doc/postfix

# HTML directory
html_directory = /usr/share/doc/postfix/html

# Certificates
smtpd_tls_cert_file = /etc/letsencrypt/live/{{ mail_hostname }}/fullchain.pem
smtpd_tls_key_file = /etc/letsencrypt/live/{{ mail_hostname }}/privkey.pem

# Opportunistic TLS. TLS auth only.
smtpd_tls_security_level=may
smtpd_tls_auth_only=yes

# TLS session cache for SMTPd
smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_scache

# Disallow SSLv2 and SSLv3, only accept secure ciphers
smtpd_tls_mandatory_protocols = !SSLv2, !SSLv3
smtpd_tls_mandatory_ciphers=high

# Log TLS handling
smtpd_tls_loglevel = 1
smtp_tls_loglevel = 1

# Delay reject until RCPT TO
smtpd_delay_reject = yes

# Enable elliptic curve cryptography, "ultra" needs more cpu time
smtpd_tls_eecdh_grade = strong

# Sender, recipient, client and data restrictions
# !! non-FQDN HELOs are rejected on Port 25 only, see master.cf

# Auth. Benutzer dürfen auch innerhalb der "mynetworks" nur von den Adressen senden, die ihnen zugehörig sind.
smtpd_sender_restrictions = reject_authenticated_sender_login_mismatch,
# Erst jetzt werden "mynetworks" zugelassen
# Unauth. Benutzer wie der Cron-Dienst können so weiterhin Mails versenden, etwa
# als cron@fqdn
   permit_mynetworks,
# Anderen unauth. Benutzern das Benutzen jeder Adresse verbieten.
   reject_sender_login_mismatch,
# Alle auth. jetzt zulassen.
   permit_sasl_authenticated,
# Nicht im System vorhandene Absender jetzt ablehnen
   reject_unlisted_sender,
# Ablehnen, wenn die Sender-Domäne nicht existiert
   reject_unknown_sender_domain

# Akzeptiere alle Empfänger, die ein authentifizierter Absender oder ein Absender aus "mynetworks" angibt
smtpd_recipient_restrictions = permit_sasl_authenticated,
   permit_mynetworks,
# Schnittstelle zu Dovecot, um die Quota live zu überprüfen (verhindert Bounces)
   check_policy_service unix:private/quota-status,
# Ablehnen, wenn der HELO FQDN nicht aufzulösen ist
   reject_unknown_helo_hostname,
# Ablehnen, wenn KEIN PTR zu dieser IP existiert
# Verhindert nicht, dass ein FALSCHER PTR abgelehnt wird!
# Hierfür würde "reject_unknown_client_hostname" verwendet.
   reject_unknown_reverse_client_hostname,
# Kein offenes Relay
   reject_unauth_destination

# Unauth. Benutzer dürfen ihre Befehle nicht "pipen"
smtpd_data_restrictions =
   reject_unauth_pipelining,
   permit

# Eine Art Tabelle mit vorhanden Identitäten und ihren Zugehörigkeiten
smtpd_sender_login_maps = proxy:mysql:/etc/postfix/mysql/postfix-mysql-virtual_alias_maps.cf

# Certificates
smtp_tls_cert_file = /etc/letsencrypt/live/{{ mail_hostname }}/fullchain.pem
smtp_tls_key_file = /etc/letsencrypt/live/{{ mail_hostname }}/privkey.pem

# Opportunistic TLS. Use TLS if this is supported by the remote SMTP server, otherwise use plaintext.
smtp_tls_security_level=may

# TLS session cache for SMTP
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache

# A custom list with secure ciphers.
tls_high_cipherlist=EDH+CAMELLIA:EDH+aRSA:EECDH+aRSA+AESGCM:EECDH+aRSA+SHA384:EECDH+aRSA+SHA256:EECDH:+CAMELLIA256:+AES256:+CAMELLIA128:+AES128:+SSLv3:!aNULL:!eNULL:!LOW:!3DES:!MD5:!EXP:!PSK:!DSS:!RC4:!SEED:!ECDSA:CAMELLIA256-SHA:AES256-SHA:CAMELLIA128-SHA:AES128-SHA

# Use the FQDN for the local hostname!
myhostname = {{ mail_hostname }}

# Alias maps and database for -local- delivery only
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases

# The domain name that locally-posted mail appears to come from, and that locally posted mail is delivered to.
myorigin = {{ mail_hostname }}

# The list of domains that are delivered via the -local- mail delivery transport. No external domains like "domain.tld" belong here! "amalia.fastnameserver.eu" is fine.
mydestination = {{ mail_hostname }}, localhost

# We lookup MX records to send non-local mail, so this stays empty
relayhost =

# Trusted SMTP clients with more privileges. Trusted clients can relay mail.
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128

# The maximal size of any -local- individual mailbox
mailbox_size_limit = 0

# The maximal size of any -virtual- individual mailbox
virtual_mailbox_limit = 0

# Handle Postfix-style extensions
recipient_delimiter = +

# The network interface addresses that this mail system receives mail on.
inet_interfaces = all

# Specifies what protocols Postfix will use when it makes or accepts network connections, and also controls what DNS lookups Postfix will use when it makes network connections.
inet_protocols = ipv4

# VRFY command is not really needed anymore
disable_vrfy_command = yes

# Please say hello first...
smtpd_helo_required = yes

# The SASL plug-in type that the Postfix SMTP server should use for authentication.
smtpd_sasl_type=dovecot

# Where to passthrough our authentication information for the above plug-in
smtpd_sasl_path=private/auth_dovecot

# Enable SASL authentication in the Postfix SMTP server.
smtpd_sasl_auth_enable = yes

# Report the SASL authenticated user name in the smtpd Received message header.
smtpd_sasl_authenticated_header = yes

# Have Postfix advertise AUTH support in a non-standard way.
broken_sasl_auth_clients = yes

# The lookup tables that the proxymap server is allowed to access for the read-only service.
proxy_read_maps = $local_recipient_maps $mydestination $virtual_alias_maps $virtual_alias_domains $virtual_mailbox_maps $virtual_mailbox_domains $relay_recipient_maps $relay_domains $canonical_maps $sender_canonical_maps $recipient_canonical_maps $relocated_maps $transport_maps $mynetworks $smtpd_sender_login_maps

## Virtual transport configuration
# A prefix that the virtual delivery agent prepends to all pathname results from $virtual_mailbox_maps
virtual_mailbox_base = /

# THIS contains a list of domains we are the final destination for (unlike "mydestination").
virtual_mailbox_domains = proxy:mysql:/etc/postfix/mysql/postfix-mysql-virtual_domains_maps.cf

# Alias specific mail addresses or domains to other local or remote address.
virtual_alias_maps = proxy:mysql:/etc/postfix/mysql/postfix-mysql-virtual_alias_maps.cf

# Specify a left-hand side of "@domain.tld" to match any user in the specified domain
virtual_mailbox_maps = proxy:mysql:/etc/postfix/mysql/postfix-mysql-virtual_mailbox_maps.cf

# The minimum user ID value that the virtual delivery agent accepts
virtual_minimum_uid = 5000

# We use "vmail" user with UID/GID 5000 to lookup tables
virtual_uid_maps = static:5000
virtual_gid_maps = static:5000

# The default mail delivery transport and next-hop destination for final delivery to domains listed with "virtual_mailbox_domains"
virtual_transport = lmtps:unix:private/dovecot-lmtp

lmtp_tls_CApath = /usr/share/ca-certificates/

transport_maps = mysql:/etc/postfix/mysql/postfix-mysql-virtual_transport_maps.cf

## Queue configuration
# Consider a message as undeliverable, when delivery fails with a temporary error, and the time in the queue has reached this limit.
maximal_queue_lifetime = 1d

# Consider a bounce message as undeliverable, when delivery fails with a temporary error, and the time in the queue has reached this limit.
bounce_queue_lifetime = 1d

# The time between deferred queue scans by the queue manager.
queue_run_delay = 300s

# The maximal/minimal time between attempts to deliver a deferred message.
maximal_backoff_time = 1800s
minimal_backoff_time = 300s

# Maximum mail size (500 MiB)
message_size_limit = 524288000

# This tarpits a client after 3 erroneous commands for 10s
smtpd_soft_error_limit = 3
smtpd_error_sleep_time = 10s
smtpd_hard_error_limit = ${stress?1}${stress:5}

postscreen_access_list = permit_mynetworks

# Drop connections from blacklisted servers with a 521 reply
postscreen_blacklist_action = drop

# Clean Postscreen cache after 24h
postscreen_cache_cleanup_interval = 24h

postscreen_dnsbl_ttl = 5m
postscreen_dnsbl_threshold = 8
postscreen_dnsbl_action = enforce
postscreen_dnsbl_sites =
  b.barracudacentral.org=127.0.0.2*7
  dnsbl.inps.de=127.0.0.2*7
  bl.mailspike.net=127.0.0.2*5
  bl.mailspike.net=127.0.0.[10;11;12]*4
  dnsbl.sorbs.net=127.0.0.10*8
  dnsbl.sorbs.net=127.0.0.5*6
  dnsbl.sorbs.net=127.0.0.7*3
  dnsbl.sorbs.net=127.0.0.8*2
  dnsbl.sorbs.net=127.0.0.6*2
  dnsbl.sorbs.net=127.0.0.9*2
  zen.spamhaus.org=127.0.0.[10;11]*8
  zen.spamhaus.org=127.0.0.[4..7]*6
  zen.spamhaus.org=127.0.0.3*4
  zen.spamhaus.org=127.0.0.2*3
  hostkarma.junkemailfilter.com=127.0.0.2*3
  hostkarma.junkemailfilter.com=127.0.0.4*1
  hostkarma.junkemailfilter.com=127.0.1.2*1
  wl.mailspike.net=127.0.0.[18;19;20]*-2
  hostkarma.junkemailfilter.com=127.0.0.1*-2
postscreen_greet_banner = $smtpd_banner
postscreen_greet_action = enforce
postscreen_greet_wait = 3s
postscreen_greet_ttl = 2d
postscreen_bare_newline_enable = no
postscreen_non_smtp_command_enable = no
postscreen_pipelining_enable = no
postscreen_cache_map = proxy:btree:$data_directory/postscreen_cach

Also we need a master configuration file which also handles the amavis injection.

roles/mailserver/templates/postfix/master.j2:

# Postscreen on Port 25/tcp, filters zombies (spam machines) on first level with lowest costs.
smtp      inet  n       -       n       -       1       postscreen

# Postscreen passes sane clients to the real SMTP daemon here.
smtpd      pass  -       -       n       -       -       smtpd
# Reject non-FQDN HELOs on Port 25 (after passing postscreen process)
  -o smtpd_helo_restrictions=permit_mynetworks,reject_non_fqdn_helo_hostname
  -o smtpd_proxy_filter=127.0.0.1:10024
  -o smtpd_client_connection_count_limit=10
  -o smtpd_proxy_options=speed_adjust

# For mail submitting users. Authenticated clients and known networks only.
submission inet n       -       -       -       -       smtpd
  -o smtpd_client_restrictions=permit_sasl_authenticated,reject
  -o smtpd_proxy_filter=127.0.0.1:10025
  -o smtpd_client_connection_count_limit=10
  -o smtpd_proxy_options=speed_adjust

# Handles TLS connections for postscreen to make them readable
tlsproxy  unix  -       -       n       -       0       tlsproxy
# This implements an ad-hoc DNS white/blacklist lookup service
dnsblog   unix  -       -       n       -       0       dnsblog

pickup    fifo  n       -       -       60      1       pickup
cleanup   unix  n       -       -       -       0       cleanup
qmgr      fifo  n       -       n       300     1       qmgr
tlsmgr    unix  -       -       -       1000?   1       tlsmgr
rewrite   unix  -       -       -       -       -       trivial-rewrite
bounce    unix  -       -       -       -       0       bounce
defer     unix  -       -       -       -       0       bounce
trace     unix  -       -       -       -       0       bounce
verify    unix  -       -       -       -       1       verify
flush     unix  n       -       -       1000?   0       flush
proxymap  unix  -       -       n       -       -       proxymap
proxywrite unix -       -       n       -       1       proxymap
smtp      unix  -       -       -       -       -       smtp
relay     unix  -       -       -       -       -       smtp
showq     unix  n       -       -       -       -       showq
error     unix  -       -       -       -       -       error
retry     unix  -       -       -       -       -       error
discard   unix  -       -       -       -       -       discard
local     unix  -       n       n       -       -       local
virtual   unix  -       n       n       -       -       virtual
lmtp      unix  -       -       -       -       -       lmtp
anvil     unix  -       -       -       -       1       anvil
scache    unix  -       -       -       -       1       scache

# LMTP with STARTTLS support, needs newer Dovecot versions
lmtps     unix  -       -       -       -       -       lmtp
  -o lmtp_use_tls=yes
  -o lmtp_tls_loglevel=1
  -o lmtp_tls_CApath=/usr/share/ca-certificates/
  -o lmtp_tls_CAfile=/etc/letsencrypt/live/{{ mail_hostname }}/fullchain.pem
  -o lmtp_enforce_tls=yes
  -o lmtp_tls_mandatory_protocols=!SSLv2,!SSLv3
  -o lmtp_tls_protocols=!SSLv2,!SSLv3
  -o lmtp_tls_mandatory_ciphers=high
  -o lmtp_tls_ciphers=high
  -o lmtp_send_xforward_command=yes
  -o lmtp_tls_security_level=encrypt
  -o lmtp_tls_note_starttls_offer=yes

# Amavis reinjection, maximal 5 smtpd Prozesse, muss den Amavis Prozessen entsprechen!
127.0.0.1:10035 inet    n       -       -       -       5       smtpd
  -o smtpd_authorized_xforward_hosts=127.0.0.0/8
  -o smtpd_client_restrictions=
  -o smtpd_helo_restrictions=
  -o smtpd_sender_restrictions=
  -o smtpd_recipient_restrictions=permit_mynetworks,reject
  -o smtpd_data_restrictions=
  -o mynetworks=127.0.0.0/8
  -o receive_override_options=no_unknown_recipient_checks

Postfix needs mysql configuration and statements too, so we need to specify four configuration files

roles/mailserver/templates/postfix/postfix-mysql-virtual_alias_maps.j2:

user = {{ MYSQL_USER }}
password = {{ MYSQL_PASSWORD }}
hosts = {{ MYSQL_HOST }}
dbname = {{ MYSQL_DATABASE }}
query = SELECT goto FROM alias WHERE address = '%s' AND active = '1'

roles/mailserver/templates/postfix/postfix-mysql-virtual_domains_maps.j2:

user = {{ MYSQL_USER }}
password = {{ MYSQL_PASSWORD }}
hosts = {{ MYSQL_HOST }}
dbname = {{ MYSQL_DATABASE }}
query = SELECT domain FROM domain WHERE domain = '%s' AND backupmx = '0' AND active = '1'

roles/mailserver/templates/postfix/postfix-mysql-virtual_mailbox_maps.j2:

user = {{ MYSQL_USER }}
password = {{ MYSQL_PASSWORD }}
hosts = {{ MYSQL_HOST }}
dbname = {{ MYSQL_DATABASE }}
table = mailbox
select_field = maildir
where_field = username

roles/mailserver/templates/postfix/postfix-mysql-virtual_transport_maps.j2:

user = {{ MYSQL_USER }}
password = {{ MYSQL_PASSWORD }}
hosts = {{ MYSQL_HOST }}
dbname = {{ MYSQL_DATABASE }}
table = domain
select_field = transport
where_field = domain
additional_conditions = and backupmx = '0' and active = '1'

Amavis

To configure DKIM keys we also need to configure amavis.

roles/mailserver/templates/amavis/amavis.j2:

# {{ ansible_managed }}
use strict;

# Maximale Anzahl an Prozessen, die Amavis vorhält.
# Siehe auch Anmerkung in master.cf im Listener für Reinjection
$max_servers = 5;

# Amavis wird mitgeteilt, wie auf die MySQL-Datenbank zugegriffen werden kann.
# "changeme" bitte anpassen
@lookup_sql_dsn = (
    ['DBI:mysql:database={{ MYSQL_DATABASE }};host={{ MYSQL_HOST }};port=3306',
     '{{ MYSQL_USER }}',
     '{{ MYSQL_PASSWORD }}']);

# Hierdurch ermittelt Amavis die lokalen Domänen
$sql_select_policy = 'SELECT domain FROM domain WHERE CONCAT("@",domain) IN (%k)';

# Ein Listener für die Herkunft "external" sowie "submission"
$inet_socket_port = [10024,10025];

# Mails werden auf Port 10035 zurückgeführt
$forward_method = 'smtp:[127.0.0.1]:10035';
$notify_method  = 'smtp:[127.0.0.1]:10035';

# Listener :10025 bekommt eine eigene Policy
$interface_policy{'10025'} = 'SUBMISSION';

$policy_bank{'SUBMISSION'} = {
        # Diese Mails kommen von einem vertrauten System
        originating => 1,
        # 7-bit Kodierung erzwingen, damit ein späteres Kodieren die DKIM-Signatur nicht zerstört
        smtpd_discard_ehlo_keywords => ['8BITMIME'],
        # Viren auch von auth. Sendern ablehnen
        final_virus_destiny => D_REJECT,
        final_bad_header_destiny => D_PASS,
        final_spam_destiny => D_PASS,
        terminate_dsn_on_notify_success => 0,
        warnbadhsender => 1,
};

# "mail.domain.tld" bitte anpassen
$myhostname = "{{ mail_hostname }}";

# Wer wird über Viren, Spam und "bad header mails" informiert?
# Den Benutzer "postmaster" bitte nachträglich in ViMbAdmin erstellen (Alias möglich)
$virus_admin = "postmaster\@$mydomain";
$spam_admin = "postmaster\@$mydomain";
$banned_quarantine_to = "postmaster\@$mydomain";
$bad_header_quarantine_to = "postmaster\@$mydomain";

# DKIM kann verifiziert werden.
$enable_dkim_verification = 1;

# AR-Header darf gesetzt werden
$allowed_added_header_fields{lc('Authentication-Results')} = 1;

# DKIM-Signatur
# Gilt nur, wenn "originating = 1", ergo für die SUBMISSION policy bank
# "default" ist hierbei der Selector
# "domain.tld" als Domäne bitte anpassen
# "enable_dkim_signing" nur "1" setzen, wenn Mails wirklich signiert werden sollen.
# "/var/lib/amavis/db/dkim_domain.tld.key" sollte ebenso dem Namen der Domäne angepasst werden.
# Die TTL beträgt im Beispiel 7 Tage
# relaxed/relaxed beschreibt die Header/Body canonicalization, relaxed ist weniger restriktiv

$enable_dkim_signing = 1;

# BEGIN AUTO GENERATED DKIM CONFIGURATION
{% for DOMAIN in DKIM_DOMAINS %}
dkim_key('{{ DOMAIN }}', 'default', '/var/lib/amavis/db/dkim_{{ DOMAIN }}.key');
{% endfor %}
# END AUTO GENERATED DKIM CONFIGURATION
@dkim_signature_options_bysender_maps = (
    { '.' =>
        {
                ttl => 7*24*3600,
                c => 'relaxed/relaxed'
        }
    }
);

# Viren- und Spamfilter ACL; werden automatisch ermittelt
@bypass_virus_checks_maps = (
   \%bypass_virus_checks, \@bypass_virus_checks_acl, \$bypass_virus_checks_re);

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

#------------ Do not modify anything below this line -------------
1;  # ensure a defined return

ClamAV

For ClamAV we need to configure the ClamAV Daemon

roles/mailserver/templates/clamav/clamd.j2:

# {{ ansible_managed }}
#To reconfigure clamd run #dpkg-reconfigure clamav-daemon
LocalSocket /var/run/clamav/clamd.ctl
FixStaleSocket true
LocalSocketGroup amavis
LocalSocketMode 666
# TemporaryDirectory is not set to its default /tmp here to make overriding
# the default with environment variables TMPDIR/TMP/TEMP possible
User clamav
ScanMail true
ScanArchive true
ArchiveBlockEncrypted false
MaxDirectoryRecursion 15
FollowDirectorySymlinks false
FollowFileSymlinks false
ReadTimeout 180
MaxThreads 12
MaxConnectionQueueLength 15
LogSyslog false
LogRotate true
LogFacility LOG_LOCAL6
LogClean false
LogVerbose false
PreludeEnable no
PreludeAnalyzerName ClamAV
DatabaseDirectory /var/lib/clamav
OfficialDatabaseOnly false
SelfCheck 3600
Foreground false
Debug false
ScanPE true
MaxEmbeddedPE 10M
ScanOLE2 true
ScanPDF true
ScanHTML true
MaxHTMLNormalize 10M
MaxHTMLNoTags 2M
MaxScriptNormalize 5M
MaxZipTypeRcg 1M
ScanSWF true
ExitOnOOM false
LeaveTemporaryFiles false
AlgorithmicDetection true
ScanELF true
IdleTimeout 30
CrossFilesystems true
PhishingSignatures true
PhishingScanURLs true
PhishingAlwaysBlockSSLMismatch false
PhishingAlwaysBlockCloak false
PartitionIntersection false
DetectPUA false
ScanPartialMessages false
HeuristicScanPrecedence false
StructuredDataDetection false
CommandReadTimeout 30
SendBufTimeout 200
MaxQueue 100
ExtendedDetectionInfo true
OLE2BlockMacros false
ScanOnAccess false
AllowAllMatchScan true
ForceToDisk false
DisableCertCheck false
DisableCache false
MaxScanTime 120000
MaxScanSize 100M
MaxFileSize 25M
MaxRecursion 16
MaxFiles 10000
MaxPartitions 50
MaxIconsPE 100
PCREMatchLimit 10000
PCRERecMatchLimit 5000
PCREMaxFileSize 25M
ScanXMLDOCS true
ScanHWP3 true
MaxRecHWP3 16
StreamMaxLength 25M
LogFile /var/log/clamav/clamav.log
LogTime true
LogFileUnlock false
LogFileMaxSize 0
Bytecode true
BytecodeSecurity TrustSigned
BytecodeTimeout 60000

and Freshclam to get updated virus databases

roles/mailserver/templates/clamav/freshclam.j2:

# {{ ansible_managed }}
DatabaseOwner clamav
UpdateLogFile /var/log/clamav/freshclam.log
LogVerbose false
LogSyslog false
LogFacility LOG_LOCAL6
LogFileMaxSize 0
LogRotate true
LogTime true
Foreground false
Debug false
MaxAttempts 5
DatabaseDirectory /var/lib/clamav
DNSDatabaseInfo current.cvd.clamav.net
ConnectTimeout 30
ReceiveTimeout 30
TestDatabases yes
ScriptedUpdates yes
CompressLocalDatabase no
SafeBrowsing false
Bytecode true
NotifyClamd /etc/clamav/clamd.conf
# Check for new database 24 times a day
Checks 24
DatabaseMirror db.local.clamav.net
DatabaseMirror database.clamav.net

Certbot

Certbot initializes it's configuration on first run, so we use this template to generate a bash script which we use to issue a certificate with all of our alias domains included.

roles/mailserver/templates/certbot/create-certificate.j2:

#!/usr/bin/env bash
certbot certonly \
        -d {{ mail_hostname }} \
        --cert-name {{ mail_hostname }} \
        {% if ALIAS_DOMAINS is defined %}
        {% for DOMAIN in ALIAS_DOMAINS %}
        -d {{ DOMAIN }} \
        {% endfor %}
        {% endif %}
        -n \
        --agree-tos \
        --standalone \
        --email {{ CERTBOT_EMAIL }}

Execution

So our whole role is finished. Now we need to execute our playbook:

ansible-playbook mailservers.yml