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"; 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 subject = `${t.invoice} ${invoice.invoiceNumber}` + (settings.companyName ? ` — ${settings.companyName}` : ""); const body = renderEmailBody({ settings, invoiceNumber: invoice.invoiceNumber, language, }); // 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", }, ], }); 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]!, ); }