#!/usr/bin/env bash # check_mail_deliverability.sh — Taler mail-plane deliverability (ops-checklist) # # SUT domains (NOT cyon): taler.net, gnunet.org, firefly.gnunet.org, # taler-systems.com, anastasis.lu, taler-ops.ch (from mail-catalog.conf) # # Based on admin-log/firefly/ops-checklist.md (2026-03 MT/ED scores): # dig SPF + DMARC + DKIM selector(s); SMTP RCPT smoke via monitord helper; # optional IMAP sink poll (mon001–003@hacktivism.ch on cyon = sink only). # # Explicit phase (not in all/full): # ./taler-monitoring.sh mail-deliverability # ./taler-monitoring.sh maildeliv # # Env: # MAIL_CATALOG default mail-catalog.conf # MAIL_DKIM_SELECTORS default "mail-2026-3 mail" (space-separated) # MAIL_DELIV_IMAP=1 enable sink IMAP login+INBOX check (needs pass) # MAIL_DELIV_IMAP_USERS default mon001@hacktivism.ch mon002@… mon003@… # MAIL_DELIV_IMAP_HOST default mail.cyon.ch (sink host — not SUT) # MAIL_DELIV_IMAP_PASS shared password for mon* (prefer secrets.env) # MAIL_DELIV_SEND=0 1 = authenticated send (Freigabe; needs SMTP_*) # MAIL_DELIV_ALERT=0 1 = on ERROR, send alert via sink SMTP (Freigabe) # MAIL_DELIV_ALERT_FROM default mon001@hacktivism.ch # MAIL_DELIV_ALERT_TO default hernani+mon@taler-systems.com # hernani+mon@vecirex.net vecirex+mon@icloud.com # fdold+mon@taler.net # MAIL_DELIV_ALERT_SMTP default mail.cyon.ch:587 (sink submission — not SUT) # MAIL_DELIV_ALERT_PASS default MAIL_DELIV_IMAP_PASS # MAIL_DELIV_ALERT_COOLDOWN_S default 21600 (6h); stamp under monitord state # TALER_MONITORD_DELIVER_* see site-gen smtp_deliverability_probe # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh source "$ROOT/lib.sh" set_area maildeliv section "maildeliv · Taler mail domains (SPF/DMARC/DKIM + SMTP RCPT; cyon=sink only)" CATALOG="${MAIL_CATALOG:-$ROOT/mail-catalog.conf}" SELECTORS="${MAIL_DKIM_SELECTORS:-mail-2026-3 mail}" : "${MAIL_DELIV_IMAP:=0}" : "${MAIL_DELIV_SEND:=0}" : "${MAIL_DELIV_ALERT:=0}" : "${MAIL_DELIV_PROBE:=0}" IMAP_HOST="${MAIL_DELIV_IMAP_HOST:-mail.cyon.ch}" IMAP_USERS="${MAIL_DELIV_IMAP_USERS:-mon001@hacktivism.ch mon002@hacktivism.ch mon003@hacktivism.ch}" ALERT_FROM="${MAIL_DELIV_ALERT_FROM:-mon001@hacktivism.ch}" ALERT_TO="${MAIL_DELIV_ALERT_TO:-hernani+mon@taler-systems.com hernani+mon@vecirex.net vecirex+mon@icloud.com fdold+mon@taler.net}" ALERT_SMTP="${MAIL_DELIV_ALERT_SMTP:-mail.cyon.ch:587}" ALERT_PASS="${MAIL_DELIV_ALERT_PASS:-${MAIL_DELIV_IMAP_PASS:-}}" ALERT_COOLDOWN_S="${MAIL_DELIV_ALERT_COOLDOWN_S:-21600}" STATE_DIR="${TALER_MONITORD_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/taler-monitord}" ALERT_STAMP="${STATE_DIR}/mail-deliv-alert.stamp" if [ ! -f "$CATALOG" ]; then fail "catalog" "missing $CATALOG" summary exit 1 fi info "scope" "SUT = catalog Taler domains · sink IMAP host=$IMAP_HOST (not under test)" dns_txt() { local name="$1" if command -v dig >/dev/null 2>&1; then dig +short +time=3 +tries=2 TXT "$name" 2>/dev/null | tr -d '"' else host -t TXT "$name" 2>/dev/null | sed 's/.*"\(.*\)"/\1/' fi } dns_mx() { local d="$1" dig +short +time=3 +tries=2 MX "$d" 2>/dev/null | awk '{print tolower($2)}' | sed 's/\.$//' } # --- per catalog domain --- while IFS= read -r line || [ -n "$line" ]; do line=${line%%#*} line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') [ -z "$line" ] && continue domain=$(echo "$line" | awk '{print $1}') expect_mx=$(echo "$line" | awk '{print $2}') label=$(echo "$line" | awk '{print $6}') [ -n "$domain" ] || continue set_group "${label:-$domain}" # SPF spf=$(dns_txt "$domain" | tr '\n' ' ' | grep -oE 'v=spf1[^"]*' | head -1 || true) if [ -n "$spf" ]; then ok "spf" "$domain has SPF" "$spf" else fail "spf" "$domain missing SPF TXT" fi # DMARC dmarc=$(dns_txt "_dmarc.$domain" | tr '\n' ' ' | grep -oE 'v=DMARC1.*' | head -1 || true) if [ -n "$dmarc" ]; then case "$dmarc" in *p=none*) if [ "$domain" = "taler-ops.ch" ] || [ "$domain" = "firefly.gnunet.org" ]; then warn "dmarc" "$domain DMARC p=none (known weak · ops-checklist)" "$dmarc" else warn "dmarc" "$domain DMARC p=none" "$dmarc" fi ;; *) ok "dmarc" "$domain DMARC present" "$dmarc" ;; esac else fail "dmarc" "$domain missing _dmarc TXT" fi # DKIM selectors (ops-checklist / 2026-03) dkim_ok=0 dkim_hit="" for sel in $SELECTORS; do [ -n "$sel" ] || continue txt=$(dns_txt "${sel}._domainkey.${domain}" | tr '\n' ' ') if echo "$txt" | grep -qiE 'v=DKIM1|p='; then dkim_ok=1 dkim_hit="$sel" break fi done if [ "$dkim_ok" = "1" ]; then ok "dkim" "$domain DKIM selector $dkim_hit" else warn "dkim" "$domain no DKIM TXT for selectors [$SELECTORS] (mail-tester often -1)" fi # MX matches catalog expectation (first token) mx_got=$(dns_mx "$domain" | head -1) exp_first=${expect_mx%%,*} exp_first=${exp_first%% *} if [ -n "$mx_got" ]; then if [ -z "$exp_first" ] || [ "$exp_first" = "-" ] || [ "$mx_got" = "$exp_first" ]; then ok "mx" "$domain MX=$mx_got" else # allow alternate listed in expect_mx comma list case ",${expect_mx}," in *",${mx_got},"*) ok "mx" "$domain MX=$mx_got (listed)" ;; *) warn "mx" "$domain MX=$mx_got want~$exp_first" ;; esac fi else fail "mx" "$domain no MX" continue fi # SMTP RCPT smoke (no DATA) — reuse site-gen helper if [ -f "$ROOT/site-gen/stack_status_lib.py" ]; then out=$( ROOT="$ROOT" DOMAIN="$domain" MX="$mx_got" python3 - <<'PY' 2>/dev/null || echo "err=import" import os, sys sys.path.insert(0, os.path.join(os.environ["ROOT"], "site-gen")) from stack_status_lib import smtp_deliverability_probe r = smtp_deliverability_probe(os.environ["MX"], domain=os.environ["DOMAIN"]) print(f"ok={r.get('ok')} light={r.get('light')} reason={r.get('reason')} rcpt={r.get('rcpt')}") PY ) case "$out" in ok=True*) ok "smtp-rcpt" "$domain $out" ;; ok=False*light=yellow*|ok=False*rcpt_reject*|ok=False*reason=rcpt_reject*) # Many MTAs reject postmaster@ from random clients; still useful signal (ops-checklist uses mail-tester for full score). warn "smtp-rcpt" "$domain $out" ;; ok=False*) fail "smtp-rcpt" "$domain $out" ;; *) warn "smtp-rcpt" "$domain probe unavailable · $out" ;; esac else info "smtp-rcpt" "skipped (no site-gen/stack_status_lib.py)" fi done <"$CATALOG" # --- optional IMAP sinks (cyon hosts mon*; not SUT) --- if [ "$MAIL_DELIV_IMAP" = "1" ]; then set_group sink-imap if [ -z "${MAIL_DELIV_IMAP_PASS:-}" ]; then warn "sink-imap" "MAIL_DELIV_IMAP=1 but MAIL_DELIV_IMAP_PASS unset — skip" else info "sink-imap" "probing sinks on $IMAP_HOST (infrastructure only · not Taler MTA)" for user in $IMAP_USERS; do res=$( MAIL_DELIV_IMAP_HOST="$IMAP_HOST" MAIL_DELIV_IMAP_USER="$user" \ MAIL_DELIV_IMAP_PASS="$MAIL_DELIV_IMAP_PASS" python3 - <<'PY' 2>/dev/null || echo FAIL import imaplib, os, ssl host=os.environ["MAIL_DELIV_IMAP_HOST"] user=os.environ["MAIL_DELIV_IMAP_USER"] pw=os.environ["MAIL_DELIV_IMAP_PASS"] ctx=ssl.create_default_context() M=imaplib.IMAP4_SSL(host, 993, ssl_context=ctx) M.login(user, pw) typ, data=M.select("INBOX") n=data[0].decode() if data and data[0] else "?" # also see junk/spam exists boxes=[] t, bl=M.list() for b in bl or []: s=b.decode(errors="replace") if "Junk" in s or "spam" in s.lower(): boxes.append("junk/spam") M.logout() print(f"OK inbox={n} extra={','.join(boxes) or 'none'}") PY ) case "$res" in OK*) ok "sink" "$user · $res" ;; *) fail "sink" "$user · $res" ;; esac done fi else info "sink-imap" "skipped (MAIL_DELIV_IMAP=0) · sinks mon001–003 for Freigabe send tests" fi if [ "$MAIL_DELIV_SEND" = "1" ]; then warn "send" "MAIL_DELIV_SEND=1 deprecated here — use MAIL_DELIV_PROBE=1 (inbox vs spam)" fi if [ "$MAIL_DELIV_PROBE" = "1" ]; then set_group probe if [ -z "${MAIL_DELIV_IMAP_PASS:-}${MAIL_DELIV_ALERT_PASS:-}" ]; then warn "probe" "MAIL_DELIV_PROBE=1 but no IMAP pass" else info "probe" "mon* send + IMAP inbox-vs-spam (regular path · host-agent timer 6h)" if out=$(python3 "$ROOT/mail_deliv_probe.py" 2>&1); then ok "probe" "all sinks inbox" info "probe-detail" "$(echo "$out" | tr '\n' ' ' | head -c 240)" else ec=$? echo "$out" | while IFS= read -r line; do case "$line" in *result=spam*|*result=both*) fail "probe-spam" "$line" ;; *result=missing*|*IMAP_FAIL*|*SEND_FAIL*) fail "probe-miss" "$line" ;; SENT*|ALERT*) info "probe" "$line" ;; *) info "probe" "$line" ;; esac done [ "$ec" -eq 0 ] || true fi fi else info "probe" "skipped (MAIL_DELIV_PROBE=0) · enable for inbox-vs-spam; timer: taler-monitoring-mail-deliv.timer" fi # --- alert on ERROR via sink SMTP (cyon submission; recipients = hernani+mon / icloud) --- maildeliv_alert_if_needed() { set_group alert if [ "$MAIL_DELIV_ALERT" != "1" ]; then info "alert" "skipped (MAIL_DELIV_ALERT=0) · would To: $ALERT_TO" return 0 fi if [ "${#ERRORS[@]}" -eq 0 ]; then ok "alert" "no ERRORs — no alert mail" return 0 fi if [ -z "$ALERT_PASS" ]; then warn "alert" "MAIL_DELIV_ALERT=1 but no MAIL_DELIV_ALERT_PASS / MAIL_DELIV_IMAP_PASS" return 0 fi if ! command -v swaks >/dev/null 2>&1; then warn "alert" "swaks missing — apt/brew install swaks" return 0 fi mkdir -p "$STATE_DIR" 2>/dev/null || true now=$(date +%s) if [ -f "$ALERT_STAMP" ]; then prev=$(cat "$ALERT_STAMP" 2>/dev/null || echo 0) if [ "$prev" -gt 0 ] 2>/dev/null; then age=$((now - prev)) if [ "$age" -lt "$ALERT_COOLDOWN_S" ]; then info "alert" "cooldown ${age}s < ${ALERT_COOLDOWN_S}s — skip send" return 0 fi fi fi smtp_host=${ALERT_SMTP%%:*} smtp_port=${ALERT_SMTP##*:} [ "$smtp_port" = "$ALERT_SMTP" ] && smtp_port=587 body=$(mktemp) { echo "taler-monitoring mail-deliverability ALERT" echo "host=$(hostname 2>/dev/null || echo ?)" echo "when=$(date -Iseconds 2>/dev/null || date)" echo "errors=${#ERRORS[@]}" echo "" for e in "${ERRORS[@]}"; do echo "- $e" done echo "" echo "SUT = Taler mail domains (not cyon). From=$ALERT_FROM via $ALERT_SMTP" } >"$body" subj="[taler-mon maildeliv] ${#ERRORS[@]} ERROR(s)" send_fail=0 for to in $ALERT_TO; do if swaks --server "$smtp_host" --port "$smtp_port" \ --auth LOGIN --auth-user "$ALERT_FROM" --auth-password "$ALERT_PASS" \ --tls --from "$ALERT_FROM" --to "$to" \ --header "Subject: $subj" --body "$body" >/dev/null 2>&1; then ok "alert" "sent → $to" else warn "alert" "send failed → $to" send_fail=1 fi done rm -f "$body" if [ "$send_fail" = "0" ]; then echo "$now" >"$ALERT_STAMP" 2>/dev/null || true fi } maildeliv_alert_if_needed summary