LibrePortal/containers/stalwart/scripts/stalwart_install_hooks.sh
librelad 88e9631b68 stalwart: choose private or public mail, and switch between them later
A mail server is two quite different products wearing one name, and until
now LibrePortal only offered the hard one. Installing Stalwart meant being
handed a wall of DNS records, a red error about port 25 and a warning about
reverse DNS — all of it correct, none of it fixable by the installer, and
most of it irrelevant to someone who wanted mailboxes and a shared calendar
on their own network.

CFG_STALWART_MODE now names which one you are running:

  private  mailboxes, IMAP, CalDAV and CardDAV on your own network. Port 25
           is not published at all; the client ports stay bound to the host
           but are never opened through the firewall. No MX, no PTR, no
           deliverability. Nothing to publish, so nothing is printed.
  public   the internet mail server, as before.
  auto     public if Traefik is installed, private if not, resolved at
           install and written back so it reads as a real answer afterwards.

DKIM keys are generated in both modes even though private has no use for
them today — that is what makes switching later a setting change rather
than a key ceremony. The WebUI gets a "Mail Exposure" tool that flips the
setting both ways and reconfigures the server, plus a "Show DNS Records"
tool that prints the live zone including current DKIM keys.

Two things this had to get right, both found by testing rather than
reading. Port access lives in the shell as CFG_<APP>_PORT_n, not just in
the config file, and the compose file is built from the parsed shell
values — editing only the file left the config claiming port 25 was
disabled while the container published it anyway. And going public needs
an AcmeProvider to exist before a domain can reference one, so the switch
creates it; note that doing so registers an account with Let's Encrypt.

Verified through real installs: auto resolves to private with no Traefik,
port 25 is genuinely unpublished and absent from the compose file, the
client ports are skipped by the firewall as host-bound, and the tool
round-trips private -> public -> private with the config landing back
exactly where it started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:52:55 +01:00

482 lines
24 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 "${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_<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="$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
}
# 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.
stalwart_wait_http()
{
local probe="$1" tries="${2:-40}" i code
for ((i = 0; i < tries; i++)); do
code=$(runFileOp docker exec stalwart-service curl -fsS -o /dev/null -w '%{http_code}' \
--max-time 3 "http://localhost:8080/healthz/$probe" 2>/dev/null | tr -d '\r')
[[ "$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@<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="$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=$(runFileOp docker exec stalwart-service curl -fsS -o /dev/null -w '%{http_code}' \
--max-time 5 http://localhost:8080/admin 2>/dev/null | tr -d '\r')
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:-<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
}