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
@@ -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-');
|
||||
$url = 'sqlite:///'.str_replace('\\', '/', $this->path);
|
||||
$this->previous = [$_SERVER, $_ENV];
|
||||
foreach (['APP_SECRET' => 'isolated-test-secret', 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
|
||||
foreach (['APP_SECRET' => 'isolated-test-secret', 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'MAIL_LOG' => $this->path.'-mail.log'] as $key => $value) {
|
||||
$_SERVER[$key] = $_ENV[$key] = $value;
|
||||
}
|
||||
$this->db = ConnectionFactory::create($url);
|
||||
@@ -81,6 +81,7 @@ final class SystemScreenTest extends WebTestCase
|
||||
$this->db->close();
|
||||
gc_collect_cycles();
|
||||
@unlink($this->path);
|
||||
@unlink($this->path.'-mail.log');
|
||||
foreach (glob($this->path.'-media/*') ?: [] as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
@@ -359,6 +360,41 @@ final class SystemScreenTest extends WebTestCase
|
||||
self::assertStringContainsString('Migrationen stehen aus', (string) $admin->getResponse()->getContent(), 'Und es steht immer noch aus.');
|
||||
}
|
||||
|
||||
public function testATestMailGoesToAnyAddressAndTheScreenSaysWhatHappened(): void
|
||||
{
|
||||
// Umut: „Bau mal in den Backend einen Knopf ein, Testmail an Mailadresse, wo man einträgt,
|
||||
// und wo Logs angezeigt werden, woran es liegt“ (ADR 121).
|
||||
$client = $this->login();
|
||||
$client->request('GET', '/admin/system');
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('E-Mail-Versand', $html);
|
||||
self::assertStringContainsString('Noch nichts versendet', $html);
|
||||
$token = (string) $client->getCrawler()->filter('form[action$="/system/testmail"] input[name=_csrf_token]')->attr('value');
|
||||
|
||||
$client->request('POST', '/admin/system/testmail', ['_csrf_token' => $token, 'to' => 'kein-postfach']);
|
||||
$client->followRedirect();
|
||||
self::assertStringContainsString('keine gültige E-Mail-Adresse', (string) $client->getResponse()->getContent());
|
||||
|
||||
$client->request('POST', '/admin/system/testmail', ['_csrf_token' => $token, 'to' => 'umut@cms.test', 'from' => 'formular@cms.test']);
|
||||
self::assertStringEndsWith('/admin/system#mail', (string) $client->getResponse()->headers->get('Location'));
|
||||
self::assertEmailCount(1);
|
||||
$mail = self::getMailerMessage();
|
||||
self::assertEmailAddressContains($mail, 'To', 'umut@cms.test');
|
||||
self::assertEmailAddressContains($mail, 'From', 'formular@cms.test');
|
||||
|
||||
$client->request('GET', '/admin/system');
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('Testmail an umut@cms.test vom Mailserver angenommen.', $html);
|
||||
self::assertStringContainsString('Testmail an <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
|
||||
{
|
||||
self::ensureKernelShutdown();
|
||||
|
||||
Reference in new issue
Block a user