fabrik = $fabrik ?? static fn (string $dsn): TransportInterface => Transport::fromDsn($dsn); } /** * Die Wege, die ein gewöhnliches Hosting ohne Zugangsdaten anbietet (ADR 122). Bei IONOS etwa * scheitert sendmail -bs, mail() geht. * * @return array */ public static function serverWege(): array { return [ 'native' => ['label' => 'PHP mail() des Servers', 'dsn' => 'native://default'], 'sendmail-t' => ['label' => 'sendmail -t des Servers', 'dsn' => 'sendmail://default?command=/usr/sbin/sendmail%20-t%20-i'], 'sendmail-bs' => ['label' => 'sendmail -bs des Servers', 'dsn' => 'sendmail://default'], ]; } /** smtp://… aus Einzelfeldern; Benutzer und Passwort URL-kodiert. Port 465 heißt smtps. */ public static function smtpDsn(string $server, int $port, string $benutzer, string $passwort): string { $server = trim($server); if (1 !== preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/iD', $server)) { throw new \InvalidArgumentException('Der SMTP-Server ist kein gültiger Rechnername, etwa smtp.ionos.de.'); } if ($port < 1 || $port > 65535) { throw new \InvalidArgumentException('Der Port muss zwischen 1 und 65535 liegen, meist 587.'); } $anmeldung = '' === $benutzer ? '' : rawurlencode($benutzer).('' === $passwort ? '' : ':'.rawurlencode($passwort)).'@'; return (465 === $port ? 'smtps' : 'smtp').'://'.$anmeldung.strtolower($server).':'.$port; } /** * Die aktuelle Einstellung zum Vorbelegen der Felder - ohne Passwort, nur ob es eins gibt. * * @return array{weg: string, server: string, port: int, benutzer: string, passwort: bool} */ public function einstellung(): array { $leer = ['weg' => 'smtp', 'server' => '', 'port' => 587, 'benutzer' => '', 'passwort' => false]; foreach (self::serverWege() as $schluessel => $weg) { if ($weg['dsn'] === $this->dsn) { return ['weg' => $schluessel] + $leer; } } try { $dsn = Dsn::fromString($this->dsn); } catch (\Throwable) { return $leer; } if (!in_array($dsn->getScheme(), ['smtp', 'smtps'], true)) { return ['weg' => 'native' === $dsn->getScheme() ? 'native' : ('sendmail' === $dsn->getScheme() ? 'sendmail-bs' : 'smtp')] + $leer; } return [ 'weg' => 'smtp', 'server' => $dsn->getHost(), 'port' => $dsn->getPort() ?? ('smtps' === $dsn->getScheme() ? 465 : 587), 'benutzer' => (string) $dsn->getUser(), 'passwort' => '' !== (string) $dsn->getPassword(), ]; } /** Das gespeicherte SMTP-Passwort, wenn Server und Benutzer gleich bleiben - sonst leer. */ public function gespeichertesPasswort(string $server, string $benutzer): string { try { $dsn = Dsn::fromString($this->dsn); } catch (\Throwable) { return ''; } if (!in_array($dsn->getScheme(), ['smtp', 'smtps'], true) || strtolower($dsn->getHost()) !== strtolower(trim($server)) || (string) $dsn->getUser() !== $benutzer) { return ''; } return (string) $dsn->getPassword(); } /** * Schickt die Nachricht und hält das Ergebnis fest. Wirft nie - ein kaputter Versand darf * keine Anfrage und keine Seite kosten. * * Mit $dsn geht der Versand über diesen Weg statt über den eingestellten - zum Ausprobieren * vor dem Speichern. * * @return array{ok: bool, meldung: string, hinweis: string, dialog: string, weg: string} */ public function send(Email $email, string $anlass, ?string $dsn = null): array { $an = implode(', ', array_map(static fn ($adresse): string => $adresse->getAddress(), $email->getTo())); $scheme = self::schemeVon($dsn ?? $this->dsn); try { // Derselbe Weg wie eingestellt: der Transport des Frameworks, samt seinen Ereignissen. $transport = null === $dsn || $dsn === $this->dsn ? $this->transport : ($this->fabrik)($dsn); $gesendet = $transport->send($email); $ergebnis = [ 'ok' => true, 'meldung' => 'Vom Mailserver angenommen.', 'hinweis' => self::hinweisNachErfolg($scheme), 'dialog' => self::dialog((string) $gesendet?->getDebug()), ]; } catch (TransportExceptionInterface $fehler) { $ergebnis = [ 'ok' => false, 'meldung' => self::einzeilig($fehler->getMessage()), 'hinweis' => self::hinweis($fehler->getMessage(), $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(), $scheme), 'dialog' => '', ]; } $weg = $this->route($dsn); $ergebnis['weg'] = trim($weg['art'].' '.$weg['ziel']); $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(?string $roh = null): array { try { $dsn = Dsn::fromString($roh ?? $this->dsn); } catch (\Throwable) { return ['art' => 'ungültig', 'ziel' => '', 'benutzer' => '', 'gueltig' => false]; } $art = match ($dsn->getScheme()) { 'smtp', 'smtps' => 'SMTP', 'sendmail', 'sendmail+smtp' => str_contains((string) $dsn->getOption('command'), ' -t') ? 'sendmail -t des Servers' : 'sendmail -bs 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> */ 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; } /** Protokoll leeren: die Datei und ihre ältere Hälfte. */ public function clearLog(): void { foreach ([$this->logFile, $this->logFile.'.1'] as $datei) { if (is_file($datei)) { @unlink($datei); } } } 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')) => 'Die Einstellung des Versands ist ungültig. Unter System → E-Mail-Versand neu setzen.', 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($m, 'sender address is not allowed') || str_contains($m, 'envelope from address') => 'Der Server lehnt den Absender ab. Beim Versand über den Server (mail(), sendmail) muss der Absender zu einer eigenen Domain beim Hoster gehören, nicht etwa Gmail; bei SMTP ist es meist genau das angemeldete Postfach.', str_contains($scheme, 'sendmail') => 'sendmail ist auf diesem Server so nicht nutzbar. Zuerst „PHP mail() des Servers“ probieren, dauerhaft besser SMTP mit einem echten Postfach.', 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 static function schemeVon(string $roh): string { try { return Dsn::fromString($roh)->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); } }