Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
303
api/src/email.ts
Normal file
303
api/src/email.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
/**
|
||||
* 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;"> </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. · <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>
|
||||
|
||||
<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:support@performancewest.net" style="color:#1e3a5f;">support@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 support@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})`);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue