This is a classic one. I run my own mailserver since I’m legally allowed to rent rootservers and twice a decade this stuff had to be rebuilt somehow. I ran a setup with postfix + dbmail + spamassasin for a long time but since dbmail is completly dead and it’s a hassle to get the share libraries straight on anything newer that Ubuntu 14.04 I must switch to something more convinient and better supported.

For the new mailserver I wanted to change a few things in the software stack and setup organisation. First I decided to build it following these priciples:

  • keep it simple Mailservers are complex enough
  • config management For the whole setup. no more ‘oh shit where is this special config tweak’
  • reduce overhead No special additions like admin webinterface/containerization etc. which have to be maintained

Then I thought about the technical specs. The mailserver must support the following features:

  • smtp+imap+sieve Basic feature to use email.
  • webinterface For all situation on the run
  • password change Users must be able to change their password without an admin

Eventually I came up with the following decisions

  • postfix+dovecot+btrfs Stable software, no database just a filesystem
  • no spamfilter Since I recieve less than 2 spam mails per month with a decent configured mailserver
  • ansible+git Use ansible for config management and setup and git to store the ansible stuff
  • nextcloud+rainloop A already running nextcloud will be used with Rainloop Plugin

I then decided to use dovecot as a dbmail replacement as it is well maintained and well-hung. So lets get started!


As soon as we have set the MX record for our domain to our mailserver we should ensure that we can receive mails there. Thats postfix’ job and it has a whole lot of configs. I found a good tutorial and reduced it for my purposes. The whole config with ansible automation can be found here but let us go throught the most important stuff.


First the virtual user and domain setup:

# /etc/postfix/
virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = domain.tld another.tld
virtual_mailbox_maps = texthash:/etc/postfix/accounts
local_recipient_maps = $virtual_mailbox_maps
virtual_alias_maps = texthash:/etc/postfix/aliases

Mails for virtual mailboxes should be transferred via a unix socket to dovecot using the LMTP protocol. The domains which postfix should feel responsible for are configured via ansible directly into the cause they are just a few.
The addresses/accounts for which postfix should accept mail are written by ansible to /etc/postfix/accounts and their aliases added to /etc/postfix/aliases. I use texthash as fileformat although its slower but for a few entrys that should not matter at all and so I don’t have to run postmap everytime they change. Keep it simple.The account and alias file look like:

# /etc/postfix/accounts
example@domain.tld  stub
eg@another.tld  stub

The second column of the file is not used and the ‘stub’ is just there to keep the file format. So it’s basically a list of accountnames. The same list will later be nessesary for dovecot acoount lookups but ansible just pastes the this for every server in its format, so no additional work needs to be done.\

# /etc/postfix/aliases
# aliases for example@domain.tld
@domain.tld example@domain.tld
# aliases for eg@another.tld
edgaer_the_pimp@another.tld eg@another.tld

There is an catch all alias for *@domain.tld to the account of example@domain.tld and a second address for the account eg@another.tld

Then there is the whole security stuff. That is the crucial part of the postfix config. Here we decide whether to block a lot of spam even without parsen the message or accidentally create an open relay.

# /etc/postfix/
disable_vrfy_command = yes
smtpd_helo_required = yes
smtpd_data_restrictions = reject_unauth_pipelining

We don’t want spammer to be able to lookup the catch all config using the VRFY command so we just disable it.
Also we require a civilized greeting and force clients to a use SMTP without shortcuts.

# /etc/postfix/
smtpd_client_restrictions =

Now we come to the restriction of the various SMTP commands. We always allow our local network like localhost (permit_mynetworks) and reject clients with broken DNS<->reverse DNS (reject_unknown_client_hostname)

# /etc/postfix/
smtpd_helo_restrictions =

We also want the remote client to give a valid and resolveable FQDN in the HELO. With the DNS based filters postfix drops 500-600 Connections per month with log messages like this:

# mail.log
/var/log/mail.log.4.gz:Oct  4 05:55:39 novaprospekt postfix/smtpd[21911]: warning: hostname does not resolve to address Name or service not known
/var/log/mail.log.4.gz:Oct  6 00:02:03 novaprospekt postfix/smtpd[32514]: warning: hostname does not resolve to address Name or service not known
/var/log/mail.log.4.gz:Oct  6 00:30:53 novaprospekt postfix/smtpd[485]: warning: hostname does not resolve to address
/var/log/mail.log.4.gz:Oct  6 03:41:56 novaprospekt postfix/smtpd[4261]: warning: hostname does not resolve to address Name or service not known
# /etc/postfix/
smtpd_sender_restrictions =
        check_sender_access texthash:/etc/postfix/check_sender_domain

In the sender address restriction we additionally allow connections which have authenticated via username & password and for everyone else we add another filter

# /etc/postfix/check_sender_domain
domain.tld       REJECT You're not one of us!
another.tld      REJECT You're not one of us!

That rejects every connection from a remote host which tries to send mail with one of our own domains as sender.

# /etc/postfix/
smtpd_recipient_restrictions =

Finally we only accept mails for domains we defined in our config (reject_unauth_destination). That prevents an open relay.

Another trick from the tutorial mentioned above is to create seperate rules for Mail client (like Webinterface or Thunderbird) and use them only on submission port 587. They are very simple

# /etc/postfix/
# Restrictions for MUAs (used by submission)
mua_relay_restrictions =
mua_sender_restrictions =
mua_client_restrictions =

but need an extra config snippet in the

# /etc/postfix/
submission inet  n       -       n       -       -       smtpd
  -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_sasl_local_domain=$myhostname
  -o smtpd_client_restrictions=$mua_client_restrictions
  -o smtpd_sender_restrictions=$mua_sender_restrictions
  -o smtpd_relay_restrictions=$mua_relay_restrictions
  -o smtpd_recipient_restrictions=$mua_recipient_restrictions
  -o smtpd_helo_required=no
  -o smtpd_helo_restrictions=
  -o cleanup_service_name=submission-header-cleanup
# remove specific headers for privacy reasons
submission-header-cleanup unix n - n    -       0       cleanup
    -o header_checks=regexp:/etc/postfix/submission_header_cleanup

Here we can also see the setting for the authentication. Postfix connects to a socket which dovecot provieds so that we don’t need to configure the authentication in postfix.

The last important feature in the postfix config is the removal of a few client headers that is added by some MUAs. Especially the IP of the client must not be leaked in my understanding of privacy.

# /etc/postfix/submission_header_cleanup
/^Received:/            IGNORE
/^X-Originating-IP:/    IGNORE
/^X-Mailer:/            IGNORE
/^User-Agent:/          IGNORE


Afterwards we want to hand over the mail to dovecot to store it in the appropriate acoount and make it available via IMAP for clients. The dovecot config is pretty much straight forward and the whole, commented config can be found here.

# /etc/dovecot/dovecot.conf
service auth {
    ### Auth socket für Postfix
    unix_listener /var/spool/postfix/private/auth {
        mode = 0660
        user = postfix
        group = postfix

Here is the socket on which postfix can check the user credentials.

# /etc/dovecot/dovecot.conf
userdb {
    driver = static
    args = /etc/dovecot/userdb

And thats the user account list, very similar to the one which is used by postfix.

One interesting solution I’m slightly proud of is the user password handling. I wanted that users are able to change their password without any administrative action. Back in the days I wrote a roundcube plugin to do this directly in the dbmail MySQL database but without both dbmail and roundcube a new solution has to be found. I decided just to reuse their nextcloud account information and wrote a small SQL for that:

# /etc/dovecot/nextcloud_passwd_sql.conf
driver = mysql
connect = host=localhost dbname=nextcloud user=nextcloud-dovecot password=supersecret port=3306
password_query = SELECT REPLACE( password, '2|', '{ARGON2I}') password FROM oc_users WHERE uid_lower=REGEXP_REPLACE('%u', '@.*', '');

The query replaces the nextcloud specific hash prefix with the one dovecot uses and search for a nextcloud username which matches the local-part of the account emailaddress in dovecot. The corresponding user table from nextcloud looks like:

# mysql> select * from nextcloud.oc_users;
| uid     | displayname | password                                                                                           | uid_lower |
| Example | NULL        | 2|$argon2i$v=19$m=32768,t=4,p=1$oS3RHai8LRX8gqOMCX095w$eFEEHBaVOh56whmB6hJgggHeVydrJpKMOi7T2hj5vfI | example   |
| eg      | NULL        | 2|$argon2i$v=19$m=32768,t=4,p=1$seluAwz5IZJskkOrFlevNw$KEim1FMtlLqIFb20Co1d/bK+7xj23irip9/GLiXPNpY | eg        |

and the dovecot userdb file which is provided via ansible looks like:

# /etc/dovecot/userdb

The final tidbit was to use the Rainloop IMAP Webinterface, installed as a nextcloud plugin, with autologin. The benefit of using Rainloop as a Nextcloud plugin is that no extra Vhost configuration is nessesary, updates come automatically via Nextcloud plugin management, all contacts stored in Nextcloud are automatically available in Rainloop and last but no least, if the user files the emailaddress in the Nextcloud account the Rainloop webinterface logs in automatically.
Well at least it will when this Pull Request is merged :-P

Reference: on