Compare commits

..
2 Commits
Author SHA1 Message Date
kantarciandClaude Fable 5.1 d05dba3042 Let a form send from its own address in 1.15.1
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Umut asked where a contact form's mail is sent from: it was the recipient
address, `From` equal to `To`. That breaks as soon as the recipient is a
mailbox at another provider, because most hosts only deliver mail that
claims an address of their own domain, and the provider's SPF/DMARC then
treats it as forged.

Each form now has an optional `sender` setting, validated like the
recipient; empty keeps the old behaviour, so stored forms need no
migration. Delivery uses `sender ?: recipient`; the reply-to field is
untouched. The administration shows the field under the recipient with a
note on why it matters. The pilot launch config moves to port 8091 because
8087 was held by another session.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:16:15 +02:00
kantarciandClaude Fable 5.1 4d1f51801c Document verified 1.15.0 release
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 08:01:42 +02:00
10 changed files with 82 additions and 7 deletions

No files matched your search

+2 -2
View File
@@ -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",
+4
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
1.15.0
1.15.1
+1 -1
View File
@@ -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.
+22
View File
@@ -1,5 +1,27 @@
# 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.
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.15.0 v1.15.0`, `php scripts/build-release.php` darin): 227 Quelldateien, `vendor/` ohne Entwicklungspakete, 3549 Dateien im Archiv.
**Anhänge:** `kantsites-1.15.0.zip` (4,7 MB, SHA-256 `86ef728812db3b2f565f07dd1d8aebf8d5d41b75875695544134684b77a193ed`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.15.0.py`, erst Entwurf, dann veröffentlicht.
**Geprüft:** HTTP-Archiv-Update 1.14.2 → 1.15.0 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`, aus dem Hauptrepository heraus, weil der saubere Checkout weder `vendor/` noch `var/kantit.db` hat); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.15.0`, Installeradresse 200. Die Prüfungen des Codes selbst stehen im Eintrag darunter.
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv (keine Migration), dann unter Formulare das Aussehen des Kontaktformulars wählen.
## 1.15.0 – Formulare, Statistik, Design und Seitenleiste überarbeitet (15.09.2026)
**Anlass:** Umut fand die Formularanlage unschön und wollte Felder löschen können, die Statistik „lieblos“, auf der Designseite vordefinierte Farben, eigene Farben für Link- und Seitenfarbe und eine Vorschau der Schriften, und den Fuß der Seitenleiste (Fassung, Benutzer, Abmelden) besser. Nachgeschoben: eine Vorschau des Kontaktformulars, in der Rahmen-, Hintergrund- und Buttonfarbe direkt gewählt werden.
+6
View File
@@ -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,
+1
View File
@@ -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'),
+3 -1
View File
@@ -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, '/'));
+5
View File
@@ -96,6 +96,11 @@
<label for="recipient-{{ f }}">Empfänger</label>
<input id="recipient-{{ f }}" name="recipient" type="email" required value="{{ settings.recipient|default('') }}" placeholder="info@beispiel.de">
</div>
<div>
<label for="sender-{{ f }}">Absender</label>
<input id="sender-{{ f }}" name="sender" type="email" value="{{ settings.sender|default('') }}" placeholder="formular@beispiel.de">
<p class="field-help">Leer: die Empfängeradresse. Viele Anbieter stellen nur zu, was von einer Adresse der eigenen Domain kommt.</p>
</div>
<div>
<label for="subject-{{ f }}">Betreff der E-Mail</label>
<input id="subject-{{ f }}" name="subject" maxlength="160" value="{{ settings.subject|default('') }}" placeholder="Neue Formularnachricht">
+37 -2
View File
@@ -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<string, string> $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();