#!/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_1:-}" } # Run the Stalwart CLI against our own container. # # The CLI is deliberately NOT in the server image — upstream split it into its # own repository so it ships and versions separately. It does publish a # multi-arch container, which suits us better than a host binary: nothing to # install, nothing to clean up on uninstall, and arm64 works for Pi installs. # # `--network container:stalwart-service` borrows the server's network namespace, # so the CLI reaches it on localhost:8080 without us having to resolve the # LibrePortal network name or expose the admin port to get at it. stalwart_cli() { runFileOp docker run --rm -i --network "container:stalwart-service" \ -e STALWART_URL="http://localhost:8080" \ -e STALWART_USER="${CFG_STALWART_ADMIN_USER:-admin}" \ -e STALWART_PASSWORD="${CFG_STALWART_ADMIN_PASSWORD_1:-}" \ "ghcr.io/stalwartlabs/cli:${CFG_STALWART_CLI_VERSION:-1.0.12}" --no-color "$@" } # Resolve CFG_STALWART_MODE, turning 'auto' into a real answer. # # Traefik is the tell: LibrePortal only installs it once there is a domain # pointed at this box, so its presence is a decent proxy for "this machine is # meant to be reachable from the internet". Absent it, the safe reading is that # nobody outside can reach this host anyway, and a public mail server would only # produce mail that silently fails to deliver. stalwart_mode() { local mode="${CFG_STALWART_MODE:-auto}" if [[ "$mode" == "auto" ]]; then if [[ -d "${containers_dir}traefik" ]]; then mode="public"; else mode="private"; fi fi [[ "$mode" == "public" || "$mode" == "private" ]] || mode="private" printf '%s' "$mode" } # Rewrite the `access` field (4th, pipe-separated) of one port line, in BOTH # places it has to change. # # sed rather than updateConfigOption because these values are themselves # pipe-delimited, and the config-update path encodes `|` as %7C — round-tripping # a port spec through it would mangle the very field we are editing. # # The second half is the part that is easy to miss: initializeAppVariables reads # CFG__PORT_n out of the SHELL, not off disk, and the compose file is built # from what it parses. Editing only the file leaves the config claiming one thing # while the running container does another. stalwart_set_port_access() { local cfg="$1" key="$2" access="$3" runFileOp sed -i -E "s#^(${key}=\"([^|\"]*\|){3})[^|\"]*#\1${access}#" "$cfg" 2>/dev/null local cur="${!key}" [[ -z "$cur" ]] && return 0 printf -v "$key" '%s' "$(printf '%s' "$cur" | sed -E "s#^(([^|]*\|){3})[^|]*#\1${access}#")" } # Put the mail ports where the chosen mode says they belong. # # Private does NOT simply firewall everything off: port 25 is dropped entirely # (nothing should be delivering mail here from outside), while the client ports # stay bound to the host under `private`, so a mail client on the LAN — or over # the tailnet, if Headscale is in play — still reaches its mailbox. That # distinction is the whole point: private means "not on the internet", not # "switched off". stalwart_apply_port_access() { local cfg="$1" mode="$2" local smtp client if [[ "$mode" == "private" ]]; then smtp="disabled"; client="private" else smtp="public"; client="public" fi stalwart_set_port_access "$cfg" "CFG_STALWART_PORT_2" "$smtp" # 25 inbound mail stalwart_set_port_access "$cfg" "CFG_STALWART_PORT_3" "$client" # 465 submissions stalwart_set_port_access "$cfg" "CFG_STALWART_PORT_4" "$client" # 587 submission stalwart_set_port_access "$cfg" "CFG_STALWART_PORT_5" "$client" # 993 imaps } # Runs after the deployed config exists but before the compose file is built # from it — the only window where changing port exposure still reaches the # running container. stalwart_install_post_setup() { local app_name="$1" local cfg="$containers_dir$app_name/$app_name.config" [[ -f "$cfg" ]] || return 0 local mode; mode=$(stalwart_mode) # Write the resolved answer back, so the WebUI shows what this install # actually is instead of leaving the user to work out what 'auto' became. if [[ "${CFG_STALWART_MODE:-auto}" == "auto" ]]; then runFileOp sed -i -E "s#^CFG_STALWART_MODE=.*#CFG_STALWART_MODE=${mode}#" "$cfg" 2>/dev/null isNotice "Mail exposure not set explicitly — using '${mode}' (Traefik $([[ "$mode" == public ]] && echo present || echo absent))." fi stalwart_apply_port_access "$cfg" "$mode" # The port arrays were parsed from this config before this hook ran, and the # compose file is filled in from those arrays, not from the file we just # edited. Without re-reading, the access changes above are written to disk # and then quietly ignored — the config says port 25 is disabled while the # container publishes it anyway, which is the worst of both worlds. if declare -F initializeAppVariables >/dev/null 2>&1; then initializeAppVariables "$app_name" >/dev/null 2>&1 fi if [[ "$mode" == "private" ]]; then isNotice "Private mail server: port 25 stays closed, mail clients reach it on the local network only." fi } # Status code for one path on the admin listener, empty if the container did # not answer at all. Empty and a code are different answers — "no reply" is a # container/exec problem, while a code means the server replied and said no. # # curl runs INSIDE the container so this works whatever the port mapping does: # private mode unbinds ports from the host, and a probe aimed at the host would # then fail for reasons that have nothing to do with the server's health. stalwart_http_code() { local path="$1" max_time="${2:-5}" runFileOp docker exec stalwart-service curl -fsS -o /dev/null -w '%{http_code}' \ --max-time "$max_time" "http://localhost:8080${path}" 2>/dev/null | tr -d '\r' } # Wait for the admin HTTP listener. Used twice: once for the bootstrap listener # before we configure anything, once for the real one after the restart. # # Takes a healthz probe NAME (live/ready), not a path — the two callers are both # waiting on a health probe, and only a 200 ends the wait. Anything that needs a # different path, or the code rather than a yes/no, wants stalwart_http_code. stalwart_wait_http() { local probe="$1" tries="${2:-40}" i code for ((i = 0; i < tries; i++)); do code=$(stalwart_http_code "/healthz/$probe" 3) [[ "$code" == "200" ]] && return 0 sleep 2 done return 1 } # Fill in the first-run setup wizard instead of making the user do it. # # Out of the box Stalwart boots into "bootstrap mode" and waits for a human to # answer five screens of questions in the WebUI: hostname, domain, which storage # backend, which directory, where to log, how to handle DNS. LibrePortal already # knows the answers to the ones that matter and the rest have sane defaults, so # making the user answer them is asking a question we can answer ourselves. # # `Bootstrap` is a singleton object holding exactly those wizard fields, so the # whole wizard is one `update`. Once it is set the server writes its config.json, # provisions the domain, generates DKIM keys and leaves bootstrap mode — and the # user's first sight of Stalwart is a configured mail server, not a form. stalwart_install_provision() { # config.json only exists once setup has completed, which makes it the # honest "is this already configured?" test. Re-running the installer over a # working server must not re-answer its setup questions. if runFileOp docker exec stalwart-service test -f /etc/stalwart/config.json 2>/dev/null; then isNotice "Stalwart is already configured — leaving its existing settings alone." return 0 fi local mail_host="${host_setup:-}" local mail_domain="${mail_host#*.}" if [[ -z "$mail_host" || "$mail_host" != *.* ]]; then isError "No mail hostname is configured, so Stalwart cannot be set up automatically." isNotice " Set a domain in the general config, then open the admin console to" isNotice " complete setup by hand." return 1 fi isNotice "Waiting for Stalwart's setup listener…" if ! stalwart_wait_http live; then isError "Stalwart did not open its setup listener — skipping automatic setup." return 1 fi # requestTlsCertificate matters more than it looks. Traefik only fronts the # admin port; SMTP and IMAP (25/465/587/993) bypass it entirely, so Traefik's # certificate never reaches a mail client. Without this Stalwart serves a # self-signed cert on 993 and every mail client throws a warning. # # generateDkimKeys makes the server own DKIM: it creates an Ed25519 and an # RSA key, publishes both, and rotates them on a schedule. Hand-managed DKIM # keys are, in practice, keys that never get rotated. # A private server has no route to a publicly-trusted certificate — there is # no public name to validate — so asking for one only produces a failing # renewal loop. DKIM keys are generated either way: they cost nothing, and # having them already in place is what makes a later switch to public a # setting change rather than a key ceremony. local mode; mode=$(stalwart_mode) local want_cert=true [[ "$mode" == "private" ]] && want_cert=false # Public + Traefik + no DNS provider is a real dead end worth naming. Stalwart # validates over TLS-ALPN-01 (443) or HTTP-01 (80), and Traefik holds both, so # the only route left is DNS-01 — which needs the DNS provider integration. # Without it mail clients get a self-signed certificate on 993 and no amount # of waiting fixes it. if [[ "$mode" == "public" && -d "${containers_dir}traefik" \ && ( "${CFG_STALWART_DNS_PROVIDER:-manual}" == "manual" || -z "${CFG_STALWART_DNS_API_TOKEN:-}" ) ]]; then isNotice "Traefik owns ports 80 and 443, so Stalwart cannot validate a certificate" isNotice " for its mail ports on its own. Set CFG_STALWART_DNS_PROVIDER and a token" isNotice " to validate over DNS instead, or mail clients will see a self-signed" isNotice " certificate on port 993." fi isNotice "Configuring Stalwart for ${mail_domain} (${mode})…" local plan plan=$(printf '{"@type":"update","object":"Bootstrap","value":{"serverHostname":"%s","defaultDomain":"%s","generateDkimKeys":true,"requestTlsCertificate":%s}}' \ "$mail_host" "$mail_domain" "$want_cert") # Keep the server's own error rather than swallowing it. The two that # actually happen say exactly what is wrong — a hostname under a TLD that # does not exist, or a data directory the server cannot write — and both are # unfixable guesswork without the message. local apply_out if ! apply_out=$(printf '%s\n' "$plan" | stalwart_cli apply --stdin 2>&1); then isError "Automatic setup failed — open the admin console to finish it by hand." local reason reason=$(printf '%s\n' "$apply_out" | grep -oE '(Invalid|Failed|Permission)[^|]*' | head -1) [[ -n "$reason" ]] && isNotice " Stalwart said: ${reason% }" return 1 fi # Leaving bootstrap mode needs a restart: the server swaps its temporary # setup listener for the real ones (SMTP, IMAP, submission) on the way back up. isNotice "Restarting Stalwart to bring up the mail services…" runFileOp docker restart stalwart-service >/dev/null 2>&1 if ! stalwart_wait_http ready 60; then isError "Stalwart did not come back up after setup — check: docker logs stalwart-service" return 1 fi isSuccessful "Stalwart configured: ${mail_domain} added, DKIM keys generated." stalwart_install_dns_provider stalwart_install_first_mailbox return 0 } # Optionally hand DNS to the provider's API. # # This is the part that turns a page of records-to-paste into nothing at all: # Stalwart writes MX, SPF, DKIM, DMARC, MTA-STS, TLS-RPT, SRV and CAA into the # zone itself and keeps them in sync — including republishing DKIM records when # it rotates the keys, which is the whole reason rotation is safe to automate. # # Off by default. It needs an API token with write access to the zone, stored in # the mail server's database, which widens what a compromise of this box costs. # Scope the token to the single zone if your provider supports it. stalwart_install_dns_provider() { local provider="${CFG_STALWART_DNS_PROVIDER:-manual}" [[ "$provider" == "manual" || -z "$provider" ]] && return 0 # A private server publishes nothing: there is no public zone to keep in # sync, and handing a zone-write token to a box that never sends mail is # blast radius bought for nothing. [[ "$(stalwart_mode)" == "private" ]] && return 0 if [[ -z "${CFG_STALWART_DNS_API_TOKEN:-}" ]]; then isError "CFG_STALWART_DNS_PROVIDER is set to '$provider' but no API token was given." isNotice " Falling back to manual DNS — the records are printed below." return 0 fi local mail_domain="${host_setup#*.}" # Verified against Cloudflare; DigitalOcean and DeSEC take the same # description+secret shape. Providers needing more than a token (Route 53, # Google Cloud DNS) are not wired up here — add them in the admin console. local plan plan=$(printf '{"@type":"upsert","object":"DnsServer","matchOn":["description"],"value":{"dns":{"@type":"%s","description":"LibrePortal managed DNS","secret":{"@type":"Value","secret":"%s"}}}}\n{"@type":"upsert","object":"Domain","matchOn":["name"],"value":{"dom":{"name":"%s","dnsManagement":{"@type":"Automatic","dnsServerId":"#dns"}}}}' \ "$provider" "$CFG_STALWART_DNS_API_TOKEN" "$mail_domain") if printf '%s\n' "$plan" | stalwart_cli apply --stdin >/dev/null 2>&1; then isSuccessful "DNS records will be published and kept in sync via $provider." else isError "Could not enable automatic DNS via $provider — check the API token." isNotice " Manual DNS is still in effect; the records are printed below." fi } # Create one real mailbox. # # Setup leaves you with admin@, which is an administrator account — using # it as a day-to-day mailbox is the wrong habit to start someone on. More # concretely: the generated zone file points DMARC and TLS-RPT reports at # postmaster@, and nothing creates that address, so those reports would # bounce. Creating it fixes a real gap and gives the user a mailbox to log into. stalwart_install_first_mailbox() { local mailbox="${CFG_STALWART_FIRST_MAILBOX:-postmaster}" [[ -z "$mailbox" ]] && return 0 local mail_domain="${host_setup#*.}" local plan # The domain is upserted by name purely to get a reference to it — it already # exists, so this matches rather than creates. Credentials are a map keyed by # slot, not a list. Stalwart enforces password strength, so the password has # to be a generated one, not a short hand-picked string. plan=$(printf '{"@type":"upsert","object":"Domain","matchOn":["name"],"value":{"dom":{"name":"%s"}}}\n{"@type":"upsert","object":"Account","matchOn":["name"],"value":{"acc":{"@type":"User","name":"%s","domainId":"#dom","description":"First mailbox","credentials":{"0":{"@type":"Password","secret":"%s"}}}}}' \ "$mail_domain" "$mailbox" "${CFG_STALWART_FIRST_MAILBOX_PASSWORD_1:-}") if printf '%s\n' "$plan" | stalwart_cli apply --stdin >/dev/null 2>&1; then isSuccessful "Created the first mailbox: ${mailbox}@${mail_domain}" else isError "Could not create ${mailbox}@${mail_domain} — add it in the admin console." fi } stalwart_install_post_start() { local app_name="$1" ((menu_number++)) echo "" local mode; mode=$(stalwart_mode) if [[ "$mode" == "private" ]]; then echo "---- $menu_number. Setting up your private mail + calendar server" else echo "---- $menu_number. Mail server checks + the DNS records you still need" fi 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. Answer the setup wizard on the user's behalf ------------------ stalwart_install_provision # ---- 2 + 3. Internet-mail prerequisites ------------------------------ # Both of these are about exchanging mail with the outside world, so both # are noise on a private server — worse than noise, since a red ERROR about # port 25 on a server that is deliberately not on the internet reads as a # broken install and sends people chasing a problem they do not have. if [[ "$mode" == "public" ]]; then # 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." isNotice " If you did not mean to run an internet mail server, switch this app" isNotice " to private in its Tools tab — none of this applies there." fi # 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 fi # ---- 4. Is the admin console actually there? ------------------------- # Stalwart v0.16 does not ship the WebUI inside the Docker image: the admin # console is a single-page app the server fetches from GitHub on first # start. If this host had no outbound HTTPS at that moment the download # silently fails, the server still comes up healthy, and /admin and # /account answer 404 forever with nothing to explain why. Checking it here # turns "the panel is broken" into a one-line, fixable cause. isNotice "Checking the admin console (WebUI) responds…" local admin_code admin_code=$(stalwart_http_code /admin) if [[ "$admin_code" == "404" ]]; then isError "The admin console is missing (/admin returns 404)." isNotice " Stalwart does not bundle the WebUI — it downloads it from" isNotice " https://github.com/stalwartlabs/webui/releases/latest on first start." isNotice " That download failed, so /admin and /account will 404 until it succeeds." isNotice " Allow outbound HTTPS to github.com from this host, then restart the" isNotice " container: docker restart stalwart-service" isNotice " The mail server itself is unaffected — only the web interface is." elif [[ -z "$admin_code" ]]; then isError "Could not probe the admin console (no response from the container)." else isSuccessful "Admin console is being served (HTTP $admin_code)." fi # ---- 5. The records that still have to be published ------------------- # Read back from the server rather than composed here. Stalwart keeps the # domain's full record set in `dnsZoneFile`, so this prints what it actually # expects — MX, SPF, DMARC, MTA-STS, TLS-RPT, the SRV records clients use to # autoconfigure, and crucially the real DKIM public keys. The old version of # this hook had to tell the user to go and fetch DKIM themselves; by this # point the keys exist, so there is nothing left to look up. local mail_host="${host_setup:-your-mail-hostname}" local mail_domain="${mail_host#*.}" if [[ "$mode" == "private" ]]; then # Nothing to publish, so say what the user actually has instead — the # honest headline is that everything below works today, with no DNS, # no registrar and no waiting. echo "" isNotice "Nothing to add at a registrar — this server is not on the internet." isNotice "What you have, working now, on your local network:" echo " Mail between local accounts, IMAP on 993, submission on 465/587" echo " Calendars (CalDAV) and contacts (CardDAV)" echo "" isNotice "Point mail and calendar clients at: ${mail_host}" isNotice " Certificates are self-signed, so clients will ask you to trust it once." [[ -d "${containers_dir}headscale" ]] && \ isNotice " Headscale is installed, so this also reaches you from anywhere on your tailnet." echo "" isNotice "To exchange mail with the internet later, switch this app to public" isNotice " in its Tools tab. DKIM keys are already generated, so nothing is lost." echo "" elif [[ "${CFG_STALWART_DNS_PROVIDER:-manual}" != "manual" && -n "${CFG_STALWART_DNS_API_TOKEN:-}" ]]; then isNotice "DNS is managed automatically — no records to add by hand." isNotice " Still set at your provider, because they are not in the zone:" echo " A ${mail_host} ${public_ip_v4:-}" echo " PTR ${public_ip_v4:-} ${mail_host}" else local domain_id zone domain_id=$(stalwart_cli query Domain 2>/dev/null | awk 'NR==2{print $1}') if [[ -n "$domain_id" ]]; then zone=$(stalwart_cli get Domain "$domain_id" 2>/dev/null \ | sed -n '/DNS Zone File:/,$p' | sed 's/^ *DNS Zone File: *//') fi echo "" if [[ -n "$zone" ]]; then isNotice "DNS records to add at your domain registrar:" echo " A ${mail_host} ${public_ip_v4:-}" echo "" printf '%s\n' "$zone" | sed 's/^/ /' else # Setup did not complete, so there is no zone to read. Say what is # needed rather than printing nothing. 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}\"" isNotice " DKIM records are shown in the admin console under your domain." fi echo "" fi # ---- 6. Where to go next --------------------------------------------- if [[ -n "$admin_port" ]]; then isNotice "Admin console (server settings, domains, queue, reports):" [[ -n "$public_ip_v4" ]] && echo " http://$public_ip_v4:$admin_port/admin" isNotice "Mailbox settings for everyday users (password, filters, autoresponder):" [[ -n "$public_ip_v4" ]] && echo " http://$public_ip_v4:$admin_port/account" echo "" isNotice "Sign in as '${CFG_STALWART_ADMIN_USER:-admin}' with the password shown below." fi if [[ "$mode" == "public" ]]; then 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." isNotice "Even once they are, large providers distrust a brand-new sending IP for" isNotice "a while. That part is reputation, not configuration, and it takes time." fi }