Files
einfach-produktiv/app/lib/emailTemplates.ts
T
Marco b5ad13cf43 Revert generalPartners, fix shareCapital: neither is a Pflichtangabe
Corrected after user feedback: Stammkapital/Grundkapital is only required on business correspondence if voluntarily disclosed in the first place (§35a Abs. 1 S. 2 GmbHG) — not an unconditional Pflichtangabe. shareCapital's Impressum rendering stays (shown only if an admin voluntarily filled it in), but it's dropped from the email/invoice footer.

generalPartners is removed entirely — the legal basis was genuinely unclear on research (§125a HGB's Geschäftsbriefe-naming duty only applies to the narrow case where no partner is a natural person; whether §5 DDG's Impressum-specific "vertretungsberechtigte Person" requirement independently mandates it for the general OHG/KG case wasn't resolved with confidence) — reverted rather than shipped on an uncertain legal basis.

Also fixes stale "§5 TMG" citations to "§5 DDG" (TMG was replaced 14 May 2024).
2026-07-23 14:12:35 +00:00

344 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { formatPrice, formatDate } from "./format";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import type { CompanySettings } from "./payload";
// Pure string-building functions, no server-only or client-only imports —
// used both server-side for the actual email send (app/lib/orderEmail.ts,
// app/api/checkout/route.ts) and client-side for the Live Preview page
// (app/email-preview/[type]/page.tsx's client component), so a Live
// Preview edit and the real sent email are guaranteed to render
// identically — same function, same input shape, just different data
// (real order vs. SAMPLE_ORDER below).
//
// Inline-styled HTML, not Tailwind classes or a <style> block — most email
// clients strip external/embedded CSS and only reliably honor inline
// `style` attributes. Structure (this file) is fixed; only the wording
// (heading/bodyText/footerText, edited in Payload's email-templates
// collection) is admin-editable — see that collection's own comment for why.
//
// Visual language deliberately echoes /bestellbestaetigung (the on-screen
// confirmation page, app/bestellbestaetigung/components/BestellbestaetigungContent.tsx)
// rather than being a generic transactional-email template: same brand
// color/warm cream background, the same circular success-icon treatment,
// the same thin brand-colored divider under the headline, a serif display
// heading. Not literally shared code (that component is React+Tailwind,
// this is plain inline-styled HTML for email-client compatibility — no
// external fonts/CSS, no flexbox reliance), just matched by eye so a
// customer doesn't get a starkly different "brand voice" between the page
// they just saw and the email that follows it.
export type EmailTemplateContent = {
subject: string;
heading: string;
bodyText: string;
footerText: string | null;
};
// Same hex values as app/globals.css's --color-* tokens — kept as literal
// constants here rather than imported, since this file has no build-time
// access to CSS custom properties (email clients wouldn't resolve `var()`
// either, even if it did).
const BRAND = "#f6a701";
const BG_BASE = "#f8f5f1";
const BG_MUTED = "#f3efe9";
const TEXT_PRIMARY = "#1a1a18";
const TEXT_MUTED = "#6b6b69";
const BORDER = "#e5e0d8";
const SUCCESS = "#2f8f4e";
// Georgia, not a web font — most email clients strip @font-face/external
// font requests, so this is the closest reliably-available serif to
// Playfair Display/Lora's editorial feel rather than an attempt to load
// the real thing.
const FONT_SERIF = "Georgia,'Times New Roman',serif";
const FONT_SANS = "-apple-system,Helvetica,Arial,sans-serif";
function paragraphs(text: string, align: "center" | "left" = "left"): string {
return text
.split(/\n{2,}/)
.map(
(p) =>
`<p style="margin:0 0 12px;line-height:1.6;font-size:15px;color:${TEXT_MUTED};text-align:${align};">${escapeHtml(p).replace(/\n/g, "<br/>")}</p>`,
)
.join("");
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Live-Preview-only fallback (no real order/company-settings fetch there,
// see /email-preview/[type]) — the actual send always passes the real
// seller (company-settings) through buildLegalFooterLines() below. Email
// deliberately doesn't match the real admin@mk360.de send address, so this
// never reads as a hardcoded real value in the preview — it's a visibly
// fake placeholder, same spirit as "Musterstraße 12".
export const DEFAULT_LEGAL_FOOTER_LINES: string[] = [
"einfach produktiv",
"Musterstraße 12",
"12345 Musterstadt",
"E-Mail: kontakt@musterfirma.de",
];
// Every business email needs an Anbieterkennzeichnung (§5 DDG-equivalent
// minimum for business correspondence: full name, postal address, contact,
// plus VAT ID once assigned) — not just a friendly "brand · email" line.
// Built from the same company-settings fields the invoice PDFs already
// render (app/lib/invoicePdf.tsx), so there is exactly one source of truth
// for this business's legal identity.
//
// registerCourt/registerNumber/managingDirector are conditionally required
// on the Payload side (CompanySettings.ts, gated by legalForm) — only
// appended here when actually present, so a sole proprietorship's footer
// stays exactly as short as before this field set existed. Keep this in
// sync with the Payload backend's own copy in src/lib/sellerInfo.ts.
// `shareCapital` deliberately does NOT appear here even though it's a
// company-settings field — see CompanySettings.ts's own comment: it's a
// voluntary disclosure, not something safe to auto-inject into every
// outgoing email regardless of whether the business actually wants that
// disclosure made.
export function buildLegalFooterLines(seller: CompanySettings | null): string[] {
if (!seller) return DEFAULT_LEGAL_FOOTER_LINES;
const lines = [
seller.sellerName,
seller.sellerStreet,
`${seller.sellerZip} ${seller.sellerCity}${seller.sellerCountry && seller.sellerCountry !== "Deutschland" ? `, ${seller.sellerCountry}` : ""}`,
`E-Mail: ${seller.sellerEmail}`,
];
if (seller.vatId) lines.push(`USt-IdNr.: ${seller.vatId}`);
if (seller.registerCourt && seller.registerNumber) lines.push(`${seller.registerCourt} · ${seller.registerNumber}`);
if (seller.managingDirector) lines.push(`Geschäftsführung: ${seller.managingDirector}`);
return lines;
}
// `icon`: a single glyph rendered inside the brand-tinted circle up top —
// "✓" for order-confirmation, "✉" for password-reset. Same circular
// treatment as the confirmation page's own success icon and its delivery-
// status panel icon. `footerLines`: the legal Anbieterkennzeichnung from
// buildLegalFooterLines() above — required on every email, not optional.
function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerText: string | null, footerLines: string[]): string {
return `<body style="margin:0;padding:32px 16px;background:${BG_BASE};font-family:${FONT_SANS};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;margin:0 auto;">
<tr>
<td style="padding-bottom:20px;text-align:center;">
<span style="font-family:${FONT_SERIF};font-weight:700;font-size:15px;color:${TEXT_PRIMARY};letter-spacing:0.02em;">einfach produktiv.</span>
</td>
</tr>
<tr>
<td style="background:#ffffff;border:1px solid ${BORDER};border-radius:12px;padding:40px 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align:center;padding-bottom:20px;">
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 auto;">
<tr>
<td width="56" height="56" style="background:${BRAND}1a;border-radius:50%;text-align:center;vertical-align:middle;font-size:24px;color:${BRAND};">
${icon}
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="text-align:center;padding-bottom:8px;">
<span style="font-family:${FONT_SERIF};font-weight:700;font-size:26px;color:${TEXT_PRIMARY};">${headingHtml}</span>
</td>
</tr>
<tr>
<td style="text-align:center;padding-bottom:20px;">
<div style="width:32px;height:2px;background:${BRAND};margin:0 auto;"></div>
</td>
</tr>
<tr>
<td>${bodyHtml}</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding-top:24px;text-align:center;">
${footerText ? `<p style="margin:0 0 8px;font-size:13px;color:${TEXT_MUTED};">${escapeHtml(footerText)}</p>` : ""}
${footerLines.map((line) => `<p style="margin:0;font-size:11px;line-height:1.6;color:${TEXT_MUTED};">${escapeHtml(line)}</p>`).join("")}
</td>
</tr>
</table>
</body>`;
}
export type OrderConfirmationItem = {
productName: string;
quantity: number;
unitPrice: number;
imageUrl?: string | null;
bundleContents?: string | null;
variantName?: string | null;
taxRatePercent: number;
};
export type OrderConfirmationData = {
orderNumber: string;
createdAt: string;
items: OrderConfirmationItem[];
subtotal: number;
shippingCost: number;
discountAmount: number;
discountCode: string | null;
total: number;
};
export const SAMPLE_ORDER: OrderConfirmationData = {
orderNumber: "#EP-0001-A7K2",
createdAt: new Date().toISOString(),
items: [
{
productName: "ToDo-Karten Set",
quantity: 1,
unitPrice: 12.9,
imageUrl: "https://payload.mk360.de/api/media/file/product-todo-karten.png",
bundleContents: null,
taxRatePercent: 19,
},
{ productName: "Wochenplaner Überblick", quantity: 2, unitPrice: 14.9, imageUrl: null, taxRatePercent: 19 },
],
subtotal: 42.7,
shippingCost: 0,
discountAmount: 5,
discountCode: "WILLKOMMEN10",
total: 37.7,
};
export function renderOrderConfirmationHtml(template: EmailTemplateContent, order: OrderConfirmationData, seller: CompanySettings | null): string {
const rows = order.items
.map(
(item) => `<tr>
<td width="52" style="padding:10px 0;border-bottom:1px solid ${BORDER};">
${
item.imageUrl
? `<img src="${item.imageUrl}" width="44" height="44" alt="" style="display:block;width:44px;height:44px;border-radius:6px;object-fit:cover;border:1px solid ${BORDER};" />`
: `<div style="width:44px;height:44px;border-radius:6px;background:${BG_MUTED};"></div>`
}
</td>
<td style="padding:10px 0 10px 12px;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)}${item.variantName ? ` (${escapeHtml(item.variantName)})` : ""} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span>${item.bundleContents ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">${escapeHtml(item.bundleContents)}</span>` : ""}</td>
<td style="padding:10px 0;border-bottom:1px solid ${BORDER};text-align:right;white-space:nowrap;font-size:14px;color:${TEXT_PRIMARY};">${formatPrice(item.quantity * item.unitPrice)}</td>
</tr>`,
)
.join("");
const summaryRow = (label: string, value: string, color = TEXT_PRIMARY) =>
`<tr><td style="padding:4px 0;font-size:14px;color:${color};">${label}</td><td style="padding:4px 0;text-align:right;font-size:14px;color:${color};">${value}</td></tr>`;
const vatRow = (label: string, value: string) =>
`<tr><td style="padding-top:4px;font-size:12px;color:${TEXT_MUTED};">${label}</td><td style="padding-top:4px;text-align:right;font-size:12px;color:${TEXT_MUTED};">${value}</td></tr>`;
// Actual VAT amount included in the total, broken down per rate when the
// order spans more than one — mirrors /bestellbestaetigung's own
// VatBreakdown component (not shared code, this file is plain
// inline-styled HTML for email-client compatibility, see the top-of-file
// comment) and the same lib/taxBreakdown.ts math the invoice PDF uses.
const taxBreakdown = computeTaxBreakdown(
order.items.map((item) => ({ quantity: item.quantity, unitPrice: item.unitPrice, taxRatePercent: item.taxRatePercent })),
order.subtotal,
order.discountAmount,
order.shippingCost,
);
const taxRows =
taxBreakdown.length <= 1
? taxBreakdown[0]
? vatRow(`enthält ${taxBreakdown[0].rate}% MwSt.`, formatPrice(taxBreakdown[0].tax))
: ""
: taxBreakdown.map((g) => vatRow(`davon ${g.rate}% MwSt.`, formatPrice(g.tax))).join("");
const body = `
${paragraphs(template.bodyText, "center")}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:16px;background:${BG_MUTED};border-radius:8px;padding:20px;">
<tr><td colspan="3" style="padding-bottom:10px;font-size:12px;color:${TEXT_MUTED};">Bestellnummer <strong style="color:${TEXT_PRIMARY};">${escapeHtml(order.orderNumber)}</strong> · ${formatDate(order.createdAt)}</td></tr>
${rows}
</table>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:16px;">
${summaryRow("Zwischensumme", formatPrice(order.subtotal))}
${order.discountAmount > 0 ? summaryRow(`Rabattcode${order.discountCode ? ` (${escapeHtml(order.discountCode)})` : ""}`, `-${formatPrice(order.discountAmount)}`, SUCCESS) : ""}
${summaryRow("Versand", order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost))}
</table>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:12px;padding-top:12px;border-top:1px solid ${BORDER};">
<tr>
<td style="font-family:${FONT_SERIF};font-weight:700;font-size:17px;color:${TEXT_PRIMARY};">Gesamtsumme</td>
<td style="text-align:right;font-weight:700;font-size:17px;color:${TEXT_PRIMARY};">${formatPrice(order.total)}</td>
</tr>
${taxRows}
</table>
`;
return emailShell("✓", escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
}
// Icon shown per status — matches the Payload-side send exactly (see
// STATUS_EMAIL in the backend repo's src/collections/Orders.ts), kept here
// only for the Live Preview approximation (the real send happens from
// Payload itself, not this repo — see that file's own comment on why the
// two aren't pixel-identical, same established gap as password-reset).
export const ORDER_STATUS_EMAIL_ICON: Record<string, string> = {
"order-shipped": "→",
"order-cancelled": "✕",
"order-return-requested": "↩",
"order-returned": "✓",
};
export function renderOrderStatusHtml(
template: EmailTemplateContent,
icon: string,
orderNumber: string,
orderUrl: string,
seller: CompanySettings | null,
): string {
const body = `
${paragraphs(template.bodyText, "center")}
<p style="text-align:center;font-size:13px;color:${TEXT_MUTED};margin:0 0 4px;">Bestellnummer ${escapeHtml(orderNumber)}</p>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px auto 8px;">
<tr>
<td style="background:${BRAND};border-radius:6px;">
<a href="${orderUrl}" style="display:inline-block;padding:13px 28px;font-weight:700;font-size:15px;color:${TEXT_PRIMARY};text-decoration:none;">Bestellung ansehen</a>
</td>
</tr>
</table>
`;
return emailShell(icon, escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
}
export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string, seller: CompanySettings | null): string {
const body = `
${paragraphs(template.bodyText, "center")}
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px auto 8px;">
<tr>
<td style="background:${BRAND};border-radius:6px;">
<a href="${resetUrl}" style="display:inline-block;padding:13px 28px;font-weight:700;font-size:15px;color:${TEXT_PRIMARY};text-decoration:none;">Neues Passwort vergeben</a>
</td>
</tr>
</table>
<p style="text-align:center;font-size:12px;color:${TEXT_MUTED};word-break:break-all;">${resetUrl}</p>
<p style="text-align:center;font-size:12px;color:${TEXT_MUTED};">Der Link ist 1 Stunde gültig.</p>
`;
return emailShell("✉", escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
}
// Not a Payload-editable template like the others above (see alertAdmin.ts's
// own comment on why this one send path exists outside the email-templates
// collection) — wording is fixed in code, but it still goes through the
// same emailShell()/buildLegalFooterLines() so the resend-verification email
// carries the same branding and legally required footer as every other
// email this app sends.
export function renderVerificationEmailHtml(firstName: string, verifyUrl: string, seller: CompanySettings | null): string {
const body = `
${paragraphs(`Hallo ${firstName},\n\nbitte bestätige deine E-Mail-Adresse für dein Konto bei einfach produktiv.`, "center")}
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px auto 8px;">
<tr>
<td style="background:${BRAND};border-radius:6px;">
<a href="${verifyUrl}" style="display:inline-block;padding:13px 28px;font-weight:700;font-size:15px;color:${TEXT_PRIMARY};text-decoration:none;">E-Mail-Adresse bestätigen</a>
</td>
</tr>
</table>
<p style="text-align:center;font-size:12px;color:${TEXT_MUTED};word-break:break-all;">${verifyUrl}</p>
<p style="text-align:center;font-size:12px;color:${TEXT_MUTED};">Der Link ist 24 Stunden gültig.</p>
`;
return emailShell("✉", "Bitte bestätige deine E-Mail-Adresse", body, null, buildLegalFooterLines(seller));
}