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).
210 lines
7.8 KiB
Python
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 →
|
|
</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()
|