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:
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 |
Reference in New Issue
Block a user