Send a test mail from the admin and log why delivery failed

Form submissions marked as not sent gave no reason: the transport
exception was swallowed. Every send now goes through MailCheck, which
logs the server's answer and a hint in German to var/log/mail.log
(server lines only, no credentials). The system screen shows the mail
route, sends a test mail to any address and lists recent attempts
(ADR 121).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-10-12 20:29:36 +02:00
1 parent 090436233d
commit d313156c73
11 files changed
+501 -13

No files matched your search

+5
View File
@@ -2,3 +2,8 @@ framework:
mailer: mailer:
# Tests never send real mail; the transport collects messages in memory instead. # Tests never send real mail; the transport collects messages in memory instead.
dsn: 'null://null' 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'
+8
View File
@@ -10,6 +10,8 @@ parameters:
# das Projekt schreiben, und eine Installation mit schreibgeschuetztem public/ ebenfalls. # das Projekt schreiben, und eine Installation mit schreibgeschuetztem public/ ebenfalls.
kant.fonts_dir: '%kernel.project_dir%/public/assets/fonts' kant.fonts_dir: '%kernel.project_dir%/public/assets/fonts'
kant.asset_dir: '%kernel.project_dir%/public/assets' kant.asset_dir: '%kernel.project_dir%/public/assets'
kant.mail_log: '%kernel.project_dir%/var/log/mail.log'
kant.mailer_dsn: 'sendmail://default'
services: services:
_defaults: _defaults:
@@ -59,6 +61,12 @@ services:
App\Content\StyleBundle: App\Content\StyleBundle:
arguments: arguments:
$assetDirectory: '%env(default:kant.asset_dir:ASSET_DIRECTORY)%' $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: App\Controller\FormController:
arguments: arguments:
$secret: '%kernel.secret%' $secret: '%kernel.secret%'
+10
View File
@@ -2,6 +2,16 @@
Stand: 12.10.2026. Arbeitstitel KantSites; Produktname offen. 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) ## 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). 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).
+10
View File
@@ -1,5 +1,15 @@
# Projektstatus # 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) ## Fassung 1.33.0: HTTPS erzwingen aus der Verwaltung (12.10.2026)
**Anlass:** Umut: HTTPS wird noch nicht erzwungen – allgemein lösen. **Anlass:** Umut: HTTPS wird noch nicht erzwungen – allgemein lösen.
+5
View File
@@ -718,6 +718,11 @@ details.card > summary:hover { background: #fafbf8 }
.check-bad { color: var(--danger) } .check-bad { color: var(--danger) }
.log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px } .log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
.log-list code { font-size: 11px; overflow-wrap: anywhere } .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 ---------------------------- */ /* ---- the installer: six screens on an otherwise empty server ---------------------------- */
+5 -11
View File
@@ -8,11 +8,10 @@ use App\Content\MenuRepository;
use App\Content\PageRepository; use App\Content\PageRepository;
use App\Content\SiteSettings; use App\Content\SiteSettings;
use App\Content\Turnstile; use App\Content\Turnstile;
use App\Ops\MailCheck;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -29,7 +28,7 @@ final class FormController extends AbstractController
private PageRepository $pages, private PageRepository $pages,
private MenuRepository $menus, private MenuRepository $menus,
private SiteSettings $settings, private SiteSettings $settings,
private MailerInterface $mailer, private MailCheck $mail,
private Turnstile $turnstile, private Turnstile $turnstile,
private string $secret, private string $secret,
private string $siteOrigin, private string $siteOrigin,
@@ -127,15 +126,10 @@ final class FormController extends AbstractController
if ('' !== $replyTo && '' !== ($values[$replyTo] ?? '')) { if ('' !== $replyTo && '' !== ($values[$replyTo] ?? '')) {
$email->replyTo($values[$replyTo]); $email->replyTo($values[$replyTo]);
} }
try {
$this->mailer->send($email);
return true; // The submission is already stored, so a broken mail setup loses no enquiry. It stays
} catch (TransportExceptionInterface) { // marked as undelivered, and var/log/mail.log says why (System -> E-Mail-Versand).
// The submission is already stored, so a broken mail setup loses no enquiry. return $this->mail->send($email, 'Formular '.$form['slug'])['ok'];
// It stays marked as undelivered and is visible in the administration.
return false;
}
} }
/** /**
+52 -1
View File
@@ -11,6 +11,7 @@ use App\Ops\Backup;
use App\Ops\Environment; use App\Ops\Environment;
use App\Ops\HttpsRedirect; use App\Ops\HttpsRedirect;
use App\Ops\Installation; use App\Ops\Installation;
use App\Ops\MailCheck;
use App\Ops\OriginCheck; use App\Ops\OriginCheck;
use App\Ops\SitePackage; use App\Ops\SitePackage;
use App\Ops\Updater; use App\Ops\Updater;
@@ -19,6 +20,8 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
/** /**
@@ -30,7 +33,7 @@ use Symfony\Component\Routing\Attribute\Route;
*/ */
final class SystemController extends AbstractController 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 erzwingen und HSTS (ADR 120): der Stand in public/.htaccess.
'https' => $this->https->state(), 'https' => $this->https->state(),
'secure' => $request->isSecure(), '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'); return $this->back($result['message'], $result['ok'] ? 'success' : 'error');
} }
/**
* Eine Testmail an eine frei eingetragene Adresse (ADR 121). Das Ergebnis - Meldung, Hinweis,
* Antworten des Servers - steht danach auf der Seite und in var/log/mail.log.
*/
#[Route('/%kant.admin_path%/system/testmail', name: 'admin_system_testmail', methods: ['POST'])]
public function testMail(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$an = trim($request->request->getString('to'));
$von = trim($request->request->getString('from')) ?: $an;
foreach ([$an, $von] as $adresse) {
if (false === filter_var($adresse, FILTER_VALIDATE_EMAIL)) {
return $this->back(sprintf('„%s“ ist keine gültige E-Mail-Adresse.', mb_substr($adresse, 0, 100)));
}
}
$website = rtrim($this->siteOrigin, '/') ?: $request->getSchemeAndHttpHost();
$email = (new Email())
->from(new Address($von))
->to(new Address($an))
->subject('Testmail von '.(parse_url($website, PHP_URL_HOST) ?: $website))
->text("Diese Testmail kommt aus der Verwaltung von {$website} (System → E-Mail-Versand).
Wenn sie angekommen ist, funktioniert der Versand der Formulare auf demselben Weg.
");
$request->getSession()->set('mail_result', ['an' => $an, 'von' => $von] + $this->mail->send($email, 'Testmail'));
return $this->redirect($this->generateUrl('admin_system').'#mail');
}
/** @return array<string, mixed> */
private function mailFacts(): array
{
$route = $this->mail->route();
return $route + [
'quelle' => $this->environmentSource('MAILER_DSN'),
// Wer sich mit einer Adresse anmeldet, versendet meist auch nur unter ihr.
'absender' => false !== filter_var($route['benutzer'], FILTER_VALIDATE_EMAIL) ? $route['benutzer'] : '',
'protokoll' => $this->mail->recent(),
];
}
/** Die Gegenstelle zur Prüfung oben: öffentlich, und sie verrät nichts als diese Kennung. */ /** 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)] #[Route(OriginCheck::PATH, name: 'site_origin_probe', methods: ['GET'], priority: 10)]
public function originProbe(): Response public function originProbe(): Response
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace App\Ops;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\Transport\Dsn;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Component\Mime\Email;
/**
* Versand mit Protokoll (ADR 121).
*
* huefner-gartenbau.de am 12.10.2026: zwei Anfragen „Nicht versendet“, und nirgends stand,
* warum. Der Formularversand hat die Ausnahme verschluckt; die Ursache - sendmail, das der Hoster
* so nicht anbietet, ein falsches SMTP-Passwort, ein Absender, den der Server nicht annimmt -
* blieb Raten. Jetzt geht jeder Versand durch diese Klasse: Formular und Testmail aus der
* Verwaltung. Sie schreibt eine Zeile nach var/log/mail.log (per FTP lesbar) mit Meldung, einem
* Hinweis auf Deutsch und den Antworten des Servers.
*
* Aus dem SMTP-Gespräch bleiben nur die Zeilen des Servers. Was der Client schickt, enthält bei
* AUTH LOGIN Benutzer und Passwort in Base64 - das gehört in kein Protokoll.
*/
final class MailCheck
{
/** Größer wird die Datei nicht; die ältere Hälfte wandert nach mail.log.1. */
private const GRENZE = 262144;
/**
* Der Transport erst beim ersten Versand: Eine ungültige MAILER_DSN soll den Versand scheitern
* lassen und im Protokoll stehen, nicht schon die Seite, die ihn anbietet.
*/
public function __construct(#[Autowire(service: 'mailer.transports', lazy: true)] private TransportInterface $transport, private string $dsn, private string $logFile)
{
}
/**
* 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}
*/
public function send(Email $email, string $anlass): array
{
$an = implode(', ', array_map(static fn ($adresse): string => $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<array<string, mixed>>
*/
public function recent(int $anzahl = 10): array
{
if (!is_file($this->logFile)) {
return [];
}
$zeilen = file($this->logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$eintraege = [];
foreach (array_reverse(array_slice($zeilen, -$anzahl)) as $zeile) {
$eintrag = json_decode($zeile, true);
if (is_array($eintrag)) {
$eintraege[] = $eintrag;
}
}
return $eintraege;
}
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);
}
}
+53
View File
@@ -127,6 +127,59 @@
</div> </div>
</div> </div>
{# E-Mail-Versand (ADR 121): Weg ohne Passwort, Testmail an eine frei gewählte Adresse und die
letzten Versuche mit Ursache - vorher stand bei „Nicht versendet“ nirgends, warum. #}
<div class="card" id="mail">
<div class="card-head"><h2>E-Mail-Versand</h2><span class="muted">var/log/mail.log</span></div>
<div class="card-body">
<dl class="facts">
<dt>Versandweg</dt>
<dd>
{% if mail.gueltig %}
<strong>{{ mail.art }}</strong>{% if mail.ziel %} <code>{{ mail.ziel }}</code>{% endif %}{% if mail.benutzer %} <span class="muted">· angemeldet als</span> <code>{{ mail.benutzer }}</code>{% endif %}
{% else %}
<span class="check-bad"><strong>MAILER_DSN ist ungültig.</strong></span>
{% endif %}
<br><span class="muted">Eingestellt in <code>{{ mail.quelle }}</code> als <code>MAILER_DSN</code>, zum Beispiel <code>smtp://info%40firma.de:passwort@smtp.ionos.de:587</code>. Das Passwort wird hier nie angezeigt.</span>
</dd>
</dl>
{% if mailResult %}
<div class="notice {{ mailResult.ok ? 'ok' : 'error' }} mail-result" role="{{ mailResult.ok ? 'status' : 'alert' }}">
<p><strong>{{ mailResult.ok ? 'Testmail an ' ~ mailResult.an ~ ' vom Mailserver angenommen.' : 'Testmail an ' ~ mailResult.an ~ ' nicht versendet.' }}</strong>{% if not mailResult.ok %} {{ mailResult.meldung }}{% endif %}</p>
{% if mailResult.hinweis %}<p>{{ mailResult.hinweis }}</p>{% endif %}
{% if mailResult.dialog %}<details><summary>Antworten des Mailservers</summary><pre>{{ mailResult.dialog }}</pre></details>{% endif %}
</div>
{% endif %}
<form class="origin-form" action="{{ path('admin_system_testmail') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<label for="mail-to">Testmail senden an</label>
<div class="origin-row">
<input id="mail-to" name="to" type="email" autocomplete="email" maxlength="200" required placeholder="name@beispiel.de" value="{{ mailResult.an ?? '' }}">
<button class="secondary" type="submit">Testmail senden</button>
</div>
<label for="mail-from">Absender</label>
<input id="mail-from" name="from" type="email" maxlength="200" placeholder="leer: dieselbe Adresse" value="{{ mailResult.von ?? mail.absender }}" aria-describedby="mail-from-help">
<p class="field-help" id="mail-from-help">Am besten der Absender, der auch bei den Formularen eingetragen ist. Viele Mailserver nehmen nur das Postfach an, mit dem angemeldet wird.</p>
</form>
<h3 class="mail-log-head">Letzte Versuche</h3>
{% if mail.protokoll is empty %}
<p class="muted">Noch nichts versendet – weder Testmail noch Formular.</p>
{% else %}
<ul class="log-list">
{% for e in mail.protokoll %}
<li class="{{ e.ok ? 'check-ok' : 'check-bad' }}">
<strong>{{ e.ok ? 'Angenommen' : 'Fehlgeschlagen' }}</strong> · {{ e.zeit }} UTC · {{ e.anlass }} an <code>{{ e.an }}</code>{% if e.von %} von <code>{{ e.von }}</code>{% endif %}
{% if not e.ok %}<br><code>{{ e.meldung }}</code>{% if e.hinweis %}<br><span class="muted">{{ e.hinweis }}</span>{% endif %}{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{# Die Vorprüfung gehört zum Updateweg über Git: Arbeitsverzeichnis sauber, Datenbank {# 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 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 Bedingungen, die dort keine sind - und zeigt lauter rote Ausrufezeichen für etwas, das
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace App\Tests;
use App\Ops\MailCheck;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\RawMessage;
/**
* Versand mit Protokoll (ADR 121): huefner-gartenbau.de zeigte zweimal „Nicht versendet“, und
* nirgends stand, warum.
*/
final class MailCheckTest extends TestCase
{
private string $log;
protected function setUp(): void
{
$this->log = sys_get_temp_dir().'/kant-mail-'.bin2hex(random_bytes(4)).'/mail.log';
}
protected function tearDown(): void
{
@unlink($this->log);
@rmdir(dirname($this->log));
}
public function testAFailedSendSaysWhyAndKeepsTheCredentialsOutOfTheLog(): void
{
$fehler = new TransportException('Failed to authenticate on SMTP server with username "formular@kant-it.de" using the following authenticators: "LOGIN". Authenticator "LOGIN" returned "Expected response code "235" but got code "535", with message "535 Authentication credentials invalid".');
$fehler->appendDebug("< 220 smtp.ionos.de ESMTP\n> EHLO cms.test\n< 250 AUTH LOGIN PLAIN\n> AUTH LOGIN\n< 334 VXNlcm5hbWU6\n> Zm9ybXVsYXJAa2FudC1pdC5kZQ==\n< 334 UGFzc3dvcmQ6\n> R2VoZWltMTIz\n< 535 Authentication credentials invalid\n");
$mail = new MailCheck($this->transport($fehler), 'smtp://formular%40kant-it.de:Geheim123@smtp.ionos.de:587', $this->log);
$ergebnis = $mail->send($this->email(), 'Formular anfrage');
self::assertFalse($ergebnis['ok']);
self::assertStringContainsString('535', $ergebnis['meldung']);
self::assertStringContainsString('lehnt die Anmeldung ab', $ergebnis['hinweis']);
self::assertStringContainsString('< 535 Authentication credentials invalid', $ergebnis['dialog']);
// Was der Client schickt, bleibt draußen: bei AUTH LOGIN sind das Benutzer und Passwort.
self::assertStringNotContainsString('R2VoZWltMTIz', $ergebnis['dialog']);
self::assertStringNotContainsString('> ', $ergebnis['dialog']);
$protokoll = (string) file_get_contents($this->log);
self::assertStringNotContainsString('R2VoZWltMTIz', $protokoll);
self::assertStringNotContainsString('Geheim123', $protokoll);
$eintrag = $mail->recent()[0];
self::assertFalse($eintrag['ok']);
self::assertSame('Formular anfrage', $eintrag['anlass']);
self::assertSame('info@huefner-gartenbau.de', $eintrag['an']);
self::assertSame('formular@kant-it.de', $eintrag['von']);
}
public function testTheRouteNamesServerAndUserButNeverThePassword(): void
{
$route = (new MailCheck($this->transport(null), 'smtp://formular%40kant-it.de:Geheim123@smtp.ionos.de:587', $this->log))->route();
self::assertSame(['art' => 'SMTP', 'ziel' => 'smtp.ionos.de:587', 'benutzer' => 'formular@kant-it.de', 'gueltig' => true], $route);
self::assertFalse((new MailCheck($this->transport(null), 'kein dsn', $this->log))->route()['gueltig']);
self::assertSame('sendmail 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://';
}
};
}
}
+37 -1
View File
@@ -47,7 +47,7 @@ final class SystemScreenTest extends WebTestCase
$this->path = tempnam(sys_get_temp_dir(), 'kant-system-'); $this->path = tempnam(sys_get_temp_dir(), 'kant-system-');
$url = 'sqlite:///'.str_replace('\\', '/', $this->path); $url = 'sqlite:///'.str_replace('\\', '/', $this->path);
$this->previous = [$_SERVER, $_ENV]; $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; $_SERVER[$key] = $_ENV[$key] = $value;
} }
$this->db = ConnectionFactory::create($url); $this->db = ConnectionFactory::create($url);
@@ -81,6 +81,7 @@ final class SystemScreenTest extends WebTestCase
$this->db->close(); $this->db->close();
gc_collect_cycles(); gc_collect_cycles();
@unlink($this->path); @unlink($this->path);
@unlink($this->path.'-mail.log');
foreach (glob($this->path.'-media/*') ?: [] as $file) { foreach (glob($this->path.'-media/*') ?: [] as $file) {
unlink($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.'); 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 <code>umut@cms.test</code>', $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 private function login(string $username = 'admin'): KernelBrowser
{ {
self::ensureKernelShutdown(); self::ensureKernelShutdown();