---
title: 'Benachrichtigungssystem'
description: 'Das interne Benachrichtigungssystem von CrispyCMS: Glocke in der Topbar, E-Mail-Versand über dieselbe API, Benachrichtigungstypen registrieren und benutzerbezogene Einstellungen.'
navLabel: 'Benachrichtigungen'
navIcon: '🔔'
---
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"

CrispyCMS besitzt ein zentrales Benachrichtigungssystem. Es gibt genau **einen** Einstiegspunkt, um einen Benutzer über irgendetwas zu informieren: `Crispy\Services\NotificationService`.

Eine Benachrichtigung kann dabei

- **intern** zugestellt werden (Eintrag in der Glocke in der Topbar und unter `/admin/notifications`),
- **per E-Mail** zugestellt werden,
- oder **beides** — mit einem einzigen Aufruf.

---

## Der einfachste Fall

```php
use Crispy\Services\NotificationService;

NotificationService::send($user, 'Seite veröffentlicht', 'Das Impressum ist jetzt online.', '/admin/pages/12');
```

Das erzeugt einen Eintrag in der Glocke des Benutzers. Mehr ist nicht nötig.

Soll dieselbe Benachrichtigung **zusätzlich** als E-Mail rausgehen, ist das genau ein Argument:

```php
use Crispy\Enums\NotificationChannel;

NotificationService::send(
    $user,
    'Seite veröffentlicht',
    'Das Impressum ist jetzt online.',
    '/admin/pages/12',
    channel: NotificationChannel::BOTH,
);
```

Es wird dann die generische Benachrichtigungs-E-Mail (`templates/mail/notifications/generic_notification.mjml.twig`) gerendert — Sie brauchen **kein** eigenes MJML-Template.

---

## Nur E-Mail, ohne Glocken-Eintrag

Transaktionale E-Mails haben in der Benachrichtigungszentrale nichts verloren: Einladungen, E-Mail-Verifizierung, Passwort-Zurücksetzen, Kontaktformular-Weiterleitungen. Dafür gibt es `sendMail()`, das **niemals** einen Eintrag in `crispy_notifications` schreibt:

```php
NotificationService::sendMail(
    recipients: $user,             // UserModel, rohe E-Mail-Adresse oder ein Array aus beidem
    subject: Translation::fetch('MeinPlugin.Mail.Titel'),
    mailTemplate: 'mail/notifications/mein_template.mjml.twig',
    mailVariables: ['Foo' => $foo],
);
```

`sendMail()` akzeptiert bewusst auch rohe E-Mail-Adressen — Empfänger, die gar keine CrispyCMS-Benutzer sind.

---

## Empfänger über Berechtigungen auflösen

Für „alle, die X dürfen" gibt es eine eigene Kurzform. Empfänger werden dabei automatisch nach Benutzer-ID dedupliziert (ein Benutzer, der über zwei Rollen passt, wird nur einmal benachrichtigt):

```php
NotificationService::sendToPermission(
    permissions: [Permissions::WRITE_BROKEN_MEDIA, Permissions::SUPERUSER],
    title: $titel,
    content: $text,
    link: '/admin/broken-media-finder',
    severity: NotificationSeverity::ERROR,
    channel: NotificationChannel::BOTH,
    type: NotificationTypes::BROKEN_MEDIA,
);
```

---

## Eigenes MJML-Template verwenden

Wenn die E-Mail reicher sein soll als Titel + Text + Button (Tabellen, Abschnitte, Diagramme), übergeben Sie Ihr eigenes Template. Die Variablen werden unmittelbar vor dem Rendern pro Empfänger als `ThemeVariables` gesetzt:

```php
NotificationService::send(
    recipients: $empfaenger,
    title: $titel,
    content: $text,
    link: '/admin/meine-seite',
    channel: NotificationChannel::BOTH,
    type: 'meinplugin.mein_typ',
    mailTemplate: 'mail/notifications/mein_template.mjml.twig',
    mailVariables: ['MeineTabelle' => $zeilen],
);
```

---

## Benachrichtigungstypen registrieren

Ein **Typ** ist das, was ein Benutzer unter `/admin/me` abschalten kann. Er ist optional: `send()` funktioniert auch ohne Typ (dann greifen keine Benutzereinstellungen). Registrieren Sie einen Typ, sobald die Benachrichtigung wiederkehrend ist.

Typen werden — genau wie Berechtigungen — beim Boot registriert und die Registry anschließend gesperrt. Plugins hängen sich dafür an `NotificationTypeRegisterEvent`:

```php
use Crispy\Enums\NotificationChannel;
use Crispy\Events\NotificationTypeRegisterEvent;
use Crispy\Notifications\NotificationTypeDefinition;
use Crispy\Notifications\NotificationTypeRegistry;

class PluginEventSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [NotificationTypeRegisterEvent::class => 'onNotificationTypeRegister'];
    }

    public function onNotificationTypeRegister(): void
    {
        NotificationTypeRegistry::register(
            new NotificationTypeDefinition(
                id: 'kiosk.device_offline',
                translationKey: 'Kiosk.Notifications.DeviceOffline',
                descriptionTranslationKey: 'Kiosk.Notifications.DeviceOffline.Hint',
                group: 'Kiosk',
                defaultChannel: NotificationChannel::BOTH,
            ),
        );
    }
}
```

IDs müssen mit einem Punkt namensraumiert sein (`meinplugin.mein_typ`), analog zu `PermissionDefinition`.

| Feld | Bedeutung |
| :--- | :--- |
| `id` | Eindeutige, namensraumierte ID. Wird in `crispy_notifications.type` gespeichert. |
| `translationKey` | Anzeigename in den Benutzereinstellungen. |
| `descriptionTranslationKey` | Optionaler Erklärtext unter dem Namen. |
| `group` | Gruppenüberschrift in den Einstellungen (Übersetzungsschlüssel oder Klartext). |
| `defaultChannel` | Kanal, solange der Benutzer nichts eingestellt hat. |
| `mandatory` | Ignoriert Benutzereinstellungen vollständig. Sparsam einsetzen — z. B. Lizenzablauf. |

---

## Zustellungsregeln

1. Hat die Benachrichtigung einen **registrierten Typ**, wird sie durch die Einstellungen des jeweiligen Benutzers gefiltert (`crispy_notification_preferences`), ersatzweise durch den `defaultChannel` des Typs. Ein `mandatory`-Typ überspringt das.
2. Benachrichtigungen **ohne** Typ werden nie gefiltert.
3. Der E-Mail-Kanal setzt zusätzlich `Helper::mailSendingIsEnabled()` voraus. Ist der Mailversand aus, wird die interne Benachrichtigung trotzdem erzeugt.
4. **Vorrangig vor allem anderen** greifen die administratorseitigen Schalter unter **Einstellungen → Benachrichtigungen**. Jede benachrichtigende Funktion hat davon **zwei**, einen pro Kanal:

   | Konfigurationsschlüssel | Kanal |
   | :--- | :--- |
   | `CMSControl_Notification_{Feature}_Internal_Enabled` | Glocke / Benachrichtigungszentrale |
   | `CMSControl_Notification_{Feature}_Enabled` | E-Mail |

   Sie sind unabhängig voneinander: Wer den Mailversand einer Funktion abschaltet, entfernt sie damit **nicht** mehr aus der Glocke aller Benutzer. Der `{Feature}`-Teil wird als `configKey` an der `NotificationTypeDefinition` deklariert; Typen ohne `configKey` (z. B. verpflichtende) kennen keine globalen Schalter.

   `NotificationService::globalChannel($type)` liefert den global erlaubten Kanal, `NotificationService::isEnabledGlobally($type)` ist genau dann `false`, wenn **beide** Kanäle aus sind. Aufrufende Tasks/Services benutzen Letzteres anstelle des früheren einzelnen `Config::get(...)`-Guards, um teure Arbeit (Empfänger auflösen, De-Dup-Sync) zu überspringen, wenn ohnehin nichts zugestellt würde.

   Ein global abgeschalteter Kanal wird in den persönlichen Einstellungen unter `/admin/me` nicht als wirkungsloser Schalter, sondern als Hinweis „Deaktiviert" dargestellt.

<Alert>
  <AlertTitle>Kein `mailSendingIsEnabled()`-Guard mehr im Aufrufer</AlertTitle>
  <AlertDescription>
    Tasks, die früher früh abgebrochen haben, wenn kein Mailversand konfiguriert war, dürfen das nicht mehr tun: Die Benachrichtigung landet ja weiterhin in der Glocke. `NotificationService` lässt den E-Mail-Teil selbstständig weg.
  </AlertDescription>
</Alert>

---

## Datenmodell

| Tabelle | Inhalt |
| :--- | :--- |
| `crispy_notifications` | Eine Zeile **pro Empfänger**, nicht pro Ereignis. Dadurch sind Gelesen-/Markiert-/Archiviert-Zustand ohne Zwischentabelle benutzerbezogen. |
| `crispy_notification_preferences` | Opt-out pro Benutzer und Typ. Zeilen entstehen erst, wenn ein Benutzer etwas ändert — ein neuer Typ braucht also keine Backfill-Migration. |

`type` ist bewusst ein einfacher String und kein Fremdschlüssel: Typen leben im Code, nicht in der Datenbank, und eine Benachrichtigung muss die Deaktivierung ihres Plugins überleben.

---

## Aufbewahrung

`Crispy\Tasks\NotificationPurgeTask` läuft nachts und räumt auf:

| Konfiguration | Standard | Wirkung |
| :--- | :--- | :--- |
| `CMSControl_Notification_RetentionDays` | 90 | Löscht bereits **archivierte** Benachrichtigungen. |
| `CMSControl_Notification_StaleRetentionDays` | 365 | Auffangnetz für Benachrichtigungen, die nie angefasst wurden. |

Angeheftete (gepinnte) Benachrichtigungen werden von keinem der beiden Durchläufe gelöscht — und auch „Alle archivieren" lässt sie stehen. Das Anheften ist damit die einzige Möglichkeit, eine Benachrichtigung dauerhaft zu behalten.

---

## Oberfläche

| Ort | Datei |
| :--- | :--- |
| Glocke in der Topbar | `Components/Navbar/Notifications.twig` + `js/CMSControl/Notifications.js` |
| Vollständige Liste | `Views/Notifications.twig` (`/admin/notifications`) |
| Detail-Modal | `Components/Modals/NotificationDetail.twig` + `js/CMSControl/NotificationDetail.js` |
| Benutzereinstellungen | Karte „Benachrichtigungen" auf `/admin/me` |

Beide Listen zeigen pro Eintrag nur **eine** gekürzte Zeile. Ein Klick auf den Eintrag öffnet das gemeinsame Detail-Modal mit dem vollständigen Text, Typ, Schweregrad und Zeitstempel — und markiert die Benachrichtigung als gelesen. Der hinterlegte Link ist dort ein eigener Button: „lesen" und „dorthin springen" sind bewusst getrennte Aktionen, weil ein Klick sonst wegnavigieren würde, bevor man den Text gelesen hat.

### Mehrzeilige Inhalte

Der Benachrichtigungstext (`content`) darf mehrzeilig sein. Listen zeigen davon nur die **erste Zeile**, das Detail-Modal rendert ihn vollständig mit `white-space: pre-wrap`. Die Website-Zusammenfassung nutzt das: `SiteDigestMailerService::buildNotificationSummary()` baut denselben Bericht, der auch in der E-Mail steht (meistbesuchte Seiten, Speicher inkl. Delta, Audit-Log, Barrierefreiheit, defekte Medien, News), als Klartext in den Benachrichtigungstext — man muss die E-Mail also nicht öffnen, um den Digest zu lesen. Die Rohwerte liegen zusätzlich strukturiert in `data`.

Der Text ist bewusst **Klartext, kein HTML**: Benachrichtigungsinhalte werden überall als Text gerendert, damit ein aus einem Plugin stammender Inhalt kein Markup einschleusen kann.

Das Anheften (Pin) ist von beiden Listen und aus dem Modal heraus erreichbar. Es ist die einzige benutzerseitige Möglichkeit, eine Benachrichtigung dauerhaft zu behalten — der Tooltip am Pin-Button sagt das auch explizit, damit die Wirkung nicht geraten werden muss.

Die Glocke pollt ausschließlich den günstigen Endpunkt `/admin/api/notifications/unread-count` (standardmäßig alle 60 Sekunden) und lädt die Liste selbst erst, wenn das Dropdown geöffnet wird. Der Poll pausiert, solange der Tab im Hintergrund ist.
