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 -bs 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')); } public function testSmtpSettingsBecomeADsnWithTheSecretsEncoded(): void { self::assertSame('smtp://formular%40kant-it.de:a%23b%2Fc%25d%24e@smtp.ionos.de:587', MailCheck::smtpDsn(' SMTP.ionos.de ', 587, 'formular@kant-it.de', 'a#b/c%d$e')); self::assertSame('smtps://u:p@mail.example.de:465', MailCheck::smtpDsn('mail.example.de', 465, 'u', 'p')); self::assertSame('smtp://localhost:25', MailCheck::smtpDsn('localhost', 25, '', '')); foreach ([['smtp.ionos.de/../x', 587], ['', 587], ['smtp.ionos.de', 0]] as [$server, $port]) { try { MailCheck::smtpDsn($server, $port, 'u', 'p'); self::fail($server.':'.$port.' hätte abgelehnt werden müssen.'); } catch (\InvalidArgumentException) { self::addToAssertionCount(1); } } } public function testTheCurrentSettingFillsTheFormButKeepsThePasswordToItself(): void { $mail = new MailCheck($this->transport(null), 'smtp://formular%40kant-it.de:Geheim123@smtp.ionos.de:587', $this->log); self::assertSame(['weg' => 'smtp', 'server' => 'smtp.ionos.de', 'port' => 587, 'benutzer' => 'formular@kant-it.de', 'passwort' => true], $mail->einstellung()); self::assertSame('Geheim123', $mail->gespeichertesPasswort('smtp.ionos.de', 'formular@kant-it.de')); // Anderer Server oder Benutzer: das gespeicherte Passwort gilt nicht mit. self::assertSame('', $mail->gespeichertesPasswort('smtp.office365.com', 'formular@kant-it.de')); self::assertSame('', $mail->gespeichertesPasswort('smtp.ionos.de', 'jemand@kant-it.de')); self::assertSame('native', (new MailCheck($this->transport(null), 'native://default', $this->log))->einstellung()['weg']); self::assertSame('sendmail-t', (new MailCheck($this->transport(null), MailCheck::serverWege()['sendmail-t']['dsn'], $this->log))->einstellung()['weg']); } public function testAWayCanBeTriedWithoutBeingTheSetOne(): void { // IONOS am 12.10.2026: sendmail -bs bricht ab, mail() geht. Ausprobiert wird über die // Fabrik, ohne die Einstellung zu ändern. $versucht = []; $fabrik = function (string $dsn) use (&$versucht): TransportInterface { $versucht[] = $dsn; return $this->transport(null); }; $eingestellt = $this->transport(new TransportException('Connection to "process /usr/sbin/sendmail -bs" has been closed unexpectedly.')); $mail = new MailCheck($eingestellt, 'sendmail://default', $this->log, $fabrik); $ergebnisse = []; foreach (MailCheck::serverWege() as $schluessel => $weg) { $ergebnisse[$schluessel] = $mail->send($this->email(), 'Testmail (alle Wege)', $weg['dsn']); } self::assertTrue($ergebnisse['native']['ok']); self::assertSame('PHP mail() des Servers', $ergebnisse['native']['weg']); self::assertSame('sendmail -t des Servers', $ergebnisse['sendmail-t']['weg']); self::assertFalse($ergebnisse['sendmail-bs']['ok']); self::assertStringContainsString('PHP mail()', $ergebnisse['sendmail-bs']['hinweis']); // Der eingestellte Weg (hier sendmail -bs) geht über den Transport des Frameworks, nicht die Fabrik. self::assertSame(['native://default', MailCheck::serverWege()['sendmail-t']['dsn']], $versucht); self::assertCount(3, $mail->recent()); } public function testAForeignSenderGetsItsOwnHint(): void { $meldung = 'Process failed with exit code 65: sendmail: envelope from address kantarci.u@gmail.com not accepted by the server sendmail: server message: 550-Sender address is not allowed.'; self::assertStringContainsString('eigenen Domain beim Hoster', MailCheck::hinweis($meldung, 'native')); } 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://'; } }; } }