Nerd-oriented Guide on Setting up a Mail Server on FreeBSD

2026-08-19

I used to run luke smiths’ script to set up my mail server on my vps.

But since it’s fully automated and I don’t understand everything and got scared, I don’t dare to touch the configuration ever after.

This time, I am using FreeBSD and I will try to figure everything out and do it the manual way, so that hopefully I can better debug / upgrade / customize this later.

The structure and most of the content is based on luke smiths’ landchad guide1. I try to use FreeBSD’s way of setting things up as much as possible.

Some parts of this also referred to Gentoo wiki2

The end result should be:

The stability of services

When restarting the servers and the services, the Email server may malfunction at the start. Don’t worry about this, and wait for a few minutes, then it should be good to go.

Sending mail with SMTP

Use postfix to send emails.

pkg install postfix

Follow the post install message and copy the default config:

Postfix was *not* activated in /usr/local/etc/mail/mailer.conf!

To finish installation run the following commands:

  mkdir -p /usr/local/etc/mail
  install -m 0644 /usr/local/share/postfix/mailer.conf.postfix //usr/local/etc/mail/mailer.conf

And enable it

sysrc postfix_enable="YES"
sysrc sendmail_enable="NONE" # Already done by bsd

Then, follow the bundled documentation from /usr/local/share/doc/postfix/BASIC_CONFIGURATION_README.

At this stage, you can already edit aliases for who receive the mail, see postfix/aliases.

Add a alias from DMARC according your DMARC configuration.

After editing, run newaliases.

REMEMBER TO RUN that, otherwise a error will appear:

error: open database /etc/aliases.db: No such file or directory

In the landchad website it wants you to install mailutils, it’s already present on FreeBSD. 1

Start the daemon and test it with mail(1):

echo "test message" | mail -s "test email sending" <recipient_address_here>

Check the log:

tail /var/log/maillog

You’ll likely find out that the mail has been rejected:

Aug 19 14:56:38 vultr postfix/qmgr[8860]: A83699CA20: from=<root@vps>, size=333, nrcpt=1 (queue active)
Aug 19 14:56:39 vultr postfix/smtp[8863]: A83699CA20: host <dest_mail_provider>[1.1.1.1] said: 450 4.1.8 <root@vps>: Sender address rejected: Domain not found (in reply to RCPT TO command)

Prove I am I

The log indicates that it doesn’t know the domain from the IP.

Reverse DNS

We need to set up the reverse DNS on the VPS side that points the outbound IP to the DNS address.

See the landchad link for pictures:

https://landchad.net/mail/rdns/

Set up DKIM

rDNS itself is not enough, we need DKIM to sign our messages to associate the message with the domain.

This basically proves that you are you cryptographically, to prevent people from impersonating as someone else.

See https://landchad.net/mail/validate/ or https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail for an introduction to DKIM.

pkg install opendkim

Follow the post install message:

=====
Message from opendkim-2.10.3_23:

--
In order to run this port, write your opendkim.conf and:

if you use sendmail, add the milter socket `socketspec' in
/etc/mail/<your_configuration>.mc:

INPUT_MAIL_FILTER(`dkim-filter', `S=_YOUR_SOCKET_SPEC_, F=T, T=R:2m')

or if you use postfix write your milter socket `socketspec' in
/usr/local/etc/postfix/main.cf:

smtpd_milters = _YOUR_SOCKET_SPEC_


And to run the milter from startup, add milteropendkim_enable="YES" in
your /etc/rc.conf.
Extra options can be found in startup script.

Note: milter sockets must be accessible from postfix/smtpd;
  using inet sockets might be preferred.

opendkim is installed at at /usr/local/etc/mail/opendkim.conf

Set up keys

There’s an option to use a single Keyfile or a KeyTable. I use KeyTable, so that I can potentially sign my other domains. This is the same choice as the landchad tutorial.

##  KeyFile filename
##      default (none)
##
##  Specifies the path to the private key to use when signing.  Ignored if
##  SigningTable and KeyTable are used.  No default; must be specified for
##  signing if SigningTable/KeyTable are not in use.

# KeyFile           /var/db/dkim/example.private

##  KeyTable dataset
##      default (none)
##
##  Defines a table that will be queried to convert key names to
##  sets of data of the form (signing domain, signing selector, private key).
##  The private key can either contain a PEM-formatted private key,https://wiki.gentoo.org/wiki/Special:MyLanguage/Complete_Virtual_Mail_Server
##  a base64-encoded DER format private key, or a path to a file containing
##  one of those.

KeyTable        dataset

To generate them, run:

cd /usr/local/etc/mail/
mkdir dkim && cd dkim

opendkim-genkey --domain=<your domain>

Since BSD doesn’t bundle a user for this package, add a user for this and set the permission for the folder.

Configure the daemon to use the user and group for it.

There is a selector option that specifies sub-domains(?). It’s default value is default.

More information on https://datatracker.ietf.org/doc/html/rfc6376#section-3.1

Now, link the keys to a KeyTable and SigningTable:

Documentation of their structure is in opendkim.conf(5)

echo "<name_of_your_choice> <domain>:<sector>:/usr/local/etc/mail/dkim/<sector>.private" > key_table
echo "*@<domain> <name_from_key_table>" > signing_table

Now, add the tables to the config.

SigningTable    refile:/usr/local/etc/mail/dkim/signing_table

KeyTable    file:/usr/local/etc/mail/dkim/key_table

refile: means the file is a pattern. see opendkim(8) “DATA SETS”

Finish setting up by modifying configurations that’s not commented.

Take note of the Socket setting, set it to a local inet port.

Socket          inet:12301@localhost

Also, set up a user for its operation.

adduser
# Enter information and add it to mail group

Add the following to rc.conf

milteropendkim_enable="YES"
milteropendkim_uid="<uid>"
milteropendkim_gid="<gid>"

Set up more stuff

Now, we need to make these software play together.

DKIM is going to expose a listening socket at localhost port 12301, connect postfix to it.

# For external mail that arrive from smtp
smtpd_milters = inet:localhost:12301
# For internal unix mails
non_smtpd_milters = inet:localhost:12301

Set up TXT record for DKIM’s public key.

For receivers, they need to verify the sender (us) ’s identity, which is by looking up a TXT record from our server’s domain name.

Find the TXT record from /usr/local/etc/mail/dkim/default.txt

The content inside of the quotes will be the text of TXT record. Copy paste them into the TXT record of

<selector>._domainkey.<your_domain>

The specs is here: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail#Verification

Use some online tool to verify that the TXT record is valid according to the spec in Wikipedia.

Set up DMARC message to guide what to do when mail failed.

DMARC is basically telling others what authentication you’re using, and what to do when authentication failed.3

We add a mail user to receive violation reports, and specify the actions according to the specification:

v=DMARC1; p=reject; adkim=s; aspf=s; rua=mailto:dmarc@<your_mail_domain>; fo=1

The (simplified) specs is here: https://en.wikipedia.org/wiki/DMARC#DNS_record

The original RFC standard for the parameters: https://www.rfc-editor.org/info/rfc9989/#name-dmarc-policy-record-format

Add it to a TXT record for sub-domain _dmarc.

And remember to add a dmarc alias to some users (prolly root)

Set up a SPF policy

This makes your email domain less likely to be used as spamming.

See more here: https://en.wikipedia.org/wiki/Sender_Policy_Framework#Reasons_to_implement

Add the following to TXT record for the domain of mail server:

v=spf1 mx a:boxstr.nl ip4:<ip_4_addr> ip6:<ip_6_addr> -all

The IP addresses should match the one in reverse DNS.

Test if it works

Refer to the previous chaptor to try it out.

Also, the following site can be used and check if your mail is in a spam list:

https://appmaildev.com/en/spf

Virtual domains with UNIX accounts

We can update postfix to use shared domains.

Documentation for postfix can be found at /usr/local/share/doc/postfix/VIRTUAL_README

TODO: figure this out one day

Remotely access your inbox (IMAP)

Now, SMTP has been finished. This server can send mails just fine. We need to access incoming mail with a email client using the IMAP protocol.

There are other protocols, but IMAP is the most modern, has the most features and is the most widely used one. 4

pkg install dovecot

The postinstall messages has said dovecot is installed with UNIX authentication, and mails will be put into /var/mail/$USER directory. I’m fine with it, and I’m not gonna do anything about it:

=====
Message from cyrus-sasl-2.1.28_6:

--
You can use sasldb2 for authentication, to add users use:

    saslpasswd2 -c username

If you want to enable SMTP AUTH with the system Sendmail, read
Sendmail.README

NOTE: This port has been compiled with a default pwcheck_method of
      auxprop.  If you want to authenticate your user by /etc/passwd,
      PAM or LDAP, install ports/security/cyrus-sasl2-saslauthd and
      set sasl_pwcheck_method to saslauthd after installing the
      Cyrus-IMAPd 2.X port.  You should also check the
      /usr/local/lib/sasl2/*.conf files for the correct
      pwcheck_method.
      If you want to use GSSAPI mechanism, install
      ports/security/cyrus-sasl2-gssapi.
      If you want to use SRP mechanism, install
      ports/security/cyrus-sasl2-srp.
      If you want to use LDAP auxprop plugin, install
      ports/security/cyrus-sasl2-ldapdb.
=====
Message from openldap26-client-2.6.13:

--
The OpenLDAP client package has been successfully installed.

Edit
  /usr/local/etc/openldap/ldap.conf
to change the system-wide client defaults.

Try `man ldap.conf' and visit the OpenLDAP FAQ-O-Matic at
  http://www.OpenLDAP.org/faq/index.cgi?file=3
for more information.
=====
Message from dovecot-2.3.21.1_3:

--
You must create the configuration files yourself. Copy them over
 to /usr/local/etc/dovecot and edit them as desired:

    cp -R /usr/local/etc/dovecot/example-config/* \
        /usr/local/etc/dovecot

 The default configuration includes IMAP and POP3 services, will
 authenticate users agains the system's passwd file, and will use
 the default /var/mail/$USER mbox files.

 Next, enable dovecot in /etc/rc.conf:

    dovecot_enable="YES"


 To avoid a risk of mailbox corruption, do not set the
 security.bsd.see_other_uids or .see_other_gids sysctls to 0
 if Dovecot is storing mail for multiple concurrent users (PR 218392).

 Similarly, setting sysctls security.bsd.hardlink_check_uid or
 security.bsd.hardlink_check_gid to 1 might result in non-working
 mailboxes, depending on what mailbox locking mechanism is used
 (PR 242223).

 If you want to be able to search within attachments using the
 decode2text plugin, you'll need to install textproc/catdoc, and
 one of graphics/xpdf or graphics/poppler-utils.


 There are some potentially breaking changes in Dovecot 2.3. If you
 are upgrading from Dovecot 2.2:

   * https://doc.dovecot.org/2.3/installation_guide/upgrading/from-2.2-to-2.3/
   * Merge the configuration file changes from
     /usr/local/etc/dovecot/examples-config/

Ignore the ones from cyrus-sasl and openldap for now.

Follow the message and copy the configuration files:

cp -R /usr/local/etc/dovecot/example-config/* /usr/local/etc/dovecot

Make sure uid / gid hiding is not set:

sysctl security.bsd.see_other_uids
sysctl security.bsd.see_other_gids

None of the above should return 1.

You can edit the default mail-boxes now, by editing /usr/local/etc/dovecot/conf.d/15-mailboxes.conf.

Usually the auto option should be set there!

Luke’s guide5 has some more settings, but they are redundant to me, I’ve omitted them.

Also set up the mail folder from 10-mail.conf, in mail_location:

mail_location = maildir:~/Maildir

This is the standard configuration. 6

Interop with Postfix with SASL

Now, we need to connect this with postfix. 7

Edit 10-master.conf:

service auth {
    # Uncomment the postfix part
    # And allow postfix to have full access to all lookups (might not be required?)
    unix_listener /var/spool/postfix/private/auth {
        mode = 0666
        user = postfix
        group = postfix
    }
}

From postfix’s configuration:

# Interop with dovecot
# smtpd_sasl_auth_enable = yes
# smtpd_sasl_type = dovecot
# smtpd_sasl_path = private/auth
mailbox_command = /usr/local/libexec/dovecot/deliver

The sasl can be skipped, since we will user Dovecot’s submission proxy to handle uploading and authentication. And we have set it up so that it doesn’t need authentication when sending from localhost. (verify the exact setting for this TODO)

TLS

We need to allow dovecot to use TLS to encrypt our IMAP connection.

I use caddy for other web-hosting, it has a feature named automatic HTTPS, which will automatically request and renew your certificates, and by default it puts the new certificate in the file system.

Since web and IMAP use different ports, we can use the same certificate made by caddy for IMAP server.

Here, We will use a sub-domain for IMAP connections, so that the root domain can use some other certs.

DNS setup

Add two sub-domain DNS record just for the IMAP server:

MX means send the SMTP traffic for the host to the look up result. In this case, we send mail to our domain with our mail server on the same domain.

Caddy for auto tls

pkg install caddy

Follow the postinstall message to set up:

To enable caddy:

- Edit /usr/local/etc/caddy/Caddyfile
  See https://caddyserver.com/docs/
- Run 'service caddy enable'

Note while Caddy currently defaults to running as root:wheel, it is strongly
recommended to run the server as an unprivileged user, such as www:www --

- Use security/portacl-rc to enable privileged port binding:

  # pkg install security/portacl-rc
  # sysrc portacl_users+=www
  # sysrc portacl_user_www_tcp="http https"
  # sysrc portacl_user_www_udp="https"
  # service portacl enable
  # service portacl start

- Configure caddy to run as www:www

  # sysrc caddy_user=www caddy_group=www

- Note if Caddy has been started as root previously, files in
  /var/log/caddy, /var/db/caddy, and /var/run/caddy may require their ownership
  changing manually.

/usr/local/etc/rc.d/caddy has the following defaults:

- Server log: /var/log/caddy/caddy.log
  (runtime messages, NOT an access.log)
- Automatic SSL certificate storage: /var/db/caddy/data/caddy/
- Administration endpoint: //unix/var/run/caddy/caddy.sock
- Runs as root:wheel (this will change to www:www in the future)

Edit the Caddyfile to just send a response:

<your_mail_domain> {
                respond "Use an IMAP client to access the emails"
}

After starting it, caddy will automatically acquire a TLS certificate in /var/db/caddy/data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<your_imap_domain>

Dovecot will read the files when it’s still running as root, so no permission change is needed. 8

Also, a DH parameter is needed.

Generate it with:

openssl dhparam -out /usr/local/etc/dovecot/dh.pem 4096

Edit /usr/local/etc/dovecot/conf.d/10-ssl.conf:

SSL = required

ssl_cert = </etc/SSL/certs/dovecot.pem
ssl_key = </etc/SSL/private/dovecot.pem
ssh_dh = </usr/local/etc/dovecot/dh.pem

On vim, use to complete files and paths

See the official documentation for more SSL options.9 The default ones are pretty sane to me, so I didn’t change anything else except for trying to use the latest TLS version possible.

TLS-enabled submission proxy

Postfix doesn’t have good support for TLS, but dovecot do. Dovecot also has a submission server that forwards your submission mail to the local agent.10.

The mail sending works now using un-encrypted port 25 SMTP messages, but that’s obviously unsafe and will leak your passwords. So, we use the dovecot’s submission proxy for this.

  • Enable the submission protocol on dovecot.conf
  • Head over to conf.d/20-submission.conf, and edit the required parameter, that is the smtp server host, in this case 127.0.0.1
  • Enable the submissions listen port, and add ssl = yes to the configuration.

Separate certificates for separate domains

Currently, if the imap subdomain is named imap, we can have another subdomain named smtp to avoid confusion.

It’s possible to separate your certs by protocols, ips, and SNI.

The default ssl_cert and ssl_key are still needed, so that the config check passes.

Get a address and certs just like earlier, and set up like following:

See https://doc.dovecot.org/2.3/configuration_manual/dovecot_ssl_configuration/#with-client-tls-sni-server-name-indication-support.

Example (20-submission.conf):

local_name <smtp.your.domain> {
	protocol submission {
		# Space-separated list of plugins to load (default is global mail_plugins).
		#mail_plugins = $mail_plugins

		# Maximum number of SMTP submission connections allowed for a user from
		# each IP address.
		# NOTE: The username is compared case-sensitively.
		#mail_max_userip_connections = 10

		ssl_cert = </var/db/caddy/data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<path_to_cert>
		ssl_key = </var/db/caddy/data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<path_to_key>
	}
}

Other goodies for IMAP

At this stage, you can already login to the email using a email client like thunderbird.

Add a user with mail login group, and use the credentials to log in to thunderbird.

However, many features are still missing.

Searching (FTS)

To support server side fast mail body searching, a fts plugin can be used.

I use dovecot-fts-flatcurve for local based indexing. 11

In imap.conf:

protocol imap {
  # Space separated list of plugins to load (default is global mail_plugins).
  mail_plugins = $mail_plugins fts fts_flatcurve


  plugin {
     # Define "flatcurve" as the FTS driver.
     fts = flatcurve

     # These are not flatcurve settings, but required for Dovecot FTS. See
     # Dovecot FTS Configuration link above for further information.
     fts_languages = en nl fr de
     fts_tokenizers = generic email-address

     fts_filters = lowercase stopwords

     # Force more consistent result
     fts_enforced = yes
  }
  # Maximum number of IMAP connections allowed for a user from each IP address.
  # NOTE: The username is compared case-sensitively.
  #mail_max_userip_connections = 10
}

Automatically sort and deliver emails to a folder (Pigeonhole)

An overview of this can be found at Dovecot’s official documentation

pkg install dovecot-pigeonhole

The package will install more example configs, copy them to conf.d:

/usr/local/share/doc/dovecot/example-config/conf.d/20-managesieve.conf
/usr/local/share/doc/dovecot/example-config/conf.d/90-sieve-extprograms.conf
/usr/local/share/doc/dovecot/example-config/conf.d/90-sieve.conf

As instructed by 90-sieve.conf, add it to LMTP and LDA protocols’ plugins.

As far as I know, LMTP is used to transfer mail received from SMTP to a user’s local folder

In 90-sieve.conf Setup default sieve locations, I’ve changed them for FreeBSD style hierarchy.

plugin {
  # The location of the user's main Sieve script or script storage. The LDA
  # Sieve plugin uses this to find the active script for Sieve filtering at
  # delivery. The "include" extension uses this location for retrieving
  # :personal" scripts. This is also where the  ManageSieve service will store
  # the user's scripts, if supported.
  #
  # Currently only the 'file:' location type supports ManageSieve operation.
  # Other location types like 'dict:' and 'ldap:' can currently only
  # be used as a read-only script source ().
  #
  # For the 'file:' type: use the ';active=' parameter to specify where the
  # active script symlink is located.
  # For other types: use the ';name=' parameter to specify the name of the
  # default/active script.
  sieve = file:~/sieve;active=~/.dovecot.sieve

  # The default Sieve script when the user has none. This is the location of a
  # global sieve script file, which gets executed ONLY if user's personal Sieve
  # script doesn't exist. Be sure to pre-compile this script manually using the
  # sievec command line tool if the binary is not stored in a global location.
  # --> See sieve_before for executing scripts before the user's personal
  #     script.
  sieve_default = /usr/local/lib/dovecot/sieve/default.sieve

  # The name by which the default Sieve script (as configured by the
  # sieve_default setting) is visible to the user through ManageSieve.
  #sieve_default_name =

  # Location for ":global" include scripts as used by the "include" extension.
  sieve_global = /usr/local/lib/dovecot/sieve/

}

Spam and Virus protection (Amavis with spamassassin and clamav)

Postfix can be configured to stop some spam:12

main.cf:

# Block spam using DNS blacklists
smtpd_client_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_rbl_client zen.spamhaus.org, reject_rbl_client bl.spamcop.net

Gentoo uses a intermediate smtp server to route incoming messages and check for virus.

Luke’s landchad uses content filter on smtp server.

I perfer Luke’s approach here, because it’s basically one less server to set up.

First, the email is going to be received by postfix, which is then added a header for spam status. Lastly, it’s sent to dovecot and the sieves are going to take care of the spams by putting them into the spam folder.

Postfix spam marking part

TODO: figure this out oneday.

I’ve skipped this one right now because it has too much dependencies and is hard to set up.

Dovecot put into spam folder part

There is spamtest and virustest. They don’t require installing new applications.

It’s relatively easy to configure, just like this:

plugin {
  sieve_extensions = +spamtest +spamtestplus +virustest

  # CUSTOM: spamtest and virustest configuration
  sieve_spamtest_status_type = text
  sieve_spamtest_status_header = X-Spam-Status
  sieve_spamtest_text_value1 = No
  sieve_spamtest_text_value10 = Yes

  sieve_virustest_status_type = text
  sieve_virustest_status_header = X-Virus-Scan: Found to be (.+)\.
  sieve_virustest_text_value1 = clean
  sieve_virustest_text_value5 = infected
}

Security and Privacy

Auth limit the authentication:

smtpd_client_auth_rate_limit = 4

Reject dubious incoming mail:

smtpd_recipient_restrictions = permit_sasl_authenticated, permit_mynetworks, reject_unauth_destination, reject_unknown_recipient_domain

Remove some headers like IP:

/^Received:.*/		    IGNORE
/^X-Originating-IP:/	IGNORE
/^User-Agent:/	    	IGNORE
/^X-Mailer:/	    	IGNORE

Firewall with PF

Reference the FreeBSD Wiki and the online tutorial, set up the following:

  • Block everything
  • Set up services
    • ssh
    • smtp (postfix mail transport)
    • smtps (dovecot submission)
    • domain (dns)
    • https
    • imaps
    • ntp
  • Allow the services
  • Allow from localhost to localhost
  1. https://landchad.net/mail/smtp/ ↩2

  2. https://wiki.gentoo.org/wiki/Special:MyLanguage/Complete_Virtual_Mail_Server

  3. https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail#Relationship_to_SPF_and_DMARC

  4. https://en.wikipedia.org/wiki/Internet_Message_Access_Protocol#Advantages_over_POP

  5. https://landchad.net/mail/inbox/

  6. https://en.wikipedia.org/wiki/Maildir also see the dovecot documentation: /usr/local/share/doc/dovecot/wiki/MailLocation.txt

  7. https://doc.dovecot.org/2.3/configuration_manual/howto/postfix_and_dovecot_sasl/

  8. https://doc.dovecot.org/2.3/configuration_manual/dovecot_ssl_configuration/

  9. https://doc.dovecot.org/2.3/configuration_manual/dovecot_ssl_configuration/#ssl-security-settings

  10. https://doc.dovecot.org/2.3/admin_manual/submission_server/

  11. https://doc.dovecot.org/2.3/settings/plugin/fts-plugin/#plugin_setting-fts-fts_tokenizers

  12. https://wiki.gentoo.org/wiki/Complete_Virtual_Mail_Server/amavisd_spamassassin_clamav#Postfix