The decision that shapes a FreeBSD mail server gets made before you install a single package, and it is not the one most guides start with. Since FreeBSD 14 the base system has not handed local mail to sendmail. It hands it to dma, the DragonFly Mail Agent, through mailwrapper. Sendmail is still shipped, but nothing points at it and nothing starts it, so every Postfix-on-FreeBSD walkthrough that opens by telling you to stop the sendmail daemons is asking you to stop something that was never running.
What follows is a complete Postfix and Dovecot install on FreeBSD 15: package selection, the mailwrapper handover, a real Let’s Encrypt certificate, SMTP submission on port 587 with Dovecot SASL, IMAP over TLS, Maildir storage, and a pf ruleset that opens only the ports the server needs. We built it on a 2 vCPU host with 4 GB of RAM in August 2026, on FreeBSD 15.1 with the current Postfix and Dovecot packages, and every command, error, and log line below came off that machine.
What FreeBSD already runs before you install anything
A stock FreeBSD 15 host can already deliver mail locally. Look at what /usr/sbin/sendmail actually points to:
ls -l /usr/sbin/sendmail
grep ^sendmail /etc/mail/mailer.conf
The binary is a symlink into mailwrapper, and mailwrapper reads a table that names dma, not sendmail:
lrwxr-xr-x 1 root wheel 11 Jul 28 19:26 /usr/sbin/sendmail -> mailwrapper
sendmail /usr/libexec/dma
This matters for two reasons. The obvious one is that the shutdown steps in older guides are noise, because sendmail_enable already defaults to NONE in /etc/defaults/rc.conf. The one that bites later is that installing Postfix does not automatically take over the sendmail, mailq, and newaliases commands. Something has to rewrite the mailwrapper table, and until it does, half your diagnostic commands will be talking to the wrong mail system.
Prerequisites
A mail server for a handful of mailboxes is not a heavy workload. Postfix forks a short-lived process per connection and Dovecot keeps one IMAP process per session, so the sizing driver is concurrent connections and mailbox size, not CPU. Two vCPU and 4 GB carried this build comfortably, and 1 GB is enough for a personal domain with a few accounts. Storage is the number that actually grows: Maildir writes one file per message, so plan for inode count as much as gigabytes, and put /home or wherever the mailboxes live on its own ZFS dataset so you can snapshot it independently.
You also need a hostname that resolves publicly, an MX record for the domain pointing at it, and TCP 25, 465, 587, and 993 reachable, plus 80 if you take the default certificate path below rather than a DNS-01 plugin. Many residential and cloud providers block outbound 25 by default, which is worth confirming before you spend an evening debugging a queue that will never drain.
Everything below runs as root. The base system carries no sudo, so either work in a root shell or set up sudo or doas on FreeBSD 15 first. There is no vim in base either, only vi and ee, and the file edits here use ee because it prints its own key bindings at the top of the screen. If you would rather have the editor you know, install vim from packages and substitute it everywhere.
Set the hostname first, because Postfix reads it at install time to seed myhostname:
hostname mail.example.com
sysrc hostname="mail.example.com"
If the box gets its address from DHCP and you want that pinned as well, the static IP and hostname guide for FreeBSD covers the rc.conf side.
Which Postfix package you actually need
pkg search returns nineteen Postfix packages, and the flavour you pick decides which SASL implementations are compiled in. That is a build-time choice, not a runtime one, so getting it wrong means reinstalling later. Ask the plain package what it supports before committing:
pkg install -y postfix
postconf -a
postconf -A
postconf -a lists the SASL plugins available to the SMTP server, and postconf -A lists the ones available to the SMTP client, a distinction the Postfix SASL_README spells out in detail. On the default package the first prints a single line:
dovecot
And the second prints nothing at all, not an error, just an empty result.
So the trade-off is clean. The default postfix package can accept authenticated submissions through Dovecot, which is exactly the architecture in this guide, and it cannot authenticate outbound to a smarthost. If you plan to relay through SendGrid, Mailgun, Amazon SES, or a provider’s SMTP host with a username and password, you need postfix-sasl instead. Swapping later means pkg delete postfix && pkg install postfix-sasl, so copy main.cf and master.cf somewhere safe before you do it. Note that installing the cyrus-sasl package on its own changes nothing here, because the capability is baked in when the port is built: pkg rquery "%Ok=%Ov" postfix reports SASL=off on the default package, and only the -sasl flavour depends on cyrus-sasl.
One useful detail falls out of the same check. Upstream Postfix defaults smtpd_sasl_type to cyrus, but the FreeBSD build ships it already set to dovecot:
postconf -d smtpd_sasl_type smtpd_sasl_path
Which prints:
smtpd_sasl_type = dovecot
smtpd_sasl_path = smtpd
We still set it explicitly in main.cf later. A default that differs from upstream is a default that can change back, and an explicit line costs nothing.
Install the packages
Postfix is already on the box from the check above. Add Dovecot, plus the tools this guide uses to prove the result works:
pkg install -y dovecot swaks py312-certbot py312-certbot-dns-cloudflare p5-Net-SSLeay p5-Authen-SASL
That first Perl module is not optional padding. swaks declares no dependency on it, so a fresh install fails the moment you ask it for TLS:
*** TLS not available: requires Net::SSLeay. Exiting
p5-Net-SSLeay fixes that. p5-Authen-SASL is optional here, because PLAIN and LOGIN only need MIME::Base64 from core Perl, but it costs nothing and it is what you will want the day you test DIGEST-MD5. Confirm the versions you are working with:
postconf mail_version
dovecot --version
Which on this build printed:
mail_version = 3.11.5
2.3.21.1 (d492236fa0)
Dovecot 2.4 exists upstream, but the ports tree is still on the 2.3 branch, so the configuration layout below is the classic conf.d one rather than the reorganised 2.4 scheme. Factor that into your plans for an internet-facing IMAP server: upstream moved the 2.3 community branch to security fixes only, so what you install here gets patched but not improved, and the eventual jump to 2.4 will be a config migration rather than a package upgrade.
Hand the sendmail interface over to Postfix
This is the step that replaces “stop sendmail” from the older guides. Postfix ships a mailwrapper table of its own; install it and switch the rc knobs:
install -d /usr/local/etc/mail
install -m 0644 /usr/local/share/postfix/mailer.conf.postfix /usr/local/etc/mail/mailer.conf
sysrc postfix_enable="YES"
sysrc sendmail_enable="NONE"
Yes, sendmail_enable is already NONE by default. Writing it into rc.conf anyway is what the port’s own package message asks for, and it means a future default change cannot quietly start a second MTA underneath Postfix.
Two sysrc lines are all that is needed there. Older guides set three more sendmail_* knobs alongside them, and /etc/rc.d/sendmail already zeroes those itself the moment sendmail_enable is NONE, so they are ceremony.
Worth knowing exactly what that install line does, because the man page will mislead you. mailer.conf(5) documents /etc/mail/mailer.conf and never mentions the /usr/local path, yet the localbase copy wins. We tested it both ways: with /etc/mail/mailer.conf still pointing at dma and only the localbase file naming Postfix, mailq ran postqueue. You do not need to touch the base file at all, and leaving it alone means a pkg delete postfix puts you back on a working dma without any manual repair.
FreeBSD’s daily periodic jobs still assume sendmail, so silence the ones that no longer apply:
ee /etc/periodic.conf
Add these four lines:
daily_clean_hoststat_enable="NO"
daily_status_mail_rejects_enable="NO"
daily_status_include_submit_mailq="NO"
daily_submit_queuerun="NO"
Error: “hash:/etc/aliases is unavailable” and mail to root deferring
Postfix on FreeBSD points alias_maps at /etc/aliases, and that file is a symlink to /etc/mail/aliases which exists. The compiled database next to it does not, because nothing has ever run newaliases on this host. The first message addressed to a local alias lands in the queue and stays there:
postfix/local[4647]: warning: hash:/etc/aliases is unavailable. open database /etc/aliases.db: No such file or directory
postfix/local[4647]: warning: hash:/etc/aliases: lookup of 'root' failed
postfix/local[4647]: 54B3210F1B: to=<[email protected]>, relay=local, dsn=4.3.0, status=deferred (alias database unavailable)
A 4.3.0 status means Postfix will keep retrying rather than bounce, so this hides for hours if you are not reading the log. Build the database once and the retry succeeds:
newaliases
ls -la /etc/aliases.db
Do this before the first start, not after the first complaint. While the alias file is in front of you, point root’s mail somewhere you will actually read, because periodic reports and certificate renewal failures are addressed there and a Maildir on a server nobody logs into is a good place for warnings to die:
ee /etc/mail/aliases
There is a commented root: template about seventeen lines in. Uncomment it and give it two destinations, a local account that exists on this box and an address on a domain this server does not host. Substitute your own names for both:
root: admin, [email protected]
Two details there matter more than they look. The remote address must be off-domain, because the mydestination you set in the next section lists $mydomain, which makes anything at example.com a local address: root: [email protected] then resolves to a local user named you, finds nobody, and bounces. We tried it, and the log answers with sender non-delivery notification, so the warning you were trying to rescue is gone for good. The local recipient in front of it matters too. Delivering to both a real local mailbox and an outside address means that if outbound mail is blocked or a large provider rejects your young IP, root’s mail still lands somewhere on disk instead of expiring in the queue. Rebuild the database after editing:
newaliases
Forwarding off the box needs working outbound delivery, so this only starts paying off after the deliverability work at the end of this guide.
Get a real certificate
Postfix and Dovecot both want a certificate and a key, and both are happier with one issued for the public hostname than with a self-signed pair every client will refuse. Certbot on FreeBSD keeps its state under /usr/local/etc/letsencrypt, not /etc/letsencrypt, which is the single most common path mismatch when adapting a Linux mail guide.
If the server has a public IP and port 80 is reachable, the HTTP-01 challenge is the shortest route and works with any DNS provider:
certbot certonly --standalone -d mail.example.com \
--non-interactive --agree-tos -m [email protected]
Point an A record at the box and that is the whole flow. Understand what you are signing up for, though: --standalone binds port 80 and the stored authenticator is re-used on every renewal, so port 80 has to stay reachable permanently, not just for the first issuance. The firewall section later opens it for exactly this reason. The other requirement is that port 80 be free, because --standalone runs its own listener: if the box already has Nginx or Apache on 80, either stop it for the run, switch to --webroot, or set weekly_certbot_service so the periodic job stops and restarts it for you. For a server behind NAT with no inbound 80, or for a wildcard, use a DNS-01 plugin instead and leave 80 closed.
Alternative: DNS-01 when port 80 is not reachable
The ports tree carries a plugin for most large providers, so pick the one matching your DNS host rather than following the Cloudflare example literally.
| DNS provider | Package |
|---|---|
| Cloudflare | py312-certbot-dns-cloudflare |
| Amazon Route 53 | py312-certbot-dns-route53 |
| DigitalOcean | py312-certbot-dns-digitalocean |
| Google Cloud DNS | py312-certbot-dns-google |
| Linode | py312-certbot-dns-linode |
| OVH | py312-certbot-dns-ovh |
| Any RFC2136 server (BIND) | py312-certbot-dns-rfc2136 |
The package does not create certbot’s config directory, certbot does that on its first run, so on the DNS-01 path you have to make it yourself before the editor has anywhere to save. Store the API credential with tight permissions while you are at it. Certbot does not refuse a world-readable file, it just prints Unsafe permissions on credentials configuration file on every run including each renewal, which is the kind of warning that gets filtered out and forgotten:
install -d -m 0700 /usr/local/etc/letsencrypt
ee /usr/local/etc/letsencrypt/cloudflare.ini
A single line is all it holds:
dns_cloudflare_api_token = your-api-token-here
Lock it down and request the certificate:
chmod 600 /usr/local/etc/letsencrypt/cloudflare.ini
certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /usr/local/etc/letsencrypt/cloudflare.ini \
--dns-cloudflare-propagation-seconds 30 \
-d mail.example.com --non-interactive --agree-tos -m [email protected]
Certbot writes the pair into a versioned archive and symlinks the current one into live:
Successfully received certificate.
Certificate is saved at: /usr/local/etc/letsencrypt/live/mail.example.com/fullchain.pem
Key is saved at: /usr/local/etc/letsencrypt/live/mail.example.com/privkey.pem
This certificate expires on 2026-11-11.
Renewal on FreeBSD is a weekly periodic script rather than a systemd timer, and it is off until you enable it. That is the part people miss, and they find out ninety days later. Add the knob and a hook that reloads both daemons when the certificate actually changes:
ee /etc/periodic.conf
Append the renewal settings:
weekly_certbot_enable="YES"
weekly_certbot_deploy_hook="service postfix reload && service dovecot reload"
Both services take reload, which re-reads the certificate without dropping live IMAP sessions, so there is no reason to reach for restart here. The script lives at /usr/local/etc/periodic/weekly/500.certbot-3.12 if you want to read what it runs, and the suffix tracks the Python flavour of the certbot package you installed, so it follows the py312- prefix above rather than being something to guess. The same pattern shows up in the Nginx with Let’s Encrypt guide for FreeBSD 15, where the hook reloads the web server instead.
Configure Postfix
FreeBSD’s main.cf is the upstream sample with the paths rewritten, so it is around 700 lines of commented defaults with a handful of live settings at the bottom. Open it and append the working configuration at the end:
ee /usr/local/etc/postfix/main.cf
This block is the whole server. Identity first, then storage, then TLS, then who is allowed to hand it mail:
myhostname = mail.example.com
mydomain = example.com
myorigin = $mydomain
mydestination = $myhostname, localhost.$mydomain, localhost, $mydomain
inet_interfaces = all
mynetworks = 127.0.0.0/8, [::1]/128
home_mailbox = Maildir/
mailbox_command =
smtpd_tls_cert_file = /usr/local/etc/letsencrypt/live/mail.example.com/fullchain.pem
smtpd_tls_key_file = /usr/local/etc/letsencrypt/live/mail.example.com/privkey.pem
smtpd_tls_security_level = may
smtpd_tls_mandatory_protocols = >=TLSv1.2
smtpd_tls_loglevel = 1
smtp_tls_security_level = may
smtp_tls_CApath = /etc/ssl/certs
smtpd_sasl_auth_enable = yes
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_security_options = noanonymous
smtpd_tls_auth_only = yes
smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination
Three of those lines deserve a note. smtpd_tls_auth_only = yes means Postfix will not advertise AUTH until the session is encrypted, which is what stops a client from cheerfully sending a password in the clear.
smtpd_tls_mandatory_protocols sets a TLS 1.2 floor, and it applies only where TLS is compulsory, meaning the two submission services. Inbound TLS on port 25 is opportunistic and governed by smtpd_tls_protocols, which still defaults to >=TLSv1. Leave it that way. Refusing TLS 1.0 from an ancient sender on your public MX does not upgrade that sender, it just pushes the message to cleartext or to a bounce.
smtp_tls_CApath is the other one, and at smtp_tls_security_level = may it does not authenticate anything. Opportunistic TLS encrypts the session and deliberately does not gate delivery on the far end’s certificate, because refusing to deliver over an untrusted certificate is worse than delivering anyway. What the trust store does buy you at this level is an honest log line, Trusted TLS connection established instead of Untrusted, and a foundation for raising the level to secure or verify later. FreeBSD 15 supplies that store without ca_root_nss: certctl populates /etc/ssl/certs from base with 120 hashed CA certificates, and an openssl s_client -starttls smtp -CApath /etc/ssl/certs against a live MX returns Verify return code: 0 (ok). Worth knowing that smtp_tls_security_level = may is now a no-op on a fresh install, because may became the default at compatibility level 3.11. Its server-side counterpart is not: smtpd_tls_security_level still defaults to empty, and it is what makes Postfix advertise STARTTLS at all.
If you want IPv4 only, do not append inet_protocols to the end of the file. The stock main.cf already sets it near line 693, and a second copy lower down triggers a warning on every Postfix and postconf invocation from then on:
postfix: warning: /usr/local/etc/postfix/main.cf, line 701: overriding earlier entry: inet_protocols=all
It is harmless and it is relentless. Find the existing line and edit it in place instead of adding a second one:
grep -n ^inet_protocols /usr/local/etc/postfix/main.cf
Next, the submission ports. Port 25 is for other mail servers and stays unauthenticated; 587 and 465 are where your own users connect with a password. Open the service table:
ee /usr/local/etc/postfix/master.cf
The file already contains both blocks commented out. Rather than uncommenting twenty lines of explanatory text, append these two clean definitions at the bottom:
submission inet n - n - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt
-o smtpd_sasl_auth_enable=yes
-o smtpd_tls_auth_only=yes
-o smtpd_client_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING
submissions inet n - n - - smtpd
-o syslog_name=postfix/submissions
-o smtpd_tls_wrappermode=yes
-o smtpd_sasl_auth_enable=yes
-o smtpd_client_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING
The difference between them is when TLS starts. submission on 587 begins in the clear and upgrades with STARTTLS, and smtpd_tls_security_level=encrypt makes that upgrade mandatory rather than optional. submissions on 465 wraps the whole connection in TLS from the first byte. Both names resolve through /etc/services, so the port numbers never appear in the config.
While the file is open, consider what port 25 should offer. Because smtpd_sasl_auth_enable lives in main.cf, it applies to the public MX listener as well, so port 25 will advertise AUTH after STARTTLS and start collecting credential-stuffing attempts within days of the MX record going live. Your own users have 587 and 465, so nothing legitimate needs to authenticate on 25. This one is an edit, not an append: find the existing smtp inet line near the top of the file and insert a single override line directly beneath it, indented by two spaces. Appending a whole second smtp inet service is worse than an outright error, because Postfix starts anyway. It logs warning: duplicate master.cf entry for service "smtp" (25) -- using the last entry and quietly runs whichever definition sits lower in the file, which is the one you just pasted rather than the one you have been editing.
-o smtpd_sasl_auth_enable=no
Validate before starting anything:
postfix check && service postfix start
Configure Dovecot
The Dovecot package installs no working configuration. /usr/local/etc/dovecot holds a README and a directory of samples, and the service refuses to start until you supply the real thing:
Config file /usr/local/etc/dovecot/dovecot.conf does not exist. If this is
a new installation, please create the config files as outlined in
# pkg info -D dovecot
Copy the samples into place. The directory is example-config, singular, which is worth reading carefully because the package message names it correctly in its copy instruction and then misspells it as examples-config in the upgrade note a few lines further down:
cp -R /usr/local/etc/dovecot/example-config/* /usr/local/etc/dovecot/
Five files need edits. Start with mailbox storage, which is unset in the sample and must match the home_mailbox you gave Postfix:
ee /usr/local/etc/dovecot/conf.d/10-mail.conf
Find the commented #mail_location = line, near line 30 in the current sample, and set it:
mail_location = maildir:~/Maildir
Then authentication. Only the second of these two changes anything: disable_plaintext_auth already defaults to yes and the sample line is commented out, so uncommenting it makes the default explicit rather than tightening anything. The mechanism list is the real edit, because the sample offers PLAIN alone and plenty of desktop clients still ask for LOGIN:
ee /usr/local/etc/dovecot/conf.d/10-auth.conf
Set both:
disable_plaintext_auth = yes
auth_mechanisms = plain login
The TLS file is the one that stops a naive first start dead.
Error: “ssl_cert: Can’t open file /etc/ssl/certs/dovecot.pem”
Dovecot’s sample points at a self-signed certificate that some Linux packages generate at install time. The FreeBSD port does not generate it, so the file named in the config has never existed on your system:
doveconf: Fatal: Error in configuration file /usr/local/etc/dovecot/conf.d/10-ssl.conf line 12: ssl_cert: Can't open file /etc/ssl/certs/dovecot.pem: No such file or directory
Repoint both lines at the certificate certbot issued, and while you are in the file require TLS rather than merely offering it:
ee /usr/local/etc/dovecot/conf.d/10-ssl.conf
The leading < is Dovecot syntax meaning “read the contents of this path”, not a shell redirect, so keep it:
ssl = required
ssl_cert = </usr/local/etc/letsencrypt/live/mail.example.com/fullchain.pem
ssl_key = </usr/local/etc/letsencrypt/live/mail.example.com/privkey.pem
The last edit is the socket Postfix will authenticate against. The smtpd_sasl_path = private/auth you set earlier is a relative path, and Postfix resolves it against $queue_directory, so the socket has to be created inside /var/spool/postfix with Postfix as the owner. Writing it relative rather than absolute is what keeps the same config working whether or not the service runs chrooted, which on Postfix 3.x it does not by default:
ee /usr/local/etc/dovecot/conf.d/10-master.conf
Find the commented “Postfix smtp-auth” block inside service auth and replace it with a real listener:
# Postfix smtp-auth
unix_listener /var/spool/postfix/private/auth {
mode = 0660
user = postfix
group = postfix
}
The sample suggests mode = 0666 with no owner. That works and it lets any local process query the authentication socket, which is a poor trade for a setting that takes two extra lines to do properly.
Finally trim the protocol list. The default enables IMAP, POP3, LMTP and submission; this server needs IMAP for clients and LMTP for future Sieve work:
ee /usr/local/etc/dovecot/dovecot.conf
Uncomment the #protocols = line and shorten it:
protocols = imap lmtp
Enable and start it, then confirm what Dovecot thinks it is running. doveconf -n prints only the settings that differ from the defaults, which makes it the fastest way to spot a typo buried in 30 sample files:
sysrc dovecot_enable="YES"
service dovecot start
doveconf -n
Both daemons should now be listening on their five ports. sockstat is the FreeBSD equivalent of the ss command you would reach for on Linux, and grepping it down to the two daemons keeps sshd out of the way:
sockstat -4 -l | grep -E 'master|dovecot'
That is five listeners, three for Postfix and two for Dovecot:

If sockstat shows Postfix on 25 but nothing on 587 or 465, the master.cf block did not load, and postfix reload will tell you why.
Create a mailbox
Dovecot authenticates against the system password database through PAM in this configuration, so a mailbox is an ordinary FreeBSD user. Give it a home directory and no shell:
echo "StrongPassword" | pw useradd -n jdoe -c "John Doe" -m -s /usr/sbin/nologin -h 0
The -h 0 reads the password from standard input and -s /usr/sbin/nologin keeps the account out of SSH. That shell setting does not interfere with mail: Dovecot’s PAM lookup never consults the login shell, so the user can collect mail over IMAP while being unable to open a terminal session. Neither Maildir nor the subdirectories need creating by hand, because Postfix builds them on first delivery.
An echo pipe puts the password in your shell history, which is fine for a throwaway lab and careless on a real server. For anything you intend to keep, feed it from a file you delete afterwards with pw useradd ... -h 0 < /tmp/pw, or drop the redirect entirely and let pw prompt you.
Filter the mail ports with pf
A mail server is one of the loudest targets on the internet, and pf is already in the kernel waiting to be pointed at a ruleset. Write one that names the interface dynamically rather than hardcoding vtnet0, since the same file then survives a move to different hardware:
route -n get default | grep interface
Note the name it prints, then open the ruleset:
ee /etc/pf.conf
Substitute the interface that command printed for vtnet0 below:
ext_if = "vtnet0"
mail_ports = "{ 25, 465, 587, 993 }"
set skip on lo0
scrub in all
block in log all
pass out quick all keep state
pass in on $ext_if proto tcp to ($ext_if) port ssh keep state
pass in on $ext_if proto tcp to ($ext_if) port $mail_ports keep state
# needed permanently if you issue certificates with certbot --standalone
pass in on $ext_if proto tcp to ($ext_if) port http keep state
pass in inet proto icmp icmp-type echoreq keep state
That port 80 rule is not decoration, and leaving it out is how people lose their certificate on day ninety. The --standalone authenticator binds port 80 and needs inbound reachability on every renewal, not just the first issuance. The failure is not quite silent: the periodic script exits 3 and prints “Errors were reported when renewing Let’s Encrypt certificate(s)” into the weekly report, which is mailed to root. Whether you ever see it depends entirely on whether you set the root alias earlier. If you issued the certificate with a DNS-01 plugin instead, delete that line, because DNS-01 never touches port 80.
Port 143 is deliberately absent. Dovecot still listens there for a STARTTLS upgrade, but nothing outside the host can reach it, so the only path in from the network is 993.
One asymmetry in that ruleset is worth understanding before you deploy it on a dual-stack host. A pf rule that names no address family matches both, so the TCP rules above already pass IPv4 and IPv6 alike, and pfctl -s rules shows them with no family qualifier. The ICMP rule is the exception, because it says inet. On a host with a real IPv6 address that leaves SMTP and IMAPS reachable over v6 while block in log all silently drops inbound ICMPv6, and ICMPv6 is not the optional convenience that ICMP echo is on v4: neighbour discovery and packet-too-big ride on it, so you get a working handshake and then a blackhole on large messages. If the machine has an AAAA record, add the one rule that is genuinely missing:
pass in on $ext_if inet6 proto icmp6 all keep state
Place it below block in log all with the others. Rules without quick are last-match-wins, so a pass rule written above the block rule parses cleanly, exits 0, and passes nothing. If you would rather run IPv4 only, say so in Postfix too by setting inet_protocols = ipv4 on the existing line in main.cf, otherwise Postfix accepts v6 connections that your ruleset has not been thought through for.
Now the part that catches people. Starting pf runs pfctl -F all, which empties the state table before the new rules load. The flush by itself is harmless; the damage comes a moment later, when your established SSH connection has no state and its mid-stream packets cannot create one, because a state-creating TCP rule matches on the SYN it will never send again. The session dies. Advice to “keep a second terminal open” does not help, because that one dies with it. Give yourself an automatic escape hatch, and load the ruleset with onestart so that nothing is written to rc.conf yet:
nohup sh -c 'sleep 120; pfctl -d' >/dev/null 2>&1 &
echo $! > /tmp/pf-timer.pid
service pf onestart
That ordering matters more than it looks. If you set pf_enable="YES" first and the ruleset does lock you out, the timer saves you exactly once, and the next reboot brings the lockout back with no way in. Leaving rc.conf untouched until the rules are proven means a reboot is always a way out.
Reconnect and cancel the timer, then check two things, not one. A No such process from the kill is not a failure, it means the timer already fired:
kill "$(cat /tmp/pf-timer.pid)"
pfctl -s info | head -1
pfctl -s rules | wc -l
The status line alone will lie to you. If /etc/pf.conf has a syntax error, the rc script warns and carries on: pf_fallback_rules_enable defaults to NO, so pf ends up enabled with zero rules, which passes every packet. We reproduced it by breaking one line of the ruleset, and pfctl -s info cheerfully reported Status: Enabled while pfctl -s rules returned nothing. It is a quiet failure in the worst direction, because a status check that says Enabled reads like success while the mail server sits unfiltered on the internet. So the pass condition is Enabled and a non-zero rule count.
If the status line reads Disabled instead, check the clock before blaming the rules. A No such process from the kill means the timer fired, and a fired timer means pfctl -d ran, so Disabled at that point tells you only that you took longer than two minutes. Re-arm a timer, re-apply, and check again.
Only when both are true, persist the setting:
sysrc pf_enable="YES"
sysrc pflog_enable="YES"
service pflog start
If either check fails, fix the ruleset and re-arm the timer before trying again, because you have not yet earned the right to persist anything. Reload with pfctl directly rather than a second service pf onestart: pf_start() runs pfctl -F all every time regardless of whether pf is already up, so an “it is probably already running, this will do nothing” invocation flushes the state table underneath you. The && matters too, because it leaves pf as it was when the parse fails instead of enabling an empty ruleset:
pfctl -f /etc/pf.conf && pfctl -e
Be careful how much you trust pfctl -n -f as a pre-flight check. On a host where the pf module has never loaded it cannot run at all, and returns pfctl: Failed to open netlink: No such file or directory for good and bad files alike. Once pf is loaded it works, but it validates grammar only: a rule naming an interface that does not exist parses cleanly and exits 0, then matches nothing at runtime. Only a genuine syntax error produces a non-zero exit.
The block in log all line writes rejected packets to pflog0, and that interface only exists once pflog is running, which is why it was enabled above. Blocked connection attempts then read back with tcpdump:

Those repeated SYNs to port 143 are a client retrying against a port the firewall drops silently. For NAT, queues, and the more elaborate rule syntax, the dedicated pf guide for FreeBSD 15 goes considerably further than this ruleset needs to.
Test submission, delivery, and IMAP
swaks drives a complete SMTP conversation and prints both sides of it, which makes it far better than mail for proving a submission path works. Send yourself a message through port 587:
swaks --to [email protected] --from [email protected] --server 127.0.0.1:587 \
--tls --auth-user jdoe --auth-password 'StrongPassword' \
--h-Subject "Submission test" --body "First message through Postfix."
The transcript shows the AUTH exchange succeeding and Postfix accepting the message, and the maillog shows it landing in Maildir a fraction of a second later:

Look closely at the credential in that transcript. amRvZQ== and the string after it are base64, which is an encoding, not encryption, and anyone watching the wire can decode both in a second. That is the entire argument for smtpd_tls_auth_only: the password is protected by the TLS layer around it and by nothing else.
Now read the message back over IMAP. openssl s_client validates the certificate chain and then gives you a raw IMAP session to type into:
openssl s_client -quiet -crlf -connect mail.example.com:993 -servername mail.example.com
Every hop of the chain returns verify return:1, and the mailbox contains exactly what Postfix delivered:

One detail from that handshake is worth pulling out, because it arrives free and nobody configured it. Both Postfix and Dovecot negotiated X25519MLKEM768, a hybrid post-quantum key exchange, on every TLS session:
postfix/submission/smtpd: Anonymous TLS connection established from localhost[127.0.0.1]: TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519MLKEM768 server-signature ECDSA (prime256v1) server-digest SHA256
That comes from OpenSSL 3.5.6 in the FreeBSD 15 base system, which offers the hybrid group by default. Neither daemon was told to do it. It is one of the quieter wins in the FreeBSD 15 feature set.
Confirm the server is not an open relay
Never take this on faith. Connect from an address outside mynetworks, skip authentication entirely, and try to send to a domain the server does not host:
swaks --to [email protected] --from [email protected] \
--server 10.0.1.50:25 --quit-after RCPT
The correct answer is a rejection at RCPT time:
-> RCPT TO:<[email protected]>
<** 554 5.7.1 <[email protected]>: Relay access denied
Two more EHLO checks are worth running while you are here. On port 587, a plain EHLO lists STARTTLS and no AUTH line, and the same EHLO issued after the TLS upgrade produces 250-AUTH PLAIN LOGIN. That asymmetry is smtpd_tls_auth_only doing its job. On port 25, AUTH should be absent in both cases, which confirms the smtpd_sasl_auth_enable=no override took effect and your MX is not offering a login prompt to the internet.
Why disable_plaintext_auth looks broken from localhost
Start on the server itself, where set skip on lo0 means pf is not in the way. Grab the banner and try a cleartext login. IMAP has no equivalent of the pipelining guard that made this technique fail against Postfix earlier, so feeding nc from a printf works here:
printf 'a LOGIN jdoe StrongPassword\r\na LOGOUT\r\n' | nc -w5 127.0.0.1 143
The setting appears to do nothing. Dovecot advertises the plaintext mechanisms and the login succeeds over an unencrypted socket:
* OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE LITERAL+ STARTTLS AUTH=PLAIN AUTH=LOGIN] Dovecot ready.
a OK [CAPABILITY ...] Logged in
Now from a second machine. The banner alone carries the answer, so there is no reason to put a real password on the wire to prove that passwords are refused:
printf 'a LOGOUT\r\n' | nc -w5 10.0.1.50 143
You have already loaded the pf ruleset by this point, and it drops 143, so all you would otherwise measure is the firewall. Add a rule scoped to the machine you are testing from, 10.0.1.99 in this example, then reload with pfctl -f /etc/pf.conf so it takes effect: pass in on $ext_if proto tcp from 10.0.1.99 to ($ext_if) port 143 keep state. Delete it and reload again once the test is done. The banner changes: the mechanisms are gone and LOGINDISABLED appears in their place:
* OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE LITERAL+ STARTTLS LOGINDISABLED] Dovecot ready.
Dovecot treats a connection as already secured when the client IP is in login_trusted_networks, or when it comes from localhost and is not arriving through a HAProxy listener, and it exempts those from the plaintext ban. The setting documentation spells out both cases. The config is correct; the test was measuring the one case the rule intentionally skips. Always verify authentication policy from an address that is not the server itself, and the same applies to any relay or restriction test you run.
Error: “554 5.5.0 Error: SMTP protocol synchronization”
Pipe a handful of SMTP commands into nc and Postfix hangs up on you before reading any of them. Recent Postfix releases enable smtpd_forbid_unauth_pipelining by default, and it rejects any client that sends a command before it has read the response to the previous one. Printing four lines at once from a shell does precisely that. Use swaks, which waits for each reply, or feed nc one command at a time.
Verify it survives a reboot
Every sysrc line above exists so the stack comes back on its own, and the only honest way to know is to restart the machine:
reboot
Twenty seconds later, check all three services and send a second message, this time over implicit TLS on 465 to exercise the other submission path:
service postfix status
service dovecot status
pfctl -s info | head -1
pfctl -s rules | wc -l
swaks --to [email protected] --from [email protected] --server 127.0.0.1:465 \
--tlsc --auth-user jdoe --auth-password 'StrongPassword' \
--h-Subject "Post-reboot check" --body "Second message, implicit TLS."
Note --tlsc rather than --tls: the first negotiates TLS immediately, the second issues STARTTLS. Using the wrong one against the wrong port produces a confusing handshake failure that looks like a certificate problem and is not.
What this server still needs before Gmail will accept its mail
Everything above produces a mail server that sends, receives, authenticates, and stores mail correctly. It will still have its outbound messages filed as spam by every major provider, because deliverability is decided almost entirely outside the software you just configured.
Four things stand between this install and an inbox at a large provider. A PTR record for the sending IP that resolves back to mail.example.com, which only the owner of the IP block can create, so it is a support ticket with your hosting provider rather than a config change. An SPF record on the domain naming that IP as an authorised sender. DKIM signing, which on FreeBSD means installing opendkim or rspamd and wiring it into Postfix as a milter, then publishing the public key in DNS. And a DMARC record telling receivers what to do when the first two disagree.
Get the ordering right. Publish SPF and DMARC in a monitoring policy first, add DKIM signing, confirm your own test messages carry a valid signature, and only then move DMARC to a policy that asks for rejection. Reversing that sequence is how people silently lose mail for a week. You will also want the DNS side under your own control if the domain is not hosted elsewhere, and the BIND 9 guide for FreeBSD 15 covers that.
The other honest gap is spam filtering in the inbound direction. Postfix will accept anything addressed to a local mailbox, and a public MX collects a remarkable amount of junk within days of the record going live. rspamd is the usual answer on FreeBSD and it slots in as a milter alongside DKIM, so the two are worth planning as one piece of work rather than two. If all of that sounds like more moving parts than you want to assemble by hand, iRedMail on FreeBSD bundles the same components with the integration already done, at the cost of the visibility you get from building it yourself.