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:
1 parent
090436233d
commit
d313156c73
11 files changed
+501
-13
No files matched your search
@@ -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'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, mixed> */
|
||||
private function mailFacts(): array
|
||||
{
|
||||
$route = $this->mail->route();
|
||||
|
||||
return $route + [
|
||||
'quelle' => $this->environmentSource('MAILER_DSN'),
|
||||
// Wer sich mit einer Adresse anmeldet, versendet meist auch nur unter ihr.
|
||||
'absender' => false !== filter_var($route['benutzer'], FILTER_VALIDATE_EMAIL) ? $route['benutzer'] : '',
|
||||
'protokoll' => $this->mail->recent(),
|
||||
];
|
||||
}
|
||||
|
||||
/** Die Gegenstelle zur Prüfung oben: öffentlich, und sie verrät nichts als diese Kennung. */
|
||||
#[Route(OriginCheck::PATH, name: 'site_origin_probe', methods: ['GET'], priority: 10)]
|
||||
public function originProbe(): Response
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user