726 lines
30 KiB
TypeScript
726 lines
30 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)
|
|
* SUPPORT_CONTACT_EMAIL — e.g. support@pieced.ch (optional)
|
|
* Customer-facing address for "have
|
|
* questions?" follow-ups in
|
|
* transactional emails. The from
|
|
* address itself (SMTP_USER) is
|
|
* typically a noreply mailbox, so we
|
|
* don't tell customers to "reply to
|
|
* this email" — instead we point them
|
|
* at this monitored address. If
|
|
* unset, the contact-prompt line is
|
|
* simply omitted from emails.
|
|
*/
|
|
|
|
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}>`
|
|
);
|
|
}
|
|
|
|
/** Returns the customer-facing support email address, or null if unset. */
|
|
function getSupportContactEmail(): string | null {
|
|
const v = process.env.SUPPORT_CONTACT_EMAIL?.trim();
|
|
return v && v.length > 0 ? v : null;
|
|
}
|
|
|
|
/**
|
|
* Escape HTML entities to prevent injection in HTML emails.
|
|
*/
|
|
function escapeHtml(str: string): string {
|
|
return str
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
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>`
|
|
: "";
|
|
|
|
const supportEmail = getSupportContactEmail();
|
|
// The customer here is rejected pre-onboarding — they don't yet
|
|
// have a portal account, so we can't send them to /support.
|
|
// Instead point at the configured support address (if set).
|
|
// If unset (e.g. early pilot before a support inbox exists), we
|
|
// omit the follow-up line entirely rather than promise something
|
|
// that goes nowhere — telling the customer to "reply to this
|
|
// email" would be misleading because we send from a noreply box.
|
|
const contactLineText = supportEmail
|
|
? `If you have questions or would like to discuss this further, please contact us at ${supportEmail}.`
|
|
: "";
|
|
const contactLineHtml = supportEmail
|
|
? `<p>If you have questions or would like to discuss this further, please contact us at <a href="mailto:${escapeHtml(supportEmail)}" style="color: #3b82f6;">${escapeHtml(supportEmail)}</a>.</p>`
|
|
: "";
|
|
|
|
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,
|
|
contactLineText,
|
|
"",
|
|
"Best regards,",
|
|
"PieCed IT",
|
|
]
|
|
.filter((s) => s !== "")
|
|
.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}
|
|
${contactLineHtml}
|
|
<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>`
|
|
: "";
|
|
|
|
// The customer has portal access (their tenant exists, they
|
|
// just had a resume request rejected), so direct them to the
|
|
// support ticket system for follow-up. We never tell them to
|
|
// "reply to this email" because the from address is a noreply
|
|
// mailbox.
|
|
const contactLineText =
|
|
"If you have questions, open a support ticket at https://app.pieced.ch/support.";
|
|
const contactLineHtml = `<p>If you have questions, <a href="https://app.pieced.ch/support" style="color: #3b82f6;">open a support ticket</a>.</p>`;
|
|
|
|
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.",
|
|
"",
|
|
contactLineText,
|
|
"",
|
|
"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>
|
|
${contactLineHtml}
|
|
<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);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Feature 6: resume-request admin notification
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Notify the admin distribution list that a customer has requested
|
|
* reactivation of a suspended tenant. Distinct from the onboarding
|
|
* notification because the action consequences differ (admin
|
|
* approving a resume just unsuspends an existing tenant; no
|
|
* provisioning runs), and because the customer's note — explaining
|
|
* why they want reactivation — is meaningful context for the admin
|
|
* triaging the queue.
|
|
*
|
|
* Skipped silently if ADMIN_NOTIFICATION_EMAIL isn't set, matching
|
|
* the pattern of the other admin notification functions.
|
|
*/
|
|
export async function sendResumeRequestAdminNotificationEmail(params: {
|
|
tenantName: string;
|
|
companyName: string;
|
|
contactName: string;
|
|
contactEmail: string;
|
|
customerNotes?: string | null;
|
|
}): Promise<void> {
|
|
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
|
|
if (!adminEmail) return;
|
|
|
|
const safeCompany = escapeHtml(params.companyName);
|
|
const safeName = escapeHtml(params.contactName);
|
|
const safeEmail = escapeHtml(params.contactEmail);
|
|
const safeTenant = escapeHtml(params.tenantName);
|
|
const safeNotes = params.customerNotes ? escapeHtml(params.customerNotes) : "";
|
|
|
|
const noteText = params.customerNotes
|
|
? `\nCustomer's note:\n${params.customerNotes}\n`
|
|
: "";
|
|
const noteHtml = safeNotes
|
|
? `<div style="background: #2a2a2a; border-left: 3px solid #3b82f6; padding: 12px 16px; border-radius: 6px; margin: 16px 0; white-space: pre-wrap;">
|
|
<p style="color: #ccc; font-size: 13px; margin: 0 0 8px 0;"><strong>Customer's note:</strong></p>
|
|
<p style="color: #e0e0e0; font-size: 13px; margin: 0;">${safeNotes}</p>
|
|
</div>`
|
|
: "";
|
|
|
|
try {
|
|
await getTransporter().sendMail({
|
|
from: getFrom(),
|
|
to: adminEmail,
|
|
subject: `Reactivation request: ${params.companyName}`,
|
|
text: [
|
|
`A customer has requested reactivation of a suspended tenant.`,
|
|
"",
|
|
`Company: ${params.companyName}`,
|
|
`Tenant: ${params.tenantName}`,
|
|
`Contact: ${params.contactName} (${params.contactEmail})`,
|
|
noteText,
|
|
`Review at https://app.pieced.ch/admin`,
|
|
]
|
|
.filter((s) => s !== "")
|
|
.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;">Reactivation request</h2>
|
|
<p>A customer has requested reactivation of a suspended tenant.</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;">Tenant:</td><td style="font-family: monospace;">${safeTenant}</td></tr>
|
|
<tr><td style="padding: 4px 12px 4px 0; color: #888;">Contact:</td><td>${safeName} (${safeEmail})</td></tr>
|
|
</table>
|
|
${noteHtml}
|
|
<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 — Admin notification</p>
|
|
</div>
|
|
`,
|
|
});
|
|
} catch (err) {
|
|
console.error("Failed to send resume request admin notification:", 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<string, string> = {
|
|
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<void> {
|
|
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: `
|
|
<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;">Support request received</h2>
|
|
<p>Hello ${safeName},</p>
|
|
<p>We've received your support request <strong>"${safeTitle}"</strong> (reference #${shortId}).</p>
|
|
<p>Our team will review and respond as soon as possible.</p>
|
|
<p>
|
|
<a href="https://app.pieced.ch/support" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
|
|
View ticket
|
|
</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 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<void> {
|
|
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: `
|
|
<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 reply on your ticket</h2>
|
|
<p>Hello ${safeName},</p>
|
|
<p><strong>${safeAuthor}</strong> replied to your ticket <strong>"${safeTitle}"</strong> (#${shortId}):</p>
|
|
<div style="background: #2a2a2a; border-left: 3px solid #3b82f6; padding: 12px 16px; border-radius: 6px; margin: 16px 0; white-space: pre-wrap;">
|
|
${safeBody}
|
|
</div>
|
|
<p>
|
|
<a href="https://app.pieced.ch/support" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
|
|
View ticket
|
|
</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 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<void> {
|
|
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: `
|
|
<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;">Ticket status update</h2>
|
|
<p>Hello ${safeName},</p>
|
|
<p>The status of your ticket <strong>"${safeTitle}"</strong> (#${shortId}) has been updated to:</p>
|
|
<p style="font-size: 18px; color: #3b82f6; font-weight: 600;">${escapeHtml(statusLabel)}</p>
|
|
<p>
|
|
<a href="https://app.pieced.ch/support" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
|
|
View ticket
|
|
</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 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<void> {
|
|
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"
|
|
? `<h2 style="color: #ffffff; margin-top: 0;">New support ticket</h2>`
|
|
: `<h2 style="color: #ffffff; margin-top: 0;">Customer replied on ticket</h2>`;
|
|
|
|
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: `
|
|
<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;">
|
|
${headlineHtml}
|
|
<table style="width:100%; font-size: 13px; color: #aaa; margin-bottom: 16px;">
|
|
<tr><td style="padding: 4px 0; width: 100px;">From</td><td style="padding: 4px 0; color: #fff;">${safeContact} <${safeContactEmail}></td></tr>
|
|
<tr><td style="padding: 4px 0;">Title</td><td style="padding: 4px 0; color: #fff;">${safeTitle} <span style="color: #666;">(#${shortId})</span></td></tr>
|
|
${params.category ? `<tr><td style="padding: 4px 0;">Category</td><td style="padding: 4px 0; color: #fff;">${escapeHtml(params.category)}</td></tr>` : ""}
|
|
</table>
|
|
${
|
|
params.body
|
|
? `<div style="background: #2a2a2a; border-left: 3px solid #3b82f6; padding: 12px 16px; border-radius: 6px; margin: 16px 0; white-space: pre-wrap;">${safeBody}</div>`
|
|
: ""
|
|
}
|
|
<p>
|
|
<a href="https://app.pieced.ch/support/${params.ticketId}" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
|
|
Open in admin queue
|
|
</a>
|
|
</p>
|
|
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
|
|
<p style="color: #666; font-size: 12px;">PieCed IT — Admin notification</p>
|
|
</div>
|
|
`,
|
|
});
|
|
} catch (err) {
|
|
console.error("Failed to send admin support notification:", err);
|
|
}
|
|
}
|