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
+12 -4
View File
@@ -14,6 +14,12 @@ import {
storeUploadedLogo, storeUploadedLogo,
} from "../services/invoice/logoCache.server"; } from "../services/invoice/logoCache.server";
import { RichTextEditor } from "../components/RichTextEditor"; import { RichTextEditor } from "../components/RichTextEditor";
import {
DEFAULT_EMAIL_BODY_DE,
DEFAULT_EMAIL_BODY_EN,
DEFAULT_EMAIL_SUBJECT_DE,
DEFAULT_EMAIL_SUBJECT_EN,
} from "../services/invoice/emailTemplates";
interface SettingsFieldErrors { interface SettingsFieldErrors {
vatId?: string; vatId?: string;
@@ -376,13 +382,13 @@ export default function SettingsRoute() {
<Field <Field
label="Subject (German)" label="Subject (German)"
name="emailSubjectDe" name="emailSubjectDe"
defaultValue={settings.emailSubjectDe} defaultValue={settings.emailSubjectDe || DEFAULT_EMAIL_SUBJECT_DE}
helpText="Variables like {{invoiceNumber}} are substituted at send time." helpText="Variables like {{invoiceNumber}} are substituted at send time."
/> />
<RichTextEditor <RichTextEditor
name="emailBodyHtmlDe" name="emailBodyHtmlDe"
label="Body (German)" label="Body (German)"
defaultValue={settings.emailBodyHtmlDe} defaultValue={settings.emailBodyHtmlDe || DEFAULT_EMAIL_BODY_DE}
variables={EMAIL_VARS} variables={EMAIL_VARS}
minHeight={220} minHeight={220}
/> />
@@ -390,13 +396,13 @@ export default function SettingsRoute() {
<Field <Field
label="Subject (English)" label="Subject (English)"
name="emailSubjectEn" name="emailSubjectEn"
defaultValue={settings.emailSubjectEn} defaultValue={settings.emailSubjectEn || DEFAULT_EMAIL_SUBJECT_EN}
helpText="Variables like {{invoiceNumber}} are substituted at send time." helpText="Variables like {{invoiceNumber}} are substituted at send time."
/> />
<RichTextEditor <RichTextEditor
name="emailBodyHtmlEn" name="emailBodyHtmlEn"
label="Body (English)" label="Body (English)"
defaultValue={settings.emailBodyHtmlEn} defaultValue={settings.emailBodyHtmlEn || DEFAULT_EMAIL_BODY_EN}
variables={EMAIL_VARS} variables={EMAIL_VARS}
minHeight={220} minHeight={220}
/> />
@@ -425,6 +431,8 @@ const EMAIL_VARS = [
{ token: "{{dueDate}}" }, { token: "{{dueDate}}" },
{ token: "{{companyName}}" }, { token: "{{companyName}}" },
{ token: "{{ownerName}}" }, { token: "{{ownerName}}" },
{ token: "{{shopEmail}}" },
{ token: "{{shopWebsite}}" },
]; ];
interface FieldProps { interface FieldProps {
+59 -13
View File
@@ -4,6 +4,13 @@ import type { ShopSettings } from "@prisma/client";
import db from "../../db.server"; import db from "../../db.server";
import { getStrings, pickLanguage } from "./i18n"; 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 { export interface SendInvoiceEmailArgs {
shopDomain: string; shopDomain: string;
@@ -73,20 +80,20 @@ export async function sendInvoiceEmail(
totalGross: totals.totalGross ?? "", totalGross: totals.totalGross ?? "",
}); });
const customSubject = language === "en" ? settings.emailSubjectEn : settings.emailSubjectDe; const customSubject =
const subject = customSubject (language === "en" ? settings.emailSubjectEn : settings.emailSubjectDe) ||
? renderTemplate(customSubject, vars) (language === "en" ? DEFAULT_EMAIL_SUBJECT_EN : DEFAULT_EMAIL_SUBJECT_DE);
: `${t.invoice} ${invoice.invoiceNumber}` + const subject = renderTemplate(customSubject, vars);
(settings.companyName ? `${settings.companyName}` : "");
const customBodyHtml = language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe; const customBodyHtml =
const body = customBodyHtml (language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe) ||
? renderHtmlBody(renderTemplate(customBodyHtml, vars)) (language === "en" ? DEFAULT_EMAIL_BODY_EN : DEFAULT_EMAIL_BODY_DE);
: renderEmailBody({ const body = renderHtmlBody(renderTemplate(customBodyHtml, vars));
settings,
invoiceNumber: invoice.invoiceNumber, // If the rendered body references the inline logo, attach it.
language, const inlineLogo = body.html.includes("cid:invoice-logo")
}); ? await loadInlineLogo(args.shopDomain, settings)
: null;
// Download the PDF (Shopify Files URLs are public CDN URLs). // Download the PDF (Shopify Files URLs are public CDN URLs).
let pdfBytes: Uint8Array; let pdfBytes: Uint8Array;
@@ -122,6 +129,16 @@ export async function sendInvoiceEmail(
content: Buffer.from(pdfBytes), content: Buffer.from(pdfBytes),
contentType: "application/pdf", 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; dueDate: string;
companyName: string; companyName: string;
ownerName: string; ownerName: string;
shopEmail: string;
shopWebsite: string;
} }
function buildTemplateVars(args: { function buildTemplateVars(args: {
@@ -251,6 +270,8 @@ function buildTemplateVars(args: {
dueDate: dueMs ? new Date(dueMs).toLocaleDateString() : "", dueDate: dueMs ? new Date(dueMs).toLocaleDateString() : "",
companyName: args.settings.companyName, companyName: args.settings.companyName,
ownerName: args.settings.ownerName, ownerName: args.settings.ownerName,
shopEmail: args.settings.email,
shopWebsite: args.settings.website,
}; };
} }
@@ -308,3 +329,28 @@ function parseTotals(json: string): InvoiceTotalsSnapshot {
return {}; 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;
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Default invoice email templates per language. Used when the user hasn't
* customised them in settings. Variables ({{invoiceNumber}}, etc.) are
* substituted by `renderTemplate` at send time.
*
* The shop logo is rendered as an inline attachment with content-id
* `invoice-logo`; the email sender attaches the logo bytes automatically
* when the template (or any custom template) references that cid.
*/
const DE_HTML = `\
<h2 style="color:#3070c0;margin:0 0 8px;font-family:Arial,Helvetica,sans-serif;">{{companyName}}</h2>
<h3 style="color:#3070c0;margin:0 0 16px;font-family:Arial,Helvetica,sans-serif;">Danke für deinen Einkauf!</h3>
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
Die Rechnung befindet sich im Anhang.
</p>
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
Bei Überweisung bitte die Rechnungs-Nummer als Referenz verwenden:
<strong>{{invoiceNumber}}</strong><br>
Besten Dank!
</p>
<p style="margin-top:24px;">
<img src="cid:invoice-logo" alt="{{companyName}}" style="max-height:48px;">
</p>
<p style="font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:1.6;color:#3070c0;">
✉ <a href="mailto:{{shopEmail}}" style="color:#3070c0;">Kontakt</a><br>
🌐 <a href="{{shopWebsite}}" style="color:#3070c0;">{{shopWebsite}}</a>
</p>`;
const EN_HTML = `\
<h2 style="color:#3070c0;margin:0 0 8px;font-family:Arial,Helvetica,sans-serif;">{{companyName}}</h2>
<h3 style="color:#3070c0;margin:0 0 16px;font-family:Arial,Helvetica,sans-serif;">Thank you for your purchase!</h3>
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
Please find the invoice attached.
</p>
<p style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;">
When paying by bank transfer, please use the invoice number as the reference:
<strong>{{invoiceNumber}}</strong><br>
Thanks a lot!
</p>
<p style="margin-top:24px;">
<img src="cid:invoice-logo" alt="{{companyName}}" style="max-height:48px;">
</p>
<p style="font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:1.6;color:#3070c0;">
✉ <a href="mailto:{{shopEmail}}" style="color:#3070c0;">Contact</a><br>
🌐 <a href="{{shopWebsite}}" style="color:#3070c0;">{{shopWebsite}}</a>
</p>`;
export const DEFAULT_EMAIL_SUBJECT_DE = "Rechnung {{invoiceNumber}} {{companyName}}";
export const DEFAULT_EMAIL_SUBJECT_EN = "Invoice {{invoiceNumber}} {{companyName}}";
export const DEFAULT_EMAIL_BODY_DE = DE_HTML;
export const DEFAULT_EMAIL_BODY_EN = EN_HTML;
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB