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
@@ -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'
|
||||||
@@ -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%'
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ---------------------------- */
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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://';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user