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 @@