diff --git a/deploy/provision-mail.sh b/deploy/provision-mail.sh index 514578e..0958d25 100755 --- a/deploy/provision-mail.sh +++ b/deploy/provision-mail.sh @@ -61,11 +61,45 @@ apt-get install -y -qq postfix libsasl2-modules echo "==> configuring send-only null client" postconf -e "myhostname = ${MAIL_HOSTNAME}" postconf -e "myorigin = ${MAIL_DOMAIN}" -# THE important line: never listen on a public interface. This box sends only. -postconf -e "inet_interfaces = loopback-only" +# Never listen on a public interface. This box sends only. The app runs in a +# Docker container, so it can't reach the host's loopback — it hands mail to +# Postfix over the compose bridge's gateway. So Postfix also listens on that +# gateway and accepts mail from the bridge subnet (both pinned in +# docker-compose.yml). Set DOCKER_MAIL_GATEWAY="" for a pure loopback-only null +# client (app running natively on the host, not in a container). +DOCKER_MAIL_GATEWAY="${DOCKER_MAIL_GATEWAY-172.19.0.1}" +DOCKER_MAIL_SUBNET="${DOCKER_MAIL_SUBNET-172.19.0.0/16}" +# Optional WireGuard-mesh listener: other mesh nodes (e.g. beta's Forgejo, whose +# mailer posts to 10.10.0.1:25 — see deploy/forgejo/docker-stack.yml) can relay +# through this box. Prod runs with MESH_MAIL_LISTEN=10.10.0.1 and +# MESH_MAIL_PEERS=10.10.0.2/32; both default OFF so a plain run stays a strict +# null client. Without these, re-running this script on prod would silently drop +# the mesh listener and break Forgejo's outbound mail — the live config was +# originally hand-applied and this script is the source of truth for it now. +MESH_MAIL_LISTEN="${MESH_MAIL_LISTEN-}" +MESH_MAIL_PEERS="${MESH_MAIL_PEERS-}" postconf -e "inet_protocols = ipv4" -# Accept mail only from this machine. -postconf -e "mynetworks = 127.0.0.0/8 [::1]/128" +listen="127.0.0.1" +networks="127.0.0.0/8 [::1]/128" +if [[ -n "$DOCKER_MAIL_GATEWAY" ]]; then + listen="${listen}, ${DOCKER_MAIL_GATEWAY}" + networks="${networks} ${DOCKER_MAIL_SUBNET}" + # ufw is default-deny incoming; a container connecting to the host's gateway IP + # hits the INPUT chain, so allow the bridge subnet to reach port 25. + command -v ufw >/dev/null 2>&1 && \ + ufw allow from "${DOCKER_MAIL_SUBNET}" to any port 25 proto tcp \ + comment 'app container -> host Postfix' || true +fi +if [[ -n "$MESH_MAIL_LISTEN" ]]; then + listen="${listen}, ${MESH_MAIL_LISTEN}" + networks="${networks} ${MESH_MAIL_PEERS}" +fi +if [[ "$listen" == "127.0.0.1" ]]; then + postconf -e "inet_interfaces = loopback-only" +else + postconf -e "inet_interfaces = ${listen}" +fi +postconf -e "mynetworks = ${networks}" # A null client delivers nothing locally; everything is relayed out. postconf -e "mydestination =" postconf -e "local_transport = error:local delivery is disabled" diff --git a/deploy/thermograph.env.example b/deploy/thermograph.env.example index c4faae1..2ab56a0 100644 --- a/deploy/thermograph.env.example +++ b/deploy/thermograph.env.example @@ -126,16 +126,20 @@ THERMOGRAPH_BASE_URL=https://thermograph.org #THERMOGRAPH_VAPID_CONTACT=mailto:you@example.com # --- Outbound email -------------------------------------------------------------- -# Delivery goes through a local Postfix null client on 127.0.0.1:25 — see -# deploy/provision-mail.sh. The app only ever speaks plain SMTP to loopback, so -# switching between "direct to MX" and "relay through a provider" is a Postfix -# config change and needs no code change or redeploy. +# Delivery goes through the host's Postfix null client (deploy/provision-mail.sh). +# The app runs in a container, so it can't reach the host's loopback — it speaks +# plain SMTP to the compose bridge's gateway (172.19.0.1, pinned in +# docker-compose.yml), where Postfix listens and relays out. Switching between +# "direct to MX" and "relay through a provider" is a Postfix change, no redeploy. # # Backends: console (log it, send nothing — the default, right for dev), # smtp (actually send), disabled (drop silently). # Leave unset until Postfix is provisioned: signups are still collected either way. +# THERMOGRAPH_MAIL_FROM is left unset here on purpose — the app default +# (Thermograph ) is correct, and its "<>" would need +# escaping in this shell-sourced file. Override only if the address differs. #THERMOGRAPH_MAIL_BACKEND=smtp -#THERMOGRAPH_SMTP_HOST=127.0.0.1 +#THERMOGRAPH_SMTP_HOST=172.19.0.1 #THERMOGRAPH_SMTP_PORT=25 # Only needed if talking to a remote SMTP server directly instead of local Postfix. #THERMOGRAPH_SMTP_USER= diff --git a/docker-compose.yml b/docker-compose.yml index c97fea7..c866273 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -180,3 +180,17 @@ volumes: pgdata: {} appdata: {} applogs: {} + +networks: + # Pin the default network's subnet + gateway so the host Postfix can rely on a + # stable address. The app sends verification email by speaking SMTP to the + # gateway (172.19.0.1), where the host Postfix listens and relays out (see + # deploy/provision-mail.sh + THERMOGRAPH_SMTP_HOST in thermograph.env). Without + # the pin, Docker picks a subnet from its pool and the gateway could move. + # 172.19.0.0/16 matches what prod and beta are live on today, so applying this + # is a no-op there. + default: + ipam: + config: + - subnet: 172.19.0.0/16 + gateway: 172.19.0.1