""" rmd_deficiency_campaign.py — Populate a Listmonk mailing list with carriers whose 2026 RMD filings have deficiencies, including per-carrier issue details for mail merge. Creates/updates Listmonk list 7 "FCC RMD Deficiency Alert" with subscriber attributes containing the specific issues found in their filing. Usage: python -m workers.rmd_deficiency_campaign python -m workers.rmd_deficiency_campaign --dry-run """ from __future__ import annotations import json import logging import os import sys import psycopg2 import psycopg2.extras import requests LOG = logging.getLogger("workers.rmd_deficiency_campaign") logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s", stream=sys.stdout, ) DATABASE_URL = os.environ.get("DATABASE_URL", "") LISTMONK_URL = os.environ.get("LISTMONK_URL", "https://lists.performancewest.net") LISTMONK_USER = os.environ.get("LISTMONK_USER", "api") LISTMONK_PASS = os.environ.get("LISTMONK_PASS", "") LIST_ID = 7 # "FCC RMD Deficiency Alert" LIST_NAME = "FCC RMD Deficiency Alert" # Human-readable issue labels for email template ISSUE_LABELS = { "missing_kyc": "Missing Know Your Customer (KYC) procedures — required under 2025 RMD Report & Order", "missing_material_change": "Missing 10-business-day material change update commitment — required effective Feb 5, 2026", "missing_dno": "Missing Do-Not-Originate (DNO) list enforcement — emphasized in 2026 requirements", "ss_vsp_no_shaken": "Voice Service Provider without STIR/SHAKEN implementation — VSPs must implement unless exempt", "ss_intermediate_complete": "Intermediate provider claims Complete STIR/SHAKEN — intermediates cannot sign calls", "missing_traceback": "Missing 24-hour traceback response commitment", "missing_recertification": "Missing annual recertification acknowledgment (March 1 deadline)", "missing_perjury": "Missing perjury declaration in uploaded document", "missing_stir_shaken": "Missing STIR/SHAKEN implementation details", "missing_mitigation": "Missing robocall mitigation program description", "missing_provider_id": "Missing provider identification (FRN/entity details)", "missing_classification": "Missing provider classification", "missing_enforcement": "Missing enforcement history disclosure", "xref_ss_mismatch": "STIR/SHAKEN status in document doesn't match structured data", "xref_old_document": "Uploaded document references outdated year — may not reflect 2026 requirements", "xref_name_mismatch": "Business name in uploaded document doesn't match RMD record", "no_classification": "No provider classification selected (critical)", "no_recert_date": "No recertification date on file", "ss_partial_note": "Partial STIR/SHAKEN — upstream provider should be named", } def get_deficient_carriers(conn) -> list[dict]: """Get all carriers with RMD deficiencies and their contact info.""" cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) # Pull emails from multiple sources: RMD contact_email, or fallback to # the email we scraped via the ServiceNow SP API (Phase 2 of fcc_rmd_scraper) cur.execute(""" SELECT a.rmd_number, a.frn, a.business_name, a.severity, a.total_deficiencies, a.structured_checks, a.pdf_checks, COALESCE(r.contact_email, '') AS contact_email, COALESCE(r.contact_name, '') AS contact_name, r.implementation, r.voice_service_provider, r.gateway_provider, r.intermediate_provider, r.last_recertified, r.servicenow_sys_id FROM fcc_rmd_audit_results a JOIN fcc_rmd r ON r.rmd_number = a.rmd_number WHERE a.total_deficiencies > 0 AND a.severity IN ('major', 'critical') AND (r.removed_from_rmd = FALSE OR r.removed_from_rmd IS NULL) ORDER BY a.total_deficiencies DESC """) carriers = [] for row in cur.fetchall(): row = dict(row) # Collect all issue IDs from structured + pdf checks issues = [] for check_list in [row.get("structured_checks") or [], row.get("pdf_checks") or []]: if isinstance(check_list, str): check_list = json.loads(check_list) for check in check_list: issue_id = check.get("id", "") # Skip contact email/name issues if issue_id in ("missing_contact_email", "missing_contact_name"): continue issues.append({ "id": issue_id, "label": ISSUE_LABELS.get(issue_id, check.get("label", issue_id)), "severity": check.get("severity", "major"), }) if not issues: continue # Build human-readable issue list for the email issue_bullets = [] for iss in issues: icon = "🔴" if iss["severity"] == "critical" else "🟡" if iss["severity"] == "major" else "🟢" issue_bullets.append(f"{icon} {iss['label']}") raw_email = (row.get("contact_email") or "").strip().lower() if not raw_email or "@" not in raw_email: continue # Skip carriers without email carriers.append({ "email": raw_email, "name": row.get("contact_name", ""), "company": row["business_name"], "frn": row["frn"], "rmd_number": row["rmd_number"], "severity": row["severity"], "deficiency_count": len(issues), "issues_human": "\n".join(issue_bullets), "issues_html": "