Before you begin
This is a fresh installation. You need a funded Asteroid, a domain for your lists, and basic knowledge of mailboxes, PostgreSQL, and systemd services. For an existing installation, keep its data and configuration and follow the Mailman upgrade documentation.
Prerequisites¶
The examples use lists.isabell.uber.space as both the mail and web domain, isabell as the username, and moondust.uberspace.de as the host.
Replace these throughout with your own values. Find your host with the following command. Example output:
[isabell@moondust ~]$ hostname -f
moondust.uberspace.de
Domains and mailbox¶
Add your mail domain and web domain, including any required DNS records. Skip an add command if the domain is already configured. Example output:
[isabell@moondust ~]$ uberspace mail domain add lists.isabell.uber.space
OK: Added maildomain 'lists.isabell.uber.space' to your Asteroid
[isabell@moondust ~]$ uberspace web domain add lists.isabell.uber.space
OK: Added webdomain 'lists.isabell.uber.space' to your Asteroid
[isabell@moondust ~]$ uberspace mail address add mailman@lists.isabell.uber.space --password-random
random password: '<mailbox password>'
...
Keep the generated password for the configuration below. Use a dedicated mailbox without catch-all or forwarding. Each list's addresses will become aliases of this mailbox, so unrelated addresses can remain on the domain.
Databases¶
Create one database for Mailman Core and another for the web interface. Example output:
[isabell@moondust ~]$ uberspace tool postgresql database add "${USER}_mailman"
OK: Added PostgreSQL database 'isabell_mailman' to your Asteroid
[isabell@moondust ~]$ uberspace tool postgresql database add "${USER}_mailman_web"
OK: Added PostgreSQL database 'isabell_mailman_web' to your Asteroid
Both applications use your existing ~/.pgpass for PostgreSQL authentication.
Its entries must cover localhost, port 5432, your username, and both database names; a * in the database field covers both.
See the PostgreSQL Manual if authentication fails.
Installation¶
Create a private application directory. This command produces no output on success:
[isabell@moondust ~]$ mkdir -m 700 ~/mailman
Create ~/mailman/pyproject.toml:
[project]
name = "mailman-instance"
version = "0.1.0"
requires-python = ">=3.12,<3.13"
dependencies = [
"mailman",
"mailman-web",
"mailman-hyperkitty",
"gunicorn",
"libsass",
"psycopg2-binary",
"sqlalchemy<2.1",
]
[tool.uv]
package = false
Install the packages using Uberspace's Python 3.12. Example output (abbreviated):
[isabell@moondust ~]$ uv sync --project ~/mailman --python /usr/bin/python3.12 --no-python-downloads
Using CPython 3.12...
Creating virtual environment at: .../mailman/.venv
Resolved ... packages ...
Installed ... packages ...
Keep the generated uv.lock alongside your configuration. It records the installed dependency versions.
The SQLAlchemy constraint avoids an incompatibility between Mailman 3.3.10 and SQLAlchemy 2.1 that breaks the list overview.
Launcher¶
Create ~/mailman/run so commands and services always find the same environment and configuration:
#!/bin/sh
cd "$HOME/mailman" || exit 1
export MAILMAN_CONFIG_FILE="$HOME/mailman/mailman.cfg"
export MAILMAN_WEB_CONFIG="$HOME/mailman/settings.py"
command=$1
shift
exec "$HOME/mailman/.venv/bin/$command" "$@"
Make it executable. This command produces no output on success:
[isabell@moondust ~]$ chmod 700 ~/mailman/run
Configuration¶
Secrets¶
Generate three different secrets, one each for the Core REST API, HyperKitty, and Django. Run this command three times; example output is a placeholder:
[isabell@moondust ~]$ openssl rand -hex 32
<random secret>
Replace the placeholders in the files below. The REST password and archiver key must each match between Core and the web interface.
Your private ~/mailman directory prevents other users from reading these files.
Mailman Core¶
Create ~/mailman/mailman.cfg:
[mailman]
site_owner: sysmail@isabell.uber.space
layout: here
[paths.here]
var_dir: /home/isabell/mailman/var
[database]
class: mailman.database.postgresql.PostgreSQLDatabase
url: postgresql+psycopg2://isabell@localhost:5432/isabell_mailman
[webservice]
hostname: 127.0.0.1
port: 8001
admin_user: restadmin
admin_pass: <REST password>
configuration: /home/isabell/mailman/gunicorn.cfg
[mta]
incoming: mailman.mta.null.NullMTA
lmtp_host: 127.0.0.1
lmtp_port: 8024
smtp_host: moondust.uberspace.de
smtp_port: 587
smtp_secure_mode: starttls
smtp_user: mailman@lists.isabell.uber.space
smtp_pass: <mailbox password>
[archiver.hyperkitty]
class: mailman_hyperkitty.Archiver
enable: yes
configuration: /home/isabell/mailman/hyperkitty.cfg
NullMTA leaves incoming mail collection to Fetchmail. The REST API and LMTP receiver listen only on loopback; do not expose their ports as web backends.
Outgoing mail goes directly through Uberspace's SMTP server.
Create ~/mailman/gunicorn.cfg to use one REST worker:
Create ~/mailman/hyperkitty.cfg:
Fetchmail¶
Fetchmail is already installed. Create ~/mailman/fetchmailrc:
poll moondust.uberspace.de protocol IMAP
envelope 1 "Received"
localdomains lists.isabell.uber.space
user "mailman@lists.isabell.uber.space" password "<mailbox password>"
is * here
ssl
smtphost "127.0.0.1/8024"
lmtp
fetchall
no rewrite
envelope 1 "Received" skips Dovecot's mailbox-delivery header and reads the original list recipient from the next, locally added Postfix header.
This also allows delivery to Bcc and list-management addresses.
fetchall includes messages marked as read; no rewrite preserves addresses in message headers.
Protect the password file; Fetchmail requires this. The command produces no output on success:
[isabell@moondust ~]$ chmod 600 ~/mailman/fetchmailrc
Use one list recipient per message
The Postfix header must contain the original envelope recipient. Messages delivered to several list addresses at once can lack that information; Fetchmail may then fall back to the To and Cc headers. Send separate messages to separate list addresses.
Web interface¶
Create ~/mailman/settings.py:
import os
import sys
from mailman_web.settings.base import *
from mailman_web.settings.mailman import *
SECRET_KEY = "<Django key>"
ALLOWED_HOSTS = ["lists.isabell.uber.space", "localhost", "127.0.0.1"]
CSRF_TRUSTED_ORIGINS = ["https://lists.isabell.uber.space"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
DATABASES = {"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "isabell_mailman_web",
"USER": "isabell",
"HOST": "localhost",
"PORT": "5432",
}}
MAILMAN_REST_API_URL = "http://127.0.0.1:8001"
MAILMAN_REST_API_USER = "restadmin"
MAILMAN_REST_API_PASS = "<REST password>"
MAILMAN_ARCHIVER_KEY = "<Archiver key>"
MAILMAN_ARCHIVER_FROM = ("127.0.0.1", "::1")
STATIC_ROOT = "/var/www/virtual/isabell/html/mailman-static"
STATIC_URL = "/mailman-static/"
COMPRESS_OFFLINE = True
COMPRESS_PRECOMPILERS = (
("text/x-scss", os.path.join(sys.prefix, "bin", "pysassc") + " {infile} {outfile}"),
("text/x-sass", os.path.join(sys.prefix, "bin", "pysassc") + " {infile} {outfile}"),
)
HAYSTACK_CONNECTIONS = {"default": {
"ENGINE": "haystack.backends.whoosh_backend.WhooshEngine",
"PATH": "/home/isabell/mailman/fulltext_index",
}}
EMAIL_HOST = "moondust.uberspace.de"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = "mailman@lists.isabell.uber.space"
EMAIL_HOST_PASSWORD = "<mailbox password>"
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
SERVER_EMAIL = EMAIL_HOST_USER
POSTORIUS_TEMPLATE_BASE_URL = "http://127.0.0.1:8967"
SITE_ID = 1
# Replace deprecated defaults supplied by mailman-web.
globals().pop("ACCOUNT_AUTHENTICATION_METHOD", None)
globals().pop("ACCOUNT_EMAIL_REQUIRED", None)
ACCOUNT_LOGIN_METHODS = {"email", "username"}
ACCOUNT_SIGNUP_FIELDS = ["email*", "username*", "password1*", "password2*"]
Q_CLUSTER = {**Q_CLUSTER, "workers": 1, "queue_limit": 2}
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"root": {"handlers": ["console"], "level": "INFO"},
}
Static files are public and live in the document root, while settings and archives stay private.
The settings above assume the list domain uses the default html document root. If you use a separate document root, put mailman-static there and adjust STATIC_ROOT.
Initialize the database and site, then create your web administrator. Example output (abbreviated):
[isabell@moondust ~]$ ~/mailman/run mailman-web migrate
Operations to perform:
...
Applying ... OK
[isabell@moondust ~]$ ~/mailman/run mailman-web shell -c "from django.contrib.sites.models import Site; Site.objects.filter(pk=1).update(domain='lists.isabell.uber.space', name='Mailman')"
[isabell@moondust ~]$ ~/mailman/run mailman-web createsuperuser
Username: isabell
Email address: sysmail@isabell.uber.space
Password:
Password (again):
Superuser created successfully.
[isabell@moondust ~]$ ~/mailman/run mailman-web collectstatic --noinput
... static files copied to '/var/www/virtual/isabell/html/mailman-static'.
[isabell@moondust ~]$ ~/mailman/run mailman-web compress --force
...
Compressed ... block(s) from ... template(s) for ... context(s).
The site-update command produces no output apart from possible Django startup notices.
Services¶
Configure the four services to restart after a failure. These commands produce no output on success:
[isabell@moondust ~]$ for service in mailman-core mailman-web mailman-qcluster mailman-fetchmail; do
> mkdir -p "$HOME/.config/systemd/user/$service.service.d"
> printf '[Service]\nRestart=on-failure\n' > "$HOME/.config/systemd/user/$service.service.d/restart.conf"
> done
Start Core, the web interface, its background worker, and Fetchmail:
[isabell@moondust ~]$ uberspace service add mailman-core "$HOME/mailman/run master --force" --workdir "$HOME/mailman"
[isabell@moondust ~]$ uberspace service add mailman-web "$HOME/mailman/run gunicorn --bind 0.0.0.0:8967 --workers 1 --threads 2 mailman_web.wsgi:application" --workdir "$HOME/mailman"
[isabell@moondust ~]$ uberspace service add mailman-qcluster "$HOME/mailman/run mailman-web qcluster" --workdir "$HOME/mailman"
[isabell@moondust ~]$ uberspace service add mailman-fetchmail "fetchmail --fetchmailrc $HOME/mailman/fetchmailrc --nodetach --daemon 30" --workdir "$HOME/mailman"
Each command creates and enables a service. Example output for Core (abbreviated):
OK: Created service file: /home/isabell/.config/systemd/user/mailman-core.service
OK: systemctl --user daemon-reload
OK: systemctl --user start mailman-core.service
OK: systemctl --user enable mailman-core.service
OK: systemctl --user status mailman-core.service
...
Active: active (running)
Fetchmail collects messages every 30 seconds. Do not run another Fetchmail daemon under the same user with its default PID file.
Add the web backends. Example output:
[isabell@moondust ~]$ uberspace web backend add lists.isabell.uber.space PORT 8967
OK: Added webbackend 'lists.isabell.uber.space/' to your Asteroid
[isabell@moondust ~]$ uberspace web backend add lists.isabell.uber.space/mailman-static/ STATIC
OK: Added webbackend 'lists.isabell.uber.space/mailman-static' to your Asteroid
Open https://lists.isabell.uber.space/ and log in with your administrator account.
Create a mailing list¶
In Postorius, open Domains and add lists.isabell.uber.space as the mail host, selecting your Mailman site. Then create a list, for example announce@lists.isabell.uber.space.
Then register its main address and eight functional addresses as mailbox aliases:
[isabell@moondust ~]$ for suffix in '' -bounces -confirm -join -leave -owner -request -subscribe -unsubscribe; do
> uberspace mail address add "announce${suffix}@lists.isabell.uber.space" --alias-of mailman@lists.isabell.uber.space || break
> done
Example output (abbreviated):
OK: Added mailaddress 'announce@lists.isabell.uber.space' to your Asteroid
OK: Added mailaddress 'announce-bounces@lists.isabell.uber.space' to your Asteroid
...
Repeat this for every list, replacing announce with its name. Choose names whose functional addresses do not overlap another list or mailbox.
If a command fails, resolve the conflict and add the remaining aliases; do not overwrite unrelated addresses.
Plus-addressed confirmations and bounces use these aliases too.
Subscribe a test address in Postorius, send a message to the list, and verify delivery and its appearance in HyperKitty. When removing a list, delete it in Postorius and remove only its nine aliases; keep the shared mailbox for the remaining lists. Deleting a list removes its configuration and memberships.
Scheduled jobs¶
Open your crontab:
[isabell@moondust ~]$ crontab -e
This opens an editor; saving changes normally prints crontab: installing new crontab.
Add these jobs alongside any existing entries. The shared lock prevents maintenance jobs from overlapping:
@daily flock $HOME/mailman/cron.lock $HOME/mailman/run mailman digests --periodic
@hourly flock $HOME/mailman/cron.lock $HOME/mailman/run mailman notify
* * * * * flock $HOME/mailman/cron.lock $HOME/mailman/run mailman-web runjobs minutely
*/15 * * * * flock $HOME/mailman/cron.lock $HOME/mailman/run mailman-web runjobs quarter_hourly
@hourly flock $HOME/mailman/cron.lock $HOME/mailman/run mailman-web runjobs hourly
@daily flock $HOME/mailman/cron.lock $HOME/mailman/run mailman-web runjobs daily
@weekly flock $HOME/mailman/cron.lock $HOME/mailman/run mailman-web runjobs weekly
@monthly flock $HOME/mailman/cron.lock $HOME/mailman/run mailman-web runjobs monthly
@yearly flock $HOME/mailman/cron.lock $HOME/mailman/run mailman-web runjobs yearly
Debugging¶
Check the service journal. Example output (abbreviated):
[isabell@moondust ~]$ journalctl --user -u mailman-core -u mailman-web -u mailman-qcluster -u mailman-fetchmail -n 30
...
fetchmail: reading message ... flushed
Core also writes logs under ~/mailman/var/logs/.
If incoming mail is not delivered, check Fetchmail's log, the mailbox credentials, and all nine aliases. Do not delete retained mail before investigating the cause.
If outgoing mail fails, check the SMTP credentials and your account's mail quota.
If a service is killed because of its memory use, check your Asteroid's RAM limit and avoid running migrations or other maintenance alongside the services.
Updates¶
Follow releases
Watch the Mailman announcements and the Mailman release feed. Read the upgrade notes before updating.
Comment out the nine Mailman cronjobs and wait for running jobs to finish. Stop the four services; the command produces no output on success:
[isabell@moondust ~]$ systemctl --user stop mailman-fetchmail mailman-core mailman-web mailman-qcluster
Back up ~/mailman, including configuration, queued messages, and uv.lock, and dump both PostgreSQL databases using the Manual's backup instructions.
Keep these backups together. Mail arriving during maintenance remains in the dedicated mailbox.
Upgrade dependencies and rebuild the web application. Example output is abbreviated:
[isabell@moondust ~]$ uv lock --project ~/mailman --upgrade --python /usr/bin/python3.12 --no-python-downloads
Resolved ... packages ...
[isabell@moondust ~]$ uv sync --project ~/mailman --locked --python /usr/bin/python3.12 --no-python-downloads
...
[isabell@moondust ~]$ ~/mailman/run mailman-web migrate
...
[isabell@moondust ~]$ ~/mailman/run mailman-web collectstatic --noinput
...
[isabell@moondust ~]$ ~/mailman/run mailman-web compress --force
...
[isabell@moondust ~]$ systemctl --user start mailman-core mailman-web mailman-qcluster mailman-fetchmail
The final command produces no output on success. Check the services, website, list delivery, and archives before re-enabling the cronjobs. If an update fails, keep processing stopped and restore the matching configuration, lockfile, Core data, and database backups before starting again.
Further Reading¶
- Mailman documentation: Core, Postorius, and HyperKitty
- Fetchmail manual: IMAP collection and envelope routing
- Uberspace mail access: IMAP and SMTP settings