Compare commits

...
3 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5.5 6b1293cdb8 Release 1.35.0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:07:27 +02:00
kantarciandClaude Opus 5.5 b982cb8944 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>
2026-10-12 21:07:27 +02:00
kantarciandClaude Opus 5.5 2497b8cb69 Note the 1.34.0 release in the status
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:30:39 +02:00
18 changed files with 602 additions and 151 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.34.0
1.35.0
-4
View File
@@ -3,7 +3,3 @@ framework:
# Tests never send real mail; the transport collects messages in memory instead.
dsn: 'null://null'
parameters:
# Das Versandprotokoll der Tests gehört nicht nach var/log der echten Installation.
kant.mail_log: '%kernel.cache_dir%/mail.log'
kant.mailer_dsn: 'null://null'
+16 -1
View File
@@ -12,6 +12,8 @@ parameters:
kant.asset_dir: '%kernel.project_dir%/public/assets'
kant.mail_log: '%kernel.project_dir%/var/log/mail.log'
kant.mailer_dsn: 'sendmail://default'
# Die Einstellung, die die Verwaltung anzeigt und ausprobiert; Tests setzen sie fest.
kant.mail_dsn: '%env(default:kant.mailer_dsn:MAILER_DSN)%'
services:
_defaults:
@@ -65,7 +67,7 @@ services:
# schreiben.
App\Ops\MailCheck:
arguments:
$dsn: '%env(default:kant.mailer_dsn:MAILER_DSN)%'
$dsn: '%kant.mail_dsn%'
$logFile: '%env(default:kant.mail_log:MAIL_LOG)%'
App\Controller\FormController:
arguments:
@@ -81,6 +83,10 @@ services:
App\Command\HealthCommand:
arguments:
$projectDir: '%kernel.project_dir%'
App\Controller\MailController:
arguments:
$projectDir: '%kernel.project_dir%'
$siteOrigin: '%env(SITE_ORIGIN)%'
App\Controller\SystemController:
arguments:
$adminPath: '%kant.admin_path%'
@@ -174,3 +180,12 @@ services:
App\Twig\PluginExtension:
arguments:
$loader: '@twig.loader.native_filesystem'
# Tests: das Versandprotokoll gehoert nicht nach var/log der echten Installation, und die
# Verwaltung soll einen festen SMTP-Weg als eingestellt sehen. Verschickt wird trotzdem ueber
# null://null (config/packages/test/mailer.yaml), weil derselbe Weg den Transport des
# Frameworks nimmt. Hier und nicht in config/packages/test, weil diese Datei danach geladen wird.
when@test:
parameters:
kant.mail_log: '%kernel.cache_dir%/mail.log'
kant.mail_dsn: 'smtp://cms.test:587'
+10
View File
@@ -2,6 +2,16 @@
Stand: 12.10.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 122 – E-Mail-Versand als eigene Seite, Probe nicht mehr unter /.well-known (12.10.2026)
Anlass: huefner-gartenbau.de bei IONOS. Mit 1.34.0 zeigte die Testmail, dass `sendmail -bs` dort abbricht und `native://default` (PHP `mail()`) geht, solange der Absender zu einer Domain des Vertrags gehört. Umut: „Können wir die Einstellungsmethoden ins Backend verlagern? Dass man dort alle Methoden probiert und auch SMTP setzt?“ – und „bau es in ein Untermenü ein, nicht System vollstopfen“. Gleichzeitig scheiterte „HTTPS erzwingen“ mit „antwortet mit 403“, obwohl HTTPS lief.
**E-Mail-Versand.** Eigene Seite `/admin/system/e-mail` (`MailController`), im Menü als Unterpunkt von „System“; die Karte auf der Systemseite ist weg. Wege: SMTP mit Postfach (Server, Port, Benutzer, Passwort), PHP `mail()`, `sendmail -t`, `sendmail -bs`. „Diesen Weg testen“ schickt eine Testmail über den gewählten Weg, ohne ihn zu speichern; „Alle Wege testen“ probiert alle Wege des Servers und – wenn eingetragen – SMTP nacheinander; „Diesen Weg speichern“ schreibt `MAILER_DSN` über `Environment::write` in `.env` (nur wenn sie dort steht, nicht aus der Server-Umgebung). `MailCheck::smtpDsn` baut die DSN mit URL-kodiertem Benutzer und Passwort, Port 465 wird `smtps`. Das Passwort steht nie im Formular: leer gelassen gilt das gespeicherte, solange Server und Benutzer gleich bleiben. Derselbe Weg wie eingestellt nutzt den Transport des Frameworks, ein anderer wird mit `Transport::fromDsn` gebaut. Hinweise neu: fremder Absender bei Server-Versand („Sender address is not allowed“) und bei sendmail-Fehlern zuerst `mail()`.
**Probe.** `OriginCheck::PATH` ist jetzt `/kantsites-origin` statt `/.well-known/kantsites-origin`. IONOS beantwortet alles unter `/.well-known/` selbst mit 403 (nachgemessen mit und ohne www, http und https); die Anfrage erreicht die Installation nie, deshalb konnten „Adresse speichern“ und „HTTPS erzwingen“ dort nicht gelingen. Die Ausnahme für `.well-known` in `public/.htaccess` bleibt für Zertifikatsanbieter.
**Folgen.** Die Tests setzen die angezeigte Einstellung über `when@test` in `config/services.yaml` (`kant.mail_dsn`, `kant.mail_log`); in `config/packages/test/` wurden die Parameter von `services.yaml` überschrieben, so dass die Tests von 1.34.0 in das lokale `var/log/mail.log` schrieben (nicht Teil eines Releases). Keine Migration.
## ADR 121 – E-Mail-Versand mit Protokoll und Testmail (12.10.2026)
Anlass: Auf huefner-gartenbau.de (IONOS) standen zwei Anfragen als „Nicht versendet“ in der Verwaltung, und nirgends stand, warum: `FormController` fing die `TransportException` und verwarf sie. Umut: „Bau mal in den Backend einen Knopf ein, Testmail an Mailadresse, wo man einträgt, und wo Logs angezeigt werden, woran es liegt.“
+14 -2
View File
@@ -1,6 +1,16 @@
# Projektstatus
## E-Mail-Versand: Testmail und Protokoll in der Verwaltung (12.10.2026)
## E-Mail-Versand als eigene Seite, HTTPS-Probe bei IONOS (12.10.2026)
**Anlass:** huefner-gartenbau.de: mit `native://default` geht der Versand, Umut will die Einstellung samt SMTP in der Verwaltung, als Unterpunkt statt auf „System“. „HTTPS erzwingen“ meldete 403, obwohl HTTPS lief.
**Erledigt (ADR 122):** Seite `/admin/system/e-mail` unter „System“: Weg wählen (SMTP, mail(), sendmail -t, sendmail -bs), testen ohne Speichern, alle Wege nacheinander testen, speichern nach `.env`; Passwort nie im Formular. Karte von der Systemseite entfernt. Probe der Adresse von `/.well-known/kantsites-origin` nach `/kantsites-origin`, weil IONOS `/.well-known/` selbst mit 403 beantwortet. Test-Parameter über `when@test`.
**Geprüft:** `MailCheckTest` (DSN aus Feldern samt Sonderzeichen, Vorbelegung ohne Passwort, Wege ausprobieren über die Fabrik, Hinweis bei fremdem Absender), `SystemScreenTest` (Menüpunkt, Seite, Testen, fehlendes Passwort, Speichern nur aus `.env`, nur Administratoren), `HttpsRedirectTest`. PHPUnit vollständig 682 grün (die bekannte Warnung aus `UpdaterTest`), php-cs-fixer sauber; `var/log/mail.log` entsteht durch Tests nicht mehr. Im Browser auf der Wegwerfkopie: „Alle Wege testen“ zeigt je Weg Ergebnis und Hinweis, SMTP-Felder verschwinden bei anderen Wegen, Handybreite ohne waagrechtes Scrollen, Unterpunkt im Menü markiert. Live auf IONOS ist die neue Probe noch nicht geprüft; dass `/.well-known/` dort 403 liefert und `/index.php/...` durchkommt, ist nachgemessen.
**Offen:** Nicht committet, kein Release.
## Fassung 1.34.0: Testmail und Versandprotokoll in der Verwaltung (12.10.2026)
**Anlass:** huefner-gartenbau.de: Anfragen „Nicht versendet“, Ursache unbekannt (Ausnahme wurde verschluckt; vermutlich `sendmail://default` bei IONOS).
@@ -8,7 +18,9 @@
**Geprüft:** `MailCheckTest` (Hinweise, Zugangsdaten bleiben draußen, Versandweg ohne Passwort), `SystemScreenTest` (Testmail, ungültige Adresse, Token, nur Administratoren); PHPUnit vollständig 678 grün (eine bekannte Warnung aus `UpdaterTest`, unabhängig davon), php-cs-fixer sauber. Im Browser auf einer Wegwerfkopie der Hüfner-Datenbank mit unerreichbarem SMTP-Server: Fehlermeldung, Hinweis „nicht erreichbar“ und Eintrag unter „Letzte Versuche“ erscheinen. Ein echter Versand über einen echten Mailserver wurde nicht ausgeführt.
**Offen:** Nicht committet, kein Release. Hüfner: `MAILER_DSN` auf SMTP umstellen (zentrales Absenderpostfach bei kant-it.de geplant), dann Testmail.
**Veröffentlicht:** Tag `v1.34.0`, Release-Eintrag mit Archiv (3632 Dateien, 5,3 MB, gebaut aus `var/release-source-1.34.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
**Offen:** Hüfner auf 1.34.0 aktualisieren, `MAILER_DSN` auf SMTP umstellen (zentrales Absenderpostfach bei kant-it.de geplant), dann Testmail.
## Fassung 1.33.0: HTTPS erzwingen aus der Verwaltung (12.10.2026)
+4 -4
View File
@@ -78,10 +78,10 @@ RewriteRule ^ index.php [L]
# Nichts ausliefern, was nur intern gebraucht wird - falls jemand das Paket versehentlich
# vollstaendig in den oeffentlichen Ordner legt statt nur public/.
# .well-known ausgenommen: Unter /.well-known/kantsites-origin prueft die Verwaltung, ob die
# Website unter einer Adresse hier ankommt (Adresse speichern, HTTPS erzwingen), und Apache
# prueft den ersten nicht vorhandenen Pfadteil gegen diese Regel - vorher antwortete die
# Probe mit 403. Zertifikatsanbieter (acme-challenge) brauchen den Ordner ebenso.
# .well-known ausgenommen: Zertifikatsanbieter (acme-challenge) brauchen den Ordner, und Apache
# prueft den ersten nicht vorhandenen Pfadteil gegen diese Regel. Die Probe der Verwaltung
# (Adresse speichern, HTTPS erzwingen) liegt nicht mehr dort, sondern unter /kantsites-origin:
# IONOS beantwortet /.well-known/ selbst mit 403 (ADR 122).
<FilesMatch "^\.(?!well-known$)|\.(env|db|sqlite|lock|md|yaml|yml|dist|log)$">
Require all denied
</FilesMatch>
+15 -1
View File
@@ -719,10 +719,24 @@ details.card > summary:hover { background: #fafbf8 }
.log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
.log-list code { font-size: 11px; overflow-wrap: anywhere }
.mail-result { margin-top: 14px }
#mail .facts { margin-top: 0 }
.mail-result p { margin: 0 0 6px }
.mail-result p:last-child { margin-bottom: 0 }
.mail-result pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11px; margin: 6px 0 0 }
.mail-log-head { font-size: 14px; margin: 22px 0 0 }
/* Unterpunkte im Menü: eingerückt auf die Höhe des Textes, ohne Symbol. */
.nav-sub { list-style: none; margin: 2px 0 4px; padding: 0 }
.nav-sub .nav-item { padding-left: 42px; font-size: 13px }
.mail-form { margin-top: 18px; max-width: 640px }
.mail-form fieldset { border: 0; margin: 0 0 16px; padding: 0; min-width: 0 }
.mail-form legend { font-weight: 600; font-size: 13px; margin-bottom: 6px; padding: 0 }
.mail-form .check-label { display: flex; gap: 8px; align-items: baseline; margin: 4px 0 }
.mail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 8px 12px }
.mail-grid label { margin-top: 0 }
.mail-grid input { width: 100%; box-sizing: border-box }
.mail-actions { display: flex; flex-wrap: wrap; gap: 8px }
.mail-actions button { margin: 0 }
/* Ohne SMTP brauchen die Felder niemand; wo :has() fehlt, bleiben sie einfach sichtbar. */
.mail-form:has(input[name=weg]:checked:not([value=smtp])) .smtp-fields { display: none }
/* ---- the installer: six screens on an otherwise empty server ---------------------------- */
+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';
}
}
+1 -52
View File
@@ -11,7 +11,6 @@ use App\Ops\Backup;
use App\Ops\Environment;
use App\Ops\HttpsRedirect;
use App\Ops\Installation;
use App\Ops\MailCheck;
use App\Ops\OriginCheck;
use App\Ops\SitePackage;
use App\Ops\Updater;
@@ -20,8 +19,6 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Attribute\Route;
/**
@@ -33,7 +30,7 @@ use Symfony\Component\Routing\Attribute\Route;
*/
final class SystemController extends AbstractController
{
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck, private HttpKernelInterface $kernel, private PageRepository $pages, private SiteSettings $settings, private SitePackage $packages, private HttpsRedirect $https, private MailCheck $mail)
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck, private HttpKernelInterface $kernel, private PageRepository $pages, private SiteSettings $settings, private SitePackage $packages, private HttpsRedirect $https)
{
}
@@ -74,9 +71,6 @@ final class SystemController extends AbstractController
// HTTPS erzwingen und HSTS (ADR 120): der Stand in public/.htaccess.
'https' => $this->https->state(),
'secure' => $request->isSecure(),
// E-Mail-Versand (ADR 121): Weg ohne Passwort, Testmail, die letzten Versuche.
'mail' => $this->mailFacts(),
'mailResult' => $request->getSession()->remove('mail_result'),
]);
}
@@ -526,51 +520,6 @@ final class SystemController extends AbstractController
return $this->back($result['message'], $result['ok'] ? 'success' : 'error');
}
/**
* Eine Testmail an eine frei eingetragene Adresse (ADR 121). Das Ergebnis - Meldung, Hinweis,
* Antworten des Servers - steht danach auf der Seite und in var/log/mail.log.
*/
#[Route('/%kant.admin_path%/system/testmail', name: 'admin_system_testmail', methods: ['POST'])]
public function testMail(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$an = trim($request->request->getString('to'));
$von = trim($request->request->getString('from')) ?: $an;
foreach ([$an, $von] as $adresse) {
if (false === filter_var($adresse, FILTER_VALIDATE_EMAIL)) {
return $this->back(sprintf('„%s“ ist keine gültige E-Mail-Adresse.', mb_substr($adresse, 0, 100)));
}
}
$website = rtrim($this->siteOrigin, '/') ?: $request->getSchemeAndHttpHost();
$email = (new Email())
->from(new Address($von))
->to(new Address($an))
->subject('Testmail von '.(parse_url($website, PHP_URL_HOST) ?: $website))
->text("Diese Testmail kommt aus der Verwaltung von {$website} (System → E-Mail-Versand).
Wenn sie angekommen ist, funktioniert der Versand der Formulare auf demselben Weg.
");
$request->getSession()->set('mail_result', ['an' => $an, 'von' => $von] + $this->mail->send($email, 'Testmail'));
return $this->redirect($this->generateUrl('admin_system').'#mail');
}
/** @return array<string, mixed> */
private function mailFacts(): array
{
$route = $this->mail->route();
return $route + [
'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(),
];
}
/** Die Gegenstelle zur Prüfung oben: öffentlich, und sie verrät nichts als diese Kennung. */
#[Route(OriginCheck::PATH, name: 'site_origin_probe', methods: ['GET'], priority: 10)]
public function originProbe(): Response
@@ -35,7 +35,7 @@ final class MaintenanceSubscriber implements EventSubscriberInterface
return;
}
$path = $event->getRequest()->getPathInfo();
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon|\.well-known/kantsites-origin)#', $path)) {
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon|kantsites-origin$)#', $path)) {
return;
}
$since = @file_get_contents($this->projectDir.'/'.Updater::MAINTENANCE_FILE);
+104 -14
View File
@@ -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 '';
}
+5 -1
View File
@@ -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;
+132
View File
@@ -0,0 +1,132 @@
{% extends 'admin/shell.html.twig' %}
{% set nav = 'mail' %}
{% block title %}E-Mail-Versand{% endblock %}
{% block heading %}E-Mail-Versand{% endblock %}
{% block lead %}Wie Formulare ihre Mails verschicken – ausprobieren, speichern und sehen, woran es hakt.{% endblock %}
{% block content %}
{# E-Mail-Versand (ADR 121, 122): Weg wählen und ausprobieren, alle Wege des Servers testen,
speichern - und die letzten Versuche mit Ursache. Das SMTP-Passwort steht nie im Formular. #}
{% set f = mailForm ?? {} %}
{% set e = mail.einstellung %}
{% set weg = f.weg ?? e.weg %}
<div class="card" id="mail">
<div class="card-head"><h2>Versandweg</h2><span class="muted">gilt für alle Formulare</span></div>
<div class="card-body">
<dl class="facts">
<dt>Eingestellt</dt>
<dd>
{% if mail.gueltig %}
<strong>{{ mail.art }}</strong>{% if mail.ziel %} <code>{{ mail.ziel }}</code>{% endif %}{% if mail.benutzer %} <span class="muted">· angemeldet als</span> <code>{{ mail.benutzer }}</code>{% endif %}
{% else %}
<span class="check-bad"><strong>Die Einstellung ist ungültig.</strong></span>
{% endif %}
<br><span class="muted">Gespeichert in <code>{{ mail.quelle }}</code> als <code>MAILER_DSN</code>.{% if mail.quelle != '.env' %} Sie hat dort Vorrang; speichern lässt sich hier nichts, testen schon.{% endif %}</span>
</dd>
</dl>
{% if mailNotice %}
<p class="notice {{ mailNotice.art == 'ok' ? 'ok' : 'error' }} mail-result" role="{{ mailNotice.art == 'ok' ? 'status' : 'alert' }}">{{ mailNotice.text }}</p>
{% endif %}
{% if mailResult %}
<div class="notice {{ mailResult.ok ? 'ok' : 'error' }} mail-result" role="{{ mailResult.ok ? 'status' : 'alert' }}">
<p><strong>{{ mailResult.ok ? 'Testmail an ' ~ mailResult.an ~ ' vom Mailserver angenommen' : 'Testmail an ' ~ mailResult.an ~ ' nicht versendet' }}</strong>{% if mailResult.weg is defined %} <span>({{ mailResult.weg }})</span>{% endif %}.{% if not mailResult.ok %} {{ mailResult.meldung }}{% endif %}</p>
{% if mailResult.hinweis %}<p>{{ mailResult.hinweis }}</p>{% endif %}
{% if mailResult.dialog %}<details><summary>Antworten des Mailservers</summary><pre>{{ mailResult.dialog }}</pre></details>{% endif %}
</div>
{% endif %}
{% if mailResults %}
<div class="mail-result">
<p><strong>Alle Wege ausprobiert</strong> <span class="muted">– an {{ (f.an ?? '') }}. Was angenommen wurde, kann gespeichert werden; angekommen ist es damit noch nicht, bitte im Postfach nachsehen.</span></p>
<ul class="log-list">
{% for r in mailResults %}
<li class="{{ r.ok ? 'check-ok' : 'check-bad' }}">
<strong>{{ r.ok ? 'Angenommen' : 'Fehlgeschlagen' }}</strong> · {{ r.weg }}
{% if not r.ok %}<br><code>{{ r.meldung }}</code>{% if r.hinweis %}<br><span class="muted">{{ r.hinweis }}</span>{% endif %}{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<form class="mail-form" action="{{ path('admin_mail_save') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<fieldset>
<legend>Versandweg</legend>
<label class="check-label" for="weg-smtp"><input id="weg-smtp" type="radio" name="weg" value="smtp"{% if weg == 'smtp' %} checked{% endif %}> <span>SMTP mit Postfach <span class="muted">– empfohlen, unabhängig vom Hoster</span></span></label>
{% for key, w in mail.wege %}
<label class="check-label" for="weg-{{ key }}"><input id="weg-{{ key }}" type="radio" name="weg" value="{{ key }}"{% if weg == key %} checked{% endif %}> {{ w.label }}</label>
{% endfor %}
</fieldset>
<fieldset class="smtp-fields">
<legend>SMTP</legend>
<div class="mail-grid">
<div>
<label for="mail-server">Server</label>
<input id="mail-server" name="server" type="text" inputmode="url" autocomplete="off" spellcheck="false" maxlength="200" placeholder="smtp.ionos.de" value="{{ f.server ?? e.server }}">
</div>
<div>
<label for="mail-port">Port</label>
<input id="mail-port" name="port" type="number" min="1" max="65535" value="{{ f.port ?? e.port }}" aria-describedby="mail-port-help">
</div>
<div>
<label for="mail-user">Benutzer</label>
<input id="mail-user" name="benutzer" type="text" autocomplete="off" spellcheck="false" maxlength="200" placeholder="formular@meine-firma.de" value="{{ f.benutzer ?? e.benutzer }}">
</div>
<div>
<label for="mail-pass">Passwort</label>
<input id="mail-pass" name="passwort" type="password" autocomplete="new-password" maxlength="500" placeholder="{{ e.passwort ? 'gespeichert – leer lassen zum Behalten' : '' }}">
</div>
</div>
<p class="field-help" id="mail-port-help">587 mit STARTTLS ist üblich (IONOS, Microsoft 365: <code>smtp.office365.com</code>), 465 verschlüsselt von Anfang an. Das Passwort wird nie angezeigt und landet nur in <code>.env</code>.</p>
</fieldset>
<fieldset>
<legend>Testmail</legend>
<div class="mail-grid">
<div>
<label for="mail-to">An</label>
<input id="mail-to" name="to" type="email" autocomplete="email" maxlength="200" placeholder="name@beispiel.de" value="{{ f.an ?? (mailResult.an ?? '') }}">
</div>
<div>
<label for="mail-from">Absender</label>
<input id="mail-from" name="from" type="email" maxlength="200" placeholder="leer: dieselbe Adresse" value="{{ f.von ?? mail.absender }}" aria-describedby="mail-from-help">
</div>
</div>
<p class="field-help" id="mail-from-help">Derselbe Absender wie bei den Formularen. Über den Server (mail(), sendmail) muss er zu einer eigenen Domain beim Hoster gehören, bei SMTP ist es meist das angemeldete Postfach.</p>
</fieldset>
<div class="mail-actions">
<button class="secondary" type="submit" name="aktion" value="test">Diesen Weg testen</button>
<button class="secondary" type="submit" name="aktion" value="alle">Alle Wege testen</button>
{% if mail.quelle == '.env' %}<button type="submit" name="aktion" value="speichern">Diesen Weg speichern</button>{% endif %}
</div>
<p class="field-help">„Alle Wege testen“ schickt je Weg eine Testmail – bis zu vier. Gespeichert wird {{ mail.quelle == '.env' ? 'nur mit „Diesen Weg speichern“' : 'hier nichts, weil MAILER_DSN in der Server-Umgebung steht' }}.</p>
</form>
</div>
</div>
<div class="card">
<div class="card-head"><h2>Letzte Versuche</h2><span class="muted">var/log/mail.log</span></div>
<div class="card-body">
{% if mail.protokoll is empty %}
<p class="muted">Noch nichts versendet – weder Testmail noch Formular.</p>
{% else %}
<ul class="log-list">
{% for eintrag in mail.protokoll %}
<li class="{{ eintrag.ok ? 'check-ok' : 'check-bad' }}">
<strong>{{ eintrag.ok ? 'Angenommen' : 'Fehlgeschlagen' }}</strong> · {{ eintrag.zeit }} UTC · {{ eintrag.anlass }} an <code>{{ eintrag.an }}</code>{% if eintrag.von %} von <code>{{ eintrag.von }}</code>{% endif %}{% if eintrag.weg is defined and eintrag.weg %} · {{ eintrag.weg }}{% endif %}
{% if not eintrag.ok %}<br><code>{{ eintrag.meldung }}</code>{% if eintrag.hinweis %}<br><span class="muted">{{ eintrag.hinweis }}</span>{% endif %}{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
<p class="footnote">KantSites · Arbeitsstand</p>
{% endblock %}
+6 -1
View File
@@ -46,7 +46,12 @@
<ul class="nav-list" aria-labelledby="nav-site">
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'theme'} only %}Design</a></li>{% endif %}
{% if is_granted('ROLE_PUBLISHER') %}<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'settings'} only %}Einstellungen</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'system'} only %}System</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'system'} only %}System</a>
{# Unterpunkte von System: eigene Seiten, damit „System“ nicht alles trägt. #}
<ul class="nav-sub">
<li><a class="nav-item" href="{{ path('admin_mail') }}"{{ nav|default('') == 'mail' ? ' aria-current="page"' : '' }}>E-Mail-Versand</a></li>
</ul>
</li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_tasks') }}"{{ nav|default('') == 'tasks' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'tasks'} only %}Aufgaben</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_plugins') }}"{{ nav|default('') == 'plugins' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'plugins'} only %}Plugins</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'users'} only %}Benutzer</a></li>{% endif %}
-53
View File
@@ -127,59 +127,6 @@
</div>
</div>
{# E-Mail-Versand (ADR 121): Weg ohne Passwort, Testmail an eine frei gewählte Adresse und die
letzten Versuche mit Ursache - vorher stand bei „Nicht versendet“ nirgends, warum. #}
<div class="card" id="mail">
<div class="card-head"><h2>E-Mail-Versand</h2><span class="muted">var/log/mail.log</span></div>
<div class="card-body">
<dl class="facts">
<dt>Versandweg</dt>
<dd>
{% if mail.gueltig %}
<strong>{{ mail.art }}</strong>{% if mail.ziel %} <code>{{ mail.ziel }}</code>{% endif %}{% if mail.benutzer %} <span class="muted">· angemeldet als</span> <code>{{ mail.benutzer }}</code>{% endif %}
{% else %}
<span class="check-bad"><strong>MAILER_DSN ist ungültig.</strong></span>
{% endif %}
<br><span class="muted">Eingestellt in <code>{{ mail.quelle }}</code> als <code>MAILER_DSN</code>, zum Beispiel <code>smtp://info%40firma.de:passwort@smtp.ionos.de:587</code>. Das Passwort wird hier nie angezeigt.</span>
</dd>
</dl>
{% if mailResult %}
<div class="notice {{ mailResult.ok ? 'ok' : 'error' }} mail-result" role="{{ mailResult.ok ? 'status' : 'alert' }}">
<p><strong>{{ mailResult.ok ? 'Testmail an ' ~ mailResult.an ~ ' vom Mailserver angenommen.' : 'Testmail an ' ~ mailResult.an ~ ' nicht versendet.' }}</strong>{% if not mailResult.ok %} {{ mailResult.meldung }}{% endif %}</p>
{% if mailResult.hinweis %}<p>{{ mailResult.hinweis }}</p>{% endif %}
{% if mailResult.dialog %}<details><summary>Antworten des Mailservers</summary><pre>{{ mailResult.dialog }}</pre></details>{% endif %}
</div>
{% endif %}
<form class="origin-form" action="{{ path('admin_system_testmail') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<label for="mail-to">Testmail senden an</label>
<div class="origin-row">
<input id="mail-to" name="to" type="email" autocomplete="email" maxlength="200" required placeholder="name@beispiel.de" value="{{ mailResult.an ?? '' }}">
<button class="secondary" type="submit">Testmail senden</button>
</div>
<label for="mail-from">Absender</label>
<input id="mail-from" name="from" type="email" maxlength="200" placeholder="leer: dieselbe Adresse" value="{{ mailResult.von ?? mail.absender }}" aria-describedby="mail-from-help">
<p class="field-help" id="mail-from-help">Am besten der Absender, der auch bei den Formularen eingetragen ist. Viele Mailserver nehmen nur das Postfach an, mit dem angemeldet wird.</p>
</form>
<h3 class="mail-log-head">Letzte Versuche</h3>
{% if mail.protokoll is empty %}
<p class="muted">Noch nichts versendet – weder Testmail noch Formular.</p>
{% else %}
<ul class="log-list">
{% for e in mail.protokoll %}
<li class="{{ e.ok ? 'check-ok' : 'check-bad' }}">
<strong>{{ e.ok ? 'Angenommen' : 'Fehlgeschlagen' }}</strong> · {{ e.zeit }} UTC · {{ e.anlass }} an <code>{{ e.an }}</code>{% if e.von %} von <code>{{ e.von }}</code>{% endif %}
{% if not e.ok %}<br><code>{{ e.meldung }}</code>{% if e.hinweis %}<br><span class="muted">{{ e.hinweis }}</span>{% endif %}{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{# Die Vorprüfung gehört zum Updateweg über Git: Arbeitsverzeichnis sauber, Datenbank
außerhalb von Git, Tag erreichbar. Auf einer Installation aus dem Releasearchiv prüft sie
Bedingungen, die dort keine sind - und zeigt lauter rote Ausrufezeichen für etwas, das
+1 -1
View File
@@ -18,7 +18,7 @@ final class HttpsRedirectTest extends TestCase
/** @var list<?string> */
private array $answers = [];
/** @var array{status: int, location: string, error: string} */
private array $http = ['status' => 301, 'location' => 'https://www.example.de/.well-known/kantsites-origin', 'error' => ''];
private array $http = ['status' => 301, 'location' => 'https://www.example.de/kantsites-origin', 'error' => ''];
protected function setUp(): void
{
+63 -1
View File
@@ -62,7 +62,7 @@ final class MailCheckTest extends TestCase
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 des Servers', (new MailCheck($this->transport(null), 'sendmail://default', $this->log))->route()['art']);
self::assertSame('sendmail -bs des Servers', (new MailCheck($this->transport(null), 'sendmail://default', $this->log))->route()['art']);
}
public function testAnAcceptedMailIsLoggedWithWhatToCheckIfNothingArrives(): void
@@ -84,6 +84,68 @@ final class MailCheckTest extends TestCase
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');
+41 -14
View File
@@ -201,7 +201,7 @@ final class SystemScreenTest extends WebTestCase
self::assertStringNotContainsString('kein-server', (string) file_get_contents(dirname(__DIR__).'/.env'));
// Die Gegenstelle der Prüfung: öffentlich, ohne Anmeldung, und nur die Kennung.
$client->request('GET', '/.well-known/kantsites-origin');
$client->request('GET', '/kantsites-origin');
self::assertSame(200, $client->getResponse()->getStatusCode());
self::assertSame(hash_hmac('sha256', 'site-origin', 'isolated-test-secret'), $client->getResponse()->getContent());
}
@@ -360,38 +360,65 @@ final class SystemScreenTest extends WebTestCase
self::assertStringContainsString('Migrationen stehen aus', (string) $admin->getResponse()->getContent(), 'Und es steht immer noch aus.');
}
public function testATestMailGoesToAnyAddressAndTheScreenSaysWhatHappened(): void
public function testTheMailPageTestsAWayAndSaysWhatHappened(): void
{
// Umut: „Bau mal in den Backend einen Knopf ein, Testmail an Mailadresse, wo man einträgt,
// und wo Logs angezeigt werden, woran es liegt“ (ADR 121).
// und wo Logs angezeigt werden, woran es liegt“ (ADR 121) - und dann: alle Wege probieren,
// SMTP dort setzen, auf einer eigenen Seite unter „System“ (ADR 122).
// MAILER_DSN gilt hier als Server-Umgebung: so schreibt kein Test in die echte .env.
$_SERVER['SYMFONY_DOTENV_VARS'] = '';
$client = $this->login();
$client->request('GET', '/admin/system');
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('E-Mail-Versand', $html);
self::assertStringContainsString('Noch nichts versendet', $html);
$token = (string) $client->getCrawler()->filter('form[action$="/system/testmail"] input[name=_csrf_token]')->attr('value');
self::assertStringContainsString('href="/admin/system/e-mail"', $html, 'Ein Unterpunkt von System im Menü.');
self::assertStringNotContainsString('Alle Wege testen', $html, 'Die Systemseite trägt den Versand nicht mehr selbst.');
$client->request('POST', '/admin/system/testmail', ['_csrf_token' => $token, 'to' => 'kein-postfach']);
$client->request('GET', '/admin/system/e-mail');
self::assertSame(200, $client->getResponse()->getStatusCode());
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('Alle Wege testen', $html);
self::assertStringContainsString('value="cms.test"', $html, 'Der eingestellte SMTP-Server steht im Feld.');
self::assertStringContainsString('Noch nichts versendet', $html);
// Im Test steht MAILER_DSN nicht in .env: testen ja, speichern nein.
self::assertStringNotContainsString('value="speichern"', $html);
$token = (string) $client->getCrawler()->filter('form[action$="/system/e-mail"] input[name=_csrf_token]')->attr('value');
$client->request('POST', '/admin/system/e-mail', ['_csrf_token' => $token, 'aktion' => 'test', 'weg' => 'smtp', 'server' => 'cms.test', 'port' => '587', 'to' => 'kein-postfach']);
$client->followRedirect();
self::assertStringContainsString('keine gültige E-Mail-Adresse', (string) $client->getResponse()->getContent());
$client->request('POST', '/admin/system/testmail', ['_csrf_token' => $token, 'to' => 'umut@cms.test', 'from' => 'formular@cms.test']);
self::assertStringEndsWith('/admin/system#mail', (string) $client->getResponse()->headers->get('Location'));
// Derselbe Weg wie eingestellt nimmt den Transport des Frameworks - im Test null://null.
$client->request('POST', '/admin/system/e-mail', ['_csrf_token' => $token, 'aktion' => 'test', 'weg' => 'smtp', 'server' => 'cms.test', 'port' => '587', 'to' => 'umut@cms.test', 'from' => 'formular@cms.test']);
self::assertStringEndsWith('/admin/system/e-mail', (string) $client->getResponse()->headers->get('Location'));
self::assertEmailCount(1);
$mail = self::getMailerMessage();
self::assertEmailAddressContains($mail, 'To', 'umut@cms.test');
self::assertEmailAddressContains($mail, 'From', 'formular@cms.test');
$client->request('GET', '/admin/system');
$client->request('GET', '/admin/system/e-mail');
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('Testmail an umut@cms.test vom Mailserver angenommen.', $html);
self::assertStringContainsString('Testmail an <code>umut@cms.test</code>', $html, 'Der Versuch steht danach in „Letzte Versuche“.');
self::assertStringContainsString('Testmail an umut@cms.test vom Mailserver angenommen', $html);
self::assertStringContainsString('Testmail an <code>umut@cms.test</code>', $html, 'Der Versuch steht danach unter „Letzte Versuche“.');
self::assertStringContainsString('value="umut@cms.test"', $html, 'Die Eingabe bleibt stehen.');
// SMTP mit Benutzer, aber ohne Passwort und ohne gespeichertes: abgelehnt, bevor etwas rausgeht.
$client->request('POST', '/admin/system/e-mail', ['_csrf_token' => $token, 'aktion' => 'test', 'weg' => 'smtp', 'server' => 'smtp.anderer.test', 'port' => '587', 'benutzer' => 'x@anderer.test', 'to' => 'umut@cms.test']);
self::assertEmailCount(0);
$client->followRedirect();
self::assertStringContainsString('das Passwort des Postfachs eintragen', (string) $client->getResponse()->getContent());
// Speichern geht nur, wenn die Einstellung in .env steht.
$client->request('POST', '/admin/system/e-mail', ['_csrf_token' => $token, 'aktion' => 'speichern', 'weg' => 'native']);
$client->followRedirect();
self::assertStringContainsString('in der Server-Umgebung gesetzt', (string) $client->getResponse()->getContent());
// Nur für Administratoren, und nur mit Token.
$client->request('POST', '/admin/system/testmail', ['_csrf_token' => 'erfunden', 'to' => 'umut@cms.test']);
$client->request('POST', '/admin/system/e-mail', ['_csrf_token' => 'erfunden', 'aktion' => 'test', 'weg' => 'smtp', 'server' => 'cms.test', 'to' => 'umut@cms.test']);
self::assertEmailCount(0);
$redaktion = $this->login('editor');
$redaktion->request('POST', '/admin/system/testmail', ['_csrf_token' => $token, 'to' => 'umut@cms.test']);
$redaktion->request('GET', '/admin/system/e-mail');
self::assertSame(403, $redaktion->getResponse()->getStatusCode());
$redaktion->request('POST', '/admin/system/e-mail', ['_csrf_token' => $token, 'aktion' => 'test', 'weg' => 'smtp', 'server' => 'cms.test', 'to' => 'umut@cms.test']);
self::assertSame(403, $redaktion->getResponse()->getStatusCode());
}