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:
@@ -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
|
||||
`<table>`s, no flexbox) — just matched by eye.
|
||||
- **Footer company line is configurable, not hardcoded.** `emailShell()`
|
||||
takes a required `companyLine` parameter ("<sellerName> · <sellerEmail>")
|
||||
— `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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+21
-9
@@ -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<string, unknown>): void {
|
||||
transport
|
||||
.sendMail({
|
||||
from: '"einfach produktiv Alerts" <admin@mk360.de>',
|
||||
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" <admin@mk360.de>',
|
||||
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<string, unkno
|
||||
// route.ts), which updates the token via the customer's own session
|
||||
// (app/lib/customerAuth.ts's resendVerificationEmail) but has no Payload
|
||||
// hook to piggyback on for a plain update, so it sends directly instead —
|
||||
// same Hostinger transport as the alert above, just a different template.
|
||||
// same Hostinger transport as the alert above, and the same branded
|
||||
// emailShell()/legal-footer template as every other email (see
|
||||
// renderVerificationEmailHtml in emailTemplates.ts).
|
||||
export async function sendVerificationEmail(to: string, firstName: string, token: string): Promise<void> {
|
||||
const url = `https://einfach-produktiv.mk360.de/api/account/verify-email?token=${token}`;
|
||||
const seller = await getSellerForInvoice();
|
||||
await transport.sendMail({
|
||||
from: '"einfach produktiv" <admin@mk360.de>',
|
||||
to,
|
||||
subject: "Bitte bestätige deine E-Mail-Adresse",
|
||||
html: `<p>Hallo ${firstName},</p><p>bitte bestätige deine E-Mail-Adresse für dein Konto bei einfach produktiv:</p><p><a href="${url}">${url}</a></p><p>Der Link ist 24 Stunden gültig.</p>`,
|
||||
html: renderVerificationEmailHtml(firstName, url, seller),
|
||||
});
|
||||
}
|
||||
|
||||
+63
-14
@@ -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, "<").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
|
||||
// "<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));
|
||||
}
|
||||
|
||||
@@ -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<CompanySettings | null> {
|
||||
return getCompanySettings();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user