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:
kantarciandClaude Opus 5.5 committed 2026-10-12 21:07:27 +02:00
1 parent 2497b8cb69
commit b982cb8944
17 files changed
+597 -148

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
<?php
namespace App\Controller;
use App\Ops\Environment;
use App\Ops\MailCheck;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Attribute\Route;
/**
* E-Mail-Versand (ADR 121, 122): eigene Seite unter „System“, damit die Systemseite nicht alles
* trägt. Weg wählen, ausprobieren, alle Wege des Servers testen, speichern - und die letzten
* Versuche mit Ursache.
*/
final class MailController extends AbstractController
{
public function __construct(private MailCheck $mail, private string $projectDir, private string $siteOrigin)
{
}
#[Route('/%kant.admin_path%/system/e-mail', name: 'admin_mail', methods: ['GET'])]
public function index(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$session = $request->getSession();
return $this->render('admin/mail.html.twig', [
'mail' => $this->mailFacts(),
'mailResult' => $session->remove('mail_result'),
'mailResults' => $session->remove('mail_results'),
'mailNotice' => $session->remove('mail_notice'),
// Die letzte Eingabe bleibt stehen, ohne Passwort.
'mailForm' => $session->remove('mail_form'),
]);
}
/**
* Versand einstellen und ausprobieren (ADR 121, 122): ein Weg testen, alle Wege des Servers
* nacheinander testen, oder den gewählten Weg als MAILER_DSN in .env speichern.
*
* Das SMTP-Passwort steht nie im Formular. Bleibt das Feld leer, gilt das gespeicherte, solange
* Server und Benutzer gleich bleiben.
*/
#[Route('/%kant.admin_path%/system/e-mail', name: 'admin_mail_save', methods: ['POST'])]
public function save(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$session = $request->getSession();
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
return $this->backToMail($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$eingabe = [
'weg' => $request->request->getString('weg'),
'server' => trim($request->request->getString('server')),
'port' => $request->request->getInt('port', 587),
'benutzer' => trim($request->request->getString('benutzer')),
'an' => trim($request->request->getString('to')),
'von' => trim($request->request->getString('from')),
];
$session->set('mail_form', $eingabe);
$aktion = $request->request->getString('aktion');
if ('alle' === $aktion) {
if (null !== $fehler = $this->mailAdressFehler($eingabe)) {
return $this->backToMail($request, $fehler);
}
$wege = array_map(static fn (array $weg): string => $weg['dsn'], MailCheck::serverWege());
if ('' !== $eingabe['server']) {
try {
$wege['smtp'] = $this->smtpDsn($request, $eingabe);
} catch (\InvalidArgumentException) {
// Unvollständiges SMTP bleibt bei „alle“ einfach draußen.
}
}
$ergebnisse = [];
foreach ($wege as $schluessel => $dsn) {
$ergebnisse[] = ['schluessel' => $schluessel] + $this->mail->send($this->testEmail($request, $eingabe), 'Testmail (alle Wege)', $dsn);
}
$session->set('mail_results', $ergebnisse);
return $this->backToMail($request);
}
try {
$dsn = isset(MailCheck::serverWege()[$eingabe['weg']]) ? MailCheck::serverWege()[$eingabe['weg']]['dsn'] : $this->smtpDsn($request, $eingabe);
} catch (\InvalidArgumentException $fehler) {
return $this->backToMail($request, $fehler->getMessage());
}
if ('speichern' === $aktion) {
if ('.env' !== $this->environmentSource('MAILER_DSN')) {
return $this->backToMail($request, 'MAILER_DSN ist in der Server-Umgebung gesetzt und hat dort Vorrang. Ändern lässt sie sich nur beim Hoster.');
}
try {
Environment::write($this->projectDir, ['MAILER_DSN' => $dsn]);
} catch (\RuntimeException $fehler) {
return $this->backToMail($request, $fehler->getMessage());
}
$weg = $this->mail->route($dsn);
return $this->backToMail($request, sprintf('Gespeichert: %s%s. Formulare versenden ab jetzt auf diesem Weg.', $weg['art'], '' !== $weg['ziel'] ? ' über '.$weg['ziel'] : ''), 'ok');
}
if (null !== $fehler = $this->mailAdressFehler($eingabe)) {
return $this->backToMail($request, $fehler);
}
$session->set('mail_result', ['an' => $eingabe['an']] + $this->mail->send($this->testEmail($request, $eingabe), 'Testmail', $dsn));
return $this->backToMail($request);
}
/** @param array{an: string, von: string} $eingabe */
private function mailAdressFehler(array $eingabe): ?string
{
foreach ([$eingabe['an'], $eingabe['von'] ?: $eingabe['an']] as $adresse) {
if (false === filter_var($adresse, FILTER_VALIDATE_EMAIL)) {
return '' === $adresse ? 'Für eine Testmail bitte eine Empfängeradresse eintragen.' : sprintf('„%s“ ist keine gültige E-Mail-Adresse.', mb_substr($adresse, 0, 100));
}
}
return null;
}
/** @param array{server: string, port: int, benutzer: string} $eingabe */
private function smtpDsn(Request $request, array $eingabe): string
{
if ('' === $eingabe['server']) {
throw new \InvalidArgumentException('Für SMTP bitte den Server eintragen, etwa smtp.ionos.de.');
}
$passwort = $request->request->getString('passwort');
if ('' === $passwort) {
$passwort = $this->mail->gespeichertesPasswort($eingabe['server'], $eingabe['benutzer']);
}
if ('' !== $eingabe['benutzer'] && '' === $passwort) {
throw new \InvalidArgumentException('Für SMTP bitte das Passwort des Postfachs eintragen.');
}
return MailCheck::smtpDsn($eingabe['server'], $eingabe['port'], $eingabe['benutzer'], $passwort);
}
/** @param array{an: string, von: string} $eingabe */
private function testEmail(Request $request, array $eingabe): Email
{
$website = rtrim($this->siteOrigin, '/') ?: $request->getSchemeAndHttpHost();
return (new Email())
->from(new Address($eingabe['von'] ?: $eingabe['an']))
->to(new Address($eingabe['an']))
->subject('Testmail von '.(parse_url($website, PHP_URL_HOST) ?: $website))
->text("Diese Testmail kommt aus der Verwaltung von {$website} (System → E-Mail-Versand).\n\nWenn sie angekommen ist, funktioniert der Versand der Formulare auf diesem Weg.\n");
}
private function backToMail(Request $request, ?string $meldung = null, string $art = 'error'): Response
{
if (null !== $meldung) {
$request->getSession()->set('mail_notice', ['art' => $art, 'text' => $meldung]);
}
return $this->redirect($this->generateUrl('admin_mail'));
}
/** @return array<string, mixed> */
private function mailFacts(): array
{
$route = $this->mail->route();
return $route + [
'einstellung' => $this->mail->einstellung(),
'wege' => MailCheck::serverWege(),
'quelle' => $this->environmentSource('MAILER_DSN'),
// Wer sich mit einer Adresse anmeldet, versendet meist auch nur unter ihr.
'absender' => false !== filter_var($route['benutzer'], FILTER_VALIDATE_EMAIL) ? $route['benutzer'] : '',
'protokoll' => $this->mail->recent(),
];
}
/** Explicit server values take precedence over the single installation file. */
private function environmentSource(string $name): string
{
$loaded = explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? '');
return in_array($name, $loaded, true) ? '.env' : 'Server-Umgebung';
}
}