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:
parent
899b880e7f
commit
b375385efd
19 changed files with 114 additions and 8 deletions
|
|
@ -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(/ /gi, " ").replace(/&/gi, "&").replace(/</gi, "<")
|
||||
.replace(/>/gi, ">").replace(/"/gi, '"').replace(/'/gi, "'")
|
||||
.replace(/→/gi, "->").replace(/·/gi, "-").replace(/§/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),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue