Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef7cb02cbd | ||
|
|
7eaa3f6efa | ||
|
|
daaeb52588 | ||
|
|
6b1293cdb8 | ||
|
|
b982cb8944 | ||
|
|
2497b8cb69 |
No files matched your search
@@ -3,7 +3,3 @@ framework:
|
||||
# Tests never send real mail; the transport collects messages in memory instead.
|
||||
dsn: 'null://null'
|
||||
|
||||
parameters:
|
||||
# Das Versandprotokoll der Tests gehört nicht nach var/log der echten Installation.
|
||||
kant.mail_log: '%kernel.cache_dir%/mail.log'
|
||||
kant.mailer_dsn: 'null://null'
|
||||
+16
-1
@@ -12,6 +12,8 @@ parameters:
|
||||
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:
|
||||
@@ -65,7 +67,7 @@ services:
|
||||
# schreiben.
|
||||
App\Ops\MailCheck:
|
||||
arguments:
|
||||
$dsn: '%env(default:kant.mailer_dsn:MAILER_DSN)%'
|
||||
$dsn: '%kant.mail_dsn%'
|
||||
$logFile: '%env(default:kant.mail_log:MAIL_LOG)%'
|
||||
App\Controller\FormController:
|
||||
arguments:
|
||||
@@ -81,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%'
|
||||
@@ -174,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'
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
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.“
|
||||
|
||||
+26
-2
@@ -1,6 +1,28 @@
|
||||
# Projektstatus
|
||||
|
||||
## E-Mail-Versand: Testmail und Protokoll in der Verwaltung (12.10.2026)
|
||||
## Nach 1.35.0: 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.
|
||||
|
||||
**Offen:** nicht veröffentlicht (kein Tag/Release).
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -8,7 +30,9 @@
|
||||
|
||||
**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.
|
||||
|
||||
**Offen:** Nicht committet, kein Release. Hüfner: `MAILER_DSN` auf SMTP umstellen (zentrales Absenderpostfach bei kant-it.de geplant), dann Testmail.
|
||||
**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)
|
||||
|
||||
|
||||
+4
-4
@@ -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>
|
||||
|
||||
+72
-3
@@ -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,11 +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: 14px }
|
||||
.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-log-head { font-size: 14px; margin: 22px 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 ---------------------------- */
|
||||
|
||||
@@ -834,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 }
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ use App\Ops\Backup;
|
||||
use App\Ops\Environment;
|
||||
use App\Ops\HttpsRedirect;
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\MailCheck;
|
||||
use App\Ops\OriginCheck;
|
||||
use App\Ops\SitePackage;
|
||||
use App\Ops\Updater;
|
||||
@@ -20,8 +19,6 @@ use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\Mime\Address;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
@@ -33,7 +30,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class SystemController extends AbstractController
|
||||
{
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck, private HttpKernelInterface $kernel, private PageRepository $pages, private SiteSettings $settings, private SitePackage $packages, private HttpsRedirect $https, private MailCheck $mail)
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck, private HttpKernelInterface $kernel, private PageRepository $pages, private SiteSettings $settings, private SitePackage $packages, private HttpsRedirect $https)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -74,9 +71,6 @@ final class SystemController extends AbstractController
|
||||
// HTTPS erzwingen und HSTS (ADR 120): der Stand in public/.htaccess.
|
||||
'https' => $this->https->state(),
|
||||
'secure' => $request->isSecure(),
|
||||
// E-Mail-Versand (ADR 121): Weg ohne Passwort, Testmail, die letzten Versuche.
|
||||
'mail' => $this->mailFacts(),
|
||||
'mailResult' => $request->getSession()->remove('mail_result'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -526,51 +520,6 @@ final class SystemController extends AbstractController
|
||||
return $this->back($result['message'], $result['ok'] ? 'success' : 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Testmail an eine frei eingetragene Adresse (ADR 121). Das Ergebnis - Meldung, Hinweis,
|
||||
* Antworten des Servers - steht danach auf der Seite und in var/log/mail.log.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/system/testmail', name: 'admin_system_testmail', methods: ['POST'])]
|
||||
public function testMail(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$an = trim($request->request->getString('to'));
|
||||
$von = trim($request->request->getString('from')) ?: $an;
|
||||
foreach ([$an, $von] as $adresse) {
|
||||
if (false === filter_var($adresse, FILTER_VALIDATE_EMAIL)) {
|
||||
return $this->back(sprintf('„%s“ ist keine gültige E-Mail-Adresse.', mb_substr($adresse, 0, 100)));
|
||||
}
|
||||
}
|
||||
$website = rtrim($this->siteOrigin, '/') ?: $request->getSchemeAndHttpHost();
|
||||
$email = (new Email())
|
||||
->from(new Address($von))
|
||||
->to(new Address($an))
|
||||
->subject('Testmail von '.(parse_url($website, PHP_URL_HOST) ?: $website))
|
||||
->text("Diese Testmail kommt aus der Verwaltung von {$website} (System → E-Mail-Versand).
|
||||
|
||||
Wenn sie angekommen ist, funktioniert der Versand der Formulare auf demselben Weg.
|
||||
");
|
||||
$request->getSession()->set('mail_result', ['an' => $an, 'von' => $von] + $this->mail->send($email, 'Testmail'));
|
||||
|
||||
return $this->redirect($this->generateUrl('admin_system').'#mail');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function mailFacts(): array
|
||||
{
|
||||
$route = $this->mail->route();
|
||||
|
||||
return $route + [
|
||||
'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(),
|
||||
];
|
||||
}
|
||||
|
||||
/** Die Gegenstelle zur Prüfung oben: öffentlich, und sie verrät nichts als diese Kennung. */
|
||||
#[Route(OriginCheck::PATH, name: 'site_origin_probe', methods: ['GET'], priority: 10)]
|
||||
public function originProbe(): Response
|
||||
|
||||
@@ -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);
|
||||
|
||||
+114
-14
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -30,32 +31,118 @@ final class MailCheck
|
||||
* 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.
|
||||
*/
|
||||
public function __construct(#[Autowire(service: 'mailer.transports', lazy: true)] private TransportInterface $transport, private string $dsn, private string $logFile)
|
||||
/** 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.
|
||||
*
|
||||
* @return array{ok: bool, meldung: string, hinweis: string, dialog: string}
|
||||
* 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): array
|
||||
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 {
|
||||
$gesendet = $this->transport->send($email);
|
||||
// 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($this->scheme()),
|
||||
'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(), $this->scheme()),
|
||||
'hinweis' => self::hinweis($fehler->getMessage(), $scheme),
|
||||
'dialog' => self::dialog($fehler->getDebug()),
|
||||
];
|
||||
} catch (\Throwable $fehler) {
|
||||
@@ -63,10 +150,12 @@ final class MailCheck
|
||||
$ergebnis = [
|
||||
'ok' => false,
|
||||
'meldung' => self::einzeilig($fehler->getMessage()),
|
||||
'hinweis' => self::hinweis($fehler->getMessage(), $this->scheme()),
|
||||
'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;
|
||||
@@ -77,16 +166,16 @@ final class MailCheck
|
||||
*
|
||||
* @return array{art: string, ziel: string, benutzer: string, gueltig: bool}
|
||||
*/
|
||||
public function route(): array
|
||||
public function route(?string $roh = null): array
|
||||
{
|
||||
try {
|
||||
$dsn = Dsn::fromString($this->dsn);
|
||||
$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' => 'sendmail des Servers',
|
||||
'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(),
|
||||
@@ -123,6 +212,16 @@ final class MailCheck
|
||||
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;
|
||||
@@ -135,11 +234,12 @@ final class MailCheck
|
||||
|
||||
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')) => 'MAILER_DSN in .env ist ungültig. Form: smtp://benutzer:passwort@server:587 - @ und Sonderzeichen in Benutzer und Passwort URL-kodiert (@ → %40, # → %23, / → %2F, % → %25).',
|
||||
(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($scheme, 'sendmail') => 'sendmail ist auf diesem Server so nicht nutzbar. Besser über SMTP mit einem echten Postfach versenden: MAILER_DSN=smtp://benutzer:passwort@server:587 in .env.',
|
||||
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 => '',
|
||||
};
|
||||
@@ -171,10 +271,10 @@ final class MailCheck
|
||||
return implode("\n", array_slice($zeilen, -12));
|
||||
}
|
||||
|
||||
private function scheme(): string
|
||||
private static function schemeVon(string $roh): string
|
||||
{
|
||||
try {
|
||||
return Dsn::fromString($this->dsn)->getScheme();
|
||||
return Dsn::fromString($roh)->getScheme();
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -127,59 +127,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# E-Mail-Versand (ADR 121): Weg ohne Passwort, Testmail an eine frei gewählte Adresse und die
|
||||
letzten Versuche mit Ursache - vorher stand bei „Nicht versendet“ nirgends, warum. #}
|
||||
<div class="card" id="mail">
|
||||
<div class="card-head"><h2>E-Mail-Versand</h2><span class="muted">var/log/mail.log</span></div>
|
||||
<div class="card-body">
|
||||
<dl class="facts">
|
||||
<dt>Versandweg</dt>
|
||||
<dd>
|
||||
{% if mail.gueltig %}
|
||||
<strong>{{ mail.art }}</strong>{% if mail.ziel %} <code>{{ mail.ziel }}</code>{% endif %}{% if mail.benutzer %} <span class="muted">· angemeldet als</span> <code>{{ mail.benutzer }}</code>{% endif %}
|
||||
{% else %}
|
||||
<span class="check-bad"><strong>MAILER_DSN ist ungültig.</strong></span>
|
||||
{% endif %}
|
||||
<br><span class="muted">Eingestellt in <code>{{ mail.quelle }}</code> als <code>MAILER_DSN</code>, zum Beispiel <code>smtp://info%40firma.de:passwort@smtp.ionos.de:587</code>. Das Passwort wird hier nie angezeigt.</span>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{% 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 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 %}
|
||||
|
||||
<form class="origin-form" action="{{ path('admin_system_testmail') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<label for="mail-to">Testmail senden an</label>
|
||||
<div class="origin-row">
|
||||
<input id="mail-to" name="to" type="email" autocomplete="email" maxlength="200" required placeholder="name@beispiel.de" value="{{ mailResult.an ?? '' }}">
|
||||
<button class="secondary" type="submit">Testmail senden</button>
|
||||
</div>
|
||||
<label for="mail-from">Absender</label>
|
||||
<input id="mail-from" name="from" type="email" maxlength="200" placeholder="leer: dieselbe Adresse" value="{{ mailResult.von ?? mail.absender }}" aria-describedby="mail-from-help">
|
||||
<p class="field-help" id="mail-from-help">Am besten der Absender, der auch bei den Formularen eingetragen ist. Viele Mailserver nehmen nur das Postfach an, mit dem angemeldet wird.</p>
|
||||
</form>
|
||||
|
||||
<h3 class="mail-log-head">Letzte Versuche</h3>
|
||||
{% if mail.protokoll is empty %}
|
||||
<p class="muted">Noch nichts versendet – weder Testmail noch Formular.</p>
|
||||
{% else %}
|
||||
<ul class="log-list">
|
||||
{% for e in mail.protokoll %}
|
||||
<li class="{{ e.ok ? 'check-ok' : 'check-bad' }}">
|
||||
<strong>{{ e.ok ? 'Angenommen' : 'Fehlgeschlagen' }}</strong> · {{ e.zeit }} UTC · {{ e.anlass }} an <code>{{ e.an }}</code>{% if e.von %} von <code>{{ e.von }}</code>{% endif %}
|
||||
{% if not e.ok %}<br><code>{{ e.meldung }}</code>{% if e.hinweis %}<br><span class="muted">{{ e.hinweis }}</span>{% endif %}{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Die Vorprüfung gehört zum Updateweg über Git: Arbeitsverzeichnis sauber, Datenbank
|
||||
außerhalb von Git, Tag erreichbar. Auf einer Installation aus dem Releasearchiv prüft sie
|
||||
Bedingungen, die dort keine sind - und zeigt lauter rote Ausrufezeichen für etwas, das
|
||||
@@ -321,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 }}">
|
||||
@@ -349,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>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
+63
-1
@@ -62,7 +62,7 @@ final class MailCheckTest extends TestCase
|
||||
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 des Servers', (new MailCheck($this->transport(null), 'sendmail://default', $this->log))->route()['art']);
|
||||
self::assertSame('sendmail -bs des Servers', (new MailCheck($this->transport(null), 'sendmail://default', $this->log))->route()['art']);
|
||||
}
|
||||
|
||||
public function testAnAcceptedMailIsLoggedWithWhatToCheckIfNothingArrives(): void
|
||||
@@ -84,6 +84,68 @@ final class MailCheckTest extends TestCase
|
||||
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');
|
||||
|
||||
+58
-14
@@ -201,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());
|
||||
}
|
||||
@@ -360,38 +360,82 @@ final class SystemScreenTest extends WebTestCase
|
||||
self::assertStringContainsString('Migrationen stehen aus', (string) $admin->getResponse()->getContent(), 'Und es steht immer noch aus.');
|
||||
}
|
||||
|
||||
public function testATestMailGoesToAnyAddressAndTheScreenSaysWhatHappened(): void
|
||||
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 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('E-Mail-Versand', $html);
|
||||
self::assertStringContainsString('Noch nichts versendet', $html);
|
||||
$token = (string) $client->getCrawler()->filter('form[action$="/system/testmail"] input[name=_csrf_token]')->attr('value');
|
||||
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('POST', '/admin/system/testmail', ['_csrf_token' => $token, 'to' => 'kein-postfach']);
|
||||
$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());
|
||||
|
||||
$client->request('POST', '/admin/system/testmail', ['_csrf_token' => $token, 'to' => 'umut@cms.test', 'from' => 'formular@cms.test']);
|
||||
self::assertStringEndsWith('/admin/system#mail', (string) $client->getResponse()->headers->get('Location'));
|
||||
// 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');
|
||||
$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 in „Letzte Versuche“.');
|
||||
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/testmail', ['_csrf_token' => 'erfunden', 'to' => 'umut@cms.test']);
|
||||
$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('POST', '/admin/system/testmail', ['_csrf_token' => $token, 'to' => 'umut@cms.test']);
|
||||
$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());
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user