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.
102 lines
4 KiB
Bash
102 lines
4 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. Bulk now sends from the
|
|
# dedicated bulk subdomain (noreply@send.performancewest.net); the root-domain
|
|
# sender is kept so the pre-cutover queue still drains correctly.
|
|
if echo "$line" | grep -qE "from=<noreply@send.performancewest.net>|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
|