413 lines
13 KiB
TypeScript
413 lines
13 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";
|
|
import { decryptField } from "../crypto/fieldCrypto.server";
|
|
import { optionalEnv } from "../config/env.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, { html: false });
|
|
|
|
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 {
|
|
// Optional archival BCC. Off by default for privacy/GDPR; set INVOICE_BCC
|
|
// to a comma-separated address list to opt in.
|
|
const bcc = optionalEnv("INVOICE_BCC");
|
|
const info = await transporter.sendMail({
|
|
from: `"${fromName}" <${fromEmail}>`,
|
|
to,
|
|
...(bcc ? { bcc } : {}),
|
|
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: decryptField(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.
|
|
*
|
|
* For HTML output (the default), every interpolated value is HTML-escaped to
|
|
* prevent stored-XSS from merchant- or customer-derived data bleeding into the
|
|
* email body. URL-valued tokens that land inside `href` attributes are scheme-
|
|
* validated first: `shopWebsite` must be an `https:` URL and `shopEmail` must
|
|
* look like a bare email address (rendered after a `mailto:` prefix); anything
|
|
* else renders empty so a hostile `javascript:`/`data:` value can't be planted.
|
|
*
|
|
* Pass `{ html: false }` for plain-text contexts (e.g. the subject line), where
|
|
* the raw value is substituted without HTML entity encoding.
|
|
*/
|
|
const URL_TOKENS = new Set(["shopWebsite"]);
|
|
const EMAIL_TOKENS = new Set(["shopEmail"]);
|
|
|
|
function safeHttpsUrl(value: string): string {
|
|
const v = value.trim();
|
|
try {
|
|
return new URL(v).protocol === "https:" ? v : "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function safeEmailAddress(value: string): string {
|
|
const v = value.trim();
|
|
return /^[^\s@<>"'/\\]+@[^\s@<>"'/\\]+\.[^\s@<>"'/\\]+$/.test(v) ? v : "";
|
|
}
|
|
|
|
function renderTemplate(
|
|
template: string,
|
|
vars: TemplateVars,
|
|
opts: { html?: boolean } = {},
|
|
): string {
|
|
const html = opts.html !== false; // HTML-escape by default
|
|
return template.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (full, key) => {
|
|
const raw = (vars as unknown as Record<string, string | undefined>)[key];
|
|
if (raw === undefined) return full;
|
|
if (!html) return raw;
|
|
let value = raw;
|
|
if (URL_TOKENS.has(key)) value = safeHttpsUrl(raw);
|
|
else if (EMAIL_TOKENS.has(key)) value = safeEmailAddress(raw);
|
|
return escapeHtml(value);
|
|
});
|
|
}
|
|
|
|
/** 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 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;
|
|
}
|
|
}
|