Sign in

My OpenSMTPD setup on FreeBSD

Here you can see how I’ve set up my personal OpenSMTPD mail server with the three domains of kfv.io, jail.io, and irbug.org on a FreeBSD box. Besides OpenSMTPD and some filters, I use Dovecot as my IMAP server and Pigeonhole project for Sieve Support for Dovecot — by which you can customise how messages are delivered and whereto they belong by writing simple Sieve scripts. You can also write Sieve scripts to handle auto-replies when you’re on vacation or if it’s your birthday. I also use Rspamd for DKIM signing and spam filtering. This document doesn’t mean to be a complete guide and will be updated whenever I make any changes to the setup. Therefore you should read official manuals to understand how to make the most of each component in this setup to fit your need.

Install the required packages:

# pkg install mail/opensmtpd mail/opensmtpd-filter-senderscore mail/opensmtpd-filter-rspamd mail/opensmtpd-extras-table-sqlite mail/rspamd

I didn’t install Dovecot from packages since it needs SQLite support, so I do it from ports (I assume you know ports) and select necessary modules:

# portmaster --packages-build mail/dovecot
# portmaster --packages-build mail/dovecot-pigeonhole

The latter needs no change. But to keep it in sync with mail/dovecot and avoid version conflicts, I build it from ports as well.

By the way, I use ports-mgmt/portmaster for making and managing ports. You could use your favourite tool or do make -C /path/to/port install clean.

Now I do need to lock these two ports or otherwise if they get upgraded by pkg upgrade, it is possible to lose corresponding modules and face a failure; we lock the ports, unlock them when planning for an upgrade, and lock them again when done.

# pkg lock mail/dovecot
# pkg lock mail/dovecot-pigeonhole

You’ve probably heard about DomainKeys Identified Mail (DKIM). It lets the owner of the signing domain claim responsibility, so the recipient ensures the owner has sent and authorised the message and the message is not altered in transit by any means. Validation of the assertion of responsibility happens through checking a cryptographic signature where the public key could be retrieved by querying the signer’s DNS entries. In short, DKIM signs messages by computing two hashes, one over the body of the message and the other over the selected header fields.
To further understand its mechanism, you could study RFC6376. It is worth mentioning that the defining standard specifies a single signing algorithm, RSA, which is considered weak. RFC8463 has clearly indicated the necessity of implementing the stronger Edwards-Curve Digital Signature Algorithm (EdDSA) using the Curve25519 curve, i.e. Ed25519.
Despite the fact, the signature is not yet widely supported and we, unfortunately, cannot solely rely on Ed25519 signatures as it takes time for validators to implement its support.

To generate RSA keys of 2048 bits for each domain under /urs/local/etc/mail/dkim (you should create the directory):

# openssl genrsa -out /usr/local/etc/mail/dkim/kfv.io.key 2048
# openssl genrsa -out /usr/local/etc/mail/dkim/jail.io.key 2048
# openssl genrsa -out /usr/local/etc/mail/dkim/irbug.org.key 2048

Public keys are also necessary for updating the DNS entries (discussed later):

# openssl rsa -in /usr/local/etc/mail/dkim/kfv.io.key -pubout -out /usr/local/etc/mail/dkim/kfv.io.pub
# openssl rsa -in /usr/local/etc/mail/dkim/jail.io.key -pubout -out /usr/local/etc/mail/dkim/jail.io.pub
# openssl rsa -in /usr/local/etc/mail/dkim/irbug.org.key -pubout -out /usr/local/etc/mail/dkim/irbug.org.pub

Please keep in mind that you need to change the public keys’ mode to 0644, or the will not be readable.

OK, now it’s time to make the /usr/local/etc/rspamd/local.d directory and therein create the dkim_signing.conf file with the following data:

allow_username_mismatch = true;

domain {
kfv.io {
path = "/usr/local/etc/mail/dkim/kfv.io.key";
selector = "dkim";
}
}
domain {
jail.io {
path = "/usr/local/etc/mail/dkim/jail.io.key";
selector = "dkim";
}
}
domain {
irbug.org {
path = "/usr/local/etc/mail/dkim/irbug.org.key";
selector = "dkim";
}
}

We will come back to the DKIM setup later.

Now let’s review some basic differences between two of the widely used file formats for storing email messages. The traditional UNIX mbox, and the Maildir. The mbox has been around for a long time and it has its use-cases, but for a public electronic mail server? It is wholly inadequate and not recommended. Individual messages are concatenated together and saved in a single file with a special marker placed where a message ends and the next begins. So as you might have guessed already, modifications are error-prone and you are very likely to experience a corrupted mbox database. You should also remember only one process can access mbox in read/write mode, and concurrent access requires a locking mechanism where most nightmares stem from. On the other hand, we’ve got Maildir, something Daniel J. Bernstein made when working on the Qmail project to presumably save us in complex scenarios. Contrary to mbox, Maildir is an atomic solution storing individual messages in separate files where no locking mechanism is required and simultaneous access is nothing extraordinary.
Undoubtedly, none of these facts means mbox should be abandoned. It has its use- cases and in some rare cases that Maildir might face degradation over time, mbox could be even better. I’ve never had any issue with Maildir in either small- or medium- to large-sized environments. Therefore I mostly go with Maildir when setting up a public electronic mail server to keep it as clean and corruption-free as possible. There are some other file formats that I haven’t used personally, please email me if there’s anything I must try.

I create a vmail account to whom my virtual users will be mapped. I then store messages with Maildir format under vmail’s home directory in <domain>/<username> pattern. To create the account:

# pw adduser -n vmail -c "Virtual Mail" -d /var/vmail -s /sbin/nologin -m

Now I create the smtp.sqlite database under /usr/local/etc/mail/ as follows:

CREATE TABLE domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain VARCHAR(255) NOT NULL
);

CREATE TABLE virtuals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email VARCHAR(255) NOT NULL,
destination VARCHAR(255) NOT NULL
);

CREATE TABLE credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL
);

Now a configuration file is required so smtpd could know how to read from the database, so I create /usr/local/etc/mail/sqlite.conf with the following data:

dbpath /usr/local/etc/mail/smtp.sqlite
query_alias SELECT destination FROM virtuals WHERE email=?;
query_credentials SELECT email, password FROM credentials WHERE email=?;
query_domain SELECT domain FROM domains WHERE domain=?;

Now I am ready to configure OpenSMTPD in /usr/local/etc/mail/smtpd.conf:

# OpenSMTPD Configuration File

# --- Macros
ext_if = "vtnet0"
mxaddr = "mail.kfv.io"

# --- TLS Certificates
pki $mxaddr cert '/usr/local/etc/mail/pki/kfv.io/fullchain.cer'
pki $mxaddr key '/usr/local/etc/mail/pki/kfv.io/kfv.io.key'

pki mail.jail.io cert '/usr/local/etc/mail/pki/jail.io/fullchain.cer'
pki mail.jail.io key '/usr/local/etc/mail/pki/jail.io/jail.io.key'

pki mail.irbug.org cert '/usr/local/etc/mail/pki/irbug.org/fullchain.cer'
pki mail.irbug.org key '/usr/local/etc/mail/pki/irbug.org/irbug.org.key'

# --- Tables
table domains sqlite:/usr/local/etc/mail/sqlite.conf
table virtuals sqlite:/usr/local/etc/mail/sqlite.conf
table credentials sqlite:/usr/local/etc/mail/sqlite.conf

# --- Filter
filter check_dyndns phase connect match rdns \
regex { '.*\.dyn\..*', '.*\.dsl\..*' } junk

filter check_rdns phase connect match !rdns \
disconnect "550 no rDNS available"

filter check_fcrdns phase connect match !fcrdns \
disconnect "550 no FCrDNS available"

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

filter rspamd proc-exec "opensmtpd-filter-rspamd"

# --- Listen
listen on $ext_if tls pki $mxaddr \
filter { check_dyndns, check_rdns, check_fcrdns, senderscore, rspamd }

listen on $ext_if port submission tls-require pki $mxaddr auth filter rspamd

# --- Actions Definition
action "inbound" maildir "/var/vmail/%{dest.domain}/%{dest.user}" junk virtual
action "outbound" relay helo $mxaddr

# --- Matching Rules
match from any for domain action "inbound"
match for local action "inbound"

match from any auth for any action "outbound"
match for any action "outbound"

I use acme.sh to get and manage Let’s Encrypt certificates and certs under /usr/local/mail/pki/ are copied and chowned from /var/db/acme/certs/ with a cron job running once a week after renewal.

Good, let’s point to some lines before going for Dovecot’s configuration. First of all, the filter rules could be changed as you please. You can either disconnect if a rule is matched or mark junk. The senderscore and rspamd filters are both well documented in their official repositories. The whole configuration file is also well documented in smtpd.conf(5). You can also see what I said earlier about the <domain>/<username> pattern in action inbound rule, you will also see the pattern in auth-sql.conf.ext for Dovecot.

But what if you do /var/vmail/%{dest.user}? Well, in this case, you will not be able to have users with the same name in different domains or they will overlap. Even if you could somehow fix it, it would be a messy setup that you’d later regret — consider a migration, backup, manual check, etc.

To begin Dovecot’s configuration, I first copy example-config files to the base configuration directory:

# cp -r /usr/local/etc/dovecot/example-config/{,..}

I take care of my domains’ certificates in conf.d/10-ssl.conf by declaring my kfv.io as the primary certificate and the rest in local_name blocks to handle TLS SNIs:

ssl_cert = </usr/local/etc/mail/pki/kfv.io/fullchain.cer
ssl_key = </usr/local/etc/mail/pki/kfv.io/kfv.io.key

local_name mail.jail.io {
ssl_cert = </usr/local/etc/mail/pki/jail.io/fullchain.cer
ssl_key = </usr/local/etc/mail/pki/jail.io/jail.io.key
}

local_name mail.irbug.org {
ssl_cert = </usr/local/etc/mail/pki/irbug.org/fullchain.cer
ssl_key = </usr/local/etc/mail/pki/irbug.org/irbug.org.key
}

Next, the include line for auth-sql.conf.ext must be uncommented and the one for auth-system.conf.ext commented out in conf.d/10-auth.conf:

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

Then the userdb block in conf.d/auth-sql.conf.ext should come as follows:

userdb {
driver = static
args = uid=vmail gid=vmail home=/var/vmail/%d/%n
}

To use and connect to the database, the following lines are required in dovecot-sql.conf.ext:

driver = sqlit
connect = /usr/local/etc/mail/smtp.sqlite
default_pass_scheme = BLF-CRYPT

To train Rspamd, add the imap_sieve plugin to the imap protocol block in conf.d/20-imap.conf:

protocol imap {
mail_plugins = $mail_plugins imap_sieve
}

And to set it up, the following block is required in conf.d/90-plugin.conf:

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
}

Sieve scripts are not so difficult to write and understand, and in this document, I do only include two simple scripts I’ve found so much useful and used by many. Once you move an email in or out of the junk folder it triggers an event to train Rspamd about that user being spam or not (ham). Scripts reside in the /usr/local/lib/dovecot/sieve directory. But before writing the scripts, let’s take a quick look at the imapsieve lines we wrote in 90-plugin.conf. What we’re doing is very simple, if we move something to Junk, we consider it as spam, if we move something out of Junk, we surely consider it ham (non-spam), and if something comes and remains in Inbox, it is definitely considered safe and therefore ham. Now let’s take a look at script files.

The report-spam.sieve file looks like this:

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

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

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

The report-ham.sieve:

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

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

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

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

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

The sa-learn-spam.sh:

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

And the sa-learn-ham.sh:

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

Now it’s required to compile the scripts:

# sievec /usr/local/lib/dovecot/sieve/report-spam.sieve
# sievec /usr/local/lib/dovecot/sieve/report-ham.sieve

Good. To enable services, execute the following command:

# sysrc {smtpd,dovecot,rspamd}_enable="YES"

Well, I do need to add domains, users, and their credentials to the database. But first, we need to remember that we are using the Blowfish crypt (bcrypt) scheme and therefore, we must correctly generate the hash. You can easily do so by using doveadm -s BLF-CRYPT. It asks you to enter and retype a new password and then does it output the hash. Remove the {BLF-CRYPT} portion and copy the rest for the next step.

I connect to the database and add an entry for each domain:

INSERT INTO domains (domain) VALUES ("kfv.io");
INSERT INTO domains (domain) VALUES ("jail.io");
INSERT INTO domains (domain) VALUES ("irbug.org");

Then I create my users. If the destination is another email address, it acts as a forwarding rule. And if it is vmail, it is indeed a virtual user you will use for sending and receiving emails. Let’s take a look at some of my queries:

INSERT INTO virtuals (email, destination) VALUES ("postmaster@kfv.io", "kfv@kfv.io");
INSERT INTO virtuals (email, destination) VALUES ("contact@kfv.io", "kfv@kfv.io");
INSERT INTO virtuals (email, destination) VALUES ("abuse@kfv.io", "kfv@kfv.io");
INSERT INTO virtuals (email, destination) VALUES ("noreply@kfv.io", "vmail");
INSERT INTO virtuals (email, destination) VALUES ("kfv@kfv.io", "vmail");
INSERT INTO virtuals (email, destination) VALUES ("bot@kfv.io", "vmail");

I keep doing the same for my jail.io and irbug.org users, but instead of following the auto-incremented IDs, I add them manually to keep them distant. As an example, I will have my postmaster@jail.io created like this:

INSERT INTO virtuals (id, email, destination) VALUES (101, "postmaster@jail.io", "kfv@jail.io");

The credentials table is no different, an example is:

INSERT INTO credentials (email, password) VALUES ("kfv@kfv.io", "<BLF-CRYPT HASH>");

Alright, I am ready to use my mail server, but there’s still one important step and then It’ll be ready. Yes, DNS entries. I need to add an A record for my mx1.kfv.io (I may later update this document with IPv6 settings), a CNAME for mail.kfv.io to mx1.kfv.io, a TXT record for my kfv.io where I redirect SPF queries to _spf.kfv.io, a TXT record for the DKIM selector dkim at dkim._domainkey.kfv.io that I put my public key inside, and also a TXT record to specify my DMARC policy at _dmarc.kfv.io. Feel free to query all of the mentioned addresses to understand them better.
For jail.io and irbug.org domains, I no more need an A record, consequently no CNAME either. I simply add an MX record for @ to mail.kfv.io, and TXT records of @, _spf, dkim._domainkey, and _dmarc. But let’s quickly see what SPF and DMARC are.

They are both email authentication methods enhancing your email security and giving you the ability to protect your domains from unauthorised use, in other words, email spoofing. Sender Policy Framework, as its name implies, sets a policy to specify which email servers are permitted to send email messages on your behalf. I use a simple rule that I am sure you understand well and there is no need for further explanation, but in case you’d like to learn more about SPF see what else (in complex scenarios) you can use in your records, follow the standard at RFC7208.
SPF alone is not so much helpful, you also need a Domain-based Message Authentication, Reporting, and Conformance (DMARC) that mail-originating organisation can express domain-level policies for message validation, disposition, and reporting, that the recipient can use to improve mail-handling. Again, I’m not using a complicated policy, it’s a simple reject policy where I’ve specified to what address I like aggregate feedback (reports) be sent. But you can’t always use such small and simple policies and you are therefore encouraged to study the standard RFC7489.

Whenever done with DNS entries, you are ready to start services and test your setup.

Take a break and enjoy your day!

A futurist C/C++ developer who loves mathematics, political philosophy, and art.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store