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).
This commit is contained in:
justin 2026-06-17 21:07:40 -05:00
parent 899b880e7f
commit b375385efd
19 changed files with 114 additions and 8 deletions

View file

@ -34,6 +34,36 @@ function getTransporter(): nodemailer.Transporter {
return _transporter; return _transporter;
} }
// ─── HTML → plaintext (dependency-free) ─────────────────────────────────────
// A multipart/alternative (which nodemailer builds when both html+text are
// given) with ONLY an HTML part is malformed, and HTML-only mail is a spam
// signal. When a caller doesn't supply `text`, derive a readable plaintext
// fallback from the HTML so every message ships a proper text/plain part.
export function htmlToText(html: string): string {
if (!html) return "";
let s = html;
// Drop non-content blocks entirely.
s = s.replace(/<(script|style|head)[\s\S]*?<\/\1>/gi, "");
// <a href="url">text</a> -> text (url)
s = s.replace(/<a\b[^>]*\bhref\s*=\s*["']?([^"'>\s]+)["']?[^>]*>([\s\S]*?)<\/a>/gi,
(_m, url, txt) => {
const t = txt.replace(/<[^>]+>/g, "").trim();
return t && !url.startsWith("mailto:") && t !== url ? `${t} (${url})` : (t || url);
});
// List items -> "- item"; block/line breaks -> newlines.
s = s.replace(/<li\b[^>]*>/gi, "\n- ");
s = s.replace(/<\/(p|div|tr|h[1-6]|li|ul|ol|table)>/gi, "\n");
s = s.replace(/<br\s*\/?>/gi, "\n");
// Strip remaining tags, unescape common entities, collapse whitespace.
s = s.replace(/<[^>]+>/g, "");
s = s.replace(/&nbsp;/gi, " ").replace(/&amp;/gi, "&").replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">").replace(/&quot;/gi, '"').replace(/&#39;/gi, "'")
.replace(/&rarr;/gi, "->").replace(/&middot;/gi, "-").replace(/&sect;/gi, "Section");
s = s.replace(/[ \t]+/g, " ").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
s = s.split("\n").map((line) => line.trim()).join("\n");
return s.trim();
}
// ─── Generic send ───────────────────────────────────────────────────────────── // ─── Generic send ─────────────────────────────────────────────────────────────
export async function sendEmail(opts: { to: string; subject: string; html: string; text?: string; cc?: string }): Promise<void> { export async function sendEmail(opts: { to: string; subject: string; html: string; text?: string; cc?: string }): Promise<void> {
@ -44,7 +74,7 @@ export async function sendEmail(opts: { to: string; subject: string; html: strin
...(opts.cc ? { cc: opts.cc } : {}), ...(opts.cc ? { cc: opts.cc } : {}),
subject: opts.subject, subject: opts.subject,
html: opts.html, html: opts.html,
text: opts.text || "", text: opts.text || htmlToText(opts.html),
}); });
} }

View file

@ -267,9 +267,13 @@ def _build_email(
msg["Subject"] = f"Your {type_display} Has Been Filed — {entity_name}" msg["Subject"] = f"Your {type_display} Has Been Filed — {entity_name}"
msg["Reply-To"] = FROM_EMAIL msg["Reply-To"] = FROM_EMAIL
# HTML body # HTML body wrapped in alternative with a plaintext sibling (HTML-only is
html_part = email.mime.text.MIMEText(html_body, "html", "utf-8") # malformed inside multipart + a spam signal); documents attach to mixed root.
msg.attach(html_part) from scripts._email_plaintext import html_to_text
alt = email.mime.multipart.MIMEMultipart("alternative")
alt.attach(email.mime.text.MIMEText(html_to_text(html_body), "plain", "utf-8"))
alt.attach(email.mime.text.MIMEText(html_body, "html", "utf-8"))
msg.attach(alt)
# Attach documents # Attach documents
for doc_path in documents: for doc_path in documents:

View file

@ -23,6 +23,8 @@ from datetime import datetime, timedelta
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from scripts._email_plaintext import html_to_text
import psycopg2 import psycopg2
import psycopg2.extras import psycopg2.extras
@ -81,6 +83,7 @@ def _send(to_email: str, subject: str, body_html: str) -> bool:
msg["From"] = FROM_EMAIL msg["From"] = FROM_EMAIL
msg["To"] = to_email msg["To"] = to_email
msg["Bcc"] = ADMIN_EMAIL msg["Bcc"] = ADMIN_EMAIL
msg.attach(MIMEText(html_to_text(body_html), "plain"))
msg.attach(MIMEText(body_html, "html")) msg.attach(MIMEText(body_html, "html"))
try: try:
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server: with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:

View file

@ -26,6 +26,8 @@ from collections import defaultdict
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from scripts._email_plaintext import html_to_text
from typing import Any from typing import Any
import psycopg2 import psycopg2
@ -114,6 +116,7 @@ def _send_email(to: str, subject: str, body_html: str) -> None:
msg["From"] = FROM_EMAIL msg["From"] = FROM_EMAIL
msg["To"] = to msg["To"] = to
msg["Subject"] = subject msg["Subject"] = subject
msg.attach(MIMEText(html_to_text(body_html), "plain"))
msg.attach(MIMEText(body_html, "html")) msg.attach(MIMEText(body_html, "html"))
try: try:

View file

@ -18,6 +18,8 @@ from datetime import datetime, timedelta, timezone
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from scripts._email_plaintext import html_to_text
import psycopg2 import psycopg2
LOG = logging.getLogger("workers.completion_emails") LOG = logging.getLogger("workers.completion_emails")
@ -37,6 +39,7 @@ def send_email(to: str, subject: str, html: str):
msg["From"] = f"Performance West <{SMTP_FROM}>" msg["From"] = f"Performance West <{SMTP_FROM}>"
msg["To"] = to msg["To"] = to
msg["Subject"] = subject msg["Subject"] = subject
msg.attach(MIMEText(html_to_text(html), "plain"))
msg.attach(MIMEText(html, "html")) msg.attach(MIMEText(html, "html"))
import os as _smtp_os import os as _smtp_os

View file

@ -354,10 +354,13 @@ def _send_email(to_address: str, subject: str, html_body: str) -> None:
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from scripts._email_plaintext import html_to_text
msg = MIMEMultipart("alternative") msg = MIMEMultipart("alternative")
msg["Subject"] = subject msg["Subject"] = subject
msg["From"] = SMTP_FROM msg["From"] = SMTP_FROM
msg["To"] = to_address msg["To"] = to_address
msg.attach(MIMEText(html_to_text(html_body), "plain"))
msg.attach(MIMEText(html_body, "html")) msg.attach(MIMEText(html_body, "html"))
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server: with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:

View file

@ -52,6 +52,8 @@ from datetime import datetime, timezone
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from scripts._email_plaintext import html_to_text
import psycopg2 import psycopg2
import psycopg2.extras import psycopg2.extras
@ -112,6 +114,7 @@ def _send_email(to: str, subject: str, html: str) -> bool:
msg["From"] = SMTP_FROM msg["From"] = SMTP_FROM
msg["To"] = to msg["To"] = to
msg["Subject"] = subject msg["Subject"] = subject
msg.attach(MIMEText(html_to_text(html), "plain"))
msg.attach(MIMEText(html, "html")) msg.attach(MIMEText(html, "html"))
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as s: with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as s:
s.starttls() s.starttls()

View file

@ -930,7 +930,13 @@ def _send_instant_delivery(
f"Your {service_name} documents are ready \u2014 Order {order_number}" f"Your {service_name} documents are ready \u2014 Order {order_number}"
) )
msg["Reply-To"] = "info@performancewest.net" msg["Reply-To"] = "info@performancewest.net"
msg.attach(MIMEText(html, "html")) # text+html in an alternative sub-part (HTML-only is malformed/spam-signal),
# then PDFs/DOCX attach to the mixed root below.
from scripts._email_plaintext import html_to_text
alt = MIMEMultipart("alternative")
alt.attach(MIMEText(html_to_text(html), "plain"))
alt.attach(MIMEText(html, "html"))
msg.attach(alt)
# Download deliverables from MinIO and attach (PDF + DOCX) # Download deliverables from MinIO and attach (PDF + DOCX)
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
@ -1020,7 +1026,12 @@ def _send_filing_confirmation(
msg["To"] = customer_email msg["To"] = customer_email
msg["Subject"] = f"\u2705 Filed: {service_name} \u2014 Confirmation {confirmation_number}" msg["Subject"] = f"\u2705 Filed: {service_name} \u2014 Confirmation {confirmation_number}"
msg["Reply-To"] = "info@performancewest.net" msg["Reply-To"] = "info@performancewest.net"
msg.attach(MIMEText(html, "html")) # text+html in an alternative sub-part (HTML-only is malformed/spam-signal).
from scripts._email_plaintext import html_to_text
alt = MIMEMultipart("alternative")
alt.attach(MIMEText(html_to_text(html), "plain"))
alt.attach(MIMEText(html, "html"))
msg.attach(alt)
# Attach confirmation PDFs # Attach confirmation PDFs
pdf_paths = [p for p in minio_paths if p.lower().endswith(".pdf")] pdf_paths = [p for p in minio_paths if p.lower().endswith(".pdf")]

View file

@ -29,6 +29,8 @@ from datetime import date, timedelta
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from scripts._email_plaintext import html_to_text
import psycopg2 import psycopg2
import psycopg2.extras import psycopg2.extras
@ -48,6 +50,7 @@ def _send_email(to: str, subject: str, html: str) -> bool:
msg["From"] = SMTP_FROM msg["From"] = SMTP_FROM
msg["To"] = to msg["To"] = to
msg["Subject"] = subject msg["Subject"] = subject
msg.attach(MIMEText(html_to_text(html), "plain"))
msg.attach(MIMEText(html, "html")) msg.attach(MIMEText(html, "html"))
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as s: with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as s:
s.starttls() s.starttls()

View file

@ -60,11 +60,14 @@ def send_email(to_email: str, subject: str, html_body: str):
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from scripts._email_plaintext import html_to_text
smtp_cfg = get_smtp_config() smtp_cfg = get_smtp_config()
msg = MIMEMultipart("alternative") msg = MIMEMultipart("alternative")
msg["From"] = f"Performance West <{smtp_cfg['from_addr']}>" msg["From"] = f"Performance West <{smtp_cfg['from_addr']}>"
msg["To"] = to_email msg["To"] = to_email
msg["Subject"] = subject msg["Subject"] = subject
msg.attach(MIMEText(html_to_text(html_body), "plain"))
msg.attach(MIMEText(html_body, "html")) msg.attach(MIMEText(html_body, "html"))
with smtplib.SMTP(smtp_cfg["host"], smtp_cfg["port"]) as server: with smtplib.SMTP(smtp_cfg["host"], smtp_cfg["port"]) as server:

View file

@ -282,6 +282,8 @@ class BaseServiceHandler(ABC):
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from scripts._email_plaintext import html_to_text
first_name = customer_name.split(" ")[0] if customer_name else "there" first_name = customer_name.split(" ")[0] if customer_name else "there"
subject = f"Action Required — Complete your {service_label} intake" subject = f"Action Required — Complete your {service_label} intake"
body = ( body = (
@ -308,6 +310,7 @@ class BaseServiceHandler(ABC):
msg["From"] = smtp_from msg["From"] = smtp_from
msg["To"] = customer_email msg["To"] = customer_email
msg["Reply-To"] = "info@performancewest.net" msg["Reply-To"] = "info@performancewest.net"
msg.attach(MIMEText(html_to_text(body), "plain"))
msg.attach(MIMEText(body, "html")) msg.attach(MIMEText(body, "html"))
with smtplib.SMTP(smtp_host, smtp_port) as server: with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls() server.starttls()

View file

@ -282,6 +282,8 @@ class FCCCarrierRegistrationHandler:
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from scripts._email_plaintext import html_to_text
email = order.get("customer_email", "") email = order.get("customer_email", "")
name = order.get("customer_name", "") name = order.get("customer_name", "")
if not email: if not email:
@ -302,6 +304,7 @@ class FCCCarrierRegistrationHandler:
msg["Subject"] = subject msg["Subject"] = subject
msg["From"] = os.environ.get("SMTP_FROM", "Performance West <noreply@performancewest.net>") msg["From"] = os.environ.get("SMTP_FROM", "Performance West <noreply@performancewest.net>")
msg["To"] = email msg["To"] = email
msg.attach(MIMEText(html_to_text(body), "plain"))
msg.attach(MIMEText(body, "html")) msg.attach(MIMEText(body, "html"))
smtp_host = os.environ.get("SMTP_HOST", "co.carrierone.com") smtp_host = os.environ.get("SMTP_HOST", "co.carrierone.com")

View file

@ -241,6 +241,8 @@ class Form499ADiscontinuanceHandler(BaseServiceHandler):
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from scripts._email_plaintext import html_to_text
subject = f"Form 499-A Discontinuance Filed — {entity_name}" subject = f"Form 499-A Discontinuance Filed — {entity_name}"
html = f""" html = f"""
<div style="font-family:Inter,sans-serif;max-width:600px;margin:0 auto;color:#1f2937"> <div style="font-family:Inter,sans-serif;max-width:600px;margin:0 auto;color:#1f2937">
@ -275,6 +277,7 @@ class Form499ADiscontinuanceHandler(BaseServiceHandler):
msg["From"] = os.environ.get("SMTP_FROM", "Performance West <noreply@performancewest.net>") msg["From"] = os.environ.get("SMTP_FROM", "Performance West <noreply@performancewest.net>")
msg["To"] = to msg["To"] = to
msg["Subject"] = subject msg["Subject"] = subject
msg.attach(MIMEText(html_to_text(html), "plain"))
msg.attach(MIMEText(html, "html")) msg.attach(MIMEText(html, "html"))
with smtplib.SMTP( with smtplib.SMTP(

View file

@ -338,10 +338,13 @@ class Form499QHandler(BaseServiceHandler):
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from scripts._email_plaintext import html_to_text
msg = MIMEMultipart("alternative") msg = MIMEMultipart("alternative")
msg["From"] = os.environ.get("SMTP_FROM", "Performance West <noreply@performancewest.net>") msg["From"] = os.environ.get("SMTP_FROM", "Performance West <noreply@performancewest.net>")
msg["To"] = to msg["To"] = to
msg["Subject"] = subject msg["Subject"] = subject
msg.attach(MIMEText(html_to_text(html), "plain"))
msg.attach(MIMEText(html, "html")) msg.attach(MIMEText(html, "html"))
with smtplib.SMTP( with smtplib.SMTP(

View file

@ -136,6 +136,8 @@ class RMDFilingHandler(BaseServiceHandler):
import smtplib import smtplib
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from scripts._email_plaintext import html_to_text
subject = f"Action Required — Review your RMD certification for {entity.get('legal_name', order_number)}" subject = f"Action Required — Review your RMD certification for {entity.get('legal_name', order_number)}"
body = ( body = (
f"<h2>Your RMD Certification is Ready for Review</h2>" f"<h2>Your RMD Certification is Ready for Review</h2>"
@ -161,6 +163,7 @@ class RMDFilingHandler(BaseServiceHandler):
msg["From"] = smtp_from msg["From"] = smtp_from
msg["To"] = customer_email msg["To"] = customer_email
msg["Reply-To"] = "info@performancewest.net" msg["Reply-To"] = "info@performancewest.net"
msg.attach(MIMEText(html_to_text(body), "plain"))
msg.attach(MIMEText(body, "html")) msg.attach(MIMEText(body, "html"))
with smtplib.SMTP(smtp_host, smtp_port) as server: with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls() server.starttls()

View file

@ -44,6 +44,8 @@ import smtplib
from dataclasses import dataclass from dataclasses import dataclass
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from scripts._email_plaintext import html_to_text
from typing import Optional from typing import Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -279,6 +281,7 @@ def _send_admin_email(
msg["Subject"] = f"[Review & File] {order_number}{service_name}" msg["Subject"] = f"[Review & File] {order_number}{service_name}"
msg["From"] = smtp_from msg["From"] = smtp_from
msg["To"] = to_email msg["To"] = to_email
msg.attach(MIMEText(html_to_text(html), "plain"))
msg.attach(MIMEText(html, "html")) msg.attach(MIMEText(html, "html"))
try: try:

View file

@ -28,6 +28,8 @@ import smtplib
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from scripts._email_plaintext import html_to_text
logger = logging.getLogger("esign_helper") logger = logging.getLogger("esign_helper")
@ -197,6 +199,7 @@ def _send_signing_email(
msg["To"] = f"{to_name} <{to_email}>" msg["To"] = f"{to_name} <{to_email}>"
msg["Subject"] = subject msg["Subject"] = subject
msg["Reply-To"] = "info@performancewest.net" msg["Reply-To"] = "info@performancewest.net"
msg.attach(MIMEText(html_to_text(body), "plain"))
msg.attach(MIMEText(body, "html")) msg.attach(MIMEText(body, "html"))
with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server: with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server:

View file

@ -52,6 +52,8 @@ from datetime import date, datetime
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from scripts._email_plaintext import html_to_text
from typing import Iterable from typing import Iterable
import psycopg2 import psycopg2
@ -604,6 +606,7 @@ def send_email(*, to_email: str, subject: str, html_body: str, dry_run: bool) ->
msg["From"] = FROM_EMAIL msg["From"] = FROM_EMAIL
msg["To"] = to_email msg["To"] = to_email
msg["Bcc"] = ADMIN_EMAIL msg["Bcc"] = ADMIN_EMAIL
msg.attach(MIMEText(html_to_text(html_body), "plain"))
msg.attach(MIMEText(html_body, "html")) msg.attach(MIMEText(html_body, "html"))
try: try:
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server: with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:

View file

@ -17,10 +17,21 @@ from __future__ import annotations
import logging import logging
import os import os
import smtplib import smtplib
import sys
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.application import MIMEApplication from email.mime.application import MIMEApplication
# Shared HTML -> plaintext converter (scripts/_email_plaintext.py). Adding a
# text/plain part to every message is a deliverability win: a multipart/
# alternative with ONLY an HTML part is malformed, and HTML-only mail is a
# spam-score signal. Import is path-tolerant (module run from /app or scripts/).
try:
from scripts._email_plaintext import html_to_text
except ImportError: # pragma: no cover - fallback when scripts/ is on sys.path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from _email_plaintext import html_to_text # type: ignore
LOG = logging.getLogger("workers.email") LOG = logging.getLogger("workers.email")
SMTP_HOST = os.getenv("SMTP_HOST", "co.carrierone.com") SMTP_HOST = os.getenv("SMTP_HOST", "co.carrierone.com")
@ -53,8 +64,13 @@ def send_worker_email(
msg["Subject"] = subject msg["Subject"] = subject
alt = MIMEMultipart("alternative") alt = MIMEMultipart("alternative")
if text: # Always include a text/plain part. If the caller did not supply one,
alt.attach(MIMEText(text, "plain")) # derive it from the HTML so the message is a well-formed multipart/
# alternative (HTML-only is malformed + a spam signal). RFC 2046: list
# the plain part first (least->most preferred).
plain = text if text else html_to_text(html)
if plain:
alt.attach(MIMEText(plain, "plain"))
alt.attach(MIMEText(html, "html")) alt.attach(MIMEText(html, "html"))
msg.attach(alt) msg.attach(alt)