/** * 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 " * 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, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } export async function sendApprovalEmail( to: string, contactName: string, companyName: string ): Promise { 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: `

Your AI assistant is being set up

Hello ${safeName},

Great news! Your onboarding request for ${safeCompany} has been approved.

Your AI assistant instance is now being provisioned. This usually takes a few minutes.

Go to Dashboard

Once your instance is ready, you'll see it on your dashboard and can start configuring it.


PieCed IT — Hosted on-premises in Switzerland

`, }); } catch (err) { console.error("Failed to send approval email:", err); } } export async function sendRejectionEmail( to: string, contactName: string, companyName: string, adminNotes?: string ): Promise { 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 ? `

Note from our team:

${safeNotes}

` : ""; 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: `

Update on your onboarding request

Hello ${safeName},

Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for ${safeCompany} at this time.

${notesHtml}

If you have questions or would like to discuss this further, please reply to this email.


PieCed IT — Hosted on-premises in Switzerland

`, }); } 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 { 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: `

Your AI assistant has been reactivated

Hello ${safeName},

Good news — your reactivation request for ${safeCompany} has been approved.

Your AI assistant is being brought back online and should be ready in a few minutes.

Go to Dashboard


PieCed IT — Hosted on-premises in Switzerland

`, }); } 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 { 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 ? `

Note from our team:

${safeNotes}

` : ""; 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: `

Update on your reactivation request

Hello ${safeName},

Thank you for your reactivation request for ${safeCompany}. Unfortunately, we were unable to approve it at this time.

${notesHtml}

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.


PieCed IT — Hosted on-premises in Switzerland

`, }); } catch (err) { console.error("Failed to send resume rejection email:", err); } } export async function sendAdminNotificationEmail( companyName: string, contactName: string, contactEmail: string ): Promise { 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: `

New onboarding request

A new onboarding request has been submitted.

Company:${safeCompany}
Contact:${safeName} (${safeEmail})

Review Request


PieCed IT — Hosted on-premises in Switzerland

`, }); } catch (err) { console.error("Failed to send admin notification email:", err); } } // --------------------------------------------------------------------------- // Feature 5: support ticket emails // --------------------------------------------------------------------------- /** * Email subject prefix that helps customers thread tickets in their * mail client. We don't have inbound email processing — replies via * email back to us go nowhere — but the prefix is still useful for * the customer's own organisation. The id is shortened to 8 chars * for human readability; collisions on the truncated form within a * single user's inbox are vanishingly unlikely. */ function ticketSubjectPrefix(ticketId: string): string { return `[PieCed Support #${ticketId.slice(0, 8)}]`; } const STATUS_LABELS_EN: Record = { open: "Open", in_progress: "In progress", waiting_for_customer: "Waiting for your reply", resolved: "Resolved", reopened: "Reopened", }; /** * Sent to the customer when they create a ticket — confirmation * that we received it and a copy of the ticket id for their records. */ export async function sendSupportTicketCreatedEmail(params: { to: string; contactName: string; ticketId: string; title: string; }): Promise { const safeName = escapeHtml(params.contactName); const safeTitle = escapeHtml(params.title); const shortId = params.ticketId.slice(0, 8); const subject = `${ticketSubjectPrefix(params.ticketId)} ${params.title}`; try { await getTransporter().sendMail({ from: getFrom(), to: params.to, subject, text: [ `Hello ${params.contactName},`, "", `We've received your support request "${params.title}" (reference #${shortId}).`, "", "Our team will review and respond as soon as possible. You can track the status and reply at https://app.pieced.ch/support.", "", "Best regards,", "PieCed IT", ].join("\n"), html: `

Support request received

Hello ${safeName},

We've received your support request "${safeTitle}" (reference #${shortId}).

Our team will review and respond as soon as possible.

View ticket


PieCed IT — Hosted on-premises in Switzerland

`, }); } catch (err) { console.error("Failed to send support ticket creation email:", err); } } /** * Sent to the customer when an admin replies to one of their tickets. * Includes the body of the reply inline so the customer can read it * without clicking through (especially useful on mobile). */ export async function sendSupportTicketReplyEmail(params: { to: string; contactName: string; ticketId: string; title: string; authorName: string; body: string; }): Promise { const safeName = escapeHtml(params.contactName); const safeTitle = escapeHtml(params.title); const safeAuthor = escapeHtml(params.authorName); const safeBody = escapeHtml(params.body); const shortId = params.ticketId.slice(0, 8); const subject = `${ticketSubjectPrefix(params.ticketId)} Re: ${params.title}`; try { await getTransporter().sendMail({ from: getFrom(), to: params.to, subject, text: [ `Hello ${params.contactName},`, "", `${params.authorName} replied to your ticket "${params.title}" (#${shortId}):`, "", params.body, "", "Reply or follow up at https://app.pieced.ch/support.", "", "Best regards,", "PieCed IT", ].join("\n"), html: `

New reply on your ticket

Hello ${safeName},

${safeAuthor} replied to your ticket "${safeTitle}" (#${shortId}):

${safeBody}

View ticket


PieCed IT — Hosted on-premises in Switzerland

`, }); } catch (err) { console.error("Failed to send support ticket reply email:", err); } } /** * Sent to the customer when an admin changes status without a comment. * If the same admin action included a comment, they'd get the * reply email instead — caller decides which to send. */ export async function sendSupportTicketStatusEmail(params: { to: string; contactName: string; ticketId: string; title: string; newStatus: string; }): Promise { const safeName = escapeHtml(params.contactName); const safeTitle = escapeHtml(params.title); const statusLabel = STATUS_LABELS_EN[params.newStatus] ?? params.newStatus; const shortId = params.ticketId.slice(0, 8); const subject = `${ticketSubjectPrefix(params.ticketId)} Status: ${statusLabel}`; try { await getTransporter().sendMail({ from: getFrom(), to: params.to, subject, text: [ `Hello ${params.contactName},`, "", `The status of your ticket "${params.title}" (#${shortId}) has been updated to: ${statusLabel}.`, "", "View details and respond if needed at https://app.pieced.ch/support.", "", "Best regards,", "PieCed IT", ].join("\n"), html: `

Ticket status update

Hello ${safeName},

The status of your ticket "${safeTitle}" (#${shortId}) has been updated to:

${escapeHtml(statusLabel)}

View ticket


PieCed IT — Hosted on-premises in Switzerland

`, }); } catch (err) { console.error("Failed to send support ticket status email:", err); } } /** * Notify the platform admin distribution list of a new ticket OR a * customer reply. Mirror of sendAdminNotificationEmail's pattern — * uses the same ADMIN_NOTIFICATION_EMAIL env var. * * Two trigger reasons supported: * - 'created' → new ticket from a customer * - 'replied' → customer replied to existing ticket (we want admin * visibility, e.g. to know the ticket needs another * round of attention) */ export async function sendSupportAdminNotificationEmail(params: { reason: "created" | "replied"; ticketId: string; title: string; contactName: string; contactEmail: string; body?: string; // The new message content (description on create, comment body on reply) category?: string; }): Promise { const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL; if (!adminEmail) { console.warn( "ADMIN_NOTIFICATION_EMAIL not set; skipping admin support notification" ); return; } const safeContact = escapeHtml(params.contactName); const safeContactEmail = escapeHtml(params.contactEmail); const safeTitle = escapeHtml(params.title); const safeBody = params.body ? escapeHtml(params.body) : ""; const shortId = params.ticketId.slice(0, 8); const subjectVerb = params.reason === "created" ? "New" : "Reply on"; const subject = `${ticketSubjectPrefix(params.ticketId)} ${subjectVerb}: ${params.title}`; const headlineHtml = params.reason === "created" ? `

New support ticket

` : `

Customer replied on ticket

`; try { await getTransporter().sendMail({ from: getFrom(), to: adminEmail, subject, text: [ params.reason === "created" ? "A new support ticket was opened:" : "A customer replied to a support ticket:", "", `From: ${params.contactName} <${params.contactEmail}>`, `Ticket: ${params.title} (#${shortId})`, params.category ? `Category: ${params.category}` : "", "", params.body ? "Message:" : "", params.body ?? "", "", `View at https://app.pieced.ch/support/${params.ticketId}`, ] .filter((s) => s !== "") .join("\n"), html: `
${headlineHtml} ${params.category ? `` : ""}
From${safeContact} <${safeContactEmail}>
Title${safeTitle} (#${shortId})
Category${escapeHtml(params.category)}
${ params.body ? `
${safeBody}
` : "" }

Open in admin queue


PieCed IT — Admin notification

`, }); } catch (err) { console.error("Failed to send admin support notification:", err); } }