diff --git a/.claude/launch.json b/.claude/launch.json index 082d143..fc054dd 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -25,9 +25,9 @@ "runtimeArgs": [ "-NoProfile", "-Command", - "$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-539a85bd3015/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8087 -t public bin/dev-router.php" + "$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-1d19ca1bb9ca/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8091 -t public bin/dev-router.php" ], - "port": 8087 + "port": 8091 }, { "name": "elektro-bernhard", diff --git a/README.md b/README.md index 3fce5bc..381ad49 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ Lokaler CMS-Prototyp für PHP-Webhosting mit erstem visuellen Editor. Symfony 7. Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Den tatsächlichen Stand enthält `docs/STATUS.md`, Entscheidungen und Datenmodell stehen in `docs/ARCHITECTURE.md`. +## Eigene Absenderadresse je Formular (ab 1.15.1) + +Unter **Formulare → Zustellung** gibt es neben dem Empfänger ein Feld **Absender**. Bisher ging jede Nachricht von der Empfängeradresse an sich selbst; das scheitert, sobald der Empfänger ein Postfach bei einem anderen Anbieter ist, weil viele Hoster nur zustellen, was von einer Adresse der eigenen Domain kommt. Leer heißt weiterhin: Absender ist der Empfänger. Die Antwortadresse aus dem Formularfeld bleibt davon unberührt. + ## Formulare, Statistik, Design und Seitenleiste überarbeitet (ab 1.15.0) **Formulare** sind ein Editor: jedes Feld eine Karte mit Nummer, Beschriftung, Typ, technischem Namen, Hilfetext und Pflicht-Schalter, dazu „Feld hinzufügen“ und ein Entfernen-Knopf je Karte. Der technische Name folgt der Beschriftung, bis du ihn selbst schreibst. Unter **Aussehen** bekommt jedes Formular eine Buttonbeschriftung sowie Rahmen- und Hintergrundfarbe der Felder, Button- und Buttonschriftfarbe – aus der Palette oder als eigener Wert, leer heißt „wie die Website“. Die Vorschau daneben folgt jeder Eingabe, zeichnet den Absendeknopf mit dem Standardbutton des Designs und nennt den Kontrast der Buttonschrift. diff --git a/VERSION b/VERSION index 141f2e8..ace4423 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.15.0 +1.15.1 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 81c4836..a9a3bbe 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -414,7 +414,7 @@ Ein neuer Tab-Block bringt zwei Tabs mit: ein einzelner Tab ist keine Tabgruppe, ## ADR 017 – Formulare (Dokumentschema v8, 12.09.2026) -Migration `20260912000700` legt `form` und `form_submission` an. **Ein Formularblock im Seiteninhalt enthält nur die Adresse des Formulars.** Felder, Empfänger, Betreff, Antwortadresse und Einwilligungstext liegen in der Datenbank. Ein gespeichertes Dokument kann dadurch weder ändern, wonach gefragt wird, noch wohin eine Anfrage geht – dieselbe Trennung wie beim Block `postlist`. +Migration `20260912000700` legt `form` und `form_submission` an. **Ein Formularblock im Seiteninhalt enthält nur die Adresse des Formulars.** Felder, Empfänger, Absender (seit 1.15.1 wählbar, leer heißt Empfänger – manche Hoster stellen nur zu, was von einer Adresse der eigenen Domain kommt), Betreff, Antwortadresse und Einwilligungstext liegen in der Datenbank. Ein gespeichertes Dokument kann dadurch weder ändern, wonach gefragt wird, noch wohin eine Anfrage geht – dieselbe Trennung wie beim Block `postlist`. **Kein CSRF-Token und keine Sitzung auf dem öffentlichen Weg.** Symfonys CSRF-Schutz braucht eine Sitzung, und eine Sitzung bedeutet ein Cookie auf jeder öffentlichen Seite; das vermeidet das Projekt bewusst. Die Route ist anonym und ändert nichts, was einem Besucher gehört: eine gefälschte Anfrage könnte höchstens eine Nachricht senden, und genau dagegen wirkt der Spamschutz. Die Verwaltung der Formulardefinitionen bleibt dagegen sitzungsgebunden und CSRF-geschützt. diff --git a/docs/STATUS.md b/docs/STATUS.md index de84d8b..74a9073 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,15 @@ # Projektstatus +## 1.15.1 – Eigene Absenderadresse je Formular (15.09.2026) + +**Anlass:** Umut: „Versandadresse bei Kontaktformularen sollte man noch festlegen können – aktuell wird's über die gleiche Adresse verschickt wie der Empfänger?" Stimmte: `FormController::deliver()` setzte `From` auf die Empfängeradresse. Das geht schief, sobald der Empfänger ein Postfach bei einem anderen Anbieter ist (z. B. eine GMX- oder Gmail-Adresse), weil viele Hoster nur zustellen, was von einer Adresse der eigenen Domain kommt, und SPF/DMARC des fremden Anbieters die Post als Fälschung abweisen. + +**Umsetzung:** Neue Formulareinstellung `sender` (freiwillig). `FormRepository::validateSettings()` prüft sie wie den Empfänger mit `FILTER_VALIDATE_EMAIL`, leer bleibt erlaubt; die Verwaltung (`forms.html.twig`, Abschnitt Zustellung, Feld „Absender“ direkt unter dem Empfänger mit Hinweis) und `FormAdminController` reichen sie durch. Beim Versand gilt `sender ?: recipient` – bestehende Formulare ohne den Schlüssel verhalten sich wie bisher, es ist keine Migration nötig. Die Antwortadresse aus dem Formularfeld bleibt `Reply-To`. + +**Geprüft:** `phpunit` vollständig mit PHP 8.4 509 von 509 grün, mit PHP 8.2 509 grün mit der vorbestehenden Warnung in `UpdaterTest`; `npm test` 33 grün; `npm run test:editor` mit `PHP_BINARY=C:/php84/php.exe` vollständig bestanden, darin die axe-Prüfung der Formularseite. Neu in `EditorSecurityTest`: `testAFormMaySendFromItsOwnAddress` (From = Absender, To = Empfänger, Reply-To = Feld; ungültiger Absender abgewiesen, Leerzeichen werden zu leer) und im bestehenden Absendetest die Prüfung, dass ohne Absender From = To ist – beides über `getMailerMessage()` mit dem `null://`-Transport der Tests. php-cs-fixer ohne Befund. In der Pilotkopie im Browser (`kantsites-pilot`, jetzt Port 8091, weil 8087 von einer anderen Sitzung belegt war): Feld sichtbar, `formular@kant-it.de` gespeichert und nach dem Neuladen erhalten, Meldung „Das Formular wurde gespeichert.“ Ein echter Versand über SMTP wurde nicht geprüft. + +**Nächster Schritt:** Auf `web.kant-it.de` nach dem nächsten Update unter Formulare eine Absenderadresse der eigenen Domain eintragen (z. B. `formular@kant-it.de`), dann eine Testanfrage senden und im Postfach Absender und Zustellung kontrollieren. + ## 1.15.0 veröffentlicht (15.09.2026) Tag `v1.15.0` (Commit `160c394`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.15.0 – Formulare, Statistik, Design und Seitenleiste der Verwaltung überarbeitet, Formulare mit eigenen Farben und Vorschau. diff --git a/src/Content/FormRepository.php b/src/Content/FormRepository.php index 2cbaf14..3a8e4f5 100644 --- a/src/Content/FormRepository.php +++ b/src/Content/FormRepository.php @@ -164,6 +164,11 @@ final class FormRepository if (false === filter_var($recipient, FILTER_VALIDATE_EMAIL)) { throw new InvalidForm('Bitte gib eine gültige Empfängeradresse an.'); } + // Der Absender ist freiwillig: leer heisst, die Post kommt von der Empfaengeradresse. + $sender = trim((string) ($settings['sender'] ?? '')); + if ('' !== $sender && false === filter_var($sender, FILTER_VALIDATE_EMAIL)) { + throw new InvalidForm('Bitte gib eine gültige Absenderadresse an oder lass das Feld leer.'); + } $replyTo = trim((string) ($settings['replyToField'] ?? '')); if ('' !== $replyTo) { $match = array_filter($fields, static fn (array $f): bool => $f['name'] === $replyTo && 'email' === $f['type']); @@ -174,6 +179,7 @@ final class FormRepository $clean = [ 'recipient' => $recipient, + 'sender' => $sender, 'subject' => mb_substr(trim((string) ($settings['subject'] ?? '')), 0, 160) ?: 'Neue Formularnachricht', 'success' => mb_substr(trim((string) ($settings['success'] ?? '')), 0, 300) ?: 'Vielen Dank. Deine Nachricht ist angekommen.', 'replyToField' => $replyTo, diff --git a/src/Controller/FormAdminController.php b/src/Controller/FormAdminController.php index 939bfc9..362852b 100644 --- a/src/Controller/FormAdminController.php +++ b/src/Controller/FormAdminController.php @@ -62,6 +62,7 @@ final class FormAdminController extends AbstractController $fields, [ 'recipient' => $request->request->getString('recipient'), + 'sender' => $request->request->getString('sender'), 'subject' => $request->request->getString('subject'), 'success' => $request->request->getString('success'), 'replyToField' => $request->request->getString('replyToField'), diff --git a/src/Controller/FormController.php b/src/Controller/FormController.php index debc716..94deb28 100644 --- a/src/Controller/FormController.php +++ b/src/Controller/FormController.php @@ -90,8 +90,10 @@ final class FormController extends AbstractController foreach ($form['fields'] as $field) { $lines[] = $field['label'].': '.($values[$field['name']] ?? ''); } + // Der Absender ist die eigene Adresse des Formulars, sonst der Empfaenger. Viele Hoster + // liefern nur aus, was von einer Adresse ihrer eigenen Domain kommt. $email = (new Email()) - ->from($form['settings']['recipient']) + ->from(($form['settings']['sender'] ?? '') ?: $form['settings']['recipient']) ->to($form['settings']['recipient']) ->subject($form['settings']['subject']) ->text(implode("\n", $lines)."\n\n-- \nGesendet über ".rtrim($this->siteOrigin, '/')); diff --git a/templates/admin/forms.html.twig b/templates/admin/forms.html.twig index 3395ba1..d02088b 100644 --- a/templates/admin/forms.html.twig +++ b/templates/admin/forms.html.twig @@ -96,6 +96,11 @@ +
+ + +

Leer: die Empfängeradresse. Viele Anbieter stellen nur zu, was von einer Adresse der eigenen Domain kommt.

+
diff --git a/tests/EditorSecurityTest.php b/tests/EditorSecurityTest.php index b4c72d3..f51d7a9 100644 --- a/tests/EditorSecurityTest.php +++ b/tests/EditorSecurityTest.php @@ -11,6 +11,7 @@ use App\Content\PageRepository; use App\Content\PartialRepository; use App\Content\SiteSettings; use App\Content\Theme; +use App\Domain\InvalidForm; use App\Infrastructure\ConnectionFactory; use App\Kernel; use App\Security\UserProvider; @@ -322,7 +323,8 @@ final class EditorSecurityTest extends WebTestCase self::assertSame('Neu', $this->db->fetchOne('SELECT name FROM menu WHERE slot = ?', ['header'])); } - private function contactForm(): FormRepository + /** @param array $settings overrides for the mail settings */ + private function contactForm(array $settings = []): FormRepository { $forms = new FormRepository($this->db); $forms->save('kontakt', 'Kontakt', [ @@ -331,7 +333,7 @@ final class EditorSecurityTest extends WebTestCase ['name' => 'thema', 'type' => 'select', 'label' => 'Thema', 'required' => true, 'options' => ['Beratung', 'Support']], ['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Nachricht', 'required' => false], ['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich willige ein.'], - ], ['recipient' => 'info@kant-it.de', 'subject' => 'Anfrage', 'replyToField' => 'email', 'success' => 'Danke.'], 0); + ], ['recipient' => 'info@kant-it.de', 'subject' => 'Anfrage', 'replyToField' => 'email', 'success' => 'Danke.', ...$settings], 0); return $forms; } @@ -380,6 +382,12 @@ final class EditorSecurityTest extends WebTestCase // Post/Redirect/Get, so reloading the result page sends nothing twice. self::assertResponseStatusCodeSame(303); self::assertStringContainsString('?gesendet=kontakt#formblock', (string) $client->getResponse()->headers->get('Location')); + // Ohne eigene Absenderadresse geht die Post vom Empfaenger an sich selbst. + $mail = self::getMailerMessage(); + self::assertNotNull($mail); + self::assertEmailAddressContains($mail, 'From', 'info@kant-it.de'); + self::assertEmailAddressContains($mail, 'To', 'info@kant-it.de'); + self::assertEmailAddressContains($mail, 'Reply-To', 'umut@example.com'); $row = $this->db->fetchAssociative('SELECT * FROM form_submission'); self::assertNotFalse($row); @@ -396,6 +404,33 @@ final class EditorSecurityTest extends WebTestCase self::assertStringContainsString('Danke.', $client->getResponse()->getContent()); } + public function testAFormMaySendFromItsOwnAddress(): void + { + // Umut: die Versandadresse soll sich festlegen lassen - bisher war sie immer der + // Empfaenger, und manche Hoster nehmen fremde Absender gar nicht erst an. + $forms = $this->contactForm(['sender' => 'formular@kant-it.de']); + $this->pageWithForm(); + $client = self::createClient(); + + $client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10))); + self::assertResponseStatusCodeSame(303); + $mail = self::getMailerMessage(); + self::assertNotNull($mail); + self::assertEmailAddressContains($mail, 'From', 'formular@kant-it.de'); + self::assertEmailAddressContains($mail, 'To', 'info@kant-it.de'); + self::assertEmailAddressContains($mail, 'Reply-To', 'umut@example.com'); + + // Eine kaputte Absenderadresse wird abgewiesen, eine leere bedeutet: wie der Empfaenger. + try { + $forms->save('kontakt', 'Kontakt', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail']], ['recipient' => 'info@kant-it.de', 'sender' => 'kein-postfach'], 1); + self::fail('Eine ungueltige Absenderadresse darf nicht gespeichert werden.'); + } catch (InvalidForm $e) { + self::assertStringContainsString('Absenderadresse', $e->getMessage()); + } + $forms->save('kontakt', 'Kontakt', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail']], ['recipient' => 'info@kant-it.de', 'sender' => ' '], 1); + self::assertSame('', $forms->findBySlug('kontakt')['settings']['sender']); + } + public function testInvalidSubmissionKeepsEveryEntryAndNamesTheOffendingFields(): void { $this->contactForm();