feat(stalwart): add Stalwart Mail Server as a catalog app

One container providing SMTP/IMAP/POP3/JMAP plus CalDAV/CardDAV, an admin
UI and spam filtering — chosen over mailcow (owns its own installer, which
is what killed the earlier attempt now sitting in scripts/unused/) and
over Mailu (~7 containers) because a single image with a single data dir
is the only shape that fits the existing conventions cleanly: one anchor
service the updater can version, one path the backup engine can snapshot.

Mail-specific departures from the usual app template, each deliberate:

* Ports are FIXED, not random. Other mail servers connect to :25 by
  number and clients expect 465/587/993 — a randomised external port
  would silently make the server unreachable. Only the admin UI takes a
  random port, since that one really is just a browser behind Traefik.
  143/995/4190/443 ship disabled; the port processor comments them out.

* UPDATE_TYPE=manual and the image pinned to v0.16, not :latest.
  Stalwart is pre-1.0 and has said the storage schema is still being
  finalised, so an unattended minor bump could carry a data migration on
  the message store. This is the one app where the auto default is wrong.

* BACKUP_STRATEGY=stop-snapshot-start. The message store is written
  continuously; a live copy can land mid-transaction. Seconds of queued
  delivery (senders retry) buys a consistent snapshot.

* The install hook checks outbound port 25 and reverse DNS, then prints
  the MX/SPF/DMARC records with real values. A mail server whose
  container started is not a working mail server, and every remaining
  requirement lives at the registrar or the VPS provider.

Admin credentials are seeded via STALWART_RECOVERY_ADMIN from the app
config rather than left to Stalwart's first-run random password, which
would otherwise exist only in the container log.

Icon is a drawn placeholder, not the upstream trademark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-12 21:02:26 +01:00
parent 8153d82282
commit 4ee25292d5
5 changed files with 275 additions and 0 deletions

View File

@ -0,0 +1,76 @@
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
external: true
services:
# Stalwart Mail & Collaboration Server — SMTP/IMAP/POP3/JMAP + CalDAV/CardDAV
# in ONE container. That is why it is here rather than a multi-container mail
# stack: one image the updater can version, one data dir the backup engine can
# snapshot, one thing to restore.
stalwart-service: #LIBREPORTAL|SERVICE_TAG_1|stalwart-service
container_name: stalwart-service
# Pinned to a minor tag, NOT :latest. Stalwart is pre-1.0 and has said the
# storage schema is still being finalised, so an unattended jump to a new
# minor could carry a data migration. CFG_STALWART_UPDATE_TYPE is likewise
# 'manual' — read the release notes, then press Update.
image: stalwartlabs/stalwart:v0.16 #LIBREPORTAL|STALWART_VERSION_TAG|v0.16
# GLUETUN_OFF_BEGIN
ports:
- "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1
- "PORTS_DATA_2" #LIBREPORTAL|PORTS_TAG_2|PORTS_DATA_2
- "PORTS_DATA_3" #LIBREPORTAL|PORTS_TAG_3|PORTS_DATA_3
- "PORTS_DATA_4" #LIBREPORTAL|PORTS_TAG_4|PORTS_DATA_4
- "PORTS_DATA_5" #LIBREPORTAL|PORTS_TAG_5|PORTS_DATA_5
- "PORTS_DATA_6" #LIBREPORTAL|PORTS_TAG_6|PORTS_DATA_6
- "PORTS_DATA_7" #LIBREPORTAL|PORTS_TAG_7|PORTS_DATA_7
- "PORTS_DATA_8" #LIBREPORTAL|PORTS_TAG_8|PORTS_DATA_8
- "PORTS_DATA_9" #LIBREPORTAL|PORTS_TAG_9|PORTS_DATA_9
# GLUETUN_OFF_END
volumes:
# Server configuration (config.toml + TLS material).
- ./etc:/etc/stalwart
# The message store, blobs, indexes and internal database — this is
# the mail itself.
- ./data:/var/lib/stalwart
environment:
- TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
# First boot only: without this Stalwart generates a random admin
# password and prints it to stderr, which would leave the credential
# in the container log and nowhere else. Seeding it from the app
# config means the WebUI can show it like every other app.
# Two annotations on one line — the tag manager scopes its sed per
# tag, so each placeholder needs its own marker (see ollama's
# OLLAMA_BASE_URL row for the same pattern).
- STALWART_RECOVERY_ADMIN=STALWART_ADMIN_USER_DATA:STALWART_ADMIN_PASSWORD_DATA #LIBREPORTAL|STALWART_ADMIN_USER_TAG|STALWART_ADMIN_USER_DATA #LIBREPORTAL|STALWART_ADMIN_PASSWORD_TAG|STALWART_ADMIN_PASSWORD_DATA
labels:
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
# No backup.db descriptor: Stalwart's store is its own embedded
# database inside the data dir, not a dump-able SQLite/MySQL file, so
# the files descriptor covers it — paired with the stop-snapshot-start
# strategy in stalwart.config so the copy is taken at rest.
libreportal.backup.files: "stalwart-service:/var/lib/stalwart:data"
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
# TRAEFIK_PORT_1_BEGIN
traefik.http.routers.stalwart-service.entrypoints: web,websecure
traefik.http.routers.stalwart-service.rule: Host(`DOMAINSUBNAME_DATA_1`) #LIBREPORTAL|DOMAINSUBNAME_TAG_1|DOMAINSUBNAME_DATA_1
traefik.http.routers.stalwart-service.tls: true
traefik.http.routers.stalwart-service.tls.certresolver: production
traefik.http.services.stalwart-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1
traefik.http.routers.stalwart-service.middlewares: MIDDLEWARE_DATA_1 #LIBREPORTAL|MIDDLEWARE_TAG_1|MIDDLEWARE_DATA_1
# TRAEFIK_PORT_1_END
traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
healthcheck:
disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA
restart: 'unless-stopped'
# GLUETUN_OFF_BEGIN
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1
# GLUETUN_OFF_END
# GLUETUN_ON_BEGIN
# A mail server behind a VPN exit is almost always wrong: outbound port 25
# from a VPN IP is blocked or blocklisted nearly everywhere, and inbound
# delivery cannot reach you at all. Left here for structure only.
# network_mode: "container:gluetun-service"
# GLUETUN_ON_END

View File

@ -0,0 +1,92 @@
#!/bin/bash
# Stalwart install hooks.
#
# Installing a mail server is not like installing any other app: the container
# starting successfully means almost nothing. Mail only works once DNS, reverse
# DNS and outbound port 25 are right, and every one of those lives OUTSIDE the
# box — at the registrar and the VPS provider. So the job of these hooks is to
# say plainly what still has to be done, with the actual values to enter, rather
# than reporting "installed" and leaving the admin to discover weeks later that
# their mail is landing in spam.
# Echo the admin credentials for the standard final-message block (word-split
# by the caller into positional args: user pass).
stalwart_install_message_data()
{
printf '%s %s' "${CFG_STALWART_ADMIN_USER:-admin}" "${CFG_STALWART_ADMIN_PASSWORD:-}"
}
stalwart_install_post_start()
{
local app_name="$1"
((menu_number++))
echo ""
echo "---- $menu_number. Mail server checks + the DNS records you still need"
echo ""
# Resolved admin port comes from the compose tag (format `external:internal`),
# the same source adguard's hook reads — the legacy $usedport1 isn't populated
# by the current install pipeline.
local compose_file="$containers_dir$app_name/docker-compose.yml"
local admin_pair admin_port
admin_pair=$(tagsManagerGetTagContent "$compose_file" "PORTS_TAG_1" 2>/dev/null)
admin_port="${admin_pair%%:*}"
# ---- 1. Can this host even send mail? --------------------------------
# Most cheap VPS providers block outbound 25 by default (and several only
# unblock on request). A blocked port 25 means no mail EVER leaves the box,
# and nothing in the WebUI would otherwise reveal it.
isNotice "Checking outbound port 25 (required to deliver mail to other servers)…"
if command -v timeout >/dev/null 2>&1 \
&& timeout 8 bash -c 'exec 3<>/dev/tcp/gmail-smtp-in.l.google.com/25' 2>/dev/null; then
isSuccessful "Outbound port 25 is open."
else
isError "Outbound port 25 appears BLOCKED or filtered on this host."
isNotice " Most VPS providers block it by default. Ask your provider to unblock"
isNotice " outbound 25, or mail will queue and never deliver."
fi
# ---- 2. Reverse DNS --------------------------------------------------
# Receiving servers check that the sending IP resolves back to a name. A
# generic provider PTR (e.g. static.1.2.3.4.provider.net) is a common reason
# for mail being junked, and it can only be fixed in the provider's panel.
if [[ -n "$public_ip_v4" ]] && command -v dig >/dev/null 2>&1; then
local ptr; ptr=$(dig +short -x "$public_ip_v4" 2>/dev/null | head -1)
if [[ -n "$ptr" ]]; then
isNotice "Reverse DNS (PTR) for $public_ip_v4 is: ${ptr%.}"
isNotice " It should match your mail hostname. Set it in your VPS provider's panel."
else
isError "No reverse DNS (PTR) record for $public_ip_v4 — set one at your VPS provider."
fi
fi
# ---- 3. The records the admin must add themselves --------------------
# Printed with real values so they can be pasted at the registrar. DKIM is
# deliberately NOT guessed here: Stalwart generates the keypair on first
# run, and the public key must be copied from its admin UI.
local mail_host="${host_setup:-your-mail-hostname}"
local mail_domain="${mail_host#*.}"
echo ""
isNotice "DNS records to add at your domain registrar:"
echo " MX @ 10 ${mail_host}"
echo " A ${mail_host} ${public_ip_v4:-<the IP of this server>}"
echo " TXT @ \"v=spf1 mx -all\""
echo " TXT _dmarc \"v=DMARC1; p=quarantine; rua=mailto:postmaster@${mail_domain}\""
echo " TXT <selector>._domainkey (DKIM — copy from Stalwart's admin UI once it has"
echo " generated the key; it is not known until then)"
echo ""
# ---- 4. Where to go next ---------------------------------------------
if [[ -n "$admin_port" ]]; then
isNotice "Finish setup in the admin interface:"
[[ -n "$public_ip_v4" ]] && echo " http://$public_ip_v4:$admin_port/"
echo ""
isNotice "Sign in as '${CFG_STALWART_ADMIN_USER:-admin}' with the password shown below,"
isNotice "then add your domain and create mailboxes."
fi
isNotice "Until MX, PTR, SPF, DKIM and DMARC are all in place, expect delivery"
isNotice "problems — that is normal for a new mail server, not a fault in the app."
}

View File

@ -0,0 +1,90 @@
#
# =============================================================================
# GENERAL CONFIGURATION
# =============================================================================
# APP_NAME = name of application for use in scripts
# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is
# BACKUP = if true, include this application in backup operations
# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update
# HEALTHCHECK = if true, default docker health checks for that container will be enabled
# AUTHELIA = if true, use Authelia authentication, if false turned off.
# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote
#
CFG_STALWART_APP_NAME=stalwart
CFG_STALWART_BACKUP=true
# Mail is the one app where a "live" snapshot is a real risk: the message store
# is being written continuously, so a copy taken mid-write can land mid-transaction.
# stop-snapshot-start costs seconds of queued delivery (senders retry — that is
# what SMTP does) and buys a consistent store.
CFG_STALWART_BACKUP_STRATEGY=stop-snapshot-start
CFG_STALWART_UPDATE_TYPE=manual
CFG_STALWART_COMPOSE_FILE=default
CFG_STALWART_HEALTHCHECK=true
CFG_STALWART_AUTHELIA=false
CFG_STALWART_HEADSCALE=false
#
# =============================================================================
# METADATA
# =============================================================================
# CATEGORY = application category for grouping
# TITLE = display name for the application
# DESCRIPTION = short description of the application
# LONG_DESCRIPTION = detailed description of the application
# URL = source repository or documentation URL
# ACTIONS = available actions for this application
#
CFG_STALWART_CATEGORY="communication"
CFG_STALWART_TITLE="Stalwart"
CFG_STALWART_DESCRIPTION="Mail Server"
CFG_STALWART_LONG_DESCRIPTION="Stalwart is an all-in-one mail and collaboration server — SMTP, IMAP, POP3 and JMAP plus calendars and contacts, in a single container with a built-in admin interface and spam filtering."
CFG_STALWART_URL="https://github.com/stalwartlabs/stalwart"
CFG_STALWART_ACTIONS="configure|install|restart|shutdown|uninstall"
#
# =============================================================================
# NETWORK CONFIGURATION
# =============================================================================
# DOMAIN = number of domain from the general config, useful when using multiple domains
# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all
#
CFG_STALWART_DOMAIN=1
CFG_STALWART_WHITELIST=false
CFG_STALWART_NETWORK=default
#
# =============================================================================
# PORT CONFIGURATION
# =============================================================================
# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description|paths|subdomain
# - app: application name
# - name: service identifier (webui, dns, ssh, etc.)
# - external:internal: port mapping (external can be 'random' for auto-allocation)
# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running)
# - protocol: 'tcp' or 'udp'
# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true)
# - traefik: if true, Traefik handles this port (reverse proxy)
# - webui: if true, this port serves the main web interface
# - description: human-readable description of the service
#
# MAIL PORTS ARE FIXED, NOT RANDOM. Every other app can take a random external
# port because only a browser (via Traefik) reaches it. Mail is different: other
# mail servers on the internet connect to port 25 by number, and mail clients
# expect 465/587/993 — a randomised external port silently makes the server
# unreachable. Only the admin UI gets a random port, because that one IS just a
# browser behind Traefik.
CFG_STALWART_PORT_1="stalwart-service|webui|random:8080|public|tcp|false|true|true|Admin Interface||mail"
CFG_STALWART_PORT_2="stalwart-service|smtp|25:25|public|tcp|false|false|false|SMTP (server-to-server mail delivery)|"
CFG_STALWART_PORT_3="stalwart-service|submissions|465:465|public|tcp|false|false|false|Mail submission (implicit TLS)|"
CFG_STALWART_PORT_4="stalwart-service|submission|587:587|public|tcp|false|false|false|Mail submission (STARTTLS)|"
CFG_STALWART_PORT_5="stalwart-service|imaps|993:993|public|tcp|false|false|false|IMAP (implicit TLS)|"
CFG_STALWART_PORT_6="stalwart-service|imap|143:143|disabled|tcp|false|false|false|IMAP (STARTTLS) — enable only if a client needs it|"
CFG_STALWART_PORT_7="stalwart-service|pop3s|995:995|disabled|tcp|false|false|false|POP3 (implicit TLS) — off by default, IMAP is preferred|"
CFG_STALWART_PORT_8="stalwart-service|sieve|4190:4190|disabled|tcp|false|false|false|ManageSieve (client-managed mail filters)|"
CFG_STALWART_PORT_9="stalwart-service|jmap|443:443|disabled|tcp|false|false|false|HTTPS/JMAP direct — leave disabled when Traefik fronts the UI|"
# AUTH_PROFILE = capability tier for the WebUI auth tools (single_password | user_password | multi_user)
# The password reaches the container through a compose environment variable, so
# if you replace the generated one by hand keep it alphanumeric: docker compose
# interpolates `$` and YAML treats ` #` as a comment, either of which would
# silently mangle the value. The generator only emits letters and digits.
CFG_STALWART_AUTH_PROFILE=user_password
CFG_STALWART_ADMIN_USER=admin
CFG_STALWART_ADMIN_PASSWORD=RANDOMIZEDPASSWORD1

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="Stalwart Mail Server">
<!-- Placeholder mark: a shield (stalwart) around an envelope (mail), drawn
rather than copied so no upstream trademark is vendored into the repo.
Swap for the official logo if the project's brand terms allow it. -->
<path d="M24 3 40 8v14c0 10-6.6 18.6-16 23C14.6 40.6 8 32 8 22V8z"
fill="none" stroke="#2f6fed" stroke-width="2.5" stroke-linejoin="round"/>
<rect x="14" y="17" width="20" height="14" rx="2"
fill="none" stroke="#2f6fed" stroke-width="2.5"/>
<path d="M14.5 18.5 24 26l9.5-7.5"
fill="none" stroke="#2f6fed" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 772 B

View File

@ -871,6 +871,8 @@ declare -gA LP_FN_MAP=(
[showInstructions]="menu/message/instructions.sh"
[sourceBackupLocations]="backup/locations/location_loader.sh"
[sshRemote]="network/ssh/ssh.sh"
[stalwart_install_message_data]="stalwart/scripts/stalwart_install_hooks.sh"
[stalwart_install_post_start]="stalwart/scripts/stalwart_install_hooks.sh"
[startInstall]="start/start_install.sh"
[startLoad]="start/start_load.sh"
[startOther]="start/start_other.sh"
@ -1891,6 +1893,8 @@ declare -gA LP_FN_ROOT=(
[showInstructions]="scripts"
[sourceBackupLocations]="scripts"
[sshRemote]="scripts"
[stalwart_install_message_data]="containers"
[stalwart_install_post_start]="containers"
[startInstall]="scripts"
[startLoad]="scripts"
[startOther]="scripts"
@ -2944,6 +2948,8 @@ setupWizardTerminal() { unset -f setupWizardTerminal; __lpAutoload "${install_sc
showInstructions() { unset -f showInstructions; __lpAutoload "${install_scripts_dir}menu/message/instructions.sh"; showInstructions "$@"; }
sourceBackupLocations() { unset -f sourceBackupLocations; __lpAutoload "${install_scripts_dir}backup/locations/location_loader.sh"; sourceBackupLocations "$@"; }
sshRemote() { unset -f sshRemote; __lpAutoload "${install_scripts_dir}network/ssh/ssh.sh"; sshRemote "$@"; }
stalwart_install_message_data() { unset -f stalwart_install_message_data; __lpAutoload "${install_containers_dir}stalwart/scripts/stalwart_install_hooks.sh"; stalwart_install_message_data "$@"; }
stalwart_install_post_start() { unset -f stalwart_install_post_start; __lpAutoload "${install_containers_dir}stalwart/scripts/stalwart_install_hooks.sh"; stalwart_install_post_start "$@"; }
startInstall() { unset -f startInstall; __lpAutoload "${install_scripts_dir}start/start_install.sh"; startInstall "$@"; }
startLoad() { unset -f startLoad; __lpAutoload "${install_scripts_dir}start/start_load.sh"; startLoad "$@"; }
startOther() { unset -f startOther; __lpAutoload "${install_scripts_dir}start/start_other.sh"; startOther "$@"; }