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;
}
// ─── 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 ─────────────────────────────────────────────────────────────
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 } : {}),
subject: opts.subject,
html: opts.html,
text: opts.text || "",
text: opts.text || htmlToText(opts.html),
});
}