feat(newsletter): newsletter signup as internal component
Deploy / verify (push) Successful in 1m18s
Deploy / deploy (push) Successful in 1m34s

Wire a newsletter signup form into the CMS internal-component pattern
(form-newsletter). Posts to /api/newsletter which subscribes to listmonk
(double opt-in) and mirrors a best-effort backup record to the RustyCMS
forms plugin. Reuses the contact-form anti-spam stack: Cloudflare
Turnstile, honeypots, client-age check, origin validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-06-25 11:02:48 +02:00
parent 0ab2c58eb4
commit af90483dc4
6 changed files with 506 additions and 1 deletions
+1 -1
View File
@@ -469,7 +469,7 @@ export type CalendarBlockData = Omit<
};
/** Internal component block (_type: "internal_component"). */
export type InternalComponentId = "form-contact" | "form-mitmachen";
export type InternalComponentId = "form-contact" | "form-mitmachen" | "form-newsletter";
export interface InternalComponentBlockData {
_type?: "internal_component";
@@ -3,6 +3,7 @@
import type { InternalComponentBlockData } from "$lib/block-types";
import ContactFormComponent from "$lib/components/internal/ContactFormComponent.svelte";
import MitmachenFormComponent from "$lib/components/internal/MitmachenFormComponent.svelte";
import NewsletterFormComponent from "$lib/components/internal/NewsletterFormComponent.svelte";
import FormCard from "$lib/components/internal/FormCard.svelte";
let { block }: { block: InternalComponentBlockData } = $props();
@@ -20,6 +21,10 @@
<FormCard>
<MitmachenFormComponent />
</FormCard>
{:else if component === "form-newsletter"}
<FormCard>
<NewsletterFormComponent />
</FormCard>
{:else}
<p class="text-sm text-stein-500">Unbekannte interne Komponente: <code>{component}</code></p>
{/if}
@@ -0,0 +1,265 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { env as publicEnv } from "$env/dynamic/public";
import Button from "$lib/ui/Button.svelte";
import TextInput from "$lib/ui/TextInput.svelte";
import Checkbox from "$lib/ui/Checkbox.svelte";
import { useTranslate, T } from "$lib/translations";
import {
validateNewsletter,
type NewsletterErrors,
type NewsletterFields,
} from "./newsletter-form-validation";
type FormState = "idle" | "pending" | "success" | "error";
const t = useTranslate();
const TURNSTILE_SITE_KEY = publicEnv.PUBLIC_TURNSTILE_SITE_KEY ?? "";
const TURNSTILE_ENABLED = TURNSTILE_SITE_KEY !== "";
let name = $state("");
let email = $state("");
let consent = $state(false);
// Honeypots — three with realistic names. Real users never see them.
let honeypot = $state("");
let website = $state("");
let company = $state("");
const pageLoadedAt = Date.now();
let formState = $state<FormState>("idle");
let serverError = $state("");
let submitAttempted = $state(false);
let turnstileToken = $state("");
let turnstileEl: HTMLDivElement | null = null;
let turnstileWidgetId: string | null = null;
const fields = $derived<NewsletterFields>({ name, email, consent });
const errors = $derived<NewsletterErrors>(validateNewsletter(fields));
const isValid = $derived(Object.keys(errors).length === 0);
function showError(field: keyof NewsletterFields): string {
if (!submitAttempted) return "";
const e = errors[field];
return e ? t(e.key, e.params) : "";
}
type TurnstileAPI = {
render: (
el: HTMLElement,
opts: {
sitekey: string;
theme?: "light" | "dark" | "auto";
size?: "normal" | "compact" | "flexible";
callback?: (token: string) => void;
"expired-callback"?: () => void;
"error-callback"?: () => void;
},
) => string;
reset: (id?: string) => void;
remove: (id: string) => void;
};
function getTurnstile(): TurnstileAPI | undefined {
return (window as unknown as { turnstile?: TurnstileAPI }).turnstile;
}
function renderTurnstile() {
if (!TURNSTILE_ENABLED || !turnstileEl) return;
const ts = getTurnstile();
if (!ts) return;
if (turnstileWidgetId !== null) return;
turnstileWidgetId = ts.render(turnstileEl, {
sitekey: TURNSTILE_SITE_KEY,
theme: "light",
size: "flexible",
callback: (token) => {
turnstileToken = token;
},
"expired-callback": () => {
turnstileToken = "";
},
"error-callback": () => {
turnstileToken = "";
},
});
}
function loadTurnstileScript() {
if (!TURNSTILE_ENABLED) return;
if (document.querySelector("script[data-turnstile]")) {
renderTurnstile();
return;
}
const s = document.createElement("script");
s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
s.async = true;
s.defer = true;
s.dataset.turnstile = "1";
s.onload = renderTurnstile;
document.head.appendChild(s);
}
onMount(loadTurnstileScript);
onDestroy(() => {
if (turnstileWidgetId !== null) {
getTurnstile()?.remove(turnstileWidgetId);
turnstileWidgetId = null;
}
});
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitAttempted = true;
if (formState === "pending") return;
if (!isValid) {
const first = Object.keys(errors)[0];
const el = document.querySelector<HTMLElement>(`[data-field="${first}"]`);
el?.focus();
return;
}
if (TURNSTILE_ENABLED && !turnstileToken) {
serverError = t(T.contact_captcha_pending);
formState = "error";
return;
}
formState = "pending";
serverError = "";
try {
const res = await fetch("/api/newsletter", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
email,
consent,
_honeypot: honeypot,
website,
company,
_client_age_ms: Date.now() - pageLoadedAt,
cf_turnstile_token: turnstileToken,
}),
});
if (res.ok) {
formState = "success";
return;
}
const body = await res.json().catch(() => ({}));
if (res.status === 422) {
serverError = t(T.contact_server_error_validation);
} else if (res.status === 429) {
serverError = t(T.contact_server_error_throttle);
} else if (res.status === 403) {
serverError = body?.error === "captcha"
? t(T.contact_server_error_captcha)
: t(T.contact_server_error_blocked);
} else {
serverError = body?.error || t(T.contact_server_error_generic);
}
formState = "error";
if (TURNSTILE_ENABLED) {
turnstileToken = "";
if (turnstileWidgetId !== null) getTurnstile()?.reset(turnstileWidgetId);
}
} catch {
serverError = t(T.contact_server_error_network);
formState = "error";
if (TURNSTILE_ENABLED) {
turnstileToken = "";
if (turnstileWidgetId !== null) getTurnstile()?.reset(turnstileWidgetId);
}
}
}
</script>
{#if formState === "success"}
<div class="rounded-sm border border-wald-300 bg-wald-50 px-4 py-5 text-center shadow-sm">
<p class="text-base font-semibold text-wald-700">{t(T.newsletter_success_title)}</p>
<p class="mt-1 text-sm text-stein-600">{t(T.newsletter_success_body)}</p>
</div>
{:else}
<form
onsubmit={handleSubmit}
novalidate
class="space-y-3"
aria-busy={formState === "pending"}
>
<!-- Honeypots: visually hidden, off the keyboard, off the a11y tree -->
<div aria-hidden="true" class="pointer-events-none absolute left-[-9999px] top-auto h-px w-px overflow-hidden opacity-0">
<label for="newsletter-hp-1">leer lassen</label>
<input id="newsletter-hp-1" name="_honeypot" type="text" tabindex="-1" autocomplete="off" bind:value={honeypot} />
<label for="newsletter-hp-2">Website</label>
<input id="newsletter-hp-2" name="website" type="url" tabindex="-1" autocomplete="off" bind:value={website} />
<label for="newsletter-hp-3">Firma</label>
<input id="newsletter-hp-3" name="company" type="text" tabindex="-1" autocomplete="off" bind:value={company} />
</div>
<div data-field="name">
<TextInput
size="sm"
name="name"
label={t(T.newsletter_label_name)}
placeholder={t(T.newsletter_placeholder_name)}
bind:value={name}
error={showError("name")}
/>
</div>
<div data-field="email">
<TextInput
size="sm"
name="email"
type="email"
label={t(T.newsletter_label_email)}
placeholder={t(T.newsletter_placeholder_email)}
required
bind:value={email}
error={showError("email")}
/>
</div>
<div data-field="consent" class="pt-1">
<Checkbox
size="sm"
name="consent"
bind:checked={consent}
label={t(T.newsletter_label_consent)}
/>
{#if showError("consent")}
<p class="mt-1 text-[0.7rem] text-error" role="alert">{showError("consent")}</p>
{/if}
<p class="mt-1 text-[0.7rem] leading-snug text-stein-500">{t(T.newsletter_privacy_note)}</p>
</div>
{#if TURNSTILE_ENABLED}
<div bind:this={turnstileEl} class="pt-1"></div>
{/if}
{#if serverError}
<div class="rounded-sm border border-error bg-error-subtle px-3 py-2 text-sm text-error shadow-sm" role="alert">
{serverError}
</div>
{/if}
<div class="flex items-center gap-3 pt-2">
<Button
type="submit"
variant="primary"
size="sm"
loading={formState === "pending"}
disabled={!isValid && submitAttempted}
label={formState === "pending" ? t(T.newsletter_submitting) : t(T.newsletter_submit)}
/>
{#if !isValid && submitAttempted && !serverError}
<span class="text-xs text-error">{t(T.contact_fix_errors)}</span>
{/if}
</div>
</form>
{/if}
@@ -0,0 +1,51 @@
/**
* Shared validation for NewsletterFormComponent + /api/newsletter server route.
* Single source of truth for limits and rules.
*/
export const LIMITS = {
NAME_MAX: 80,
EMAIL_MAX: 200,
MIN_CLIENT_AGE_MS: 3000,
} as const;
export const HONEYPOT_FIELDS = ["_honeypot", "website", "company"] as const;
export type NewsletterFields = {
name: string;
email: string;
consent: boolean;
};
/** Error as translation-key + optional params (resolved in the UI). */
export type NewsletterError = {
key: string;
params?: Record<string, string | number>;
};
export type NewsletterErrors = Partial<Record<keyof NewsletterFields, NewsletterError>>;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
function trim(s: unknown): string {
return typeof s === "string" ? s.trim() : "";
}
export function validateNewsletter(input: Partial<NewsletterFields>): NewsletterErrors {
const errors: NewsletterErrors = {};
const email = trim(input.email);
if (!email) {
errors.email = { key: "newsletter_error_email_required" };
} else if (email.length > LIMITS.EMAIL_MAX) {
errors.email = { key: "newsletter_error_email_max", params: { n: LIMITS.EMAIL_MAX } };
} else if (!EMAIL_RE.test(email)) {
errors.email = { key: "newsletter_error_email_invalid" };
}
if (input.consent !== true) {
errors.consent = { key: "newsletter_error_consent_required" };
}
return errors;
}
+16
View File
@@ -353,6 +353,22 @@ const TRANSLATION_KEYS = [
"mitmachen_error_files_count", // {{n}}
"mitmachen_error_files_size", // {{mb}}
"mitmachen_error_files_type",
// Newsletter form (NewsletterFormComponent.svelte). Generische Submit-/
// Server-Error-Texte werden aus contact_* wiederverwendet.
"newsletter_label_name",
"newsletter_placeholder_name",
"newsletter_label_email",
"newsletter_placeholder_email",
"newsletter_label_consent",
"newsletter_privacy_note",
"newsletter_submit",
"newsletter_submitting",
"newsletter_success_title",
"newsletter_success_body",
"newsletter_error_email_required",
"newsletter_error_email_invalid",
"newsletter_error_email_max", // {{n}}
"newsletter_error_consent_required",
] as const;
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
+168
View File
@@ -0,0 +1,168 @@
import { json } from "@sveltejs/kit";
import { env as publicEnv } from "$env/dynamic/public";
import { env as privateEnv } from "$env/dynamic/private";
import type { RequestHandler } from "./$types";
import {
HONEYPOT_FIELDS,
LIMITS,
validateNewsletter,
} from "$lib/components/internal/newsletter-form-validation";
const TURNSTILE_SECRET = privateEnv.TURNSTILE_SECRET ?? "";
const TURNSTILE_ENABLED = TURNSTILE_SECRET !== "";
// listmonk: server reaches it via the shared docker network in prod
// (http://listmonk:9000); falls back to the public URL for local dev.
const LISTMONK_URL = (privateEnv.LISTMONK_URL || "https://newsletter.windwiderstand.de").replace(/\/$/, "");
const LISTMONK_LIST_UUID =
privateEnv.LISTMONK_NEWSLETTER_LIST_UUID || "36b49110-bb20-486e-8269-971135e1dee9";
async function verifyTurnstile(token: string, ip: string): Promise<boolean> {
if (!TURNSTILE_ENABLED) return true;
if (!token) return false;
try {
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ secret: TURNSTILE_SECRET, response: token, remoteip: ip }),
});
if (!res.ok) return false;
const data = (await res.json()) as { success?: boolean };
return data.success === true;
} catch (e) {
console.error("[newsletter] turnstile verify failed:", e);
return false;
}
}
const ALLOWED_ORIGINS = new Set([
"https://windwiderstand.de",
"https://www.windwiderstand.de",
"http://localhost:5173",
"http://localhost:4173",
]);
/** Subscribe to listmonk via its public subscription form endpoint.
* A double-opt-in list makes listmonk send the confirmation mail itself. */
async function subscribeListmonk(email: string, name: string, ip: string): Promise<boolean> {
const body = new URLSearchParams();
body.set("email", email);
if (name) body.set("name", name);
body.append("l", LISTMONK_LIST_UUID);
try {
const res = await fetch(`${LISTMONK_URL}/subscription/form`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-Forwarded-For": ip,
},
body,
redirect: "manual", // success redirects to a confirm page (3xx)
});
// 2xx = rendered confirm page, 3xx = redirect to confirm page → both OK.
return res.status < 400;
} catch (e) {
console.error("[newsletter] listmonk subscribe failed:", e);
return false;
}
}
/** Best-effort backup record in the RustyCMS forms plugin. Never blocks signup. */
async function mirrorToCms(payload: Record<string, unknown>, ip: string): Promise<void> {
const cmsBase = (publicEnv.PUBLIC_CMS_URL || "http://localhost:3099").replace(/\/$/, "");
const cmsEnv = publicEnv.PUBLIC_CMS_ENV || "";
const cmsUrl = cmsEnv
? `${cmsBase}/api/forms/newsletter/submit?_environment=${encodeURIComponent(cmsEnv)}`
: `${cmsBase}/api/forms/newsletter/submit`;
try {
await fetch(cmsUrl, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Forwarded-For": ip },
body: JSON.stringify(payload),
});
} catch (e) {
console.error("[newsletter] CMS mirror failed (non-fatal):", e);
}
}
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
// Origin/Referer must match — blocks naive cross-site POSTs
const origin = request.headers.get("origin") ?? "";
const referer = request.headers.get("referer") ?? "";
const refererOrigin = referer ? safeOrigin(referer) : "";
if (!ALLOWED_ORIGINS.has(origin) && !ALLOWED_ORIGINS.has(refererOrigin)) {
return json({ error: "Ungültige Anfrage." }, { status: 403 });
}
const raw = await request.json().catch(() => null);
if (!raw || typeof raw !== "object") {
return json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const body = raw as Record<string, unknown>;
// Honeypots — must all be empty. Pretend success so bots don't learn the trap.
for (const hp of HONEYPOT_FIELDS) {
const val = body[hp];
if (typeof val === "string" && val.trim() !== "") {
return json({ ok: true }, { status: 201 });
}
}
// Client age — bots usually post immediately
const clientAge = Number(body._client_age_ms);
if (!Number.isFinite(clientAge) || clientAge < LIMITS.MIN_CLIENT_AGE_MS) {
return json({ error: "Bitte etwas mehr Zeit lassen." }, { status: 429 });
}
// Cloudflare Turnstile (skipped when no secret configured)
if (TURNSTILE_ENABLED) {
const token = typeof body.cf_turnstile_token === "string" ? body.cf_turnstile_token : "";
const ok = await verifyTurnstile(token, getClientAddress());
if (!ok) {
return json({ error: "captcha" }, { status: 403 });
}
}
// Validation
const fields = {
name: typeof body.name === "string" ? body.name : "",
email: typeof body.email === "string" ? body.email : "",
consent: body.consent === true,
};
const errors = validateNewsletter(fields);
if (Object.keys(errors).length > 0) {
return json({ errors }, { status: 422 });
}
const email = fields.email.trim();
const name = fields.name.trim();
const ip = getClientAddress();
// Primary: listmonk subscription (triggers double opt-in)
const subscribed = await subscribeListmonk(email, name, ip);
if (!subscribed) {
return json({ error: "Anmeldung fehlgeschlagen. Bitte später erneut versuchen." }, { status: 502 });
}
// Best-effort backup record in CMS (never blocks the signup)
await mirrorToCms(
{
email,
name,
consent_at: new Date().toISOString(),
submitted_via: "windwiderstand.de/api/newsletter",
user_agent: (request.headers.get("user-agent") ?? "").slice(0, 200),
},
ip,
);
return json({ ok: true }, { status: 201 });
};
function safeOrigin(u: string): string {
try {
return new URL(u).origin;
} catch {
return "";
}
}