The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.
Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.
Three places needed judgement rather than substitution:
db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.
instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.
peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.
Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
498 lines
25 KiB
Bash
498 lines
25 KiB
Bash
#!/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 "$(appDir 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_<APP>_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="$(appDir "$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 "$(appDir 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@<domain>, 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@<domain>, 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="$(appDir "$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 "$(appDir 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:-<the IP of this server>}"
|
|
echo " PTR ${public_ip_v4:-<the IP of this server>} ${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:-<the IP of this server>}"
|
|
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:-<the IP of this server>}"
|
|
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
|
|
}
|