UPL-proof document templates + reliable bounce sync

Templates (22 files):
- Replace "Reviewed By" with "Document prepared by" + consulting disclaimer
- Add "not a law firm / not legal advice" footer to all CPNI, CALEA, RMD docs
- Change "on behalf of" to "at the direction of" in discontinuance letter
- Reframe RMD penalty language as client acknowledgment

Bounce sync:
- New listmonk-bounce-sync.py replaces unreliable bash tail watcher
- Scans full mail.log, matches QIDs to campaign senders, inserts directly
  into Listmonk DB with proper subscriber_id foreign keys
- Idempotent, runs via cron every 5 minutes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
justin 2026-05-21 15:06:29 -05:00
parent d39e10485f
commit ba2f6eb667
23 changed files with 367 additions and 17 deletions

View file

@ -133,7 +133,7 @@ def add_pw_footer(doc, entity_name: str = "") -> None:
p.alignment = WD_ALIGN_PARAGRAPH.CENTER p.alignment = WD_ALIGN_PARAGRAPH.CENTER
# "Prepared by Performance West Inc." text # "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.size = PW.FOOTER_SIZE
run.font.color.rgb = PW.LIGHT_GRAY run.font.color.rgb = PW.LIGHT_GRAY
run.font.name = PW.FONT_FAMILY run.font.name = PW.FONT_FAMILY

View file

@ -203,7 +203,9 @@ def generate_calea_audio_bridge(
_body(doc, f"{signatory_title}, {entity_name}") _body(doc, f"{signatory_title}, {entity_name}")
_body(doc, f"Effective Date: {effective}") _body(doc, f"Effective Date: {effective}")
if frn: _body(doc, f"FRN: {frn}") 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}") _body(doc, f"Next Review Date: {next_review}")
out = Path(output_path) out = Path(output_path)

View file

@ -240,7 +240,9 @@ def generate_calea_clec_ss7(
_body(doc, f"Effective Date: {effective}") _body(doc, f"Effective Date: {effective}")
if frn: if frn:
_body(doc, f"FRN: {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}") _body(doc, f"Next Review Date: {next_review}")
out = Path(output_path) out = Path(output_path)

View file

@ -209,7 +209,9 @@ def generate_calea_ixc_ss7(
_body(doc, f"{signatory_title}, {entity_name}") _body(doc, f"{signatory_title}, {entity_name}")
_body(doc, f"Effective Date: {effective}") _body(doc, f"Effective Date: {effective}")
if frn: _body(doc, f"FRN: {frn}") 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}") _body(doc, f"Next Review Date: {next_review}")
out = Path(output_path) out = Path(output_path)

View file

@ -210,7 +210,9 @@ def generate_calea_satellite(
_body(doc, f"{signatory_title}, {entity_name}") _body(doc, f"{signatory_title}, {entity_name}")
_body(doc, f"Effective Date: {effective}") _body(doc, f"Effective Date: {effective}")
if frn: _body(doc, f"FRN: {frn}") 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}") _body(doc, f"Next Review Date: {next_review}")
out = Path(output_path) out = Path(output_path)

View file

@ -298,7 +298,9 @@ def generate_calea_ssi_plan(
_body(doc, f"Effective Date: {effective}") _body(doc, f"Effective Date: {effective}")
if frn: if frn:
_body(doc, f"FRN: {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}") _body(doc, f"Next Review Date: {next_review}")
out = Path(output_path) out = Path(output_path)

View file

@ -221,7 +221,9 @@ def generate_calea_wireless(
_body(doc, f"{signatory_title}, {entity_name}") _body(doc, f"{signatory_title}, {entity_name}")
_body(doc, f"Effective Date: {effective}") _body(doc, f"Effective Date: {effective}")
if frn: _body(doc, f"FRN: {frn}") 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}") _body(doc, f"Next Review Date: {next_review}")
out = Path(output_path) out = Path(output_path)

View file

@ -219,7 +219,9 @@ def generate_calea_wireless_mvno(
_body(doc, f"{signatory_title}, {entity_name}") _body(doc, f"{signatory_title}, {entity_name}")
_body(doc, f"Effective Date: {effective}") _body(doc, f"Effective Date: {effective}")
if frn: _body(doc, f"FRN: {frn}") 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}") _body(doc, f"Next Review Date: {next_review}")
out = Path(output_path) out = Path(output_path)

View file

@ -256,6 +256,18 @@ def generate_cpni_audio_bridge(
dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10) dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10)
_sp(dp, after=2) _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 = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out)) doc.save(str(out))

View file

@ -580,6 +580,18 @@ def generate_cpni_cert_letter(
sig_email_p.add_run(f"Email: {contact_email}").font.size = Pt(10) sig_email_p.add_run(f"Email: {contact_email}").font.size = Pt(10)
_set_spacing(sig_email_p, after_pt=2) _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 ────────────────────────────────────────────────────── # ── Save ──────────────────────────────────────────────────────
output = Path(output_path) output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)

View file

@ -359,6 +359,18 @@ def generate_cpni_clec(
dp.add_run(f"Date: {today}").font.size = Pt(10) dp.add_run(f"Date: {today}").font.size = Pt(10)
_set_spacing(dp, after_pt=2) _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 = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out)) doc.save(str(out))

View file

@ -300,6 +300,18 @@ def generate_cpni_clec_reseller(
dp.add_run(f"Date: {today}").font.size = Pt(10) dp.add_run(f"Date: {today}").font.size = Pt(10)
_set_spacing(dp, after_pt=2) _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 = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out)) doc.save(str(out))

View file

@ -282,6 +282,18 @@ def generate_cpni_ixc(
dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10) dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10)
_sp(dp, after=2) _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 = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out)) doc.save(str(out))

View file

@ -263,6 +263,18 @@ def generate_cpni_ixc_reseller(
dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10) dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10)
_sp(dp, after=2) _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 = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out)) doc.save(str(out))

View file

@ -218,6 +218,18 @@ def generate_cpni_private_line(
dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10) dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10)
_sp(dp, after=2) _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 = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out)) doc.save(str(out))

View file

@ -22,7 +22,7 @@ Canonical section outline:
9. Enforcement and Penalties 9. Enforcement and Penalties
10. Contact Information 10. Contact Information
Footer: Effective Date / Signatory / Reviewed By / Next Review Date. Footer: Effective Date / Signatory / Prepared By / Next Review Date.
Usage: Usage:
from scripts.document_gen.templates.cpni_procedure_statement_generator import ( 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 "" title_suffix = f", {signatory_title}" if signatory_title else ""
_body(doc, f"Signatory: {signatory_name}{title_suffix}, {entity_name}") _body(doc, f"Signatory: {signatory_name}{title_suffix}, {entity_name}")
if reviewer_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}") _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 = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out)) doc.save(str(out))

View file

@ -265,6 +265,18 @@ def generate_cpni_satellite(
dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10) dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10)
_sp(dp, after=2) _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 = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out)) doc.save(str(out))

View file

@ -275,6 +275,18 @@ def generate_cpni_wireless(
dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10) dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10)
_sp(dp, after=2) _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 = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out)) doc.save(str(out))

View file

@ -266,6 +266,18 @@ def generate_cpni_wireless_mvno(
dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10) dp = doc.add_paragraph(); dp.add_run(f"Date: {today}").font.size = Pt(10)
_sp(dp, after=2) _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 = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out)) doc.save(str(out))

View file

@ -149,7 +149,7 @@ def generate_discontinuance_letter(
# Paragraph 1: Purpose # Paragraph 1: Purpose
_add_body( _add_body(
doc, doc,
f"On behalf of {entity_name}" f"At the direction of {entity_name}"
f"{' (Filer ID: ' + filer_id + ')' if filer_id else ''}" f"{' (Filer ID: ' + filer_id + ')' if filer_id else ''}"
f"{' (FRN: ' + frn + ')' if frn else ''}, " f"{' (FRN: ' + frn + ')' if frn else ''}, "
f"this letter serves as a formal request to deactivate the above-referenced " 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, "", size=Pt(6))
_add_body( _add_body(
doc, doc,
f"Prepared by Performance West Inc. on behalf of {entity_name}. " f"Document prepared by Performance West Inc., a regulatory compliance consulting firm (not a law firm), "
f"Generated {today_str}.", f"at the direction of {entity_name}. Generated {today_str}.",
italic=True, size=Pt(8), color=RGBColor(0x99, 0x99, 0x99), italic=True, size=Pt(8), color=RGBColor(0x99, 0x99, 0x99),
) )

View file

@ -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.", "Responds promptly to FCC deficiency notices, curing issues within specified timeframes to avoid RMD removal or traffic blocking.",
]) ])
_add_body(doc, ( _add_body(doc, (
"Noncompliance risks under the 2025 RMD R&O include a base forfeiture " f"{entity_abbr} acknowledges that under the 2025 RMD R&O, noncompliance "
"of $10,000 for false or inaccurate information and $1,000 per day " "penalties include a base forfeiture of $10,000 for false or inaccurate "
"until cured for failure to update RMD information within 10 " "information and $1,000 per day until cured for failure to update RMD "
"business days of a material change." "information within 10 business days of a material change."
)) ))
# ── 6. Future Enhancements ── # ── 6. Future Enhancements ──
@ -610,6 +610,18 @@ def generate_exhibit_a(
_add_body(doc, entity_name) _add_body(doc, entity_name)
_add_body(doc, f"Date: {today}") _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 # Save
output = Path(output_path) output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)

View file

@ -666,6 +666,18 @@ def generate_rmd_letter(
italic=True, 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 # Save
output = Path(output_path) output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)

View file

@ -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()