diff --git a/deploy/provision-mail.sh b/deploy/provision-mail.sh new file mode 100755 index 0000000..514578e --- /dev/null +++ b/deploy/provision-mail.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Outbound email for Thermograph — run once on the VPS, as root. +# +# Installs Postfix as a SEND-ONLY NULL CLIENT: it listens on 127.0.0.1:25 only, +# accepts mail from this machine, and never receives mail from the internet. +# +# Why a local MTA instead of talking to a mail provider's API from Python: +# +# * The app's only mail config becomes "SMTP on localhost". Whether delivery +# then goes direct to the recipient's MX or through a relay is a Postfix +# setting — switchable without touching, redeploying, or retesting the app. +# * Postfix queues and retries. A request handler hands the message over in +# microseconds and returns; a slow or briefly-down upstream can't stall a +# web request or lose a signup. +# * No new Python dependency: stdlib smtplib talks to it (see backend/mailer.py). +# +# DELIVERABILITY — read before pointing this at real subscribers. +# +# Mail sent straight from a VPS IP is very often junked, regardless of Postfix +# config, because the IP has no sending reputation. Two options: +# +# A. RELAY through a transactional provider (recommended for real mail). +# Set RELAYHOST + RELAY_USER + RELAY_PASSWORD below. The provider handles +# SPF/DKIM alignment and reputation; you keep the loopback-SMTP seam. +# +# B. DIRECT to MX (no third party). Then you must also set up, in DNS: +# - SPF: TXT @ "v=spf1 a mx ip4: -all" +# - DKIM: install opendkim, publish the public key as a TXT record +# - DMARC: TXT _dmarc "v=DMARC1; p=none; rua=mailto:you@domain" +# - PTR / reverse DNS on the VPS IP -> mail.thermograph.org +# The PTR record is the one people forget, and its absence alone is enough +# for Gmail and Outlook to junk everything you send. +# +# Usage: +# sudo MAIL_DOMAIN=thermograph.org bash deploy/provision-mail.sh +# sudo MAIL_DOMAIN=thermograph.org RELAYHOST='[smtp.provider.com]:587' \ +# RELAY_USER=apikey RELAY_PASSWORD=secret bash deploy/provision-mail.sh +set -euo pipefail + +MAIL_DOMAIN="${MAIL_DOMAIN:-thermograph.org}" +MAIL_HOSTNAME="${MAIL_HOSTNAME:-mail.${MAIL_DOMAIN}}" +RELAYHOST="${RELAYHOST:-}" +RELAY_USER="${RELAY_USER:-}" +RELAY_PASSWORD="${RELAY_PASSWORD:-}" + +if [[ $EUID -ne 0 ]]; then + echo "run as root (sudo)" >&2 + exit 1 +fi + +echo "==> installing postfix (non-interactive)" +export DEBIAN_FRONTEND=noninteractive +# Preseed so the installer doesn't open its curses dialog. +debconf-set-selections < 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" +postconf -e "inet_protocols = ipv4" +# Accept mail only from this machine. +postconf -e "mynetworks = 127.0.0.0/8 [::1]/128" +# A null client delivers nothing locally; everything is relayed out. +postconf -e "mydestination =" +postconf -e "local_transport = error:local delivery is disabled" +# Use TLS opportunistically when talking to the next hop. +postconf -e "smtp_tls_security_level = may" +postconf -e "smtp_tls_loglevel = 1" + +if [[ -n "$RELAYHOST" ]]; then + echo "==> configuring relay via ${RELAYHOST}" + postconf -e "relayhost = ${RELAYHOST}" + if [[ -n "$RELAY_USER" ]]; then + postconf -e "smtp_sasl_auth_enable = yes" + postconf -e "smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd" + postconf -e "smtp_sasl_security_options = noanonymous" + printf '%s %s:%s\n' "$RELAYHOST" "$RELAY_USER" "$RELAY_PASSWORD" \ + > /etc/postfix/sasl_passwd + # The credential file must not be world-readable. + chmod 600 /etc/postfix/sasl_passwd + postmap /etc/postfix/sasl_passwd + chmod 600 /etc/postfix/sasl_passwd.db + fi +else + echo "==> no RELAYHOST set: delivering direct to MX" + echo " remember SPF + DKIM + DMARC + PTR, or expect the spam folder" + postconf -e "relayhost =" +fi + +systemctl enable postfix +systemctl restart postfix + +echo "==> verifying it listens on loopback only" +ss -lntp | grep ':25 ' || true + +cat <<'NOTE' + +==> next steps + +1. Point the app at it, in /etc/thermograph.env: + + THERMOGRAPH_MAIL_BACKEND=smtp + THERMOGRAPH_SMTP_HOST=127.0.0.1 + THERMOGRAPH_SMTP_PORT=25 + THERMOGRAPH_MAIL_FROM=Thermograph + + then: sudo systemctl restart thermograph + +2. Send yourself a test message: + + echo "test body" | mail -s "thermograph test" you@example.com + # or, exercising the app's own path: + # python -c "import sys; sys.path.insert(0,'/opt/thermograph/backend'); \ + # import mailer; print(mailer.send('you@example.com','t','body'))" + +3. Watch it leave: journalctl -u postfix -f (queue: mailq) + +4. Check placement with https://www.mail-tester.com — it scores SPF, DKIM, + DMARC and rDNS in one shot and tells you exactly what's missing. +NOTE diff --git a/deploy/thermograph.env.example b/deploy/thermograph.env.example index 832cb9e..0ec3883 100644 --- a/deploy/thermograph.env.example +++ b/deploy/thermograph.env.example @@ -48,3 +48,28 @@ THERMOGRAPH_BASE_URL=https://thermograph.org #THERMOGRAPH_VAPID_PUBLIC_KEY= # Contact (mailto: or https URL) sent to push services in the VAPID claim. #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. +# +# 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_BACKEND=smtp +#THERMOGRAPH_SMTP_HOST=127.0.0.1 +#THERMOGRAPH_SMTP_PORT=25 +# Only needed if talking to a remote SMTP server directly instead of local Postfix. +#THERMOGRAPH_SMTP_USER= +#THERMOGRAPH_SMTP_PASSWORD= +#THERMOGRAPH_SMTP_STARTTLS=1 +#THERMOGRAPH_MAIL_FROM=Thermograph +#THERMOGRAPH_MAIL_REPLY_TO= + +# Signing secret for email confirmation / password-reset tokens. MUST be set to a +# fixed value before any such link is mailed: it defaults to a per-boot random +# value, which would invalidate every outstanding link on each restart. +# generate with: python -c "import secrets; print(secrets.token_urlsafe(48))" +#THERMOGRAPH_AUTH_SECRET=