bounce-sync: stop blocklisting good carriers on first auth/policy bounce

This script ran every 5 min and blocklisted on the FIRST hard bounce of ANY
5xx DSN via direct SQL, bypassing Listmonk's count-based bounce.actions rule.
That is the actual mechanism that wrongly killed ~17,000 good carriers during
the broken-DKIM window (their mail got 5.7.1 DMARC-reject, not bad-mailbox).

Fix: only genuine bad-mailbox DSNs (5.1.1/5.1.0/5.0.0/5.4.1/5.5.0) count toward
a blocklist, and a subscriber must accumulate >=3 such hard bounces (matching
Listmonk's threshold) before being blocklisted. Reputation/policy 5.7.x and
quota/greylist 5.2.x never trigger a blocklist.
This commit is contained in:
justin 2026-06-26 23:53:20 -05:00
parent f3442872f2
commit bfdbf8f031

View file

@ -157,21 +157,50 @@ def main():
print(f" Inserted: {inserted}, Skipped (existing): {skipped}, No subscriber: {no_subscriber}")
# Blocklist subscribers with hard bounces (Listmonk's own behavior)
# Blocklist subscribers ONLY for genuine bad-mailbox bounces, and only
# once they cross the same 3-strike threshold Listmonk's bounce.actions
# uses. This script previously blocklisted on the FIRST hard bounce of
# ANY 5xx DSN -- including 5.7.1 (DMARC/auth/"low reputation" policy
# rejections). During the Jun broken-DKIM window that wrongly killed
# ~17,000 good carriers in one pass (a deliverability bug, not bad
# addresses). Now: policy/reputation DSNs (5.7.x) and greylist/quota
# (5.2.x) never trigger a blocklist, and a real bad-mailbox address must
# accumulate >= HARD_BOUNCE_BLOCKLIST_THRESHOLD distinct hard bounces.
if not dry_run and inserted > 0:
# DSN prefixes that indicate a genuinely undeliverable mailbox
# (vs. sender-reputation/policy or transient mailbox-full issues).
BAD_MAILBOX_DSNS = ("5.1.1", "5.1.10", "5.1.0", "5.0.0", "5.4.1", "5.5.0")
HARD_BOUNCE_BLOCKLIST_THRESHOLD = 3
new_hard = [
b for b in bounces
if b["type"] == "hard"
and b["email"] in sub_map
and b["email"] not in already_bounced
and b["dsn"].startswith(BAD_MAILBOX_DSNS)
]
if new_hard:
# Count TOTAL distinct hard bounces per subscriber (existing in
# the DB + the new one) and blocklist only those at/over the
# threshold, exactly as Listmonk's own count-based rule would.
sids = ",".join(str(sub_map[b["email"]]) for b in new_hard)
run_sql(
f"UPDATE subscribers SET status = 'blocklisted' "
f"WHERE id IN ({sids}) AND status != 'blocklisted';"
over_threshold = run_sql(
f"SELECT subscriber_id FROM bounces "
f"WHERE subscriber_id IN ({sids}) AND type = 'hard' "
f"GROUP BY subscriber_id "
f"HAVING count(*) >= {HARD_BOUNCE_BLOCKLIST_THRESHOLD};"
)
print(f" Blocklisted {len(new_hard)} hard-bounce subscribers")
bl_sids = [s.strip() for s in over_threshold.split("\n") if s.strip()]
if bl_sids:
run_sql(
f"UPDATE subscribers SET status = 'blocklisted' "
f"WHERE id IN ({','.join(bl_sids)}) AND status != 'blocklisted';"
)
print(f" Blocklisted {len(bl_sids)} subscribers "
f"(>= {HARD_BOUNCE_BLOCKLIST_THRESHOLD} bad-mailbox hard bounces)")
else:
print(f" Recorded {len(new_hard)} bad-mailbox hard bounces; "
f"none yet at {HARD_BOUNCE_BLOCKLIST_THRESHOLD}-strike blocklist threshold")
if __name__ == "__main__":