new-site/scripts/bounce-watcher.sh
justin 09b32d6e75 fix(bounce-watchers): QID regex broke on out05-09/hcout transports
Both Postfix bounce watchers extracted the queue-ID with regex
postfix/[a-z]*[ (main) and postfix/[a-z0-9]*[ (hc), which only matched simple
transports like qmgr/cleanup. When the MTA started rotating sends through the
numbered warmup transports (out05-09/smtp, hcout1-3/smtp -- a transport/subprocess
name WITH a slash), the QID extraction returned empty, so status=bounced lines
never matched a tracked campaign QID and NO bounces were posted to listmonk since
~May 31. Result: dead mailboxes never got blocklisted and kept being retried,
dragging the warmup bounce rate.

Fix: regex postfix/[^[]*[ matches any transport/subprocess name up to the [pid].
Verified live on both watchers (out07/smtp and hcout1/smtp test bounces now
detected + posted).

Separately (server-side, not in repo): listmonk bounce.actions was null (no
auto-action), so even posted bounces did nothing. Set hard=1->blocklist,
complaint=1->blocklist; verified a real bounce now records + blocklists.
2026-06-08 15:02:32 -05:00

100 lines
3.8 KiB
Bash

#!/bin/bash
# Postfix bounce watcher — tails mail.log and reports bounces to Listmonk.
# Only processes bounces for campaign emails (from=<noreply@performancewest.net>).
# Ignores system cron bounces, postmaster notices, etc.
#
# Install: copy to /usr/local/bin/postfix-bounce-notify.sh
# Service: systemd unit at /etc/systemd/system/pw-bounce-watcher.service
LOG=/var/log/mail.log
AUTH="api:6X1rKPea61N4rZ1S65Hx5zvqzbCj30F6nvEe9oVGH_Y"
API="http://localhost:9100"
# Get the UUID of the currently running campaign (if any)
get_campaign_uuid() {
curl -s -u "$AUTH" "$API/api/campaigns?status=running&per_page=1" 2>/dev/null \
| grep -oP '"uuid":"[^"]*"' | head -1 | cut -d'"' -f4
}
# Track queue IDs from campaign sends (from=noreply@) so we only
# report bounces for those, not for system emails.
declare -A CAMPAIGN_QIDS
CURRENT_UUID=""
tail -F "$LOG" 2>/dev/null | while IFS= read -r line; do
# Track queue IDs originating from campaign sender
if echo "$line" | grep -q "from=<noreply@performancewest.net>"; then
QID=$(echo "$line" | sed -n 's/.*postfix\/[^[]*\[\([0-9]*\)\]: \([A-Z0-9]*\):.*/\2/p')
if [ -n "$QID" ]; then
CAMPAIGN_QIDS[$QID]=1
fi
fi
# Also track from=<info@performancewest.net>
if echo "$line" | grep -q "from=<info@performancewest.net>"; then
QID=$(echo "$line" | sed -n 's/.*postfix\/[^[]*\[\([0-9]*\)\]: \([A-Z0-9]*\):.*/\2/p')
if [ -n "$QID" ]; then
CAMPAIGN_QIDS[$QID]=1
fi
fi
# Hard bounce: status=bounced
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')
# Only report if this was a campaign email
if [ -n "$QID" ] && [ -n "$RCPT" ] && [ -n "${CAMPAIGN_QIDS[$QID]+x}" ]; then
# Refresh campaign UUID periodically
if [ -z "$CURRENT_UUID" ]; then
CURRENT_UUID=$(get_campaign_uuid)
fi
UUID_FIELD=""
if [ -n "$CURRENT_UUID" ]; then
UUID_FIELD=", \"campaign_uuid\": \"$CURRENT_UUID\""
fi
curl -s -u "$AUTH" -X POST "$API/webhooks/bounce" \
-H "Content-Type: application/json" \
-d "{\"email\": \"$RCPT\", \"source\": \"postfix\", \"type\": \"hard\"$UUID_FIELD}" \
>/dev/null 2>&1
logger -t bounce-notify "Hard bounce: $RCPT (qid=$QID, campaign=$CURRENT_UUID)"
unset CAMPAIGN_QIDS[$QID]
fi
fi
# Soft bounce with permanent 5xx in the response
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 "${CAMPAIGN_QIDS[$QID]+x}" ]; then
if [ -z "$CURRENT_UUID" ]; then
CURRENT_UUID=$(get_campaign_uuid)
fi
UUID_FIELD=""
if [ -n "$CURRENT_UUID" ]; then
UUID_FIELD=", \"campaign_uuid\": \"$CURRENT_UUID\""
fi
curl -s -u "$AUTH" -X POST "$API/webhooks/bounce" \
-H "Content-Type: application/json" \
-d "{\"email\": \"$RCPT\", \"source\": \"postfix\", \"type\": \"soft\"$UUID_FIELD}" \
>/dev/null 2>&1
logger -t bounce-notify "Soft bounce (5xx): $RCPT (qid=$QID, campaign=$CURRENT_UUID)"
fi
fi
# Clean up old QIDs when they're removed from queue
if echo "$line" | grep -q "removed$"; then
QID=$(echo "$line" | sed -n 's/.*postfix\/[^[]*\[\([0-9]*\)\]: \([A-Z0-9]*\):.*/\2/p')
if [ -n "$QID" ]; then
unset CAMPAIGN_QIDS[$QID] 2>/dev/null
fi
# Refresh campaign UUID every 100 removed messages
REMOVED_COUNT=$((${REMOVED_COUNT:-0} + 1))
if [ $((REMOVED_COUNT % 100)) -eq 0 ]; then
CURRENT_UUID=$(get_campaign_uuid)
fi
fi
done