573dfbfd50
Mirrors the layout from data/mail_template.png:
- Company name + greeting headline
- Body referencing the invoice number
- Inline logo (cid:invoice-logo) attached automatically
- Footer with mailto + website links
New template vars: {{shopEmail}}, {{shopWebsite}}.
Settings UI prefills empty fields with the defaults so users see and
can tweak them without losing the fallback.
357 lines
11 KiB
TypeScript
357 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.
|
|
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) ||
|
|
(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;
|
|
}
|
|
}
|