1324 lines
59 KiB
TypeScript
1324 lines
59 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);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Skill activation requests — Phase 2.5
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// Three notifications:
|
||
//
|
||
// sendSkillActivationAdminNotification — to ADMIN_NOTIFICATION_EMAIL
|
||
// when a customer requests a
|
||
// flagged skill.
|
||
//
|
||
// sendSkillActivationApprovalEmail — to the customer, on approve.
|
||
//
|
||
// sendSkillActivationRejectionEmail — to the customer, on reject,
|
||
// including the admin's reason.
|
||
//
|
||
// All three follow the existing patterns in this file (HTML + plaintext,
|
||
// escaped vars, best-effort with errors logged not thrown).
|
||
|
||
/**
|
||
* Notify admin (ADMIN_NOTIFICATION_EMAIL) that a customer has
|
||
* requested activation of a manual-setup skill. The skill name +
|
||
* tenant + requester are all included so admin can act without
|
||
* loading the portal.
|
||
*/
|
||
export async function sendSkillActivationAdminNotification(params: {
|
||
tenantName: string;
|
||
skillId: string;
|
||
skillName: string;
|
||
requesterEmail: string;
|
||
requesterName: string;
|
||
companyName: string | null;
|
||
}): Promise<void> {
|
||
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
|
||
if (!adminEmail) return;
|
||
const safeTenant = escapeHtml(params.tenantName);
|
||
const safeSkillId = escapeHtml(params.skillId);
|
||
const safeSkillName = escapeHtml(params.skillName);
|
||
const safeRequester = escapeHtml(params.requesterName);
|
||
const safeRequesterEmail = escapeHtml(params.requesterEmail);
|
||
const safeCompany = params.companyName
|
||
? escapeHtml(params.companyName)
|
||
: "—";
|
||
try {
|
||
await getTransporter().sendMail({
|
||
from: getFrom(),
|
||
to: adminEmail,
|
||
subject: `[PieCed] Skill activation requested — ${params.skillName} on ${params.tenantName}`,
|
||
text: [
|
||
"A customer has requested activation of a manual-setup skill.",
|
||
"",
|
||
`Skill: ${params.skillName} (${params.skillId})`,
|
||
`Tenant: ${params.tenantName}`,
|
||
`Organization:${params.companyName ?? "—"}`,
|
||
`Requested by:${params.requesterName} <${params.requesterEmail}>`,
|
||
"",
|
||
"Review and act in the admin queue:",
|
||
"https://app.pieced.ch/admin/skills/pending",
|
||
].join("\n"),
|
||
html: `
|
||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
|
||
<h2 style="margin: 0 0 16px; color: #10B981;">Skill activation requested</h2>
|
||
<p>A customer has requested activation of a manual-setup skill.</p>
|
||
<table style="width:100%; border-collapse: collapse; margin: 12px 0;">
|
||
<tr><td style="color:#888; padding:4px 0;">Skill</td><td>${safeSkillName} (<code>${safeSkillId}</code>)</td></tr>
|
||
<tr><td style="color:#888; padding:4px 0;">Tenant</td><td><code>${safeTenant}</code></td></tr>
|
||
<tr><td style="color:#888; padding:4px 0;">Organization</td><td>${safeCompany}</td></tr>
|
||
<tr><td style="color:#888; padding:4px 0;">Requested by</td><td>${safeRequester} <${safeRequesterEmail}></td></tr>
|
||
</table>
|
||
<p>
|
||
<a href="https://app.pieced.ch/admin/skills/pending" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||
Open 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 skill activation admin notification:", err);
|
||
}
|
||
}
|
||
|
||
export async function sendSkillActivationApprovalEmail(params: {
|
||
to: string;
|
||
contactName: string;
|
||
skillName: string;
|
||
tenantName: string;
|
||
}): Promise<void> {
|
||
const safeName = escapeHtml(params.contactName);
|
||
const safeSkill = escapeHtml(params.skillName);
|
||
const safeTenant = escapeHtml(params.tenantName);
|
||
try {
|
||
await getTransporter().sendMail({
|
||
from: getFrom(),
|
||
to: params.to,
|
||
subject: `Your skill activation has been approved — ${params.skillName}`,
|
||
text: [
|
||
`Hello ${params.contactName},`,
|
||
"",
|
||
`Good news — your request to activate "${params.skillName}" on tenant ${params.tenantName} has been approved and the skill is now live.`,
|
||
"",
|
||
"You can manage it from your tenant settings.",
|
||
"",
|
||
"Best regards,",
|
||
"PieCed IT",
|
||
].join("\n"),
|
||
html: `
|
||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width:560px; padding:24px; background:#1a1a1a; color:#e5e5e5;">
|
||
<h2 style="margin:0 0 16px; color:#10B981;">Skill approved & activated</h2>
|
||
<p>Hello ${safeName},</p>
|
||
<p>Your request to activate <strong>${safeSkill}</strong> on tenant <code>${safeTenant}</code> has been approved and the skill is now live.</p>
|
||
<p>You can manage it from your tenant settings.</p>
|
||
<p>
|
||
<a href="https://app.pieced.ch/tenants/${encodeURIComponent(params.tenantName)}" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||
Open tenant
|
||
</a>
|
||
</p>
|
||
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||
<p style="color:#666; font-size:12px;">PieCed IT</p>
|
||
</div>
|
||
`,
|
||
});
|
||
} catch (err) {
|
||
console.error("Failed to send skill activation approval email:", err);
|
||
}
|
||
}
|
||
|
||
export async function sendSkillActivationRejectionEmail(params: {
|
||
to: string;
|
||
contactName: string;
|
||
skillName: string;
|
||
tenantName: string;
|
||
reason: string;
|
||
}): Promise<void> {
|
||
const safeName = escapeHtml(params.contactName);
|
||
const safeSkill = escapeHtml(params.skillName);
|
||
const safeTenant = escapeHtml(params.tenantName);
|
||
const safeReason = escapeHtml(params.reason);
|
||
try {
|
||
await getTransporter().sendMail({
|
||
from: getFrom(),
|
||
to: params.to,
|
||
subject: `Update on your skill activation request — ${params.skillName}`,
|
||
text: [
|
||
`Hello ${params.contactName},`,
|
||
"",
|
||
`We were unable to approve your request to activate "${params.skillName}" on tenant ${params.tenantName}.`,
|
||
"",
|
||
"Reason from our team:",
|
||
params.reason,
|
||
"",
|
||
"You can try again from your tenant settings once the matter is resolved.",
|
||
"",
|
||
"Best regards,",
|
||
"PieCed IT",
|
||
].join("\n"),
|
||
html: `
|
||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width:560px; padding:24px; background:#1a1a1a; color:#e5e5e5;">
|
||
<h2 style="margin:0 0 16px; color:#ef4444;">Activation request not approved</h2>
|
||
<p>Hello ${safeName},</p>
|
||
<p>We were unable to approve your request to activate <strong>${safeSkill}</strong> on tenant <code>${safeTenant}</code>.</p>
|
||
<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>Reason from our team:</strong></p>
|
||
<p style="color:#aaa; font-size:13px; margin:8px 0 0 0; white-space:pre-wrap;">${safeReason}</p>
|
||
</div>
|
||
<p>You can try again from your tenant settings once the matter is resolved.</p>
|
||
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||
<p style="color:#666; font-size:12px;">PieCed IT</p>
|
||
</div>
|
||
`,
|
||
});
|
||
} catch (err) {
|
||
console.error("Failed to send skill activation rejection email:", err);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Invoice issuance — Phase 3
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Notify the billing contact when a new invoice has been issued.
|
||
* Includes a brief summary (total + due date + line count) so the
|
||
* recipient can triage without opening the portal, plus a deep
|
||
* link to /billing/<invoice number> where they can download the
|
||
* PDF. The PDF itself is NOT attached — it lives in the portal,
|
||
* keeps mail payloads small, and avoids the audit-trail headache
|
||
* of "which copy is authoritative".
|
||
*/
|
||
export async function sendInvoiceIssuedEmail(params: {
|
||
to: string;
|
||
contactName: string;
|
||
companyName: string;
|
||
invoiceNumber: string;
|
||
totalChf: number;
|
||
currency: string; // "CHF" — passed for future-proofing
|
||
dueAt: string; // ISO date
|
||
lineCount: number;
|
||
periodStart: string | null; // ISO date; null for custom invoices
|
||
periodEnd: string | null; // ISO date; null for custom invoices
|
||
locale: "de" | "en" | "fr" | "it";
|
||
}): Promise<void> {
|
||
// All four locales — the email is sent in the invoice's locale,
|
||
// which was frozen at issue time. No fallback to admin's locale.
|
||
const L = params.locale;
|
||
const subjectsByLocale: Record<typeof L, string> = {
|
||
en: `New invoice ${params.invoiceNumber} from PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
|
||
de: `Neue Rechnung ${params.invoiceNumber} von PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
|
||
fr: `Nouvelle facture ${params.invoiceNumber} de PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
|
||
it: `Nuova fattura ${params.invoiceNumber} da PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
|
||
};
|
||
const greetingsByLocale: Record<typeof L, string> = {
|
||
en: `Hello ${params.contactName},`,
|
||
de: `Sehr geehrte/r ${params.contactName},`,
|
||
fr: `Bonjour ${params.contactName},`,
|
||
it: `Gentile ${params.contactName},`,
|
||
};
|
||
const introByLocale: Record<typeof L, string> = {
|
||
en: `A new invoice has been issued for ${params.companyName}.`,
|
||
de: `Für ${params.companyName} wurde eine neue Rechnung ausgestellt.`,
|
||
fr: `Une nouvelle facture a été émise pour ${params.companyName}.`,
|
||
it: `È stata emessa una nuova fattura per ${params.companyName}.`,
|
||
};
|
||
const labels: Record<typeof L, Record<string, string>> = {
|
||
en: { number: "Invoice", period: "Period", total: "Total", due: "Due by", lines: "Line items", cta: "View invoice & download PDF", signoff: "Best regards", brand: "PieCed IT" },
|
||
de: { number: "Rechnung", period: "Zeitraum", total: "Gesamt", due: "Zahlbar bis", lines: "Positionen", cta: "Rechnung ansehen & PDF herunterladen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
|
||
fr: { number: "Facture", period: "Période", total: "Total", due: "À régler avant", lines: "Lignes", cta: "Voir la facture & télécharger le PDF", signoff: "Cordialement", brand: "PieCed IT" },
|
||
it: { number: "Fattura", period: "Periodo", total: "Totale", due: "Scadenza", lines: "Voci", cta: "Visualizza fattura & scarica PDF", signoff: "Cordiali saluti", brand: "PieCed IT" },
|
||
};
|
||
const l = labels[L];
|
||
|
||
const safeName = escapeHtml(params.contactName);
|
||
const safeCompany = escapeHtml(params.companyName);
|
||
const safeNumber = escapeHtml(params.invoiceNumber);
|
||
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
||
// Phase 8: period is null for custom invoices. When missing, the
|
||
// template skips the "Service period:" line entirely; otherwise
|
||
// it renders the date range as before.
|
||
const periodFmt =
|
||
params.periodStart && params.periodEnd
|
||
? `${params.periodStart.slice(0, 10)} → ${params.periodEnd.slice(0, 10)}`
|
||
: null;
|
||
const dueFmt = params.dueAt.slice(0, 10);
|
||
|
||
// Both bodies built in the invoice's locale.
|
||
const link = `https://app.pieced.ch/billing/${encodeURIComponent(params.invoiceNumber)}`;
|
||
|
||
try {
|
||
await getTransporter().sendMail({
|
||
from: getFrom(),
|
||
to: params.to,
|
||
subject: subjectsByLocale[L],
|
||
text: [
|
||
greetingsByLocale[L],
|
||
"",
|
||
introByLocale[L],
|
||
"",
|
||
`${l.number}: ${params.invoiceNumber}`,
|
||
// Phase 8: omit the period line entirely for custom
|
||
// invoices (which have no billing period).
|
||
...(periodFmt ? [`${l.period}: ${periodFmt}`] : []),
|
||
`${l.total}: ${totalFmt}`,
|
||
`${l.due}: ${dueFmt}`,
|
||
`${l.lines}: ${params.lineCount}`,
|
||
"",
|
||
`${l.cta}:`,
|
||
link,
|
||
"",
|
||
`${l.signoff},`,
|
||
l.brand,
|
||
].join("\n"),
|
||
html: `
|
||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
|
||
<h2 style="margin: 0 0 16px; color: #10B981;">${escapeHtml(introByLocale[L])}</h2>
|
||
<p>${escapeHtml(greetingsByLocale[L])}</p>
|
||
<p>${escapeHtml(introByLocale[L])}</p>
|
||
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
||
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
|
||
${periodFmt ? `<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>` : ""}
|
||
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#10B981; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||
<tr><td style="color:#888; padding:6px 0;">${l.lines}</td><td>${params.lineCount}</td></tr>
|
||
</table>
|
||
<p>
|
||
<a href="${link}" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||
${l.cta}
|
||
</a>
|
||
</p>
|
||
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||
<p style="color:#666; font-size:12px;">${l.brand}</p>
|
||
</div>
|
||
`,
|
||
});
|
||
} catch (err) {
|
||
console.error("Failed to send invoice issued email:", err);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Reminder emails — Phase 5
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Send a payment reminder for an open/overdue invoice.
|
||
*
|
||
* Three escalation levels:
|
||
* 1 — Gentle nudge: ~7 days past due. Friendly tone, "in case
|
||
* you missed it".
|
||
* 2 — Firmer reminder: ~14 days past due. Clear that payment is
|
||
* outstanding, please pay.
|
||
* 3 — Final notice: ~30 days past due. Explicit consequences
|
||
* (service may be suspended). Last automated touch — beyond
|
||
* this, admin involvement is expected.
|
||
*
|
||
* Failure is logged, never thrown — the cron sweep must continue
|
||
* past a single failed send.
|
||
*/
|
||
export async function sendInvoiceReminderEmail(params: {
|
||
to: string;
|
||
contactName: string;
|
||
companyName: string;
|
||
invoiceNumber: string;
|
||
totalChf: number;
|
||
currency: string;
|
||
dueAt: string;
|
||
daysPastDue: number;
|
||
level: 1 | 2 | 3;
|
||
locale: "de" | "en" | "fr" | "it";
|
||
}): Promise<void> {
|
||
const L = params.locale;
|
||
// Per-locale strings keyed by the three escalation levels.
|
||
// Kept inline (rather than the next-intl message files) because
|
||
// the email layer doesn't import from React's i18n context.
|
||
const SUBJECTS: Record<typeof L, Record<1 | 2 | 3, string>> = {
|
||
en: {
|
||
1: `Friendly reminder: invoice ${params.invoiceNumber} is overdue`,
|
||
2: `Second reminder: invoice ${params.invoiceNumber} is still unpaid`,
|
||
3: `Final notice: invoice ${params.invoiceNumber} requires immediate payment`,
|
||
},
|
||
de: {
|
||
1: `Freundliche Erinnerung: Rechnung ${params.invoiceNumber} ist überfällig`,
|
||
2: `Zweite Mahnung: Rechnung ${params.invoiceNumber} ist weiterhin unbezahlt`,
|
||
3: `Letzte Mahnung: Rechnung ${params.invoiceNumber} erfordert sofortige Zahlung`,
|
||
},
|
||
fr: {
|
||
1: `Rappel amical : la facture ${params.invoiceNumber} est en retard`,
|
||
2: `Deuxième rappel : la facture ${params.invoiceNumber} reste impayée`,
|
||
3: `Dernier avis : la facture ${params.invoiceNumber} doit être réglée sans délai`,
|
||
},
|
||
it: {
|
||
1: `Promemoria amichevole: la fattura ${params.invoiceNumber} è scaduta`,
|
||
2: `Secondo sollecito: la fattura ${params.invoiceNumber} è ancora insoluta`,
|
||
3: `Avviso finale: la fattura ${params.invoiceNumber} richiede pagamento immediato`,
|
||
},
|
||
};
|
||
const INTROS: Record<typeof L, Record<1 | 2 | 3, string>> = {
|
||
en: {
|
||
1: "We noticed this invoice hasn't been settled yet — in case it slipped through.",
|
||
2: "This invoice remains unpaid. Please arrange payment at your earliest convenience.",
|
||
3: "This invoice is significantly overdue. Service may be suspended if payment is not received promptly.",
|
||
},
|
||
de: {
|
||
1: "Diese Rechnung scheint noch nicht beglichen — falls sie übersehen wurde, möchten wir freundlich daran erinnern.",
|
||
2: "Diese Rechnung ist weiterhin unbezahlt. Bitte veranlassen Sie die Zahlung umgehend.",
|
||
3: "Diese Rechnung ist erheblich überfällig. Bei nicht zeitnaher Zahlung kann der Dienst ausgesetzt werden.",
|
||
},
|
||
fr: {
|
||
1: "Cette facture n'a pas encore été réglée — au cas où elle vous aurait échappé.",
|
||
2: "Cette facture reste impayée. Merci d'effectuer le paiement dans les meilleurs délais.",
|
||
3: "Cette facture est en grand retard. Le service pourra être suspendu en l'absence de paiement rapide.",
|
||
},
|
||
it: {
|
||
1: "Questa fattura non risulta ancora saldata — nel caso vi fosse sfuggita.",
|
||
2: "Questa fattura risulta ancora insoluta. Si prega di provvedere al pagamento al più presto.",
|
||
3: "Questa fattura è significativamente in ritardo. In assenza di pagamento tempestivo il servizio potrà essere sospeso.",
|
||
},
|
||
};
|
||
const LABELS: Record<typeof L, Record<string, string>> = {
|
||
en: { num: "Invoice", total: "Total", due: "Due date", days: "Days past due", cta: "View invoice & pay", signoff: "Best regards", brand: "PieCed IT", greeting: "Hello" },
|
||
de: { num: "Rechnung", total: "Gesamt", due: "Fälligkeitsdatum", days: "Tage überfällig", cta: "Rechnung ansehen & bezahlen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT", greeting: "Sehr geehrte/r" },
|
||
fr: { num: "Facture", total: "Total", due: "Échéance", days: "Jours de retard", cta: "Voir la facture & payer", signoff: "Cordialement", brand: "PieCed IT", greeting: "Bonjour" },
|
||
it: { num: "Fattura", total: "Totale", due: "Scadenza", days: "Giorni di ritardo", cta: "Vedi fattura & paga", signoff: "Cordiali saluti", brand: "PieCed IT", greeting: "Gentile" },
|
||
};
|
||
const l = LABELS[L];
|
||
const safeName = escapeHtml(params.contactName);
|
||
const safeCompany = escapeHtml(params.companyName);
|
||
const safeNumber = escapeHtml(params.invoiceNumber);
|
||
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
||
const dueFmt = params.dueAt.slice(0, 10);
|
||
const link = `https://app.pieced.ch/billing/${encodeURIComponent(params.invoiceNumber)}`;
|
||
// Final-notice gets red accent; earlier levels keep the brand green.
|
||
const accent = params.level === 3 ? "#dc2626" : "#10B981";
|
||
|
||
try {
|
||
await getTransporter().sendMail({
|
||
from: getFrom(),
|
||
to: params.to,
|
||
subject: SUBJECTS[L][params.level],
|
||
text: [
|
||
`${l.greeting} ${params.contactName},`,
|
||
"",
|
||
INTROS[L][params.level],
|
||
"",
|
||
`${l.num}: ${params.invoiceNumber}`,
|
||
`${l.total}: ${totalFmt}`,
|
||
`${l.due}: ${dueFmt}`,
|
||
`${l.days}: ${params.daysPastDue}`,
|
||
"",
|
||
`${l.cta}: ${link}`,
|
||
"",
|
||
`${l.signoff},`,
|
||
l.brand,
|
||
].join("\n"),
|
||
html: `
|
||
<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:560px;padding:24px;background:#1a1a1a;color:#e5e5e5;">
|
||
<h2 style="margin:0 0 16px;color:${accent};">${escapeHtml(SUBJECTS[L][params.level])}</h2>
|
||
<p>${l.greeting} ${safeName},</p>
|
||
<p>${escapeHtml(INTROS[L][params.level])}</p>
|
||
<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;">
|
||
<tr><td style="color:#888;padding:6px 0;width:140px;">${l.num}</td><td><strong>${safeNumber}</strong></td></tr>
|
||
<tr><td style="color:#888;padding:6px 0;">${l.total}</td><td style="color:${accent};font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||
<tr><td style="color:#888;padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||
<tr><td style="color:#888;padding:6px 0;">${l.days}</td><td>${params.daysPastDue}</td></tr>
|
||
</table>
|
||
<p>
|
||
<a href="${link}" style="display:inline-block;padding:10px 24px;background:${accent};color:#fff;text-decoration:none;border-radius:8px;font-weight:500;">
|
||
${l.cta}
|
||
</a>
|
||
</p>
|
||
<hr style="border:none;border-top:1px solid #333;margin:24px 0;" />
|
||
<p style="color:#666;font-size:12px;">${l.brand}</p>
|
||
</div>
|
||
`,
|
||
});
|
||
} catch (err) {
|
||
console.error(
|
||
`Failed to send reminder L${params.level} for invoice ${params.invoiceNumber}:`,
|
||
err
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Credit note emails — Phase 7
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Send a credit-note notification to the customer's billing email.
|
||
*
|
||
* Covers both kinds (void and refund). The subject and body adapt
|
||
* based on `kind` — voids ("we've cancelled invoice X, no payment
|
||
* needed") read very differently from refunds ("we've refunded CHF
|
||
* X, expect to see it on your card statement within 5-10 days").
|
||
*
|
||
* Link-only — the PDF is not attached. The customer downloads it
|
||
* from /api/credit-notes/<number>/pdf when they click through, which
|
||
* also gives them a permanent in-portal record next to their
|
||
* invoices. Same approach as invoice emails.
|
||
*
|
||
* Best-effort: failures are logged and swallowed. A mail-server
|
||
* hiccup must never roll back a credit-note issuance.
|
||
*/
|
||
export async function sendCreditNoteEmail(params: {
|
||
to: string;
|
||
contactName: string;
|
||
companyName: string;
|
||
creditNoteNumber: string;
|
||
invoiceNumber: string;
|
||
amountChf: number;
|
||
currency: string;
|
||
kind: "void" | "refund";
|
||
reason: string | null;
|
||
locale: "de" | "en" | "fr" | "it";
|
||
}): Promise<void> {
|
||
const L = params.locale;
|
||
const totalFmt = `${params.currency} ${params.amountChf.toFixed(2)}`;
|
||
const link = `https://app.pieced.ch/billing/cn/${encodeURIComponent(
|
||
params.creditNoteNumber
|
||
)}`;
|
||
|
||
// Subject lines diverge between void and refund — different
|
||
// mental models for the recipient. Void: "your charge is
|
||
// cancelled". Refund: "your money is on the way back".
|
||
const subjectsByLocale: Record<typeof L, { void: string; refund: string }> = {
|
||
en: {
|
||
void: `Invoice ${params.invoiceNumber} cancelled — credit note ${params.creditNoteNumber}`,
|
||
refund: `Refund of ${totalFmt} for invoice ${params.invoiceNumber} — credit note ${params.creditNoteNumber}`,
|
||
},
|
||
de: {
|
||
void: `Rechnung ${params.invoiceNumber} storniert — Gutschrift ${params.creditNoteNumber}`,
|
||
refund: `Rückerstattung ${totalFmt} für Rechnung ${params.invoiceNumber} — Gutschrift ${params.creditNoteNumber}`,
|
||
},
|
||
fr: {
|
||
void: `Facture ${params.invoiceNumber} annulée — note de crédit ${params.creditNoteNumber}`,
|
||
refund: `Remboursement ${totalFmt} pour la facture ${params.invoiceNumber} — note de crédit ${params.creditNoteNumber}`,
|
||
},
|
||
it: {
|
||
void: `Fattura ${params.invoiceNumber} annullata — nota di credito ${params.creditNoteNumber}`,
|
||
refund: `Rimborso ${totalFmt} per fattura ${params.invoiceNumber} — nota di credito ${params.creditNoteNumber}`,
|
||
},
|
||
};
|
||
|
||
const greetingsByLocale: Record<typeof L, string> = {
|
||
en: `Hello ${params.contactName},`,
|
||
de: `Sehr geehrte/r ${params.contactName},`,
|
||
fr: `Bonjour ${params.contactName},`,
|
||
it: `Gentile ${params.contactName},`,
|
||
};
|
||
|
||
// Intro: distinct phrasing per kind in each locale.
|
||
const introsByLocale: Record<typeof L, { void: string; refund: string }> = {
|
||
en: {
|
||
void: `We've cancelled invoice ${params.invoiceNumber}. The invoice is no longer payable, and a credit note has been issued for your records.`,
|
||
refund: `We've refunded ${totalFmt} for invoice ${params.invoiceNumber}. The refund will appear on the original payment method within 5–10 business days, depending on your bank.`,
|
||
},
|
||
de: {
|
||
void: `Wir haben Rechnung ${params.invoiceNumber} storniert. Die Rechnung ist nicht mehr zahlbar; eine Gutschrift wurde für Ihre Unterlagen ausgestellt.`,
|
||
refund: `Wir haben ${totalFmt} für Rechnung ${params.invoiceNumber} zurückerstattet. Der Betrag wird je nach Bank innerhalb von 5–10 Geschäftstagen auf dem ursprünglichen Zahlungsweg gutgeschrieben.`,
|
||
},
|
||
fr: {
|
||
void: `Nous avons annulé la facture ${params.invoiceNumber}. La facture n'est plus exigible ; une note de crédit a été émise pour vos archives.`,
|
||
refund: `Nous avons remboursé ${totalFmt} pour la facture ${params.invoiceNumber}. Le montant apparaîtra sur le moyen de paiement initial sous 5 à 10 jours ouvrés, selon votre banque.`,
|
||
},
|
||
it: {
|
||
void: `Abbiamo annullato la fattura ${params.invoiceNumber}. La fattura non è più dovuta; è stata emessa una nota di credito per la sua documentazione.`,
|
||
refund: `Abbiamo rimborsato ${totalFmt} per la fattura ${params.invoiceNumber}. L'importo apparirà sul metodo di pagamento originale entro 5–10 giorni lavorativi, a seconda della banca.`,
|
||
},
|
||
};
|
||
|
||
const labels: Record<typeof L, Record<string, string>> = {
|
||
en: { creditNote: "Credit note", invoice: "Invoice", amount: "Amount", reason: "Reason", cta: "View credit note & download PDF", signoff: "Best regards", brand: "PieCed IT" },
|
||
de: { creditNote: "Gutschrift", invoice: "Rechnung", amount: "Betrag", reason: "Begründung", cta: "Gutschrift ansehen & PDF herunterladen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
|
||
fr: { creditNote: "Note de crédit", invoice: "Facture", amount: "Montant", reason: "Motif", cta: "Voir la note de crédit & télécharger le PDF", signoff: "Cordialement", brand: "PieCed IT" },
|
||
it: { creditNote: "Nota di credito", invoice: "Fattura", amount: "Importo", reason: "Motivo", cta: "Visualizza nota di credito & scarica PDF", signoff: "Cordiali saluti", brand: "PieCed IT" },
|
||
};
|
||
const l = labels[L];
|
||
|
||
const subject = subjectsByLocale[L][params.kind];
|
||
const intro = introsByLocale[L][params.kind];
|
||
const safeName = escapeHtml(params.contactName);
|
||
const safeNumberCN = escapeHtml(params.creditNoteNumber);
|
||
const safeNumberINV = escapeHtml(params.invoiceNumber);
|
||
const safeReason = params.reason ? escapeHtml(params.reason) : null;
|
||
|
||
// PieCed brand emerald — same accent the invoice email uses.
|
||
// A credit note is still a PieCed IT document; the company
|
||
// identity stays consistent across the document family. The
|
||
// doc type is distinguished by the subject line and copy, not
|
||
// by colour.
|
||
const ACCENT = "#10B981";
|
||
|
||
try {
|
||
await getTransporter().sendMail({
|
||
from: getFrom(),
|
||
to: params.to,
|
||
subject,
|
||
text: [
|
||
greetingsByLocale[L],
|
||
"",
|
||
intro,
|
||
"",
|
||
`${l.creditNote}: ${params.creditNoteNumber}`,
|
||
`${l.invoice}: ${params.invoiceNumber}`,
|
||
`${l.amount}: ${totalFmt}`,
|
||
...(params.reason ? [`${l.reason}: ${params.reason}`] : []),
|
||
"",
|
||
`${l.cta}:`,
|
||
link,
|
||
"",
|
||
`${l.signoff},`,
|
||
l.brand,
|
||
].join("\n"),
|
||
html: `
|
||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
|
||
<h2 style="margin: 0 0 16px; color: ${ACCENT};">${escapeHtml(intro)}</h2>
|
||
<p>${safeName === "" ? "" : escapeHtml(greetingsByLocale[L])}</p>
|
||
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
||
<tr><td style="color:#888; padding:6px 0; width:140px;">${l.creditNote}</td><td><strong>${safeNumberCN}</strong></td></tr>
|
||
<tr><td style="color:#888; padding:6px 0;">${l.invoice}</td><td>${safeNumberINV}</td></tr>
|
||
<tr><td style="color:#888; padding:6px 0;">${l.amount}</td><td style="color:${ACCENT}; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||
${safeReason ? `<tr><td style="color:#888; padding:6px 0; vertical-align:top;">${l.reason}</td><td style="color:#bbb;">${safeReason}</td></tr>` : ""}
|
||
</table>
|
||
<p>
|
||
<a href="${link}" style="display:inline-block; padding:10px 24px; background:${ACCENT}; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||
${l.cta}
|
||
</a>
|
||
</p>
|
||
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||
<p style="color:#666; font-size:12px;">${l.brand}</p>
|
||
</div>
|
||
`,
|
||
});
|
||
} catch (err) {
|
||
console.error("Failed to send credit note email:", err);
|
||
}
|
||
}
|