Files
linumiq-invoice/app/services/invoice/email.server.ts
T
2026-05-09 22:19:25 +02:00

372 lines
12 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";
import {
DEFAULT_EMAIL_BODY_DE,
DEFAULT_EMAIL_BODY_EN,
DEFAULT_EMAIL_SUBJECT_DE,
DEFAULT_EMAIL_SUBJECT_EN,
} from "./emailTemplates";
import { STORED_LOGO_SENTINEL } from "./logoCache.constants";
import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server";
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. Always use the language the invoice PDF was
// rendered in, so the email matches the attachment. Caller can still
// override via `customerLocale` if they really want a different language.
const language = pickLanguage(args.customerLocale ?? invoice.language ?? 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) ||
(language === "en" ? DEFAULT_EMAIL_SUBJECT_EN : DEFAULT_EMAIL_SUBJECT_DE);
const subject = renderTemplate(customSubject, vars);
const customBodyHtml =
(language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe) ||
(language === "en" ? DEFAULT_EMAIL_BODY_EN : DEFAULT_EMAIL_BODY_DE);
const body = renderHtmlBody(renderTemplate(customBodyHtml, vars));
// If the rendered body references the inline logo, attach it.
const inlineLogo = body.html.includes("cid:invoice-logo")
? await loadInlineLogo(args.shopDomain, settings)
: null;
// Download the PDF (Shopify Files URLs are public CDN URLs).
let pdfBytes: Uint8Array;
try {
const res = await safeFetch(invoice.pdfUrl, {
maxBytes: 25 * 1024 * 1024, // 25 MB — generous; emails impose their own limit later
accept: "application/pdf",
// Invoice PDFs always live on Shopify's Files CDN — anything else is
// suspicious and should be rejected.
allowedHosts: SHOPIFY_CDN_HOSTS,
});
if (res.status < 200 || res.status >= 300) throw new Error(`HTTP ${res.status}`);
pdfBytes = res.bytes;
} 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",
},
...(inlineLogo
? [
{
filename: "logo",
content: Buffer.from(inlineLogo.bytes),
contentType: inlineLogo.contentType,
cid: "invoice-logo",
},
]
: []),
],
});
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;
shopEmail: string;
shopWebsite: 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,
shopEmail: args.settings.email,
shopWebsite: args.settings.website,
};
}
/**
* 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 {};
}
}
/**
* Resolves the shop logo bytes for inline embedding. Returns null if no
* logo is configured or the lookup fails — the email is still sent, just
* without the inline image (the alt text remains visible).
*/
async function loadInlineLogo(
shopDomain: string,
settings: ShopSettings,
): Promise<{ bytes: Uint8Array; contentType: string } | null> {
if (!settings.logoUrl) return null;
try {
if (settings.logoUrl === STORED_LOGO_SENTINEL) {
const cached = await db.logoCache.findUnique({ where: { shopDomain } });
if (!cached) return null;
return { bytes: new Uint8Array(cached.bytes), contentType: cached.contentType };
}
const res = await safeFetch(settings.logoUrl, {
maxBytes: 5 * 1024 * 1024,
accept: "image/*",
});
if (res.status < 200 || res.status >= 300) return null;
const ct = res.contentType ?? "image/png";
return { bytes: res.bytes, contentType: ct };
} catch (err) {
if (err instanceof SafeFetchError) {
console.warn(`Inline logo fetch refused (${err.code}): ${err.message}`);
}
return null;
}
}