From 2943021fe945401069fc0edfb482a93d5fda0ece Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 26 Sep 2026 20:35:50 +0200 Subject: [PATCH] fix(contact): keep the contact email editable and protect it from scrapers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An earlier change rejected obfuscated entries such as "info [á] wabenhain.com" in Admin -> Inhalte and replaced them with the default address. The obfuscation was intentional, so: - The admin accepts the entry as written (plain or obfuscated with [at], (at), {ät}, [á], <@>, [dot], [punkt]) and stores it unchanged; only entries that yield no valid address are rejected. - lib/contact-email.ts turns the entry into the real address for mailto links and DHL labels. - Public pages never contain the address in clear text: footer, contact page and every email in legal texts (Impressum, Datenschutz, Widerruf) render "info [at] wabenhain.com" plus an XOR-encoded payload with a fresh random key; the browser builds the clickable mailto link after load (components/protected-email.tsx). Verified with a production build: the raw HTML of /, /kontakt, /impressum, /datenschutz and /widerrufsbelehrung contains no plain address, and after load each page shows mailto:info@wabenhain.com. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/admin/actions/content.ts | 20 ++++---- src/app/kontakt/page.tsx | 36 ++++++++------- src/components/contact-email.tsx | 24 ++++++++++ src/components/legal-page.tsx | 11 ++++- src/components/protected-email.tsx | 47 +++++++++++++++++++ src/components/site-footer.tsx | 3 +- src/lib/company-info.ts | 15 +++--- src/lib/contact-email.test.ts | 56 ++++++++++++++++++++++ src/lib/contact-email.ts | 74 ++++++++++++++++++++++++++++++ 9 files changed, 251 insertions(+), 35 deletions(-) create mode 100644 src/components/contact-email.tsx create mode 100644 src/components/protected-email.tsx create mode 100644 src/lib/contact-email.test.ts create mode 100644 src/lib/contact-email.ts diff --git a/src/app/admin/actions/content.ts b/src/app/admin/actions/content.ts index 33f36a7..7e251b8 100644 --- a/src/app/admin/actions/content.ts +++ b/src/app/admin/actions/content.ts @@ -7,7 +7,7 @@ import { CONTENT_REGISTRY } from "@/lib/content-registry"; import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads"; import { requireAdmin } from "./_shared"; import { fail, succeed } from "@/lib/flash"; -import { z } from "zod"; +import { normalizeContactEmail } from "@/lib/contact-email"; const COMPANY_EMAIL_KEY = "company.email"; @@ -51,13 +51,17 @@ export async function updateSiteContentAction(formData: FormData): Promise if (def.type === "video" && valueDe !== "" && !isValidVideoUploadPath(valueDe)) { continue; } - // Die Firmen-E-Mail steht im Impressum (§ 5 Abs. 1 Nr. 2 DDG verlangt - // eine direkt nutzbare Adresse), in der Widerrufsbelehrung und in - // mailto-Links. Eine verschleierte Schreibweise ("info [at] …") waere - // dort rechtlich angreifbar und technisch unbrauchbar — daher wird der - // ganze Speichervorgang abgelehnt statt still uebernommen. - if (def.key === COMPANY_EMAIL_KEY && !z.string().trim().email().safeParse(valueDe).success) { - return fail("/admin/inhalte", "Bitte gib als Firmen-E-Mail eine vollständige, gültige Adresse an (z. B. info@wabenhain.com). Sie steht im Impressum und muss direkt nutzbar sein."); + // Die Firmen-E-Mail darf so eingetragen werden, wie sie gemeint ist — + // auch verschleiert ("info [at] …", "info [á] …"). Gespeichert wird der + // Eintrag unveraendert; die Website rechnet ihn in die echte Adresse um + // und zeigt sie geschuetzt an (lib/contact-email.ts). Abgelehnt wird nur, + // was sich in keine gueltige Adresse umrechnen laesst — sonst stuende im + // Impressum ein Kontaktweg, der ins Leere fuehrt. + if (def.key === COMPANY_EMAIL_KEY && normalizeContactEmail(valueDe) === null) { + return fail( + "/admin/inhalte", + "Die Firmen-E-Mail ergibt keine gültige Adresse. Möglich sind z. B. info@wabenhain.com, info [at] wabenhain.com oder info [á] wabenhain.com." + ); } updates.push({ key: def.key, valueDe: def.key === COMPANY_EMAIL_KEY ? valueDe.trim() : valueDe, valueEn: null }); } diff --git a/src/app/kontakt/page.tsx b/src/app/kontakt/page.tsx index 0073c05..c57b7d4 100644 --- a/src/app/kontakt/page.tsx +++ b/src/app/kontakt/page.tsx @@ -3,6 +3,7 @@ import { Container, SectionHeading } from "@/components/ui"; import { HoneycombPattern, HexMark } from "@/components/honeycomb"; import { ScrollReveal } from "@/components/scroll-reveal"; import { getCompanyInfo } from "@/lib/company-info"; +import { ContactEmail } from "@/components/contact-email"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; @@ -32,22 +33,25 @@ export default async function KontaktPage() {
- - {company.email} - - + {/* Vor Adress-Sammlern geschuetzt: im HTML steht nur + "info [at] …", den mailto-Link baut erst der Browser. */} +