new-site/scripts/hc-bounce-watcher.sh
justin 5c3b4291e7 feat(deliverability): send bulk campaigns from dedicated subdomain send.performancewest.net
Isolates bulk sending reputation onto a dedicated subdomain so the root domain
stays clean for transactional/verification mail (and recovers faster). Replies
still go to the root domain via Reply-To, so the customer-facing reply experience
is unchanged.

- build_trucking_campaigns.py: add env-overridable FROM_EMAIL
  (noreply@send.performancewest.net); use it for both scheduled + test sends
  instead of inheriting base["from_email"] from the DB base campaign.
- build_healthcare_campaigns_cron.py: FROM_EMAIL ->
  compliance@send.performancewest.net (env-overridable).
- bounce-watcher.sh / hc-bounce-watcher.sh: track the new subdomain envelope
  sender (keep legacy root-domain sender so the pre-cutover queue still drains;
  HC also tracks by hcout transport regardless of sender).

Infra already live (separate, non-code): subdomain DNS (A/MX/SPF/DKIM
selector=send/DMARC p=reject) on the Hestia master, OpenDKIM signs
d=send.performancewest.net (verified end-to-end), egress .94/.107. Root SPF
trimmed to the real IPs; pointless IP-rehab cron disabled.
2026-06-18 23:07:23 -05:00

85 lines
3.6 KiB
Bash

#!/bin/bash
# Healthcare Postfix bounce watcher -> listmonk-hc webhook.
# Parallel to the trucking pw-bounce-watcher, but scoped to the HEALTHCARE HOT
# stream: it tracks the hc sender (compliance@performancewest.net) and the
# hcout1/2/3 transports, and reports bounces to listmonk-hc (:9101).
#
# Install: /usr/local/bin/postfix-hc-bounce-notify.sh
# Service: /etc/systemd/system/pw-hc-bounce-watcher.service
#
# Bounce policy is set in listmonk-hc settings (bounce.actions): a single hard
# bounce or complaint -> blocklist; soft bounces tracked. This keeps the warming
# hc IPs clean by suppressing dead/complaining addresses immediately.
LOG=/var/log/mail.log
API="http://localhost:9101"
TOKEN_FILE=/opt/performancewest/.secrets/hc-listmonk-token
TOKEN="$(cat "$TOKEN_FILE" 2>/dev/null)"
AUTHHDR="Authorization: token api:${TOKEN}"
# hc campaign senders. Bulk now sends from the dedicated bulk subdomain
# (compliance@send.performancewest.net); the root-domain sender is kept so the
# pre-cutover queue still drains correctly. (Transport-based tracking below also
# catches everything that egresses an hcout transport, regardless of sender.)
HC_SENDER1="from=<compliance@send.performancewest.net>"
HC_SENDER_LEGACY="from=<compliance@performancewest.net>"
get_campaign_uuid() {
curl -s -H "$AUTHHDR" "$API/api/campaigns?status=running&per_page=1" 2>/dev/null \
| grep -oP '"uuid":"[^"]*"' | head -1 | cut -d'"' -f4
}
declare -A HC_QIDS
CURRENT_UUID=""
REMOVED_COUNT=0
post_bounce() {
local rcpt="$1" btype="$2" qid="$3"
[ -z "$CURRENT_UUID" ] && CURRENT_UUID=$(get_campaign_uuid)
local uuid_field=""
[ -n "$CURRENT_UUID" ] && uuid_field=", \"campaign_uuid\": \"$CURRENT_UUID\""
curl -s -H "$AUTHHDR" -X POST "$API/webhooks/bounce" \
-H "Content-Type: application/json" \
-d "{\"email\": \"$rcpt\", \"source\": \"postfix\", \"type\": \"$btype\"$uuid_field}" \
>/dev/null 2>&1
logger -t hc-bounce-notify "$btype bounce: $rcpt (qid=$qid, campaign=$CURRENT_UUID)"
}
tail -F "$LOG" 2>/dev/null | while IFS= read -r line; do
# Track queue IDs that originate from the hc sender OR go out an hcout transport.
if echo "$line" | grep -qE "$HC_SENDER1|$HC_SENDER_LEGACY"; then
QID=$(echo "$line" | sed -n 's/.*postfix\/[^[]*\[\([0-9]*\)\]: \([A-Z0-9]*\):.*/\2/p')
[ -n "$QID" ] && HC_QIDS[$QID]=1
fi
if echo "$line" | grep -qE "postfix/hcout[123]/"; then
QID=$(echo "$line" | sed -n 's/.*postfix\/[^[]*\[\([0-9]*\)\]: \([A-Z0-9]*\):.*/\2/p')
[ -n "$QID" ] && HC_QIDS[$QID]=1
fi
# Hard bounce
if echo "$line" | grep -q "status=bounced"; then
QID=$(echo "$line" | sed -n 's/.*postfix\/[^[]*\[\([0-9]*\)\]: \([A-Z0-9]*\):.*/\2/p')
RCPT=$(echo "$line" | sed -n 's/.*to=<\([^>]*\)>.*/\1/p')
if [ -n "$QID" ] && [ -n "$RCPT" ] && [ -n "${HC_QIDS[$QID]+x}" ]; then
post_bounce "$RCPT" "hard" "$QID"
unset HC_QIDS[$QID]
fi
fi
# Soft bounce that's actually a permanent 5xx
if echo "$line" | grep -q "status=deferred" && echo "$line" | grep -qE "said: 5[0-9][0-9]"; then
QID=$(echo "$line" | sed -n 's/.*postfix\/[^[]*\[\([0-9]*\)\]: \([A-Z0-9]*\):.*/\2/p')
RCPT=$(echo "$line" | sed -n 's/.*to=<\([^>]*\)>.*/\1/p')
if [ -n "$QID" ] && [ -n "$RCPT" ] && [ -n "${HC_QIDS[$QID]+x}" ]; then
post_bounce "$RCPT" "soft" "$QID"
fi
fi
# Cleanup queue IDs as they leave the queue
if echo "$line" | grep -q "removed$"; then
QID=$(echo "$line" | sed -n 's/.*postfix\/[^[]*\[\([0-9]*\)\]: \([A-Z0-9]*\):.*/\2/p')
[ -n "$QID" ] && unset HC_QIDS[$QID] 2>/dev/null
REMOVED_COUNT=$((REMOVED_COUNT + 1))
[ $((REMOVED_COUNT % 100)) -eq 0 ] && CURRENT_UUID=$(get_campaign_uuid)
fi
done