diff --git a/api/src/email.ts b/api/src/email.ts index 2832fe2..5dcda6a 100644 --- a/api/src/email.ts +++ b/api/src/email.ts @@ -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, ""); + // text -> text (url) + s = s.replace(/]*\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(/]*>/gi, "\n- "); + s = s.replace(/<\/(p|div|tr|h[1-6]|li|ul|ol|table)>/gi, "\n"); + s = s.replace(//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 { @@ -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), }); } diff --git a/scripts/formation/document_delivery.py b/scripts/formation/document_delivery.py index 65b3d86..5480a9d 100644 --- a/scripts/formation/document_delivery.py +++ b/scripts/formation/document_delivery.py @@ -267,9 +267,13 @@ def _build_email( msg["Subject"] = f"Your {type_display} Has Been Filed — {entity_name}" msg["Reply-To"] = FROM_EMAIL - # HTML body - html_part = email.mime.text.MIMEText(html_body, "html", "utf-8") - msg.attach(html_part) + # HTML body wrapped in alternative with a plaintext sibling (HTML-only is + # malformed inside multipart + a spam signal); documents attach to mixed root. + 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 for doc_path in documents: diff --git a/scripts/workers/cdr_unlock_nudge.py b/scripts/workers/cdr_unlock_nudge.py index 4d75c81..b24826f 100644 --- a/scripts/workers/cdr_unlock_nudge.py +++ b/scripts/workers/cdr_unlock_nudge.py @@ -23,6 +23,8 @@ from datetime import datetime, 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 @@ -81,6 +83,7 @@ def _send(to_email: str, subject: str, body_html: str) -> bool: msg["From"] = FROM_EMAIL msg["To"] = to_email msg["Bcc"] = ADMIN_EMAIL + msg.attach(MIMEText(html_to_text(body_html), "plain")) msg.attach(MIMEText(body_html, "html")) try: with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server: diff --git a/scripts/workers/commission_worker.py b/scripts/workers/commission_worker.py index 2c6b58d..25b3097 100644 --- a/scripts/workers/commission_worker.py +++ b/scripts/workers/commission_worker.py @@ -26,6 +26,8 @@ from collections import defaultdict from datetime import datetime, timedelta, timezone from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText + +from scripts._email_plaintext import html_to_text from typing import Any import psycopg2 @@ -114,6 +116,7 @@ def _send_email(to: str, subject: str, body_html: str) -> None: msg["From"] = FROM_EMAIL msg["To"] = to msg["Subject"] = subject + msg.attach(MIMEText(html_to_text(body_html), "plain")) msg.attach(MIMEText(body_html, "html")) try: diff --git a/scripts/workers/completion_emails.py b/scripts/workers/completion_emails.py index e911c17..6923ab8 100644 --- a/scripts/workers/completion_emails.py +++ b/scripts/workers/completion_emails.py @@ -18,6 +18,8 @@ from datetime import datetime, timedelta, timezone from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from scripts._email_plaintext import html_to_text + import psycopg2 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["To"] = to msg["Subject"] = subject + msg.attach(MIMEText(html_to_text(html), "plain")) msg.attach(MIMEText(html, "html")) import os as _smtp_os diff --git a/scripts/workers/delivery_worker.py b/scripts/workers/delivery_worker.py index afd5dc8..8c95ca4 100644 --- a/scripts/workers/delivery_worker.py +++ b/scripts/workers/delivery_worker.py @@ -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.text import MIMEText + from scripts._email_plaintext import html_to_text + msg = MIMEMultipart("alternative") msg["Subject"] = subject msg["From"] = SMTP_FROM msg["To"] = to_address + msg.attach(MIMEText(html_to_text(html_body), "plain")) msg.attach(MIMEText(html_body, "html")) with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server: diff --git a/scripts/workers/intake_reminder.py b/scripts/workers/intake_reminder.py index 19995b5..4431066 100644 --- a/scripts/workers/intake_reminder.py +++ b/scripts/workers/intake_reminder.py @@ -52,6 +52,8 @@ from datetime import datetime, timezone 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 @@ -112,6 +114,7 @@ def _send_email(to: str, subject: str, html: str) -> bool: 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() diff --git a/scripts/workers/job_server.py b/scripts/workers/job_server.py index 03d37ae..8341eed 100644 --- a/scripts/workers/job_server.py +++ b/scripts/workers/job_server.py @@ -930,7 +930,13 @@ def _send_instant_delivery( f"Your {service_name} documents are ready \u2014 Order {order_number}" ) 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) with tempfile.TemporaryDirectory() as tmpdir: @@ -1020,7 +1026,12 @@ def _send_filing_confirmation( msg["To"] = customer_email msg["Subject"] = f"\u2705 Filed: {service_name} \u2014 Confirmation {confirmation_number}" 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 pdf_paths = [p for p in minio_paths if p.lower().endswith(".pdf")] diff --git a/scripts/workers/quarterly_499q_notify.py b/scripts/workers/quarterly_499q_notify.py index e5709ad..0a44fc1 100644 --- a/scripts/workers/quarterly_499q_notify.py +++ b/scripts/workers/quarterly_499q_notify.py @@ -29,6 +29,8 @@ 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 @@ -48,6 +50,7 @@ def _send_email(to: str, subject: str, html: str) -> bool: 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() diff --git a/scripts/workers/renewal_worker.py b/scripts/workers/renewal_worker.py index 83ccd6b..b681f9b 100644 --- a/scripts/workers/renewal_worker.py +++ b/scripts/workers/renewal_worker.py @@ -60,11 +60,14 @@ def send_email(to_email: str, subject: str, html_body: str): from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText + from scripts._email_plaintext import html_to_text + smtp_cfg = get_smtp_config() msg = MIMEMultipart("alternative") msg["From"] = f"Performance West <{smtp_cfg['from_addr']}>" msg["To"] = to_email msg["Subject"] = subject + msg.attach(MIMEText(html_to_text(html_body), "plain")) msg.attach(MIMEText(html_body, "html")) with smtplib.SMTP(smtp_cfg["host"], smtp_cfg["port"]) as server: diff --git a/scripts/workers/services/base_handler.py b/scripts/workers/services/base_handler.py index a76f7b6..69a5021 100644 --- a/scripts/workers/services/base_handler.py +++ b/scripts/workers/services/base_handler.py @@ -282,6 +282,8 @@ class BaseServiceHandler(ABC): from email.mime.text import MIMEText 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" subject = f"Action Required — Complete your {service_label} intake" body = ( @@ -308,6 +310,7 @@ class BaseServiceHandler(ABC): msg["From"] = smtp_from msg["To"] = customer_email msg["Reply-To"] = "info@performancewest.net" + msg.attach(MIMEText(html_to_text(body), "plain")) msg.attach(MIMEText(body, "html")) with smtplib.SMTP(smtp_host, smtp_port) as server: server.starttls() diff --git a/scripts/workers/services/fcc_carrier_registration.py b/scripts/workers/services/fcc_carrier_registration.py index c4b2d0a..958e3f7 100644 --- a/scripts/workers/services/fcc_carrier_registration.py +++ b/scripts/workers/services/fcc_carrier_registration.py @@ -282,6 +282,8 @@ class FCCCarrierRegistrationHandler: from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart + from scripts._email_plaintext import html_to_text + email = order.get("customer_email", "") name = order.get("customer_name", "") if not email: @@ -302,6 +304,7 @@ class FCCCarrierRegistrationHandler: msg["Subject"] = subject msg["From"] = os.environ.get("SMTP_FROM", "Performance West ") msg["To"] = email + msg.attach(MIMEText(html_to_text(body), "plain")) msg.attach(MIMEText(body, "html")) smtp_host = os.environ.get("SMTP_HOST", "co.carrierone.com") diff --git a/scripts/workers/services/form_499a_discontinuance.py b/scripts/workers/services/form_499a_discontinuance.py index 2d4320c..9feb44a 100644 --- a/scripts/workers/services/form_499a_discontinuance.py +++ b/scripts/workers/services/form_499a_discontinuance.py @@ -241,6 +241,8 @@ class Form499ADiscontinuanceHandler(BaseServiceHandler): from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText + from scripts._email_plaintext import html_to_text + subject = f"Form 499-A Discontinuance Filed — {entity_name}" html = f"""
@@ -275,6 +277,7 @@ class Form499ADiscontinuanceHandler(BaseServiceHandler): msg["From"] = os.environ.get("SMTP_FROM", "Performance West ") msg["To"] = to msg["Subject"] = subject + msg.attach(MIMEText(html_to_text(html), "plain")) msg.attach(MIMEText(html, "html")) with smtplib.SMTP( diff --git a/scripts/workers/services/form_499q.py b/scripts/workers/services/form_499q.py index 90b7326..4e83d9a 100644 --- a/scripts/workers/services/form_499q.py +++ b/scripts/workers/services/form_499q.py @@ -338,10 +338,13 @@ class Form499QHandler(BaseServiceHandler): from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText + from scripts._email_plaintext import html_to_text + msg = MIMEMultipart("alternative") msg["From"] = os.environ.get("SMTP_FROM", "Performance West ") msg["To"] = to msg["Subject"] = subject + msg.attach(MIMEText(html_to_text(html), "plain")) msg.attach(MIMEText(html, "html")) with smtplib.SMTP( diff --git a/scripts/workers/services/rmd_filing.py b/scripts/workers/services/rmd_filing.py index f0f13cf..c278093 100644 --- a/scripts/workers/services/rmd_filing.py +++ b/scripts/workers/services/rmd_filing.py @@ -136,6 +136,8 @@ class RMDFilingHandler(BaseServiceHandler): import smtplib from email.mime.text import MIMEText 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)}" body = ( f"

Your RMD Certification is Ready for Review

" @@ -161,6 +163,7 @@ class RMDFilingHandler(BaseServiceHandler): msg["From"] = smtp_from msg["To"] = customer_email msg["Reply-To"] = "info@performancewest.net" + msg.attach(MIMEText(html_to_text(body), "plain")) msg.attach(MIMEText(body, "html")) with smtplib.SMTP(smtp_host, smtp_port) as server: server.starttls() diff --git a/scripts/workers/services/telecom/auto_filing.py b/scripts/workers/services/telecom/auto_filing.py index f6145de..4acb9fc 100644 --- a/scripts/workers/services/telecom/auto_filing.py +++ b/scripts/workers/services/telecom/auto_filing.py @@ -44,6 +44,8 @@ import smtplib from dataclasses import dataclass from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText + +from scripts._email_plaintext import html_to_text from typing import Optional logger = logging.getLogger(__name__) @@ -279,6 +281,7 @@ def _send_admin_email( msg["Subject"] = f"[Review & File] {order_number} — {service_name}" msg["From"] = smtp_from msg["To"] = to_email + msg.attach(MIMEText(html_to_text(html), "plain")) msg.attach(MIMEText(html, "html")) try: diff --git a/scripts/workers/services/telecom/esign_helper.py b/scripts/workers/services/telecom/esign_helper.py index eda6b6a..5b3a1b4 100644 --- a/scripts/workers/services/telecom/esign_helper.py +++ b/scripts/workers/services/telecom/esign_helper.py @@ -28,6 +28,8 @@ import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from scripts._email_plaintext import html_to_text + logger = logging.getLogger("esign_helper") @@ -197,6 +199,7 @@ def _send_signing_email( msg["To"] = f"{to_name} <{to_email}>" msg["Subject"] = subject msg["Reply-To"] = "info@performancewest.net" + msg.attach(MIMEText(html_to_text(body), "plain")) msg.attach(MIMEText(body, "html")) with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server: diff --git a/scripts/workers/usf_factor_monitor.py b/scripts/workers/usf_factor_monitor.py index 4d7e33b..db93869 100644 --- a/scripts/workers/usf_factor_monitor.py +++ b/scripts/workers/usf_factor_monitor.py @@ -52,6 +52,8 @@ from datetime import date, datetime from decimal import Decimal, InvalidOperation from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText + +from scripts._email_plaintext import html_to_text from typing import Iterable 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["To"] = to_email msg["Bcc"] = ADMIN_EMAIL + msg.attach(MIMEText(html_to_text(html_body), "plain")) msg.attach(MIMEText(html_body, "html")) try: with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server: diff --git a/scripts/workers/worker_email.py b/scripts/workers/worker_email.py index 58cdbc4..ad5d629 100644 --- a/scripts/workers/worker_email.py +++ b/scripts/workers/worker_email.py @@ -17,10 +17,21 @@ from __future__ import annotations import logging import os import smtplib +import sys from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText 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") SMTP_HOST = os.getenv("SMTP_HOST", "co.carrierone.com") @@ -53,8 +64,13 @@ def send_worker_email( msg["Subject"] = subject alt = MIMEMultipart("alternative") - if text: - alt.attach(MIMEText(text, "plain")) + # Always include a text/plain part. If the caller did not supply one, + # 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")) msg.attach(alt)