Introduction

Getting a new email server set up right and working well can be tricky. If asked about how to set up an email server, most who've done it before will tell you to use a third party service and not roll your own email server.

This is generally good advice, as maintaining well working spam filters, ensuring good deliverability/reachability and staying off blocklists quickly becomes a full time job. I personally use Proton Mail with a custom domain, even though I wrote this guide and manage several production email servers (not sponsored by Proton in any way).

There are still several great reasons to roll your own email servers. To mention a few, it is a great learning experience, helps keep the web decentralized, can be cost effective and provides you high flexibility. For example if you want or need to support old and insecure SSL/TLS ciphers/protocols for legacy device/software support? No problem, just modify your configuration.


Features

This guide will show you step by step how to set up a secure, compatible and reliable email server with the following features:

  • SSL/TLS certificates issued using Certbot
  • Database schema designed around isolated siloes (tenants)
  • Database schema only allowing domains to belong to (be verified by) one tenant, but still allowing use as external/forwarding domain by other tenants
  • Per-user IMAP/SMTP access control to mailboxes in the same tenant (shared mailboxes)
  • Inbound forwarding (aliases)
  • Outbound forwarding, with support for storing copy of email in source mailbox
  • Outbound /dev/null forwarding (no-reply addresses)
  • bcrypt password hashing (a lot safer than MD5/crypt)
  • Unauthenticated SMTP on port 25 (unencrypted), with necessary protections to prevent the server being used as an open relay
  • Authenticated SMTP on port 587 (STARTTLS) and 465 (SSL/TLS)
  • Authenticated IMAP on port 143 (STARTTLS) and 993 (SSL/TLS)
  • Webmail using nginx, php and Roundcube
  • Autodiscover XML for Outlook and Apple Mail clients
  • Autodiscover JSON for Outlook 2016 for Mac clients (and newer, possibly also Outlook mobile clients)
  • Autoconfig for Thunderbird, Evolution, KMail and Kontact clients
  • DNSBL/RBL checks against common spam blocklists
  • Bayesian spam filtering and rule-based filtering with rspamd
  • Automatic training of bayesian spam filter based on spam/inbox contents
  • SPF verification
  • DKIM signing and verification
  • DMARC verification and reporting
  • SSL/TLS

Prerequisites

This guide assumes the following prerequisites are met:

  • The LEMP/LNMP web server stack is set up
  • An SSL/TLS certificate for mail.example.com is issued using Certbot
  • DH parameters are downloaded to /etc/ssl/ffdhe4096.pem

I recommend reading through How to set up a Debian/Ubuntu Linux web server (LEMP/LNMP stack) before continuing. It covers installing and configuring nginx, MySQL and php, as well as issuing SSL/TLS certificates using Certbot.

Prepare SSL/TLS DH parameters

If you haven't done so already, download the RFC 7919-defined standard 4096-bit Diffie-Hellman parameters file ffdhe4096.txt for use with your 4096-bit SSL/TLS certificates.

sudo curl https://ssl-config.mozilla.org/ffdhe4096.txt -o /etc/ssl/ffdhe4096.pem

Database (MySQL)

Database schema configuration

First of all we need to create a database which will contain our tables and data. To improve security, we'll create a specific user and only grant it SELECT privileges on the database, because the email servers only need to read data. Then we can USE the database and create the tables.

The following SQL query can be used to create the database, user, tables and views. Make sure you change [MAIL_DB_USERNAME] and [MAIL_DB_PASSWORD]. I suggest generating a password.

CREATE DATABASE IF NOT EXISTS `mail` DEFAULT CHARACTER SET utf8mb4;
GRANT SELECT ON `mail`.* TO '[MAIL_DB_USERNAME]'@'localhost' IDENTIFIED BY '[MAIL_DB_PASSWORD]';
FLUSH PRIVILEGES;
USE `mail`;

CREATE TABLE IF NOT EXISTS `tenants` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(50) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `domains` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `tenant_id` int(11) unsigned NOT NULL,
  `domain_name` varchar(253) NOT NULL,
  `verification` char(40) binary DEFAULT NULL,
  `verified_by_tenant` tinyint(1) unsigned DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `tenant_id_domain_name_unique` (`tenant_id`,`domain_name`),
  UNIQUE KEY `domain_name_external_unique` (`domain_name`,`verified_by_tenant`),
  CONSTRAINT `domain_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `mailboxes` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `tenant_id` int(11) unsigned NOT NULL,
  `domain_id` int(11) unsigned NOT NULL,
  `local_part` varchar(64) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `domain_id_local_part_unique` (`domain_id`,`local_part`),
  KEY `mailbox_tenant` (`tenant_id`),
  CONSTRAINT `mailbox_domain` FOREIGN KEY (`domain_id`) REFERENCES `domains` (`id`) ON DELETE RESTRICT,
  CONSTRAINT `mailbox_domain_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `domains` (`tenant_id`) ON DELETE RESTRICT,
  CONSTRAINT `mailbox_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `inbound_forwardings` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `tenant_id` int(11) unsigned NOT NULL,
  `source_local_part` varchar(64) NOT NULL,
  `source_domain_id` int(11) unsigned NOT NULL,
  `destination_mailbox_id` int(11) unsigned NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `inbound_forwarding_unique` (`source_local_part`,`source_domain_id`,`destination_mailbox_id`),
  KEY `inbound_forwarding_source_domain` (`source_domain_id`),
  KEY `inbound_forwarding_destination_mailbox` (`destination_mailbox_id`),
  KEY `inbound_forwarding_mailbox_tenant` (`tenant_id`),
  CONSTRAINT `inbound_forwarding_destination_mailbox` FOREIGN KEY (`destination_mailbox_id`) REFERENCES `mailboxes` (`id`) ON DELETE RESTRICT,
  CONSTRAINT `inbound_forwarding_mailbox_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `mailboxes` (`tenant_id`) ON DELETE RESTRICT,
  CONSTRAINT `inbound_forwarding_source_domain` FOREIGN KEY (`source_domain_id`) REFERENCES `domains` (`id`) ON DELETE RESTRICT,
  CONSTRAINT `inbound_forwarding_source_domain_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `domains` (`tenant_id`) ON DELETE RESTRICT,
  CONSTRAINT `inbound_forwarding_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `outbound_forwardings` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `tenant_id` int(11) unsigned NOT NULL,
  `source_mailbox_id` int(11) unsigned NOT NULL,
  `destination_local_part` varchar(64) NOT NULL,
  `destination_domain_id` int(1) unsigned NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `outbound_forwarding_unique` (`source_mailbox_id`,`destination_local_part`,`destination_domain_id`),
  KEY `outbound_forwarding_destination_domain` (`destination_domain_id`),
  KEY `outbound_forwarding_tenant` (`tenant_id`),
  CONSTRAINT `outbound_forwarding_destination_domain` FOREIGN KEY (`destination_domain_id`) REFERENCES `domains` (`id`) ON DELETE RESTRICT,
  CONSTRAINT `outbound_forwarding_destination_domain_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `domains` (`tenant_id`) ON DELETE RESTRICT,
  CONSTRAINT `outbound_forwarding_source_mailbox` FOREIGN KEY (`source_mailbox_id`) REFERENCES `mailboxes` (`id`) ON DELETE RESTRICT,
  CONSTRAINT `outbound_forwarding_source_mailbox_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `mailboxes` (`tenant_id`) ON DELETE RESTRICT,
  CONSTRAINT `outbound_forwarding_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `users` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `tenant_id` int(11) unsigned NOT NULL,
  `username` varchar(254) NOT NULL,
  `password` char(60) binary NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `username_unique` (`username`),
  KEY `user_tenant` (`tenant_id`),
  CONSTRAINT `user_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS `user_mailboxes` (
  `user_id` int(11) unsigned NOT NULL,
  `mailbox_id` int(11) unsigned NOT NULL,
  `tenant_id` int(11) unsigned NOT NULL,
  `imap_access` tinyint(1) unsigned DEFAULT NULL,
  `smtp_access` tinyint(1) unsigned DEFAULT NULL,
  PRIMARY KEY (`user_id`,`mailbox_id`),
  KEY `user_mailbox_mailbox` (`mailbox_id`),
  KEY `user_mailbox_mailbox_tenant` (`tenant_id`),
  CONSTRAINT `user_mailbox_mailbox` FOREIGN KEY (`mailbox_id`) REFERENCES `mailboxes` (`id`) ON DELETE RESTRICT,
  CONSTRAINT `user_mailbox_mailbox_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `mailboxes` (`tenant_id`) ON DELETE RESTRICT,
  CONSTRAINT `user_mailbox_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT,
  CONSTRAINT `user_mailbox_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT,
  CONSTRAINT `user_mailbox_user_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `users` (`tenant_id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE SQL SECURITY INVOKER VIEW `mailbox_email_addresses` AS
  SELECT
    `m`.`id` AS `id`,
    `m`.`tenant_id` AS `tenant_id`,
    `m`.`domain_id` AS `domain_id`,
    `m`.`local_part` AS `local_part`,
    `d`.`domain_name` AS `domain_name`,
    CONCAT(`m`.`local_part`,'@',`d`.`domain_name`) AS `email_address`
  FROM `mailboxes` AS `m`
  LEFT JOIN `domains` AS `d` ON `d`.`id` = `m`.`domain_id`
;

CREATE SQL SECURITY INVOKER VIEW `mailbox_forwardings` AS
  SELECT
    `m`.`id` AS `mailbox_id`,
    `m`.`tenant_id` AS `tenant_id`,
    `s`.`id` AS `forwarding_id`,
    'inbound' AS `forwarding_direction`,
    CONCAT(`s`.`source_local_part`,'@',`sd`.`domain_name`) AS `source`,
    `m`.`email_address` AS `destination`
  FROM `mailbox_email_addresses` AS `m`
  LEFT JOIN `inbound_forwardings` AS `s` ON `s`.`destination_mailbox_id` = `m`.`id`
  LEFT JOIN `domains` AS `sd` ON `sd`.`id` = `s`.`source_domain_id`
  WHERE `s`.`id` IS NOT NULL
  UNION
  SELECT
    `m`.`id` AS `mailbox_id`,
    `m`.`tenant_id` AS `tenant_id`,
    `d`.`id` AS `forwarding_id`,
    'outbound' AS `forwarding_direction`,
    `m`.`email_address` AS `source`,
    GROUP_CONCAT(CONCAT(`d`.`destination_local_part`,'@',`dd`.`domain_name`) ORDER BY (`m`.`email_address` = CONCAT(`d`.`destination_local_part`,'@',`dd`.`domain_name`)) ASC separator ',') AS `destination`
  FROM `mailbox_email_addresses` AS `m`
  LEFT JOIN `outbound_forwardings` AS `d` on `d`.`source_mailbox_id` = `m`.`id`
  LEFT JOIN `domains` AS `dd` on `dd`.`id` = `d`.`destination_domain_id`
  WHERE `d`.`id` IS NOT NULL
  GROUP BY `mailbox_id`
  ORDER BY `tenant_id`,`mailbox_id`,`forwarding_direction`,`forwarding_id`
;

CREATE SQL SECURITY INVOKER VIEW `user_mailbox_email_addresses` AS
  SELECT
    `um`.`user_id` AS `user_id`,
    `um`.`mailbox_id` AS `mailbox_id`,
    `um`.`tenant_id` AS `tenant_id`,
    `m`.`domain_id` AS `domain_id`,
    `um`.`imap_access` AS `imap_access`,
    `um`.`smtp_access` AS `smtp_access`,
    `u`.`username` AS `username`,
    `u`.`password` AS `password`,
    `m`.`local_part` AS `local_part`,
    `md`.`domain_name` AS `domain_name`,
    CONCAT(`m`.`local_part`,'@',`md`.`domain_name`) AS `email_address`
  FROM `user_mailboxes` AS `um`
  LEFT JOIN `users` AS `u` ON `u`.`id` = `um`.`user_id`
  LEFT JOIN `mailboxes` AS `m` ON `m`.`id` = `um`.`mailbox_id`
  LEFT JOIN `domains` AS `md` ON `md`.`id` = `m`.`domain_id`
;

Creating tenants

First of all we create a new "tenant" (customer).

In this database schema, all objects are associated to a tenant. This makes it easy to keep track of who owns any given object in the database and who should have access.

INSERT INTO `tenants` (`name`) VALUES ('Test tenant');
# SELECT LAST_INSERT_ID(); # 1

Creating domains

Now that we have a tenant, we can start adding domains.

This example creates the domain example.com in the tenant we previously created (tenant ID 1), with verified_by_tenant = 1 and a NULL verification code for example purposes. You'll want to actually verify domain MX and TXT DNS records in production. Bonus points for continuously/periodically monitoring these records.

Tip: You can use the verification field to store a verification code, for automatic verification of domain ownership via a DNS TXT pointer, but that's outside the scope of this guide.

INSERT INTO `domains` (
  `tenant_id`,
  `domain_name`,
  `verification`,
  `verified_by_tenant`
) VALUES (1, 'example.com', NULL, 1);
# SELECT LAST_INSERT_ID(); # 1

Creating mailboxes

After adding a domain, we can create a mailbox associated to the domain and tenant.

To create a mailbox for another domain name, the domain would have to be added first (see previous step). This maintains a nice logical separation between domain names and email addresses. It also ensures you cannot create mailboxes for domains you have not already added.

This example creates the mailbox [email protected] (mailbox ID 1).

INSERT INTO `mailboxes` (
  `tenant_id`,
  `domain_id`,
  `local_part`
)
VALUES (1, 1, 'sender');
# SELECT LAST_INSERT_ID(); # 1

Creating inbound forwardings to mailboxes (aliases)

Once we have mailboxes, we may want to add alias email addresses pointing to mailboxes.

This example adds the aliases [email protected] and [email protected] to the mailbox [email protected] (mailbox ID 1).

INSERT INTO `inbound_forwardings` (
  `tenant_id`,
  `source_local_part`,
  `source_domain_id`,
  `destination_mailbox_id`
)
VALUES
  (1, 'alias1', 1, 1),
  (1, 'alias2', 1, 1)
;

Creating outbound forwardings from mailboxes

First, we need to create the external domain we want to forward emails to, in the same tenant. External domains don't need to be verified, as that would make outbound forwardings impossible.

For this example, we'll add the domain gmail.com to the tenant we created (tenant ID 1).

INSERT INTO `domains` (
  `tenant_id`,
  `domain_name`,
  `verified_by_tenant`
) VALUES (1, 'gmail.com', 0);
# SELECT LAST_INSERT_ID(); # 2

Once the external domain is added, we can create an outbound forwarding to forward emails arriving at [email protected] (mailbox ID 1) to [email protected] (domain ID 2, local part example).

INSERT INTO `outbound_forwardings` (
  `tenant_id`,
  `source_mailbox_id`,
  `destination_local_part`,
  `destination_domain_id`
) VALUES (1, 1, 'example', 2);

If you want a mailbox to retain a copy of inbound emails while having outbound forwardings, make sure the mailbox has an outbound forwarding to itself. Just like shown in this example, where we forward emails arriving at [email protected] (mailbox ID 1) to [email protected] (domain ID 1, same local part as mailbox).

INSERT INTO `outbound_forwardings` (
  `tenant_id`,
  `source_mailbox_id`,
  `destination_local_part`,
  `destination_domain_id`
) VALUES (1, 1, 'sender', 1);

Creating users

First generate a bcrypt hash of the password, then insert a new user. Usually the username is the same as the user's primary email address, but it doesn't have to be. You can make up your own system.

The login credentials (username/password) have no relation to any mailbox or email address by default. This means that even though the username is [email protected], this user does not have access to the mailbox with the same email address. We'll get to that in the next step.

This example adds a new user to tenant Test tenant with username [email protected] and password password.

INSERT INTO `users` (
  `tenant_id`,
  `username`,
  `password`
) VALUES (
  1,
  '[email protected]',
  '$2a$12$BdCTpokLNW8nJe.MvH1.hu/4xzvMBk0WEV5Hfr6oN64.FO9Qq0REe'
);
# SELECT LAST_INSERT_ID(); # 1

Granting users access to mailboxes

By default, users don't have access to anything at all. Users by themselves are just a method of storing login credentials, nothing more. We can grant users access to multiple mailboxes, with granular read/send permission.

To add multiple mailboxes to an email client, simply use the same username/password for any mailbox the user is granted access to.

This example grants user [email protected] (user ID 1) IMAP access (read) and SMTP access (send) to mailbox [email protected] (mailbox ID 1) in tenant ID 1.

INSERT INTO `user_mailboxes` (
  `user_id`,
  `mailbox_id`,
  `tenant_id`,
  `imap_access`,
  `smtp_access`
) VALUES (1, 1, 1, 1, 1);

Postfix

Postfix is the MTA or SMTP server we'll be using, as it's my go-to MTA for production use and the second-most used MTA worldwide according to a survey by E-Soft Inc. It also integrates easily with rspamd and any MDA supporting maildir or LMTP, which will be useful later in the guide.

You could replace Postfix with any other MTA of your choosing, but that's outside the scope of this guide.

Postfix installation

sudo apt update # Update list of available packages
sudo apt show postfix # Inspect the postfix version available
sudo apt install postfix # Install postfix
sudo systemctl enable postfix # Enable the postfix service on startup
sudo systemctl start postfix # Start the postfix service now

Postfix database integration

Create some supplemental configuration files to integrate Postfix with the MySQL database.

ℹ️ Replace [MAIL_DB_USERNAME] and [MAIL_DB_PASSWORD] with database credentials.

/etc/postfix/db-domains.cf

user = [MAIL_DB_USERNAME]
password = [MAIL_DB_PASSWORD]
hosts = 127.0.0.1
dbname = mail
query = SELECT 1 FROM `domains` WHERE `domain_name` = '%s' AND `verified_by_tenant` = 1;

/etc/postfix/db-mailboxes.cf

ℹ️ Replace [MAIL_DB_USERNAME] and [MAIL_DB_PASSWORD] with database credentials.
user = [MAIL_DB_USERNAME]
password = [MAIL_DB_PASSWORD]
hosts = 127.0.0.1
dbname = mail
query = SELECT 1 FROM `mailbox_email_addresses` WHERE `email_address` = '%s';

/etc/postfix/db-aliases.cf

ℹ️ Replace [MAIL_DB_USERNAME] and [MAIL_DB_PASSWORD] with database credentials.
user = [MAIL_DB_USERNAME]
password = [MAIL_DB_PASSWORD]
hosts = 127.0.0.1
dbname = mail
query = SELECT `destination` FROM `mailbox_forwardings` WHERE `source` = '%s';

/etc/postfix/db-senders.cf

ℹ️ Replace [MAIL_DB_USERNAME] and [MAIL_DB_PASSWORD] with database credentials.
user = [MAIL_DB_USERNAME]
password = [MAIL_DB_PASSWORD]
hosts = 127.0.0.1
dbname = mail
query = SELECT `username` FROM `user_mailbox_email_addresses` WHERE `email_address` = '%s' AND `smtp_access` = 1;

Postfix configuration

Create/modify the main Postfix configuration file /etc/postfix/main.cf with the following contents.

ℹ️ Replace mail.example.com with a DNS name pointing to your email server.
# SMTPD banner
smtpd_banner = $myhostname ESMTP $mail_name

# Queue dir
queue_directory = /var/spool/postfix

# Disable local mail notifications
biff = no

# Disable the rewriting of "site!user" into "user@site".
swap_bangpath = no

# Disable the rewriting of the form "user%domain" to "user@domain".
allow_percent_hack = no

# Allow recipient address start with '-'.
allow_min_user = no

# Disable the SMTP VRFY command. This stops some techniques used to
# harvest email addresses.
disable_vrfy_command = yes

# Appending .domain is the MUA's job.
append_dot_mydomain = no

# Message size limit
message_size_limit = 104857600

# Enable SASL authentication
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_auth_enable = yes

# owner of the Postfix queue and of most Postfix daemon processes.
# Specify the name of a user account THAT DOES NOT SHARE ITS USER OR GROUP ID
# WITH OTHER ACCOUNTS AND THAT OWNS NO OTHER FILES OR PROCESSES ON THE SYSTEM.
# In particular, don't specify nobody or daemon. PLEASE USE A DEDICATED USER.
# Default is postfix.
# mail_owner = mail

#
# SSL/TLS configuration
#
tls_medium_cipherlist = 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
tls_preempt_cipherlist = no
tls_random_source = dev:/dev/urandom

# SMTPD SSL/TLS
smtpd_use_tls = yes
smtpd_tls_auth_only = yes
smtpd_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1
smtpd_tls_mandatory_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1
smtpd_tls_mandatory_ciphers = medium
smtpd_tls_exclude_ciphers = aNULL, eNULL, EXPORT, DES, RC4, MD5, PSK, aECDH, EDH-DSS-DES-CBC3-SHA, EDH-RSA-DES-CDC3-SHA, KRB5-DE5, CBC3-SHA
smtpd_tls_security_level = may
smtpd_tls_loglevel = 1
smtpd_tls_cert_file = /etc/letsencrypt/live/mail.example.com/fullchain.pem
smtpd_tls_key_file = /etc/letsencrypt/live/mail.example.com/privkey.pem
smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_scache
smtpd_tls_dh1024_param_file = /etc/ssl/ffdhe4096.pem

# SMTP SSL/TLS
smtp_use_tls = yes
smtp_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1
smtp_tls_mandatory_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1
smtp_tls_mandatory_ciphers = medium
smtp_tls_exclude_ciphers = aNULL, eNULL, EXPORT, DES, RC4, MD5, PSK, aECDH, EDH-DSS-DES-CBC3-SHA, EDH-RSA-DES-CDC3-SHA, KRB5-DE5, CBC3-SHA
smtp_tls_security_level = may
smtp_tls_loglevel = 1
smtp_tls_cert_file = $smtpd_tls_cert_file
smtp_tls_key_file = $smtpd_tls_key_file
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache

# Reject unlisted sender and recipient
smtpd_reject_unlisted_recipient = yes
smtpd_reject_unlisted_sender = yes

# Reject blacklisted hosts
smtpd_client_restrictions =
  permit_mynetworks,
  permit_sasl_authenticated

# Block clients that speak too early.
smtpd_data_restrictions = reject_unauth_pipelining

# Don't accept mail from domains that don't exist.
smtpd_sender_restrictions = reject_unknown_sender_domain

# Don't talk to mail systems that don't know their own hostname.
smtpd_helo_required = yes
smtpd_helo_restrictions =
  permit_mynetworks,
  permit_sasl_authenticated,
  reject_invalid_helo_hostname,
  reject_non_fqdn_helo_hostname,
  reject_unknown_helo_hostname,
  reject_unknown_reverse_client_hostname

# Block senders with invalid or blacklisted hostname, unknown domain, non-fqdn domain, unauthorized or unverified destination.
smtpd_recipient_restrictions =
  permit_mynetworks,
  reject_sender_login_mismatch,
  permit_sasl_authenticated,
  reject_unauth_pipelining,
  reject_invalid_hostname,
  reject_non_fqdn_sender,
  reject_unknown_sender_domain,
  reject_unauth_destination,
  reject_unknown_recipient_domain,
  reject_non_fqdn_recipient,
  reject_unverified_recipient

# Avoid duplicate recipient messages. Default is 'yes'.
enable_original_recipient = no

# Network/Database config
myhostname = mail.example.com
myorigin = mail.example.com
mydomain = mail.example.com
mydestination = localhost
relayhost =
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
mailbox_size_limit = 0
recipient_delimiter = +
inet_interfaces = all
inet_protocols = all
local_recipient_maps = proxy:unix:passwd.byname $alias_maps

# Virtual mailboxes and database mappings
virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = mysql:/etc/postfix/db-domains.cf
virtual_mailbox_maps = mysql:/etc/postfix/db-mailboxes.cf
virtual_alias_domains = $virtual_mailbox_domains
virtual_alias_maps = mysql:/etc/postfix/db-aliases.cf
smtpd_sender_login_maps = mysql:/etc/postfix/db-senders.cf

# Default milter options
milter_protocol = 6
milter_connect_macros = i j {daemon_name} v {if_name} _
milter_default_action = accept

# rspamd
smtpd_milters = inet:localhost:11332

# SPF time limit
policy-spf_time_limit = 3600s
compatibility_level = 2

Create/modify the postfix master process configuration file /etc/postfix/master.cf with the following contents.

# ==========================================================================
# service type  private unpriv  chroot  wakeup  maxproc command + args
#               (yes)   (yes)   (yes)   (never) (100)
# ==========================================================================

# INPUT SERVICES ===========================================================
smtp      inet  n       -       -       -       -       smtpd
submission inet n       -       -       -       -       smtpd
  -o syslog_name=postfix/submission
  -o smtpd_tls_wrappermode=no
smtps     inet  n       -       -       -       -       smtpd
  -o syslog_name=postfix/smtps
  -o smtpd_tls_wrappermode=yes
pickup    unix  n       -       -       60      1       pickup

# PROCESSING SERVICES ======================================================
cleanup   unix  n       -       -       -       0       cleanup
qmgr      unix  n       -       n       300     1       qmgr
rewrite   unix  -       -       -       -       -       trivial-rewrite

# OUTPUT SERVICES ==========================================================
error     unix  -       -       -       -       -       error
retry     unix  -       -       -       -       -       error
discard   unix  -       -       -       -       -       discard
local     unix  -       n       n       -       -       local
virtual   unix  -       n       n       -       -       virtual
lmtp      unix  -       -       -       -       -       lmtp
smtp      unix  -       -       -       -       -       smtp
relay     unix  -       -       -       -       -       smtp

# BOUNCE SERVICES ==========================================================
bounce    unix  -       -       -       -       0       bounce
defer     unix  -       -       -       -       0       bounce
trace     unix  -       -       -       -       0       bounce

# ADDRESS VERIFICATION SERVICE =============================================
verify    unix  -       -       -       -       1       verify

# CACHE SERVICES ===========================================================
scache    unix  -       -       -       -       1       scache
tlsmgr    unix  -       -       -       1000?   1       tlsmgr

# STATS SERVICE ============================================================
anvil     unix  -       -       -       -       1       anvil

# COMMAND SERVICES =========================================================
showq     unix  n       -       -       -       -       showq
flush     unix  n       -       -       1000?   0       flush

# PROXYMAP SERVICES ========================================================
proxymap  unix  -       -       n       -       -       proxymap
proxywrite unix -       -       n       -       1       proxymap

Dovecot

Dovecot is the MDA or IMAP server we'll be using, as I use it on several production mailservers and have ready-to-use configurations for it.

Dovecot installation

sudo apt update # Update list of available packages
sudo apt show dovecot-core # Inspect the dovecot version available
sudo apt install dovecot-core dovecot-imapd dovecot-lmtpd dovecot-mysql dovecot-sieve dovecot-managesieved # Install dovecot
sudo systemctl enable dovecot # Enable the dovecot service on startup
sudo systemctl start dovecot # Start the dovecot service now

Dovecot configuration

Edit /etc/dovecot/dovecot.conf and ensure the following lines are present and uncommented:

!include_try /usr/share/dovecot/protocols.d/*.protocol
protocols = imap lmtp

listen = *, ::

!include conf.d/*.conf

Edit /etc/dovecot/conf.d/10-mail.conf and ensure the following lines are present and uncommented:

mail_location = maildir:/var/mail/vhosts/%d/%n
mail_privileged_group = mail
first_valid_uid = 8 # UID of mail user
mail_uid = mail
mail_gid = mail

namespace {
  type = private
  separator = /
  prefix =
  inbox = yes
  hidden = no
  list = yes
  subscriptions = yes
}

Edit /etc/dovecot/conf.d/10-auth.conf and ensure the following lines are present and uncommented:

disable_plaintext_auth = yes
auth_mechanisms = plain login
!include auth-sql.conf.ext

Edit /etc/dovecot/conf.d/auth-sql.conf.ext and ensure the following lines are present and uncommented:

passdb {
  driver = sql
  args = /etc/dovecot/dovecot-sql.conf.ext
}
userdb {
  driver = static
  args = uid=mail gid=mail home=/var/mail/vhosts/%d/%n
}

Edit /etc/dovecot/dovecot-sql.conf.ext and ensure the following lines are present and uncommented:

ℹ️ Replace [MAIL_DB_USERNAME] and [MAIL_DB_PASSWORD] with database credentials.
driver = mysql
connect = host=127.0.0.1 dbname=mail user=[MAIL_DB_USERNAME] password=[MAIL_DB_PASSWORD]
default_pass_scheme = BLF-CRYPT
password_query = SELECT `local_part` AS `username`, `domain_name` AS `domain`, `password` FROM `user_mailbox_email_addresses` WHERE `local_part` = '%n' AND `domain_name` = '%d';

Edit /etc/dovecot/conf.d/10-master.conf and ensure the following lines are present and uncommented:

ℹ️ You may need to replace postfix, dovecot and mail with appropriate users depending on your system.
service imap-login {
    inet_listener imap {
        port = 143
    }
    inet_listener imaps {
        port = 993
        ssl = yes
    }
}
service lmtp {
    unix_listener /var/spool/postfix/private/dovecot-lmtp {
        mode = 0600
        user = postfix
        group = postfix
    }
}
service auth {
    unix_listener /var/spool/postfix/private/auth {
        mode = 0666
        user = postfix
        group = postfix
    }
    unix_listener auth-userdb {
        mode = 0600
        user = mail
    }
    user = dovecot
}
service auth-worker {
    user = mail
}

Edit /etc/dovecot/conf.d/10-ssl.conf and ensure the following lines are present and uncommented:

ℹ️ Replace mail.example.com with a DNS name pointing to your email server.
ssl = required

ssl_cert = </etc/letsencrypt/live/mail.example.com/fullchain.pem
ssl_key = </etc/letsencrypt/live/mail.example.com/privkey.pem

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

rspamd

rspamd is the content filter we're using, because it's open source, fast, easy to use, scriptable and reliable. It will be performing SPF, DKIM and DMARC verifying, DKIM signing and spam content filtering.

As a bonus, it also provides a nice web interface to show logged actions on all mail passing through it. We'll be making that available on the same domain as the webmail application using reverse proxying.

rspamd installation

sudo apt update # Update list of available packages
sudo apt show rspamd # Inspect the rspamd version available
sudo apt install rspamd # Install rspamd
sudo systemctl enable rspamd # Enable the rspamd service on startup
sudo systemctl start rspamd # Start the rspamd service now

rspamd configuration

I recommend following rspamd's documentation and setting up the following features:

#!/bin/bash
set -euxo pipefail
shopt -s failglob
rspamc learn_ham /var/mail/vhosts/example.com/sender/cur/*
rspamc learn_ham /var/mail/vhosts/example.com/sender/new/*
rspamc learn_spam /var/mail/vhosts/example.com/sender/.Junk/cur/*
rspamc learn_spam /var/mail/vhosts/example.com/sender/.Junk/new/*

You'll also want to move emails identified as spam by rspamd to the Junk folder automatically. This can also be done with a Dovecot sieve script.

Fully implementing automatic learning and moving emails with sieve scripts is left as an exercise to the reader, for now.


Autodiscover/autoconfig web services

Create the folder /var/www/autodiscover

mkdir /var/www/autodiscover

Create the file /var/www/autodiscover/autodiscover.xml.php with the following contents.

ℹ️ Replace mail.example.com with a DNS name pointing to your email server
<?php
header('Content-Type: text/xml; charset=utf-8');
$data = file_get_contents("php://input");
$email = '';
if (preg_match("/\<EMailAddress\>(.*?)\<\/EMailAddress\>/", $data, $matches) === 1) {
  $email = trim($matches[1]);
} elseif (array_key_exists('email', $_GET)) {
  $email = trim($_GET['email']);
}
if (strlen($email) === 0 && array_key_exists('PHP_AUTH_USER', $_SERVER)) {
  $email = $_SERVER['PHP_AUTH_USER'];
}
?>
<?xml version="1.0" encoding="utf-8"?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
  <Response xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a">
<?php if (strlen($email) > 0): ?>
    <User>
      <DisplayName><?= $email ?></DisplayName>
      <LoginName><?= $email ?></LoginName>
    </User>
<?php endif; ?>
    <Account>
      <AccountType>email</AccountType>
      <Action>settings</Action>
      <Protocol>
        <Type>IMAP</Type>
        <Server>mail.example.com</Server>
<?php if (strlen($email) > 0): ?>
        <LoginName><?= $email ?></LoginName>
<?php endif; ?>
        <Port>143</Port>
        <SPA>off</SPA>
        <DomainRequired>on</DomainRequired>
        <Encryption>tls</Encryption>
        <AuthRequired>on</AuthRequired>
      </Protocol>
      <Protocol>
        <Type>SMTP</Type>
        <Server>mail.example.com</Server>
<?php if (strlen($email) > 0): ?>
        <LoginName><?= $email ?></LoginName>
<?php endif; ?>
        <Port>587</Port>
        <SPA>off</SPA>
        <DomainRequired>on</DomainRequired>
        <Encryption>tls</Encryption>
        <AuthRequired>on</AuthRequired>
        <UsePOPAuth>off</UsePOPAuth>
        <SMTPLast>off</SMTPLast>
      </Protocol>
    </Account>
  </Response>
</Autodiscover>

Create the file /var/www/autodiscover/autodiscover.json.php with the following contents.

ℹ️ Replace mail.example.com with a DNS name pointing to your email server
<?php
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
  http_response_code(405);
  flush(); exit;
}
header('Content-Type: application/json; charset=utf-8');

if (!array_key_exists('Protocol', $_GET)) {
  http_response_code(400);
  echo json_encode([
    'ErrorCode' => 'MandatoryParameterMissing',
    'ErrorMessage' => 'A valid value must be provided for the query parameter \'Protocol\''
  ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
  flush(); exit;
}

if (strtolower($_GET['Protocol']) !== 'autodiscoverv1') {
  http_response_code(404);
  echo json_encode([
    'ErrorCode' => 'ProtocolNotSupported',
    'ErrorMessage' => 'The given protocol value \'' . str_replace('\'', '\\\'', $_GET['Protocol']) . '\' is not supported. Supported values are \'AutodiscoverV1\''
  ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
  flush(); exit;
}

http_response_code(200);
echo json_encode([
  'Protocol' => $_GET['Protocol'],
  'Url' => 'https://autodiscover.mail.example.com/autodiscover/autodiscover.xml' . (array_key_exists('Email', $_GET) ? '?email=' . rawurlencode($_GET['Email']) : '')
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
flush(); exit;

Create the file /var/www/autodiscover/config-v1.1.xml with the following contents.

ℹ️ Replace mail.example.com with a DNS name pointing to your email server
<?xml version="1.0" encoding="utf-8"?>
<clientConfig version="1.1">
  <emailProvider id="mail.example.com">
    <domain>%EMAILDOMAIN%</domain>
    <displayName>My email server</displayName>
    <displayShortName>MyEmail</displayShortName>
    <incomingServer type="imap">
      <hostname>mail.example.com</hostname>
      <port>993</port>
      <socketType>SSL</socketType>
      <authentication>password-cleartext</authentication>
      <username>%EMAILADDRESS%</username>
    </incomingServer>
    <incomingServer type="imap">
      <hostname>mail.example.com</hostname>
      <port>143</port>
      <socketType>STARTTLS</socketType>
      <authentication>password-cleartext</authentication>
      <username>%EMAILADDRESS%</username>
    </incomingServer>
    <outgoingServer type="smtp">
      <hostname>mail.example.com</hostname>
      <port>465</port>
      <socketType>SSL</socketType>
      <authentication>password-cleartext</authentication>
      <username>%EMAILADDRESS%</username>
    </outgoingServer>
    <outgoingServer type="smtp">
      <hostname>mail.example.com</hostname>
      <port>587</port>
      <socketType>STARTTLS</socketType>
      <authentication>password-cleartext</authentication>
      <username>%EMAILADDRESS%</username>
    </outgoingServer>
    <documentation url="https://mail.example.com">
      <descr lang="en">Webmail</descr>
    </documentation>
  </emailProvider>
</clientConfig>

Create/modify the file /etc/nginx/conf.d/default.conf with the following configuration. This configuration is only for autoconfig/autodiscover. A more complete configuration that also includes Roundcube support is included later in the guide.

ℹ️ Replace mail.example.com with a DNS name pointing to your email server
server {
  listen 80 default_server;
  listen [::]:80 default_server;
  server_name _;

  include snippets/location-certbot.conf;

  location / {
    return 301 https://mail.example.com$request_uri;
  }
}

server {
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  server_name mail.example.com;

  charset utf-8;
  index index.php index.html;

  include snippets/security-ssl.conf;
  add_header X-Robots-Tag "noindex, nofollow";

  ssl_certificate /etc/letsencrypt/live/mail.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/mail.example.com/privkey.pem;
  ssl_trusted_certificate /etc/letsencrypt/live/mail.example.com/chain.pem;

  location ~* ^\/mail\/config-v1\.1\.xml$ {
    alias /var/www/autodiscover;
    try_files /config-v1.1.xml =404;
  }

  location ~* ^\/autodiscover\/autodiscover\.xml$ {
    alias /var/www/autodiscover;

    # Override FastCGI script filename
    fastcgi_param SCRIPT_FILENAME /var/www/autodiscover/autodiscover.xml.php;

    # Split path after .xml file extension and check that file exists
    fastcgi_split_path_info ^(.+?\.xml)(/.*)$;
    try_files /autodiscover.xml.php =404;

  	include snippets/fcgi-php.conf;
  }

  location ~* ^\/autodiscover\/autodiscover\.json$ {
    alias /var/www/autodiscover;

    # Override FastCGI script filename
    fastcgi_param SCRIPT_FILENAME /var/www/autodiscover/autodiscover.json.php;

    # Split path after .xml file extension and check that file exists
    fastcgi_split_path_info ^(.+?\.json)(/.*)$;
    try_files /autodiscover.json.php =404;

  	include snippets/fcgi-php.conf;
  }
  
  location / {
  	return 404;
  }
}

Reload the nginx configuration to apply any changes you've made.

sudo nginx -s reload

Try it out:

ℹ️ Replace mail.example.com with a DNS name pointing to your email server
curl https://mail.example.com/autodiscover/autodiscover.xml

Roundcube

Roundcube is the webmail application we'll be configuring in this guide. You can of course use another application or even make your own if you're feeling adventurous.

Roundcube installation

I suggest following the Roundcube Installation guide, skipping installing the Apache web server and PHP 5.

Install Roundcube to /var/www/webmail, then (re-)configure nginx using the below configuration, as Roundcube only includes configuration for Apache.

Roundcube configuration

Create/modify /var/www/webmail/config/config.inc.php with the following contents.

ℹ️ Replace mail.example.com with a DNS name pointing to your email server, rcmail-!24ByteDESkey*Str with a generated key, [ROUNDCUBE_DB_USERNAME] and [ROUNDCUBE_DB_PASSWORD] with database credentials.
<?php

/* Local configuration for Roundcube Webmail */

$config = [];

// Database connection string (DSN) for read+write operations
// Format (compatible with PEAR MDB2): db_provider://user:password@host/database
// Currently supported db_providers: mysql, pgsql, sqlite, mssql, sqlsrv, oracle
// For examples see http://pear.php.net/manual/en/package.database.mdb2.intro-dsn.php
// NOTE: for SQLite use absolute path (Linux): 'sqlite:////full/path/to/sqlite.db?mode=0646'
//       or (Windows): 'sqlite:///C:/full/path/to/sqlite.db'
$config['db_dsnw'] = 'mysql://[ROUNDCUBE_DB_USERNAME]:[ROUNDCUBE_DB_PASSWORD]@127.0.0.1/roundcube';

// IMAP host chosen to perform the log-in.
// See defaults.inc.php for the option description.
$config['imap_host'] = 'tls://mail.example.com:143';

// SMTP server host (for sending mails).
// See defaults.inc.php for the option description.
$config['smtp_host'] = 'tls://mail.example.com:587';

// SMTP username (if required) if you use %u as the username Roundcube
// will use the current username for login
$config['smtp_user'] = '%u';

// SMTP password (if required) if you use %p as the password Roundcube
// will use the current user's password for login
$config['smtp_pass'] = '%p';

// provide an URL where a user can get support for this Roundcube installation
// PLEASE DO NOT LINK TO THE ROUNDCUBE.NET WEBSITE HERE!
$config['support_url'] = '';

// Name your service. This is displayed on the login screen and in the window title
$config['product_name'] = 'Roundcube Webmail';

// This key is used to encrypt the users imap password which is stored
// in the session record. For the default cipher method it must be
// exactly 24 characters long.
// YOUR KEY MUST BE DIFFERENT THAN THE SAMPLE VALUE FOR SECURITY REASONS
$config['des_key'] = 'rcmail-!24ByteDESkey*Str';

// List of active plugins (in plugins/ directory)
$config['plugins'] = [
  'archive',
  'attachment_reminder',
  'filesystem_attachments',
  'markasjunk',
  'password',
  'vcard_attachments',
  'zipdownload',
];

// Make use of the built-in spell checker. It is based on GoogieSpell.
$config['enable_spellcheck'] = false;

// Compose HTML formatted messages by default
$config['htmleditor'] = 1;

// Set SMTP connection SSL/TLS verification options
$config['smtp_conn_options'] = [
  'ssl' => [
    'verify_peer' => true,
    'verify_peer_name' => true,
    'verify_depth' => 3,
    'cafile' => '/etc/ssl/certs/ca-certificates.crt',
  ]
];

// SECURITY: Set to true after running installer for the first time
$config['enable_installer'] = false;

Roundcube nginx configuration

Create/modify the file /etc/nginx/conf.d/default.conf with the following configuration.

ℹ️ Replace mail.example.com with a DNS name pointing to your email server
server {
  listen 80 default_server;
  listen [::]:80 default_server;
  server_name _;

  include snippets/location-certbot.conf;

  location / {
    return 301 https://mail.example.com$request_uri;
  }
}

server {
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  server_name mail.example.com;

  root /var/www/webmail;
  charset utf-8;
  index index.php index.html;

  include snippets/security-ssl.conf;
  add_header X-Robots-Tag "noindex, nofollow";
  expires 1M;

  ssl_certificate /etc/letsencrypt/live/mail.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/mail.example.com/privkey.pem;
  ssl_trusted_certificate /etc/letsencrypt/live/mail.example.com/chain.pem;

  # Autoconfig for Thunderbird, Evolution, KMail and Kontact clients
  location ~* ^\/mail\/config-v1\.1\.xml$ {
    alias /var/www/autodiscover;
    try_files /config-v1.1.xml =404;
  }

  # Autodiscover XML for Outlook and Apple Mail clients
  location ~* ^\/autodiscover\/autodiscover\.xml$ {
    alias /var/www/autodiscover;

    # Override FastCGI script filename
    fastcgi_param SCRIPT_FILENAME /var/www/autodiscover/autodiscover.xml.php;

    # Split path after .xml file extension and check that file exists
    fastcgi_split_path_info ^(.+?\.xml)(/.*)$;
    try_files /autodiscover.xml.php =404;

  	include snippets/fcgi-php.conf;
  }

  # Autodiscover JSON for Outlook 2016 for Mac clients
  # (and newer, possibly also Outlook mobile clients)
  location ~* ^\/autodiscover\/autodiscover\.json$ {
    alias /var/www/autodiscover;

    # Override FastCGI script filename
    fastcgi_param SCRIPT_FILENAME /var/www/autodiscover/autodiscover.json.php;

    # Split path after .xml file extension and check that file exists
    fastcgi_split_path_info ^(.+?\.json)(/.*)$;
    try_files /autodiscover.json.php =404;

  	include snippets/fcgi-php.conf;
  }

  # Deny access to files not containing a dot or starting with a dot,
  # in all locations except /installer/ and /.well-known/
  location ~* ^(?!installer|\.well-known\/|[a-zA-Z0-9]{16})(\.?[^\.]+)$ {
    access_log off;
    log_not_found off;
    return 404;
  }

  # Deny access to some locations
  location ~* ^\/?(\.git|\.tx|SQL|bin|config|logs|temp|tests|vendor|program\/(include|lib|localization|steps)) {
    access_log off;
    log_not_found off;
    return 404;
  }

  # Deny access to some documentation files
  location ~* \/?(README.*|CHANGELOG.*|SECURITY.*|meta\.json|composer\..*|jsdeps.json)$ {
    access_log off;
    log_not_found off;
    return 404;
  }

  # Disable logging for favicon, return favicon file from roundcube skin
  location = /favicon.ico {
    access_log off;
    log_not_found off;
    try_files /skins/elastic/images/favicon.ico =404;
  }

  # Restrict access to installer (add your IP address to access it)
  location ~ ^\/installer\/.*\.php$ {
    allow 127.0.0.1;
    deny all;

    # Split path after .php file extension and check that file exists
    # This avoids RCE via uploaded file execution
    fastcgi_split_path_info ^(.+?\.php)(/.*)$;
    try_files $fastcgi_script_name =404;

    include snippets/fcgi-php.conf;
  }

  # Restrict access to installer (add your IP address to access it)
  location = /installer {
    allow 127.0.0.1;
    deny all;
    try_files $uri $uri/ =404;
  }

  # Restrict access to installer (add your IP address to access it)
  location /installer/ {
    allow 127.0.0.1;
    deny all;
    try_files $uri $uri/ =404;
  }

  include snippets/location-php.conf;

  location / {
    try_files $uri $uri/ =404;
  }
}

Reload the nginx configuration to apply any changes you've made.

sudo nginx -s reload

Testing

Test SSL/TLS certificates and STARTTLS

(out)# SMTP - SSL/TLS
openssl s_client -connect mail.example.com:465 -crlf -showcerts -strict -verify_quiet -verify_return_error <<< "" | openssl x509 -noout -dates
(out)
(out)# SMTP - STARTTLS
openssl s_client -connect mail.example.com:587 -crlf -starttls smtp -showcerts -strict -verify_quiet -verify_return_error <<< "" | openssl x509 -noout -dates
(out)
(out)# IMAP - SSL/TLS
openssl s_client -connect mail.example.com:993 -crlf -showcerts -strict -verify_quiet -verify_return_error <<< "" | openssl x509 -noout -dates
(out)
(out)# IMAP - STARTTLS
openssl s_client -connect mail.example.com:143 -crlf -starttls imap -showcerts -strict -verify_quiet -verify_return_error <<< "" | openssl x509 -noout -dates

Test sending email

swaks --to [email protected] --from [email protected] --auth-user [email protected] --server mail.example.com -tls -p 587

Written by
Alexander Sagen