diff --git a/app/routes/app.settings.tsx b/app/routes/app.settings.tsx index 033ec63..d9ca14e 100644 --- a/app/routes/app.settings.tsx +++ b/app/routes/app.settings.tsx @@ -14,6 +14,12 @@ import { storeUploadedLogo, } from "../services/invoice/logoCache.server"; 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 { vatId?: string; @@ -376,13 +382,13 @@ export default function SettingsRoute() { @@ -390,13 +396,13 @@ export default function SettingsRoute() { @@ -425,6 +431,8 @@ const EMAIL_VARS = [ { token: "{{dueDate}}" }, { token: "{{companyName}}" }, { token: "{{ownerName}}" }, + { token: "{{shopEmail}}" }, + { token: "{{shopWebsite}}" }, ]; interface FieldProps { diff --git a/app/services/invoice/email.server.ts b/app/services/invoice/email.server.ts index 4104ff8..a73982a 100644 --- a/app/services/invoice/email.server.ts +++ b/app/services/invoice/email.server.ts @@ -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; + } +} diff --git a/app/services/invoice/emailTemplates.ts b/app/services/invoice/emailTemplates.ts new file mode 100644 index 0000000..b3a00b4 --- /dev/null +++ b/app/services/invoice/emailTemplates.ts @@ -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 = `\ +

{{companyName}}

+

Danke für deinen Einkauf!

+

+Die Rechnung befindet sich im Anhang. +

+

+Bei Überweisung bitte die Rechnungs-Nummer als Referenz verwenden: +{{invoiceNumber}}
+Besten Dank! +

+

+{{companyName}} +

+

+✉ Kontakt
+🌐 {{shopWebsite}} +

`; + +const EN_HTML = `\ +

{{companyName}}

+

Thank you for your purchase!

+

+Please find the invoice attached. +

+

+When paying by bank transfer, please use the invoice number as the reference: +{{invoiceNumber}}
+Thanks a lot! +

+

+{{companyName}} +

+

+✉ Contact
+🌐 {{shopWebsite}} +

`; + +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; diff --git a/data/mail_template.png b/data/mail_template.png new file mode 100644 index 0000000..9699793 Binary files /dev/null and b/data/mail_template.png differ