first version
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
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]!,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user