Give every email a full legal footer (Anbieterkennzeichnung), not just a company line

Order confirmation, resend-verification, and the internal critical-alert
mail now render name, street, ZIP/city, email, and VAT ID from
company-settings instead of a bare "<sellerName> · <sellerEmail>" line,
so every email this app sends meets business-correspondence footer
requirements rather than just the customer-facing ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-22 14:15:43 +00:00
parent a801d41d79
commit e61a62e579
6 changed files with 119 additions and 43 deletions
+63 -14
View File
@@ -1,4 +1,5 @@
import { formatPrice, formatDate } from "./format";
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,
@@ -64,18 +65,43 @@ function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Live-Preview-only fallback (no real order/invoice-settings fetch there,
// Live-Preview-only fallback (no real order/company-settings fetch there,
// see /email-preview/[type]) — the actual send always passes the real
// "<sellerName> · <sellerEmail>" from invoice-settings (see orderEmail.ts).
export const DEFAULT_COMPANY_LINE = "einfach produktiv · admin@mk360.de";
// seller (company-settings) through buildLegalFooterLines() below.
export const DEFAULT_LEGAL_FOOTER_LINES: string[] = [
"einfach produktiv",
"Musterstraße 12",
"12345 Musterstadt",
"E-Mail: admin@mk360.de",
];
// Every business email needs an Anbieterkennzeichnung (§5 TMG-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. If company-settings ever grows a
// Handelsregister court/number or Geschäftsführer field (needed once this
// business is a registered legal form rather than a sole proprietorship),
// add those lines here too.
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}`);
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. `companyLine`: sourced from Payload's invoice-settings
// (sellerName/sellerEmail), not hardcoded — same admin-editable business
// data the invoice PDFs already use, see orderEmail.ts.
function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerText: string | null, companyLine: string): string {
// 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>
@@ -116,7 +142,7 @@ function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerT
<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>` : ""}
<p style="margin:0;font-size:12px;color:${TEXT_MUTED};">${escapeHtml(companyLine)}</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>
@@ -163,7 +189,7 @@ export const SAMPLE_ORDER: OrderConfirmationData = {
total: 37.7,
};
export function renderOrderConfirmationHtml(template: EmailTemplateContent, order: OrderConfirmationData, companyLine: string): string {
export function renderOrderConfirmationHtml(template: EmailTemplateContent, order: OrderConfirmationData, seller: CompanySettings | null): string {
const rows = order.items
.map(
(item) => `<tr>
@@ -202,7 +228,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
</table>
`;
return emailShell("✓", escapeHtml(template.heading), body, template.footerText, companyLine);
return emailShell("✓", escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
}
// Icon shown per status — matches the Payload-side send exactly (see
@@ -222,7 +248,7 @@ export function renderOrderStatusHtml(
icon: string,
orderNumber: string,
orderUrl: string,
companyLine: string,
seller: CompanySettings | null,
): string {
const body = `
${paragraphs(template.bodyText, "center")}
@@ -236,10 +262,10 @@ export function renderOrderStatusHtml(
</table>
`;
return emailShell(icon, escapeHtml(template.heading), body, template.footerText, companyLine);
return emailShell(icon, escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
}
export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string, companyLine: string): string {
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;">
@@ -253,5 +279,28 @@ export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl
<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, companyLine);
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));
}