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 { 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 { 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 = `

Dear customer,

` + `

Please find attached invoice ${escapeHtml(invoiceNumber)}.

` + `

Thank you for your purchase.

`; return { text, html }; } const text = `Hallo,\n\n` + `anbei finden Sie die Rechnung ${invoiceNumber}.\n\n` + `Danke für deinen Einkauf.`; const html = `

Hallo,

` + `

anbei finden Sie die Rechnung ${escapeHtml(invoiceNumber)}.

` + `

Danke für deinen Einkauf.

`; 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)[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(/(?=\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; } }