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;
|
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 ─────────────────────────────────────────────────────────────
|
// ─── 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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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")]
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue