Setup Email Server From Scratch On FreeBSD #2 - 09 Create Virtual Domains

07 RoundCube WebMail <- Intro -> 10 Blocking Spam

We believe in data independence, and support others who want data independence.
This tutorial is partially complete 2025-08-07

This is version 2 and everthing works up to and including Roundcube.

#################################
# How to Create Virtual Domains #
#################################

# This page of the "FreeBSD Email From Scratch" tutorial assumes successful setup of the following ...

1. FreeBSD Server
2. FAMP - FreeBSD Apache MySQL/MariaDB PHP
3. Postfix - smtp server
4. Dovecot - imap server
5. PostfixAdmin - Postfix Web Administration
6. SPF DMARC and DKIM - email authentication and spam filters
7. Roundcube - Webmail

# Setup DNS for New Domain(s) in NameCheap, we'll use the name okfig.com in this example. Change
# your IPv4 IPV4 and domain name as appropriate. Example DNS for coragarden.com.

A	@		147.135.37.135
AAAA	@		2604:2dc0:200:187::1
A	imap		147.135.37.135
AAAA	imap		2604:2dc0:200:187::1
A	mail		147.135.37.135
AAAA	mail		2604:2dc0:200:187::1
A	smtp		147.135.37.135
AAAA	smtp		2604:2dc0:200:187::1
CNAME   autoconfig      mail.coragarden.com
CNAME   autodiscover    mail.coragarden.com
CNAME   www     okfig.com
TXT     @       v=spf1 ip4:147.135.37.135 ip6:2604:2dc0:200:187::1/64 mx ~all
TXT     _dmarc  v=DMARC1; p=quarantine; rua=mailto:postmaster@coragarden.com; ruf=mailto:postmaster@coragarden.com; sp=quarantine

Type	Service		Protocol  Priority  Weight  Port  Target
SRV	_autodiscover	_tcp      5         0       443   mail.coragarden.com

# In Custom MX Records
MX      @       smtp.coragarden.com    0
MX      @       mail.coragarden.com  10

# If one administrator handles all the dmarc reports it is easier to just use one address for all the domains
# managed. But if you use an email address outside of the virtual domain for sending dmarc reports modify the
# DNS of the target domain. Example postmaster@okbsd.com recieves dmarc reports from coragarden.com so the
# TXT _dmarc.coragarden.com uses rua=mailto:postmaster@okbsd.com; ruf=mailto:postmaster@okbsd.com.

# Add the following to okbsd.com DNS to allow dmarc reports from coragarden.com.

TXT	okfig.com._report._dmarc	v=DMARC1;


# Add these to the hosts file
nano /etc/hosts
# ---
147.135.37.135       coragarden.com www.coragarden.com imap.coragarden.com mail.coragarden.com smtp.coragarden.com
2604:2dc0:200:187::1 coragarden.com www.coragarden.com imap.coragarden.com mail.coragarden.com smtp.coragarden.com
# ---

# Setup DKIM Records For New Domain

mkdir /usr/local/etc/mail/keys/coragarden.com

opendkim-genkey -b 2048 -d coragarden.com -D /usr/local/etc/mail/keys/coragarden.com -s 20250808 -v

# Set permissions, if not set opendkim will report key not secure.

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

# Create the TXT record in DNS for coragarden.com.

cat /usr/local/etc/mail/keys/coragarden.com/20250808.txt
20250808._domainkey     IN      TXT     ( "v=DKIM1; k=rsa; "
          "p=MIIBIj ... 5V/XUwIDAQAB" )  ; ----- DKIM key 20250808 for coragarden.com

# Copy the keys to your name server record and remember to remove the extra quotes and
# spaces  ..."  "... in 2 sections of the DKIM line or paste 3 parts between the quotes together.

TXT	20250808._domainkey	v=DKIM1; k=rsa; p=MIIBIj ... /XUIDAQAB

# Check the record with google name server.

nslookup -type=txt 20250808._domainkey.coragarden.com 8.8.8.8

# If you're using bind locally you may need to reload the server.

service named restart

# Check if the record is available locally. The output will contain quotes and
# space but that doesn't mean the entry is wrong, check with mxtoolbox.com.

nslookup -type=txt 20250808._domainkey.coragarden.com
20250808._domainkey.coragarden.com      text = "v=DKIM1; k=rsa;p=MIIBIj ... 5V/XUwIDAQAB"

# Add the new entries to the opendkim keytable, signingtable, and trustedhosts files.

cat /usr/local/etc/mail/keys/coragarden.com/*.txt >> /usr/local/etc/mail/keytable

nano /usr/local/etc/mail/keytable
# ---
20250807._domainkey.okbsd.com okbsd.com:20250807:/usr/local/etc/mail/keys/okbsd.com/20250807.private
20250808._domainkey.coragarden.com coragarden.com:20250808:/usr/local/etc/mail/keys/coragarden.com/20250808.private
# ---

nano /usr/local/etc/mail/signingtable
# ---
*@okbsd.com 20250807._domainkey.okbsd.com
*@coragarden.com 20250808._domainkey.coragarden.com
# ---

nano /usr/local/etc/mail/trustedhosts
# ---
127.0.0.1
::1
localhost
147.135.37.135
2604:2dc0:200:187::1
okbsd.com
.okbsd.com
coragarden.com
.coragarden.com
# ---

service milter-opendkim restart

# By the time this is created the DNS has hopefully propagated and can be tested, test the key.
# Without -x /usr/local/etc/mail/opendkim.conf opendkim-testkey will report key not secure.
# If not secure it's not a big issue, but check DNSSEC is enabled with your DNS provider, file
# permissions in the mail/keys directory, and TrustAnchorFile in mail/opendkim.conf

# Update your root.key if needed.

unbound-anchor

# Check TrustAnchorFile

nano /usr/local/etc/mail/opendkim.conf
# ---
TrustAnchorFile         /usr/local/etc/unbound/root.key
# ---

# Test the new DKIM key.

opendkim-testkey -d coragarden.com -s 20250808 -vvv -x /usr/local/etc/mail/opendkim.conf
opendkim-testkey: checking key '20250808._domainkey.coragarden.com'
opendkim-testkey: key secure
opendkim-testkey: key OK

# Setup Domain in PostfixAdmin. Choose if you want to setup the default aliases. It may
# be easier to choose No, uncheck the box, and create a postmaster account and a catchall
# alias that points to postmaster. Alternatively, make a forward or alias for postmaster to
# another account. Adding abuse@, hostmaster@ and webmaster@ adds complexity and will
# increase time spent on maintenance (with many domains alias lists will be longer).

Domain List -> New Domain
Domain: coragarden.com
Description: Dry Land Corn
Pass expires : 3650
Add Domain

Virtual List - Add Mailbox
postmaster@coragarden.com
jack@coragarden.com

Virtual List - Add Alias (Catchall)
*@coragarden.com	postmaster@coragarden.com


# Virtual List - A real postmaster@domain.tld is recommended by RFC and real time blacklist
# ,RBL services, require mail from postmaster@domain.tld to request blacklist removal.

mkdir -p /usr/local/www/coragarden/html
mkdir -p /usr/local/www/coragarden/mail
echo '<?php print "<!DOCTYPE html lang=\"en\"><html><head><title>Title</title></head><body><h1>Hello World!</h1></body></html>"; ?>' > /usr/local/www/coragarden/html/index.php

chown -R root:www /usr/local/www/coragarden
find /usr/local/www/coragarden -type d -exec chmod 750 {} \;
find /usr/local/www/coragarden -type f -exec chmod 640 {} \;

nano /usr/local/etc/apache24/Includes/coragarden.conf
# ---
<VirtualHost *:80>
        ServerName coragarden.com
        ServerAlias www.coragarden.com
        ServerAdmin postmaster@coragarden.com
        DirectoryIndex index.php index.html
        DocumentRoot /usr/local/www/coragarden/html/
        <Directory /usr/local/www/coragarden/html/>
                Options FollowSymLinks
                AllowOverride All
                Require all granted
        </Directory>
</VirtualHost>
# ---

nano /usr/local/etc/apache24/Includes/mail.coragarden.conf
# ---
<VirtualHost *:80>
        ServerName mail.coragarden.com
        ServerAlias smtp.coragarden.com
        ServerAlias imap.coragarden.com
        ServerAlias autoconfig.coragarden.com
        ServerAlias autodiscover.coragarden.com
        ServerAdmin postmaster@coragarden.com
        DirectoryIndex index.php index.html

        DocumentRoot /usr/local/www/coragarden/html/
        <Directory /usr/local/www/coragarden/html/>
                Options FollowSymLinks
                AllowOverride All
                Require all granted
        </Directory>

        Alias /mail "/usr/local/www/coragarden/mail/"
        <Directory /usr/local/www/coragarden/mail/>
                Options FollowSymLinks
                AllowOverride All
                Require all granted
        </Directory>
</VirtualHost>
# ---

# Certbot needs the virtual host enabled so restart apache. Do you know apache
# was first created by patching httpd, so was named after 'a-patch-y'.

apachectl configtest
apachectl restart

# Test that the non-ssl website works.

http://coragarden.com
http://mail.coragarden.com

# Create certificates with certbot.

certbot certonly --apache --agree-tos --redirect --hsts --staple-ocsp --email postmaster@okbsd.com --cert-name coragarden.com -d coragarden.com,www.coragarden.com

# Create a certificate for mail services, and mail. web services.
certbot certonly --apache --agree-tos --redirect --hsts --staple-ocsp --email postmaster@okbsd.com --cert-name mail.coragarden.com -d mail.coragarden.com,smtp.coragarden.com,imap.coragarden.com

# Secure private key certificate files.

chown -R root:www /usr/local/etc/letsencrypt/live
find /usr/local/etc/letsencrypt/live -type d -exec chmod 755 {} \;
find /usr/local/etc/letsencrypt/live -type f -exec chmod 644 {} \;

chown -R root:www /usr/local/etc/letsencrypt/archive
find /usr/local/etc/letsencrypt/archive -type d -exec chmod 755 {} \;
find /usr/local/etc/letsencrypt/archive -type f -exec chmod 644 {} \;
chmod o-rwx /usr/local/etc/letsencrypt/archive/*/privkey*.pem

# Create ssl configurations for new virtual hosts.

nano /usr/local/etc/apache24/Includes/ssl-coragarden.conf
# ---
<IfModule mod_ssl.c>
  SSLStaplingCache shmcb:/var/run/apache2/stapling_cache(128000)
  <VirtualHost *:443>
        ServerName coragarden.com
        ServerAlias www.coragarden.com
        ServerAdmin postmaster@coragarden.com
        TransferLog "/var/log/httpd-access.log"
        CustomLog "/var/log/httpd-ssl_request.log" \
          "%v:%p %h %l %u %t %{SSL_PROTOCOL}x %{SSL_CIPHER}x \"%r\" %b"
        DirectoryIndex index.php index.html
        DocumentRoot /usr/local/www/coragarden/html/
        <Directory /usr/local/www/coragarden/html/>
                Options FollowSymLinks
                AllowOverride All
                Require all granted
        </Directory>
        Include /usr/local/etc/letsencrypt/options-ssl-apache.conf
        SSLCertificateFile /usr/local/etc/letsencrypt/live/coragarden.com/fullchain.pem
        SSLCertificateKeyFile /usr/local/etc/letsencrypt/live/coragarden.com/privkey.pem
        Header always set Strict-Transport-Security "max-age=31536000"
  </VirtualHost>
</IfModule>
# ---

nano /usr/local/etc/apache24/Includes/ssl-mail.coragarden.conf
# ---
<IfModule mod_ssl.c>
  SSLStaplingCache shmcb:/var/run/apache2/stapling_cache(128000)
  <VirtualHost *:443>
        ServerName mail.coragarden.com
        ServerAdmin postmaster@coragarden.com
        # RewriteEngine on
        DirectoryIndex index.php index.html
        DocumentRoot /usr/local/www/roundcube/
        Alias /webmail "/usr/local/www/roundcube"
        Alias /roundcube "/usr/local/www/roundcube"
        <Directory /usr/local/www/roundcube/>
                # Options FollowSymLinks MultiViews
                Options FollowSymLinks
                AllowOverride All
                # Require all granted
                Require all denied
		Require ip allowed_ip1 allowed_net2/cidr allowed_ipv6 allowed_ipv6/cidr
        </Directory>
        Alias /admin-login /usr/local/www/postfixadmin/public
        <Directory /usr/local/www/postfixadmin/>
                Options FollowSymLinks MultiViews
                AllowOverride All
                Require all denied
		Require ip allowed_ip1 allowed_net2/cidr allowed_ipv6 allowed_ipv6/cidr
        </Directory>
        Alias /mail "/usr/local/www/coragarden/mail/"
        Alias "/Autodiscover/Autodiscover.xml" "/usr/local/www/coragarden/mail/Autodiscover.xml/index.php"
        <Directory /usr/local/www/coragarden/mail/>
                DirectorySlash Off
                Options -Indexes +FollowSymLinks
                AllowOverride All
                Require all granted
        </Directory>
        Include /usr/local/etc/letsencrypt/options-ssl-apache.conf
        Header always set Strict-Transport-Security "max-age=31536000"
        SSLUseStapling on
        SSLCertificateFile /usr/local/etc/letsencrypt/live/mail.coragarden.com/fullchain.pem
        SSLCertificateKeyFile /usr/local/etc/letsencrypt/live/mail.coragarden.com/privkey.pem
  </VirtualHost>
</IfModule>
# ---

# To allow customer/admin's to add and remove mail accounts, assign them  as regular admin's with
# the domains they are to manage. These regular domain admins can only manage those domains and
# users they have been assigned. And only the assigned domains show up in postfixadmin.

# If not assigning customer/admin's to add and remove mail accounts, the postfixadmin section can
# be removed from the virtual domain. Use one main host for postfixadmin.

# Configure certificates in Postfix and Dovecot

nano /usr/local/etc/postfix/sni_maps
# ---
mail.okbsd.com /usr/local/etc/letsencrypt/live/mail.okbsd.com/privkey.pem /usr/local/etc/letsencrypt/live/mail.okbsd.com/fullchain.pem
smtp.okbsd.com /usr/local/etc/letsencrypt/live/mail.okbsd.com/privkey.pem /usr/local/etc/letsencrypt/live/mail.okbsd.com/fullchain.pem
mail.coragarden.com /usr/local/etc/letsencrypt/live/mail.coragarden.com/privkey.pem /usr/local/etc/letsencrypt/live/mail.coragarden.com/fullchain.pem
smtp.coragarden.com /usr/local/etc/letsencrypt/live/mail.coragarden.com/privkey.pem /usr/local/etc/letsencrypt/live/mail.coragarden.com/fullchain.pem
# ---

postmap -F /usr/local/etc/postfix/sni_maps

nano /usr/local/etc/dovecot/conf.d/10-ssl.conf
# ---
local_name mail.coragarden.com {
    ssl_cert = </usr/local/etc/letsencrypt/live/mail.coragarden.com/fullchain.pem
    ssl_key = </usr/local/etc/letsencrypt/live/mail.coragarden.com/privkey.pem
}
local_name imap.coragarden.com {
    ssl_cert = </usr/local/etc/letsencrypt/live/mail.coragarden.com/fullchain.pem
    ssl_key = </usr/local/etc/letsencrypt/live/mail.coragarden.com/privkey.pem
}
#---

service postfix restart
service dovecot restart

# Install and edit the Autoconfig and Autodiscover.

cd /tmp
git clone https://github.com/smartlyway/email-autoconfig-php
mkdir -p /usr/local/www/coragarden/mail
cp /tmp/email-autoconfig-php/mail/config-v1.1.xml /usr/local/www/coragarden/mail
cp -r /tmp/email-autoconfig-php/Autodiscover/Autodiscover.xml /usr/local/www/coragarden/mail

# Edit these files and change to suit your needs, this is tested and works with 
# Thunderbird. Make sure change the SMTP settings to 587 with STARTTLS in 
# config-v1.1.xml.

nano /usr/local/www/coragarden/mail/config-v1.1.xml
# ---
<?xml version="1.0"?>
<clientConfig version="1.1">
    <emailProvider id="coragarden.com">
      <domain>coragaren.com</domain>
      <displayName>coragarden.com</displayName>
      <displayShortName>coragarden.com</displayShortName>
      <incomingServer type="imap">
         <hostname>imap.coragarden.com</hostname>
         <port>993</port>
         <socketType>SSL</socketType>
         <authentication>password-cleartext</authentication>
         <username>%EMAILADDRESS%</username>
      </incomingServer>
      <outgoingServer type="smtp">
         <hostname>smtp.coragarden.com</hostname>
         <port>587</port>
         <socketType>STARTTLS</socketType>
         <username>%EMAILADDRESS%</username>
         <authentication>password-cleartext</authentication>
      </outgoingServer>
    </emailProvider>
</clientConfig>
# ---

# Do the same for Outlook clients.

nano /usr/local/www/coragarden/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>coragarden.com</DisplayName>
    </User>
    <Account>
      <AccountType>email</AccountType>
      <Action>settings</Action>
      <Protocol>
        <Type>IMAP</Type>
        <Server>imap.coragarden.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>smtp.coragarden.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

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

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

# Restart apache

apachectl configtest
apachectl restart

# Test

https://mail.coragarden.com

# Test valid xml for autoconfig.

https://mail.coragarden.com/mail/config-v1.1.xml

# Test valid xml for autodiscover.

https://mail.coragarden.com/Autodiscover/Autodiscover.xml

# Go to the main domain PostfixAdmin login page or the customer admin login page.

https://mail.okbsd.com/admin-login/
https://mail.coragarden.com/admin-login/

# Domain List -> Add Domain

Domain: coragarden.com
Description: Cora's Fantastic Garden
Aliases: 0
Mailboxes: 0
Active: x
Add default mail aliaes: UNCHECK THE BOX
Pass expires: 3650

# RFC requires that the domain has a postmaster account, and it's a good idea
# to set a catch all to alias unknown accounts and send to postmaster.

# Virtual List -> Add Mailbox

Add Mail Account postmaster@coragarden.com
Username: postmaster
Domain: coragarden.com
Password: ****
Password: ****
Name: Cora Garden Postmaster
Quota: 0
Active: x
Pass expires : 3650
Send Welcome email: no
Add Mailbox

# Virtual List -> Add Alias

Alias: *
Domain: coragarden.com
To: postmaster@coragarden.com

# Virtual List -> Add Mailbox
Add Mail Account jack@coragarden.com
Username: jack
Domain: coragarden.com
Password: ****
Password: ****
Name: Jack Pumpkin
Quota: 0
Active: x
Send Welcome email: no
Add Mailbox

# Add jack@coragarden.com to Thunderbird and test send and recieve to the account.
 
# Login to roundcube to test the new virtual domain, send and recieve to make sure
# DKIM, DMARC, and SPF are working.

https://mail.coragarden.com
Login: jack@coragarden.com
Password: ***********

# Ig the email was sent and recieved but DKIM signature is missing, check that the new
# domain was added to /usr/local/etc/mail/signingtable and restart milter-opendkim.

nano /usr/local/etc/mail/signingtable
# ---
*@okbsd.com 20250807._domainkey.okbsd.com
*@coragarden.com 202508078._domainkey.coragarden.com
# ---

service milter-opendkim restart

# Check the source and the headers now have DKIM signature something like ...

DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=coragarden.com;
	s=Cy9jmkzC2USPfcJ4JIuzA07rpB0gS; t=1747783045;
	bh=wiMQ3UTGd/zMN6+PeBSiAAUHXfKRtjj7J2UI7ZvJA+A=;
	h=Date:From:To:Subject:From;
	b=bkk87+VHY7HvSb1b9mq0b9bLc0XBMOzf6CnBMLcoLMAZ0yN1nDyrILNj9RiYUCJsq
	 hC6QsHu9S4t9Y8q85AoszhY78ddzfU8SLcg/IlTmWuiNWisrkKjAZ9ftPEtxVxkYKZ
	 VnpSveCK4O5Gw==

# Change password worked, but setting identity had an error in roundcube. Fix as follows.

pkg install gnupg
pear install Crypt_GPG

cd /var/www/roundcube/plugins/enigma
cp config.inc.php.dist config.inc.php
$config['enigma_pgp_homedir'] = "/var/www/roundcube/plugins/enigma/home";
mkdir /var/www/roundcube/plugins/enigma/home
chown www:www /var/www/roundcube/plugins/enigma/home
chmod 750 /var/www/roundcube/plugins/enigma/home

# Test that everything works with Thunderbird

# Test Roundcube, identities, sending mail, recieving mail, filters, changing password, and calendar.
# Login with the new account jack@coragarden.com

https://mail.coragarden.com

# Next Blocking Spam

07 RoundCube WebMail <- Intro -> 10 Blocking Spam