diff --git a/scripts/listmonk-bounce-sync.py b/scripts/listmonk-bounce-sync.py index 2743243..c687a49 100644 --- a/scripts/listmonk-bounce-sync.py +++ b/scripts/listmonk-bounce-sync.py @@ -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__":