I finally got around to setting up a new mailserver and i decided to give OpenSMTPD a try. It wasn’t a natural birth, i can tell you that. The switching of the configuration syntax makes for a lot of outdated Google Search results.

So what are we going to setup. Well the title gave it away i guess, so for the slow ones amongst you: we are building a Mailserver with OpenSMTPD, Dovecot, RSpamd and Sieve. The OpenSMTPD and the Dovecot will both be using the same authentication table and hashing scheme, making this a nifty solution.

Installing the required components

pkg_add postgresql-server opensmtpd-extras opensmtpd-extras-pgsql opensmtpd-filter-rspamd opensmtpd-filter-senderscore rspamd dovecot dovecot-pigeonhole dovecot-postgresql redis

Enabling them on boot

rcctl enable httpd
rcctl enable smtpd
rcctl enable postgresql
rcctl enable rspamd
rcctl enable dovecot
rcctl start dovecot
rcctl enable redis
rcctl start redis

Setting up DNS

This has been explained in numerous posts on the Internet, you should by now know how to setup an MX Record (maybe SPF and DKIM).

Setting up Let’s Encrypt SSL Certificates


Configure httpd to do the acme challenges.

server "replace.with.host.name" {
        listen on * port 80
        location "/.well-known/acme-challenge/*" {
                root "/acme"
                request strip 2
        location "/" {
                block return 301 "https://$SERVER_NAME$REQUEST_URI"

And then start httpd:

rcctl start httpd


Now we go on to configure the acme-client.

authority letsencrypt {
        api url $api_url
        account key "/etc/acme/letsencrypt-privkey.pem"

domain replace.with.host.name {
        #alternative names { www.replace.with.host.name }
        domain key "/etc/ssl/private/replace.with.host.name.key"
        #domain certificate "/etc/ssl/replace.with.host.name.crt"
        domain full chain certificate "/etc/ssl/replace.with.host.name.crt"
        sign with letsencrypt

Obtaining a certificate

acme-client -v replace.with.host.name

Adding certificate renewal to cron

Enter the crontab with crontab -e and add the following line:

30      0       *       *       *       /usr/sbin/acme-client replace.with.host.name && /usr/sbin/rcctl restart smtpd && /usr/sbin/rcctl restart dovecot

Preparations for our services


Go ahead and add the following lines at the end of your /etc/login.conf:



Once done, have the file cap_mkdb’d like this:

cap_mkdb /etc/login.conf


Append the following values to /etc/sysctl.conf so PostgreSQL has a bit of breathing room:


Then go on to actually setting them in the kernel:

sysctl -w kern.seminfo.semmni=60 kern.seminfo.semmns=1024

Adding a vmail user and group

useradd -m -d /var/vmail -s /sbin/nologin vmail

Preparing PostgreSQL

su - _postgresql
mkdir /var/postgresql/data
initdb -D /var/postgresql/data -U postgres -A scram-sha-256 -E UTF8 -W
rcctl start postgresql

Next we are going to add a user, a database, two tables and three views:

psql -Upostgres <<EOF
CREATE USER mail WITH ENCRYPTED PASSWORD 'your.mail.password';

psql -Umail mail <<EOF

-- this is the table for the users accounts
CREATE TABLE public.accounts (
    id serial,
    email character varying(255) DEFAULT ''::character varying NOT NULL,
    password character varying(255) DEFAULT ''::character varying NOT NULL,
    active boolean DEFAULT true NOT NULL

-- this is the table for the virtual mappings for email -> email
CREATE TABLE public.virtuals (
    id serial,
    email character varying(255) DEFAULT ''::character varying NOT NULL,
    destination character varying(255) DEFAULT ''::character varying NOT NULL

-- this view is used to determine where to deliver things
CREATE VIEW public.delivery AS
 SELECT virtuals.email,
   FROM public.virtuals
  WHERE (length((virtuals.email)::text) > 0)
 SELECT accounts.email,
    'vmail'::character varying AS destination
   FROM public.accounts
  WHERE (length((accounts.email)::text) > 0);

-- this view is used to determine which domains this server is serving
CREATE VIEW public.domains AS
 SELECT split_part((virtuals.email)::text, '@'::text, 2) AS domain
   FROM public.virtuals
  WHERE (length((virtuals.email)::text) > 0)
  GROUP BY (split_part((virtuals.email)::text, '@'::text, 2))
 SELECT split_part((accounts.email)::text, '@'::text, 2) AS domain
   FROM public.accounts
  WHERE (length((accounts.email)::text) > 0)
  GROUP BY (split_part((accounts.email)::text, '@'::text, 2));

-- this view should control the email addresses users can send with
CREATE VIEW public.sending AS
 SELECT virtuals.email,
    virtuals.destination AS login
   FROM public.virtuals
  WHERE (length((virtuals.email)::text) > 0)
 SELECT accounts.email,
    accounts.email AS login
   FROM public.accounts
  WHERE (length((accounts.email)::text) > 0);


Next we configure the PostgreSQL lookups for smtpd:

conninfo host='localhost' user='mail' password='your.mail.password' dbname='mail'
query_alias SELECT "destination" FROM delivery WHERE "email"=$1;
query_credentials SELECT "email", "password" FROM accounts WHERE "email"=$1;
query_domain SELECT "domain" FROM domains WHERE "domain"=$1;
query_mailaddrmap SELECT "email" FROM sending WHERE "login"=$1;

Also since this file contains the password to the database, only _smtp should be able to read it:

chown _smtpd:_smtpd /etc/mail/postgres.conf
chmod o= /etc/mail/postgres.conf


Now we can go ahead and configure OpenSMTPD:

table aliases file:/etc/mail/aliases
table auths postgres:/etc/mail/postgres.conf
table domains postgres:/etc/mail/postgres.conf
table virtuals postgres:/etc/mail/postgres.conf
table sendermap postgres:/etc/mail/postgres.conf

pki replace.with.host.name cert "/etc/ssl/replace.with.host.name.crt"
pki replace.with.host.name key "/etc/ssl/private/replace.with.host.name.key"

filter check_dyndns phase connect match rdns regex { '.*\.dyn\..*', '.*\.dsl\..*' } \
    disconnect "550 no residential connections"

filter check_rdns phase connect match !rdns \
    disconnect "550 no rDNS is so 80s"

filter check_fcrdns phase connect match !fcrdns \
    disconnect "550 no FCrDNS is so 80s"

filter senderscore \
    proc-exec "filter-senderscore -blockBelow 10 -junkBelow 70 -slowFactor 5000"

filter rspamd proc-exec "filter-rspamd"

listen on all tls pki replace.with.host.name filter { check_dyndns, check_rdns, check_fcrdns, senderscore, rspamd }
#listen on all port smtps smtps pki replace.with.host.name auth <auths> senders <sendermap> masquerade
#listen on all port submission tls-require pki replace.with.host.name auth <auths> senders <sendermap> masquerade
listen on all port smtps smtps pki replace.with.host.name auth <auths>
listen on all port submission tls-require pki replace.with.host.name auth <auths>

action "receive_aliases"        lmtp "/var/dovecot/lmtp" rcpt-to alias <aliases>
match from local for local action "receive_aliases"

action "receive_vmail"          lmtp "/var/dovecot/lmtp" rcpt-to virtual <virtuals>
match from any for domain <domains> action "receive_vmail"

action "outbound"               relay helo replace.with.host.name
match from auth for any action "outbound"

And finally start the smtpd:

rcctl start smtpd


In this file i actually just changed the spam_header to X-Spam-Status, but this optional.


greylist {
        servers = "";
        timeout = 1min;

Then we start rspamd:

rcctl start rspamd

Diff style below here

I’ve chosen to only put in things you need to change or append, everything else should remain as is.

Why did i do this? Well since dovecot has evolved into this nice configuration-file layout, i decided that this is the most efficient way to keep this document clean and relevant.


Towards the beginning of the file, disable plaintext authentication:

disable_plaintext_auth = yes

Then at the end of the file, there are several includes. We are going to comment the auth-system.conf.ext and are going to comment in the auth-sql.conf.ext instead:

#!include auth-system.conf.ext
!include auth-sql.conf.ext


We change the mailbox location to our vmail directory.

mail_location = maildir:/var/vmail/%u


Next we are going to comment in our SSL listeners, feel free to leave in 143 and 110 as they are using STARTTLS:

service imap-login {
  inet_listener imap {
    port = 143
  inet_listener imaps {
    port = 993
    ssl = yes

service pop3-login {
  inet_listener pop3 {
    port = 110
  inet_listener pop3s {
    port = 995
    ssl = yes

Then make sure lmtp is configured with the correct permissions:

service lmtp {
  unix_listener lmtp {
    mode = 0660
    user = vmail
    group = vmail


Now we are going to configure SSL:

ssl = required

ssl_cert = </etc/ssl/replace.with.host.name.crt
ssl_key = </etc/ssl/private/replace.with.host.name.key

ssl_prefer_server_ciphers = yes


Next up is our local delivery agent (LDA):

postmaster_address = postmaster@your.domain
hostname = replace.with.host.name

lda_mailbox_autocreate = yes


Now we configure our LMTP for Sieve:

protocol lmtp {
  # Space separated list of plugins to load (default is global mail_plugins).
  mail_plugins = $mail_plugins sieve


Here we configure the Sieve plugin itself:

plugin {
  sieve_plugins = sieve_imapsieve sieve_extprograms
  sieve_global_extensions = +vnd.dovecot.pipe +vnd.dovecot.environment

  imapsieve_mailbox1_name = Junk
  imapsieve_mailbox1_causes = COPY APPEND
  imapsieve_mailbox1_before = file:/usr/local/lib/dovecot/sieve/report-spam.sieve

  imapsieve_mailbox2_name = *
  imapsieve_mailbox2_from = Junk
  imapsieve_mailbox2_causes = COPY
  imapsieve_mailbox2_before = file:/usr/local/lib/dovecot/sieve/report-ham.sieve

  imapsieve_mailbox3_name = Inbox
  imapsieve_mailbox3_causes = APPEND
  imapsieve_mailbox3_before = file:/usr/local/lib/dovecot/sieve/report-ham.sieve

  sieve_pipe_bin_dir = /usr/local/lib/dovecot/sieve


Here we are going to fill in the default action for when we move files to spam folder. In our case we learn them as spam:

require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"];

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

pipe :copy "sa-learn-spam.sh" [ "${username}" ];


Fill the file with the following contets:

exec /usr/local/bin/rspamc -d "${1}" learn_spam


Here we are going to fill in the default action for when we move files out of the spam folder. In our case we learn them as ham:

require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"];

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

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

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

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


Fill the file with the following contets:

exec /usr/local/bin/rspamc -d "${1}" learn_ham

making them both executable

To make them runnable by the system, we make them executable:

chmod +x /usr/local/lib/dovecot/sieve/*.sh


And here we configure the sieve folder that gets used for everyone.

sieve_before = /var/vmail/sieve/


First create that folder:

mkdir -p /var/vmail/sieve
chown -R vmail:vmail /var/vmail

Then we sieve away the spam:

require "fileinto";
if header :contains "X-Spam-Status" "YES" {
    fileinto "Junk";


Here we configure our override fields, so we don’t have to do an ugly select:

userdb {
  driver = sql
  args = /etc/dovecot/dovecot-sql.conf.ext
  override_fields = uid=vmail gid=vmail home=/var/vmail/%u


Now are almost done, we now configure how dovecot accesses the database. Append this to the end:

driver = pgsql
connect = dbname=mail user=mail password=your.mail.password
default_pass_scheme = CRYPT

password_query = SELECT email AS user, '{CRYPT}' || password AS password FROM accounts WHERE active = true AND email = '%u' AND email != '' AND password != ''
user_query = SELECT email FROM delivery WHERE email = LOWER('%u')


Last but not least we update the protocols we are going to use:

protocols = imap pop3 lmtp

And finally start the dovecot:

rcctl start dovecot

Adding Accounts and Aliases

To generate securely hashed passwords, you can use “smtpctl encrypt” and then enter your password. The resulting hash can be used as replacement for PASSWORD:

INSERT INTO accounts (email,password) VALUES ('my@first.email.address','PASSWORD');
INSERT INTO virtuals (email,destination) VALUES ('my@second.mail.address','my@first.email.address');

That’s it

You should now be able to use this setup as expected.

If you find any errors, you can find me on Twitter and let me know!