Compare commits

...
9 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5.5 7fb4d2bfbd Note the 1.35.1 release in the status
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 Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:50:01 +02:00
kantarciandClaude Opus 5.5 ef7cb02cbd Release 1.35.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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:48:43 +02:00
kantarciandClaude Opus 5.5 7eaa3f6efa Tidy the mail and system screens and let the mail log be cleared
Text in admin cards had no size of its own, so 16px browser default sat
next to 13px notes; cards now use 13px throughout while fields stay at
16px for iOS. The mail page gets option cards, a 2x2 SMTP grid, a styled
save button in the card foot and a log with status pills and collapsed
server messages, plus "Protokoll leeren". The package card uses the same
sections and a styled file picker. The E-Mail-Versand subitem only shows
inside System and sits beside it in the horizontal strip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:48:43 +02:00
kantarciandClaude Opus 5.5 daaeb52588 Note the 1.35.0 release in the status
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 Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:08:27 +02:00
kantarciandClaude Opus 5.5 6b1293cdb8 Release 1.35.0
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 Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:07:27 +02:00
kantarciandClaude Opus 5.5 b982cb8944 Give mail delivery its own admin page and move the origin probe off /.well-known
The mail page under System tries SMTP, PHP mail() and both sendmail
modes one by one or all at once, and saves the chosen way to .env
without ever showing the password. IONOS answers everything under
/.well-known/ itself with 403, so the address and HTTPS checks never
reached the installation; the probe now lives at /kantsites-origin
(ADR 122).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:07:27 +02:00
kantarciandClaude Opus 5.5 2497b8cb69 Note the 1.34.0 release in the status
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 Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:30:39 +02:00
kantarciandClaude Opus 5.5 f5b9ae03e3 Release 1.34.0
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 Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:29:36 +02:00
kantarciandClaude Opus 5.5 d313156c73 Send a test mail from the admin and log why delivery failed
Form submissions marked as not sent gave no reason: the transport
exception was swallowed. Every send now goes through MailCheck, which
logs the server's answer and a hint in German to var/log/mail.log
(server lines only, no credentials). The system screen shows the mail
route, sends a test mail to any address and lists recent attempts
(ADR 121).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:29:36 +02:00
19 changed files with 1112 additions and 31 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.33.0
1.35.1
+1
View File
@@ -2,3 +2,4 @@ framework:
mailer:
# Tests never send real mail; the transport collects messages in memory instead.
dsn: 'null://null'
+23
View File
@@ -10,6 +10,10 @@ parameters:
# das Projekt schreiben, und eine Installation mit schreibgeschuetztem public/ ebenfalls.
kant.fonts_dir: '%kernel.project_dir%/public/assets/fonts'
kant.asset_dir: '%kernel.project_dir%/public/assets'
kant.mail_log: '%kernel.project_dir%/var/log/mail.log'
kant.mailer_dsn: 'sendmail://default'
# Die Einstellung, die die Verwaltung anzeigt und ausprobiert; Tests setzen sie fest.
kant.mail_dsn: '%env(default:kant.mailer_dsn:MAILER_DSN)%'
services:
_defaults:
@@ -59,6 +63,12 @@ services:
App\Content\StyleBundle:
arguments:
$assetDirectory: '%env(default:kant.asset_dir:ASSET_DIRECTORY)%'
# Versand und Protokoll (ADR 121). MAIL_LOG nur fuer Tests, damit sie nicht ins echte var/log
# schreiben.
App\Ops\MailCheck:
arguments:
$dsn: '%kant.mail_dsn%'
$logFile: '%env(default:kant.mail_log:MAIL_LOG)%'
App\Controller\FormController:
arguments:
$secret: '%kernel.secret%'
@@ -73,6 +83,10 @@ services:
App\Command\HealthCommand:
arguments:
$projectDir: '%kernel.project_dir%'
App\Controller\MailController:
arguments:
$projectDir: '%kernel.project_dir%'
$siteOrigin: '%env(SITE_ORIGIN)%'
App\Controller\SystemController:
arguments:
$adminPath: '%kant.admin_path%'
@@ -166,3 +180,12 @@ services:
App\Twig\PluginExtension:
arguments:
$loader: '@twig.loader.native_filesystem'
# Tests: das Versandprotokoll gehoert nicht nach var/log der echten Installation, und die
# Verwaltung soll einen festen SMTP-Weg als eingestellt sehen. Verschickt wird trotzdem ueber
# null://null (config/packages/test/mailer.yaml), weil derselbe Weg den Transport des
# Frameworks nimmt. Hier und nicht in config/packages/test, weil diese Datei danach geladen wird.
when@test:
parameters:
kant.mail_log: '%kernel.cache_dir%/mail.log'
kant.mail_dsn: 'smtp://cms.test:587'
+20
View File
@@ -2,6 +2,26 @@
Stand: 12.10.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 122 – E-Mail-Versand als eigene Seite, Probe nicht mehr unter /.well-known (12.10.2026)
Anlass: huefner-gartenbau.de bei IONOS. Mit 1.34.0 zeigte die Testmail, dass `sendmail -bs` dort abbricht und `native://default` (PHP `mail()`) geht, solange der Absender zu einer Domain des Vertrags gehört. Umut: „Können wir die Einstellungsmethoden ins Backend verlagern? Dass man dort alle Methoden probiert und auch SMTP setzt?“ – und „bau es in ein Untermenü ein, nicht System vollstopfen“. Gleichzeitig scheiterte „HTTPS erzwingen“ mit „antwortet mit 403“, obwohl HTTPS lief.
**E-Mail-Versand.** Eigene Seite `/admin/system/e-mail` (`MailController`), im Menü als Unterpunkt von „System“; die Karte auf der Systemseite ist weg. Wege: SMTP mit Postfach (Server, Port, Benutzer, Passwort), PHP `mail()`, `sendmail -t`, `sendmail -bs`. „Diesen Weg testen“ schickt eine Testmail über den gewählten Weg, ohne ihn zu speichern; „Alle Wege testen“ probiert alle Wege des Servers und – wenn eingetragen – SMTP nacheinander; „Diesen Weg speichern“ schreibt `MAILER_DSN` über `Environment::write` in `.env` (nur wenn sie dort steht, nicht aus der Server-Umgebung). `MailCheck::smtpDsn` baut die DSN mit URL-kodiertem Benutzer und Passwort, Port 465 wird `smtps`. Das Passwort steht nie im Formular: leer gelassen gilt das gespeicherte, solange Server und Benutzer gleich bleiben. Derselbe Weg wie eingestellt nutzt den Transport des Frameworks, ein anderer wird mit `Transport::fromDsn` gebaut. Hinweise neu: fremder Absender bei Server-Versand („Sender address is not allowed“) und bei sendmail-Fehlern zuerst `mail()`.
**Probe.** `OriginCheck::PATH` ist jetzt `/kantsites-origin` statt `/.well-known/kantsites-origin`. IONOS beantwortet alles unter `/.well-known/` selbst mit 403 (nachgemessen mit und ohne www, http und https); die Anfrage erreicht die Installation nie, deshalb konnten „Adresse speichern“ und „HTTPS erzwingen“ dort nicht gelingen. Die Ausnahme für `.well-known` in `public/.htaccess` bleibt für Zertifikatsanbieter.
**Folgen.** Die Tests setzen die angezeigte Einstellung über `when@test` in `config/services.yaml` (`kant.mail_dsn`, `kant.mail_log`); in `config/packages/test/` wurden die Parameter von `services.yaml` überschrieben, so dass die Tests von 1.34.0 in das lokale `var/log/mail.log` schrieben (nicht Teil eines Releases). Keine Migration.
## ADR 121 – E-Mail-Versand mit Protokoll und Testmail (12.10.2026)
Anlass: Auf huefner-gartenbau.de (IONOS) standen zwei Anfragen als „Nicht versendet“ in der Verwaltung, und nirgends stand, warum: `FormController` fing die `TransportException` und verwarf sie. Umut: „Bau mal in den Backend einen Knopf ein, Testmail an Mailadresse, wo man einträgt, und wo Logs angezeigt werden, woran es liegt.“
**Entscheidung.** Jeder Versand geht durch `App\Ops\MailCheck`: Formular und Testmail. Die Klasse versendet über den Transport aus `MAILER_DSN`, wirft nie und schreibt je Versuch eine JSON-Zeile nach `var/log/mail.log` (per FTP lesbar, ab 256 KB nach `.1` rotiert): Zeit, Anlass, Empfänger, Absender, Ergebnis, Meldung des Servers, ein Hinweis auf Deutsch für die häufigen Fälle (Anmeldung abgelehnt, Server nicht erreichbar, TLS, sendmail nicht nutzbar, Absender abgelehnt, ungültige DSN, `null://`) und die Antworten des Servers. Aus dem SMTP-Gespräch bleiben nur die Zeilen des Servers (`< `), denn die Zeilen des Clients enthalten bei AUTH LOGIN Benutzer und Passwort in Base64. Die Besucheradresse (Antwortadresse) steht nicht im Protokoll.
**Systemseite.** Neue Karte „E-Mail-Versand“: Versandweg ohne Passwort (Art, Server, Benutzer, Quelle der Einstellung), Feld für eine beliebige Empfängeradresse und einen Absender (vorbelegt mit dem SMTP-Benutzer, wenn er eine Adresse ist), danach Ergebnis, Hinweis und Serverantworten, darunter die letzten zehn Versuche. Nur Administratoren, mit CSRF-Token.
**Folgen.** Der Transport wird träge gebaut (`lazy`), damit eine ungültige `MAILER_DSN` den Versand scheitern lässt und im Protokoll steht, statt die Systemseite selbst. Die Einstellung bleibt in `.env`; ein Feld zum Ändern der DSN in der Verwaltung gibt es bewusst noch nicht (Passwort im Formular). Tests schreiben ihr Protokoll in den Cache (`kant.mail_log`, überschreibbar mit `MAIL_LOG`). Keine Migration; der Grund steht im Protokoll, nicht an der Einsendung.
## ADR 120 – HTTPS erzwingen aus der Verwaltung (12.10.2026)
Anlass: Umut: „HTTPS wird noch nicht erzwungen, wie machen wir das allgemein?“ Bisher hieß das: per FTP die drei auskommentierten Zeilen in `public/.htaccess` einschalten. Die Anwendung selbst erzwingt bewusst nichts (ADR zur Adresse: hinter einem Proxy sieht PHP oft nur `http`, ohne vertrauenswürdige Proxys wäre das eine Schleife).
+36
View File
@@ -1,5 +1,41 @@
# Projektstatus
## Fassung 1.35.1: E-Mail-Versand und Systemseite aufgeräumt, Protokoll leeren (13.10.2026)
**Anlass:** Umut: E-Mail-Seite und „Inhalte übertragen“ sehen uneinheitlich aus, Unterpunkt „E-Mail-Versand“ steht immer offen, „Logs leeren“ fehlt.
**Erledigt:** Fließtext in Karten hat jetzt durchgehend 13 px (vorher Browser-16 px neben 13 px), Felder bleiben 16 px. Neue gemeinsame Bausteine in `admin.css`: `.card-section` (Abschnitt mit Trennlinie), `.button-row`, `.form-foot`, `.option`/`.option-list` (Auswahl als Karten), `.pill-error`, Dateiauswahl im Knopfstil. E-Mail-Seite: Wege als Karten mit Erklärung, SMTP 2×2, Testmail-Abschnitt, „Speichern“ als Hauptknopf im Kartenfuß (war ungestylt). Protokoll mit Zustandsplakette, Hinweis vorn, Servermeldung zugeklappt (`admin/_mail_entry.html.twig`). „Protokoll leeren“ (`POST /admin/system/e-mail/protokoll`, löscht `mail.log` und `mail.log.1`). Unterpunkt nur im Bereich System sichtbar; in der waagrechten Leiste (761–1000 px) neben „System“.
**Geprüft:** `SystemScreenTest` erweitert (Leeren nur mit Token, danach leer, Editor 403, Unterpunkt auf Einstellungen nicht sichtbar). PHPUnit vollständig 682 grün, php-cs-fixer sauber, `lint:twig`, `test:admin`, `test:editor` grün. Im Browser (Hüfner lokal) bei 1240, 900 und 375 px: E-Mail-Seite, Systemseite, Einstellungen; kein waagrechtes Scrollen am Handy.
**Veröffentlicht:** Tag `v1.35.1`, Release-Eintrag mit Archiv (3635 Dateien, 5,3 MB, gebaut aus `var/release-source-1.35.1`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
**Offen:** Hüfner auf 1.35.1 aktualisieren (die offenen Punkte von 1.35.0 gelten weiter).
## Fassung 1.35.0: E-Mail-Versand als eigene Seite, HTTPS-Probe bei IONOS (12.10.2026)
**Anlass:** huefner-gartenbau.de: mit `native://default` geht der Versand, Umut will die Einstellung samt SMTP in der Verwaltung, als Unterpunkt statt auf „System“. „HTTPS erzwingen“ meldete 403, obwohl HTTPS lief.
**Erledigt (ADR 122):** Seite `/admin/system/e-mail` unter „System“: Weg wählen (SMTP, mail(), sendmail -t, sendmail -bs), testen ohne Speichern, alle Wege nacheinander testen, speichern nach `.env`; Passwort nie im Formular. Karte von der Systemseite entfernt. Probe der Adresse von `/.well-known/kantsites-origin` nach `/kantsites-origin`, weil IONOS `/.well-known/` selbst mit 403 beantwortet. Test-Parameter über `when@test`.
**Geprüft:** `MailCheckTest` (DSN aus Feldern samt Sonderzeichen, Vorbelegung ohne Passwort, Wege ausprobieren über die Fabrik, Hinweis bei fremdem Absender), `SystemScreenTest` (Menüpunkt, Seite, Testen, fehlendes Passwort, Speichern nur aus `.env`, nur Administratoren), `HttpsRedirectTest`. PHPUnit vollständig 682 grün (die bekannte Warnung aus `UpdaterTest`), php-cs-fixer sauber; `var/log/mail.log` entsteht durch Tests nicht mehr. Im Browser auf der Wegwerfkopie: „Alle Wege testen“ zeigt je Weg Ergebnis und Hinweis, SMTP-Felder verschwinden bei anderen Wegen, Handybreite ohne waagrechtes Scrollen, Unterpunkt im Menü markiert. Live auf IONOS ist die neue Probe noch nicht geprüft; dass `/.well-known/` dort 403 liefert und `/index.php/...` durchkommt, ist nachgemessen.
**Veröffentlicht:** Tag `v1.35.0`, Release-Eintrag mit Archiv (3634 Dateien, 5,3 MB, gebaut aus `var/release-source-1.35.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
**Offen:** Hüfner auf 1.35.0 aktualisieren, „HTTPS erzwingen“ erneut drücken, SMTP über das zentrale kant-it.de-Postfach einrichten und testen.
## Fassung 1.34.0: Testmail und Versandprotokoll in der Verwaltung (12.10.2026)
**Anlass:** huefner-gartenbau.de: Anfragen „Nicht versendet“, Ursache unbekannt (Ausnahme wurde verschluckt; vermutlich `sendmail://default` bei IONOS).
**Erledigt (ADR 121):** `App\Ops\MailCheck` für Formular und Testmail, Protokoll `var/log/mail.log` mit Meldung, deutschem Hinweis und Serverantworten (ohne Client-Zeilen, also ohne Zugangsdaten). Systemseite: Karte „E-Mail-Versand“ mit Versandweg, Testmail an frei wählbare Adresse samt Absender, letzte zehn Versuche.
**Geprüft:** `MailCheckTest` (Hinweise, Zugangsdaten bleiben draußen, Versandweg ohne Passwort), `SystemScreenTest` (Testmail, ungültige Adresse, Token, nur Administratoren); PHPUnit vollständig 678 grün (eine bekannte Warnung aus `UpdaterTest`, unabhängig davon), php-cs-fixer sauber. Im Browser auf einer Wegwerfkopie der Hüfner-Datenbank mit unerreichbarem SMTP-Server: Fehlermeldung, Hinweis „nicht erreichbar“ und Eintrag unter „Letzte Versuche“ erscheinen. Ein echter Versand über einen echten Mailserver wurde nicht ausgeführt.
**Veröffentlicht:** Tag `v1.34.0`, Release-Eintrag mit Archiv (3632 Dateien, 5,3 MB, gebaut aus `var/release-source-1.34.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
**Offen:** Hüfner auf 1.34.0 aktualisieren, `MAILER_DSN` auf SMTP umstellen (zentrales Absenderpostfach bei kant-it.de geplant), dann Testmail.
## Fassung 1.33.0: HTTPS erzwingen aus der Verwaltung (12.10.2026)
**Anlass:** Umut: HTTPS wird noch nicht erzwungen – allgemein lösen.
+4 -4
View File
@@ -78,10 +78,10 @@ RewriteRule ^ index.php [L]
# Nichts ausliefern, was nur intern gebraucht wird - falls jemand das Paket versehentlich
# vollstaendig in den oeffentlichen Ordner legt statt nur public/.
# .well-known ausgenommen: Unter /.well-known/kantsites-origin prueft die Verwaltung, ob die
# Website unter einer Adresse hier ankommt (Adresse speichern, HTTPS erzwingen), und Apache
# prueft den ersten nicht vorhandenen Pfadteil gegen diese Regel - vorher antwortete die
# Probe mit 403. Zertifikatsanbieter (acme-challenge) brauchen den Ordner ebenso.
# .well-known ausgenommen: Zertifikatsanbieter (acme-challenge) brauchen den Ordner, und Apache
# prueft den ersten nicht vorhandenen Pfadteil gegen diese Regel. Die Probe der Verwaltung
# (Adresse speichern, HTTPS erzwingen) liegt nicht mehr dort, sondern unter /kantsites-origin:
# IONOS beantwortet /.well-known/ selbst mit 403 (ADR 122).
<FilesMatch "^\.(?!well-known$)|\.(env|db|sqlite|lock|md|yaml|yml|dist|log)$">
Require all denied
</FilesMatch>
+75 -1
View File
@@ -293,7 +293,24 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
}
.card-head h2, .card-head h3 { margin: 0 }
.card-head .muted { font-size: 12px }
.card-body { padding: 8px 22px 24px; min-width: 0 }
.card-body { padding: 8px 22px 24px; min-width: 0; font-size: 13px; line-height: 1.6 }
/* Fließtext in Karten ist 13 px wie Hinweise und Fakten. Felder bleiben bei 16 px - darunter
vergrößert iOS beim Antippen die ganze Seite. :where() hält die Regel schwächer als jede Klasse. */
:where(.card-body) :where(input:not([type=checkbox], [type=radio]), select, textarea) { font-size: 16px }
.card-body h3 { font-size: 14px; margin: 26px 0 4px }
.card-body h3:first-child { margin-top: 14px }
/* Abschnitte einer Karte: Trennlinie, Überschrift, gleicher Abstand - als fieldset oder form. */
.card-section { border: 0; border-top: 1px solid var(--line); margin: 22px 0 0; padding: 18px 0 0; min-width: 0 }
.card-section > legend { float: left; width: 100%; font-weight: 650; font-size: 14px; margin: 0 0 12px; padding: 0 }
.card-section > legend + * { clear: both }
.card-section > h3, .card-body .card-section > h3:first-child { font-size: 14px; font-weight: 650; margin: 0 0 12px }
.card-section .check-label { margin: 14px 0 0 }
.card-section > h3 + .check-label, .card-section > h3 + input + .check-label { margin-top: 0 }
.card-section label.field-first { margin-top: 0 }
/* Dateiauswahl im Stil der übrigen Knöpfe statt des grauen Browserknopfs. */
input[type=file] { padding: 6px; cursor: pointer; max-width: 720px }
input[type=file]::file-selector-button { margin-right: 12px; padding: 7px 14px; border: 1px solid var(--line); border-radius: 6px; background: #f3f5f1; color: var(--ink); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer }
input[type=file]:hover::file-selector-button { background: #e8ede6 }
.card-body code { overflow-wrap: anywhere }
/* Das Kleingedruckte einer Karte: zugeklappt, damit auf einem Handy der Knopf oben bleibt. */
.hint { margin: 12px 0 0 }
@@ -306,6 +323,22 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
.card-body > p:first-child { margin-top: 14px }
.card-body .primary, .card-body .secondary { margin-top: 22px }
.card-foot { padding: 16px 22px; border-top: 1px solid var(--line); background: #fafbf8; border-radius: 0 0 12px 12px }
/* Der Fuss eines Formulars in einer Karte: Hauptknopf links, Erklärung daneben. */
.form-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; font-size: 12px }
.form-foot .primary { margin: 0 }
/* Knöpfe nebeneinander, gleicher Abstand, ohne den Abstand nach oben der Einzelknöpfe. */
.button-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px }
.card-body .button-row .primary, .card-body .button-row .secondary { margin: 0 }
/* Auswahl als Karten: ganze Fläche klickbar, Titel und eine Zeile Erklärung. */
.option-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; max-width: 720px }
.option { display: flex; align-items: flex-start; gap: 10px; margin: 0; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); font-weight: 400; cursor: pointer }
.option:hover { border-color: #a9b8af }
.option:has(input:checked) { border-color: var(--accent); background: #f4f8f1; box-shadow: inset 0 0 0 1px var(--accent) }
.option input { width: 16px; min-height: 16px; margin: 2px 0 0; flex: none; accent-color: var(--accent) }
.option > span { display: grid; gap: 2px; min-width: 0 }
.option strong { font-size: 13px; font-weight: 600 }
.option > span > span { font-size: 12px; color: var(--muted); line-height: 1.45 }
.check-label input, .option input { accent-color: var(--accent) }
/* A card that opens: used for everything secondary, so a screen starts calm. */
details.card > summary {
@@ -376,6 +409,7 @@ details.card > summary:hover { background: #fafbf8 }
.pill-live { background: #e7f0d6; color: #2c5340 }
.pill-draft { background: #fff2d9; color: #6f4e18 }
.pill-off { background: #f1f2f0; color: #5a635e }
.pill-error { background: #fff0eb; color: var(--danger) }
/* The per-row settings. One disclosure that opens in place, instead of six competing popovers
that clip each other at the edge of the card. */
@@ -593,6 +627,10 @@ details.card > summary:hover { background: #fafbf8 }
.nav-label { display: none }
.nav-list { display: flex; gap: 4px }
.nav-item { white-space: nowrap; padding: 8px 12px }
/* In der Leiste stehen Unterpunkte direkt neben ihrem Hauptpunkt, nicht in einer eigenen Zeile. */
.nav-list > li:has(.nav-sub) { display: flex; gap: 4px }
.nav-sub { display: flex; margin: 0 }
.nav-sub .nav-item { padding: 8px 12px }
.shell-foot { margin: 0 0 0 auto; padding: 0 0 0 12px; border-top: 0; border-left: 1px solid #2a3d35; display: flex; align-items: center; gap: 10px }
.shell-account { padding: 3px; gap: 2px }
.shell-user { padding: 3px }
@@ -718,6 +756,39 @@ details.card > summary:hover { background: #fafbf8 }
.check-bad { color: var(--danger) }
.log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
.log-list code { font-size: 11px; overflow-wrap: anywhere }
.mail-result { margin-top: 16px }
.mail-result p { margin: 0 0 6px }
.mail-result p:last-child { margin-bottom: 0 }
.mail-result pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11px; margin: 6px 0 0 }
.mail-result-head { margin: 0 0 10px }
/* Unterpunkte im Menü: eingerückt auf die Höhe des Textes, ohne Symbol. */
.nav-sub { list-style: none; margin: 2px 0 4px; padding: 0; display: grid; gap: 2px }
.nav-sub .nav-item { padding: 7px 10px 7px 39px; font-size: 12.5px; color: var(--shell-muted) }
.nav-sub .nav-item:hover { color: var(--shell-ink) }
.nav-sub .nav-item[aria-current=page] { color: #fff; background: #21362d }
/* E-Mail-Versand: der eingestellte Weg als Zeile, darunter Abschnitte mit Trennlinie. */
.mail-current { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; margin-top: 14px; padding: 12px 14px; border-radius: 10px; background: #f6f8f4 }
.mail-current-source { flex-basis: 100%; font-size: 12px }
.mail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0 14px; max-width: 720px }
.mail-grid label { margin: 0 0 7px }
.mail-grid > div { margin-bottom: 4px }
/* Protokoll: Zustand links, daneben was, wann und - bei Fehlern - warum. */
.mail-log { list-style: none; margin: 14px 0 0; padding: 0 }
.mail-entry { display: grid; grid-template-columns: 112px minmax(0, 1fr); gap: 4px 14px; align-items: start; padding: 12px 0; border-top: 1px solid var(--line) }
.mail-entry:first-child { border-top: 0; padding-top: 4px }
.mail-entry .pill { justify-self: start; margin-top: 1px }
.mail-entry-text p { margin: 0 }
.mail-entry-line { overflow-wrap: anywhere }
.mail-entry-time { font-size: 12px; color: var(--muted) }
.mail-entry-hint { margin-top: 6px !important; color: var(--ink) }
.mail-entry-detail { margin-top: 6px }
.mail-entry-detail summary { font-size: 12px; font-weight: 600; color: var(--muted); cursor: pointer; width: fit-content }
.mail-entry-detail pre { margin: 6px 0 0; padding: 10px 12px; border-radius: 8px; background: #f6f8f4; font-size: 11px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere }
@media (max-width: 560px) {
.mail-entry { grid-template-columns: minmax(0, 1fr) }
}
/* Ohne SMTP brauchen die Felder niemand; wo :has() fehlt, bleiben sie einfach sichtbar. */
.mail-form:has(input[name=weg]:checked:not([value=smtp])) .smtp-fields { display: none }
/* ---- the installer: six screens on an otherwise empty server ---------------------------- */
@@ -829,6 +900,9 @@ details.card > summary:hover { background: #fafbf8 }
.nav-list { display: grid; gap: 2px }
/* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */
.nav-item { min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 10px 14px; white-space: normal }
.nav-list > li:has(.nav-sub) { display: block }
.nav-sub { display: grid; margin: 2px 0 4px }
.nav-sub .nav-item { padding: 10px 14px 10px 44px }
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0 }
.shell-version { display: none }
}
+5 -11
View File
@@ -8,11 +8,10 @@ use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\SiteSettings;
use App\Content\Turnstile;
use App\Ops\MailCheck;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Attribute\Route;
@@ -29,7 +28,7 @@ final class FormController extends AbstractController
private PageRepository $pages,
private MenuRepository $menus,
private SiteSettings $settings,
private MailerInterface $mailer,
private MailCheck $mail,
private Turnstile $turnstile,
private string $secret,
private string $siteOrigin,
@@ -127,15 +126,10 @@ final class FormController extends AbstractController
if ('' !== $replyTo && '' !== ($values[$replyTo] ?? '')) {
$email->replyTo($values[$replyTo]);
}
try {
$this->mailer->send($email);
return true;
} catch (TransportExceptionInterface) {
// The submission is already stored, so a broken mail setup loses no enquiry.
// It stays marked as undelivered and is visible in the administration.
return false;
}
// The submission is already stored, so a broken mail setup loses no enquiry. It stays
// marked as undelivered, and var/log/mail.log says why (System -> E-Mail-Versand).
return $this->mail->send($email, 'Formular '.$form['slug'])['ok'];
}
/**
+201
View File
@@ -0,0 +1,201 @@
<?php
namespace App\Controller;
use App\Ops\Environment;
use App\Ops\MailCheck;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Attribute\Route;
/**
* E-Mail-Versand (ADR 121, 122): eigene Seite unter „System“, damit die Systemseite nicht alles
* trägt. Weg wählen, ausprobieren, alle Wege des Servers testen, speichern - und die letzten
* Versuche mit Ursache.
*/
final class MailController extends AbstractController
{
public function __construct(private MailCheck $mail, private string $projectDir, private string $siteOrigin)
{
}
#[Route('/%kant.admin_path%/system/e-mail', name: 'admin_mail', methods: ['GET'])]
public function index(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$session = $request->getSession();
return $this->render('admin/mail.html.twig', [
'mail' => $this->mailFacts(),
'mailResult' => $session->remove('mail_result'),
'mailResults' => $session->remove('mail_results'),
'mailNotice' => $session->remove('mail_notice'),
// Die letzte Eingabe bleibt stehen, ohne Passwort.
'mailForm' => $session->remove('mail_form'),
]);
}
/**
* Versand einstellen und ausprobieren (ADR 121, 122): ein Weg testen, alle Wege des Servers
* nacheinander testen, oder den gewählten Weg als MAILER_DSN in .env speichern.
*
* Das SMTP-Passwort steht nie im Formular. Bleibt das Feld leer, gilt das gespeicherte, solange
* Server und Benutzer gleich bleiben.
*/
#[Route('/%kant.admin_path%/system/e-mail', name: 'admin_mail_save', methods: ['POST'])]
public function save(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$session = $request->getSession();
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
return $this->backToMail($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$eingabe = [
'weg' => $request->request->getString('weg'),
'server' => trim($request->request->getString('server')),
'port' => $request->request->getInt('port', 587),
'benutzer' => trim($request->request->getString('benutzer')),
'an' => trim($request->request->getString('to')),
'von' => trim($request->request->getString('from')),
];
$session->set('mail_form', $eingabe);
$aktion = $request->request->getString('aktion');
if ('alle' === $aktion) {
if (null !== $fehler = $this->mailAdressFehler($eingabe)) {
return $this->backToMail($request, $fehler);
}
$wege = array_map(static fn (array $weg): string => $weg['dsn'], MailCheck::serverWege());
if ('' !== $eingabe['server']) {
try {
$wege['smtp'] = $this->smtpDsn($request, $eingabe);
} catch (\InvalidArgumentException) {
// Unvollständiges SMTP bleibt bei „alle“ einfach draußen.
}
}
$ergebnisse = [];
foreach ($wege as $schluessel => $dsn) {
$ergebnisse[] = ['schluessel' => $schluessel] + $this->mail->send($this->testEmail($request, $eingabe), 'Testmail (alle Wege)', $dsn);
}
$session->set('mail_results', $ergebnisse);
return $this->backToMail($request);
}
try {
$dsn = isset(MailCheck::serverWege()[$eingabe['weg']]) ? MailCheck::serverWege()[$eingabe['weg']]['dsn'] : $this->smtpDsn($request, $eingabe);
} catch (\InvalidArgumentException $fehler) {
return $this->backToMail($request, $fehler->getMessage());
}
if ('speichern' === $aktion) {
if ('.env' !== $this->environmentSource('MAILER_DSN')) {
return $this->backToMail($request, 'MAILER_DSN ist in der Server-Umgebung gesetzt und hat dort Vorrang. Ändern lässt sie sich nur beim Hoster.');
}
try {
Environment::write($this->projectDir, ['MAILER_DSN' => $dsn]);
} catch (\RuntimeException $fehler) {
return $this->backToMail($request, $fehler->getMessage());
}
$weg = $this->mail->route($dsn);
return $this->backToMail($request, sprintf('Gespeichert: %s%s. Formulare versenden ab jetzt auf diesem Weg.', $weg['art'], '' !== $weg['ziel'] ? ' über '.$weg['ziel'] : ''), 'ok');
}
if (null !== $fehler = $this->mailAdressFehler($eingabe)) {
return $this->backToMail($request, $fehler);
}
$session->set('mail_result', ['an' => $eingabe['an']] + $this->mail->send($this->testEmail($request, $eingabe), 'Testmail', $dsn));
return $this->backToMail($request);
}
/** „Letzte Versuche“ leeren: var/log/mail.log und mail.log.1. */
#[Route('/%kant.admin_path%/system/e-mail/protokoll', name: 'admin_mail_clear', methods: ['POST'])]
public function clear(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
return $this->backToMail($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$this->mail->clearLog();
return $this->backToMail($request, 'Das Protokoll ist leer.', 'ok');
}
/** @param array{an: string, von: string} $eingabe */
private function mailAdressFehler(array $eingabe): ?string
{
foreach ([$eingabe['an'], $eingabe['von'] ?: $eingabe['an']] as $adresse) {
if (false === filter_var($adresse, FILTER_VALIDATE_EMAIL)) {
return '' === $adresse ? 'Für eine Testmail bitte eine Empfängeradresse eintragen.' : sprintf('„%s“ ist keine gültige E-Mail-Adresse.', mb_substr($adresse, 0, 100));
}
}
return null;
}
/** @param array{server: string, port: int, benutzer: string} $eingabe */
private function smtpDsn(Request $request, array $eingabe): string
{
if ('' === $eingabe['server']) {
throw new \InvalidArgumentException('Für SMTP bitte den Server eintragen, etwa smtp.ionos.de.');
}
$passwort = $request->request->getString('passwort');
if ('' === $passwort) {
$passwort = $this->mail->gespeichertesPasswort($eingabe['server'], $eingabe['benutzer']);
}
if ('' !== $eingabe['benutzer'] && '' === $passwort) {
throw new \InvalidArgumentException('Für SMTP bitte das Passwort des Postfachs eintragen.');
}
return MailCheck::smtpDsn($eingabe['server'], $eingabe['port'], $eingabe['benutzer'], $passwort);
}
/** @param array{an: string, von: string} $eingabe */
private function testEmail(Request $request, array $eingabe): Email
{
$website = rtrim($this->siteOrigin, '/') ?: $request->getSchemeAndHttpHost();
return (new Email())
->from(new Address($eingabe['von'] ?: $eingabe['an']))
->to(new Address($eingabe['an']))
->subject('Testmail von '.(parse_url($website, PHP_URL_HOST) ?: $website))
->text("Diese Testmail kommt aus der Verwaltung von {$website} (System → E-Mail-Versand).\n\nWenn sie angekommen ist, funktioniert der Versand der Formulare auf diesem Weg.\n");
}
private function backToMail(Request $request, ?string $meldung = null, string $art = 'error'): Response
{
if (null !== $meldung) {
$request->getSession()->set('mail_notice', ['art' => $art, 'text' => $meldung]);
}
return $this->redirect($this->generateUrl('admin_mail'));
}
/** @return array<string, mixed> */
private function mailFacts(): array
{
$route = $this->mail->route();
return $route + [
'einstellung' => $this->mail->einstellung(),
'wege' => MailCheck::serverWege(),
'quelle' => $this->environmentSource('MAILER_DSN'),
// Wer sich mit einer Adresse anmeldet, versendet meist auch nur unter ihr.
'absender' => false !== filter_var($route['benutzer'], FILTER_VALIDATE_EMAIL) ? $route['benutzer'] : '',
'protokoll' => $this->mail->recent(),
];
}
/** Explicit server values take precedence over the single installation file. */
private function environmentSource(string $name): string
{
$loaded = explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? '');
return in_array($name, $loaded, true) ? '.env' : 'Server-Umgebung';
}
}
@@ -35,7 +35,7 @@ final class MaintenanceSubscriber implements EventSubscriberInterface
return;
}
$path = $event->getRequest()->getPathInfo();
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon|\.well-known/kantsites-origin)#', $path)) {
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon|kantsites-origin$)#', $path)) {
return;
}
$since = @file_get_contents($this->projectDir.'/'.Updater::MAINTENANCE_FILE);
+299
View File
@@ -0,0 +1,299 @@
<?php
namespace App\Ops;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mailer\Transport\Dsn;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Component\Mime\Email;
/**
* Versand mit Protokoll (ADR 121).
*
* huefner-gartenbau.de am 12.10.2026: zwei Anfragen „Nicht versendet“, und nirgends stand,
* warum. Der Formularversand hat die Ausnahme verschluckt; die Ursache - sendmail, das der Hoster
* so nicht anbietet, ein falsches SMTP-Passwort, ein Absender, den der Server nicht annimmt -
* blieb Raten. Jetzt geht jeder Versand durch diese Klasse: Formular und Testmail aus der
* Verwaltung. Sie schreibt eine Zeile nach var/log/mail.log (per FTP lesbar) mit Meldung, einem
* Hinweis auf Deutsch und den Antworten des Servers.
*
* Aus dem SMTP-Gespräch bleiben nur die Zeilen des Servers. Was der Client schickt, enthält bei
* AUTH LOGIN Benutzer und Passwort in Base64 - das gehört in kein Protokoll.
*/
final class MailCheck
{
/** Größer wird die Datei nicht; die ältere Hälfte wandert nach mail.log.1. */
private const GRENZE = 262144;
/**
* Der Transport erst beim ersten Versand: Eine ungültige MAILER_DSN soll den Versand scheitern
* lassen und im Protokoll stehen, nicht schon die Seite, die ihn anbietet.
*/
/** Baut einen Transport aus einer DSN, die (noch) nicht eingestellt ist - zum Ausprobieren. */
private \Closure $fabrik;
public function __construct(#[Autowire(service: 'mailer.transports', lazy: true)] private TransportInterface $transport, private string $dsn, private string $logFile, ?\Closure $fabrik = null)
{
$this->fabrik = $fabrik ?? static fn (string $dsn): TransportInterface => Transport::fromDsn($dsn);
}
/**
* Die Wege, die ein gewöhnliches Hosting ohne Zugangsdaten anbietet (ADR 122). Bei IONOS etwa
* scheitert sendmail -bs, mail() geht.
*
* @return array<string, array{label: string, dsn: string}>
*/
public static function serverWege(): array
{
return [
'native' => ['label' => 'PHP mail() des Servers', 'dsn' => 'native://default'],
'sendmail-t' => ['label' => 'sendmail -t des Servers', 'dsn' => 'sendmail://default?command=/usr/sbin/sendmail%20-t%20-i'],
'sendmail-bs' => ['label' => 'sendmail -bs des Servers', 'dsn' => 'sendmail://default'],
];
}
/** smtp://… aus Einzelfeldern; Benutzer und Passwort URL-kodiert. Port 465 heißt smtps. */
public static function smtpDsn(string $server, int $port, string $benutzer, string $passwort): string
{
$server = trim($server);
if (1 !== preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/iD', $server)) {
throw new \InvalidArgumentException('Der SMTP-Server ist kein gültiger Rechnername, etwa smtp.ionos.de.');
}
if ($port < 1 || $port > 65535) {
throw new \InvalidArgumentException('Der Port muss zwischen 1 und 65535 liegen, meist 587.');
}
$anmeldung = '' === $benutzer ? '' : rawurlencode($benutzer).('' === $passwort ? '' : ':'.rawurlencode($passwort)).'@';
return (465 === $port ? 'smtps' : 'smtp').'://'.$anmeldung.strtolower($server).':'.$port;
}
/**
* Die aktuelle Einstellung zum Vorbelegen der Felder - ohne Passwort, nur ob es eins gibt.
*
* @return array{weg: string, server: string, port: int, benutzer: string, passwort: bool}
*/
public function einstellung(): array
{
$leer = ['weg' => 'smtp', 'server' => '', 'port' => 587, 'benutzer' => '', 'passwort' => false];
foreach (self::serverWege() as $schluessel => $weg) {
if ($weg['dsn'] === $this->dsn) {
return ['weg' => $schluessel] + $leer;
}
}
try {
$dsn = Dsn::fromString($this->dsn);
} catch (\Throwable) {
return $leer;
}
if (!in_array($dsn->getScheme(), ['smtp', 'smtps'], true)) {
return ['weg' => 'native' === $dsn->getScheme() ? 'native' : ('sendmail' === $dsn->getScheme() ? 'sendmail-bs' : 'smtp')] + $leer;
}
return [
'weg' => 'smtp',
'server' => $dsn->getHost(),
'port' => $dsn->getPort() ?? ('smtps' === $dsn->getScheme() ? 465 : 587),
'benutzer' => (string) $dsn->getUser(),
'passwort' => '' !== (string) $dsn->getPassword(),
];
}
/** Das gespeicherte SMTP-Passwort, wenn Server und Benutzer gleich bleiben - sonst leer. */
public function gespeichertesPasswort(string $server, string $benutzer): string
{
try {
$dsn = Dsn::fromString($this->dsn);
} catch (\Throwable) {
return '';
}
if (!in_array($dsn->getScheme(), ['smtp', 'smtps'], true) || strtolower($dsn->getHost()) !== strtolower(trim($server)) || (string) $dsn->getUser() !== $benutzer) {
return '';
}
return (string) $dsn->getPassword();
}
/**
* Schickt die Nachricht und hält das Ergebnis fest. Wirft nie - ein kaputter Versand darf
* keine Anfrage und keine Seite kosten.
*
* Mit $dsn geht der Versand über diesen Weg statt über den eingestellten - zum Ausprobieren
* vor dem Speichern.
*
* @return array{ok: bool, meldung: string, hinweis: string, dialog: string, weg: string}
*/
public function send(Email $email, string $anlass, ?string $dsn = null): array
{
$an = implode(', ', array_map(static fn ($adresse): string => $adresse->getAddress(), $email->getTo()));
$scheme = self::schemeVon($dsn ?? $this->dsn);
try {
// Derselbe Weg wie eingestellt: der Transport des Frameworks, samt seinen Ereignissen.
$transport = null === $dsn || $dsn === $this->dsn ? $this->transport : ($this->fabrik)($dsn);
$gesendet = $transport->send($email);
$ergebnis = [
'ok' => true,
'meldung' => 'Vom Mailserver angenommen.',
'hinweis' => self::hinweisNachErfolg($scheme),
'dialog' => self::dialog((string) $gesendet?->getDebug()),
];
} catch (TransportExceptionInterface $fehler) {
$ergebnis = [
'ok' => false,
'meldung' => self::einzeilig($fehler->getMessage()),
'hinweis' => self::hinweis($fehler->getMessage(), $scheme),
'dialog' => self::dialog($fehler->getDebug()),
];
} catch (\Throwable $fehler) {
// Eine ungültige MAILER_DSN fällt erst hier auf, nicht beim Start.
$ergebnis = [
'ok' => false,
'meldung' => self::einzeilig($fehler->getMessage()),
'hinweis' => self::hinweis($fehler->getMessage(), $scheme),
'dialog' => '',
];
}
$weg = $this->route($dsn);
$ergebnis['weg'] = trim($weg['art'].' '.$weg['ziel']);
$this->protokollieren(['zeit' => gmdate('Y-m-d H:i:s'), 'anlass' => $anlass, 'an' => $an, 'von' => $email->getFrom()[0]?->getAddress() ?? ''] + $ergebnis);
return $ergebnis;
}
/**
* Wie versendet wird - ohne Passwort.
*
* @return array{art: string, ziel: string, benutzer: string, gueltig: bool}
*/
public function route(?string $roh = null): array
{
try {
$dsn = Dsn::fromString($roh ?? $this->dsn);
} catch (\Throwable) {
return ['art' => 'ungültig', 'ziel' => '', 'benutzer' => '', 'gueltig' => false];
}
$art = match ($dsn->getScheme()) {
'smtp', 'smtps' => 'SMTP',
'sendmail', 'sendmail+smtp' => str_contains((string) $dsn->getOption('command'), ' -t') ? 'sendmail -t des Servers' : 'sendmail -bs des Servers',
'native' => 'PHP mail() des Servers',
'null' => 'kein Versand (null)',
default => $dsn->getScheme(),
};
$port = $dsn->getPort();
return [
'art' => $art,
'ziel' => in_array($dsn->getScheme(), ['smtp', 'smtps'], true) ? $dsn->getHost().(null !== $port ? ':'.$port : '') : '',
'benutzer' => (string) $dsn->getUser(),
'gueltig' => true,
];
}
/**
* Die letzten Einträge, neueste zuerst.
*
* @return list<array<string, mixed>>
*/
public function recent(int $anzahl = 10): array
{
if (!is_file($this->logFile)) {
return [];
}
$zeilen = file($this->logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$eintraege = [];
foreach (array_reverse(array_slice($zeilen, -$anzahl)) as $zeile) {
$eintrag = json_decode($zeile, true);
if (is_array($eintrag)) {
$eintraege[] = $eintrag;
}
}
return $eintraege;
}
/** Protokoll leeren: die Datei und ihre ältere Hälfte. */
public function clearLog(): void
{
foreach ([$this->logFile, $this->logFile.'.1'] as $datei) {
if (is_file($datei)) {
@unlink($datei);
}
}
}
public function logFile(): string
{
return $this->logFile;
}
/** Was als Nächstes zu tun ist, für die häufigen Fälle. Leer, wenn nichts passt. */
public static function hinweis(string $meldung, string $scheme): string
{
$m = strtolower($meldung);
return match (true) {
'null' === $scheme => 'MAILER_DSN steht auf null://null - es wird gar nichts verschickt.',
(str_contains($m, 'dsn') && (str_contains($m, 'invalid') || str_contains($m, 'malformed') || str_contains($m, 'must contain'))) || (str_contains($m, 'scheme') && str_contains($m, 'not supported')) => 'Die Einstellung des Versands ist ungültig. Unter System → E-Mail-Versand neu setzen.',
str_contains($m, '535') || str_contains($m, 'authenticat') => 'Der Mailserver lehnt die Anmeldung ab. Benutzer (die volle Adresse) und Passwort prüfen; Sonderzeichen im Passwort URL-kodieren (# → %23, / → %2F, % → %25, @ → %40). Bei Microsoft 365 muss für das Postfach „Authentifiziertes SMTP“ eingeschaltet sein.',
str_contains($m, 'connection could not be established') || str_contains($m, 'connection refused') || str_contains($m, 'timed out') || str_contains($m, 'getaddrinfo') || str_contains($m, 'unable to connect') => 'Der Mailserver ist von hier aus nicht erreichbar. Servername und Port prüfen (meist 587); manche Hoster sperren ausgehende Verbindungen auf bestimmten Ports.',
str_contains($m, 'certificate') || str_contains($m, 'ssl') || str_contains($m, 'tls') => 'Die verschlüsselte Verbindung kam nicht zustande. Port 587 nutzt STARTTLS, Port 465 braucht smtps:// - Port und Schema müssen zusammenpassen, und der Servername muss zum Zertifikat passen.',
str_contains($m, 'sender address is not allowed') || str_contains($m, 'envelope from address') => 'Der Server lehnt den Absender ab. Beim Versand über den Server (mail(), sendmail) muss der Absender zu einer eigenen Domain beim Hoster gehören, nicht etwa Gmail; bei SMTP ist es meist genau das angemeldete Postfach.',
str_contains($scheme, 'sendmail') => 'sendmail ist auf diesem Server so nicht nutzbar. Zuerst „PHP mail() des Servers“ probieren, dauerhaft besser SMTP mit einem echten Postfach.',
1 === preg_match('/\b55[0-4]\b/', $m) || str_contains($m, 'sender') || str_contains($m, 'relay') => 'Der Mailserver nimmt Absender oder Empfänger nicht an. Der Absender muss meist genau das Postfach sein, mit dem angemeldet wird.',
default => '',
};
}
/** Angenommen heißt nicht angekommen - die üblichen Gründe dafür. */
public static function hinweisNachErfolg(string $scheme): string
{
if ('null' === $scheme) {
return self::hinweis('', 'null');
}
return 'Kommt nichts an: Spam-Ordner prüfen. Der Absender sollte zur eigenen Domain gehören (SPF). Liegt das Postfach des Empfängers nicht beim Hoster (etwa Microsoft 365), darf die Domain dort nicht auch als Mail-Domain eingerichtet sein - sonst stellt der Hoster intern zu.';
}
/**
* Nur die Antworten des Servers aus dem SMTP-Gespräch, höchstens die letzten zwölf.
* Symfony schreibt sie mit „< “ am Anfang, die eigenen Zeilen mit „> “.
*/
public static function dialog(string $debug): string
{
$zeilen = [];
foreach (preg_split('/\R/', $debug) ?: [] as $zeile) {
if (str_starts_with($zeile, '< ')) {
$zeilen[] = $zeile;
}
}
return implode("\n", array_slice($zeilen, -12));
}
private static function schemeVon(string $roh): string
{
try {
return Dsn::fromString($roh)->getScheme();
} catch (\Throwable) {
return '';
}
}
private static function einzeilig(string $text): string
{
return mb_substr(trim((string) preg_replace('/\s+/', ' ', $text)), 0, 1000);
}
private function protokollieren(array $eintrag): void
{
$ordner = dirname($this->logFile);
if (!is_dir($ordner)) {
@mkdir($ordner, 0o775, true);
}
if (is_file($this->logFile) && filesize($this->logFile) > self::GRENZE) {
@rename($this->logFile, $this->logFile.'.1');
}
@file_put_contents($this->logFile, json_encode($eintrag, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE)."\n", FILE_APPEND | LOCK_EX);
}
}
+5 -1
View File
@@ -13,7 +13,11 @@ namespace App\Ops;
*/
class OriginCheck
{
public const PATH = '/.well-known/kantsites-origin';
/**
* Bewusst nicht unter /.well-known/: IONOS beantwortet alles darunter selbst mit 403, bevor
* die Anfrage die Installation erreicht (huefner-gartenbau.de, 12.10.2026, ADR 122).
*/
public const PATH = '/kantsites-origin';
private Download $download;
+15
View File
@@ -0,0 +1,15 @@
{# Ein Versuch, gleich gebaut für „Letzte Versuche“ und „Alle Wege ausprobiert“: Zustand, worum es
ging, wann - bei Fehlern der Hinweis vorn und die Meldung des Servers zugeklappt dahinter. #}
<li class="mail-entry">
<span class="pill {{ eintrag.ok ? 'pill-live' : 'pill-error' }}">{{ eintrag.ok ? 'Angenommen' : 'Fehlgeschlagen' }}</span>
<div class="mail-entry-text">
<p class="mail-entry-line">
{% if titel is defined %}<strong>{{ titel }}</strong>{% else %}{{ eintrag.anlass }} an <code>{{ eintrag.an }}</code>{% if eintrag.von %} von <code>{{ eintrag.von }}</code>{% endif %}{% if eintrag.weg is defined and eintrag.weg %} <span class="muted">· {{ eintrag.weg }}</span>{% endif %}{% endif %}
</p>
{% if eintrag.zeit is defined %}<p class="mail-entry-time">{{ eintrag.zeit }} UTC</p>{% endif %}
{% if not eintrag.ok %}
{% if eintrag.hinweis %}<p class="mail-entry-hint">{{ eintrag.hinweis }}</p>{% endif %}
<details class="mail-entry-detail"><summary>Meldung des Servers</summary><pre>{{ eintrag.meldung }}</pre></details>
{% endif %}
</div>
</li>
+147
View File
@@ -0,0 +1,147 @@
{% extends 'admin/shell.html.twig' %}
{% set nav = 'mail' %}
{% block title %}E-Mail-Versand{% endblock %}
{% block heading %}E-Mail-Versand{% endblock %}
{% block lead %}Wie Formulare ihre Mails verschicken – ausprobieren, speichern und sehen, woran es hakt.{% endblock %}
{% block content %}
{# E-Mail-Versand (ADR 121, 122): Weg wählen und ausprobieren, alle Wege des Servers testen,
speichern - und die letzten Versuche mit Ursache. Das SMTP-Passwort steht nie im Formular. #}
{% set f = mailForm ?? {} %}
{% set e = mail.einstellung %}
{% set weg = f.weg ?? e.weg %}
{% set beschreibung = {
smtp: 'Empfohlen. Ein echtes Postfach, unabhängig vom Hoster.',
native: 'Über den Server. Der Absender muss zu einer Domain beim Hoster gehören.',
'sendmail-t': 'Über den Server, falls mail() fehlt.',
'sendmail-bs': 'Über den Server, bei manchen Hostern gesperrt.',
} %}
{% if mailNotice %}
<p class="notice {{ mailNotice.art == 'ok' ? 'ok' : 'error' }}" role="{{ mailNotice.art == 'ok' ? 'status' : 'alert' }}">{{ mailNotice.text }}</p>
{% endif %}
<form class="card mail-form" id="mail" action="{{ path('admin_mail_save') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<div class="card-head"><h2>Versandweg</h2><span class="muted">gilt für alle Formulare</span></div>
<div class="card-body">
<div class="mail-current">
<span class="muted">Eingestellt</span>
{% if mail.gueltig %}
<strong>{{ mail.art }}</strong>{% if mail.ziel %} <code>{{ mail.ziel }}</code>{% endif %}{% if mail.benutzer %} <span class="muted">als</span> <code>{{ mail.benutzer }}</code>{% endif %}
{% else %}
<strong class="danger-text">Die Einstellung ist ungültig.</strong>
{% endif %}
<span class="muted mail-current-source">in <code>{{ mail.quelle }}</code> als <code>MAILER_DSN</code>{% if mail.quelle != '.env' %} – hat Vorrang, speichern lässt sich hier nichts, testen schon{% endif %}</span>
</div>
<fieldset class="card-section">
<legend>Weg wählen</legend>
<div class="option-list">
<label class="option" for="weg-smtp"><input id="weg-smtp" type="radio" name="weg" value="smtp"{% if weg == 'smtp' %} checked{% endif %}><span><strong>SMTP mit Postfach</strong><span>{{ beschreibung.smtp }}</span></span></label>
{% for key, w in mail.wege %}
<label class="option" for="weg-{{ key }}"><input id="weg-{{ key }}" type="radio" name="weg" value="{{ key }}"{% if weg == key %} checked{% endif %}><span><strong>{{ w.label }}</strong><span>{{ beschreibung[key] ?? '' }}</span></span></label>
{% endfor %}
</div>
</fieldset>
<fieldset class="card-section smtp-fields">
<legend>SMTP-Zugang</legend>
<div class="mail-grid">
<div>
<label for="mail-server">Server</label>
<input id="mail-server" name="server" type="text" inputmode="url" autocomplete="off" spellcheck="false" maxlength="200" placeholder="smtp.ionos.de" value="{{ f.server ?? e.server }}">
</div>
<div>
<label for="mail-port">Port</label>
<input id="mail-port" name="port" type="number" min="1" max="65535" value="{{ f.port ?? e.port }}" aria-describedby="mail-port-help">
</div>
<div>
<label for="mail-user">Benutzer</label>
<input id="mail-user" name="benutzer" type="text" autocomplete="off" spellcheck="false" maxlength="200" placeholder="formular@meine-firma.de" value="{{ f.benutzer ?? e.benutzer }}">
</div>
<div>
<label for="mail-pass">Passwort</label>
<input id="mail-pass" name="passwort" type="password" autocomplete="new-password" maxlength="500" placeholder="{{ e.passwort ? 'gespeichert – leer lassen zum Behalten' : '' }}">
</div>
</div>
<p class="field-help" id="mail-port-help">587 mit STARTTLS ist üblich (IONOS, Microsoft 365: <code>smtp.office365.com</code>), 465 verschlüsselt von Anfang an. Das Passwort wird nie angezeigt und landet nur in <code>.env</code>.</p>
</fieldset>
<fieldset class="card-section">
<legend>Testmail</legend>
<div class="mail-grid">
<div>
<label for="mail-to">An</label>
<input id="mail-to" name="to" type="email" autocomplete="email" maxlength="200" placeholder="name@beispiel.de" value="{{ f.an ?? (mailResult.an ?? '') }}">
</div>
<div>
<label for="mail-from">Absender</label>
<input id="mail-from" name="from" type="email" maxlength="200" placeholder="leer: dieselbe Adresse" value="{{ f.von ?? mail.absender }}" aria-describedby="mail-from-help">
</div>
</div>
<p class="field-help" id="mail-from-help">Derselbe Absender wie bei den Formularen. Über den Server (mail(), sendmail) muss er zu einer eigenen Domain beim Hoster gehören, bei SMTP ist es meist das angemeldete Postfach.</p>
<div class="button-row">
<button class="secondary" type="submit" name="aktion" value="test">Diesen Weg testen</button>
<button class="secondary" type="submit" name="aktion" value="alle">Alle Wege testen</button>
</div>
<p class="field-help">„Alle Wege testen“ schickt je Weg eine Testmail – bis zu vier.</p>
{% if mailResult %}
<div class="notice {{ mailResult.ok ? 'ok' : 'error' }} mail-result" role="{{ mailResult.ok ? 'status' : 'alert' }}">
<p><strong>{{ mailResult.ok ? 'Testmail an ' ~ mailResult.an ~ ' vom Mailserver angenommen' : 'Testmail an ' ~ mailResult.an ~ ' nicht versendet' }}</strong>{% if mailResult.weg is defined %} ({{ mailResult.weg }}){% endif %}.{% if not mailResult.ok %} {{ mailResult.meldung }}{% endif %}</p>
{% if mailResult.hinweis %}<p>{{ mailResult.hinweis }}</p>{% endif %}
{% if mailResult.dialog %}<details><summary>Antworten des Mailservers</summary><pre>{{ mailResult.dialog }}</pre></details>{% endif %}
</div>
{% endif %}
{% if mailResults %}
<div class="mail-result">
<p class="mail-result-head"><strong>Alle Wege ausprobiert</strong> <span class="muted">– an {{ f.an ?? '' }}. Angenommen heißt noch nicht angekommen: bitte im Postfach nachsehen.</span></p>
<ul class="mail-log">
{% for r in mailResults %}
{% include 'admin/_mail_entry.html.twig' with {eintrag: r, titel: r.weg} only %}
{% endfor %}
</ul>
</div>
{% endif %}
</fieldset>
</div>
<div class="card-foot form-foot">
{% if mail.quelle == '.env' %}
<button class="primary" type="submit" name="aktion" value="speichern">Diesen Weg speichern</button>
<span class="muted">Erst mit „Speichern“ versenden die Formulare auf dem gewählten Weg.</span>
{% else %}
<span class="muted">MAILER_DSN steht in der Server-Umgebung – speichern lässt sich hier nichts.</span>
{% endif %}
</div>
</form>
<div class="card">
<div class="card-head">
<h2>Letzte Versuche</h2>
{% if mail.protokoll is empty %}
<span class="muted">var/log/mail.log</span>
{% else %}
<form action="{{ path('admin_mail_clear') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<button class="text-button" type="submit">Protokoll leeren</button>
</form>
{% endif %}
</div>
<div class="card-body">
{% if mail.protokoll is empty %}
<p class="muted">Noch nichts versendet – weder Testmail noch Formular.</p>
{% else %}
<ul class="mail-log">
{% for eintrag in mail.protokoll %}
{% include 'admin/_mail_entry.html.twig' with {eintrag: eintrag} only %}
{% endfor %}
</ul>
<p class="field-help">Die letzten zehn Versuche aus <code>var/log/mail.log</code>, neueste zuerst. Zeiten in UTC.</p>
{% endif %}
</div>
</div>
<p class="footnote">KantSites · Arbeitsstand</p>
{% endblock %}
+9 -1
View File
@@ -46,7 +46,15 @@
<ul class="nav-list" aria-labelledby="nav-site">
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'theme'} only %}Design</a></li>{% endif %}
{% if is_granted('ROLE_PUBLISHER') %}<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'settings'} only %}Einstellungen</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'system'} only %}System</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'system'} only %}System</a>
{# Unterpunkte von System: eigene Seiten, damit „System“ nicht alles trägt. Sichtbar nur
im Bereich System, sonst stünde das Menü dauerhaft aufgeklappt. #}
{% if nav|default('') in ['system', 'mail'] %}
<ul class="nav-sub">
<li><a class="nav-item" href="{{ path('admin_mail') }}"{{ nav|default('') == 'mail' ? ' aria-current="page"' : '' }}>E-Mail-Versand</a></li>
</ul>
{% endif %}
</li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_tasks') }}"{{ nav|default('') == 'tasks' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'tasks'} only %}Aufgaben</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_plugins') }}"{{ nav|default('') == 'plugins' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'plugins'} only %}Plugins</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'users'} only %}Benutzer</a></li>{% endif %}
+8 -8
View File
@@ -268,22 +268,22 @@
<div class="card-body">
<p class="muted">Ein Inhaltspaket enthält Seiten mit allen Fassungen, Bereiche, Menüs, Medien und PDFs, Formulare, Weiterleitungen, Kategorien, Übersetzungen, Design und Einstellungen. Nicht darin: Anfragen, Statistik, Zugangsdaten zu Diensten (Turnstile) und – ohne Häkchen – die Konten.</p>
<h3>Exportieren</h3>
<form action="{{ path('admin_package_export') }}" method="post">
<form class="card-section" action="{{ path('admin_package_export') }}" method="post">
<h3>Exportieren</h3>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<label class="check-label"><input type="checkbox" name="benutzer" value="1"> Konten mit Passwörtern mitnehmen</label>
<p class="field-help">Nur nötig, wenn die andere Website noch kein Konto hat. Das Paket enthält dann die Passwort-Hashes – nicht weitergeben.</p>
<button class="secondary" type="submit">Paket herunterladen</button>
<div class="button-row"><button class="secondary" type="submit">Paket herunterladen</button></div>
</form>
<h3>Importieren</h3>
<form action="{{ path('admin_package_import') }}" method="post" enctype="multipart/form-data">
<form class="card-section" action="{{ path('admin_package_import') }}" method="post" enctype="multipart/form-data">
<h3>Importieren</h3>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<label for="paket-datei">Paket hochladen (.zip)</label>
<label class="field-first" for="paket-datei">Paket hochladen (.zip)</label>
<input id="paket-datei" type="file" name="paket" accept=".zip,application/zip">
<p class="field-help">Höchstens {{ uploadLimit }} je Upload (Einstellung des Hostings). Größere Pakete per FTP nach <code>var/pakete/</code> legen, dann erscheinen sie hier zur Auswahl.</p>
{% if packages is not empty %}
<fieldset class="field-group">
<fieldset class="field-set">
<legend>Oder ein Paket aus var/pakete/</legend>
{% for package in packages %}
<label class="check-label"><input type="radio" name="wartend" value="{{ package.name }}">
@@ -296,7 +296,7 @@
<p class="field-help">Ohne Häkchen bleiben Benutzer und Passwörter dieser Website, wie sie sind.</p>
<label class="check-label"><input type="checkbox" name="bestaetigt" value="1" required> Ich weiß, dass die Inhalte dieser Website ersetzt werden.</label>
<p class="field-help">Vorher wird automatisch eine Sicherung angelegt; während des Imports antwortet die Website mit der Wartungsseite.</p>
<button class="primary" type="submit">Paket importieren</button>
<div class="button-row"><button class="primary" type="submit">Paket importieren</button></div>
</form>
</div>
</div>
+1 -1
View File
@@ -18,7 +18,7 @@ final class HttpsRedirectTest extends TestCase
/** @var list<?string> */
private array $answers = [];
/** @var array{status: int, location: string, error: string} */
private array $http = ['status' => 301, 'location' => 'https://www.example.de/.well-known/kantsites-origin', 'error' => ''];
private array $http = ['status' => 301, 'location' => 'https://www.example.de/kantsites-origin', 'error' => ''];
protected function setUp(): void
{
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace App\Tests;
use App\Ops\MailCheck;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\RawMessage;
/**
* Versand mit Protokoll (ADR 121): huefner-gartenbau.de zeigte zweimal „Nicht versendet“, und
* nirgends stand, warum.
*/
final class MailCheckTest extends TestCase
{
private string $log;
protected function setUp(): void
{
$this->log = sys_get_temp_dir().'/kant-mail-'.bin2hex(random_bytes(4)).'/mail.log';
}
protected function tearDown(): void
{
@unlink($this->log);
@rmdir(dirname($this->log));
}
public function testAFailedSendSaysWhyAndKeepsTheCredentialsOutOfTheLog(): void
{
$fehler = new TransportException('Failed to authenticate on SMTP server with username "formular@kant-it.de" using the following authenticators: "LOGIN". Authenticator "LOGIN" returned "Expected response code "235" but got code "535", with message "535 Authentication credentials invalid".');
$fehler->appendDebug("< 220 smtp.ionos.de ESMTP\n> EHLO cms.test\n< 250 AUTH LOGIN PLAIN\n> AUTH LOGIN\n< 334 VXNlcm5hbWU6\n> Zm9ybXVsYXJAa2FudC1pdC5kZQ==\n< 334 UGFzc3dvcmQ6\n> R2VoZWltMTIz\n< 535 Authentication credentials invalid\n");
$mail = new MailCheck($this->transport($fehler), 'smtp://formular%40kant-it.de:Geheim123@smtp.ionos.de:587', $this->log);
$ergebnis = $mail->send($this->email(), 'Formular anfrage');
self::assertFalse($ergebnis['ok']);
self::assertStringContainsString('535', $ergebnis['meldung']);
self::assertStringContainsString('lehnt die Anmeldung ab', $ergebnis['hinweis']);
self::assertStringContainsString('< 535 Authentication credentials invalid', $ergebnis['dialog']);
// Was der Client schickt, bleibt draußen: bei AUTH LOGIN sind das Benutzer und Passwort.
self::assertStringNotContainsString('R2VoZWltMTIz', $ergebnis['dialog']);
self::assertStringNotContainsString('> ', $ergebnis['dialog']);
$protokoll = (string) file_get_contents($this->log);
self::assertStringNotContainsString('R2VoZWltMTIz', $protokoll);
self::assertStringNotContainsString('Geheim123', $protokoll);
$eintrag = $mail->recent()[0];
self::assertFalse($eintrag['ok']);
self::assertSame('Formular anfrage', $eintrag['anlass']);
self::assertSame('info@huefner-gartenbau.de', $eintrag['an']);
self::assertSame('formular@kant-it.de', $eintrag['von']);
}
public function testTheRouteNamesServerAndUserButNeverThePassword(): void
{
$route = (new MailCheck($this->transport(null), 'smtp://formular%40kant-it.de:Geheim123@smtp.ionos.de:587', $this->log))->route();
self::assertSame(['art' => 'SMTP', 'ziel' => 'smtp.ionos.de:587', 'benutzer' => 'formular@kant-it.de', 'gueltig' => true], $route);
self::assertFalse((new MailCheck($this->transport(null), 'kein dsn', $this->log))->route()['gueltig']);
self::assertSame('sendmail -bs des Servers', (new MailCheck($this->transport(null), 'sendmail://default', $this->log))->route()['art']);
}
public function testAnAcceptedMailIsLoggedWithWhatToCheckIfNothingArrives(): void
{
$mail = new MailCheck($this->transport(null), 'smtp://u:p@smtp.ionos.de:587', $this->log);
$ergebnis = $mail->send($this->email(), 'Testmail');
self::assertTrue($ergebnis['ok']);
self::assertStringContainsString('intern zu', $ergebnis['hinweis'], 'Microsoft 365 beim Kunden, Domain auch beim Hoster: der häufigste Grund für „angenommen, nie angekommen“.');
self::assertTrue($mail->recent()[0]['ok']);
}
public function testTheHintsCoverTheUsualCauses(): void
{
self::assertStringContainsString('nicht erreichbar', MailCheck::hinweis('Connection could not be established with host "ssl://smtp.ionos.de:465": stream_socket_client(): Unable to connect', 'smtps'));
self::assertStringContainsString('SMTP', MailCheck::hinweis('Expected response code "220" but got an empty response.', 'sendmail'));
self::assertStringContainsString('Absender', MailCheck::hinweis('Expected response code "250" but got code "550", with message "550 Sender address rejected".', 'smtp'));
self::assertStringContainsString('ungültig', MailCheck::hinweis('The "foo" scheme is not supported; supported schemes for mailer "smtp" are: "smtp", "smtps".', 'foo'));
self::assertStringContainsString('null://null', MailCheck::hinweisNachErfolg('null'));
}
public function testSmtpSettingsBecomeADsnWithTheSecretsEncoded(): void
{
self::assertSame('smtp://formular%40kant-it.de:a%23b%2Fc%25d%24e@smtp.ionos.de:587', MailCheck::smtpDsn(' SMTP.ionos.de ', 587, 'formular@kant-it.de', 'a#b/c%d$e'));
self::assertSame('smtps://u:p@mail.example.de:465', MailCheck::smtpDsn('mail.example.de', 465, 'u', 'p'));
self::assertSame('smtp://localhost:25', MailCheck::smtpDsn('localhost', 25, '', ''));
foreach ([['smtp.ionos.de/../x', 587], ['', 587], ['smtp.ionos.de', 0]] as [$server, $port]) {
try {
MailCheck::smtpDsn($server, $port, 'u', 'p');
self::fail($server.':'.$port.' hätte abgelehnt werden müssen.');
} catch (\InvalidArgumentException) {
self::addToAssertionCount(1);
}
}
}
public function testTheCurrentSettingFillsTheFormButKeepsThePasswordToItself(): void
{
$mail = new MailCheck($this->transport(null), 'smtp://formular%40kant-it.de:Geheim123@smtp.ionos.de:587', $this->log);
self::assertSame(['weg' => 'smtp', 'server' => 'smtp.ionos.de', 'port' => 587, 'benutzer' => 'formular@kant-it.de', 'passwort' => true], $mail->einstellung());
self::assertSame('Geheim123', $mail->gespeichertesPasswort('smtp.ionos.de', 'formular@kant-it.de'));
// Anderer Server oder Benutzer: das gespeicherte Passwort gilt nicht mit.
self::assertSame('', $mail->gespeichertesPasswort('smtp.office365.com', 'formular@kant-it.de'));
self::assertSame('', $mail->gespeichertesPasswort('smtp.ionos.de', 'jemand@kant-it.de'));
self::assertSame('native', (new MailCheck($this->transport(null), 'native://default', $this->log))->einstellung()['weg']);
self::assertSame('sendmail-t', (new MailCheck($this->transport(null), MailCheck::serverWege()['sendmail-t']['dsn'], $this->log))->einstellung()['weg']);
}
public function testAWayCanBeTriedWithoutBeingTheSetOne(): void
{
// IONOS am 12.10.2026: sendmail -bs bricht ab, mail() geht. Ausprobiert wird über die
// Fabrik, ohne die Einstellung zu ändern.
$versucht = [];
$fabrik = function (string $dsn) use (&$versucht): TransportInterface {
$versucht[] = $dsn;
return $this->transport(null);
};
$eingestellt = $this->transport(new TransportException('Connection to "process /usr/sbin/sendmail -bs" has been closed unexpectedly.'));
$mail = new MailCheck($eingestellt, 'sendmail://default', $this->log, $fabrik);
$ergebnisse = [];
foreach (MailCheck::serverWege() as $schluessel => $weg) {
$ergebnisse[$schluessel] = $mail->send($this->email(), 'Testmail (alle Wege)', $weg['dsn']);
}
self::assertTrue($ergebnisse['native']['ok']);
self::assertSame('PHP mail() des Servers', $ergebnisse['native']['weg']);
self::assertSame('sendmail -t des Servers', $ergebnisse['sendmail-t']['weg']);
self::assertFalse($ergebnisse['sendmail-bs']['ok']);
self::assertStringContainsString('PHP mail()', $ergebnisse['sendmail-bs']['hinweis']);
// Der eingestellte Weg (hier sendmail -bs) geht über den Transport des Frameworks, nicht die Fabrik.
self::assertSame(['native://default', MailCheck::serverWege()['sendmail-t']['dsn']], $versucht);
self::assertCount(3, $mail->recent());
}
public function testAForeignSenderGetsItsOwnHint(): void
{
$meldung = 'Process failed with exit code 65: sendmail: envelope from address kantarci.u@gmail.com not accepted by the server sendmail: server message: 550-Sender address is not allowed.';
self::assertStringContainsString('eigenen Domain beim Hoster', MailCheck::hinweis($meldung, 'native'));
}
private function email(): Email
{
return (new Email())->from('formular@kant-it.de')->to('info@huefner-gartenbau.de')->subject('Anfrage')->text('Test');
}
/** Ein Transport, der nichts verschickt: wirft $fehler oder meldet „angenommen“. */
private function transport(?TransportException $fehler): TransportInterface
{
return new class($fehler) implements TransportInterface {
public function __construct(private ?TransportException $fehler)
{
}
public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage
{
if (null !== $this->fehler) {
throw $this->fehler;
}
$gesendet = new SentMessage($message, $envelope ?? Envelope::create($message));
$gesendet->appendDebug("< 250 2.0.0 Ok: queued\n");
return $gesendet;
}
public function __toString(): string
{
return 'test://';
}
};
}
}
+82 -2
View File
@@ -47,7 +47,7 @@ final class SystemScreenTest extends WebTestCase
$this->path = tempnam(sys_get_temp_dir(), 'kant-system-');
$url = 'sqlite:///'.str_replace('\\', '/', $this->path);
$this->previous = [$_SERVER, $_ENV];
foreach (['APP_SECRET' => 'isolated-test-secret', 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
foreach (['APP_SECRET' => 'isolated-test-secret', 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'MAIL_LOG' => $this->path.'-mail.log'] as $key => $value) {
$_SERVER[$key] = $_ENV[$key] = $value;
}
$this->db = ConnectionFactory::create($url);
@@ -81,6 +81,7 @@ final class SystemScreenTest extends WebTestCase
$this->db->close();
gc_collect_cycles();
@unlink($this->path);
@unlink($this->path.'-mail.log');
foreach (glob($this->path.'-media/*') ?: [] as $file) {
unlink($file);
}
@@ -200,7 +201,7 @@ final class SystemScreenTest extends WebTestCase
self::assertStringNotContainsString('kein-server', (string) file_get_contents(dirname(__DIR__).'/.env'));
// Die Gegenstelle der Prüfung: öffentlich, ohne Anmeldung, und nur die Kennung.
$client->request('GET', '/.well-known/kantsites-origin');
$client->request('GET', '/kantsites-origin');
self::assertSame(200, $client->getResponse()->getStatusCode());
self::assertSame(hash_hmac('sha256', 'site-origin', 'isolated-test-secret'), $client->getResponse()->getContent());
}
@@ -359,6 +360,85 @@ final class SystemScreenTest extends WebTestCase
self::assertStringContainsString('Migrationen stehen aus', (string) $admin->getResponse()->getContent(), 'Und es steht immer noch aus.');
}
public function testTheMailPageTestsAWayAndSaysWhatHappened(): void
{
// Umut: „Bau mal in den Backend einen Knopf ein, Testmail an Mailadresse, wo man einträgt,
// und wo Logs angezeigt werden, woran es liegt“ (ADR 121) - und dann: alle Wege probieren,
// SMTP dort setzen, auf einer eigenen Seite unter „System“ (ADR 122).
// MAILER_DSN gilt hier als Server-Umgebung: so schreibt kein Test in die echte .env.
$_SERVER['SYMFONY_DOTENV_VARS'] = '';
$client = $this->login();
$client->request('GET', '/admin/system');
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('href="/admin/system/e-mail"', $html, 'Ein Unterpunkt von System im Menü.');
self::assertStringNotContainsString('Alle Wege testen', $html, 'Die Systemseite trägt den Versand nicht mehr selbst.');
$client->request('GET', '/admin/system/e-mail');
self::assertSame(200, $client->getResponse()->getStatusCode());
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('Alle Wege testen', $html);
self::assertStringContainsString('value="cms.test"', $html, 'Der eingestellte SMTP-Server steht im Feld.');
self::assertStringContainsString('Noch nichts versendet', $html);
// Im Test steht MAILER_DSN nicht in .env: testen ja, speichern nein.
self::assertStringNotContainsString('value="speichern"', $html);
$token = (string) $client->getCrawler()->filter('form[action$="/system/e-mail"] input[name=_csrf_token]')->attr('value');
$client->request('POST', '/admin/system/e-mail', ['_csrf_token' => $token, 'aktion' => 'test', 'weg' => 'smtp', 'server' => 'cms.test', 'port' => '587', 'to' => 'kein-postfach']);
$client->followRedirect();
self::assertStringContainsString('keine gültige E-Mail-Adresse', (string) $client->getResponse()->getContent());
// Derselbe Weg wie eingestellt nimmt den Transport des Frameworks - im Test null://null.
$client->request('POST', '/admin/system/e-mail', ['_csrf_token' => $token, 'aktion' => 'test', 'weg' => 'smtp', 'server' => 'cms.test', 'port' => '587', 'to' => 'umut@cms.test', 'from' => 'formular@cms.test']);
self::assertStringEndsWith('/admin/system/e-mail', (string) $client->getResponse()->headers->get('Location'));
self::assertEmailCount(1);
$mail = self::getMailerMessage();
self::assertEmailAddressContains($mail, 'To', 'umut@cms.test');
self::assertEmailAddressContains($mail, 'From', 'formular@cms.test');
$client->request('GET', '/admin/system/e-mail');
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('Testmail an umut@cms.test vom Mailserver angenommen', $html);
self::assertStringContainsString('Testmail an <code>umut@cms.test</code>', $html, 'Der Versuch steht danach unter „Letzte Versuche“.');
self::assertStringContainsString('value="umut@cms.test"', $html, 'Die Eingabe bleibt stehen.');
// SMTP mit Benutzer, aber ohne Passwort und ohne gespeichertes: abgelehnt, bevor etwas rausgeht.
$client->request('POST', '/admin/system/e-mail', ['_csrf_token' => $token, 'aktion' => 'test', 'weg' => 'smtp', 'server' => 'smtp.anderer.test', 'port' => '587', 'benutzer' => 'x@anderer.test', 'to' => 'umut@cms.test']);
self::assertEmailCount(0);
$client->followRedirect();
self::assertStringContainsString('das Passwort des Postfachs eintragen', (string) $client->getResponse()->getContent());
// Speichern geht nur, wenn die Einstellung in .env steht.
$client->request('POST', '/admin/system/e-mail', ['_csrf_token' => $token, 'aktion' => 'speichern', 'weg' => 'native']);
$client->followRedirect();
self::assertStringContainsString('in der Server-Umgebung gesetzt', (string) $client->getResponse()->getContent());
// Umut: „Logs leeren Funktion brauch ich“ - nur mit Token, danach ist die Liste leer.
$client->request('POST', '/admin/system/e-mail/protokoll', ['_csrf_token' => 'erfunden']);
self::assertFileExists($this->path.'-mail.log', 'Ohne Token bleibt das Protokoll.');
$client->request('POST', '/admin/system/e-mail/protokoll', ['_csrf_token' => $token]);
self::assertFileDoesNotExist($this->path.'-mail.log');
$client->followRedirect();
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('Das Protokoll ist leer.', $html);
self::assertStringContainsString('Noch nichts versendet', $html);
self::assertStringNotContainsString('Protokoll leeren', $html, 'Ohne Einträge gibt es nichts zu leeren.');
// Der Unterpunkt steht nur im Bereich System offen, nicht dauerhaft.
$client->request('GET', '/admin/settings');
self::assertStringNotContainsString('href="/admin/system/e-mail"', (string) $client->getResponse()->getContent());
// Nur für Administratoren, und nur mit Token.
$client->request('POST', '/admin/system/e-mail', ['_csrf_token' => 'erfunden', 'aktion' => 'test', 'weg' => 'smtp', 'server' => 'cms.test', 'to' => 'umut@cms.test']);
self::assertEmailCount(0);
$redaktion = $this->login('editor');
$redaktion->request('GET', '/admin/system/e-mail');
self::assertSame(403, $redaktion->getResponse()->getStatusCode());
$redaktion->request('POST', '/admin/system/e-mail', ['_csrf_token' => $token, 'aktion' => 'test', 'weg' => 'smtp', 'server' => 'cms.test', 'to' => 'umut@cms.test']);
self::assertSame(403, $redaktion->getResponse()->getStatusCode());
$redaktion->request('POST', '/admin/system/e-mail/protokoll', ['_csrf_token' => $token]);
self::assertSame(403, $redaktion->getResponse()->getStatusCode());
}
private function login(string $username = 'admin'): KernelBrowser
{
self::ensureKernelShutdown();