diff --git a/config/sites/vitec/settings.yaml b/config/sites/vitec/settings.yaml
index b17c035..51b6c61 100644
--- a/config/sites/vitec/settings.yaml
+++ b/config/sites/vitec/settings.yaml
@@ -23,3 +23,5 @@ vitec.ssr.enabled: true
# Shell-Seiten (Produkt-/News-/Story-Detail-Container) nicht in die XML-Sitemap
# aufnehmen; 106 = alter /en/news-Ast (noindex-Duplikate). SEO-Audit 2026-11-09.
seo.sitemap.pages.excludePagesRecursive: '10,21,26,106'
+vitec.ssr.alertEmails:
+ - info@evomedien.de
diff --git a/packages/vitec/Classes/Command/SsrHealthSampleCommand.php b/packages/vitec/Classes/Command/SsrHealthSampleCommand.php
index 84e6b75..2b8a59b 100644
--- a/packages/vitec/Classes/Command/SsrHealthSampleCommand.php
+++ b/packages/vitec/Classes/Command/SsrHealthSampleCommand.php
@@ -206,15 +206,20 @@ final class SsrHealthSampleCommand extends Command
/**
* Mails are best effort: a mail problem must never break the sampler
- * (the cron would stop logging history). Recipients come from
- * SSR_ALERT_EMAIL in var/vitec-ssr-purge.env - unset means "alerts
- * off", which the output states so the cron log shows why.
+ * (the cron would stop logging history). Recipients come from the site
+ * setting vitec.ssr.alertEmails (Site Management › Settings › VITEC),
+ * fallback SSR_ALERT_EMAIL in var/vitec-ssr-purge.env - none at all
+ * means "alerts off", which the output states so the cron log shows why.
*/
private function sendAlert(string $subject, string $body, OutputInterface $output): void
{
- $recipients = GeneralUtility::trimExplode(',', $this->purgeService->getAlertEmail(), true);
+ $alert = $this->purgeService->getAlertRecipients();
+ if ($alert['invalid'] !== []) {
+ $output->writeln('skipped invalid alert addresses: ' . implode(', ', $alert['invalid']) . '');
+ }
+ $recipients = $alert['recipients'];
if ($recipients === []) {
- $output->writeln('transition detected but SSR_ALERT_EMAIL is not configured - no mail sent');
+ $output->writeln('transition detected but no alert recipients are configured (Site settings › VITEC or SSR_ALERT_EMAIL) - no mail sent');
return;
}
try {
diff --git a/packages/vitec/Classes/Controller/Backend/SsrController.php b/packages/vitec/Classes/Controller/Backend/SsrController.php
index 78378fd..5e11f46 100644
--- a/packages/vitec/Classes/Controller/Backend/SsrController.php
+++ b/packages/vitec/Classes/Controller/Backend/SsrController.php
@@ -70,6 +70,7 @@ final class SsrController
$lastSample = $healthLog[0] ?? null;
$clientErrors = $this->clientErrors(50);
$cockpit = $this->cockpitData($health, $lastSample);
+ $alert = $this->purgeService->getAlertRecipients();
$settingsUri = '';
if ($GLOBALS['BE_USER']->isAdmin()) {
@@ -92,6 +93,10 @@ final class SsrController
'cockpitJson' => json_encode($cockpit, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT),
'settingsUri' => $settingsUri,
'failoverAfter' => $this->purgeService->getFailoverAfter(),
+ 'alert' => $alert,
+ 'alertList' => implode(', ', $alert['recipients']),
+ 'alertCount' => count($alert['recipients']),
+ 'alertInvalid' => implode(', ', $alert['invalid']),
'chunkCount24h' => count(array_filter($clientErrors, static fn(array $r): bool => $r['is_chunk_reload'])),
'jsErrorCount24h' => count(array_filter($clientErrors, static fn(array $r): bool => !$r['is_chunk_reload'])),
'flagOn' => file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag'),
diff --git a/packages/vitec/Classes/Service/SsrPurgeService.php b/packages/vitec/Classes/Service/SsrPurgeService.php
index 9e30f46..3e72292 100644
--- a/packages/vitec/Classes/Service/SsrPurgeService.php
+++ b/packages/vitec/Classes/Service/SsrPurgeService.php
@@ -7,6 +7,8 @@ namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Http\RequestFactory;
+use TYPO3\CMS\Core\Site\SiteFinder;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Talks to the SSR node's POST /__purge endpoint (contract 0054-FE) and
@@ -24,6 +26,7 @@ final class SsrPurgeService
public function __construct(
private readonly RequestFactory $requestFactory,
private readonly ConnectionPool $connectionPool,
+ private readonly SiteFinder $siteFinder,
) {}
public static function getConfigPath(): string
@@ -66,6 +69,50 @@ final class SsrPurgeService
return trim($this->getConfig()['SSR_ALERT_EMAIL'] ?? '');
}
+ /**
+ * Who gets the vitec:ssr-health alert mails. Maintained by editors in
+ * Site Management › Settings › VITEC (vitec.ssr.alertEmails, one
+ * address per entry); the first site with at least one valid address
+ * wins. Without such a list, SSR_ALERT_EMAIL from the env file is the
+ * fallback. Invalid entries are skipped and reported back.
+ *
+ * @return array{recipients: list, source: string, invalid: list}
+ */
+ public function getAlertRecipients(): array
+ {
+ $invalid = [];
+ try {
+ foreach ($this->siteFinder->getAllSites() as $site) {
+ $list = $site->getSettings()->get('vitec.ssr.alertEmails', []);
+ if (is_string($list)) {
+ $list = GeneralUtility::trimExplode(',', $list, true);
+ }
+ $valid = [];
+ foreach ((array)$list as $address) {
+ $address = trim((string)$address);
+ if ($address === '') {
+ continue;
+ }
+ if (GeneralUtility::validEmail($address)) {
+ $valid[] = $address;
+ } else {
+ $invalid[] = $address;
+ }
+ }
+ if ($valid !== []) {
+ return ['recipients' => array_values(array_unique($valid)), 'source' => 'site settings', 'invalid' => $invalid];
+ }
+ }
+ } catch (\Throwable) {
+ // No site configuration readable (e.g. broken YAML): use the env fallback.
+ }
+ $env = array_values(array_filter(
+ GeneralUtility::trimExplode(',', $this->getAlertEmail(), true),
+ static fn(string $a): bool => GeneralUtility::validEmail($a)
+ ));
+ return ['recipients' => $env, 'source' => $env !== [] ? 'var/vitec-ssr-purge.env' : '', 'invalid' => $invalid];
+ }
+
/**
* Consecutive DOWN samples after which vitec:ssr-health fails over to
* the SPA (SSR_FAILOVER_AFTER, default 2 = ~10 minutes at the 5-minute
diff --git a/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml b/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml
index d818f98..ee0f04d 100644
--- a/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml
+++ b/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml
@@ -134,3 +134,10 @@ settings:
category: VITEC
type: bool
default: true
+
+ vitec.ssr.alertEmails:
+ label: 'SSR alert mail recipients'
+ description: 'E-mail addresses that get the alert mails of the SSR health check: node down, node back, automatic fallback to the SPA. One address per entry; invalid addresses are skipped. Empty: the addresses from SSR_ALERT_EMAIL in var/vitec-ssr-purge.env are used.'
+ category: VITEC
+ type: stringlist
+ default: []
diff --git a/packages/vitec/Resources/Private/Templates/Ssr/Index.html b/packages/vitec/Resources/Private/Templates/Ssr/Index.html
index eebc096..da51150 100644
--- a/packages/vitec/Resources/Private/Templates/Ssr/Index.html
+++ b/packages/vitec/Resources/Private/Templates/Ssr/Index.html
@@ -14,7 +14,7 @@
- Live state of the SSR node on the VPS. The dot pulses green while the node answers its health check. Delivery shows whether visitors get server-rendered pages; the switch is in the Site settings. The strip shows the last 24 hours in 15-minute windows: green ok, yellow slower than 400 ms, red down, grey no sample. After {failoverAfter} failed checks in a row the cron switches back to the SPA and sends an alert mail.
+ Live state of the SSR node on the VPS. The dot pulses green while the node answers its health check. Delivery shows whether visitors get server-rendered pages; the switch is in the Site settings. The strip shows the last 24 hours in 15-minute windows: green ok, yellow slower than 400 ms, red down, grey no sample. After {failoverAfter} failed checks in a row the cron switches back to the SPA. Alert mails go to the addresses under Site settings › VITEC › SSR alert mail recipients; hover “Alert mail” to see them.