new-site/api/src/email.ts
justin a7d7fee154 Fix 6 bugs found in compliance and checkout flows
1. CRITICAL: Add compliance_batch to stripe session tableMap —
   session IDs weren't being stored for batch orders
2. CRITICAL: Fix batch orders using order_number instead of batch_id
   when storing stripe_session_id
3. MAJOR: Tax deductibility note only shows for compliance orders,
   not CRTC/formation/bundles
4. MAJOR: Identity verification fallback changed from localhost:4321
   to performancewest.net with warning log
5. MEDIUM: Fix discount rounding — last service absorbs remainder
   to prevent cent loss across batch orders
6. LOW: Validate at least one paid service in batch orders
7. Standardize support email to info@performancewest.net everywhere

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:56:12 -05:00

303 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}
// ─── Generic send ─────────────────────────────────────────────────────────────
export async function sendEmail(opts: { to: string; subject: string; html: string; text?: string }): Promise<void> {
const t = getTransporter();
await t.sendMail({
from: SMTP_FROM,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text || "",
});
}
// ─── 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;">&nbsp;</td></tr>
<!-- Body -->
<tr>
<td style="padding:32px 40px;">
${body}
</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. &middot; <a href="https://performancewest.net" style="color:#9ca3af;">performancewest.net</a> &middot; 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: "610 weeks",
formation: "35 business days",
bundle: "510 business days",
compliance: "110 business days (varies by service)",
compliance_batch: "110 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 12 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] || "110 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 &sect; 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 &rarr;</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})`);
}