new-site/scripts/workers/quarterly_499q_notify.py
justin b375385efd fix(email): add text/plain part to every transactional + telecom email
All transactional/worker senders built multipart/alternative (or mixed)
messages with ONLY an HTML part. A single-part multipart/alternative is
malformed and HTML-only mail is a spam-score signal -- the same class of
deliverability bug that hurt the campaign pipeline, but on the telecom /
filing / customer-transactional path (499-Q reminders, RMD/FCC filing
review links, intake/completion/delivery emails, commissions, etc).

- worker_email.send_worker_email: auto-derive plaintext from HTML when
  caller omits text= (fixes the shared helper for all current+future use)
- 16 rolled-their-own senders in scripts/workers/** + scripts/formation/
  document_delivery.py: attach html_to_text(...) plaintext sibling before
  the HTML part (job_server + document_delivery wrap text+html in an
  alternative sub-part so PDFs still attach to the mixed root)
- api/src/email.ts: add dependency-free htmlToText() and default
  sendEmail text to it (fixes checkout/webhook HTML-only sends)

Verified: all py files compile + import at runtime, api tsc passes,
htmlToText handles hrefs/lists/entities, 11 plaintext unit tests pass.
Telecom campaign 407 (Jun 8) was HTML-only + sent in the DKIM-broken
window -> 384 sent / 0 clicks (same junked-mail signature).
2026-06-17 21:07:40 -05:00

210 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""FCC Form 499-Q Quarterly Filing Notification Worker.
Runs daily via cron. Finds upcoming 499-Q compliance_orders and sends
reminder emails with intake links at 30, 14, and 7 days before due date.
After the client completes intake, the 499-Q handler files at USAC.
Lifecycle:
499-A filed → 499-Q orders created (by Form499ABundleHandler)
30 days before due → first reminder email
14 days before due → second reminder
7 days before due → urgent reminder
Client fills intake → worker files 499-Q
Filed → confirmation email
Cron: 0 8 * * * (daily at 8am CT)
"""
from __future__ import annotations
import json
import logging
import os
import smtplib
from datetime import date, timedelta
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from scripts._email_plaintext import html_to_text
import psycopg2
import psycopg2.extras
LOG = logging.getLogger("workers.quarterly_499q_notify")
SMTP_HOST = os.environ.get("SMTP_HOST", "co.carrierone.com")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
SMTP_USER = os.environ.get("SMTP_USER", "noreply@performancewest.net")
SMTP_PASS = os.environ.get("SMTP_PASS", "")
SMTP_FROM = os.environ.get("SMTP_FROM", "Performance West <noreply@performancewest.net>")
DOMAIN = os.environ.get("DOMAIN", "performancewest.net")
def _send_email(to: str, subject: str, html: str) -> bool:
try:
msg = MIMEMultipart("alternative")
msg["From"] = SMTP_FROM
msg["To"] = to
msg["Subject"] = subject
msg.attach(MIMEText(html_to_text(html), "plain"))
msg.attach(MIMEText(html, "html"))
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as s:
s.starttls()
s.login(SMTP_USER, SMTP_PASS)
s.send_message(msg)
return True
except Exception as exc:
LOG.warning("Email send failed to %s: %s", to, exc)
return False
def _build_reminder_email(
entity_name: str,
quarter: str,
due_date: str,
days_until: int,
order_number: str,
filer_id: str,
) -> tuple[str, str]:
"""Return (subject, html_body) for a 499-Q reminder."""
urgency = "Reminder" if days_until > 14 else "Upcoming" if days_until > 7 else "Urgent"
subject = f"{urgency}: FCC Form 499-Q ({quarter}) due {due_date}{entity_name}"
intake_url = f"https://{DOMAIN}/order/fcc-499q?order={order_number}"
html = f"""
<div style="font-family:Inter,system-ui,sans-serif;max-width:600px;margin:0 auto;color:#1f2937">
<div style="background:#1e3a5f;padding:20px 24px;border-radius:8px 8px 0 0">
<h2 style="color:#fff;margin:0;font-size:18px">FCC Form 499-Q — {quarter} Filing</h2>
</div>
<div style="padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px">
<p>Hi,</p>
<p>This is a reminder that the <strong>FCC Form 499-Q ({quarter})</strong> quarterly
filing for <strong>{entity_name}</strong> (Filer ID: {filer_id}) is due
<strong>{due_date}</strong>{f' — in {days_until} days' if days_until > 0 else ' — today'}.</p>
<p>The 499-Q reports your projected quarterly USF contributions based on actual
telecom revenue for the prior quarter. USAC uses this to calculate your quarterly
contribution payment.</p>
<div style="text-align:center;margin:24px 0">
<a href="{intake_url}"
style="display:inline-block;padding:12px 32px;background:#1e3a5f;color:#fff;
font-weight:600;border-radius:8px;text-decoration:none;font-size:14px">
Complete 499-Q Filing &rarr;
</a>
</div>
<p style="font-size:13px;color:#6b7280">
This filing is included in your 499-A + 499-Q bundle — no additional charge.
If you need assistance, reply to this email or contact us at
<a href="mailto:ops@performancewest.net">ops@performancewest.net</a>.
</p>
{'<p style="font-size:13px;color:#dc2626;font-weight:600">⚠️ Late 499-Q filings may result in estimated USF contributions calculated by USAC, which are typically higher than actual amounts.</p>' if days_until <= 7 else ''}
</div>
</div>
"""
return subject, html
def run(dry_run: bool = False) -> dict:
"""Check for upcoming 499-Q filings and send reminders."""
conn = psycopg2.connect(os.environ["DATABASE_URL"])
today = date.today()
stats = {"checked": 0, "reminded_30d": 0, "reminded_14d": 0, "reminded_7d": 0, "skipped": 0}
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("""
SELECT co.order_number, co.customer_email, co.customer_name,
co.intake_data, co.telecom_entity_id
FROM compliance_orders co
WHERE co.service_slug = 'fcc-499q'
AND co.payment_status = 'paid'
AND co.intake_data IS NOT NULL
AND (co.intake_data->>'intake_completed')::boolean IS NOT TRUE
""")
rows = cur.fetchall()
stats["checked"] = len(rows)
for row in rows:
intake = row["intake_data"] or {}
due_str = intake.get("due_date")
if not due_str:
continue
due = date.fromisoformat(due_str)
days_until = (due - today).days
quarter = intake.get("quarter", "?")
entity_name = intake.get("entity_name", row["customer_name"])
filer_id = intake.get("filer_id_499", "")
email = row["customer_email"]
order_number = row["order_number"]
# Already past due — skip (handled by overdue process)
if days_until < -7:
stats["skipped"] += 1
continue
# Determine which reminder to send
reminder_key = None
if days_until <= 7 and not intake.get("reminder_sent_7d"):
reminder_key = "reminder_sent_7d"
stats["reminded_7d"] += 1
elif days_until <= 14 and not intake.get("reminder_sent_14d"):
reminder_key = "reminder_sent_14d"
stats["reminded_14d"] += 1
elif days_until <= 30 and not intake.get("reminder_sent_30d"):
reminder_key = "reminder_sent_30d"
stats["reminded_30d"] += 1
if not reminder_key:
continue
LOG.info(
"499-Q %s %s: %s due %s (%d days) — sending %s to %s",
order_number, quarter, entity_name, due_str, days_until, reminder_key, email,
)
if not dry_run:
subject, html = _build_reminder_email(
entity_name=entity_name,
quarter=quarter,
due_date=due_str,
days_until=max(days_until, 0),
order_number=order_number,
filer_id=filer_id,
)
sent = _send_email(email, subject, html)
if sent:
# Mark reminder as sent
intake[reminder_key] = True
cur.execute(
"UPDATE compliance_orders SET intake_data = %s, updated_at = now() WHERE order_number = %s",
(json.dumps(intake), order_number),
)
conn.commit()
else:
LOG.info("DRY RUN: would send %s to %s for %s", reminder_key, email, order_number)
conn.close()
LOG.info("499-Q notify: %s", stats)
return stats
def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
run(dry_run=args.dry_run)
if __name__ == "__main__":
main()