Text in admin cards had no size of its own, so 16px browser default sat next to 13px notes; cards now use 13px throughout while fields stay at 16px for iOS. The mail page gets option cards, a 2x2 SMTP grid, a styled save button in the card foot and a log with status pills and collapsed server messages, plus "Protokoll leeren". The package card uses the same sections and a styled file picker. The E-Mail-Versand subitem only shows inside System and sits beside it in the horizontal strip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
300 lines
14 KiB
PHP
300 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Ops;
|
|
|
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
|
use Symfony\Component\Mailer\Transport;
|
|
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.
|
|
*/
|
|
/** Baut einen Transport aus einer DSN, die (noch) nicht eingestellt ist - zum Ausprobieren. */
|
|
private \Closure $fabrik;
|
|
|
|
public function __construct(#[Autowire(service: 'mailer.transports', lazy: true)] private TransportInterface $transport, private string $dsn, private string $logFile, ?\Closure $fabrik = null)
|
|
{
|
|
$this->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<string, array{label: string, dsn: string}>
|
|
*/
|
|
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<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;
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
}
|