diff --git a/README.md b/README.md
index 1795793..0873058 100644
--- a/README.md
+++ b/README.md
@@ -641,16 +641,28 @@ mechanism as Posts/LegalPages/Testimonials (`useLivePreview()` from
option here). Not shared code with the React page — this is plain
inline-styled HTML built for email-client compatibility (nested
`
`s, no flexbox) — just matched by eye.
-- **Footer company line is configurable, not hardcoded.** `emailShell()`
- takes a required `companyLine` parameter (" · ")
- — `orderEmail.ts` fetches `company-settings` once (`getSellerForInvoice()`)
- and derives it from there, same admin-editable business data the invoice
- PDFs already use, rather than a literal `"einfach produktiv ·
- admin@mk360.de"` string. The Payload-side sends (password-reset, the 4
- status-change emails, verification) do the same via that repo's
- `src/lib/sellerInfo.ts`. Live Preview uses `DEFAULT_COMPANY_LINE` (a
- fallback constant) since there's no real order/tenant context there to
- fetch against.
+- **Footer carries a full legal Anbieterkennzeichnung, not just a brand
+ line.** `emailShell()` takes a `footerLines: string[]` array built by
+ `buildLegalFooterLines(seller)` — `sellerName`, `sellerStreet`,
+ `sellerZip`/`sellerCity` (+ `sellerCountry` if not Germany), `E-Mail:
+ sellerEmail`, and `USt-IdNr.: vatId` when set — the same admin-editable
+ `company-settings` fields the invoice PDFs already use, rather than a
+ literal `"einfach produktiv · admin@mk360.de"` string. `orderEmail.ts`
+ and `alertAdmin.ts` (both the resend-verification mail and the plain-text
+ critical-alert mail) all fetch `company-settings` once
+ (`getSellerForInvoice()`) and pass the `seller` object straight into the
+ render functions, which call `buildLegalFooterLines()` themselves — one
+ place composes the footer, not each call site. The Payload-side sends
+ (password-reset, the 4 status-change emails, the *initial* verification
+ email) need the equivalent treatment via that repo's own
+ `src/lib/sellerInfo.ts`, not present in this checkout. Live Preview passes
+ `seller: null`, which falls back to `DEFAULT_LEGAL_FOOTER_LINES` (a
+ placeholder Anbieterkennzeichnung) since there's no real order/tenant
+ context there to fetch against. Note `company-settings` currently has no
+ Handelsregister court/number or Geschäftsführer field — fine for a sole
+ proprietorship, but would need adding if the business becomes a
+ registered legal form (GmbH etc.), see that collection's own field list
+ above.
### GDPR self-service
diff --git a/app/email-preview/[type]/components/LiveEmailPreviewClient.tsx b/app/email-preview/[type]/components/LiveEmailPreviewClient.tsx
index f25ea89..3f01d8c 100644
--- a/app/email-preview/[type]/components/LiveEmailPreviewClient.tsx
+++ b/app/email-preview/[type]/components/LiveEmailPreviewClient.tsx
@@ -6,7 +6,6 @@ import {
renderPasswordResetHtml,
renderOrderStatusHtml,
ORDER_STATUS_EMAIL_ICON,
- DEFAULT_COMPANY_LINE,
SAMPLE_ORDER,
type EmailTemplateContent,
} from "../../../lib/emailTemplates";
@@ -35,17 +34,21 @@ export function LiveEmailPreviewClient({
depth: 0,
});
+ // No real company-settings fetch in this preview context — passing null
+ // falls back to DEFAULT_LEGAL_FOOTER_LINES (placeholder Anbieterkennzeichnung)
+ // inside buildLegalFooterLines(), same shape as the real send just with
+ // placeholder business data.
const html =
type === "order-confirmation"
- ? renderOrderConfirmationHtml(data, SAMPLE_ORDER, DEFAULT_COMPANY_LINE)
+ ? renderOrderConfirmationHtml(data, SAMPLE_ORDER, null)
: type === "password-reset"
- ? renderPasswordResetHtml(data, "https://einfach-produktiv.mk360.de/konto/passwort-zuruecksetzen?token=beispiel-token", DEFAULT_COMPANY_LINE)
+ ? renderPasswordResetHtml(data, "https://einfach-produktiv.mk360.de/konto/passwort-zuruecksetzen?token=beispiel-token", null)
: renderOrderStatusHtml(
data,
ORDER_STATUS_EMAIL_ICON[type] ?? "✓",
SAMPLE_ORDER.orderNumber,
`https://einfach-produktiv.mk360.de/konto/bestellungen/${SAMPLE_ORDER.orderNumber}`,
- DEFAULT_COMPANY_LINE,
+ null,
);
return (
diff --git a/app/lib/alertAdmin.ts b/app/lib/alertAdmin.ts
index b23efbe..0a2c51a 100644
--- a/app/lib/alertAdmin.ts
+++ b/app/lib/alertAdmin.ts
@@ -1,16 +1,25 @@
import { transport } from "./mailer";
+import { buildLegalFooterLines, renderVerificationEmailHtml } from "./emailTemplates";
+import { getSellerForInvoice } from "./invoiceData";
// Fire-and-forget by design — callers should not await this in a way that
// blocks or fails the actual error response the customer sees. Wrap
// everything in its own try/catch so a broken mail relay never becomes a
-// second, worse failure on top of the one being reported.
+// second, worse failure on top of the one being reported. Internal-only
+// (sent to admin@mk360.de, this business's own inbox), but still carries
+// the plain-text Anbieterkennzeichnung for consistency with every other
+// email this app sends — see buildLegalFooterLines() in emailTemplates.ts.
export function sendCriticalAlert(subject: string, details: Record): void {
- transport
- .sendMail({
- from: '"einfach produktiv Alerts" ',
- to: "admin@mk360.de",
- subject: `[einfach produktiv] ${subject}`,
- text: JSON.stringify(details, null, 2),
+ getSellerForInvoice()
+ .catch(() => null)
+ .then((seller) => {
+ const footer = buildLegalFooterLines(seller).join("\n");
+ return transport.sendMail({
+ from: '"einfach produktiv Alerts" ',
+ to: "admin@mk360.de",
+ subject: `[einfach produktiv] ${subject}`,
+ text: `${JSON.stringify(details, null, 2)}\n\n---\n${footer}`,
+ });
})
.catch((err) => {
console.error("sendCriticalAlert: failed to send alert email", err);
@@ -24,13 +33,16 @@ export function sendCriticalAlert(subject: string, details: Record {
const url = `https://einfach-produktiv.mk360.de/api/account/verify-email?token=${token}`;
+ const seller = await getSellerForInvoice();
await transport.sendMail({
from: '"einfach produktiv" ',
to,
subject: "Bitte bestätige deine E-Mail-Adresse",
- html: `Hallo ${firstName},
bitte bestätige deine E-Mail-Adresse für dein Konto bei einfach produktiv:
${url}
Der Link ist 24 Stunden gültig.
`,
+ html: renderVerificationEmailHtml(firstName, url, seller),
});
}
diff --git a/app/lib/emailTemplates.ts b/app/lib/emailTemplates.ts
index 4775527..e29ce8a 100644
--- a/app/lib/emailTemplates.ts
+++ b/app/lib/emailTemplates.ts
@@ -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, "&").replace(//g, ">");
}
-// 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
-// " · " 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 `
@@ -116,7 +142,7 @@ function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerT
|
${footerText ? ` ${escapeHtml(footerText)} ` : ""}
- ${escapeHtml(companyLine)}
+ ${footerLines.map((line) => `${escapeHtml(line)} `).join("")}
|
@@ -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) => `
@@ -202,7 +228,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
`;
- 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(
`;
- 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")}
@@ -253,5 +279,28 @@ export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl
Der Link ist 1 Stunde gültig.
`;
- 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")}
+
+ ${verifyUrl}
+ Der Link ist 24 Stunden gültig.
+ `;
+
+ return emailShell("✉", "Bitte bestätige deine E-Mail-Adresse", body, null, buildLegalFooterLines(seller));
}
diff --git a/app/lib/invoiceData.ts b/app/lib/invoiceData.ts
index b9ddb2c..afe6e30 100644
--- a/app/lib/invoiceData.ts
+++ b/app/lib/invoiceData.ts
@@ -5,9 +5,10 @@ import { renderCorrectionInvoicePdf, type CorrectionInvoiceKind, type Correction
export type { CompanySettings };
// Fetched once by callers that need seller data for more than one purpose
-// in the same request (e.g. orderEmail.ts also needs it for the email
-// footer's companyLine) — avoids a second identical company-settings round
-// trip, unlike calling getCompanySettings() again inside each generator.
+// in the same request (e.g. orderEmail.ts also needs it for the email's
+// legal footer, see buildLegalFooterLines() in emailTemplates.ts) — avoids
+// a second identical company-settings round trip, unlike calling
+// getCompanySettings() again inside each generator.
export async function getSellerForInvoice(): Promise {
return getCompanySettings();
}
diff --git a/app/lib/orderEmail.ts b/app/lib/orderEmail.ts
index 8d4bbe8..30510ab 100644
--- a/app/lib/orderEmail.ts
+++ b/app/lib/orderEmail.ts
@@ -1,6 +1,6 @@
import { transport } from "./mailer";
import { getEmailTemplate } from "./payload";
-import { renderOrderConfirmationHtml, DEFAULT_COMPANY_LINE, type OrderConfirmationData } from "./emailTemplates";
+import { renderOrderConfirmationHtml, type OrderConfirmationData } from "./emailTemplates";
import { generateInvoicePdf, getSellerForInvoice } from "./invoiceData";
import { sendCriticalAlert } from "./alertAdmin";
@@ -47,8 +47,7 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
};
const seller = await getSellerForInvoice();
- const companyLine = seller ? `${seller.sellerName} · ${seller.sellerEmail}` : DEFAULT_COMPANY_LINE;
- const html = renderOrderConfirmationHtml(template, order, companyLine);
+ const html = renderOrderConfirmationHtml(template, order, seller);
let attachments: { filename: string; content: Buffer }[] | undefined;
try {