Mailman3 on OpenBSD 7.1

A short guide on how to install and run Mailman version 3 on OpenBSD 7.1.

Mailman3 on OpenBSD 7.1

For an idea that a contact of mine brought up a few weeks ago I decided to set up some infrastructure in order to evaluate/validate a couple of things. One key part of the infrastructure is a mailing list. Even though I could have used one of the many online services to set up a mailing list, I felt like it would make sense to build it myself in order to have greater control over it. Ideally I would like to do some automation at a later stage, for which I would like to be able to hook into the mailing list software. Besides, it’s something I never tried before and I thought it might be a fun way to spend a Friday evening on.

I decided to use GNU Mailman for that purpose. As so often with this type of infrastructure, I also decided to go for OpenBSD for the base system – not only because OpenSMTPd is a breeze to use, but also because of security considerations.

Unfortunately only Mailman2 is available through the official OpenBSD repos, so I decided to manually install Mailman3 and put together a short guide on how to successfully set up and run GNU Mailman v3 (Tom Sawyer) on the latest OpenBSD 7.1.


I’m starting with a blank VPS instance here. As usual, I’m going for Vultr (referral link, gives you $100 on signup), because it’s cheap and easy to use. They also have the latest OpenBSD 7.1 OS image readily available, and all one has to do is ask the Vultr support to open port 25 (SMTP). I did so and it took less than 3 hours for them to get back and allow me outgoing SMTP access.

Note: I won’t explicitly mention under which user I’m running each command, hence please read carefully. When the prompt shows lists#, I’m acting as root user, otherwise, when it shows lists%, I’m the using the _mailman user.

As soon as the OpenBSD VPS has booted, we can log in via SSH and perform a quick update of the system:

lists# syspatch
lists# pkg_add -u

After that, let’s begin by installing some handy tools:

lists# pkg_add git zsh neovim curl mosh rsync htop base64

What I like to do is link nvim to vim, because typing vim is in my muscle memory. I also like to use zsh as shell. Since that’s all preference, these steps are optional:

lists# ln -s /usr/local/bin/nvim /usr/local/bin/vim
lists# chsh -s /usr/local/bin/zsh root

Another optional but useful thing that I like to do is to change the SSH port and disable password authentication / enable pubkey authentication. Make sure you have an authorized_keys entry with your pubkey in place before applying this change:

lists# sed -i 's/^#Port 22/Port 31231/g;\
              s/^#PubkeyAuthentication .*/PubkeyAuthentication yes/g;\
              s/^#PasswordAuthentication .*/PasswordAuthentication no/g' \
lists# rcctl restart sshd

Afterwards, disconnect from SSH, and re-connect, ideally using mosh, and launch tmux for the sake of comfort.

Next, let’s update the host information. I’m using the primary domain for this Mailman installation, so make sure to adapt these entries to the domain you’d like to use. Also, don’t worry, Mailman is able to use multiple domains with multiple mailing lists per domain.

Make sure all host information is set correctly or adjust it if necessary:

lists# cat /etc/myname
lists# cat /etc/hosts       localhost
::1             localhost lists
2a00:f100:2000:10af:1000:1ff:fa1a:1a11 lists


With the base system ready, let’s begin installing Mailman. We’ll start with the core first, which is basically the backend of the whole Mailman service. See this page for more information on the Mailman architecture.


As mentioned before, the latest Mailman version available in the OpenBSD repos is 2.1.39:

lists# pkg_info mailman
Information for

Hence we will have to manually install v3. Let’s start by fetching some dependencies first:

lists# pkg_add py-pip py-virtualenv sassc lynx gettext-tools

Before we continue, make sure that your environment is using en_US.UTF-8 as LANG and LC_ALL:

lists# echo $LANG
lists# echo $LC_ALL

Next, add a new user for our Mailman installation:

lists# groupadd _mailman
lists# useradd -d /var/mailman -m -c "Mailman" -g _mailman -L daemon -s /sbin/nologin _mailman

After that, log in as that user – I like to use zsh here, but you’re free to use whichever shell you prefer – and continue by setting up a Python virtualenv:

lists# su -s /usr/local/bin/zsh -l _mailman -
mailman% python3 -m venv venv

Test the virtualenv:

mailman% source /var/mailman/venv/bin/activate
(venv) lists%

We can see the virtualenv activated successfully, so we can go ahead and add it to our .zshrc (or .bashrc or whatever shell you’re using), in order to automatically switch into the virtualenv when we log in as _mailman user:

mailman% echo 'source /var/mailman/venv/bin/activate' >> ~/.zshrc

Try it:

mailman% exit
lists# su -s /usr/local/bin/zsh -l _mailman -
(venv) lists%

Next, let’s install the wheel package and afterwards the mailman package via pip:

(venv) lists% pip install wheel 
(venv) lists% pip install mailman

While this is installing, let’s create a new tmux window and switch back over to the root account using the C-b c key combo. Then, create a configuration folder under /etc:

lists# mkdir /etc/mailman
lists# ln -s /etc/mailman /etc/mailman3

Add a configuration file for Mailman called mailman.cfg, with the following content:

lists# cat /etc/mailman/mailman.cfg
var_dir: /var/mailman/mailman

layout: custom


history_file: $var_dir/

incoming: mailman.mta.null.NullMTA
outgoing: mailman.mta.deliver.deliver
lmtp_port: 8024
smtp_port: 25
smtp_secure_mode: smtp
remove_dkim_headers: yes

enabled: no
dmarc: yes
dkim: yes
privkey: /etc/mail/dkim/
selector: mail

class: mailman_hyperkitty.Archiver
enable: yes
configuration: /etc/mailman/hyperkitty.cfg

hostname: localhost
port: 8001
use_https: no
admin_user: mailman
admin_pass: mailpass
api_version: 3.1

level: info

level: info

level: info
path: subscribe.log

Make sure to adjust values like domain or files that contain the domain name to whatever domain you’ll be using. Then switch back to the previous tmux window using C-b p and try to run Mailman:

(venv) lists% /var/mailman/venv/bin/mailman -C /etc/mailman/mailman.cfg start

If everything worked out, Mailman should now have been forked and continue running in the background (ps aux to check).

Let’s quickly add our first two mailing lists:

(venv) lists% /var/mailman/venv/bin/mailman -C /etc/mailman/mailman.cfg create\
  -o --language en
(venv) lists% /var/mailman/venv/bin/mailman -C /etc/mailman/mailman.cfg create\
  -o --language en

We need to add a couple of cron jobs for Mailman, so let’s add those:

(venv) lists% crontab -e
@daily             /var/mailman/venv/bin/mailman digests --periodic
@daily             /var/mailman/venv/bin/mailman notify

Note: If you’re having trouble editing that file, export EDITOR= with your favorite editor before running crontab -e, e.g. export EDITOR=nvim.

Now the only thing that’s left to be done is making sure that Mailman will launch on boot. Ideally we would want to use supervisord here, to make sure that, in case the mailman process should ever die, it gets restarted. For the sake of keeping this guide lightweight we’re instead going to add a cron job instead, that will simply fire-up-and-forget the Mailman instance on (re)boot:

lists# crontab -e
@reboot su -s /usr/local/bin/zsh -l _mailman -c '/var/mailman/venv/bin/mailman -C /etc/mailman/mailman.cfg start --force' 2>&1 | logger -t mailman

Web UI

Next up, we’re going to set up the web UI for Mailman, which not only offers an administrative dashboard for maintaining Mailman, but also gives subscribes the ability to log in and manage their subscriptions.

Before we actually install the web UI, we have to fetch a Rust compiler first. Otherwise, the cryptography wheel won’t compile.

lists# pkg_add rust

Now, let’s switch back to the _mailman user, create some folders that we will need and run pip install:

(venv) lists% mkdir -p web/logs
(venv) lists% pip install uwsgi mailman-web mailman-hyperkitty mistune==2.0.0rc1

Note: I had to specify the mistune version because apparently mailman-web migrate is only compatible with that specific version and the Mailman developers didn’t seem to have pinned to exact dependency version.

While this is installing, let’s create a configuration under /etc/mailman/

lists# cat /etc/mailman/
from mailman_web.settings.base import *
from mailman_web.settings.mailman import *

    ('root', ''),

    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'HOST': '',
        'PORT': '',
        'NAME': '/var/mailman/web/mailman-web.db',

# 'collectstatic' command will copy all the static files here.
STATIC_ROOT = '/var/mailman/web/static'

LOGGING['handlers']['file']['filename'] = '/var/mailman/web/logs/mailmanweb.log'

#: See
    "localhost",  # Archiving API from Mailman, keep it.
    # Add here all production domains you have.

# /etc/mailman/mailman.cfg

#: Current Django Site being served. This is used to customize the web host
#: being used to serve the current website. For more details about Django
#: site, see:

# dd if=/dev/urandom bs=64 count=1 | argon2 'saltysalty' | rg Hash | cut -d ':' -f2
SECRET_KEY = '8bf2ef3217d95b37b67eb5ff7eb5544cb7083b27bf563802272'

# /etc/mailman/hyperkitty.cfg (quoted here, not there).
MAILMAN_ARCHIVER_KEY = '24f217d95b37b6d305876e3e519d5fb03a06315585923556e93'


In order to run the web UI we need a WSGI server config, so let’s add that quickly:

lists# cat /etc/mailman/uwsgi.ini
daemonize = true
http-socket =
virtualenv = /var/mailman/venv/

env = PYTHONPATH=/etc/mailman/

master = true
processes = 2
threads = 2

attach-daemon = /var/mailman/venv/bin/mailman-web qcluster

req-logger = file:/var/mailman/web/logs/uwsgi.log

logger = qcluster file:/var/mailman/web/logs/uwsgi-qcluster.log
log-route = qcluster uwsgi-daemons

logger = file:/var/mailman/web/logs/uwsgi-error.log

Let’s also add a dedicated HyperKitty config for the sake of consistency:

lists# cat /etc/mailman/hyperkitty.cfg
api_key: 24f217d95b37b6d305876e3e519d5fb03a06315585923556e93

The previous pip install should be done by now, so we can switch back to the _mailman user and continue there. Let’s run the database migrations, collect static files, compress CSS and compile messages for l18n:

(venv) lists% mailman-web migrate
(venv) lists% mailman-web collectstatic
(venv) lists% mailman-web compress
(venv) lists% mailman-web compilemessages

We’re also going to create a superuser for the web UI:

(venv) lists% mailman-web createsuperuser
Username (leave blank to use '_mailman'): root
Email address:
Password (again):
Superuser created successfully.

After we’re done with that, we can quickly test uwsgi to check that our config works:

(venv) lists% uwsgi --ini /etc/mailman/uwsgi.ini

Next, let’s add the required cron jobs, in addition to the previously added cron jobs for core:

(venv) lists% crontab -e
* * * * *          /var/mailman/venv/bin/mailman-web runjobs minutely
0,15,30,45 * * * * /var/mailman/venv/bin/mailman-web runjobs quarter_hourly
@hourly            /var/mailman/venv/bin/mailman-web runjobs hourly
@daily             /var/mailman/venv/bin/mailman-web runjobs daily
@weekly            /var/mailman/venv/bin/mailman-web runjobs weekly
@monthly           /var/mailman/venv/bin/mailman-web runjobs monthly
@yearly            /var/mailman/venv/bin/mailman-web runjobs yearly

Just like with core, we’ll have uwsgi start via cron, although on an actual production system we would probably set up a supervisord to take care of that:

lists# crontab -e
@reboot su -s /usr/local/bin/zsh -l _mailman -c '/var/mailman/venv/bin/uwsgi --ini /etc/mailman/uwsgi.ini' 2>&1 | logger -t uwsgi


For issuing a Let’s Encrypt SSL certificate as well as serving the web UI’s static files we’re going to configure OpenBSD’s httpd:

lists# mkdir /var/www/mailman
lists# chown root:daemon /var/www/mailman
lists# rsync -avH /var/mailman/web/static /var/www/mailman/
lists# chown -R www:daemon /var/www/mailman/static

Note: You could also try to adjust the STATIC_ROOT variable within the file to directly write static files into /var/www/mailman/static. However, you’ll likely need to adjust the user/group afterwards, so it doesn’t really matter. Static files were generated using the mailman-web collectstatic command anyway, so it’s only one additional command (rsync) that you need to remember to run whenever you re-generate static files.

lists# cat /etc/httpd.conf
server "" {
        listen on * port 80
        location "/.well-known/acme-challenge/*" {
                root "/acme"
                request strip 2
        location "/" {
                block return 301 "https://$SERVER_NAME$REQUEST_URI"

server "default" {
        listen on port 8080
        location "*" {
                root "/mailman/static/"
                request strip 1
lists# rcctl enable httpd
lists# rcctl start httpd
lists# cat /etc/acme-client.conf
authority letsencrypt {
        api url $api_url
        account key "/etc/acme/letsencrypt-privkey.pem"

domain {
        domain key "/etc/ssl/private/"
        domain full chain certificate "/etc/ssl/"
        sign with letsencrypt

With the httpd in place, let’s obtain the certificate:

lists# acme-client -v

Also make sure to add a cron job for certificate renewals:

lists# crontab -e
30      0       *       *       *       /usr/sbin/acme-client && /usr/sbin/rcctl restart smtpd && /usr/sbin/rcctl restart relayd


Next we’re going to created a relayd.conf that allows us to use relayd as a reverse proxy for the static files hosted through httpd and the web UI running via uwsgi:

lists# cat /etc/relayd.conf

log state changes
log connection errors

table <httpd> { }
table <uwsgi> { }

http protocol mailman {
    tls keypair ""
    tcp { nodelay, sack, socket buffer 65536, backlog 128 }
    tls ecdhe secp384r1

    pass request quick path "/static/*" forward to <httpd>

    match request header append "X-Forwarded-For" value "$REMOTE_ADDR"
    match request header append "X-Forwarded-By" value "$SERVER_ADDR:$SERVER_PORT"

    match response header append "X-XSS-Protection" value "1; mode=block"
    match response header append "X-Permitted-Cross-Domain-Policies" value "none"
    match response header append "X-Frame-Options" value "DENY"
    match response header append "X-Content-Type-Options" value "nosniff"
    match response header append "Referrer-Policy" value "same-origin"
    match response header append "X-Download-Options" value "noopen"
    match response header append "Content-Security-Policy" value "default-src 'none'; base-uri 'self'; form-action 'self'; img-src 'self' data: https:; media-src 'self' https:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self' 'unsafe-inline'; upgrade-insecure-requests;"
    match request header append "Connection" value "upgrade"
    match response header append "Strict-Transport-Security" value "max-age=31536000; includeSubDomains"

    match response header append "Access-Control-Allow-Origin" value "*"
    match response header append "Access-Control-Allow-Methods" value "POST, PUT, DELETE, GET, PATCH, OPTIONS"
    match response header append "Access-Control-Allow-Headers" value "Authorization, Content-Type, Idempotency-Key"
    match response header append "Access-Control-Expose-Headers" value "Link, X-RateLimit-Reset, X-RateLimit-Limit, X-RateLimit-Remaining, X-Request-Id"

relay wwwtls {
    listen on $ipv4 port https tls

    protocol mailman

    forward to <uwsgi> port 8000 check http "/" code 301
    forward to <httpd> port 8080 check http "/static/admin/css/fonts.css" code 200

relay wwwtls6 {
    listen on $ipv6 port https tls

    protocol mailman

    forward to <uwsgi> port 8000 check http "/" code 301
    forward to <httpd> port 8080 check http "/static/admin/css/fonts.css" code 200
lists# rcctl enable relayd
lists# rcctl start relayd


Now we need to take care of the actual mailing functionality. For that we’re going to install OpenSMTPd, Rspamd (+ Redis) and Senderscore. This won’t be a complete example of how to set up a mail server on OpenBSD. I will do the bare minimum that is required for the Mailman setup to function.

Start by installing the required packages:

lists# pkg_add opensmtpd-extras opensmtpd-filter-rspamd opensmtpd-filter-senderscore rspamd redis

Hint: Pick the rspamd-x.x-hyperscan package.

Then, create all the necessary files and configurations:

lists# cat /etc/mail/domains
lists# cat /etc/mail/mailname
lists# cat /etc/mail/smtpd.conf
pki cert "/etc/ssl/"
pki key "/etc/ssl/private/"

srs key "0d303891jeX6SjWCoiA2d2018b59e32d2018b59e33A2d2018b59e332"

table domains file:/etc/mail/domains

filter check_dyndns phase connect match rdns regex { '.*\.dyn\..*', '.*\.dsl\..*', '.*\.dynamic\..*' } \
    disconnect "550 get off your moms phone line"

filter check_rdns phase connect match !rdns \
    disconnect "550 no rdns who dis"

filter check_fcrdns phase connect match !fcrdns \
    disconnect "550 no fcrdns who dis"

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

filter rspamd proc-exec "filter-rspamd"

listen on lo0 \
    filter { rspamd }

listen on vio0 port 25 tls pki "" \
    filter { check_dyndns, check_rdns, check_fcrdns, senderscore, rspamd }

action "lmtp"        lmtp "" rcpt-to virtual { "@" = _mailman }
action "relay"       relay helo srs

match from any      for domain <domains> action "lmtp"
match from local    for any              action "relay"

Let’s also set up basic DKIM/ARC support:

lists# mkdir /etc/mail/dkim
lists# rspamadm dkim_keygen -s 'mail' -d -k /etc/mail/dkim/ > /etc/mail/dkim/
lists# cat /etc/rspamd/local.d/dkim_signing.conf
enabled = true;
allow_username_mismatch = true;

path = "/etc/mail/dkim/";
selector = "mail";

domain { {
        path = "/etc/mail/dkim/";
        selector = "mail";
lists# cat /etc/rspamd/local.d/arc.conf
enabled = true;
allow_username_mismatch = true;

path = "/etc/mail/dkim/";
selector = "mail";

domain { {
        path = "/etc/mail/dkim/";
        selector = "mail";

Next, add the content of the .pub file as TXT record to your DNS:

lists# cat /etc/mail/dkim/

In addition, add a DMARC entry to the DNS and adjust the mailto address:   IN TXT    "v=DMARC1;p=none;pct=100;;"

Enable and restart all services once done:

lists# rcctl enable redis
lists# rcctl start redis
lists# rcctl enable rspamd
lists# rcctl start rspamd
lists# rcctl enable smtpd
lists# rcctl start smtpd


As before, I’m going to create a basic boilerplate for the firewall, that will provide us with some protection:

lists# cat /etc/pf.conf
# Macros #

# Tables #
table <bruteforce> persist
table <troublemakers> persist

# Options #
set skip on lo

# Rules #
block return    # Block stateless traffic
pass            # Establish keep-state

# Block brute-forcers
block quick proto tcp from <bruteforce> \
  to (egress) port $ssh_alternate_port
block quick proto tcp from <troublemakers> \
  to (egress) port $ssh_alternate_port

# By default, do not permit remote connections to X11
block return in on ! lo0 proto tcp to port 6000:6010

# Port build user does not need network
block return out log proto {tcp udp} user _pbuild

# Connections that made it through to the SSH port but abusing it
pass proto tcp from any to (egress) \
  port {$ssh_alternate_port} \
  flags S/SA keep state \
  (max-src-conn 5, max-src-conn-rate 5/5, \
  overload <bruteforce> flush global \

# Port 22 is a dead-giveaway # because we know we moved our SSH server
# elsewhere. Anyone poking there is up to no good.
pass in on egress proto tcp \
  from any \
  to (egress) port { \
    telnet, \
    ssh, \
    netbios-ns, \
    netbios-ssn, \
    microsoft-ds \
  } \
  synproxy state \
  tag trouble

# Tag stuff in the range $minefield as trouble ...
pass in on egress proto tcp from any to (egress) port $minefield \
  synproxy state \
  tag trouble

# ... unless it's to our alternate port
pass in proto tcp from any to (egress) port $ssh_alternate_port tag good

# Otherwise add to the troublemakers
pass proto tcp from any to (egress) port $ssh_alternate_port \
  tagged trouble \
  synproxy state \
  (max-src-conn 1, max-src-conn-rate 1/10, \
  overload <troublemakers> flush global \

pass proto tcp from any to any port {http https smtp} \
  keep state (max-src-conn 10, max-src-conn-rate 10/3)


You will need to have the following records in place for all this to work properly:					300	IN	A					300	IN	AAAA	2a00:f100:2000:10af:1000:1ff:fa1a:1a11					900	IN	MX	10					300	IN	TXT	"v=spf1 mx a ~all"				300	IN	TXT	"v=DMARC1;p=none;pct=100;;"			300	IN	TXT	"v=DKIM1; k=rsa;p=MIGfMA0GCSqGS...Ib3DQEBAQUAA4"

Final Steps

After a quick reboot we can double-check that all services will start up correctly and that we are able to connect to the Mailman web UI via

Check that you can log in with the previously created root user under

From this point on we would go ahead and configure Mailman, as well as other parts of the mail service (full DKIM/ARC, Rspamd, etc.). I won’t cover these topics here, as there is plenty documentation on those things available and it depends a lot on the individual use case. The main goal was to show how mailman3 can be configured and run on OpenBSD 7.1.

Further Reading

Enjoyed this? Support me via Monero, Bitcoin, Lightning, or Ethereum!  More info.