359 lines
11 KiB
TypeScript
359 lines
11 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";
|
|
|
|
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 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",
|
|
},
|
|
...(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) =>
|
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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(/ /g, " ")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/'/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 fetch(settings.logoUrl);
|
|
if (!res.ok) return null;
|
|
const ct = res.headers.get("content-type") ?? "image/png";
|
|
return { bytes: new Uint8Array(await res.arrayBuffer()), contentType: ct };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|