206 lines
6.3 KiB
TypeScript
206 lines
6.3 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 } {
|
|
const company = settings.companyName || "your supplier";
|
|
if (language === "en") {
|
|
const text =
|
|
`Dear customer,\n\n` +
|
|
`Please find attached invoice ${invoiceNumber}.\n\n` +
|
|
`Kind regards,\n${company}`;
|
|
const html =
|
|
`<p>Dear customer,</p>` +
|
|
`<p>Please find attached invoice <strong>${escapeHtml(invoiceNumber)}</strong>.</p>` +
|
|
`<p>Kind regards,<br/>${escapeHtml(company)}</p>`;
|
|
return { text, html };
|
|
}
|
|
const text =
|
|
`Sehr geehrte Damen und Herren,\n\n` +
|
|
`anbei finden Sie die Rechnung ${invoiceNumber}.\n\n` +
|
|
`Mit freundlichen Grüßen,\n${company}`;
|
|
const html =
|
|
`<p>Sehr geehrte Damen und Herren,</p>` +
|
|
`<p>anbei finden Sie die Rechnung <strong>${escapeHtml(invoiceNumber)}</strong>.</p>` +
|
|
`<p>Mit freundlichen Grüßen,<br/>${escapeHtml(company)}</p>`;
|
|
return { text, html };
|
|
}
|
|
|
|
function escapeHtml(s: string): string {
|
|
return s.replace(/[&<>"']/g, (c) =>
|
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!,
|
|
);
|
|
}
|