From d313156c737370de56473f4ea3aa663a479995c6 Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Mon, 12 Oct 2026 20:29:36 +0200 Subject: [PATCH] Send a test mail from the admin and log why delivery failed Form submissions marked as not sent gave no reason: the transport exception was swallowed. Every send now goes through MailCheck, which logs the server's answer and a hint in German to var/log/mail.log (server lines only, no credentials). The system screen shows the mail route, sends a test mail to any address and lists recent attempts (ADR 121). Co-Authored-By: Claude Opus 5.5 --- config/packages/test/mailer.yaml | 5 + config/services.yaml | 8 ++ docs/ARCHITECTURE.md | 10 ++ docs/STATUS.md | 10 ++ public/assets/admin.css | 5 + src/Controller/FormController.php | 16 +-- src/Controller/SystemController.php | 53 +++++++- src/Ops/MailCheck.php | 199 ++++++++++++++++++++++++++++ templates/admin/system.html.twig | 53 ++++++++ tests/MailCheckTest.php | 117 ++++++++++++++++ tests/SystemScreenTest.php | 38 +++++- 11 files changed, 501 insertions(+), 13 deletions(-) create mode 100644 src/Ops/MailCheck.php create mode 100644 tests/MailCheckTest.php diff --git a/config/packages/test/mailer.yaml b/config/packages/test/mailer.yaml index 6fc6d2b..67e6574 100644 --- a/config/packages/test/mailer.yaml +++ b/config/packages/test/mailer.yaml @@ -2,3 +2,8 @@ framework: mailer: # 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 a7572a4..4a3d098 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -10,6 +10,8 @@ parameters: # das Projekt schreiben, und eine Installation mit schreibgeschuetztem public/ ebenfalls. kant.fonts_dir: '%kernel.project_dir%/public/assets/fonts' kant.asset_dir: '%kernel.project_dir%/public/assets' + kant.mail_log: '%kernel.project_dir%/var/log/mail.log' + kant.mailer_dsn: 'sendmail://default' services: _defaults: @@ -59,6 +61,12 @@ services: App\Content\StyleBundle: arguments: $assetDirectory: '%env(default:kant.asset_dir:ASSET_DIRECTORY)%' + # Versand und Protokoll (ADR 121). MAIL_LOG nur fuer Tests, damit sie nicht ins echte var/log + # schreiben. + App\Ops\MailCheck: + arguments: + $dsn: '%env(default:kant.mailer_dsn:MAILER_DSN)%' + $logFile: '%env(default:kant.mail_log:MAIL_LOG)%' App\Controller\FormController: arguments: $secret: '%kernel.secret%' diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 49ecde4..642ef0e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,6 +2,16 @@ Stand: 12.10.2026. Arbeitstitel KantSites; Produktname offen. +## ADR 121 – E-Mail-Versand mit Protokoll und Testmail (12.10.2026) + +Anlass: Auf huefner-gartenbau.de (IONOS) standen zwei Anfragen als „Nicht versendet“ in der Verwaltung, und nirgends stand, warum: `FormController` fing die `TransportException` und verwarf sie. Umut: „Bau mal in den Backend einen Knopf ein, Testmail an Mailadresse, wo man einträgt, und wo Logs angezeigt werden, woran es liegt.“ + +**Entscheidung.** Jeder Versand geht durch `App\Ops\MailCheck`: Formular und Testmail. Die Klasse versendet über den Transport aus `MAILER_DSN`, wirft nie und schreibt je Versuch eine JSON-Zeile nach `var/log/mail.log` (per FTP lesbar, ab 256 KB nach `.1` rotiert): Zeit, Anlass, Empfänger, Absender, Ergebnis, Meldung des Servers, ein Hinweis auf Deutsch für die häufigen Fälle (Anmeldung abgelehnt, Server nicht erreichbar, TLS, sendmail nicht nutzbar, Absender abgelehnt, ungültige DSN, `null://`) und die Antworten des Servers. Aus dem SMTP-Gespräch bleiben nur die Zeilen des Servers (`< `), denn die Zeilen des Clients enthalten bei AUTH LOGIN Benutzer und Passwort in Base64. Die Besucheradresse (Antwortadresse) steht nicht im Protokoll. + +**Systemseite.** Neue Karte „E-Mail-Versand“: Versandweg ohne Passwort (Art, Server, Benutzer, Quelle der Einstellung), Feld für eine beliebige Empfängeradresse und einen Absender (vorbelegt mit dem SMTP-Benutzer, wenn er eine Adresse ist), danach Ergebnis, Hinweis und Serverantworten, darunter die letzten zehn Versuche. Nur Administratoren, mit CSRF-Token. + +**Folgen.** Der Transport wird träge gebaut (`lazy`), damit eine ungültige `MAILER_DSN` den Versand scheitern lässt und im Protokoll steht, statt die Systemseite selbst. Die Einstellung bleibt in `.env`; ein Feld zum Ändern der DSN in der Verwaltung gibt es bewusst noch nicht (Passwort im Formular). Tests schreiben ihr Protokoll in den Cache (`kant.mail_log`, überschreibbar mit `MAIL_LOG`). Keine Migration; der Grund steht im Protokoll, nicht an der Einsendung. + ## ADR 120 – HTTPS erzwingen aus der Verwaltung (12.10.2026) Anlass: Umut: „HTTPS wird noch nicht erzwungen, wie machen wir das allgemein?“ Bisher hieß das: per FTP die drei auskommentierten Zeilen in `public/.htaccess` einschalten. Die Anwendung selbst erzwingt bewusst nichts (ADR zur Adresse: hinter einem Proxy sieht PHP oft nur `http`, ohne vertrauenswürdige Proxys wäre das eine Schleife). diff --git a/docs/STATUS.md b/docs/STATUS.md index f43d4a7..a7cc159 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,15 @@ # Projektstatus +## E-Mail-Versand: Testmail und Protokoll in der Verwaltung (12.10.2026) + +**Anlass:** huefner-gartenbau.de: Anfragen „Nicht versendet“, Ursache unbekannt (Ausnahme wurde verschluckt; vermutlich `sendmail://default` bei IONOS). + +**Erledigt (ADR 121):** `App\Ops\MailCheck` für Formular und Testmail, Protokoll `var/log/mail.log` mit Meldung, deutschem Hinweis und Serverantworten (ohne Client-Zeilen, also ohne Zugangsdaten). Systemseite: Karte „E-Mail-Versand“ mit Versandweg, Testmail an frei wählbare Adresse samt Absender, letzte zehn Versuche. + +**Geprüft:** `MailCheckTest` (Hinweise, Zugangsdaten bleiben draußen, Versandweg ohne Passwort), `SystemScreenTest` (Testmail, ungültige Adresse, Token, nur Administratoren); PHPUnit vollständig 678 grün (eine bekannte Warnung aus `UpdaterTest`, unabhängig davon), php-cs-fixer sauber. Im Browser auf einer Wegwerfkopie der Hüfner-Datenbank mit unerreichbarem SMTP-Server: Fehlermeldung, Hinweis „nicht erreichbar“ und Eintrag unter „Letzte Versuche“ erscheinen. Ein echter Versand über einen echten Mailserver wurde nicht ausgeführt. + +**Offen:** Nicht committet, kein Release. Hüfner: `MAILER_DSN` auf SMTP umstellen (zentrales Absenderpostfach bei kant-it.de geplant), dann Testmail. + ## Fassung 1.33.0: HTTPS erzwingen aus der Verwaltung (12.10.2026) **Anlass:** Umut: HTTPS wird noch nicht erzwungen – allgemein lösen. diff --git a/public/assets/admin.css b/public/assets/admin.css index 3affcea..ee91b63 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -718,6 +718,11 @@ 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 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 } /* ---- the installer: six screens on an otherwise empty server ---------------------------- */ diff --git a/src/Controller/FormController.php b/src/Controller/FormController.php index 1118cdf..94ad8c9 100644 --- a/src/Controller/FormController.php +++ b/src/Controller/FormController.php @@ -8,11 +8,10 @@ use App\Content\MenuRepository; use App\Content\PageRepository; use App\Content\SiteSettings; use App\Content\Turnstile; +use App\Ops\MailCheck; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Mailer\Exception\TransportExceptionInterface; -use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; use Symfony\Component\Routing\Attribute\Route; @@ -29,7 +28,7 @@ final class FormController extends AbstractController private PageRepository $pages, private MenuRepository $menus, private SiteSettings $settings, - private MailerInterface $mailer, + private MailCheck $mail, private Turnstile $turnstile, private string $secret, private string $siteOrigin, @@ -127,15 +126,10 @@ final class FormController extends AbstractController if ('' !== $replyTo && '' !== ($values[$replyTo] ?? '')) { $email->replyTo($values[$replyTo]); } - try { - $this->mailer->send($email); - return true; - } catch (TransportExceptionInterface) { - // The submission is already stored, so a broken mail setup loses no enquiry. - // It stays marked as undelivered and is visible in the administration. - return false; - } + // The submission is already stored, so a broken mail setup loses no enquiry. It stays + // marked as undelivered, and var/log/mail.log says why (System -> E-Mail-Versand). + return $this->mail->send($email, 'Formular '.$form['slug'])['ok']; } /** diff --git a/src/Controller/SystemController.php b/src/Controller/SystemController.php index 753df6c..9f4658e 100644 --- a/src/Controller/SystemController.php +++ b/src/Controller/SystemController.php @@ -11,6 +11,7 @@ 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; @@ -19,6 +20,8 @@ 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; /** @@ -30,7 +33,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) + 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) { } @@ -71,6 +74,9 @@ 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'), ]); } @@ -520,6 +526,51 @@ 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/Ops/MailCheck.php b/src/Ops/MailCheck.php new file mode 100644 index 0000000..447208e --- /dev/null +++ b/src/Ops/MailCheck.php @@ -0,0 +1,199 @@ + $adresse->getAddress(), $email->getTo())); + try { + $gesendet = $this->transport->send($email); + $ergebnis = [ + 'ok' => true, + 'meldung' => 'Vom Mailserver angenommen.', + 'hinweis' => self::hinweisNachErfolg($this->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()), + 'dialog' => self::dialog($fehler->getDebug()), + ]; + } catch (\Throwable $fehler) { + // Eine ungültige MAILER_DSN fällt erst hier auf, nicht beim Start. + $ergebnis = [ + 'ok' => false, + 'meldung' => self::einzeilig($fehler->getMessage()), + 'hinweis' => self::hinweis($fehler->getMessage(), $this->scheme()), + 'dialog' => '', + ]; + } + $this->protokollieren(['zeit' => gmdate('Y-m-d H:i:s'), 'anlass' => $anlass, 'an' => $an, 'von' => $email->getFrom()[0]?->getAddress() ?? ''] + $ergebnis); + + return $ergebnis; + } + + /** + * Wie versendet wird - ohne Passwort. + * + * @return array{art: string, ziel: string, benutzer: string, gueltig: bool} + */ + public function route(): array + { + try { + $dsn = Dsn::fromString($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', + 'native' => 'PHP mail() des Servers', + 'null' => 'kein Versand (null)', + default => $dsn->getScheme(), + }; + $port = $dsn->getPort(); + + return [ + 'art' => $art, + 'ziel' => in_array($dsn->getScheme(), ['smtp', 'smtps'], true) ? $dsn->getHost().(null !== $port ? ':'.$port : '') : '', + 'benutzer' => (string) $dsn->getUser(), + 'gueltig' => true, + ]; + } + + /** + * Die letzten Einträge, neueste zuerst. + * + * @return list> + */ + public function recent(int $anzahl = 10): array + { + if (!is_file($this->logFile)) { + return []; + } + $zeilen = file($this->logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + $eintraege = []; + foreach (array_reverse(array_slice($zeilen, -$anzahl)) as $zeile) { + $eintrag = json_decode($zeile, true); + if (is_array($eintrag)) { + $eintraege[] = $eintrag; + } + } + + return $eintraege; + } + + public function logFile(): string + { + return $this->logFile; + } + + /** Was als Nächstes zu tun ist, für die häufigen Fälle. Leer, wenn nichts passt. */ + public static function hinweis(string $meldung, string $scheme): string + { + $m = strtolower($meldung); + + return match (true) { + 'null' === $scheme => 'MAILER_DSN steht auf null://null - es wird gar nichts verschickt.', + (str_contains($m, 'dsn') && (str_contains($m, 'invalid') || str_contains($m, 'malformed') || str_contains($m, 'must contain'))) || (str_contains($m, 'scheme') && str_contains($m, 'not supported')) => '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, '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.', + 1 === preg_match('/\b55[0-4]\b/', $m) || str_contains($m, 'sender') || str_contains($m, 'relay') => 'Der Mailserver nimmt Absender oder Empfänger nicht an. Der Absender muss meist genau das Postfach sein, mit dem angemeldet wird.', + default => '', + }; + } + + /** Angenommen heißt nicht angekommen - die üblichen Gründe dafür. */ + public static function hinweisNachErfolg(string $scheme): string + { + if ('null' === $scheme) { + return self::hinweis('', 'null'); + } + + return 'Kommt nichts an: Spam-Ordner prüfen. Der Absender sollte zur eigenen Domain gehören (SPF). Liegt das Postfach des Empfängers nicht beim Hoster (etwa Microsoft 365), darf die Domain dort nicht auch als Mail-Domain eingerichtet sein - sonst stellt der Hoster intern zu.'; + } + + /** + * Nur die Antworten des Servers aus dem SMTP-Gespräch, höchstens die letzten zwölf. + * Symfony schreibt sie mit „< “ am Anfang, die eigenen Zeilen mit „> “. + */ + public static function dialog(string $debug): string + { + $zeilen = []; + foreach (preg_split('/\R/', $debug) ?: [] as $zeile) { + if (str_starts_with($zeile, '< ')) { + $zeilen[] = $zeile; + } + } + + return implode("\n", array_slice($zeilen, -12)); + } + + private function scheme(): string + { + try { + return Dsn::fromString($this->dsn)->getScheme(); + } catch (\Throwable) { + return ''; + } + } + + private static function einzeilig(string $text): string + { + return mb_substr(trim((string) preg_replace('/\s+/', ' ', $text)), 0, 1000); + } + + private function protokollieren(array $eintrag): void + { + $ordner = dirname($this->logFile); + if (!is_dir($ordner)) { + @mkdir($ordner, 0o775, true); + } + if (is_file($this->logFile) && filesize($this->logFile) > self::GRENZE) { + @rename($this->logFile, $this->logFile.'.1'); + } + @file_put_contents($this->logFile, json_encode($eintrag, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE)."\n", FILE_APPEND | LOCK_EX); + } +} diff --git a/templates/admin/system.html.twig b/templates/admin/system.html.twig index d843087..84f5d22 100644 --- a/templates/admin/system.html.twig +++ b/templates/admin/system.html.twig @@ -127,6 +127,59 @@ +{# 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. #} +
+

E-Mail-Versand

var/log/mail.log
+
+
+
Versandweg
+
+ {% if mail.gueltig %} + {{ mail.art }}{% if mail.ziel %} {{ mail.ziel }}{% endif %}{% if mail.benutzer %} · angemeldet als {{ mail.benutzer }}{% endif %} + {% else %} + MAILER_DSN ist ungültig. + {% endif %} +
Eingestellt in {{ mail.quelle }} als MAILER_DSN, zum Beispiel smtp://info%40firma.de:passwort@smtp.ionos.de:587. Das Passwort wird hier nie angezeigt. +
+
+ + {% if mailResult %} +
+

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

+ {% if mailResult.hinweis %}

{{ mailResult.hinweis }}

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

Am besten der Absender, der auch bei den Formularen eingetragen ist. Viele Mailserver nehmen nur das Postfach an, mit dem angemeldet wird.

+
+ +

Letzte Versuche

+ {% if mail.protokoll is empty %} +

Noch nichts versendet – weder Testmail noch Formular.

+ {% else %} +
    + {% for e in mail.protokoll %} +
  • + {{ e.ok ? 'Angenommen' : 'Fehlgeschlagen' }} · {{ e.zeit }} UTC · {{ e.anlass }} an {{ e.an }}{% if e.von %} von {{ e.von }}{% endif %} + {% if not e.ok %}
    {{ e.meldung }}{% if e.hinweis %}
    {{ e.hinweis }}{% endif %}{% endif %} +
  • + {% endfor %} +
+ {% endif %} +
+
+ {# 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 diff --git a/tests/MailCheckTest.php b/tests/MailCheckTest.php new file mode 100644 index 0000000..08bb92d --- /dev/null +++ b/tests/MailCheckTest.php @@ -0,0 +1,117 @@ +log = sys_get_temp_dir().'/kant-mail-'.bin2hex(random_bytes(4)).'/mail.log'; + } + + protected function tearDown(): void + { + @unlink($this->log); + @rmdir(dirname($this->log)); + } + + public function testAFailedSendSaysWhyAndKeepsTheCredentialsOutOfTheLog(): void + { + $fehler = new TransportException('Failed to authenticate on SMTP server with username "formular@kant-it.de" using the following authenticators: "LOGIN". Authenticator "LOGIN" returned "Expected response code "235" but got code "535", with message "535 Authentication credentials invalid".'); + $fehler->appendDebug("< 220 smtp.ionos.de ESMTP\n> EHLO cms.test\n< 250 AUTH LOGIN PLAIN\n> AUTH LOGIN\n< 334 VXNlcm5hbWU6\n> Zm9ybXVsYXJAa2FudC1pdC5kZQ==\n< 334 UGFzc3dvcmQ6\n> R2VoZWltMTIz\n< 535 Authentication credentials invalid\n"); + $mail = new MailCheck($this->transport($fehler), 'smtp://formular%40kant-it.de:Geheim123@smtp.ionos.de:587', $this->log); + + $ergebnis = $mail->send($this->email(), 'Formular anfrage'); + + self::assertFalse($ergebnis['ok']); + self::assertStringContainsString('535', $ergebnis['meldung']); + self::assertStringContainsString('lehnt die Anmeldung ab', $ergebnis['hinweis']); + self::assertStringContainsString('< 535 Authentication credentials invalid', $ergebnis['dialog']); + // Was der Client schickt, bleibt draußen: bei AUTH LOGIN sind das Benutzer und Passwort. + self::assertStringNotContainsString('R2VoZWltMTIz', $ergebnis['dialog']); + self::assertStringNotContainsString('> ', $ergebnis['dialog']); + + $protokoll = (string) file_get_contents($this->log); + self::assertStringNotContainsString('R2VoZWltMTIz', $protokoll); + self::assertStringNotContainsString('Geheim123', $protokoll); + $eintrag = $mail->recent()[0]; + self::assertFalse($eintrag['ok']); + self::assertSame('Formular anfrage', $eintrag['anlass']); + self::assertSame('info@huefner-gartenbau.de', $eintrag['an']); + self::assertSame('formular@kant-it.de', $eintrag['von']); + } + + public function testTheRouteNamesServerAndUserButNeverThePassword(): void + { + $route = (new MailCheck($this->transport(null), 'smtp://formular%40kant-it.de:Geheim123@smtp.ionos.de:587', $this->log))->route(); + self::assertSame(['art' => 'SMTP', 'ziel' => 'smtp.ionos.de:587', 'benutzer' => 'formular@kant-it.de', 'gueltig' => true], $route); + + self::assertFalse((new MailCheck($this->transport(null), 'kein dsn', $this->log))->route()['gueltig']); + self::assertSame('sendmail des Servers', (new MailCheck($this->transport(null), 'sendmail://default', $this->log))->route()['art']); + } + + public function testAnAcceptedMailIsLoggedWithWhatToCheckIfNothingArrives(): void + { + $mail = new MailCheck($this->transport(null), 'smtp://u:p@smtp.ionos.de:587', $this->log); + $ergebnis = $mail->send($this->email(), 'Testmail'); + + self::assertTrue($ergebnis['ok']); + self::assertStringContainsString('intern zu', $ergebnis['hinweis'], 'Microsoft 365 beim Kunden, Domain auch beim Hoster: der häufigste Grund für „angenommen, nie angekommen“.'); + self::assertTrue($mail->recent()[0]['ok']); + } + + public function testTheHintsCoverTheUsualCauses(): void + { + self::assertStringContainsString('nicht erreichbar', MailCheck::hinweis('Connection could not be established with host "ssl://smtp.ionos.de:465": stream_socket_client(): Unable to connect', 'smtps')); + self::assertStringContainsString('SMTP', MailCheck::hinweis('Expected response code "220" but got an empty response.', 'sendmail')); + self::assertStringContainsString('Absender', MailCheck::hinweis('Expected response code "250" but got code "550", with message "550 Sender address rejected".', 'smtp')); + self::assertStringContainsString('ungültig', MailCheck::hinweis('The "foo" scheme is not supported; supported schemes for mailer "smtp" are: "smtp", "smtps".', 'foo')); + self::assertStringContainsString('null://null', MailCheck::hinweisNachErfolg('null')); + } + + private function email(): Email + { + return (new Email())->from('formular@kant-it.de')->to('info@huefner-gartenbau.de')->subject('Anfrage')->text('Test'); + } + + /** Ein Transport, der nichts verschickt: wirft $fehler oder meldet „angenommen“. */ + private function transport(?TransportException $fehler): TransportInterface + { + return new class($fehler) implements TransportInterface { + public function __construct(private ?TransportException $fehler) + { + } + + public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage + { + if (null !== $this->fehler) { + throw $this->fehler; + } + $gesendet = new SentMessage($message, $envelope ?? Envelope::create($message)); + $gesendet->appendDebug("< 250 2.0.0 Ok: queued\n"); + + return $gesendet; + } + + public function __toString(): string + { + return 'test://'; + } + }; + } +} diff --git a/tests/SystemScreenTest.php b/tests/SystemScreenTest.php index d8fc784..a9d79b9 100644 --- a/tests/SystemScreenTest.php +++ b/tests/SystemScreenTest.php @@ -47,7 +47,7 @@ final class SystemScreenTest extends WebTestCase $this->path = tempnam(sys_get_temp_dir(), 'kant-system-'); $url = 'sqlite:///'.str_replace('\\', '/', $this->path); $this->previous = [$_SERVER, $_ENV]; - foreach (['APP_SECRET' => 'isolated-test-secret', 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) { + foreach (['APP_SECRET' => 'isolated-test-secret', 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'MAIL_LOG' => $this->path.'-mail.log'] as $key => $value) { $_SERVER[$key] = $_ENV[$key] = $value; } $this->db = ConnectionFactory::create($url); @@ -81,6 +81,7 @@ final class SystemScreenTest extends WebTestCase $this->db->close(); gc_collect_cycles(); @unlink($this->path); + @unlink($this->path.'-mail.log'); foreach (glob($this->path.'-media/*') ?: [] as $file) { unlink($file); } @@ -359,6 +360,41 @@ final class SystemScreenTest extends WebTestCase self::assertStringContainsString('Migrationen stehen aus', (string) $admin->getResponse()->getContent(), 'Und es steht immer noch aus.'); } + public function testATestMailGoesToAnyAddressAndTheScreenSaysWhatHappened(): 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). + $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'); + + $client->request('POST', '/admin/system/testmail', ['_csrf_token' => $token, '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')); + 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'); + $html = (string) $client->getResponse()->getContent(); + self::assertStringContainsString('Testmail an umut@cms.test vom Mailserver angenommen.', $html); + self::assertStringContainsString('Testmail an umut@cms.test', $html, 'Der Versuch steht danach in „Letzte Versuche“.'); + + // Nur für Administratoren, und nur mit Token. + $client->request('POST', '/admin/system/testmail', ['_csrf_token' => 'erfunden', 'to' => 'umut@cms.test']); + self::assertEmailCount(0); + $redaktion = $this->login('editor'); + $redaktion->request('POST', '/admin/system/testmail', ['_csrf_token' => $token, 'to' => 'umut@cms.test']); + self::assertSame(403, $redaktion->getResponse()->getStatusCode()); + } + private function login(string $username = 'admin'): KernelBrowser { self::ensureKernelShutdown();