/** * 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; // ISO date periodEnd: string; // ISO date 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)}`; const periodFmt = `${params.periodStart.slice(0, 10)} → ${params.periodEnd.slice(0, 10)}`; 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}`, `${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])}

${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 ); } }