This commit is contained in:
279
src/lib/email.ts
279
src/lib/email.ts
@@ -318,3 +318,282 @@ export async function sendAdminNotificationEmail(
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user