Add shared DOCX style module + campaign tools

_styles.py: Centralized typography, spacing, and formatting for all
26 DOCX generators. Calibri 9.5pt body, 1.15 line spacing, navy
headings, consistent signature blocks, page numbers, PW footer.
All generators will be migrated to use this instead of defining
their own styles.

Campaign tools:
- campaign_template.html: Styled email template for Listmonk campaigns
- populate_deficiency_list.py: Populates Listmonk with FCC deficiency data
- send_test_campaigns.py: Sends test emails with real carrier data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
justin 2026-05-04 08:52:07 -05:00
parent c9881868dd
commit 463c180444
4 changed files with 731 additions and 0 deletions

View file

@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Send test compliance gap emails to justin@performancewest.net using real carrier data."""
import json
import smtplib
import subprocess
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
ISSUE_LABELS = {
"ss_partial_note": "STIR/SHAKEN partial implementation \u2014 upstream provider not named in filing",
"ss_vsp_no_shaken": "Voice Service Provider without STIR/SHAKEN implementation",
"conflicting_classification": "Unusual provider classification \u2014 may need correction",
"missing_kyc": "Missing Know Your Customer (KYC) procedures",
"missing_material_change": "Missing 10-business-day material change update commitment",
"missing_dno": "Missing Do-Not-Originate (DNO) list enforcement",
"missing_traceback": "Missing 24-hour traceback response commitment",
"missing_recertification": "Missing annual recertification acknowledgment",
"missing_perjury": "Missing perjury declaration",
"missing_mitigation": "Missing robocall mitigation program details",
"no_classification": "No provider classification selected",
"ss_intermediate_complete": "Intermediate provider claims Complete STIR/SHAKEN",
}
# Query real carrier data
result = subprocess.run([
"docker", "exec", "performancewest-api-postgres-1", "psql", "-U", "pw",
"performancewest", "-t", "-A", "-F", "|", "-c",
"""SELECT a.frn, a.business_name, a.total_deficiencies, a.severity,
r.contact_email, r.implementation, r.last_recertified::text,
a.structured_checks::text, a.pdf_checks::text
FROM fcc_rmd_audit_results a
JOIN fcc_rmd r ON r.frn = a.frn
WHERE a.total_deficiencies > 0
AND a.severity IN ('major', 'critical')
AND r.contact_email IS NOT NULL
AND r.removed_from_rmd = FALSE
ORDER BY a.total_deficiencies DESC
LIMIT 5"""
], capture_output=True, text=True)
with open("/tmp/campaign_template.html") as f:
template = f.read()
for line in result.stdout.strip().split("\n"):
if not line.strip():
continue
parts = line.split("|")
if len(parts) < 9:
continue
frn = parts[0].strip()
company = parts[1].strip()
deficiency_count = parts[2].strip()
severity = parts[3].strip()
email_orig = parts[4].strip()
implementation = parts[5].strip()
last_recert = parts[6].strip()
structured_json = parts[7].strip()
pdf_json = parts[8].strip()
# Merge both structured and PDF checks, prioritize major/critical
all_checks = []
for cj in [structured_json, pdf_json]:
try:
checks = json.loads(cj)
if isinstance(checks, list):
all_checks.extend(checks)
except Exception:
pass
# Sort: critical first, then major, then minor — skip minor for email
severity_order = {"critical": 0, "major": 1, "minor": 2}
all_checks.sort(key=lambda c: severity_order.get(c.get("severity", "minor"), 2))
items = []
seen_ids = set()
for c in all_checks:
cid = c.get("id", "")
if cid in seen_ids:
continue
seen_ids.add(cid)
sev = c.get("severity", "minor")
if sev == "minor":
continue # skip minor issues in email
label = ISSUE_LABELS.get(cid, c.get("label", cid))
items.append(f"<li>{label}</li>")
if not items:
# If all were minor, include them anyway
for c in all_checks:
cid = c.get("id", "")
label = ISSUE_LABELS.get(cid, c.get("label", cid))
items.append(f"<li>{label}</li>")
issues_html = '<ul style="margin:0;padding:0 0 0 16px">' + "".join(items[:6]) + "</ul>"
# Build the email from template
body = template
body = body.replace("{{ .Subscriber.Name }}", "there")
body = body.replace("{{ .Subscriber.Attribs.company }}", company)
body = body.replace("{{ .Subscriber.Attribs.fcc_frn }}", frn)
body = body.replace("{{ .Subscriber.Attribs.issues_html }}", issues_html)
body = body.replace("{{ .Subscriber.Attribs.implementation }}", implementation or "Unknown")
body = body.replace("{{ .Subscriber.Attribs.last_recertified }}", last_recert or "Unknown")
body = body.replace("{{ UnsubscribeURL }}", "#")
msg = MIMEMultipart("alternative")
msg["From"] = "Performance West <noreply@performancewest.net>"
msg["To"] = "justin@performancewest.net"
msg["Subject"] = f"[TEST] FCC Compliance Alert - {company}"
msg["Reply-To"] = "info@performancewest.net"
msg.attach(MIMEText(body, "html"))
with smtplib.SMTP("email-smtp.us-east-2.amazonaws.com", 587, timeout=30) as s:
s.starttls()
s.login("AKIAYEWLMNWPHSHQWCRD", "BKrUBud+KjyaRA1RiA26FFu1R+hqR4cpFShwbZf7RUzG")
s.send_message(msg)
print(f"Sent: {company} ({frn}) - {deficiency_count} issues (would go to {email_orig})")
print("\nDone - all test emails sent to justin@performancewest.net")