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")} +
+ + + +
+ E-Mail-Adresse bestätigen +
+

${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 {