diff --git a/scripts/document_gen/templates/_styles.py b/scripts/document_gen/templates/_styles.py index 2cc639a..c4a1711 100644 --- a/scripts/document_gen/templates/_styles.py +++ b/scripts/document_gen/templates/_styles.py @@ -133,7 +133,7 @@ def add_pw_footer(doc, entity_name: str = "") -> None: p.alignment = WD_ALIGN_PARAGRAPH.CENTER # "Prepared by Performance West Inc." text - run = p.add_run("Prepared by Performance West Inc.") + run = p.add_run("Prepared by Performance West Inc., a regulatory compliance consulting firm") run.font.size = PW.FOOTER_SIZE run.font.color.rgb = PW.LIGHT_GRAY run.font.name = PW.FONT_FAMILY diff --git a/scripts/document_gen/templates/calea_audio_bridge_generator.py b/scripts/document_gen/templates/calea_audio_bridge_generator.py index 541e8bc..24e564b 100644 --- a/scripts/document_gen/templates/calea_audio_bridge_generator.py +++ b/scripts/document_gen/templates/calea_audio_bridge_generator.py @@ -203,7 +203,9 @@ def generate_calea_audio_bridge( _body(doc, f"{signatory_title}, {entity_name}") _body(doc, f"Effective Date: {effective}") if frn: _body(doc, f"FRN: {frn}") - _body(doc, f"Reviewed By: {reviewer_name}, {reviewer_company}") + _body(doc, f"Document prepared by {reviewer_name}, {reviewer_company}") + _body(doc, "Performance West Inc. is a regulatory compliance consulting firm, not a law firm. " + "This document does not constitute legal advice or legal representation.") _body(doc, f"Next Review Date: {next_review}") out = Path(output_path) diff --git a/scripts/document_gen/templates/calea_clec_ss7_generator.py b/scripts/document_gen/templates/calea_clec_ss7_generator.py index cb692f9..2ce47f4 100644 --- a/scripts/document_gen/templates/calea_clec_ss7_generator.py +++ b/scripts/document_gen/templates/calea_clec_ss7_generator.py @@ -240,7 +240,9 @@ def generate_calea_clec_ss7( _body(doc, f"Effective Date: {effective}") if frn: _body(doc, f"FRN: {frn}") - _body(doc, f"Reviewed By: {reviewer_name}, {reviewer_company}") + _body(doc, f"Document prepared by {reviewer_name}, {reviewer_company}") + _body(doc, "Performance West Inc. is a regulatory compliance consulting firm, not a law firm. " + "This document does not constitute legal advice or legal representation.") _body(doc, f"Next Review Date: {next_review}") out = Path(output_path) diff --git a/scripts/document_gen/templates/calea_ixc_ss7_generator.py b/scripts/document_gen/templates/calea_ixc_ss7_generator.py index 2471260..a0cb10e 100644 --- a/scripts/document_gen/templates/calea_ixc_ss7_generator.py +++ b/scripts/document_gen/templates/calea_ixc_ss7_generator.py @@ -209,7 +209,9 @@ def generate_calea_ixc_ss7( _body(doc, f"{signatory_title}, {entity_name}") _body(doc, f"Effective Date: {effective}") if frn: _body(doc, f"FRN: {frn}") - _body(doc, f"Reviewed By: {reviewer_name}, {reviewer_company}") + _body(doc, f"Document prepared by {reviewer_name}, {reviewer_company}") + _body(doc, "Performance West Inc. is a regulatory compliance consulting firm, not a law firm. " + "This document does not constitute legal advice or legal representation.") _body(doc, f"Next Review Date: {next_review}") out = Path(output_path) diff --git a/scripts/document_gen/templates/calea_satellite_generator.py b/scripts/document_gen/templates/calea_satellite_generator.py index 91134ed..2948d95 100644 --- a/scripts/document_gen/templates/calea_satellite_generator.py +++ b/scripts/document_gen/templates/calea_satellite_generator.py @@ -210,7 +210,9 @@ def generate_calea_satellite( _body(doc, f"{signatory_title}, {entity_name}") _body(doc, f"Effective Date: {effective}") if frn: _body(doc, f"FRN: {frn}") - _body(doc, f"Reviewed By: {reviewer_name}, {reviewer_company}") + _body(doc, f"Document prepared by {reviewer_name}, {reviewer_company}") + _body(doc, "Performance West Inc. is a regulatory compliance consulting firm, not a law firm. " + "This document does not constitute legal advice or legal representation.") _body(doc, f"Next Review Date: {next_review}") out = Path(output_path) diff --git a/scripts/document_gen/templates/calea_ssi_generator.py b/scripts/document_gen/templates/calea_ssi_generator.py index bbd0053..d189729 100644 --- a/scripts/document_gen/templates/calea_ssi_generator.py +++ b/scripts/document_gen/templates/calea_ssi_generator.py @@ -298,7 +298,9 @@ def generate_calea_ssi_plan( _body(doc, f"Effective Date: {effective}") if frn: _body(doc, f"FRN: {frn}") - _body(doc, f"Reviewed By: {reviewer_name}, {reviewer_company}") + _body(doc, f"Document prepared by {reviewer_name}, {reviewer_company}") + _body(doc, "Performance West Inc. is a regulatory compliance consulting firm, not a law firm. " + "This document does not constitute legal advice or legal representation.") _body(doc, f"Next Review Date: {next_review}") out = Path(output_path) diff --git a/scripts/document_gen/templates/calea_wireless_generator.py b/scripts/document_gen/templates/calea_wireless_generator.py index e3bda57..c0852d6 100644 --- a/scripts/document_gen/templates/calea_wireless_generator.py +++ b/scripts/document_gen/templates/calea_wireless_generator.py @@ -221,7 +221,9 @@ def generate_calea_wireless( _body(doc, f"{signatory_title}, {entity_name}") _body(doc, f"Effective Date: {effective}") if frn: _body(doc, f"FRN: {frn}") - _body(doc, f"Reviewed By: {reviewer_name}, {reviewer_company}") + _body(doc, f"Document prepared by {reviewer_name}, {reviewer_company}") + _body(doc, "Performance West Inc. is a regulatory compliance consulting firm, not a law firm. " + "This document does not constitute legal advice or legal representation.") _body(doc, f"Next Review Date: {next_review}") out = Path(output_path) diff --git a/scripts/document_gen/templates/calea_wireless_mvno_generator.py b/scripts/document_gen/templates/calea_wireless_mvno_generator.py index 4afb67a..2fad204 100644 --- a/scripts/document_gen/templates/calea_wireless_mvno_generator.py +++ b/scripts/document_gen/templates/calea_wireless_mvno_generator.py @@ -219,7 +219,9 @@ def generate_calea_wireless_mvno( _body(doc, f"{signatory_title}, {entity_name}") _body(doc, f"Effective Date: {effective}") if frn: _body(doc, f"FRN: {frn}") - _body(doc, f"Reviewed By: {reviewer_name}, {reviewer_company}") + _body(doc, f"Document prepared by {reviewer_name}, {reviewer_company}") + _body(doc, "Performance West Inc. is a regulatory compliance consulting firm, not a law firm. " + "This document does not constitute legal advice or legal representation.") _body(doc, f"Next Review Date: {next_review}") out = Path(output_path) diff --git a/scripts/document_gen/templates/cpni_audio_bridge_generator.py b/scripts/document_gen/templates/cpni_audio_bridge_generator.py index ae5ff60..cddbe37 100644 --- a/scripts/document_gen/templates/cpni_audio_bridge_generator.py +++ b/scripts/document_gen/templates/cpni_audio_bridge_generator.py @@ -256,6 +256,18 @@ def generate_cpni_audio_bridge( dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10) _sp(dp, after=2) + # ── Consulting Disclosure ───────────────────────────────────── + doc.add_paragraph() # spacer + disc_p = doc.add_paragraph() + disc_r = disc_p.add_run( + "Document prepared with assistance from Performance West Inc., " + "a regulatory compliance consulting firm. Performance West Inc. is not a law firm " + "and this document does not constitute legal advice or legal representation." + ) + disc_r.font.size = Pt(7) + disc_r.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + disc_r.font.italic = True + out = Path(output_path) out.parent.mkdir(parents=True, exist_ok=True) doc.save(str(out)) diff --git a/scripts/document_gen/templates/cpni_cert_letter_generator.py b/scripts/document_gen/templates/cpni_cert_letter_generator.py index 73d0bcb..0df7a9f 100644 --- a/scripts/document_gen/templates/cpni_cert_letter_generator.py +++ b/scripts/document_gen/templates/cpni_cert_letter_generator.py @@ -580,6 +580,18 @@ def generate_cpni_cert_letter( sig_email_p.add_run(f"Email: {contact_email}").font.size = Pt(10) _set_spacing(sig_email_p, after_pt=2) + # ── Consulting Disclosure ───────────────────────────────────── + doc.add_paragraph() # spacer + disc_p = doc.add_paragraph() + disc_r = disc_p.add_run( + "Document prepared with assistance from Performance West Inc., " + "a regulatory compliance consulting firm. Performance West Inc. is not a law firm " + "and this document does not constitute legal advice or legal representation." + ) + disc_r.font.size = Pt(7) + disc_r.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + disc_r.font.italic = True + # ── Save ────────────────────────────────────────────────────── output = Path(output_path) output.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/document_gen/templates/cpni_clec_generator.py b/scripts/document_gen/templates/cpni_clec_generator.py index e46867e..7bc9480 100644 --- a/scripts/document_gen/templates/cpni_clec_generator.py +++ b/scripts/document_gen/templates/cpni_clec_generator.py @@ -359,6 +359,18 @@ def generate_cpni_clec( dp.add_run(f"Date: {today}").font.size = Pt(10) _set_spacing(dp, after_pt=2) + # ── Consulting Disclosure ───────────────────────────────────── + doc.add_paragraph() # spacer + disc_p = doc.add_paragraph() + disc_r = disc_p.add_run( + "Document prepared with assistance from Performance West Inc., " + "a regulatory compliance consulting firm. Performance West Inc. is not a law firm " + "and this document does not constitute legal advice or legal representation." + ) + disc_r.font.size = Pt(7) + disc_r.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + disc_r.font.italic = True + out = Path(output_path) out.parent.mkdir(parents=True, exist_ok=True) doc.save(str(out)) diff --git a/scripts/document_gen/templates/cpni_clec_reseller_generator.py b/scripts/document_gen/templates/cpni_clec_reseller_generator.py index 463a50d..8ff3ee2 100644 --- a/scripts/document_gen/templates/cpni_clec_reseller_generator.py +++ b/scripts/document_gen/templates/cpni_clec_reseller_generator.py @@ -300,6 +300,18 @@ def generate_cpni_clec_reseller( dp.add_run(f"Date: {today}").font.size = Pt(10) _set_spacing(dp, after_pt=2) + # ── Consulting Disclosure ───────────────────────────────────── + doc.add_paragraph() # spacer + disc_p = doc.add_paragraph() + disc_r = disc_p.add_run( + "Document prepared with assistance from Performance West Inc., " + "a regulatory compliance consulting firm. Performance West Inc. is not a law firm " + "and this document does not constitute legal advice or legal representation." + ) + disc_r.font.size = Pt(7) + disc_r.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + disc_r.font.italic = True + out = Path(output_path) out.parent.mkdir(parents=True, exist_ok=True) doc.save(str(out)) diff --git a/scripts/document_gen/templates/cpni_ixc_generator.py b/scripts/document_gen/templates/cpni_ixc_generator.py index ca8b939..527bead 100644 --- a/scripts/document_gen/templates/cpni_ixc_generator.py +++ b/scripts/document_gen/templates/cpni_ixc_generator.py @@ -282,6 +282,18 @@ def generate_cpni_ixc( dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10) _sp(dp, after=2) + # ── Consulting Disclosure ───────────────────────────────────── + doc.add_paragraph() # spacer + disc_p = doc.add_paragraph() + disc_r = disc_p.add_run( + "Document prepared with assistance from Performance West Inc., " + "a regulatory compliance consulting firm. Performance West Inc. is not a law firm " + "and this document does not constitute legal advice or legal representation." + ) + disc_r.font.size = Pt(7) + disc_r.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + disc_r.font.italic = True + out = Path(output_path) out.parent.mkdir(parents=True, exist_ok=True) doc.save(str(out)) diff --git a/scripts/document_gen/templates/cpni_ixc_reseller_generator.py b/scripts/document_gen/templates/cpni_ixc_reseller_generator.py index 8981e6c..f0d6f90 100644 --- a/scripts/document_gen/templates/cpni_ixc_reseller_generator.py +++ b/scripts/document_gen/templates/cpni_ixc_reseller_generator.py @@ -263,6 +263,18 @@ def generate_cpni_ixc_reseller( dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10) _sp(dp, after=2) + # ── Consulting Disclosure ───────────────────────────────────── + doc.add_paragraph() # spacer + disc_p = doc.add_paragraph() + disc_r = disc_p.add_run( + "Document prepared with assistance from Performance West Inc., " + "a regulatory compliance consulting firm. Performance West Inc. is not a law firm " + "and this document does not constitute legal advice or legal representation." + ) + disc_r.font.size = Pt(7) + disc_r.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + disc_r.font.italic = True + out = Path(output_path) out.parent.mkdir(parents=True, exist_ok=True) doc.save(str(out)) diff --git a/scripts/document_gen/templates/cpni_private_line_generator.py b/scripts/document_gen/templates/cpni_private_line_generator.py index 0c8af3d..a878bff 100644 --- a/scripts/document_gen/templates/cpni_private_line_generator.py +++ b/scripts/document_gen/templates/cpni_private_line_generator.py @@ -218,6 +218,18 @@ def generate_cpni_private_line( dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10) _sp(dp, after=2) + # ── Consulting Disclosure ───────────────────────────────────── + doc.add_paragraph() # spacer + disc_p = doc.add_paragraph() + disc_r = disc_p.add_run( + "Document prepared with assistance from Performance West Inc., " + "a regulatory compliance consulting firm. Performance West Inc. is not a law firm " + "and this document does not constitute legal advice or legal representation." + ) + disc_r.font.size = Pt(7) + disc_r.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + disc_r.font.italic = True + out = Path(output_path) out.parent.mkdir(parents=True, exist_ok=True) doc.save(str(out)) diff --git a/scripts/document_gen/templates/cpni_procedure_statement_generator.py b/scripts/document_gen/templates/cpni_procedure_statement_generator.py index 1228bfe..2a9bb7e 100644 --- a/scripts/document_gen/templates/cpni_procedure_statement_generator.py +++ b/scripts/document_gen/templates/cpni_procedure_statement_generator.py @@ -22,7 +22,7 @@ Canonical section outline: 9. Enforcement and Penalties 10. Contact Information -Footer: Effective Date / Signatory / Reviewed By / Next Review Date. +Footer: Effective Date / Signatory / Prepared By / Next Review Date. Usage: from scripts.document_gen.templates.cpni_procedure_statement_generator import ( @@ -248,9 +248,23 @@ def generate_cpni_procedure_statement( title_suffix = f", {signatory_title}" if signatory_title else "" _body(doc, f"Signatory: {signatory_name}{title_suffix}, {entity_name}") if reviewer_name: - _body(doc, f"Reviewed By: {reviewer_name}, {reviewer_company}") + _body(doc, f"Document prepared by {reviewer_name}, {reviewer_company}") + _body(doc, "Performance West Inc. is a regulatory compliance consulting firm, not a law firm. " + "This document does not constitute legal advice or legal representation.") _body(doc, f"Next Review Date: {next_review}") + # ── Consulting Disclosure ───────────────────────────────────── + doc.add_paragraph() # spacer + disc_p = doc.add_paragraph() + disc_r = disc_p.add_run( + "Document prepared with assistance from Performance West Inc., " + "a regulatory compliance consulting firm. Performance West Inc. is not a law firm " + "and this document does not constitute legal advice or legal representation." + ) + disc_r.font.size = Pt(7) + disc_r.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + disc_r.font.italic = True + out = Path(output_path) out.parent.mkdir(parents=True, exist_ok=True) doc.save(str(out)) diff --git a/scripts/document_gen/templates/cpni_satellite_generator.py b/scripts/document_gen/templates/cpni_satellite_generator.py index 62cd003..76cc7c5 100644 --- a/scripts/document_gen/templates/cpni_satellite_generator.py +++ b/scripts/document_gen/templates/cpni_satellite_generator.py @@ -265,6 +265,18 @@ def generate_cpni_satellite( dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10) _sp(dp, after=2) + # ── Consulting Disclosure ───────────────────────────────────── + doc.add_paragraph() # spacer + disc_p = doc.add_paragraph() + disc_r = disc_p.add_run( + "Document prepared with assistance from Performance West Inc., " + "a regulatory compliance consulting firm. Performance West Inc. is not a law firm " + "and this document does not constitute legal advice or legal representation." + ) + disc_r.font.size = Pt(7) + disc_r.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + disc_r.font.italic = True + out = Path(output_path) out.parent.mkdir(parents=True, exist_ok=True) doc.save(str(out)) diff --git a/scripts/document_gen/templates/cpni_wireless_generator.py b/scripts/document_gen/templates/cpni_wireless_generator.py index 54b639e..045cee8 100644 --- a/scripts/document_gen/templates/cpni_wireless_generator.py +++ b/scripts/document_gen/templates/cpni_wireless_generator.py @@ -275,6 +275,18 @@ def generate_cpni_wireless( dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10) _sp(dp, after=2) + # ── Consulting Disclosure ───────────────────────────────────── + doc.add_paragraph() # spacer + disc_p = doc.add_paragraph() + disc_r = disc_p.add_run( + "Document prepared with assistance from Performance West Inc., " + "a regulatory compliance consulting firm. Performance West Inc. is not a law firm " + "and this document does not constitute legal advice or legal representation." + ) + disc_r.font.size = Pt(7) + disc_r.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + disc_r.font.italic = True + out = Path(output_path) out.parent.mkdir(parents=True, exist_ok=True) doc.save(str(out)) diff --git a/scripts/document_gen/templates/cpni_wireless_mvno_generator.py b/scripts/document_gen/templates/cpni_wireless_mvno_generator.py index fb57aac..2b6ece5 100644 --- a/scripts/document_gen/templates/cpni_wireless_mvno_generator.py +++ b/scripts/document_gen/templates/cpni_wireless_mvno_generator.py @@ -266,6 +266,18 @@ def generate_cpni_wireless_mvno( dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10) _sp(dp, after=2) + # ── Consulting Disclosure ───────────────────────────────────── + doc.add_paragraph() # spacer + disc_p = doc.add_paragraph() + disc_r = disc_p.add_run( + "Document prepared with assistance from Performance West Inc., " + "a regulatory compliance consulting firm. Performance West Inc. is not a law firm " + "and this document does not constitute legal advice or legal representation." + ) + disc_r.font.size = Pt(7) + disc_r.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + disc_r.font.italic = True + out = Path(output_path) out.parent.mkdir(parents=True, exist_ok=True) doc.save(str(out)) diff --git a/scripts/document_gen/templates/form_499a_discontinuance_letter_generator.py b/scripts/document_gen/templates/form_499a_discontinuance_letter_generator.py index 27d4750..39e7d89 100644 --- a/scripts/document_gen/templates/form_499a_discontinuance_letter_generator.py +++ b/scripts/document_gen/templates/form_499a_discontinuance_letter_generator.py @@ -149,7 +149,7 @@ def generate_discontinuance_letter( # Paragraph 1: Purpose _add_body( doc, - f"On behalf of {entity_name}" + f"At the direction of {entity_name}" f"{' (Filer ID: ' + filer_id + ')' if filer_id else ''}" f"{' (FRN: ' + frn + ')' if frn else ''}, " f"this letter serves as a formal request to deactivate the above-referenced " @@ -283,8 +283,8 @@ def generate_discontinuance_letter( _add_body(doc, "", size=Pt(6)) _add_body( doc, - f"Prepared by Performance West Inc. on behalf of {entity_name}. " - f"Generated {today_str}.", + f"Document prepared by Performance West Inc., a regulatory compliance consulting firm (not a law firm), " + f"at the direction of {entity_name}. Generated {today_str}.", italic=True, size=Pt(8), color=RGBColor(0x99, 0x99, 0x99), ) diff --git a/scripts/document_gen/templates/rmd_exhibit_a_generator.py b/scripts/document_gen/templates/rmd_exhibit_a_generator.py index 4a71752..c53135b 100644 --- a/scripts/document_gen/templates/rmd_exhibit_a_generator.py +++ b/scripts/document_gen/templates/rmd_exhibit_a_generator.py @@ -556,10 +556,10 @@ def generate_exhibit_a( "Responds promptly to FCC deficiency notices, curing issues within specified timeframes to avoid RMD removal or traffic blocking.", ]) _add_body(doc, ( - "Noncompliance risks under the 2025 RMD R&O include a base forfeiture " - "of $10,000 for false or inaccurate information and $1,000 per day " - "until cured for failure to update RMD information within 10 " - "business days of a material change." + f"{entity_abbr} acknowledges that under the 2025 RMD R&O, noncompliance " + "penalties include a base forfeiture of $10,000 for false or inaccurate " + "information and $1,000 per day until cured for failure to update RMD " + "information within 10 business days of a material change." )) # ── 6. Future Enhancements ── @@ -610,6 +610,18 @@ def generate_exhibit_a( _add_body(doc, entity_name) _add_body(doc, f"Date: {today}") + # ── Consulting Disclosure ───────────────────────────────────── + doc.add_paragraph() # spacer + disc_p = doc.add_paragraph() + disc_r = disc_p.add_run( + "Document prepared with assistance from Performance West Inc., " + "a regulatory compliance consulting firm. Performance West Inc. is not a law firm " + "and this document does not constitute legal advice or legal representation." + ) + disc_r.font.size = Pt(7) + disc_r.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + disc_r.font.italic = True + # Save output = Path(output_path) output.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/document_gen/templates/rmd_letter_generator.py b/scripts/document_gen/templates/rmd_letter_generator.py index b228924..ba7c159 100644 --- a/scripts/document_gen/templates/rmd_letter_generator.py +++ b/scripts/document_gen/templates/rmd_letter_generator.py @@ -666,6 +666,18 @@ def generate_rmd_letter( italic=True, ) + # ── Consulting Disclosure ───────────────────────────────────── + doc.add_paragraph() # spacer + disc_p = doc.add_paragraph() + disc_r = disc_p.add_run( + "Document prepared with assistance from Performance West Inc., " + "a regulatory compliance consulting firm. Performance West Inc. is not a law firm " + "and this document does not constitute legal advice or legal representation." + ) + disc_r.font.size = Pt(7) + disc_r.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + disc_r.font.italic = True + # Save output = Path(output_path) output.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/listmonk-bounce-sync.py b/scripts/listmonk-bounce-sync.py new file mode 100644 index 0000000..2743243 --- /dev/null +++ b/scripts/listmonk-bounce-sync.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +Scan Postfix mail.log for bounced campaign emails and insert into Listmonk DB. + +Listmonk's /webhooks/bounce endpoint silently ignores bounces it can't match +to a subscriber. This script queries the subscriber table directly and inserts +bounces with proper subscriber_id foreign keys. + +Idempotent — skips emails that already have a bounce record. + +Usage: + python3 listmonk-bounce-sync.py # scan /var/log/mail.log + python3 listmonk-bounce-sync.py /var/log/mail.log.1 # scan rotated log + python3 listmonk-bounce-sync.py --dry-run # show what would be reported +""" +import re +import sys +import subprocess +from pathlib import Path + +CAMPAIGN_SENDERS = {"noreply@performancewest.net", "info@performancewest.net"} +DOCKER_PSQL = [ + "docker", "exec", "-i", "performancewest-api-postgres-1", + "psql", "-U", "pw", "-d", "listmonk", "-t", "-A", +] + +# Regex patterns +QID_RE = re.compile(r"postfix/\w+\[\d+\]: ([A-Z0-9]+):") +FROM_RE = re.compile(r"from=<([^>]*)>") +TO_RE = re.compile(r"to=<([^>]*)>") +DSN_RE = re.compile(r"dsn=(\d\.\d+\.\d+)") + + +def run_sql(sql: str) -> str: + r = subprocess.run(DOCKER_PSQL, input=sql, capture_output=True, text=True, timeout=30) + return r.stdout.strip() + + +def scan_log(log_path: str) -> list: + """Scan mail.log for bounced campaign emails. Returns list of dicts.""" + campaign_qids = set() + bounces = [] + + with open(log_path) as f: + for line in f: + qid_match = QID_RE.search(line) + if not qid_match: + continue + qid = qid_match.group(1) + + from_match = FROM_RE.search(line) + if from_match and from_match.group(1) in CAMPAIGN_SENDERS: + campaign_qids.add(qid) + + if "status=bounced" in line and qid in campaign_qids: + to_match = TO_RE.search(line) + dsn_match = DSN_RE.search(line) + if to_match: + bounces.append({ + "email": to_match.group(1).lower(), + "type": "hard", + "dsn": dsn_match.group(1) if dsn_match else "", + }) + + if "status=deferred" in line and qid in campaign_qids: + if re.search(r"said: 5\d\d ", line): + to_match = TO_RE.search(line) + dsn_match = DSN_RE.search(line) + if to_match: + bounces.append({ + "email": to_match.group(1).lower(), + "type": "soft", + "dsn": dsn_match.group(1) if dsn_match else "", + }) + + # Deduplicate + seen = set() + unique = [] + for b in bounces: + if b["email"] not in seen: + seen.add(b["email"]) + unique.append(b) + return unique + + +def main(): + dry_run = "--dry-run" in sys.argv + log_files = [a for a in sys.argv[1:] if not a.startswith("--")] + if not log_files: + log_files = ["/var/log/mail.log"] + + for log_path in log_files: + if not Path(log_path).exists(): + print(f"Not found: {log_path}", file=sys.stderr) + continue + + print(f"Scanning {log_path}...") + bounces = scan_log(log_path) + print(f" Found {len(bounces)} unique bounced emails") + + if not bounces: + continue + + # Get subscriber IDs for bounced emails + emails_csv = ",".join(f"'{b['email']}'" for b in bounces) + rows = run_sql(f"SELECT id, email FROM subscribers WHERE email IN ({emails_csv});") + sub_map = {} + for row in rows.strip().split("\n"): + if "|" in row: + sid, email = row.split("|", 1) + sub_map[email.strip().lower()] = int(sid.strip()) + + print(f" Matched {len(sub_map)} to Listmonk subscribers") + + # Get emails that already have bounces + if sub_map: + sids_csv = ",".join(str(sid) for sid in sub_map.values()) + existing = run_sql( + f"SELECT DISTINCT s.email FROM bounces b " + f"JOIN subscribers s ON s.id = b.subscriber_id " + f"WHERE b.subscriber_id IN ({sids_csv});" + ) + already_bounced = {e.strip().lower() for e in existing.split("\n") if e.strip()} + else: + already_bounced = set() + + print(f" Already recorded: {len(already_bounced)}") + + # Insert new bounces + inserted = 0 + skipped = 0 + no_subscriber = 0 + + for b in bounces: + email = b["email"] + if email not in sub_map: + no_subscriber += 1 + continue + if email in already_bounced: + skipped += 1 + continue + + sid = sub_map[email] + meta = f'{{"dsn": "{b["dsn"]}"}}' + bounce_type = b["type"] + + if dry_run: + print(f" [DRY] {email} (sub={sid}, {bounce_type}, dsn={b['dsn']})") + inserted += 1 + continue + + run_sql( + f"INSERT INTO bounces (subscriber_id, type, source, meta) " + f"VALUES ({sid}, '{bounce_type}', 'postfix-logscan', '{meta}');" + ) + inserted += 1 + + print(f" Inserted: {inserted}, Skipped (existing): {skipped}, No subscriber: {no_subscriber}") + + # Blocklist subscribers with hard bounces (Listmonk's own behavior) + if not dry_run and inserted > 0: + new_hard = [ + b for b in bounces + if b["type"] == "hard" + and b["email"] in sub_map + and b["email"] not in already_bounced + ] + if new_hard: + 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';" + ) + print(f" Blocklisted {len(new_hard)} hard-bounce subscribers") + + +if __name__ == "__main__": + main()