new-site/api/src/email.ts
justin 9987b1e30d fix(checkout): create Postgres customers row on order completion (PayPal login bug)
Portal login + forgot-password read the Postgres customers table (bcrypt), NOT
ERPNext. ensureCompliancePortalUser (the common path for Stripe/PayPal/crypto via
handlePaymentComplete) only provisioned the ERPNext customer/website-user and
never created the customers row -- so customers (notably PayPal, who reach this
path directly) had no account to log into or reset a password against. Now upserts
the customers row (no password; ON CONFLICT keeps any existing hash) with name +
company so they can register/reset and log in immediately.

Also: narrowed the placeholder-email skip from 'any synthetic@ or pipeline.com' to
exactly 'synthetic@pipeline.com' (the FMCSA-census placeholder) so real customers
on those real consumer domains aren't wrongly skipped -- which is what bit Paul
Wilson. Added cc support to sendEmail. e2e-paypal-portal-fix.mjs is the regression
test (seeds a compliance order, runs handlePaymentComplete, asserts the customers
row is created). Rescue scripts for the affected customer included.
2026-06-09 14:28:19 -05:00

318 lines
15 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; 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 || "",
});
}
// ─── 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>
<!-- 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 &rarr;</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> &middot; 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. &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})`);
}