diff --git a/containers/stalwart/docker-compose.yml b/containers/stalwart/docker-compose.yml new file mode 100644 index 0000000..014d077 --- /dev/null +++ b/containers/stalwart/docker-compose.yml @@ -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 diff --git a/containers/stalwart/scripts/stalwart_install_hooks.sh b/containers/stalwart/scripts/stalwart_install_hooks.sh new file mode 100644 index 0000000..b947075 --- /dev/null +++ b/containers/stalwart/scripts/stalwart_install_hooks.sh @@ -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:-}" + echo " TXT @ \"v=spf1 mx -all\"" + echo " TXT _dmarc \"v=DMARC1; p=quarantine; rua=mailto:postmaster@${mail_domain}\"" + echo " TXT ._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." +} diff --git a/containers/stalwart/stalwart.config b/containers/stalwart/stalwart.config new file mode 100644 index 0000000..32ff555 --- /dev/null +++ b/containers/stalwart/stalwart.config @@ -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 diff --git a/containers/stalwart/stalwart.svg b/containers/stalwart/stalwart.svg new file mode 100644 index 0000000..6ed7909 --- /dev/null +++ b/containers/stalwart/stalwart.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 6153f26..28e799b 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -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 "$@"; }