Files
linumiq-invoice/app/services/invoice/email.server.ts
T
Gerhard Scheikl 770c6fd16a many updates :-)
2026-05-08 10:40:19 +02:00

205 lines
6.1 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";
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 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,
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<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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!,
);
}