Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b1293cdb8 | ||
|
|
b982cb8944 |
No files matched your search
@@ -3,7 +3,3 @@ framework:
|
|||||||
# Tests never send real mail; the transport collects messages in memory instead.
|
# Tests never send real mail; the transport collects messages in memory instead.
|
||||||
dsn: 'null://null'
|
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
@@ -12,6 +12,8 @@ parameters:
|
|||||||
kant.asset_dir: '%kernel.project_dir%/public/assets'
|
kant.asset_dir: '%kernel.project_dir%/public/assets'
|
||||||
kant.mail_log: '%kernel.project_dir%/var/log/mail.log'
|
kant.mail_log: '%kernel.project_dir%/var/log/mail.log'
|
||||||
kant.mailer_dsn: 'sendmail://default'
|
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:
|
services:
|
||||||
_defaults:
|
_defaults:
|
||||||
@@ -65,7 +67,7 @@ services:
|
|||||||
# schreiben.
|
# schreiben.
|
||||||
App\Ops\MailCheck:
|
App\Ops\MailCheck:
|
||||||
arguments:
|
arguments:
|
||||||
$dsn: '%env(default:kant.mailer_dsn:MAILER_DSN)%'
|
$dsn: '%kant.mail_dsn%'
|
||||||
$logFile: '%env(default:kant.mail_log:MAIL_LOG)%'
|
$logFile: '%env(default:kant.mail_log:MAIL_LOG)%'
|
||||||
App\Controller\FormController:
|
App\Controller\FormController:
|
||||||
arguments:
|
arguments:
|
||||||
@@ -81,6 +83,10 @@ services:
|
|||||||
App\Command\HealthCommand:
|
App\Command\HealthCommand:
|
||||||
arguments:
|
arguments:
|
||||||
$projectDir: '%kernel.project_dir%'
|
$projectDir: '%kernel.project_dir%'
|
||||||
|
App\Controller\MailController:
|
||||||
|
arguments:
|
||||||
|
$projectDir: '%kernel.project_dir%'
|
||||||
|
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||||
App\Controller\SystemController:
|
App\Controller\SystemController:
|
||||||
arguments:
|
arguments:
|
||||||
$adminPath: '%kant.admin_path%'
|
$adminPath: '%kant.admin_path%'
|
||||||
@@ -174,3 +180,12 @@ services:
|
|||||||
App\Twig\PluginExtension:
|
App\Twig\PluginExtension:
|
||||||
arguments:
|
arguments:
|
||||||
$loader: '@twig.loader.native_filesystem'
|
$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'
|
||||||
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
Stand: 12.10.2026. Arbeitstitel KantSites; Produktname offen.
|
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)
|
## 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.“
|
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.“
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Projektstatus
|
# Projektstatus
|
||||||
|
|
||||||
|
## 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)
|
## 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).
|
**Anlass:** huefner-gartenbau.de: Anfragen „Nicht versendet“, Ursache unbekannt (Ausnahme wurde verschluckt; vermutlich `sendmail://default` bei IONOS).
|
||||||
|
|||||||
+4
-4
@@ -78,10 +78,10 @@ RewriteRule ^ index.php [L]
|
|||||||
|
|
||||||
# Nichts ausliefern, was nur intern gebraucht wird - falls jemand das Paket versehentlich
|
# Nichts ausliefern, was nur intern gebraucht wird - falls jemand das Paket versehentlich
|
||||||
# vollstaendig in den oeffentlichen Ordner legt statt nur public/.
|
# vollstaendig in den oeffentlichen Ordner legt statt nur public/.
|
||||||
# .well-known ausgenommen: Unter /.well-known/kantsites-origin prueft die Verwaltung, ob die
|
# .well-known ausgenommen: Zertifikatsanbieter (acme-challenge) brauchen den Ordner, und Apache
|
||||||
# Website unter einer Adresse hier ankommt (Adresse speichern, HTTPS erzwingen), und Apache
|
# prueft den ersten nicht vorhandenen Pfadteil gegen diese Regel. Die Probe der Verwaltung
|
||||||
# prueft den ersten nicht vorhandenen Pfadteil gegen diese Regel - vorher antwortete die
|
# (Adresse speichern, HTTPS erzwingen) liegt nicht mehr dort, sondern unter /kantsites-origin:
|
||||||
# Probe mit 403. Zertifikatsanbieter (acme-challenge) brauchen den Ordner ebenso.
|
# IONOS beantwortet /.well-known/ selbst mit 403 (ADR 122).
|
||||||
<FilesMatch "^\.(?!well-known$)|\.(env|db|sqlite|lock|md|yaml|yml|dist|log)$">
|
<FilesMatch "^\.(?!well-known$)|\.(env|db|sqlite|lock|md|yaml|yml|dist|log)$">
|
||||||
Require all denied
|
Require all denied
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|||||||
+15
-1
@@ -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 { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
|
||||||
.log-list code { font-size: 11px; overflow-wrap: anywhere }
|
.log-list code { font-size: 11px; overflow-wrap: anywhere }
|
||||||
.mail-result { margin-top: 14px }
|
.mail-result { margin-top: 14px }
|
||||||
|
#mail .facts { margin-top: 0 }
|
||||||
.mail-result p { margin: 0 0 6px }
|
.mail-result p { margin: 0 0 6px }
|
||||||
.mail-result p:last-child { margin-bottom: 0 }
|
.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-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 ---------------------------- */
|
/* ---- the installer: six screens on an otherwise empty server ---------------------------- */
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ use App\Ops\Backup;
|
|||||||
use App\Ops\Environment;
|
use App\Ops\Environment;
|
||||||
use App\Ops\HttpsRedirect;
|
use App\Ops\HttpsRedirect;
|
||||||
use App\Ops\Installation;
|
use App\Ops\Installation;
|
||||||
use App\Ops\MailCheck;
|
|
||||||
use App\Ops\OriginCheck;
|
use App\Ops\OriginCheck;
|
||||||
use App\Ops\SitePackage;
|
use App\Ops\SitePackage;
|
||||||
use App\Ops\Updater;
|
use App\Ops\Updater;
|
||||||
@@ -20,8 +19,6 @@ use Symfony\Component\HttpFoundation\JsonResponse;
|
|||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||||
use Symfony\Component\Mime\Address;
|
|
||||||
use Symfony\Component\Mime\Email;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,7 +30,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||||||
*/
|
*/
|
||||||
final class SystemController extends AbstractController
|
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 erzwingen und HSTS (ADR 120): der Stand in public/.htaccess.
|
||||||
'https' => $this->https->state(),
|
'https' => $this->https->state(),
|
||||||
'secure' => $request->isSecure(),
|
'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');
|
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. */
|
/** 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)]
|
#[Route(OriginCheck::PATH, name: 'site_origin_probe', methods: ['GET'], priority: 10)]
|
||||||
public function originProbe(): Response
|
public function originProbe(): Response
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ final class MaintenanceSubscriber implements EventSubscriberInterface
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$path = $event->getRequest()->getPathInfo();
|
$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;
|
return;
|
||||||
}
|
}
|
||||||
$since = @file_get_contents($this->projectDir.'/'.Updater::MAINTENANCE_FILE);
|
$since = @file_get_contents($this->projectDir.'/'.Updater::MAINTENANCE_FILE);
|
||||||
|
|||||||
+104
-14
@@ -4,6 +4,7 @@ namespace App\Ops;
|
|||||||
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||||
|
use Symfony\Component\Mailer\Transport;
|
||||||
use Symfony\Component\Mailer\Transport\Dsn;
|
use Symfony\Component\Mailer\Transport\Dsn;
|
||||||
use Symfony\Component\Mailer\Transport\TransportInterface;
|
use Symfony\Component\Mailer\Transport\TransportInterface;
|
||||||
use Symfony\Component\Mime\Email;
|
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
|
* 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.
|
* 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
|
* Schickt die Nachricht und hält das Ergebnis fest. Wirft nie - ein kaputter Versand darf
|
||||||
* keine Anfrage und keine Seite kosten.
|
* 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()));
|
$an = implode(', ', array_map(static fn ($adresse): string => $adresse->getAddress(), $email->getTo()));
|
||||||
|
$scheme = self::schemeVon($dsn ?? $this->dsn);
|
||||||
try {
|
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 = [
|
$ergebnis = [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'meldung' => 'Vom Mailserver angenommen.',
|
'meldung' => 'Vom Mailserver angenommen.',
|
||||||
'hinweis' => self::hinweisNachErfolg($this->scheme()),
|
'hinweis' => self::hinweisNachErfolg($scheme),
|
||||||
'dialog' => self::dialog((string) $gesendet?->getDebug()),
|
'dialog' => self::dialog((string) $gesendet?->getDebug()),
|
||||||
];
|
];
|
||||||
} catch (TransportExceptionInterface $fehler) {
|
} catch (TransportExceptionInterface $fehler) {
|
||||||
$ergebnis = [
|
$ergebnis = [
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'meldung' => self::einzeilig($fehler->getMessage()),
|
'meldung' => self::einzeilig($fehler->getMessage()),
|
||||||
'hinweis' => self::hinweis($fehler->getMessage(), $this->scheme()),
|
'hinweis' => self::hinweis($fehler->getMessage(), $scheme),
|
||||||
'dialog' => self::dialog($fehler->getDebug()),
|
'dialog' => self::dialog($fehler->getDebug()),
|
||||||
];
|
];
|
||||||
} catch (\Throwable $fehler) {
|
} catch (\Throwable $fehler) {
|
||||||
@@ -63,10 +150,12 @@ final class MailCheck
|
|||||||
$ergebnis = [
|
$ergebnis = [
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'meldung' => self::einzeilig($fehler->getMessage()),
|
'meldung' => self::einzeilig($fehler->getMessage()),
|
||||||
'hinweis' => self::hinweis($fehler->getMessage(), $this->scheme()),
|
'hinweis' => self::hinweis($fehler->getMessage(), $scheme),
|
||||||
'dialog' => '',
|
'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);
|
$this->protokollieren(['zeit' => gmdate('Y-m-d H:i:s'), 'anlass' => $anlass, 'an' => $an, 'von' => $email->getFrom()[0]?->getAddress() ?? ''] + $ergebnis);
|
||||||
|
|
||||||
return $ergebnis;
|
return $ergebnis;
|
||||||
@@ -77,16 +166,16 @@ final class MailCheck
|
|||||||
*
|
*
|
||||||
* @return array{art: string, ziel: string, benutzer: string, gueltig: bool}
|
* @return array{art: string, ziel: string, benutzer: string, gueltig: bool}
|
||||||
*/
|
*/
|
||||||
public function route(): array
|
public function route(?string $roh = null): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$dsn = Dsn::fromString($this->dsn);
|
$dsn = Dsn::fromString($roh ?? $this->dsn);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
return ['art' => 'ungültig', 'ziel' => '', 'benutzer' => '', 'gueltig' => false];
|
return ['art' => 'ungültig', 'ziel' => '', 'benutzer' => '', 'gueltig' => false];
|
||||||
}
|
}
|
||||||
$art = match ($dsn->getScheme()) {
|
$art = match ($dsn->getScheme()) {
|
||||||
'smtp', 'smtps' => 'SMTP',
|
'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',
|
'native' => 'PHP mail() des Servers',
|
||||||
'null' => 'kein Versand (null)',
|
'null' => 'kein Versand (null)',
|
||||||
default => $dsn->getScheme(),
|
default => $dsn->getScheme(),
|
||||||
@@ -135,11 +224,12 @@ final class MailCheck
|
|||||||
|
|
||||||
return match (true) {
|
return match (true) {
|
||||||
'null' === $scheme => 'MAILER_DSN steht auf null://null - es wird gar nichts verschickt.',
|
'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, '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, '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, '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.',
|
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 => '',
|
default => '',
|
||||||
};
|
};
|
||||||
@@ -171,10 +261,10 @@ final class MailCheck
|
|||||||
return implode("\n", array_slice($zeilen, -12));
|
return implode("\n", array_slice($zeilen, -12));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function scheme(): string
|
private static function schemeVon(string $roh): string
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
return Dsn::fromString($this->dsn)->getScheme();
|
return Dsn::fromString($roh)->getScheme();
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ namespace App\Ops;
|
|||||||
*/
|
*/
|
||||||
class OriginCheck
|
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;
|
private Download $download;
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -46,7 +46,12 @@
|
|||||||
<ul class="nav-list" aria-labelledby="nav-site">
|
<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_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_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_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_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 %}
|
{% 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 %}
|
||||||
|
|||||||
@@ -127,59 +127,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
{# 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
|
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
|
Bedingungen, die dort keine sind - und zeigt lauter rote Ausrufezeichen für etwas, das
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ final class HttpsRedirectTest extends TestCase
|
|||||||
/** @var list<?string> */
|
/** @var list<?string> */
|
||||||
private array $answers = [];
|
private array $answers = [];
|
||||||
/** @var array{status: int, location: string, error: string} */
|
/** @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
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|||||||
+63
-1
@@ -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::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::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
|
public function testAnAcceptedMailIsLoggedWithWhatToCheckIfNothingArrives(): void
|
||||||
@@ -84,6 +84,68 @@ final class MailCheckTest extends TestCase
|
|||||||
self::assertStringContainsString('null://null', MailCheck::hinweisNachErfolg('null'));
|
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
|
private function email(): Email
|
||||||
{
|
{
|
||||||
return (new Email())->from('formular@kant-it.de')->to('info@huefner-gartenbau.de')->subject('Anfrage')->text('Test');
|
return (new Email())->from('formular@kant-it.de')->to('info@huefner-gartenbau.de')->subject('Anfrage')->text('Test');
|
||||||
|
|||||||
+41
-14
@@ -201,7 +201,7 @@ final class SystemScreenTest extends WebTestCase
|
|||||||
self::assertStringNotContainsString('kein-server', (string) file_get_contents(dirname(__DIR__).'/.env'));
|
self::assertStringNotContainsString('kein-server', (string) file_get_contents(dirname(__DIR__).'/.env'));
|
||||||
|
|
||||||
// Die Gegenstelle der Prüfung: öffentlich, ohne Anmeldung, und nur die Kennung.
|
// 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(200, $client->getResponse()->getStatusCode());
|
||||||
self::assertSame(hash_hmac('sha256', 'site-origin', 'isolated-test-secret'), $client->getResponse()->getContent());
|
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.');
|
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,
|
// 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 = $this->login();
|
||||||
$client->request('GET', '/admin/system');
|
$client->request('GET', '/admin/system');
|
||||||
$html = (string) $client->getResponse()->getContent();
|
$html = (string) $client->getResponse()->getContent();
|
||||||
self::assertStringContainsString('E-Mail-Versand', $html);
|
self::assertStringContainsString('href="/admin/system/e-mail"', $html, 'Ein Unterpunkt von System im Menü.');
|
||||||
self::assertStringContainsString('Noch nichts versendet', $html);
|
self::assertStringNotContainsString('Alle Wege testen', $html, 'Die Systemseite trägt den Versand nicht mehr selbst.');
|
||||||
$token = (string) $client->getCrawler()->filter('form[action$="/system/testmail"] input[name=_csrf_token]')->attr('value');
|
|
||||||
|
|
||||||
$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();
|
$client->followRedirect();
|
||||||
self::assertStringContainsString('keine gültige E-Mail-Adresse', (string) $client->getResponse()->getContent());
|
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']);
|
// Derselbe Weg wie eingestellt nimmt den Transport des Frameworks - im Test null://null.
|
||||||
self::assertStringEndsWith('/admin/system#mail', (string) $client->getResponse()->headers->get('Location'));
|
$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);
|
self::assertEmailCount(1);
|
||||||
$mail = self::getMailerMessage();
|
$mail = self::getMailerMessage();
|
||||||
self::assertEmailAddressContains($mail, 'To', 'umut@cms.test');
|
self::assertEmailAddressContains($mail, 'To', 'umut@cms.test');
|
||||||
self::assertEmailAddressContains($mail, 'From', 'formular@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();
|
$html = (string) $client->getResponse()->getContent();
|
||||||
self::assertStringContainsString('Testmail an umut@cms.test vom Mailserver angenommen.', $html);
|
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 <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.
|
// 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);
|
self::assertEmailCount(0);
|
||||||
$redaktion = $this->login('editor');
|
$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());
|
self::assertSame(403, $redaktion->getResponse()->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user