Give mail delivery its own admin page and move the origin probe off /.well-known
The mail page under System tries SMTP, PHP mail() and both sendmail modes one by one or all at once, and saves the chosen way to .env without ever showing the password. IONOS answers everything under /.well-known/ itself with 403, so the address and HTTPS checks never reached the installation; the probe now lives at /kantsites-origin (ADR 122). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2497b8cb69
commit
b982cb8944
17 files changed
+597
-148
No files matched your search
+104
-14
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -30,32 +31,118 @@ final class MailCheck
|
||||
* 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)
|
||||
/** 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.
|
||||
*
|
||||
* @return array{ok: bool, meldung: string, hinweis: string, dialog: string}
|
||||
* 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): array
|
||||
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 {
|
||||
$gesendet = $this->transport->send($email);
|
||||
// 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($this->scheme()),
|
||||
'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(), $this->scheme()),
|
||||
'hinweis' => self::hinweis($fehler->getMessage(), $scheme),
|
||||
'dialog' => self::dialog($fehler->getDebug()),
|
||||
];
|
||||
} catch (\Throwable $fehler) {
|
||||
@@ -63,10 +150,12 @@ final class MailCheck
|
||||
$ergebnis = [
|
||||
'ok' => false,
|
||||
'meldung' => self::einzeilig($fehler->getMessage()),
|
||||
'hinweis' => self::hinweis($fehler->getMessage(), $this->scheme()),
|
||||
'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;
|
||||
@@ -77,16 +166,16 @@ final class MailCheck
|
||||
*
|
||||
* @return array{art: string, ziel: string, benutzer: string, gueltig: bool}
|
||||
*/
|
||||
public function route(): array
|
||||
public function route(?string $roh = null): array
|
||||
{
|
||||
try {
|
||||
$dsn = Dsn::fromString($this->dsn);
|
||||
$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' => 'sendmail des Servers',
|
||||
'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(),
|
||||
@@ -135,11 +224,12 @@ final class MailCheck
|
||||
|
||||
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, '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($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.',
|
||||
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 => '',
|
||||
};
|
||||
@@ -171,10 +261,10 @@ final class MailCheck
|
||||
return implode("\n", array_slice($zeilen, -12));
|
||||
}
|
||||
|
||||
private function scheme(): string
|
||||
private static function schemeVon(string $roh): string
|
||||
{
|
||||
try {
|
||||
return Dsn::fromString($this->dsn)->getScheme();
|
||||
return Dsn::fromString($roh)->getScheme();
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ namespace App\Ops;
|
||||
*/
|
||||
class OriginCheck
|
||||
{
|
||||
public const PATH = '/.well-known/kantsites-origin';
|
||||
/**
|
||||
* Bewusst nicht unter /.well-known/: IONOS beantwortet alles darunter selbst mit 403, bevor
|
||||
* die Anfrage die Installation erreicht (huefner-gartenbau.de, 12.10.2026, ADR 122).
|
||||
*/
|
||||
public const PATH = '/kantsites-origin';
|
||||
|
||||
private Download $download;
|
||||
|
||||
|
||||
Reference in new issue
Block a user