fix(contact): keep the contact email editable and protect it from scrapers
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m20s

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:[email protected].

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 20:35:50 +02:00
1 parent 12675f381a
commit 2943021fe9
9 files changed
+251 -35

No files matched your search

+12 -8
View File
@@ -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<void>
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. [email protected]). 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. [email protected], info [at] wabenhain.com oder info [á] wabenhain.com."
);
}
updates.push({ key: def.key, valueDe: def.key === COMPANY_EMAIL_KEY ? valueDe.trim() : valueDe, valueEn: null });
}
+20 -16
View File
@@ -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() {
<div className="relative h-full overflow-hidden bg-forest px-8 py-10 text-cream md:px-10">
<HoneycombPattern className="text-honey-light/[0.1]" size={24} fade="right" />
<div className="relative">
<a
href={`mailto:${company.email}`}
className="group inline-flex items-center gap-3 font-serif text-2xl sm:text-3xl md:text-4xl text-cream"
>
<span className="link-draw pb-1">{company.email}</span>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-5 w-5 text-honey-light transition-transform duration-300 ease-[var(--ease-arrive)] group-hover:translate-x-1"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
>
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</a>
{/* Vor Adress-Sammlern geschuetzt: im HTML steht nur
"info [at] …", den mailto-Link baut erst der Browser. */}
<ContactEmail
email={company.email}
linkClassName="group inline-flex max-w-full items-center gap-3 break-all font-serif text-2xl sm:text-3xl md:text-4xl text-cream"
textClassName="link-draw pb-1"
trailing={
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-5 w-5 shrink-0 text-honey-light transition-transform duration-300 ease-[var(--ease-arrive)] group-hover:translate-x-1"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
>
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
}
/>
{/* Ohne gepflegte Nummer entfaellt die Zeile ganz. Eine
Platzhalternummer waere schlimmer als keine: sie sieht nach
einem Kontaktweg aus, an dessen Ende niemand abnimmt. */}