Files
pieced-portal/src/lib/email.ts
admin 46369fda01
All checks were successful
Build and Push / build (push) Successful in 1m26s
Show suspended since and new emails for suspend continue approve and rejection
2026-05-01 22:37:23 +02:00

321 lines
13 KiB
TypeScript

/**
* Email sending utility for the PieCed portal.
*
* Uses nodemailer with SMTP credentials from environment variables
* (populated via ExternalSecret from OpenBao at pieced/portal/smtp).
*
* Env vars (from portal-smtp K8s secret):
* SMTP_HOST — e.g. smtp.gmail.com
* SMTP_PORT — e.g. 587 (default)
* SMTP_USER — e.g. noreply@pieced.ch
* SMTP_PASS — App Password
* SMTP_FROM — e.g. "PieCed <noreply@pieced.ch>"
* ADMIN_NOTIFICATION_EMAIL — e.g. admin@pieced.ch (optional)
*/
import nodemailer from "nodemailer";
let _transporter: nodemailer.Transporter | null = null;
function getTransporter(): nodemailer.Transporter {
if (!_transporter) {
const host = process.env.SMTP_HOST;
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
if (!host || !user || !pass) {
throw new Error("SMTP_HOST, SMTP_USER, and SMTP_PASS must be set");
}
_transporter = nodemailer.createTransport({
host,
port: parseInt(process.env.SMTP_PORT || "587", 10),
secure: process.env.SMTP_SECURE === "true",
auth: { user, pass },
});
}
return _transporter;
}
function getFrom(): string {
return (
process.env.SMTP_FROM ||
`PieCed <${process.env.SMTP_USER}>`
);
}
/**
* Escape HTML entities to prevent injection in HTML emails.
*/
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export async function sendApprovalEmail(
to: string,
contactName: string,
companyName: string
): Promise<void> {
const safeName = escapeHtml(contactName);
const safeCompany = escapeHtml(companyName);
try {
await getTransporter().sendMail({
from: getFrom(),
to,
subject: `Your PieCed AI assistant is being set up — ${companyName}`,
text: [
`Hello ${contactName},`,
"",
`Great news! Your onboarding request for ${companyName} has been approved.`,
"",
"Your AI assistant instance is now being provisioned. This usually takes a few minutes.",
"You can check the status in your dashboard at https://app.pieced.ch",
"",
"Once your instance is ready, you'll see it on your dashboard and can start configuring it.",
"",
"Best regards,",
"PieCed IT",
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">Your AI assistant is being set up</h2>
<p>Hello ${safeName},</p>
<p>Great news! Your onboarding request for <strong>${safeCompany}</strong> has been approved.</p>
<p>Your AI assistant instance is now being provisioned. This usually takes a few minutes.</p>
<p>
<a href="https://app.pieced.ch" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
Go to Dashboard
</a>
</p>
<p style="color: #888; font-size: 13px; margin-top: 24px;">
Once your instance is ready, you'll see it on your dashboard and can start configuring it.
</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send approval email:", err);
}
}
export async function sendRejectionEmail(
to: string,
contactName: string,
companyName: string,
adminNotes?: string
): Promise<void> {
const safeName = escapeHtml(contactName);
const safeCompany = escapeHtml(companyName);
const safeNotes = adminNotes ? escapeHtml(adminNotes) : "";
try {
const notesBlock = adminNotes
? `\nNote from our team:\n${adminNotes}\n`
: "";
const notesHtml = safeNotes
? `<div style="background: #2a2a2a; border-left: 3px solid #ef4444; padding: 12px 16px; border-radius: 6px; margin: 16px 0;">
<p style="color: #ccc; font-size: 13px; margin: 0;"><strong>Note from our team:</strong></p>
<p style="color: #aaa; font-size: 13px; margin: 8px 0 0 0;">${safeNotes}</p>
</div>`
: "";
await getTransporter().sendMail({
from: getFrom(),
to,
subject: `Update on your PieCed onboarding request — ${companyName}`,
text: [
`Hello ${contactName},`,
"",
`Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for ${companyName} at this time.`,
notesBlock,
"If you have questions or would like to discuss this further, please reply to this email.",
"",
"Best regards,",
"PieCed IT",
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">Update on your onboarding request</h2>
<p>Hello ${safeName},</p>
<p>Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for <strong>${safeCompany}</strong> at this time.</p>
${notesHtml}
<p>If you have questions or would like to discuss this further, please reply to this email.</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send rejection email:", err);
}
}
/**
* Bug 37a: separate email for resume request approval. The tenant
* already exists; the message is "we're un-suspending it" rather than
* "we're provisioning a new instance". Avoids confusing the customer
* with onboarding language for a tenant they already had.
*/
export async function sendResumeApprovalEmail(
to: string,
contactName: string,
companyName: string
): Promise<void> {
const safeName = escapeHtml(contactName);
const safeCompany = escapeHtml(companyName);
try {
await getTransporter().sendMail({
from: getFrom(),
to,
subject: `Your PieCed AI assistant has been reactivated — ${companyName}`,
text: [
`Hello ${contactName},`,
"",
`Good news — your reactivation request for ${companyName} has been approved.`,
"",
"Your AI assistant is being brought back online and should be ready in a few minutes.",
"You can check the status in your dashboard at https://app.pieced.ch",
"",
"Best regards,",
"PieCed IT",
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">Your AI assistant has been reactivated</h2>
<p>Hello ${safeName},</p>
<p>Good news — your reactivation request for <strong>${safeCompany}</strong> has been approved.</p>
<p>Your AI assistant is being brought back online and should be ready in a few minutes.</p>
<p>
<a href="https://app.pieced.ch" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
Go to Dashboard
</a>
</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send resume approval email:", err);
}
}
/**
* Bug 37a: separate email for resume request rejection. Differs from
* the onboarding rejection in two ways: it explicitly mentions the
* tenant remains suspended, and it points the customer to the
* 60-day retention window so they understand the deletion clock is
* still ticking. The latter is important — a customer reading a
* generic "request rejected" email might not realise their data is
* still on a countdown.
*/
export async function sendResumeRejectionEmail(
to: string,
contactName: string,
companyName: string,
adminNotes?: string
): Promise<void> {
const safeName = escapeHtml(contactName);
const safeCompany = escapeHtml(companyName);
const safeNotes = adminNotes ? escapeHtml(adminNotes) : "";
try {
const notesBlock = adminNotes
? `\nNote from our team:\n${adminNotes}\n`
: "";
const notesHtml = safeNotes
? `<div style="background: #2a2a2a; border-left: 3px solid #ef4444; padding: 12px 16px; border-radius: 6px; margin: 16px 0;">
<p style="color: #ccc; font-size: 13px; margin: 0;"><strong>Note from our team:</strong></p>
<p style="color: #aaa; font-size: 13px; margin: 8px 0 0 0;">${safeNotes}</p>
</div>`
: "";
await getTransporter().sendMail({
from: getFrom(),
to,
subject: `Update on your reactivation request — ${companyName}`,
text: [
`Hello ${contactName},`,
"",
`Thank you for your reactivation request for ${companyName}. Unfortunately, we were unable to approve it at this time.`,
notesBlock,
"Your tenant remains suspended. As a reminder, your data is preserved for 60 days from the original cancellation date, after which it will be permanently deleted. You can submit a new reactivation request at any time before then.",
"",
"If you have questions, please reply to this email.",
"",
"Best regards,",
"PieCed IT",
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">Update on your reactivation request</h2>
<p>Hello ${safeName},</p>
<p>Thank you for your reactivation request for <strong>${safeCompany}</strong>. Unfortunately, we were unable to approve it at this time.</p>
${notesHtml}
<p>Your tenant remains suspended. As a reminder, your data is preserved for 60 days from the original cancellation date, after which it will be permanently deleted. You can submit a new reactivation request at any time before then.</p>
<p>If you have questions, please reply to this email.</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send resume rejection email:", err);
}
}
export async function sendAdminNotificationEmail(
companyName: string,
contactName: string,
contactEmail: string
): Promise<void> {
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
if (!adminEmail) return;
const safeCompany = escapeHtml(companyName);
const safeName = escapeHtml(contactName);
const safeEmail = escapeHtml(contactEmail);
try {
await getTransporter().sendMail({
from: getFrom(),
to: adminEmail,
subject: `New onboarding request: ${companyName}`,
text: [
`A new onboarding request has been submitted.`,
"",
`Company: ${companyName}`,
`Contact: ${contactName} (${contactEmail})`,
"",
`Review it at https://app.pieced.ch/admin`,
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">New onboarding request</h2>
<p>A new onboarding request has been submitted.</p>
<table style="color: #ccc; font-size: 14px; margin: 16px 0;">
<tr><td style="padding: 4px 12px 4px 0; color: #888;">Company:</td><td>${safeCompany}</td></tr>
<tr><td style="padding: 4px 12px 4px 0; color: #888;">Contact:</td><td>${safeName} (${safeEmail})</td></tr>
</table>
<p>
<a href="https://app.pieced.ch/admin" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
Review Request
</a>
</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send admin notification email:", err);
}
}