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).
348 lines
17 KiB
TypeScript
348 lines
17 KiB
TypeScript
/**
|
||
* Email utility — outbound SMTP via nodemailer.
|
||
*
|
||
* Used for:
|
||
* - Order confirmation emails (sent immediately after payment)
|
||
* - Portal access link emails (JWT-signed links)
|
||
*
|
||
* All transactional emails go through Carbonio: co.carrierone.com:587
|
||
* (SMTP2GO is used only by Listmonk for mass-mail campaigns.)
|
||
* Env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM
|
||
*/
|
||
|
||
import nodemailer from "nodemailer";
|
||
|
||
// ─── SMTP transport ───────────────────────────────────────────────────────────
|
||
|
||
const SMTP_HOST = process.env.SMTP_HOST || "co.carrierone.com";
|
||
const SMTP_PORT = parseInt(process.env.SMTP_PORT || "587", 10);
|
||
const SMTP_USER = process.env.SMTP_USER || "";
|
||
const SMTP_PASS = process.env.SMTP_PASS || "";
|
||
const SMTP_FROM = process.env.SMTP_FROM || "Performance West <noreply@performancewest.net>";
|
||
|
||
let _transporter: nodemailer.Transporter | null = null;
|
||
|
||
function getTransporter(): nodemailer.Transporter {
|
||
if (!_transporter) {
|
||
_transporter = nodemailer.createTransport({
|
||
host: SMTP_HOST,
|
||
port: SMTP_PORT,
|
||
secure: false, // STARTTLS
|
||
auth: { user: SMTP_USER, pass: SMTP_PASS },
|
||
});
|
||
}
|
||
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> {
|
||
const t = getTransporter();
|
||
await t.sendMail({
|
||
from: SMTP_FROM,
|
||
to: opts.to,
|
||
...(opts.cc ? { cc: opts.cc } : {}),
|
||
subject: opts.subject,
|
||
html: opts.html,
|
||
text: opts.text || htmlToText(opts.html),
|
||
});
|
||
}
|
||
|
||
// ─── Branded HTML wrapper ─────────────────────────────────────────────────────
|
||
|
||
function htmlEmail(title: string, body: string): string {
|
||
const logo = "https://performancewest.net/images/logo.png";
|
||
return `<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>${title}</title>
|
||
</head>
|
||
<body style="margin:0;padding:0;background:#eef0f3;font-family:Arial,sans-serif;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#eef0f3;padding:20px 0;">
|
||
<tr><td align="center">
|
||
<table width="620" cellpadding="0" cellspacing="0" style="width:620px;max-width:620px;background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
|
||
<!-- Header with logo -->
|
||
<tr>
|
||
<td style="background:#1a2744;padding:18px 40px 14px;">
|
||
<table cellpadding="0" cellspacing="0" border="0"><tr>
|
||
<td style="vertical-align:middle;padding-right:12px;"><img src="${logo}" width="90" alt="Performance West" style="display:block;width:90px;height:auto;"></td>
|
||
<td style="vertical-align:middle;border-left:1px solid #2d4e78;padding-left:12px;"><span style="color:#8fa8d0;font-family:Arial,sans-serif;font-size:11px;letter-spacing:1.5px;text-transform:uppercase;">Telecom Compliance</span></td>
|
||
</tr></table>
|
||
</td>
|
||
</tr>
|
||
<tr><td style="background:#059669;height:3px;font-size:0;line-height:0;"> </td></tr>
|
||
<!-- Body -->
|
||
<tr>
|
||
<td style="padding:32px 40px;">
|
||
${body}
|
||
</td>
|
||
</tr>
|
||
<!-- Help / support band: reduces friction & chargebacks by giving an
|
||
obvious place to get help with any order issue. -->
|
||
<tr>
|
||
<td style="padding:0 40px 24px;">
|
||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f0f9ff;border:1px solid #bae6fd;border-radius:8px;">
|
||
<tr><td style="padding:18px 20px;text-align:center;">
|
||
<p style="margin:0 0 4px;font-family:Arial,sans-serif;font-size:15px;font-weight:700;color:#0c4a6e;">Problem with your order? We're here to help.</p>
|
||
<p style="margin:0 0 14px;font-family:Arial,sans-serif;font-size:13px;color:#0369a1;line-height:1.5;">Questions, a change, or something not right? Reach our team and we'll make it right, fast.</p>
|
||
<a href="https://performancewest.net/contact" style="display:inline-block;background:#1e3a5f;color:#ffffff;padding:11px 26px;border-radius:6px;text-decoration:none;font-family:Arial,sans-serif;font-size:14px;font-weight:700;">Get help with your order →</a>
|
||
<p style="margin:12px 0 0;font-family:Arial,sans-serif;font-size:12px;color:#64748b;">Or email <a href="mailto:info@performancewest.net" style="color:#0369a1;">info@performancewest.net</a> · call <a href="tel:18884110383" style="color:#0369a1;">1-888-411-0383</a></p>
|
||
</td></tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
<!-- Footer -->
|
||
<tr>
|
||
<td style="background:#f4f5f7;border-top:1px solid #e8ecf0;padding:16px 40px;text-align:center;">
|
||
<img src="${logo}" width="60" alt="Performance West" style="display:block;margin:0 auto 8px;width:60px;height:auto;opacity:0.5;">
|
||
<p style="margin:0;font-family:Arial,sans-serif;font-size:11px;color:#9ca3af;">
|
||
Performance West Inc. · <a href="https://performancewest.net" style="color:#9ca3af;">performancewest.net</a> · 1-888-411-0383
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
export interface OrderConfirmationParams {
|
||
order_id: string;
|
||
order_type: string;
|
||
customer_email: string;
|
||
customer_name: string;
|
||
session_id: string;
|
||
amount_cents?: number;
|
||
service_name?: string;
|
||
payment_method?: string;
|
||
}
|
||
|
||
// ─── Order confirmation email ─────────────────────────────────────────────────
|
||
|
||
const ORDER_TIMELINES: Record<string, string> = {
|
||
canada_crtc: "6–10 weeks",
|
||
formation: "3–5 business days",
|
||
bundle: "5–10 business days",
|
||
compliance: "1–10 business days (varies by service)",
|
||
compliance_batch: "1–10 business days (varies by service)",
|
||
};
|
||
|
||
const ORDER_LABELS: Record<string, string> = {
|
||
canada_crtc: "Canada CRTC Telecom Carrier Package",
|
||
formation: "Business Formation",
|
||
bundle: "Compliance Bundle",
|
||
compliance: "Compliance Service",
|
||
compliance_batch: "FCC Compliance Services",
|
||
};
|
||
|
||
const ORDER_NEXT_STEPS: Record<string, string[]> = {
|
||
canada_crtc: [
|
||
"We will begin your BC corporation incorporation within 1–2 business days.",
|
||
"You will receive an email to choose your .ca domain name once your corporation is registered.",
|
||
"Your CRTC registration letter will be prepared after incorporation.",
|
||
"Your complete corporate binder will be delivered by email when all steps are complete.",
|
||
"A Canadian business banking referral will be sent upon delivery.",
|
||
],
|
||
formation: [
|
||
"We will begin your state filing within 1 business day.",
|
||
"You will receive your filed documents by email when processing is complete.",
|
||
"If you ordered an EIN, we will obtain it from the IRS after filing.",
|
||
],
|
||
bundle: [
|
||
"Our team will review your order within 1 business day.",
|
||
"You will be contacted to schedule any required consultations.",
|
||
"Completed deliverables will be emailed to you as they are ready.",
|
||
],
|
||
compliance: [
|
||
"Our team will review your order within 1 business day.",
|
||
"You will be contacted if we need any additional information.",
|
||
"Your completed deliverable will be emailed when ready.",
|
||
],
|
||
compliance_batch: [
|
||
"We will begin processing your services within 1 business day.",
|
||
"Some services (CPNI, RMD) may require your review and approval before we submit to the FCC.",
|
||
"You will receive a separate confirmation email for each filing as it is completed.",
|
||
"If we need any additional information, we will contact you by email.",
|
||
],
|
||
};
|
||
|
||
export async function sendOrderConfirmationEmail(params: OrderConfirmationParams): Promise<void> {
|
||
const { order_id, order_type, customer_email, customer_name, session_id,
|
||
amount_cents, service_name: svcName, payment_method } = params;
|
||
|
||
if (!customer_email) {
|
||
console.warn("[email] sendOrderConfirmationEmail: no customer_email for", order_id);
|
||
return;
|
||
}
|
||
|
||
if (!SMTP_USER || !SMTP_PASS) {
|
||
console.warn("[email] SMTP not configured — skipping confirmation email for", order_id);
|
||
return;
|
||
}
|
||
|
||
const firstName = customer_name.split(" ")[0] || customer_name;
|
||
const label = svcName || ORDER_LABELS[order_type] || "Your Order";
|
||
const timeline = ORDER_TIMELINES[order_type] || "1–10 business days";
|
||
const nextSteps = ORDER_NEXT_STEPS[order_type] || [];
|
||
|
||
const stepsHtml = nextSteps
|
||
.map((s, i) => `<p style="margin:4px 0 0;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">${i + 1}.</span> ${s}</p>`)
|
||
.join("\n");
|
||
|
||
const body = `
|
||
<h1 style="margin:0 0 8px;font-size:22px;font-weight:700;color:#111827;">Order Confirmed</h1>
|
||
<p style="margin:0 0 24px;font-size:15px;color:#6b7280;">Hi ${firstName}, your payment has been received. Here is your order summary.</p>
|
||
|
||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;margin-bottom:24px;">
|
||
<tr>
|
||
<td style="padding:16px 20px;">
|
||
<p style="margin:0;font-size:13px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;">Service</p>
|
||
<p style="margin:4px 0 0;font-size:15px;font-weight:700;color:#111827;">${svcName || label}</p>
|
||
</td>
|
||
</tr>
|
||
<tr><td style="border-top:1px solid #e5e7eb;padding:16px 20px;">
|
||
<p style="margin:0;font-size:13px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;">Order Number</p>
|
||
<p style="margin:4px 0 0;font-size:15px;font-weight:600;color:#1a2744;font-family:monospace;">${order_id}</p>
|
||
</td></tr>
|
||
${amount_cents ? `<tr><td style="border-top:1px solid #e5e7eb;padding:16px 20px;">
|
||
<p style="margin:0;font-size:13px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;">Amount Paid</p>
|
||
<p style="margin:4px 0 0;font-size:18px;font-weight:700;color:#059669;">$${(amount_cents / 100).toFixed(2)}</p>
|
||
${payment_method ? `<p style="margin:2px 0 0;font-size:12px;color:#9ca3af;">via ${payment_method}</p>` : ""}
|
||
</td></tr>` : ""}
|
||
<tr><td style="border-top:1px solid #e5e7eb;padding:16px 20px;">
|
||
<p style="margin:0;font-size:13px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;">Estimated Turnaround</p>
|
||
<p style="margin:4px 0 0;font-size:15px;color:#111827;">${timeline}</p>
|
||
</td></tr>
|
||
</table>
|
||
|
||
${(order_type === "compliance" || order_type === "compliance_batch") ? `<p style="margin:0 0 16px;font-size:13px;color:#6b7280;line-height:1.5;">
|
||
FCC compliance fees are tax deductible as ordinary business expenses under IRC § 162.
|
||
A formal receipt will be sent separately.
|
||
</p>` : ""}
|
||
|
||
<h2 style="margin:0 0 12px;font-size:16px;font-weight:700;color:#111827;">What happens next</h2>
|
||
${stepsHtml}
|
||
|
||
<div style="margin:24px 0 16px;padding:16px;background:#f0f9ff;border:1px solid #bae6fd;border-radius:8px;">
|
||
<p style="margin:0 0 8px;font-size:14px;font-weight:700;color:#0c4a6e;">Set up your client portal</p>
|
||
<p style="margin:0 0 12px;font-size:13px;color:#0369a1;">Track your orders, download documents, and manage your services.</p>
|
||
<a href="https://portal.performancewest.net" style="display:inline-block;background:#1e3a5f;color:#fff;padding:8px 20px;border-radius:6px;text-decoration:none;font-size:13px;font-weight:600;">Access Client Portal →</a>
|
||
<p style="margin:8px 0 0;font-size:11px;color:#64748b;">First time? <a href="https://portal.performancewest.net/login#forgot" style="color:#0369a1;">Set your password here</a></p>
|
||
</div>
|
||
|
||
<p style="margin:0;font-size:14px;color:#6b7280;">
|
||
Questions? Reply to this email or reach us at
|
||
<a href="mailto:info@performancewest.net" style="color:#1e3a5f;">info@performancewest.net</a>
|
||
or call <a href="tel:18884110383" style="color:#1e3a5f;">1-888-411-0383</a>.
|
||
</p>
|
||
`;
|
||
|
||
const transporter = getTransporter();
|
||
await transporter.sendMail({
|
||
from: SMTP_FROM,
|
||
to: customer_email,
|
||
subject: `Order Confirmed — ${order_id} — ${label}`,
|
||
html: htmlEmail("Order Confirmed — Performance West", body),
|
||
text: [
|
||
`Hi ${firstName},`,
|
||
``,
|
||
`Your payment has been received for order ${order_id}.`,
|
||
`Service: ${label}`,
|
||
`Estimated turnaround: ${timeline}`,
|
||
``,
|
||
`What happens next:`,
|
||
...nextSteps.map((s, i) => `${i + 1}. ${s}`),
|
||
``,
|
||
`Questions? Email info@performancewest.net or call 1-888-411-0383.`,
|
||
``,
|
||
`Performance West Inc.`,
|
||
].join("\n"),
|
||
});
|
||
|
||
console.log(`[email] Confirmation sent to ${customer_email} for ${order_id}`);
|
||
}
|
||
|
||
// ─── Portal access link email ─────────────────────────────────────────────────
|
||
|
||
export interface PortalLinkParams {
|
||
customer_email: string;
|
||
customer_name: string;
|
||
portal_url: string; // full signed URL
|
||
order_id: string;
|
||
link_purpose: string; // e.g. "domain selection", "manage your services"
|
||
}
|
||
|
||
export async function sendPortalLinkEmail(params: PortalLinkParams): Promise<void> {
|
||
const { customer_email, customer_name, portal_url, order_id, link_purpose } = params;
|
||
|
||
if (!customer_email || !SMTP_USER || !SMTP_PASS) return;
|
||
|
||
const firstName = customer_name.split(" ")[0] || customer_name;
|
||
|
||
const body = `
|
||
<h1 style="margin:0 0 8px;font-size:22px;font-weight:700;color:#111827;">Action Required</h1>
|
||
<p style="margin:0 0 24px;font-size:15px;color:#6b7280;">Hi ${firstName}, a step in your order requires your input.</p>
|
||
|
||
<p style="margin:0 0 16px;font-size:15px;color:#374151;">
|
||
Please click the button below to complete <strong>${link_purpose}</strong> for order
|
||
<strong style="font-family:monospace;">${order_id}</strong>.
|
||
</p>
|
||
|
||
<p style="margin:0 0 24px;">
|
||
<a href="${portal_url}"
|
||
style="display:inline-block;background:#1e3a5f;color:#ffffff;font-size:15px;font-weight:600;padding:12px 28px;border-radius:6px;text-decoration:none;">
|
||
Continue →
|
||
</a>
|
||
</p>
|
||
|
||
<p style="font-size:12px;color:#9ca3af;">
|
||
This link expires in 72 hours. If you did not request this, please ignore this email.
|
||
</p>
|
||
`;
|
||
|
||
const transporter = getTransporter();
|
||
await transporter.sendMail({
|
||
from: SMTP_FROM,
|
||
to: customer_email,
|
||
subject: `Action needed for order ${order_id} — ${link_purpose}`,
|
||
html: htmlEmail("Action Needed — Performance West", body),
|
||
text: `Hi ${firstName},\n\nPlease complete ${link_purpose} for order ${order_id}:\n\n${portal_url}\n\nThis link expires in 72 hours.\n\nPerformance West Inc.`,
|
||
});
|
||
|
||
console.log(`[email] Portal link sent to ${customer_email} for ${order_id} (${link_purpose})`);
|
||
}
|