Files
Wabenhain/src/lib/withdrawal.ts
T
Nick SzaunigandClaude Opus 5.5 cef2248a45 fix(security): escape untrusted values in transactional email HTML
Customer input (first name, address, product names), order data, admin
fields (company data, carrier, tracking number) and URLs were inserted
into email HTML unescaped. A first name such as <a href="…">…</a> ended
up as a working link in a genuine shop email, usable for phishing.

All variable values in email HTML now go through escapeHtml
(lib/escape-html.ts, shared with the withdrawal receipt). Button links
only accept http(s), so a javascript: tracking URL is dropped. The plain
text part stays unescaped. Tests cover order confirmation, shipment,
status mails, layout company data and back-in-stock mails.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:42:49 +02:00

117 lines
5.1 KiB
TypeScript

import type { Locale } from "@/i18n/config";
import { escapeHtml } from "@/lib/escape-html";
/**
* Elektronische Widerrufsfunktion (§ 356a BGB): Wortlaut der Erklaerung und
* der Eingangsbestaetigung. Bewusst an EINER Stelle statt in email-strings.ts:
* derselbe Wortlaut erscheint in der Mail an die Kundschaft, auf der
* Bestaetigungsseite und im Admin — die Eingangsbestaetigung muss genau das
* wiedergeben, was erklaert wurde (§ 356a Abs. 4 BGB).
*/
export interface WithdrawalDeclarationInput {
name: string;
orderNumber: string;
email: string;
message: string | null;
}
export interface WithdrawalReceiptEmail {
subject: string;
preheader: string;
/** Inhalt fuer den gemeinsamen Mail-Rahmen (emailLayout), bereits maskiert. preheader maskiert emailLayout selbst. */
bodyHtml: string;
text: string;
}
const TIME_ZONE = "Europe/Berlin";
const STRINGS = {
de: {
declaration: (orderNumber: string) => `Hiermit widerrufe ich den Vertrag über die Bestellung ${orderNumber}.`,
name: "Name",
email: "E-Mail",
message: "Nachricht",
subject: (orderNumber: string) => `Eingangsbestätigung deines Widerrufs zu ${orderNumber}`,
heading: "Dein Widerruf ist bei uns eingegangen",
intro: "Hiermit bestätigen wir den Eingang deiner Widerrufserklärung, die du über unsere Widerrufsfunktion abgegeben hast. Sie lautet:",
receivedAt: "Eingegangen am",
next: "Bitte sende uns die Ware innerhalb von 14 Tagen zurück, sofern du sie bereits erhalten hast. Sobald die Ware bei uns ist oder du uns die Rücksendung nachgewiesen hast, erstatten wir dir den Kaufpreis. Diese Bestätigung dokumentiert den Eingang deiner Erklärung.",
},
en: {
declaration: (orderNumber: string) => `I hereby withdraw from the contract for order ${orderNumber}.`,
name: "Name",
email: "Email",
message: "Message",
subject: (orderNumber: string) => `Confirmation of receipt of your withdrawal for ${orderNumber}`,
heading: "We have received your withdrawal",
intro: "We hereby confirm receipt of the notice of withdrawal you submitted via our withdrawal function. It reads:",
receivedAt: "Received on",
next: "If you have already received the goods, please send them back to us within 14 days. We will refund the purchase price once the goods have arrived or you have shown proof of return. This confirmation documents the receipt of your notice.",
},
} as const;
function part(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): string {
return parts.find((p) => p.type === type)?.value ?? "";
}
/** Zugangszeitpunkt in deutscher Ortszeit, sekundengenau und mit Zeitzone — er ist Beweismittel. */
export function formatReceivedAt(date: Date, locale: Locale): string {
const time = new Intl.DateTimeFormat("de-DE", {
timeZone: TIME_ZONE,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
}).format(date);
if (locale === "de") {
const day = new Intl.DateTimeFormat("de-DE", { timeZone: TIME_ZONE, day: "2-digit", month: "2-digit", year: "numeric" }).format(date);
const zone = part(new Intl.DateTimeFormat("de-DE", { timeZone: TIME_ZONE, timeZoneName: "short" }).formatToParts(date), "timeZoneName");
return `${day}, ${time} Uhr (${zone})`;
}
const day = new Intl.DateTimeFormat("en-GB", { timeZone: TIME_ZONE, day: "numeric", month: "long", year: "numeric" }).format(date);
const zone = part(new Intl.DateTimeFormat("en-GB", { timeZone: TIME_ZONE, timeZoneName: "short" }).formatToParts(date), "timeZoneName");
return `${day}, ${time} (${zone}, German time)`;
}
export function withdrawalDeclarationLines(input: WithdrawalDeclarationInput, locale: Locale): string[] {
const s = STRINGS[locale];
return [
s.declaration(input.orderNumber),
`${s.name}: ${input.name}`,
`${s.email}: ${input.email}`,
...(input.message ? [`${s.message}: ${input.message}`] : []),
];
}
/** Zeilenumbrueche im Betreff wuerden sonst als weitere Mail-Header gelesen (Header-Injection). */
function singleLine(value: string): string {
return value.replace(/[\r\n]+/g, " ");
}
export function withdrawalReceiptEmailContent(
input: WithdrawalDeclarationInput,
receivedAt: Date,
locale: Locale
): WithdrawalReceiptEmail {
const s = STRINGS[locale];
const lines = withdrawalDeclarationLines(input, locale);
const when = formatReceivedAt(receivedAt, locale);
const bodyHtml = `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
<p style="margin:0 0 16px;line-height:1.5;">${s.intro}</p>
<div style="margin:0 0 16px;padding:16px;background-color:#F4EFE5;line-height:1.6;">
${lines.map((line) => escapeHtml(line)).join("<br>")}
</div>
<p style="margin:0 0 16px;line-height:1.5;"><strong>${s.receivedAt}:</strong> ${escapeHtml(when)}</p>
<p style="margin:0;line-height:1.5;">${s.next}</p>
`;
const text = [s.heading, "", s.intro, "", ...lines, "", `${s.receivedAt}: ${when}`, "", s.next].join("\n");
return {
subject: singleLine(s.subject(input.orderNumber)),
preheader: singleLine(`${s.receivedAt}: ${when}`),
bodyHtml,
text,
};
}