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:
parent
8153d82282
commit
4ee25292d5
76
containers/stalwart/docker-compose.yml
Normal file
76
containers/stalwart/docker-compose.yml
Normal 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
|
||||
92
containers/stalwart/scripts/stalwart_install_hooks.sh
Normal file
92
containers/stalwart/scripts/stalwart_install_hooks.sh
Normal 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."
|
||||
}
|
||||
90
containers/stalwart/stalwart.config
Normal file
90
containers/stalwart/stalwart.config
Normal 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
|
||||
11
containers/stalwart/stalwart.svg
Normal file
11
containers/stalwart/stalwart.svg
Normal 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 |
@ -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 "$@"; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user