From 463c1804448ff3be36939722c8073b7f0f2a795f Mon Sep 17 00:00:00 2001 From: justin Date: Mon, 4 May 2026 08:52:07 -0500 Subject: [PATCH] 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) --- scripts/campaign_template.html | 69 +++++ scripts/document_gen/templates/_styles.py | 307 ++++++++++++++++++++++ scripts/populate_deficiency_list.py | 234 +++++++++++++++++ scripts/send_test_campaigns.py | 121 +++++++++ 4 files changed, 731 insertions(+) create mode 100644 scripts/campaign_template.html create mode 100644 scripts/document_gen/templates/_styles.py create mode 100644 scripts/populate_deficiency_list.py create mode 100644 scripts/send_test_campaigns.py diff --git a/scripts/campaign_template.html b/scripts/campaign_template.html new file mode 100644 index 0000000..769d19b --- /dev/null +++ b/scripts/campaign_template.html @@ -0,0 +1,69 @@ + +
+
+ + + + + + + + + + + + +
+
+ diff --git a/scripts/document_gen/templates/_styles.py b/scripts/document_gen/templates/_styles.py new file mode 100644 index 0000000..2cc639a --- /dev/null +++ b/scripts/document_gen/templates/_styles.py @@ -0,0 +1,307 @@ +"""Shared document styles for all Performance West DOCX generators. + +Import this module in every generator to ensure consistent typography, +spacing, and formatting across all compliance documents. + +Usage: + from scripts.document_gen.templates._styles import ( + PW, apply_doc_defaults, add_body, add_heading, add_bullets, + add_signature_block, add_page_numbers, add_horizontal_rule, + ) + +All generators should call apply_doc_defaults(doc) first, then use +the helper functions instead of building paragraphs manually. +""" +from __future__ import annotations + +import logging +from typing import Optional + +LOG = logging.getLogger("document_gen.styles") + +try: + from docx import Document + from docx.shared import Pt, Inches, RGBColor, Emu + from docx.enum.text import WD_ALIGN_PARAGRAPH + from docx.oxml.ns import qn + from docx.oxml import OxmlElement +except ImportError: + LOG.warning("python-docx not installed") + Document = None + + +# ═══════════════════════════════════════════════════════════════════════════ +# Color palette +# ═══════════════════════════════════════════════════════════════════════════ + +class PW: + """Performance West brand colors and typography constants.""" + NAVY = RGBColor(0x1A, 0x27, 0x44) if Document else None + DARK_GRAY = RGBColor(0x37, 0x41, 0x51) if Document else None + MEDIUM_GRAY = RGBColor(0x6B, 0x72, 0x80) if Document else None + LIGHT_GRAY = RGBColor(0x9C, 0xA3, 0xAF) if Document else None + BLACK = RGBColor(0x00, 0x00, 0x00) if Document else None + GREEN = RGBColor(0x05, 0x96, 0x69) if Document else None + RED = RGBColor(0xDC, 0x26, 0x26) if Document else None + + # Typography + FONT_FAMILY = "Calibri" + BODY_SIZE = Pt(9.5) if Document else None + BODY_SMALL = Pt(8.5) if Document else None + HEADING_1_SIZE = Pt(13) if Document else None + HEADING_2_SIZE = Pt(11) if Document else None + HEADING_3_SIZE = Pt(10) if Document else None + TITLE_SIZE = Pt(15) if Document else None + SUBTITLE_SIZE = Pt(10) if Document else None + FOOTER_SIZE = Pt(7.5) if Document else None + SIGNATURE_SIZE = Pt(9.5) if Document else None + + # Spacing + BODY_AFTER = Pt(8) if Document else None + HEADING_BEFORE = Pt(16) if Document else None + HEADING_AFTER = Pt(6) if Document else None + BULLET_AFTER = Pt(4) if Document else None + LINE_SPACING = 1.15 + + # Margins + MARGIN_TOP = Inches(0.9) if Document else None + MARGIN_BOTTOM = Inches(0.9) if Document else None + MARGIN_LEFT = Inches(1.1) if Document else None + MARGIN_RIGHT = Inches(1.1) if Document else None + + +# ═══════════════════════════════════════════════════════════════════════════ +# Document setup +# ═══════════════════════════════════════════════════════════════════════════ + + +def apply_doc_defaults(doc, title: str = "", entity_name: str = "") -> None: + """Apply standard margins, default font, and optional header/footer.""" + for section in doc.sections: + section.top_margin = PW.MARGIN_TOP + section.bottom_margin = PW.MARGIN_BOTTOM + section.left_margin = PW.MARGIN_LEFT + section.right_margin = PW.MARGIN_RIGHT + + # Set default font on the document's style + style = doc.styles["Normal"] + style.font.name = PW.FONT_FAMILY + style.font.size = PW.BODY_SIZE + style.font.color.rgb = PW.DARK_GRAY + style.paragraph_format.space_after = PW.BODY_AFTER + style.paragraph_format.line_spacing = PW.LINE_SPACING + + +def add_page_numbers(doc) -> None: + """Add centered page numbers to the document footer.""" + for section in doc.sections: + footer = section.footer + footer.is_linked_to_previous = False + p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + + # Page X + run = p.add_run() + run.font.size = PW.FOOTER_SIZE + run.font.color.rgb = PW.LIGHT_GRAY + run.font.name = PW.FONT_FAMILY + + fc_begin = OxmlElement("w:fldChar") + fc_begin.set(qn("w:fldCharType"), "begin") + run._element.append(fc_begin) + + r2 = p.add_run() + r2.font.size = PW.FOOTER_SIZE + r2.font.color.rgb = PW.LIGHT_GRAY + instr = OxmlElement("w:instrText") + instr.set(qn("xml:space"), "preserve") + instr.text = " PAGE " + r2._element.append(instr) + + r3 = p.add_run() + fc_end = OxmlElement("w:fldChar") + fc_end.set(qn("w:fldCharType"), "end") + r3._element.append(fc_end) + + +def add_pw_footer(doc, entity_name: str = "") -> None: + """Add 'Prepared by Performance West Inc.' footer with page numbers.""" + for section in doc.sections: + footer = section.footer + footer.is_linked_to_previous = False + p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + + # "Prepared by Performance West Inc." text + run = p.add_run("Prepared by Performance West Inc.") + run.font.size = PW.FOOTER_SIZE + run.font.color.rgb = PW.LIGHT_GRAY + run.font.name = PW.FONT_FAMILY + run.italic = True + + # Add separator and page number + sep = p.add_run(" \u2014 Page ") + sep.font.size = PW.FOOTER_SIZE + sep.font.color.rgb = PW.LIGHT_GRAY + sep.font.name = PW.FONT_FAMILY + + # Page number field + fc_begin = OxmlElement("w:fldChar") + fc_begin.set(qn("w:fldCharType"), "begin") + r_page = p.add_run() + r_page.font.size = PW.FOOTER_SIZE + r_page.font.color.rgb = PW.LIGHT_GRAY + r_page._element.append(fc_begin) + + r_instr = p.add_run() + r_instr.font.size = PW.FOOTER_SIZE + instr = OxmlElement("w:instrText") + instr.set(qn("xml:space"), "preserve") + instr.text = " PAGE " + r_instr._element.append(instr) + + r_end = p.add_run() + fc_end = OxmlElement("w:fldChar") + fc_end.set(qn("w:fldCharType"), "end") + r_end._element.append(fc_end) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Content helpers +# ═══════════════════════════════════════════════════════════════════════════ + + +def add_title(doc, text: str, subtitle: str = "") -> None: + """Add a document title (centered, navy, large).""" + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + p.paragraph_format.space_after = Pt(2) + run = p.add_run(text) + run.font.size = PW.TITLE_SIZE + run.bold = True + run.font.color.rgb = PW.NAVY + run.font.name = PW.FONT_FAMILY + + if subtitle: + p2 = doc.add_paragraph() + p2.alignment = WD_ALIGN_PARAGRAPH.CENTER + p2.paragraph_format.space_after = Pt(12) + run2 = p2.add_run(subtitle) + run2.font.size = PW.SUBTITLE_SIZE + run2.italic = True + run2.font.color.rgb = PW.MEDIUM_GRAY + run2.font.name = PW.FONT_FAMILY + + +def add_heading(doc, text: str, level: int = 1) -> None: + """Add a section heading (navy, bold).""" + p = doc.add_paragraph() + size = {1: PW.HEADING_1_SIZE, 2: PW.HEADING_2_SIZE, 3: PW.HEADING_3_SIZE}.get(level, PW.HEADING_2_SIZE) + before = PW.HEADING_BEFORE if level == 1 else Pt(10) + + p.paragraph_format.space_before = before + p.paragraph_format.space_after = PW.HEADING_AFTER + run = p.add_run(text) + run.bold = True + run.font.size = size + run.font.color.rgb = PW.NAVY + run.font.name = PW.FONT_FAMILY + + +def add_body(doc, text: str, bold: bool = False, italic: bool = False, + size=None, color=None, alignment=None) -> None: + """Add a body paragraph.""" + p = doc.add_paragraph() + p.paragraph_format.space_after = PW.BODY_AFTER + p.paragraph_format.line_spacing = PW.LINE_SPACING + if alignment: + p.alignment = alignment + run = p.add_run(text) + run.font.size = size or PW.BODY_SIZE + run.font.name = PW.FONT_FAMILY + run.font.color.rgb = color or PW.DARK_GRAY + run.bold = bold + run.italic = italic + + +def add_field_value(doc, label: str, value: str) -> None: + """Add a label: value pair on one line.""" + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(3) + p.paragraph_format.line_spacing = PW.LINE_SPACING + + run_label = p.add_run(f"{label}: ") + run_label.font.size = PW.BODY_SIZE + run_label.font.name = PW.FONT_FAMILY + run_label.font.color.rgb = PW.MEDIUM_GRAY + run_label.bold = False + + run_value = p.add_run(value) + run_value.font.size = PW.BODY_SIZE + run_value.font.name = PW.FONT_FAMILY + run_value.font.color.rgb = PW.DARK_GRAY + run_value.bold = True + + +def add_bullets(doc, items: list[str], indent: float = 0.25) -> None: + """Add a bulleted list.""" + for item in items: + p = doc.add_paragraph(style="List Bullet") + p.paragraph_format.left_indent = Inches(indent) + p.paragraph_format.space_after = PW.BULLET_AFTER + p.paragraph_format.line_spacing = PW.LINE_SPACING + p.clear() + run = p.add_run(item) + run.font.size = PW.BODY_SIZE + run.font.name = PW.FONT_FAMILY + run.font.color.rgb = PW.DARK_GRAY + + +def add_horizontal_rule(doc) -> None: + """Add a thin navy horizontal rule.""" + p = doc.add_paragraph() + p.paragraph_format.space_before = Pt(6) + p.paragraph_format.space_after = Pt(6) + pPr = p._p.get_or_add_pPr() + pBdr = OxmlElement("w:pBdr") + bottom = OxmlElement("w:bottom") + bottom.set(qn("w:val"), "single") + bottom.set(qn("w:sz"), "4") + bottom.set(qn("w:space"), "1") + bottom.set(qn("w:color"), "1A2744") + pBdr.append(bottom) + pPr.append(pBdr) + + +def add_signature_block( + doc, + signer_name: str = "", + signer_title: str = "", + entity_name: str = "", + date_str: str = "", + include_perjury: bool = False, +) -> None: + """Add a standardized signature block.""" + if include_perjury: + add_body(doc, ( + "I declare under penalty of perjury under the laws of the " + "United States of America that the foregoing is true and correct." + ), italic=True, size=PW.BODY_SMALL) + + # Signature line + add_body(doc, "", size=Pt(20)) # spacer + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(2) + run = p.add_run("_" * 45) + run.font.size = PW.SIGNATURE_SIZE + run.font.name = PW.FONT_FAMILY + run.font.color.rgb = PW.LIGHT_GRAY + + if signer_name: + add_body(doc, signer_name, bold=True, size=PW.SIGNATURE_SIZE) + if signer_title: + add_body(doc, signer_title, size=PW.BODY_SMALL, color=PW.MEDIUM_GRAY) + if entity_name: + add_body(doc, entity_name, size=PW.BODY_SMALL, color=PW.MEDIUM_GRAY) + if date_str: + add_body(doc, f"Date: {date_str}", size=PW.BODY_SMALL, color=PW.MEDIUM_GRAY) diff --git a/scripts/populate_deficiency_list.py b/scripts/populate_deficiency_list.py new file mode 100644 index 0000000..3705350 --- /dev/null +++ b/scripts/populate_deficiency_list.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Populate Listmonk with FCC RMD deficiency data for campaign sends. + +Creates a dedicated list and upserts every carrier that has major/critical +audit deficiencies. Sets subscriber attributes with issues_html so the +campaign template can render personalized issue lists. + +Skips carriers tagged as is_customer=true. +""" +import json +import subprocess +import sys +import time +import urllib.request +import urllib.error +import base64 + +AUTH = base64.b64encode(b"api:6X1rKPea61N4rZ1S65Hx5zvqzbCj30F6nvEe9oVGH_Y").decode() +API = "http://localhost:9100/api" + +ISSUE_LABELS = { + "ss_partial_note": "STIR/SHAKEN partial implementation \u2014 upstream provider not named", + "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", + "xref_name_mismatch": "Business name not found in certification document", + "xref_old_document": "Outdated certification document", + "xref_ss_mismatch": "STIR/SHAKEN status mismatch between RMD and document", + "missing_stir_shaken": "Missing STIR/SHAKEN implementation details", + "missing_enforcement": "Missing enforcement history disclosure", + "missing_provider_id": "Missing provider identification", + "no_recert_date": "No recertification date on file", +} + + +def api_call(method, path, data=None): + payload = json.dumps(data).encode() if data else None + req = urllib.request.Request( + API + path, data=payload, method=method, + headers={"Content-Type": "application/json", "Authorization": "Basic " + AUTH}, + ) + return json.loads(urllib.request.urlopen(req).read()) + + +def build_issues_html(structured_json, pdf_json): + """Merge structured + PDF checks, return HTML list of major/critical issues.""" + 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 + + severity_order = {"critical": 0, "major": 1, "minor": 2} + all_checks.sort(key=lambda c: severity_order.get(c.get("severity", "minor"), 2)) + + items = [] + ids = [] + seen = set() + for c in all_checks: + cid = c.get("id", "") + if cid in seen: + continue + seen.add(cid) + sev = c.get("severity", "minor") + if sev == "minor": + continue + label = ISSUE_LABELS.get(cid, c.get("label", cid)) + items.append("
  • " + label + "
  • ") + ids.append(cid) + + if not items: + for c in all_checks: + cid = c.get("id", "") + label = ISSUE_LABELS.get(cid, c.get("label", cid)) + items.append("
  • " + label + "
  • ") + ids.append(cid) + + html = '" + return html, ids + + +def main(): + # Query carriers with deficiencies + 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.contact_name, r.implementation, + r.last_recertified::text, r.rmd_number, + 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""", + ], capture_output=True, text=True) + + lines = [l for l in result.stdout.strip().split("\n") if l.strip()] + print(f"Found {len(lines)} carriers with major/critical deficiencies") + + # Find or create list + lists_resp = api_call("GET", "/lists") + deficiency_list_id = None + for l in lists_resp.get("data", {}).get("results", []): + if "Deficiency" in l["name"] and "2026" in l["name"]: + deficiency_list_id = l["id"] + break + + if not deficiency_list_id: + resp = api_call("POST", "/lists", { + "name": "FCC RMD Deficiency Alert 2026", + "type": "public", + "optin": "single", + "tags": ["fcc", "rmd", "deficiency", "2026"], + }) + deficiency_list_id = resp.get("data", {}).get("id") + print(f"Created list: {deficiency_list_id}") + else: + print(f"Using existing list: {deficiency_list_id}") + + added = 0 + updated = 0 + skipped_customer = 0 + errors = 0 + + for line in lines: + parts = line.split("||") + if len(parts) < 11: + continue + + frn = parts[0].strip() + company = parts[1].strip() + deficiency_count = int(parts[2].strip() or 0) + severity = parts[3].strip() + email = parts[4].strip().lower() + contact_name = parts[5].strip() + implementation = parts[6].strip() + last_recert = parts[7].strip() + rmd_number = parts[8].strip() + structured_json = parts[9].strip() + pdf_json = parts[10].strip() + + if not email or "@" not in email: + continue + + issues_html, issue_ids = build_issues_html(structured_json, pdf_json) + + attribs = { + "company": company, + "fcc_frn": frn, + "rmd_number": rmd_number, + "severity": severity, + "deficiency_count": deficiency_count, + "issues_html": issues_html, + "issue_ids": ",".join(issue_ids), + "implementation": implementation, + "last_recertified": last_recert, + } + + try: + resp = api_call("POST", "/subscribers", { + "email": email, + "name": contact_name or company, + "status": "enabled", + "lists": [deficiency_list_id], + "attribs": attribs, + "preconfirm_subscriptions": True, + }) + added += 1 + except urllib.error.HTTPError as e: + err = e.read().decode() + if "already exists" in err: + try: + search = api_call("GET", "/subscribers?query=subscribers.email%3D%27" + email.replace("'", "") + "%27&per_page=1") + results = search.get("data", {}).get("results", []) + if results: + sub_id = results[0]["id"] + existing = results[0].get("attribs", {}) + + if existing.get("is_customer"): + skipped_customer += 1 + continue + + existing.update(attribs) + api_call("PUT", f"/subscribers/{sub_id}", { + "email": email, + "name": contact_name or results[0].get("name", company), + "attribs": existing, + "status": "enabled", + }) + api_call("PUT", "/subscribers/lists", { + "ids": [sub_id], + "action": "add", + "target_list_ids": [deficiency_list_id], + "status": "confirmed", + }) + updated += 1 + except Exception as ex: + errors += 1 + else: + errors += 1 + + if (added + updated) % 200 == 0 and (added + updated) > 0: + print(f" Progress: {added} added, {updated} updated, {skipped_customer} skipped") + + time.sleep(0.05) # rate limit + + print(f"\nDone:") + print(f" Added: {added}") + print(f" Updated: {updated}") + print(f" Customers skipped: {skipped_customer}") + print(f" Errors: {errors}") + print(f" List ID: {deficiency_list_id}") + + search = api_call("GET", f"/subscribers?list_id={deficiency_list_id}&per_page=1") + total = search.get("data", {}).get("total", 0) + print(f" Total subscribers on list: {total}") + + +if __name__ == "__main__": + main() diff --git a/scripts/send_test_campaigns.py b/scripts/send_test_campaigns.py new file mode 100644 index 0000000..6ddf5f4 --- /dev/null +++ b/scripts/send_test_campaigns.py @@ -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"
  • {label}
  • ") + + 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"
  • {label}
  • ") + + issues_html = '" + + # 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 " + 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")