From b982cb894402f6f81d9a5ba8db0bb9398e54940c Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Mon, 12 Oct 2026 21:07:27 +0200 Subject: [PATCH] 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 --- config/packages/test/mailer.yaml | 4 - config/services.yaml | 17 +- docs/ARCHITECTURE.md | 10 + docs/STATUS.md | 10 + public/.htaccess | 8 +- public/assets/admin.css | 16 +- src/Controller/MailController.php | 188 ++++++++++++++++++ src/Controller/SystemController.php | 53 +---- src/EventSubscriber/MaintenanceSubscriber.php | 2 +- src/Ops/MailCheck.php | 118 +++++++++-- src/Ops/OriginCheck.php | 6 +- templates/admin/mail.html.twig | 132 ++++++++++++ templates/admin/shell.html.twig | 7 +- templates/admin/system.html.twig | 53 ----- tests/HttpsRedirectTest.php | 2 +- tests/MailCheckTest.php | 64 +++++- tests/SystemScreenTest.php | 55 +++-- 17 files changed, 597 insertions(+), 148 deletions(-) create mode 100644 src/Controller/MailController.php create mode 100644 templates/admin/mail.html.twig diff --git a/config/packages/test/mailer.yaml b/config/packages/test/mailer.yaml index 67e6574..f67ce87 100644 --- a/config/packages/test/mailer.yaml +++ b/config/packages/test/mailer.yaml @@ -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' diff --git a/config/services.yaml b/config/services.yaml index 4a3d098..a694471 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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' diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 642ef0e..b94ba43 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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.“ diff --git a/docs/STATUS.md b/docs/STATUS.md index c34f0bf..bcc91ef 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,15 @@ # Projektstatus +## 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. + +**Offen:** Nicht committet, kein Release. + ## 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). diff --git a/public/.htaccess b/public/.htaccess index 343d683..a7169dc 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -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). Require all denied diff --git a/public/assets/admin.css b/public/assets/admin.css index ee91b63..5411341 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -719,10 +719,24 @@ details.card > summary:hover { background: #fafbf8 } .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 .facts { margin-top: 0 } .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 } +/* Unterpunkte im Menü: eingerückt auf die Höhe des Textes, ohne Symbol. */ +.nav-sub { list-style: none; margin: 2px 0 4px; padding: 0 } +.nav-sub .nav-item { padding-left: 42px; font-size: 13px } +.mail-form { margin-top: 18px; max-width: 640px } +.mail-form fieldset { border: 0; margin: 0 0 16px; padding: 0; min-width: 0 } +.mail-form legend { font-weight: 600; font-size: 13px; margin-bottom: 6px; padding: 0 } +.mail-form .check-label { display: flex; gap: 8px; align-items: baseline; margin: 4px 0 } +.mail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 8px 12px } +.mail-grid label { margin-top: 0 } +.mail-grid input { width: 100%; box-sizing: border-box } +.mail-actions { display: flex; flex-wrap: wrap; gap: 8px } +.mail-actions button { margin: 0 } +/* 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 ---------------------------- */ diff --git a/src/Controller/MailController.php b/src/Controller/MailController.php new file mode 100644 index 0000000..2b2600f --- /dev/null +++ b/src/Controller/MailController.php @@ -0,0 +1,188 @@ +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); + } + + /** @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 */ + 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'; + } +} diff --git a/src/Controller/SystemController.php b/src/Controller/SystemController.php index 9f4658e..753df6c 100644 --- a/src/Controller/SystemController.php +++ b/src/Controller/SystemController.php @@ -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 */ - 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 diff --git a/src/EventSubscriber/MaintenanceSubscriber.php b/src/EventSubscriber/MaintenanceSubscriber.php index 3be76fb..bd62089 100644 --- a/src/EventSubscriber/MaintenanceSubscriber.php +++ b/src/EventSubscriber/MaintenanceSubscriber.php @@ -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); diff --git a/src/Ops/MailCheck.php b/src/Ops/MailCheck.php index 447208e..c6549bd 100644 --- a/src/Ops/MailCheck.php +++ b/src/Ops/MailCheck.php @@ -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 + */ + 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(), @@ -135,11 +224,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 +261,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 ''; } diff --git a/src/Ops/OriginCheck.php b/src/Ops/OriginCheck.php index b539d99..bc94791 100644 --- a/src/Ops/OriginCheck.php +++ b/src/Ops/OriginCheck.php @@ -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; diff --git a/templates/admin/mail.html.twig b/templates/admin/mail.html.twig new file mode 100644 index 0000000..b7a192f --- /dev/null +++ b/templates/admin/mail.html.twig @@ -0,0 +1,132 @@ +{% 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 %} +
+

Versandweg

gilt für alle Formulare
+
+
+
Eingestellt
+
+ {% if mail.gueltig %} + {{ mail.art }}{% if mail.ziel %} {{ mail.ziel }}{% endif %}{% if mail.benutzer %} · angemeldet als {{ mail.benutzer }}{% endif %} + {% else %} + Die Einstellung ist ungültig. + {% endif %} +
Gespeichert in {{ mail.quelle }} als MAILER_DSN.{% if mail.quelle != '.env' %} Sie hat dort Vorrang; speichern lässt sich hier nichts, testen schon.{% endif %} +
+
+ + {% if mailNotice %} +

{{ mailNotice.text }}

+ {% endif %} + + {% if mailResult %} +
+

{{ mailResult.ok ? 'Testmail an ' ~ mailResult.an ~ ' vom Mailserver angenommen' : 'Testmail an ' ~ mailResult.an ~ ' nicht versendet' }}{% if mailResult.weg is defined %} ({{ mailResult.weg }}){% endif %}.{% if not mailResult.ok %} {{ mailResult.meldung }}{% endif %}

+ {% if mailResult.hinweis %}

{{ mailResult.hinweis }}

{% endif %} + {% if mailResult.dialog %}
Antworten des Mailservers
{{ mailResult.dialog }}
{% endif %} +
+ {% endif %} + + {% if mailResults %} +
+

Alle Wege ausprobiert – an {{ (f.an ?? '') }}. Was angenommen wurde, kann gespeichert werden; angekommen ist es damit noch nicht, bitte im Postfach nachsehen.

+
    + {% for r in mailResults %} +
  • + {{ r.ok ? 'Angenommen' : 'Fehlgeschlagen' }} · {{ r.weg }} + {% if not r.ok %}
    {{ r.meldung }}{% if r.hinweis %}
    {{ r.hinweis }}{% endif %}{% endif %} +
  • + {% endfor %} +
+
+ {% endif %} + +
+ +
+ Versandweg + + {% for key, w in mail.wege %} + + {% endfor %} +
+ +
+ SMTP +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

587 mit STARTTLS ist üblich (IONOS, Microsoft 365: smtp.office365.com), 465 verschlüsselt von Anfang an. Das Passwort wird nie angezeigt und landet nur in .env.

+
+ +
+ Testmail +
+
+ + +
+
+ + +
+
+

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.

+
+ +
+ + + {% if mail.quelle == '.env' %}{% endif %} +
+

„Alle Wege testen“ schickt je Weg eine Testmail – bis zu vier. Gespeichert wird {{ mail.quelle == '.env' ? 'nur mit „Diesen Weg speichern“' : 'hier nichts, weil MAILER_DSN in der Server-Umgebung steht' }}.

+
+ +
+
+ +
+

Letzte Versuche

var/log/mail.log
+
+ {% if mail.protokoll is empty %} +

Noch nichts versendet – weder Testmail noch Formular.

+ {% else %} +
    + {% for eintrag in mail.protokoll %} +
  • + {{ eintrag.ok ? 'Angenommen' : 'Fehlgeschlagen' }} · {{ eintrag.zeit }} UTC · {{ eintrag.anlass }} an {{ eintrag.an }}{% if eintrag.von %} von {{ eintrag.von }}{% endif %}{% if eintrag.weg is defined and eintrag.weg %} · {{ eintrag.weg }}{% endif %} + {% if not eintrag.ok %}
    {{ eintrag.meldung }}{% if eintrag.hinweis %}
    {{ eintrag.hinweis }}{% endif %}{% endif %} +
  • + {% endfor %} +
+ {% endif %} +
+
+ +

KantSites · Arbeitsstand

+{% endblock %} diff --git a/templates/admin/shell.html.twig b/templates/admin/shell.html.twig index 312fed4..d3097b5 100644 --- a/templates/admin/shell.html.twig +++ b/templates/admin/shell.html.twig @@ -46,7 +46,12 @@