feat(email): default template with inline logo + shop contact vars

Mirrors the layout from data/mail_template.png:
- Company name + greeting headline
- Body referencing the invoice number
- Inline logo (cid:invoice-logo) attached automatically
- Footer with mailto + website links

New template vars: {{shopEmail}}, {{shopWebsite}}.
Settings UI prefills empty fields with the defaults so users see and
can tweak them without losing the fallback.
This commit is contained in:
Gerhard Scheikl
2026-05-08 23:12:23 +02:00
parent 04933fcac6
commit 573dfbfd50
4 changed files with 123 additions and 17 deletions
+59 -13
View File
@@ -4,6 +4,13 @@ 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;
@@ -73,20 +80,20 @@ export async function sendInvoiceEmail(
totalGross: totals.totalGross ?? "",
});
const customSubject = language === "en" ? settings.emailSubjectEn : settings.emailSubjectDe;
const subject = customSubject
? renderTemplate(customSubject, vars)
: `${t.invoice} ${invoice.invoiceNumber}` +
(settings.companyName ? `${settings.companyName}` : "");
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;
const body = customBodyHtml
? renderHtmlBody(renderTemplate(customBodyHtml, vars))
: renderEmailBody({
settings,
invoiceNumber: invoice.invoiceNumber,
language,
});
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;
@@ -122,6 +129,16 @@ export async function sendInvoiceEmail(
content: Buffer.from(pdfBytes),
contentType: "application/pdf",
},
...(inlineLogo
? [
{
filename: "logo",
content: Buffer.from(inlineLogo.bytes),
contentType: inlineLogo.contentType,
cid: "invoice-logo",
},
]
: []),
],
});
@@ -232,6 +249,8 @@ interface TemplateVars {
dueDate: string;
companyName: string;
ownerName: string;
shopEmail: string;
shopWebsite: string;
}
function buildTemplateVars(args: {
@@ -251,6 +270,8 @@ function buildTemplateVars(args: {
dueDate: dueMs ? new Date(dueMs).toLocaleDateString() : "",
companyName: args.settings.companyName,
ownerName: args.settings.ownerName,
shopEmail: args.settings.email,
shopWebsite: args.settings.website,
};
}
@@ -308,3 +329,28 @@ function parseTotals(json: string): InvoiceTotalsSnapshot {
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;
}
}