/** * Email sending utility for the PieCed portal. * * Uses nodemailer with SMTP credentials from environment variables * (populated via ExternalSecret from OpenBao at pieced/portal/smtp). * * Env vars (from portal-smtp K8s secret): * SMTP_HOST — e.g. smtp.gmail.com * SMTP_PORT — e.g. 587 (default) * SMTP_USER — e.g. noreply@pieced.ch * SMTP_PASS — App Password * SMTP_FROM — e.g. "PieCed " * ADMIN_NOTIFICATION_EMAIL — e.g. admin@pieced.ch (optional) * 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, "'"); } export async function sendApprovalEmail( to: string, contactName: string, companyName: string ): Promise { const safeName = escapeHtml(contactName); const safeCompany = escapeHtml(companyName); try { await getTransporter().sendMail({ from: getFrom(), to, subject: `Your PieCed AI assistant is being set up — ${companyName}`, text: [ `Hello ${contactName},`, "", `Great news! Your onboarding request for ${companyName} has been approved.`, "", "Your AI assistant instance is now being provisioned. This usually takes a few minutes.", "You can check the status in your dashboard at https://app.pieced.ch", "", "Once your instance is ready, you'll see it on your dashboard and can start configuring it.", "", "Best regards,", "PieCed IT", ].join("\n"), html: `

Your AI assistant is being set up

Hello ${safeName},

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

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

Go to Dashboard

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


PieCed IT — Hosted on-premises in Switzerland

`, }); } catch (err) { console.error("Failed to send approval email:", err); } } export async function sendRejectionEmail( to: string, contactName: string, companyName: string, adminNotes?: string ): Promise { const safeName = escapeHtml(contactName); const safeCompany = escapeHtml(companyName); const safeNotes = adminNotes ? escapeHtml(adminNotes) : ""; try { const notesBlock = adminNotes ? `\nNote from our team:\n${adminNotes}\n` : ""; const notesHtml = safeNotes ? `

Note from our team:

${safeNotes}

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

If you have questions or would like to discuss this further, please contact us at ${escapeHtml(supportEmail)}.

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

Update on your onboarding request

Hello ${safeName},

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

${notesHtml} ${contactLineHtml}

PieCed IT — Hosted on-premises in Switzerland

`, }); } catch (err) { console.error("Failed to send rejection email:", err); } } /** * Bug 37a: separate email for resume request approval. The tenant * already exists; the message is "we're un-suspending it" rather than * "we're provisioning a new instance". Avoids confusing the customer * with onboarding language for a tenant they already had. */ export async function sendResumeApprovalEmail( to: string, contactName: string, companyName: string ): Promise { const safeName = escapeHtml(contactName); const safeCompany = escapeHtml(companyName); try { await getTransporter().sendMail({ from: getFrom(), to, subject: `Your PieCed AI assistant has been reactivated — ${companyName}`, text: [ `Hello ${contactName},`, "", `Good news — your reactivation request for ${companyName} has been approved.`, "", "Your AI assistant is being brought back online and should be ready in a few minutes.", "You can check the status in your dashboard at https://app.pieced.ch", "", "Best regards,", "PieCed IT", ].join("\n"), html: `

Your AI assistant has been reactivated

Hello ${safeName},

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

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

Go to Dashboard


PieCed IT — Hosted on-premises in Switzerland

`, }); } catch (err) { console.error("Failed to send resume approval email:", err); } } /** * Bug 37a: separate email for resume request rejection. Differs from * the onboarding rejection in two ways: it explicitly mentions the * tenant remains suspended, and it points the customer to the * 60-day retention window so they understand the deletion clock is * still ticking. The latter is important — a customer reading a * generic "request rejected" email might not realise their data is * still on a countdown. */ export async function sendResumeRejectionEmail( to: string, contactName: string, companyName: string, adminNotes?: string ): Promise { const safeName = escapeHtml(contactName); const safeCompany = escapeHtml(companyName); const safeNotes = adminNotes ? escapeHtml(adminNotes) : ""; try { const notesBlock = adminNotes ? `\nNote from our team:\n${adminNotes}\n` : ""; const notesHtml = safeNotes ? `

Note from our team:

${safeNotes}

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

If you have questions, open a support ticket.

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

Update on your reactivation request

Hello ${safeName},

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

${notesHtml}

Your tenant remains suspended. As a reminder, your data is preserved for 60 days from the original cancellation date, after which it will be permanently deleted. You can submit a new reactivation request at any time before then.

${contactLineHtml}

PieCed IT — Hosted on-premises in Switzerland

`, }); } catch (err) { console.error("Failed to send resume rejection email:", err); } } export async function sendAdminNotificationEmail( companyName: string, contactName: string, contactEmail: string ): Promise { const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL; if (!adminEmail) return; const safeCompany = escapeHtml(companyName); const safeName = escapeHtml(contactName); const safeEmail = escapeHtml(contactEmail); try { await getTransporter().sendMail({ from: getFrom(), to: adminEmail, subject: `New onboarding request: ${companyName}`, text: [ `A new onboarding request has been submitted.`, "", `Company: ${companyName}`, `Contact: ${contactName} (${contactEmail})`, "", `Review it at https://app.pieced.ch/admin`, ].join("\n"), html: `

New onboarding request

A new onboarding request has been submitted.

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

Review Request


PieCed IT — Hosted on-premises in Switzerland

`, }); } catch (err) { console.error("Failed to send admin notification email:", err); } } // --------------------------------------------------------------------------- // Feature 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 { 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 ? `

Customer's note:

${safeNotes}

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

Reactivation request

A customer has requested reactivation of a suspended tenant.

Company:${safeCompany}
Tenant:${safeTenant}
Contact:${safeName} (${safeEmail})
${noteHtml}

Review Request


PieCed IT — Admin notification

`, }); } 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 = { open: "Open", in_progress: "In progress", waiting_for_customer: "Waiting for your reply", resolved: "Resolved", reopened: "Reopened", }; /** * Sent to the customer when they create a ticket — confirmation * that we received it and a copy of the ticket id for their records. */ export async function sendSupportTicketCreatedEmail(params: { to: string; contactName: string; ticketId: string; title: string; }): Promise { const safeName = escapeHtml(params.contactName); const safeTitle = escapeHtml(params.title); const shortId = params.ticketId.slice(0, 8); const subject = `${ticketSubjectPrefix(params.ticketId)} ${params.title}`; try { await getTransporter().sendMail({ from: getFrom(), to: params.to, subject, text: [ `Hello ${params.contactName},`, "", `We've received your support request "${params.title}" (reference #${shortId}).`, "", "Our team will review and respond as soon as possible. You can track the status and reply at https://app.pieced.ch/support.", "", "Best regards,", "PieCed IT", ].join("\n"), html: `

Support request received

Hello ${safeName},

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

Our team will review and respond as soon as possible.

View ticket


PieCed IT — Hosted on-premises in Switzerland

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

New reply on your ticket

Hello ${safeName},

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

${safeBody}

View ticket


PieCed IT — Hosted on-premises in Switzerland

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

Ticket status update

Hello ${safeName},

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

${escapeHtml(statusLabel)}

View ticket


PieCed IT — Hosted on-premises in Switzerland

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

New support ticket

` : `

Customer replied on ticket

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

Open in admin queue


PieCed IT — Admin notification

`, }); } catch (err) { console.error("Failed to send admin support notification:", err); } } // --------------------------------------------------------------------------- // 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 { 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: `

Skill activation requested

A customer has requested activation of a manual-setup skill.

Skill${safeSkillName} (${safeSkillId})
Tenant${safeTenant}
Organization${safeCompany}
Requested by${safeRequester} <${safeRequesterEmail}>

Open admin queue


PieCed IT — Admin notification

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

Skill approved & activated

Hello ${safeName},

Your request to activate ${safeSkill} on tenant ${safeTenant} has been approved and the skill is now live.

You can manage it from your tenant settings.

Open tenant


PieCed IT

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

Activation request not approved

Hello ${safeName},

We were unable to approve your request to activate ${safeSkill} on tenant ${safeTenant}.

Reason from our team:

${safeReason}

You can try again from your tenant settings once the matter is resolved.


PieCed IT

`, }); } 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/ 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 { // 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 = { 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 = { en: `Hello ${params.contactName},`, de: `Sehr geehrte/r ${params.contactName},`, fr: `Bonjour ${params.contactName},`, it: `Gentile ${params.contactName},`, }; const introByLocale: Record = { 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> = { 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: `

${escapeHtml(introByLocale[L])}

${escapeHtml(greetingsByLocale[L])}

${escapeHtml(introByLocale[L])}

${periodFmt ? `` : ""}
${l.number}${safeNumber}
${l.period}${escapeHtml(periodFmt)}
${l.total}${escapeHtml(totalFmt)}
${l.due}${escapeHtml(dueFmt)}
${l.lines}${params.lineCount}

${l.cta}


${l.brand}

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

${escapeHtml(SUBJECTS[L][params.level])}

${l.greeting} ${safeName},

${escapeHtml(INTROS[L][params.level])}

${l.num}${safeNumber}
${l.total}${escapeHtml(totalFmt)}
${l.due}${escapeHtml(dueFmt)}
${l.days}${params.daysPastDue}

${l.cta}


${l.brand}

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

${escapeHtml(intro)}

${safeName === "" ? "" : escapeHtml(greetingsByLocale[L])}

${safeReason ? `` : ""}
${l.creditNote}${safeNumberCN}
${l.invoice}${safeNumberINV}
${l.amount}${escapeHtml(totalFmt)}
${l.reason}${safeReason}

${l.cta}


${l.brand}

`, }); } catch (err) { console.error("Failed to send credit note email:", err); } } // --------------------------------------------------------------------------- // Phase 9b-2 — auto-charge failure notice // --------------------------------------------------------------------------- /** * Sent when an off-session auto-charge attempt fails for an issued * invoice (card declined, expired, 3DS required, etc.). Customer * receives this in their billing-snapshot locale. Contains: * - Invoice number + amount + due date * - Failure reason (a short human-readable string from Stripe) * - Manual-pay link to /billing/ where they can * run the regular Pay-by-Card flow (which uses * setup_future_usage to also refresh the saved card) * * Critical: the failure reason from Stripe can contain sensitive * details (card BIN, country, etc.). We pass a sanitized short * string from the caller — never the full raw error. */ export async function sendAutoChargeFailedEmail(params: { to: string; contactName: string; companyName: string; invoiceNumber: string; totalChf: number; currency: string; dueAt: string; /** * Short, customer-safe reason. e.g. "Your card was declined." * or "Your card has expired." Caller maps Stripe error codes to * these strings; we never pass raw API error messages. */ reasonForCustomer: string; locale: "de" | "en" | "fr" | "it"; }): Promise { const L = params.locale; const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`; const dueFmt = params.dueAt.slice(0, 10); const baseUrl = process.env.APP_BASE_URL ?? "https://app.pieced.ch"; const link = `${baseUrl}/billing/${encodeURIComponent(params.invoiceNumber)}`; const subjectsByLocale: Record = { en: `Auto-charge failed for invoice ${params.invoiceNumber} — please pay manually`, de: `Auto-Abbuchung fehlgeschlagen für Rechnung ${params.invoiceNumber} — bitte manuell bezahlen`, fr: `Échec du prélèvement automatique pour la facture ${params.invoiceNumber} — merci de régler manuellement`, it: `Addebito automatico fallito per la fattura ${params.invoiceNumber} — la preghiamo di pagare manualmente`, }; const greetingsByLocale: Record = { en: `Hello ${params.contactName},`, de: `Sehr geehrte/r ${params.contactName},`, fr: `Bonjour ${params.contactName},`, it: `Gentile ${params.contactName},`, }; const introByLocale: Record = { en: `We were unable to charge your saved card for invoice ${params.invoiceNumber} (${params.companyName}).`, de: `Wir konnten die Rechnung ${params.invoiceNumber} (${params.companyName}) nicht über die hinterlegte Karte abbuchen.`, fr: `Nous n'avons pas pu débiter votre carte enregistrée pour la facture ${params.invoiceNumber} (${params.companyName}).`, it: `Non siamo riusciti ad addebitare la carta salvata per la fattura ${params.invoiceNumber} (${params.companyName}).`, }; const reasonLabel: Record = { en: "Reason given by the card network", de: "Vom Kartennetzwerk gemeldeter Grund", fr: "Motif communiqué par le réseau de carte", it: "Motivo comunicato dal circuito", }; const actionLineByLocale: Record = { en: `Please pay this invoice manually before ${dueFmt} to avoid service interruption. The "Pay with card" button below will both charge the invoice and update the card we have on file for future charges.`, de: `Bitte begleichen Sie diese Rechnung manuell vor dem ${dueFmt}, um eine Unterbrechung Ihres Dienstes zu vermeiden. Die Schaltfläche "Mit Karte bezahlen" unten begleicht die Rechnung und aktualisiert gleichzeitig die hinterlegte Karte für zukünftige Abbuchungen.`, fr: `Veuillez régler cette facture manuellement avant le ${dueFmt} pour éviter toute interruption du service. Le bouton "Payer par carte" ci-dessous règle la facture et met à jour la carte enregistrée pour les futurs prélèvements.`, it: `La preghiamo di saldare questa fattura manualmente entro il ${dueFmt} per evitare interruzioni del servizio. Il pulsante "Paga con carta" qui sotto salda la fattura e aggiorna allo stesso tempo la carta in archivio per gli addebiti futuri.`, }; const labels: Record> = { en: { number: "Invoice", total: "Total", due: "Due by", cta: "Pay with card", signoff: "Best regards", brand: "PieCed IT" }, de: { number: "Rechnung", total: "Gesamt", due: "Zahlbar bis", cta: "Mit Karte bezahlen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" }, fr: { number: "Facture", total: "Total", due: "À régler avant", cta: "Payer par carte", signoff: "Cordialement", brand: "PieCed IT" }, it: { number: "Fattura", total: "Totale", due: "Scadenza", cta: "Paga con carta", 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 safeReason = escapeHtml(params.reasonForCustomer); const safeIntro = escapeHtml(introByLocale[L]); const safeAction = escapeHtml(actionLineByLocale[L]); try { await getTransporter().sendMail({ from: getFrom(), to: params.to, subject: subjectsByLocale[L], text: [ greetingsByLocale[L], "", introByLocale[L], "", `${l.number}: ${params.invoiceNumber}`, `${l.total}: ${totalFmt}`, `${l.due}: ${dueFmt}`, "", `${reasonLabel[L]}: ${params.reasonForCustomer}`, "", actionLineByLocale[L], "", `${l.cta}:`, link, "", `${l.signoff},`, l.brand, ].join("\n"), html: `

${escapeHtml(subjectsByLocale[L])}

${escapeHtml(greetingsByLocale[L])}

${safeIntro}

${l.number}${safeNumber}
${l.total}${escapeHtml(totalFmt)}
${l.due}${escapeHtml(dueFmt)}
${escapeHtml(reasonLabel[L])}: ${safeReason}

${safeAction}

${l.cta}

${l.signoff},
${l.brand}

`, }); } catch (err) { console.error("Failed to send auto-charge-failed email:", err); } }