Files
linumiq-invoice/app/services/invoice/email.server.ts
T
Gerhard Scheikl 04933fcac6 feat(email): WYSIWYG template editor with variable substitution
- Add emailSubject{De,En} + emailBodyHtml{De,En} to ShopSettings
- New RichTextEditor component (TipTap) with toolbar + variable insert
- Settings UI: Email templates section per language
- email.server.ts: substitute {{var}} placeholders, fall back to defaults
- Default vars: invoiceNumber, customerName, customerFirstName, orderName,
  totalGross, dueDate, companyName, ownerName
2026-05-08 23:06:40 +02:00

311 lines
9.3 KiB
TypeScript

import nodemailer from "nodemailer";
import type { Transporter } from "nodemailer";
import type { ShopSettings } from "@prisma/client";
import db from "../../db.server";
import { getStrings, pickLanguage } from "./i18n";
export interface SendInvoiceEmailArgs {
shopDomain: string;
invoiceId: string;
toAddress?: string;
/** Customer locale (e.g. "de-AT" or "en"); used to pick subject/body language. */
customerLocale?: string;
/**
* Override the underlying transport (test only). Production code should
* leave this undefined so SMTP creds from `ShopSettings` are used.
*/
transportOverride?: Transporter;
}
export interface SendInvoiceEmailResult {
ok: boolean;
toAddress: string;
messageId?: string;
errorMessage?: string;
}
/**
* Sends the invoice PDF as an email attachment to the customer using the
* shop's configured SMTP credentials. On success, marks the invoice
* `sentAt = now()` and `status = 'sent'`, which locks it from in-place
* regeneration (cancel-and-reissue is required to correct it after this).
*/
export async function sendInvoiceEmail(
args: SendInvoiceEmailArgs,
): Promise<SendInvoiceEmailResult> {
const settings = await db.shopSettings.findUnique({
where: { shopDomain: args.shopDomain },
});
if (!settings) {
return failLog(args, "ShopSettings missing for this shop.");
}
const invoice = await db.invoice.findUnique({ where: { id: args.invoiceId } });
if (!invoice) return failLog(args, `Invoice ${args.invoiceId} not found.`);
if (invoice.shopDomain !== args.shopDomain) {
return failLog(args, "Invoice does not belong to this shop.");
}
if (!invoice.pdfUrl) return failLog(args, "Invoice has no PDF URL.");
// Resolve recipient: explicit override > customer email captured on the invoice.
let to = args.toAddress?.trim();
if (!to) {
try {
const customer = JSON.parse(invoice.customerJson) as { customerEmail?: string };
to = customer.customerEmail?.trim();
} catch {
// ignore
}
}
if (!to) return failLog(args, "No recipient email available.", invoice.id);
// Build email content.
const language = pickLanguage(args.customerLocale ?? settings.defaultLanguage);
const t = getStrings(language);
const customer = parseCustomer(invoice.customerJson);
const totals = parseTotals(invoice.totalsJson);
const vars = buildTemplateVars({
invoice,
settings,
customerName: customer.customerName ?? "",
customerFirstName: (customer.customerName ?? "").split(/\s+/)[0] ?? "",
totalGross: totals.totalGross ?? "",
});
const customSubject = language === "en" ? settings.emailSubjectEn : settings.emailSubjectDe;
const subject = customSubject
? renderTemplate(customSubject, vars)
: `${t.invoice} ${invoice.invoiceNumber}` +
(settings.companyName ? `${settings.companyName}` : "");
const customBodyHtml = language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe;
const body = customBodyHtml
? renderHtmlBody(renderTemplate(customBodyHtml, vars))
: renderEmailBody({
settings,
invoiceNumber: invoice.invoiceNumber,
language,
});
// Download the PDF (Shopify Files URLs are public CDN URLs).
let pdfBytes: Uint8Array;
try {
const res = await fetch(invoice.pdfUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
pdfBytes = new Uint8Array(await res.arrayBuffer());
} catch (err) {
const m = err instanceof Error ? err.message : String(err);
return failLog(args, `Failed to download invoice PDF: ${m}`, invoice.id);
}
const transporter = args.transportOverride ?? buildTransport(settings);
const fromName = settings.smtpFromName || settings.companyName || "Invoices";
const fromEmail = settings.smtpFromEmail || settings.smtpUser || settings.email;
if (!fromEmail) {
return failLog(args, "No SMTP From address configured.", invoice.id);
}
try {
const info = await transporter.sendMail({
from: `"${fromName}" <${fromEmail}>`,
to,
bcc: "shop@linumiq.com",
replyTo: settings.smtpReplyTo || undefined,
subject,
text: body.text,
html: body.html,
attachments: [
{
filename: `${invoice.kind === "storno" ? "Stornorechnung" : "Rechnung"}-${invoice.invoiceNumber}.pdf`,
content: Buffer.from(pdfBytes),
contentType: "application/pdf",
},
],
});
await db.$transaction(async (tx) => {
await tx.invoice.update({
where: { id: invoice.id },
data: { sentAt: new Date(), status: "sent" },
});
await tx.emailLog.create({
data: {
shopDomain: args.shopDomain,
invoiceId: invoice.id,
toAddress: to!,
subject,
status: "sent",
},
});
});
return { ok: true, toAddress: to, messageId: info.messageId };
} catch (err) {
const m = err instanceof Error ? err.message : String(err);
return failLog(args, `SMTP send failed: ${m}`, invoice.id, to);
}
}
function buildTransport(settings: ShopSettings): Transporter {
return nodemailer.createTransport({
host: settings.smtpHost,
port: settings.smtpPort,
secure: settings.smtpSecure,
auth: settings.smtpUser
? { user: settings.smtpUser, pass: settings.smtpPassword }
: undefined,
});
}
async function failLog(
args: SendInvoiceEmailArgs,
message: string,
invoiceId?: string,
to?: string,
): Promise<SendInvoiceEmailResult> {
if (invoiceId) {
try {
await db.emailLog.create({
data: {
shopDomain: args.shopDomain,
invoiceId,
toAddress: to ?? args.toAddress ?? "",
subject: "(failed)",
status: "failed",
error: message,
},
});
} catch {
// best-effort
}
}
return { ok: false, toAddress: to ?? args.toAddress ?? "", errorMessage: message };
}
function renderEmailBody({
settings,
invoiceNumber,
language,
}: {
settings: ShopSettings;
invoiceNumber: string;
language: "de" | "en";
}): { text: string; html: string } {
if (language === "en") {
const text =
`Dear customer,\n\n` +
`Please find attached invoice ${invoiceNumber}.\n\n` +
`Thank you for your purchase.`;
const html =
`<p>Dear customer,</p>` +
`<p>Please find attached invoice <strong>${escapeHtml(invoiceNumber)}</strong>.</p>` +
`<p>Thank you for your purchase.</p>`;
return { text, html };
}
const text =
`Hallo,\n\n` +
`anbei finden Sie die Rechnung ${invoiceNumber}.\n\n` +
`Danke für deinen Einkauf.`;
const html =
`<p>Hallo,</p>` +
`<p>anbei finden Sie die Rechnung <strong>${escapeHtml(invoiceNumber)}</strong>.</p>` +
`<p>Danke für deinen Einkauf.</p>`;
return { text, html };
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!,
);
}
// --- Template variables ----------------------------------------------------
interface TemplateVars {
invoiceNumber: string;
customerName: string;
customerFirstName: string;
orderName: string;
totalGross: string;
dueDate: string;
companyName: string;
ownerName: string;
}
function buildTemplateVars(args: {
invoice: { invoiceNumber: string; orderName: string };
settings: ShopSettings;
customerName: string;
customerFirstName: string;
totalGross: string;
}): TemplateVars {
const dueMs = Number((args.invoice as unknown as { dueDate?: string | Date }).dueDate ?? 0);
return {
invoiceNumber: args.invoice.invoiceNumber,
orderName: args.invoice.orderName,
customerName: args.customerName,
customerFirstName: args.customerFirstName,
totalGross: args.totalGross,
dueDate: dueMs ? new Date(dueMs).toLocaleDateString() : "",
companyName: args.settings.companyName,
ownerName: args.settings.ownerName,
};
}
/**
* Substitutes {{token}} placeholders in `template`. Unknown tokens are left
* in place so the user notices typos instead of silent blanks. Values are
* inserted verbatim — callers are responsible for HTML-escaping if needed.
*/
function renderTemplate(template: string, vars: TemplateVars): string {
return template.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (full, key) => {
const v = (vars as unknown as Record<string, string | undefined>)[key];
return v === undefined ? full : v;
});
}
/** Strips HTML tags to produce a plain-text fallback for the multipart email. */
function htmlToText(html: string): string {
return html
.replace(/<br\s*\/?>(?=\s|$)/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function renderHtmlBody(html: string): { text: string; html: string } {
return { html, text: htmlToText(html) };
}
interface InvoiceCustomerSnapshot {
customerEmail?: string;
customerName?: string;
}
function parseCustomer(json: string): InvoiceCustomerSnapshot {
try {
return JSON.parse(json) as InvoiceCustomerSnapshot;
} catch {
return {};
}
}
interface InvoiceTotalsSnapshot {
totalGross?: string;
}
function parseTotals(json: string): InvoiceTotalsSnapshot {
try {
return JSON.parse(json) as InvoiceTotalsSnapshot;
} catch {
return {};
}
}