Setup Email Server From Scratch On FreeBSD - 06 SPF DMARC And DKIM

05 PostfixAdmin <- Intro -> 25 IMAPSYNC

This tutorial is partially complete 2025-05-14
Postfix, Dovecot, & PostfixAdmin work with MySQL and virtual accounts
can be created with PostfixAdmin and used with an email client. Incoming SPF
milter and outgoing OpenDKIM milter signing tested and working.

#######################################
# Setting Up SPF DMARC and DKIM Records
#######################################

To improve mail delivery to other email servers we need to setup DMARC, SPF,
and DKIM records so recipient mail servers can identify we are allowed to send
emails for our domain. To setup these records we need to modify the DNS records
for our domain. I covered DMARC and SPF on the first page of server installation,
and I'll post it here again to refresh.

I tried 2 versions of opendkim with unbound dnssec and chronyd ntp security but 
opendkim-testkey still reports 'key not secure'. Also, dig show dnssec is 
working with the ad flag. This shouldn't affect DKIM operation and I'll come
back to see if I can fix it later.

Login to your DNS provider, which is usually the same as domain registrar. I use
Namecheap and DNS is included with the domain registration, no extra charge.

# Here is what you should have setup with your DNS already.

Namecheap -> Account -> Domains -> DNS -> Advanced DNS

# Add New Records
A	@	147.135.65.97
AAAA	@	2604:2dc0:100:1261::10
A	mail	147.135.65.97
A	mx	147.135.65.97
AAAA	mail	2604:2dc0:100:1261::10
AAAA	mx	2604:2dc0:100:1261::10
CNAME   autoconfig      mx.okbsd.com
CNAME   autodiscover    mx.okbsd.com
CNAME	www	okbsd.com
TXT	@	v=spf1 ip4:147.135.65.97 ip6:2604:2dc0:100:1261::10/64 mx ~all
TXT	_dmarc	v=DMARC1; p=quarantine; rua=mailto:postmaster@okbsd.com; ruf=mailto:postmaster@okbsd.com; sp=quarantine

# In Custom MX Records
MX	@	mx.okbsd.com	0
MX	@	mail.okbsd.com	10

# DMARC tells recieving servers what action to take if SPF lookup fails or 
# doesn't match and where to send reports of failures or problems.

# SPF identifies which servers are authorized to send email on the domains behalf,
# in DNS the @ means this domain and the domain name is appended to the other
# entries unless you put a dot on the end.

# A couple days ago I added the above record but now they are missing, this is twice
# now I've added stuff to namecheap and had it dissappear later. Maybe I'm not hitting
# save or something?

# Check the DNS ...
nslookup -type=txt okbsd.com
okbsd.com text = "v=spf1 ip4:147.135.65.97 ip6:2604:2dc0:100:1261::10/64 mx ~all"

nslookup -type=txt _dmarc.okbsd.com
_dmarc.okbsd.com text = "v=DMARC1; p=quarantine; rua=mailto:postmaster@okbsd.com; ruf=mailto:postmaster@okbsd.com; sp=quarantine"

# You can also check your record validity with mxtoolbox.com
https://mxtoolbox.com/
Enter your domain name and submit

# Configure the SPF policy agent on FreeBSD to check SPF records on incoming mail.

pkg install py311-spf-engine

#
# Using policyd-spf with Postfix
#

Policyd-spf must be integrated with Postfix to be effective:

 1. Add to your postfix master.cf:

        policyd-spf  unix  -       n       n       -       0       spawn
            user=nobody argv=/usr/local/bin/policyd-spf

 2. Configure the Postfix policy service in your main.cf so that the
    "smtpd_recipient_restrictions" includes a call to the policyd-spf policy
    filter.  If you already have a "smtpd_recipient_restrictions" line, you can
    add the "check_policy_service" command anywhere *after* the line which
    reads "reject_unauth_destination" (otherwise you're system can become an
    open relay).

        smtpd_recipient_restrictions =
            ...
            reject_unauth_destination
            check_policy_service unix:private/policyd-spf
            ...

        policyd-spf_time_limit = 3600

  3. Please consult the postfix documentation for more information on these and
     other settings you may wish to have in the "smtpd_recipient_restrictions"
     configuration.

  4. Reload postfix.

#
# Automatically starting pyspf-milter at boot time.
#

Add 'pyspf_milter_enable="YES"' to /etc/rc.conf.

#
# Using pyspf-milter with Postfix
#

Integration of pyspf-milter into Postfix is like any milter (See Postfix's
README_FILES/MILTER_README). But care is required to segregate outbound mail
from inbound mail to be checked. Here is example using milter macros to keep
the mail streams segregated.

/usr/local/etc/postfix/main.cf:

smtpd_milters = unix:/var/run/pyspf-milter/pyspf-milter.sock

/usr/local/etc/postfix/master.cf:

smtp       inet  n       -       -       -       -       smtpd
    ...
        -o milter_macro_daemon_name=VERIFYING
    ...

/usr/local/etc/python-policyd-spf/policyd-spf.conf:

MacroList               daemon_name|VERIFYING

#################################
# Follow the above instructions #
#################################

cd /usr/local/etc
ln -s /etc/postfix

# You will need to add a policyd-spf user and group, user 114 is used on Debian
# and was available on my system. Here is a nice oneliner to add user and group

pw useradd -n policyd-spf -d /nonexistent -s /usr/sbin/nologin -u 114 -i 114

# check it
grep policyd-spf /etc/passwd /etc/group
/etc/passwd:policyd-spf:*:114:114:User &:/nonexistent:/usr/sbin/nologin
/etc/group:policyd-spf:*:114:

# Check the paths

whereis policyd-spf
policyd-spf: /usr/local/bin/policyd-spf

# Add the following

nano /etc/postfix/master.cf
# ---
policyd-spf  unix  -       n       n       -       0       spawn
    user=policyd-spf argv=/usr/local/bin/policyd-spf
# ---

sysrc pyspf_milter_enable="YES"

# Append to file
nano /usr/local/etc/postfix/main.cf

policyd-spf_time_limit = 3600
smtpd_recipient_restrictions =
   permit_mynetworks,
   permit_sasl_authenticated,
   reject_unauth_destination,
   check_policy_service unix:private/policyd-spf

# You can change the format of the spf header line
# Header-Type = Recieved-SP (default)
# Header-Type = AR requires Authserv_Id = hostname

nano /usr/local/etc/python-policyd-spf/policyd-spf.conf
# ---
#  For a fully commented sample config file see policyd-spf.conf.commented
debugLevel = 1
TestOnly = 1
HELO_reject = Fail
Mail_From_reject = Fail
PermError_reject = False
TempError_Defer = False
skip_addresses = 127.0.0.0/8,::ffff:127.0.0.0/104,::1
# Authserv_Id = mx.okbsd.com
# Header_Type = Received-SPF
# Header_Type = SPF
# Header_Type = AR
# ---

service postfix restart
service dovecot restart

# Check if the unix socket is created in the correct location

ls /var/spool/postfix/private/policyd-spf
/var/spool/postfix/private/policyd-spf=

# Send a test email and check the raw email source for a line like ...

Authentication-Results: mx.okbsd.com; spf=pass (sender SPF authorized) ...

# If you have problems recieving email check /var/log/maillog and send yourself
# a test email ...

tail -f /var/log/maillog

# DKIM is a signature that is included in the email, the recieving server looks up
# the DKIM public record on DNS does some sort of comparison with the signature in
# the email to determine if it is from a valid sender. Configure the email server
# to append the DKIM signature, and add the public key the DNS record.

# DKIM is supposed to report secure if dnssec is enabled in unbound, and unbound requires
# ntp security so I use chronyd. And supposedly ca_root_nss is required as well though
# I haven't quite figured out how that works. So we'll just install and go from there.

# If you haven't installed git ...

pkg install git

# Install chrony for unbound dnssec
pkg install chrony

cd /usr/local/etc
git clone https://github.com/jauderho/nts-servers.git

# Look for US time servers
grep -B 2 -A 1 US /usr/local/etc/nts-servers/nts-sources.yml

nano /usr/local/etc/chrony.conf
# ---
! pool 0.freebsd.pool.ntp.org iburst
# US
server ntp1.glypnod.com nts iburst
server ohio.time.system76.com nts iburst
server oregon.time.system76.com nts iburst
server virginia.time.system76.com nts iburst
! server stratum1.time.cifelli.xyz nts iburst
! server time.cifelli.xyz nts iburst
! server time.txryan.com nts iburst
# ---

nano /etc/rc.conf
# ---
ntpd_enable="NO"
ntpdate_enable="NO"
# ntpd_sync_on_start="YES"
chronyd_enable="YES"
# ---

service ntpd stop
service ntpdate onestop
service chronyd start
chronyc -Na sources 
MS Name/IP address         Stratum Poll Reach LastRx Last sample               
===============================================================================
^? ntp1.glypnod.com              0   7     0     -     +0ns[   +0ns] +/-    0ns
^? ohio.time.system76.com        0   7     0     -     +0ns[   +0ns] +/-    0ns
^? oregon.time.system76.com      0   7     0     -     +0ns[   +0ns] +/-    0ns
^? virginia.time.system76.com    0   7     0     -     +0ns[   +0ns] +/-    0ns

# Install ca_root_nss - not sure why this is required but there was a note in the forums

pkg install ca_root_nss
Scanning /usr/share/certs/untrusted for certificates...
Scanning /usr/share/certs/trusted for certificates...
Scanning /usr/local/share/certs for certificates...
  * /etc/ssl/cert.pem
  * /usr/local/etc/ssl/cert.pem
  * /usr/local/openssl/cert.pem

# Install and configure unbound, unbound is installed by default
# I did not use local_unbound which uses a config file under /var/unbound.
# Use unbound_enable="YES" with config file /usr/local/etc/unbound/unbound.conf

nano /etc/rc.conf
# ---
# local_unbound_enable="YES"
# dnsmasq_enable="YES"
unbound_enable="YES"
# ---

# Check if you have a root.key file and if missing run unbound-anchor, the default
# location is /usr/local/etc/unbound/root.key

ls -l /usr/local/etc/unbound/root.key
unbound-anchor

# If you do not have or want to update the root.hints file use ...

ls -l /usr/local/etc/unbound/root.hints
cd /usr/local/etc/unbound
dig @e.root-servers.net . ns >root.hints 2> errors

# Configure unbound
nano /usr/local/etc/unbound/unbound.conf
# ---
server:
  chroot: "/usr/local/etc/unbound"
  username: "unbound"
  root-hints: "/usr/local/etc/unbound/root.hints"
  auto-trust-anchor-file: "/usr/local/etc/unbound/root.key"
  val-log-level: 2

# Remote control config section.
remote-control:	
  control-enable: yes
  # by default the control interface is is 127.0.0.1 and ::1 and port 8953
  # it is possible to use a unix socket too
  control-interface: /var/run/unbound.ctl

# add to end of file
server:
    harden-below-nxdomain: yes
    harden-referral-path: yes
    harden-algo-downgrade: no # false positives with improperly configured zones
    use-caps-for-id: no # makes lots of queries fail
    hide-identity: yes
    hide-version: yes
    private-address: 10.0.0.0/8
    private-address: 100.64.0.0/10
    private-address: 127.0.0.0/8
    private-address: 169.254.0.0/16
    private-address: 172.16.0.0/12
    private-address: 192.168.0.0/16
    private-address: fc00::/7
    private-address: fe80::/10
    private-address: ::ffff:0:0/96
    module-config: "validator iterator" # disable EDNS client subnet support

server:
    prefetch: yes
    prefetch-key: yes
    msg-cache-size: 128k
    msg-cache-slabs: 2
    rrset-cache-size: 8m
    rrset-cache-slabs: 2
    key-cache-size: 32m
    key-cache-slabs: 2
    cache-min-ttl: 1800
    cache-max-ttl: 600
    num-threads: 2

server:
    interface: 127.0.0.1
    access-control: 0.0.0.0/0 refuse
    access-control: 127.0.0.1/32 allow

server:
    val-log-level: 2
    use-syslog: yes
    verbosity: 1


# Stop any other DNS services and start unbound
service dnsmasq onestop
service local_unbound onestop
service unbound start

# Check if it is working, look for flags to include 'ad' 
dig @127.0.0.1 okbsd.com +dnssec
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1
dig @127.0.0.1 CRHdAnOqqitUaWRuNkHLdIpbgw76._domainkey.okbsd.com +dnssec
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 0, AUTHORITY: 4, ADDITIONAL: 1

# Create opendkim user and group

pkg options opendkim
pkg options opendkim-devel

pkg install opendkim

or

pkg install opendkim-devel

pw useradd -n opendkim -d /var/run/opendkim -s /usr/sbin/nologin -u 118 -i 118
pw group mod opendkim -m postfix

# Enable opendkim in rc.conf and change default settings
nano /etc/rc.conf
# ---
milteropendkim_enable="YES"
milteropendkim_cfgfile="/usr/local/etc/opendkim/opendkim.conf"
milteropendkim_uid="opendkim"
milteropendkim_gid="opendkim"
milteropendkim_socket="local:/var/spool/postfix/opendkim/opendkim.sock"
milteropendkim_socket_perms="0770"
# ---

# Create keys directory
mkdir -p /usr/local/etc/opendkim/keys

nano /usr/local/etc/opendkim/opendkim.conf
# ---
Syslog                  yes
SyslogSuccess           yes
#LogWhy                 no

Canonicalization        relaxed/simple
On-BadSignature         reject
BodyLengthDB            refile:/usr/local/etc/opendkim/bodylengthdb.cfg

Mode                    sv
SubDomains              no
OversignHeaders         From

UserID                  opendkim
UMask                   007

Socket                  local:/var/spool/postfix/opendkim/opendkim.sock

PidFile                 /var/run/opendkim/opendkim.pid

TrustAnchorFile         /usr/local/etc/unbound/root.key

KeyTable	file:/usr/local/etc/opendkim/keytable
SigningTable	refile:/usr/local/etc/opendkim/signingtable
InternalHosts	refile:/usr/local/etc/opendkim/trustedhosts
ExternalIgnoreList	refile:/usr/local/etc/opendkim/trustedhosts
# ---

nano /usr/local/etc/opendkim/bodylengthdb.cfg
# ---
.*
# ---

# Create socket directory
mkdir /var/spool/postfix/opendkim
chown opendkim:opendkim /var/spool/postfix/opendkim
chmod 755 /var/spool/postfix/opendkim

# create some random selector for dkim 20 char is enough, remove special chars
openssl rand -base64 23
23 char OHbXdEkVAze2C1lWKIk9Aew/phuAbwA=
20 char OHbXdEkVAze2C1lWKIk9AewphuAbw

nano /usr/local/etc/opendkim/signingtable
# ---
*@okbsd.com CRHdAnOqqitUaWRuNkHLdIpbgw76._domainkey.okbsd.com
*@domain2.com	iwgI3IWnvgt2YXrQi38rXWPe4lvlV._domainkey.domain2.com
# ---

nano /usr/local/etc/opendkim/keytable
# ---
CRHdAnOqqitUaWRuNkHLdIpbgw76._domainkey.okbsd.com okbsd.com:CRHdAnOqqitUaWRuNkHLdIpbgw76:/usr/local/etc/opendkim/keys/okbsd.com/CRHdAnOqqitUaWRuNkHLdIpbgw76.private
iwgI3IWnvgt2YXrQi38rXWPe4lvlV._domainkey.domain2.com domain2.com:iwgI3IWnvgt2YXrQi38rXWPe4lvlV:/usr/local/etc/opendkim/keys/domain2.com/iwgI3IWnvgt2YXrQi38rXWPe4lvlV.private
# ---

nano /usr/local/etc/opendkim/trustedhosts
# ---
127.0.0.1
147.135.65.97
2604:2dc0:100:1261::10
# ---

mkdir /usr/local/etc/opendkim/keys/okbsd.com
opendkim-genkey -b 2048 -d okbsd.com -D /usr/local/etc/opendkim/keys/okbsd.com -s CRHdAnOqqitUaWRuNkHLdIpbgw76 -v

mkdir /usr/local/etc/opendkim/keys/domain2.com
opendkim-genkey -b 2048 -d domain2.com -D /usr/local/etc/opendkim/keys/domain2.com -s iwgI3IWnvgt2YXrQi38rXWPe4lvlV -v

chown -R opendkim:opendkim /usr/local/etc/opendkim/keys
find /usr/local/etc/opendkim/keys -type d -exec chmod 700 {} \;
find /usr/local/etc/opendkim/keys -type f -exec chmod 600 {} \;

# Namecheap Advanced DNS -> Toggle On DNS Sec

# Copy the results to your DNS as a TXT RECORD

cat /usr/local/etc/opendkim/keys/okbsd.com/CRHdAnOqqitUaWRuNkHLdIpbgw76.txt

# Remove 2 sections with " <spaces> " before submitting the DNS TXT Record
TXT CRHdAnOqqitUaWRuNkHLdIpbgw76._domainkey v=DKIM1; k=rsa; p=MIIBIjA...DAQAB

nslookup -type=txt CRHdAnOqqitUaWRuNkHLdIpbgw76._domainkey.okbsd.com
crhdanoqqituawrunkhldipbgw76._domainkey.okbsd.com text = "v=DKIM1; k=rsa; p=MIIBIjA...DAQAB"

# Go to mxtoolbox.com and do a DKIM lookup.
CRHdAnOqqitUaWRuNkHLdIpbgw76._domainkey.okbsd.com

# Start opendkim and check that it is running
service milter-opendkim start
service milter-opendkim status
milteropendkim is running as pid 3637.

# Test the dkim key - I was not able to get key secure but this is ok
opendkim-testkey -d okbsd.com -s CRHdAnOqqitUaWRuNkHLdIpbgw76 -vvv
opendkim-testkey: checking key 'CRHdAnOqqitUaWRuNkHLdIpbgw76._domainkey.okbsd.com'
opendkim-testkey: key not secure
opendkim-testkey: key OK

# Add OpenDKIM to Postfix - add to end of file

nano /etc/postfix/main.cf
# ---
# Milter configuration
milter_default_action = accept
milter_protocol = 6
smtpd_milters = unix:opendkim/opendkim.sock
non_smtpd_milters = $smtpd_milters
# ---

service postfix restart
service milter-opendkim restart
service postfix status
service milter-opendkim status

# Send a test mail from your mail server, in this case admin@okbsd.com.

# The mail should have a header that contains something like this ...
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=okbsd.com;
	s=CRHdAnOqqitUaWRuNkHLdIpbgw76; t=1747256382;
	bh=6GInUBItXoVjcpY1TOdSfAVLN/R6eU2ujlFwzTrfFtw=;
	h=Date:To:From:Subject:From;
	b=RdNarAxFLxIVz/D4sMfkK7OTzpQdgzCBX1tN6Z1xiRlPAEtq3Z6PMEbIanKhBB5Zu
	 BbHUodrOHVuib8hyvoCV0U6sp5Kz0jexiYqgM4R2KQkHrg+nIICfewSj6PHtfwMy8i
	 Tq67eaEv7jr7ShwRkZaQcqNHaZHyjFUmXlMy9y82F0mH5f2vVw+bZ2zbVMMVW3AYEv
	 f/espHlUSbKbQsuLxEj/TTHcNGQ7YD3Moji7PL7e57vkQTS8r4QyFh3OkI8Jc62W8E
	 9Rj9BA0kZ6lEFcH89wvi/BwmJowspeOcLYX3OoQtJ2UeZhrvpXg8kZAlytXJap+1dv
	 xdjzbn58GIq4g==

# Autoconfig and Autodiscover

# Autoconfig and Autodiscover help mail clients find the settings for your mail server.
# https://github.com/smartlyway/email-autoconfig-php#

cd ~
git clone https://github.com/smartlyway/email-autoconfig-php
cd email-autoconfig-php
mkdir /var/www/okbsd/html/mail
cp config-v1.1.xml /var/www/okbsd/html/mail

# Edit these files and change to suit your needs, this is tested and works with Thunderbird

nano /var/www/okbsd/html/mail/config-v1.1.xml
# ---
<?xml version="1.0"?>
<clientConfig version="1.1">
    <emailProvider id="okbsd.com">
      <domain>okbsd.com</domain>
      <displayName>okbsd.com</displayName>
      <displayShortName>okbsd.com</displayShortName>
      <incomingServer type="imap">
         <hostname>mx.okbsd.com</hostname>
         <port>993</port>
         <socketType>SSL</socketType>
         <authentication>password-cleartext</authentication>
         <username>%EMAILADDRESS%</username>
      </incomingServer>
      <outgoingServer type="smtp">
         <hostname>mx.okbsd.com</hostname>
         <port>465</port>
         <socketType>SSL</socketType>
         <username>%EMAILADDRESS%</username>
         <authentication>password-cleartext</authentication>
      </outgoingServer>
    </emailProvider>
</clientConfig>
# ---

# Do the same for Outlook clients, I have not tested this yet.

cp -R Autodiscover/Autodiscover.xml /var/www/okbsd/html/mail/Autodiscover.xml

nano /var/www/okbsd/html/mail/Autodiscover.xml/index.php
# ---
<?php
$raw = file_get_contents('php://input');
$matches = array();
preg_match('/<EMailAddress>(.*)<\/EMailAddress>/', $raw, $matches);
header('Content-Type: application/xml');
?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
  <Response xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a">
    <User>
      <DisplayName>okbsd.com</DisplayName>
    </User>
    <Account>
      <AccountType>email</AccountType>
      <Action>settings</Action>
      <Protocol>
        <Type>IMAP</Type>
        <Server>mx.okbsd.com</Server>
        <Port>993</Port>
        <DomainRequired>off</DomainRequired>
        <SPA>off</SPA>
        <SSL>on</SSL>
        <AuthRequired>on</AuthRequired>
        <LoginName><?php echo $matches[1]; ?></LoginName>
      </Protocol>
      <Protocol>
        <Type>SMTP</Type>
        <Server>mx.okbsd.com</Server>
        <Port>587</Port>
        <DomainRequired>off</DomainRequired>
        <SPA>off</SPA>
        <SSL>on</SSL>
        <AuthRequired>on</AuthRequired>
        <LoginName><?php echo $matches[1]; ?></LoginName>
      </Protocol>
    </Account>
  </Response>
</Autodiscover>
# ---

# You will need to add a SV records for Autodiscover for NameCheap I added ...
# Namecheap Domain List -> DNS -> Advanced DNS

Type		Service		Protocol  Priority  Weight  Port  Target
SRV Record	_autodiscover	_tcp      5         0       443   mx.okbsd.com

# Autoconfig needs the path http://autoconfig.okbsd.com/mail/config-v1.1.xml

nano /usr/local/etc/apache24/Includes/10-mx.okbsd.conf
# ---
  ServerAlias autoconfig.okbsd.com
  ServerAlias autodiscover.okbsd.com

  # autoconfig
  Alias /mail "/var/www/okbsd/html/mail"
# ---

# I generated a new cert for autoconfig and autodiscover but this isn't necessary because
# autoconfig doesn't use https and autodiscover uses the domain which already has a cert.
# Autodiscover needs the path https://okbsd.com//Autodiscover/Autodiscover.xml.

nano /usr/local/etc/apache24/Includes/10-mx.okbsd-le-ssl.conf

  # probably don't need the ServerAlias and if needed we'd need to update the cert
  # ServerAlias autodiscover.okbsd.com

  Alias /mail "/var/www/okbsd/html/mail"
  Alias /Autodiscover/Autodiscover.xml "/var/www/okbsd/html/mail/Autodiscover.xml"

# notes for try to fix opendkim

# Check Debian compile options
apt get source opendkim
cat opendkim-2.11.0~beta2/debian/rules

# Check FreeBSD compile options
pkg options opendkim-devel

cd /usr/ports/mail/opendkim-devel
# try to match options

05 PostfixAdmin <- Intro -> 25 IMAPSYNC