feat(contact): Kontaktformular mit CSV-Speicherung und Mailpit-Mails ergänzt und tests erstellt

feature/Information
Emirhan3 2026-09-29 19:52:07 +00:00
parent 1f5d5dca87
commit 184599d872
11 changed files with 747 additions and 4 deletions

1
.gitignore vendored
View File

@ -10,5 +10,6 @@ Thumbs.db
# Private Buchungsdaten und lokale Testergebnisse # Private Buchungsdaten und lokale Testergebnisse
src/server/data/* src/server/data/*
!src/server/data/.gitkeep !src/server/data/.gitkeep
!src/server/data/contact-requests.csv
playwright-report/ playwright-report/
test-results/ test-results/

View File

@ -0,0 +1,21 @@
# Kontaktformular der Ernährungsseite
Das Formular in `index.html` sendet seine Eingaben als JSON an `POST /api/nutrition-contact`.
Der Server validiert Name, E-Mail, Betreff und Nachricht, speichert die Anfrage in
`src/server/data/contact-requests.csv` und sendet anschließend zwei E-Mails über
Mailpit auf `127.0.0.1:1025`:
- eine Eingangsbestätigung an die eingegebene E-Mail-Adresse;
- beide Nachrichten mit dem Absender `Tri-Hub Ernährung <noreply@tri-hub.de>`;
- die vollständige Kontaktanfrage an die Mailpit-Testadresse
`ernaehrung@tri-hub.de`; die SMTP-Verbindung ist fest auf den lokalen
Mailpit-Server gesetzt, daher wird diese Nachricht nur simuliert und nicht
an den echten Mailserver zugestellt.
Die Mailpit-Weboberfläche ist lokal unter `http://127.0.0.1:8025` erreichbar.
Zum Entwickeln Mailpit mit `npm run dev:mail`, den API-Server mit
`npm run dev:server` und Vite mit `npm run dev` starten.
Die CSV-Datei wird beim ersten erfolgreichen Absenden angelegt. Bei einem
Mailfehler bleibt die Anfrage gespeichert; die API meldet, dass der Versand
nicht vollständig bestätigt werden konnte.

View File

@ -470,6 +470,7 @@
} }
input[type="text"], input[type="text"],
input[type="email"],
textarea, textarea,
select { select {
width: 100%; width: 100%;
@ -485,6 +486,7 @@
} }
input[type="text"], input[type="text"],
input[type="email"],
select { select {
height: 48px; height: 48px;
padding: 0 14px; padding: 0 14px;
@ -557,6 +559,21 @@
cursor: pointer; cursor: pointer;
} }
#contactMessageHint.is-valid {
color: var(--brand);
}
.field-error {
display: block;
min-height: 0;
color: #fca5a5;
font-size: 0.78rem;
}
.form-status.is-error {
color: #fca5a5;
}
.form-status { .form-status {
min-height: 20px; min-height: 20px;
margin-top: 12px; margin-top: 12px;
@ -1134,8 +1151,13 @@
type="text" type="text"
autocomplete="name" autocomplete="name"
placeholder="z. B. Thomas Müller" placeholder="z. B. Thomas Müller"
maxlength="120"
required required
/> />
<small
class="field-error"
data-error-for="name"
></small>
</div> </div>
<div class="field"> <div class="field">
@ -1145,11 +1167,16 @@
<input <input
id="contactEmail" id="contactEmail"
name="email" name="email"
type="text" type="email"
autocomplete="email" autocomplete="email"
placeholder="z. B. thomas@müller.de" placeholder="z. B. thomas@example.de"
maxlength="254"
required required
/> />
<small
class="field-error"
data-error-for="email"
></small>
</div> </div>
<div class="field"> <div class="field">
@ -1159,8 +1186,13 @@
name="subject" name="subject"
type="text" type="text"
placeholder="Worum geht es?" placeholder="Worum geht es?"
maxlength="160"
required required
/> />
<small
class="field-error"
data-error-for="subject"
></small>
</div> </div>
<div class="field"> <div class="field">
@ -1170,11 +1202,18 @@
name="message" name="message"
placeholder="Wie können wir dir helfen?" placeholder="Wie können wir dir helfen?"
minlength="10" minlength="10"
maxlength="5000"
required required
></textarea> ></textarea>
<small>Mindestens 10 Zeichen erforderlich.</small <small id="contactMessageHint"
><br /> >Mindestens 10 Zeichen erforderlich.</small
>
<small
class="field-error"
data-error-for="message"
></small>
<br />
<small>*Pflichtfelder</small> <small>*Pflichtfelder</small>
</div> </div>
@ -1207,6 +1246,10 @@
type="module" type="module"
src="/src/components/navigationsbar/navigation.js" src="/src/components/navigationsbar/navigation.js"
></script> ></script>
<script
type="module"
src="/src/features/contact/contact-form.js"
></script>
<script> <script>
const form = document.getElementById("reviewForm"); const form = document.getElementById("reviewForm");
const status = document.getElementById("formStatus"); const status = document.getElementById("formStatus");

View File

@ -0,0 +1,70 @@
const form = document.querySelector("#contactForm");
const status = document.querySelector("#contactFormStatus");
const message = form?.elements.message;
const messageHint = document.querySelector("#contactMessageHint");
function updateMessageHint() {
if (!message || !messageHint) return;
const valid = [...message.value.trim()].length >= 10;
messageHint.classList.toggle("is-valid", valid);
messageHint.textContent = valid
? "Mindestens 10 Zeichen erreicht."
: "Mindestens 10 Zeichen erforderlich.";
}
function showErrors(fields = {}) {
for (const input of form.elements) {
if (!input.name) continue;
input.removeAttribute("aria-invalid");
const error = form.querySelector(`[data-error-for="${input.name}"]`);
if (error) error.textContent = fields[input.name] ?? "";
if (fields[input.name]) input.setAttribute("aria-invalid", "true");
}
}
message?.addEventListener("input", updateMessageHint);
updateMessageHint();
form?.addEventListener("submit", async (event) => {
event.preventDefault();
status.textContent = "";
status.classList.remove("is-error");
showErrors();
if (!form.reportValidity()) {
status.textContent = "Bitte prüfe die markierten Eingaben.";
status.classList.add("is-error");
return;
}
const submit = form.querySelector('[type="submit"]');
submit.disabled = true;
submit.textContent = "Wird gesendet …";
try {
const response = await fetch("/api/nutrition-contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
const result = await response.json();
if (!response.ok) {
showErrors(result.error?.fields);
throw new Error(
result.error?.message ??
"Die Nachricht konnte nicht gesendet werden.",
);
}
form.reset();
updateMessageHint();
status.textContent =
"Danke für deine Nachricht. Wir melden uns schnellstmöglich bei dir.";
} catch (error) {
status.textContent =
error.message ||
"Der Server ist gerade nicht erreichbar. Bitte versuche es erneut.";
status.classList.add("is-error");
} finally {
submit.disabled = false;
submit.textContent = "Nachricht senden";
}
});

View File

@ -0,0 +1,13 @@
createdAt,name,email,subject,message
2026-09-29T18:51:35.694Z,Max Muster,test10@web.de,Erstgespräch für eine Ernährungsberatung,"Guten Tag,
ich möchte einen termin vereinbaren, können sie sich bei mir melden.
Mfg
Max Muster"
2026-09-29T19:27:34.639Z,Test Test,test11@web.de,Erstgespräch Ernährung,"Guten Tag,
können sie sich bei mir melden.
Mfg
Test"
1 createdAt name email subject message
2 2026-09-29T18:51:35.694Z Max Muster test10@web.de Erstgespräch für eine Ernährungsberatung Guten Tag, ich möchte einen termin vereinbaren, können sie sich bei mir melden. Mfg Max Muster
3 2026-09-29T19:27:34.639Z Test Test test11@web.de Erstgespräch Ernährung Guten Tag, können sie sich bei mir melden. Mfg Test

View File

@ -11,6 +11,10 @@ import {
import { createCsvStorage } from "./services/csv-storage.js"; import { createCsvStorage } from "./services/csv-storage.js";
import { createEmailService } from "./services/email-service.js"; import { createEmailService } from "./services/email-service.js";
import { handleBooking, json } from "./routes/bookings.js"; import { handleBooking, json } from "./routes/bookings.js";
import { handleContactForm } from "./routes/contact-form.js";
import { createContactFormService } from "./services/contact-form-service.js";
import { createContactFormStorage } from "./services/contact-form-storage.js";
import { createContactFormEmailService } from "./services/contact-form-email.js";
export function createApp({ export function createApp({
catalog = packages, catalog = packages,
@ -20,6 +24,10 @@ export function createApp({
details, details,
sendConfirmation, sendConfirmation,
sendContact, sendContact,
contactFormStorage = createContactFormStorage(
fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)),
),
sendContactForm,
} = {}) { } = {}) {
const ids = new Set(); const ids = new Set();
for (const entry of catalog) { for (const entry of catalog) {
@ -50,8 +58,17 @@ export function createApp({
storage, storage,
sendContact: sendContact ?? createContactEmailService(), sendContact: sendContact ?? createContactEmailService(),
}); });
// Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand.
const contactForm = createContactFormService({
storage: contactFormStorage,
sendContactForm: sendContactForm ?? createContactFormEmailService(),
});
return createServer(async (request, response) => { return createServer(async (request, response) => {
const path = new URL(request.url, "http://localhost").pathname; const path = new URL(request.url, "http://localhost").pathname;
// Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt.
if (path === "/api/nutrition-contact") {
return handleContactForm(request, response, contactForm);
}
if (path === "/api/contact" || path === "/api/contact/validate") { if (path === "/api/contact" || path === "/api/contact/validate") {
return handleContact( return handleContact(
request, request,

View File

@ -0,0 +1,43 @@
import { BookingError } from "../services/booking-service.js";
import { json, readJson } from "./bookings.js";
// Nimmt HTTP-Anfragen des Ernährungs-Kontaktformulars entgegen und übersetzt Service-Ergebnisse in JSON.
export async function handleContactForm(request, response, service) {
// Das Formular darf Anfragen ausschließlich per POST absenden.
if (request.method !== "POST") {
response.setHeader("Allow", "POST");
return json(response, 405, {
error: {
code: "METHOD_NOT_ALLOWED",
message: "Bitte POST verwenden.",
},
});
}
try {
// Liest JSON, führt den Kontaktablauf aus und antwortet bei Erfolg mit HTTP 201.
const result = await service.submit(await readJson(request));
return json(response, 201, result);
} catch (error) {
// Erwartete Validierungs- und Dienstfehler behalten Status und Fehlercode.
if (error instanceof BookingError) {
return json(response, error.status, {
error: {
code: error.code,
message: error.message,
...(Object.keys(error.fields ?? {}).length
? { fields: error.fields }
: {}),
},
});
}
// Unerwartete Fehler werden protokolliert, ohne Formulardaten offenzulegen.
console.error("Kontaktformular konnte nicht verarbeitet werden.");
return json(response, 500, {
error: {
code: "INTERNAL_ERROR",
message:
"Die Nachricht konnte gerade nicht verarbeitet werden.",
},
});
}
}

View File

@ -0,0 +1,83 @@
import { createMailTransport } from "./email-service.js";
import { BookingError } from "./booking-service.js";
// Sichtbarer Empfänger in Mailpit; der Transport darunter bleibt lokal.
const contactAddress = "ernaehrung@tri-hub.de";
// Richtet den SMTP-Transport fest auf Mailpit ein und liefert den E-Mail-Versand zurück.
export function createContactFormEmailService(
env = process.env,
makeTransport,
) {
const { transport, from } = createMailTransport(
{
SMTP_HOST: "127.0.0.1",
SMTP_PORT: "1025",
SMTP_FROM: "Tri-Hub Ernährung <noreply@tri-hub.de>",
},
makeTransport,
);
return async function sendContactForm(data) {
// Erstellt die Eingangsbestätigung für den Absender und die Anfrage für Tri-Hub.
const messages = [
{
to: data.email,
subject: "Wir haben deine Nachricht erhalten – Tri-Hub",
text: [
`Hallo ${data.name},`,
"",
"vielen Dank für deine Nachricht. Wir haben deine Anfrage erhalten und melden uns schnellstmöglich bei dir.",
"",
`Betreff: ${data.subject}`,
"Deine Nachricht:",
data.message,
"",
"Viele Grüße",
"Tri-Hub Ernährung",
].join("\n"),
},
{
to: contactAddress,
subject: `Tri-Hub Kontaktanfrage: ${data.subject}`,
text: [
"Neue Kontaktanfrage über die Ernährungsseite",
"",
`Name: ${data.name}`,
`E-Mail: ${data.email}`,
`Betreff: ${data.subject}`,
"",
"Nachricht:",
data.message,
].join("\n"),
replyTo: data.email,
},
];
// Sendet beide Nachrichten und prüft, dass Mailpit beide Empfänger angenommen hat.
const results = await Promise.allSettled(
messages.map(async (message) => {
const result = await transport.sendMail({ from, ...message });
if (
!result.accepted?.some(
(address) =>
address.toLowerCase() === message.to.toLowerCase(),
)
) {
throw new Error("SMTP hat den Empfänger nicht angenommen.");
}
}),
);
// Ein Fehler bei mindestens einer Nachricht wird an den Service weitergegeben.
if (results.some((result) => result.status === "rejected")) {
throw new BookingError(
502,
"CONTACT_EMAIL_FAILED",
"Deine Nachricht wurde gespeichert, aber die E-Mails konnten nicht vollständig gesendet werden. Bitte versuche es später erneut oder melde dich direkt bei Tri-Hub.",
);
}
return { emailStatus: "accepted" };
};
}
export { contactAddress };

View File

@ -0,0 +1,90 @@
import { BookingError, emailPattern } from "./booking-service.js";
// Prüft und normalisiert die vom Browser übermittelten Kontaktangaben.
function normalizeInput(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new BookingError(
400,
"INVALID_INPUT",
"Bitte alle Pflichtfelder prüfen.",
);
}
// Legt die zulässige Höchstlänge für jedes Formularfeld fest.
const limits = { name: 120, email: 254, subject: 160, message: 5000 };
const fields = {};
const data = {};
for (const [field, max] of Object.entries(limits)) {
const value = input[field];
if (typeof value !== "string") {
fields[field] = "Dieses Feld ist erforderlich.";
continue;
}
const trimmed = value.trim();
if (!trimmed) fields[field] = "Dieses Feld ist erforderlich.";
else if (trimmed.length > max)
fields[field] = `Maximal ${max} Zeichen eingeben.`;
else if (
(field === "message"
? /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/
: /[\x00-\x1f\x7f]/
).test(trimmed)
)
fields[field] = "Bitte entferne ungültige Steuerzeichen.";
else data[field] = trimmed;
}
// Prüft zusätzlich das E-Mail-Format und die Mindestlänge der Nachricht.
if (
data.email &&
(!emailPattern.test(data.email) || data.email.split("@")[0].length > 64)
) {
fields.email = "Bitte eine gültige E-Mail-Adresse eingeben.";
}
if (data.message && [...data.message].length < 10) {
fields.message =
"Deine Nachricht muss mindestens 10 Zeichen enthalten.";
}
// Gibt alle gefundenen Feldfehler gesammelt an die Route zurück.
if (Object.keys(fields).length) {
throw new BookingError(
400,
"INVALID_INPUT",
"Bitte prüfe deine Eingaben.",
fields,
);
}
return data;
}
// Verbindet Validierung, CSV-Speicherung und anschließenden E-Mail-Versand.
export function createContactFormService({ storage, sendContactForm }) {
return {
async submit(input) {
const data = normalizeInput(input);
// Ergänzt einen Zeitstempel, damit die Anfrage zeitlich nachvollziehbar bleibt.
const record = { createdAt: new Date().toISOString(), ...data };
// Speichert vor dem Versand, damit die Anfrage bei SMTP-Problemen erhalten bleibt.
try {
await storage.append(record);
} catch {
throw new BookingError(
503,
"CONTACT_NOT_SAVED",
"Deine Nachricht konnte gerade nicht gespeichert werden. Bitte versuche es erneut.",
);
}
// Sendet die beiden Mails erst nach erfolgreichem Speichern der Anfrage.
try {
await sendContactForm(record);
} catch (error) {
if (error instanceof BookingError) throw error;
throw new BookingError(
502,
"CONTACT_EMAIL_FAILED",
"Deine Nachricht wurde gespeichert, aber die E-Mails konnten nicht vollständig gesendet werden. Bitte versuche es später erneut oder melde dich direkt bei Tri-Hub.",
);
}
return { saved: true, emailStatus: "accepted" };
},
};
}

View File

@ -0,0 +1,93 @@
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
import { dirname } from "node:path";
import { randomUUID } from "node:crypto";
import { parse } from "csv-parse/sync";
import { stringify } from "csv-stringify/sync";
// Diese Spalten bilden den festen Aufbau der Kontaktanfragen-CSV.
const columns = ["createdAt", "name", "email", "subject", "message"];
// Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird.
function protect(value) {
const text = String(value ?? "");
return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text;
}
// Entfernt beim Einlesen den Schutzpräfix und stellt den Eingabewert wieder her.
function restore(value) {
return value.startsWith("'") ? value.slice(1) : value;
}
// Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange.
export function createContactFormStorage(filePath) {
let queue = Promise.resolve();
// Liest alle Anfragen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt.
async function readAll() {
let content;
try {
content = await readFile(filePath, "utf8");
} catch (error) {
if (error.code === "ENOENT") return [];
throw error;
}
if (!content.trim()) throw new Error("Leere Kontaktdatei");
return parse(content, {
bom: true,
columns(header) {
if (header.join(",") !== columns.join(",")) {
throw new Error("Unbekanntes Kontakt-CSV-Format");
}
return header;
},
skip_empty_lines: true,
}).map((row) =>
Object.fromEntries(
Object.entries(row).map(([key, value]) => [
key,
restore(value),
]),
),
);
}
// Schreibt die vollständige CSV in eine temporäre Datei und ersetzt danach atomar das Original.
async function writeAll(records) {
await mkdir(dirname(filePath), { recursive: true });
const temporary = `${filePath}.${randomUUID()}.tmp`;
const content = stringify(
records.map((row) =>
Object.fromEntries(
columns.map((column) => [column, protect(row[column])]),
),
),
{ header: true, columns, record_delimiter: "\r\n" },
);
try {
const handle = await open(temporary, "wx", 0o600);
try {
await handle.writeFile(content, "utf8");
await handle.sync();
} finally {
await handle.close();
}
await rename(temporary, filePath);
} finally {
await rm(temporary, { force: true });
}
}
return {
readAll,
// Hängt eine Anfrage nacheinander an, damit parallele Absendevorgänge keine Zeilen verlieren.
append(record) {
const operation = queue.then(async () => {
const records = await readAll();
records.push(record);
await writeAll(records);
});
queue = operation.catch(() => {});
return operation;
},
};
}

View File

@ -0,0 +1,269 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApp } from "../server/index.js";
import { createContactFormEmailService } from "../server/services/contact-form-email.js";
import { createContactFormStorage } from "../server/services/contact-form-storage.js";
const validInput = {
name: "Test Person",
email: "person@example.test",
subject: "Eine Frage",
message: "Das ist eine ausreichend lange Nachricht.",
};
// Startet die API mit temporärer Kontakt-CSV und einem ersetzbaren Mailversand.
async function fixture(t, options = {}) {
const directory = await mkdtemp(join(tmpdir(), "trihub-contact-form-"));
const storage =
options.storage ??
createContactFormStorage(join(directory, "contact-requests.csv"));
const sent = [];
const app = createApp({
sendConfirmation: async () => {},
sendContact: async () => {},
contactFormStorage: storage,
sendContactForm: async (data) => sent.push(data),
...options,
});
await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve));
t.after(async () => {
app.closeAllConnections();
await new Promise((resolve) => app.close(resolve));
await rm(directory, { recursive: true, force: true });
});
const base = `http://127.0.0.1:${app.address().port}`;
async function post(input = validInput, headers = {}) {
const response = await fetch(`${base}/api/nutrition-contact`, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(input),
});
return { status: response.status, body: await response.json() };
}
return { app, base, post, sent, storage, directory };
}
// Prüft den erfolgreichen Ablauf von HTTP-Anfrage über CSV bis zur Mail-Abhängigkeit.
test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", async (t) => {
const f = await fixture(t);
const result = await f.post();
assert.equal(result.status, 201);
assert.deepEqual(result.body, { saved: true, emailStatus: "accepted" });
assert.equal(f.sent.length, 1);
assert.deepEqual(
{
name: f.sent[0].name,
email: f.sent[0].email,
subject: f.sent[0].subject,
message: f.sent[0].message,
},
validInput,
);
assert.ok(!Number.isNaN(Date.parse(f.sent[0].createdAt)));
assert.deepEqual(await f.storage.readAll(), f.sent);
});
// Stellt sicher, dass fehlerhafte Pflichtfelder nicht gespeichert oder versendet werden.
test("Pflichtfelder, E-Mail, Längen und Mindestlänge werden validiert", async (t) => {
const f = await fixture(t);
const invalidInputs = [
{ ...validInput, name: " " },
{ ...validInput, name: "Test\nBcc: fremd" },
{ ...validInput, name: "x".repeat(121) },
{ ...validInput, email: "keine-adresse" },
{ ...validInput, email: "x".repeat(255) },
{ ...validInput, subject: "x".repeat(161) },
{ ...validInput, message: "zu kurz" },
{ ...validInput, message: "x".repeat(5001) },
{ ...validInput, message: "Nachricht\u0000ungültig" },
{ ...validInput, message: undefined },
null,
[],
];
for (const input of invalidInputs) {
const result = await f.post(input);
assert.equal(result.status, 400, JSON.stringify(input));
}
assert.deepEqual(await f.storage.readAll(), []);
assert.equal(f.sent.length, 0);
});
// Prüft die Begrenzungen und JSON-Fehlerbehandlung des HTTP-Endpunkts.
test("HTTP-Route lehnt falsche Methode, Inhaltstyp, JSON und große Bodies ab", async (t) => {
const f = await fixture(t);
assert.equal((await fetch(`${f.base}/api/nutrition-contact`)).status, 405);
const wrongType = await fetch(`${f.base}/api/nutrition-contact`, {
method: "POST",
body: "{}",
});
assert.equal(wrongType.status, 415);
const invalidJson = await fetch(`${f.base}/api/nutrition-contact`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{",
});
assert.equal(invalidJson.status, 400);
const tooLarge = await fetch(`${f.base}/api/nutrition-contact`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "x".repeat(9000) }),
});
assert.equal(tooLarge.status, 413);
assert.deepEqual(await f.storage.readAll(), []);
});
// Ein Speicherfehler muss verhindern, dass anschließend E-Mails abgeschickt werden.
test("Speicherfehler melden 503 und starten keinen Mailversand", async (t) => {
let sendCount = 0;
const f = await fixture(t, {
storage: {
async append() {
throw new Error("Datenträger nicht verfügbar");
},
},
sendContactForm: async () => sendCount++,
});
const result = await f.post();
assert.equal(result.status, 503);
assert.equal(result.body.error.code, "CONTACT_NOT_SAVED");
assert.equal(sendCount, 0);
});
// Nach einem SMTP-Fehler bleibt die bereits gespeicherte Anfrage erhalten.
test("Mailfehler melden 502, erhalten aber die gespeicherte Anfrage", async (t) => {
const f = await fixture(t, {
sendContactForm: async () => {
throw new Error("Mailpit nicht erreichbar");
},
});
const result = await f.post();
assert.equal(result.status, 502);
assert.equal(result.body.error.code, "CONTACT_EMAIL_FAILED");
assert.equal(result.body.error.message.includes("gespeichert"), true);
assert.equal((await f.storage.readAll()).length, 1);
});
// Prüft CSV-Rundlauf, Formelschutz und gleichzeitiges Anhängen mehrerer Anfragen.
test("CSV erhält Sonderzeichen und verliert bei parallelen Anhängen keine Zeilen", async (t) => {
const directory = await mkdtemp(join(tmpdir(), "trihub-contact-csv-"));
const file = join(directory, "contact-requests.csv");
const storage = createContactFormStorage(file);
t.after(() => rm(directory, { recursive: true, force: true }));
const names = [
'=HYPERLINK("evil")',
'Test, "Person"',
"Zeile 1\nZeile 2",
"'Original",
...Array.from({ length: 8 }, (_, index) => `Person ${index}`),
];
await Promise.all(
names.map((name, index) =>
storage.append({
createdAt: `2026-01-01T00:00:0${index}.000Z`,
name,
email: `person${index}@example.test`,
subject: "CSV Test",
message: "Eine Nachricht mit mehr als zehn Zeichen.",
}),
),
);
const records = await storage.readAll();
assert.equal(records.length, names.length);
assert.deepEqual(
records.map((record) => record.name).sort(),
[...names].sort(),
);
const csv = await readFile(file, "utf8");
assert.match(csv, /'=HYPERLINK/);
assert.match(csv, /"Test, ""Person"""/);
});
// Beschädigte CSV-Kopfzeilen sollen fehlschlagen und unangetastet bleiben.
test("CSV mit unerwartetem Kopf wird nicht überschrieben", async (t) => {
const directory = await mkdtemp(
join(tmpdir(), "trihub-contact-csv-invalid-"),
);
const file = join(directory, "contact-requests.csv");
const storage = createContactFormStorage(file);
const corrupt = "wrong,header\n1,2\n";
await writeFile(file, corrupt);
t.after(() => rm(directory, { recursive: true, force: true }));
await assert.rejects(
storage.append({ ...validInput, createdAt: "2026-01-01" }),
);
assert.equal(await readFile(file, "utf8"), corrupt);
});
// Verifiziert die zwei Mailvorlagen und dass der Transport fest bei Mailpit bleibt.
test("Mailservice verwendet Mailpit und sendet Bestätigung sowie Tri-Hub-Anfrage", async () => {
const config = [];
const messages = [];
const sendContactForm = createContactFormEmailService(
{},
(transportConfig) => {
config.push(transportConfig);
return {
async sendMail(message) {
messages.push(message);
return { accepted: [message.to] };
},
};
},
);
await sendContactForm({
...validInput,
createdAt: "2026-01-01T00:00:00.000Z",
});
assert.equal(config[0].host, "127.0.0.1");
assert.equal(config[0].port, 1025);
assert.deepEqual(
messages.map((message) => message.to).sort(),
[validInput.email, "ernaehrung@tri-hub.de"].sort(),
);
assert.ok(
messages.every(
(message) =>
message.from === "Tri-Hub Ernährung <noreply@tri-hub.de>",
),
);
assert.ok(
messages.every((message) => message.text.includes(validInput.message)),
);
const request = messages.find(
(message) => message.to === "ernaehrung@tri-hub.de",
);
assert.equal(request.replyTo, validInput.email);
});
// SMTP-Ablehnung wird als Fehler sichtbar, statt fälschlich Erfolg zu melden.
test("Mailservice meldet, wenn Mailpit einen Empfänger ablehnt", async () => {
const sendContactForm = createContactFormEmailService({}, () => ({
async sendMail(message) {
return {
accepted: message.to === validInput.email ? [message.to] : [],
};
},
}));
await assert.rejects(
sendContactForm(validInput),
(error) =>
error.status === 502 && error.code === "CONTACT_EMAIL_FAILED",
);
});