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:
parent
d39e10485f
commit
ba2f6eb667
23 changed files with 367 additions and 17 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
178
scripts/listmonk-bounce-sync.py
Normal file
178
scripts/listmonk-bounce-sync.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue