diff --git a/AI_HANDOVER.md b/AI_HANDOVER.md
index 7a2335f..f32b8f9 100644
--- a/AI_HANDOVER.md
+++ b/AI_HANDOVER.md
@@ -454,6 +454,68 @@ Wichtige Settings:
- nur Warnsymbol statt Backend-Sperre
- Admin darf Fragen auch nach Veröffentlichung oder nach vorhandenen Antworten umsortieren
+## Mitglieder-Login und Passwort-vergessen
+
+### Aufbau
+
+- Login: Contao-Kernmodul `login` (tl_module ID 5), Seite `index`.
+ - Im Modul ist die Seite `Passwort vergessen` als `pwResetPage` hinterlegt. Erst dadurch rendert Contao den Link `Passwort vergessen?` im Login-Formular (`div.password-reset` innerhalb der `formbody`).
+- Passwort vergessen: Contao-Kernmodul `lostPassword` (tl_module ID 9), Seite `passwort-vergessen`.
+ - `reg_skipName = 1`, es wird also nur die E-Mail-Adresse abgefragt.
+ - Captcha ist aktiv (`disableCaptcha = 0`).
+
+### Gestaltung
+
+- Alle Auth-Styles liegen zentral in `public/css/auth-module.css`, global eingebunden über `contao/config/config.php` (`$GLOBALS['TL_CSS']`).
+- Das Passwort-vergessen-Modul (`.mod_lostPassword.block`) verwendet bewusst dieselbe zentrierte Karte wie das Login-Modul: gleiche Breite, Radien, Farbverläufe, Schriften und Button-Optik.
+- Der Link `Passwort vergessen?` im Login ist rechtsbündig und im Corporate Design (Blau, Hover Orange, Chevron `›`). Auf Mobil wird er zentriert.
+- Fehler- und Bestätigungsmeldungen (auch das Contao-Template `mod_message`, das der Kern bei ungültigem/abgelaufenem Token einsetzt) werden als farbige Hinweisboxen dargestellt.
+
+### Template-Override
+
+- `contao/templates/frontend/mod_lostPassword.html.twig`
+- Wichtig: Der Override muss als **Twig**-Template angelegt sein. Contao 5 liefert für `mod_lostPassword` eine eigene Twig-Variante mit; ein `.html5`-Override im Bundle greift dadurch **nicht** und wird still ignoriert.
+- Das Template ergänzt oberhalb des Formulars einen Erklärblock (`.survey-auth-intro`) und darunter einen Link zurück zur Anmeldung (`.survey-auth-back`).
+- Es unterscheidet die beiden Schritte des Ablaufs anhand des Query-Parameters `token` (`pw-...`):
+ - ohne Token: Texte aus `survey.auth.lost_password.*`
+ - mit Token: Texte aus `survey.auth.set_password.*`
+- Die Texte liegen in `translations/messages.de.yaml` bzw. `messages.en.yaml` unter `survey.auth`, sind also nicht im Template hartkodiert.
+
+### E-Mail-Versand
+
+- Der Versand läuft über einen benannten Mailer-Transport `ksb`, konfiguriert in der Projektinstallation (`config/config.yaml`), nicht im Bundle:
+ - `framework.mailer.transports.ksb` liest die SMTP-DSN aus der Umgebungsvariablen `MAILER_DSN` (in `.env.local`).
+ - `contao.mailer.transports.ksb.from` setzt den Absender.
+- Häufige Fehlerquelle: Der Name der Umgebungsvariablen in `config.yaml` muss exakt zu dem in `.env.local` passen. Weicht er ab, wirft Symfony beim Absenden `EnvNotFoundException: Environment variable not found` und die Seite läuft in einen 500er.
+- Ist gar kein SMTP-Transport aktiv, fällt Symfony auf `sendmail` zurück. Auf diesem Server schlägt das mit `sendmail: fatal: User kinderschutzbund is not allowed to submit mail` fehl.
+- Mails laufen über Messenger (`contao_prio_high`, Doctrine-Transport). Fehlgeschlagene Nachrichten landen im Transport `contao_failure` und sind über `messenger:failed:show` einsehbar.
+
+### Ablauf und Sackgassen
+
+Der Kern von Contao wechselt im Passwort-vergessen-Modul in mehreren Fällen auf das Template `mod_message` (ungültiger Token, bereits benutzter Token, zu viele Versuche, Passwort erfolgreich gesetzt). Der Wrapper behält dabei die Klasse `mod_lostPassword`.
+
+- `contao/templates/frontend/mod_message.html.twig` nutzt genau diese Klasse als Erkennungsmerkmal und ergänzt in diesen Fällen einen Button `Zurück zur Anmeldung`.
+- Damit endet insbesondere die Meldung „Das Passwort wurde aktualisiert" nicht mehr in einer Sackgasse.
+- Für alle anderen Module bleibt `mod_message` unverändert beim Kern-Markup.
+
+Nach erfolgreicher Anforderung des Passwort-Links macht `ModuleLostPassword::sendPasswordLink()` nur ein `reload()`, wenn im Modul **keine** Weiterleitungsseite (`jumpTo`) gesetzt ist – der Nutzer sähe dann gar keine Bestätigung.
+
+- Deshalb ist im Modul (tl_module ID 9) die Seite `Passwortlink versendet` (tl_page ID 8, Alias `passwortlink-versendet`) als `jumpTo` hinterlegt.
+- Der Bestätigungstext liegt dort bewusst als ganz normales Contao-Textelement, damit er redaktionell pflegbar bleibt.
+- Das Inhaltselement trägt die CSS-Klasse `survey-auth-note`; darüber greift in `auth-module.css` dieselbe Kartenoptik wie beim Login. Der Button darin nutzt die Klasse `survey-auth-button`, der Link auf die Anmeldeseite das Insert-Tag `{{link_url::2}}`.
+
+### HTML-Mails im Corporate Design
+
+Contao verschickt die Passwort-Mail über `OptInToken::send()` und setzt dort ausschließlich einen Textbody – ein Template dafür gibt es im Kern nicht.
+
+- `src/EventListener/CorporateMailListener.php` hängt sich stattdessen an das `MessageEvent` von Symfony Mailer und ergänzt zu reinen Text-Mails eine HTML-Alternative.
+- Template: `src/Resources/views/email/corporate.html.twig` (Tabellenlayout, Inline-Styles, Systemschriften – Webfonts sind in Mailclients nicht verlässlich).
+- Der Textteil bleibt unverändert erhalten. Mails, die bereits HTML mitbringen, werden nicht angefasst.
+- Der Listener ist idempotent: Das `MessageEvent` wird sowohl beim Einreihen in die Messenger-Queue als auch beim tatsächlichen Versand ausgelöst; der Check auf einen vorhandenen HTML-Body fängt das ab.
+- Absätze, die nur aus einer URL bestehen, werden als Call-to-Action-Button gerendert, zusätzlich mit der URL als kopierbarem Klartext darunter. Enthält die URL `token=pw-`, lautet die Beschriftung „Neues Passwort festlegen".
+- **Wichtig (Stolperfalle):** Mails können auch aus einem CLI-Worker heraus verschickt werden. Dort gibt es keinen Request, aus dem der Translator eine Sprache ableiten könnte, und `kernel.default_locale` ist in Contao standardmäßig `en`. Der Listener liest die Sprache deshalb aus der Fallback-Startseite (`tl_page.type = 'root' AND fallback = '1'`), sonst wechselt der Text je nach Versandweg zwischen Deutsch und Englisch.
+- Die Absender-/Footerdaten stehen direkt im Template, die übrigen Texte unter `survey.email.*` in den Übersetzungsdateien.
+
## Wichtige Dateien nach Themengebiet
Frontend-Liste:
@@ -484,6 +546,16 @@ Exporte:
- `src/Service/SurveyResultsExportService.php`
- `src/Service/SurveyResultsPdfService.php`
+Mitglieder-Login / Passwort vergessen:
+
+- `public/css/auth-module.css`
+- `contao/templates/frontend/mod_lostPassword.html.twig`
+- `contao/templates/frontend/mod_message.html.twig`
+- `src/EventListener/CorporateMailListener.php`
+- `src/Resources/views/email/corporate.html.twig`
+- `translations/messages.de.yaml` (Abschnitte `survey.auth` und `survey.email`)
+- Projektinstallation: `config/config.yaml`, `.env.local`
+
Backend:
- `contao/dca/tl_survey.php`
diff --git a/contao/templates/frontend/mod_lostPassword.html.twig b/contao/templates/frontend/mod_lostPassword.html.twig
new file mode 100644
index 0000000..77bc851
--- /dev/null
+++ b/contao/templates/frontend/mod_lostPassword.html.twig
@@ -0,0 +1,37 @@
+{% extends '@Contao/block_unsearchable.html.twig' %}
+{% trans_default_domain 'messages' %}
+
+{# Schritt 2: Der Nutzer kommt ueber den Link aus der E-Mail und vergibt ein neues Passwort. #}
+{% set surveyAuthScope = (app.request.query.get('token')|default('')) starts with 'pw-' ? 'set_password' : 'lost_password' %}
+
+{% block content %}
+
+
+
+
+
{{ ('survey.auth.' ~ surveyAuthScope ~ '.headline')|trans }}
+
{{ ('survey.auth.' ~ surveyAuthScope ~ '.intro')|trans }}
+
{{ ('survey.auth.' ~ surveyAuthScope ~ '.hint')|trans }}
+
+
+
+
+
+ {{ 'survey.auth.back_to_login'|trans }}
+
+
+
+
+{% endblock %}
diff --git a/contao/templates/frontend/mod_message.html.twig b/contao/templates/frontend/mod_message.html.twig
new file mode 100644
index 0000000..7c5f5b2
--- /dev/null
+++ b/contao/templates/frontend/mod_message.html.twig
@@ -0,0 +1,26 @@
+{% extends '@Contao/block_unsearchable.html.twig' %}
+{% trans_default_domain 'messages' %}
+
+{#
+ Contao setzt beim Passwort-vergessen-Modul in mehreren Faellen auf mod_message um
+ (ungueltiger Token, bereits benutzter Token, zu viele Versuche, Passwort gesetzt).
+ Der Wrapper behaelt dabei die Klasse mod_lostPassword. In genau diesen Faellen
+ ergaenzen wir den Weg zurueck zur Anmeldung, damit der Nutzer nicht in einer
+ Sackgasse landet.
+#}
+{% set surveyIsAuthMessage = 'mod_lostPassword' in (class|default('')) %}
+
+{% block content %}
+
+ {% if surveyIsAuthMessage %}
+
+ {% else %}
+ {{ message }}
+ {% endif %}
+
+{% endblock %}
diff --git a/public/css/auth-module.css b/public/css/auth-module.css
index 0acbb4e..a80f584 100644
--- a/public/css/auth-module.css
+++ b/public/css/auth-module.css
@@ -158,6 +158,39 @@
outline: none;
}
+/* "Passwort vergessen?"-Link im Login-Modul */
+.mod_login.block.login .password-reset {
+ margin-top: 0.1rem;
+ text-align: right;
+}
+
+.mod_login.block.login .password-reset a {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ padding: 0.15rem 0;
+ border-bottom: 1px solid rgba(0, 100, 173, 0.3);
+ color: var(--survey-auth-blue);
+ font-family: "Blogger Sans", sans-serif;
+ font-size: 0.95rem;
+ font-weight: 500;
+ text-decoration: none;
+ transition: color 0.18s ease, border-color 0.18s ease;
+}
+
+.mod_login.block.login .password-reset a::after {
+ content: "\203A";
+ font-size: 1.05em;
+ line-height: 1;
+}
+
+.mod_login.block.login .password-reset a:hover,
+.mod_login.block.login .password-reset a:focus {
+ color: var(--survey-auth-orange);
+ border-color: rgba(236, 124, 50, 0.55);
+ outline: none;
+}
+
nav.mod_customnav.block:has(a[href*="/abmelden"]) {
position: fixed;
top: 1em;
@@ -231,4 +264,372 @@ nav.mod_customnav.block:has(a[href*="/abmelden"]) .level_1 > li > a:focus {
.mod_login.block.login button.submit {
width: 100%;
}
-}
\ No newline at end of file
+}
+
+
+/* ------------------------------------------------------------------
+ Passwort-vergessen-Modul (Contao "lostPassword")
+ Gleiches Kartenlayout wie das Login-Modul, mittig zentriert.
+ ------------------------------------------------------------------ */
+
+.mod_lostPassword.block {
+ max-width: min(100%, 36rem);
+ margin: clamp(1.5rem, 4vw, 3rem) auto;
+ padding: clamp(1.5rem, 2.6vw, 2.4rem);
+ border: 1px solid var(--survey-auth-border);
+ border-radius: 1.8rem;
+ background:
+ radial-gradient(circle at top left, rgba(0, 100, 173, 0.2), transparent 36%),
+ radial-gradient(circle at bottom right, rgba(0, 174, 151, 0.16), transparent 38%),
+ linear-gradient(180deg, rgba(255, 255, 255, 0.97) 0%, rgba(248, 251, 255, 0.96) 100%);
+ box-shadow: var(--survey-auth-shadow);
+ box-sizing: border-box;
+ font-family: "PT Sans Narrow", sans-serif;
+ color: var(--survey-auth-ink);
+}
+
+.mod_lostPassword.block .survey-auth-card {
+ display: grid;
+ gap: 1.35rem;
+}
+
+.mod_lostPassword.block .survey-auth-intro {
+ display: grid;
+ gap: 0.55rem;
+ padding-bottom: 1.1rem;
+ border-bottom: 1px solid rgba(162, 168, 180, 0.28);
+}
+
+.mod_lostPassword.block .survey-auth-intro-headline {
+ margin: 0;
+ font-family: "Blogger Sans", sans-serif;
+ font-size: clamp(1.35rem, 2.4vw, 1.7rem);
+ font-weight: 500;
+ line-height: 1.2;
+ color: var(--survey-auth-blue);
+}
+
+.mod_lostPassword.block .survey-auth-intro-text {
+ margin: 0;
+ font-size: 1.02rem;
+ line-height: 1.5;
+ color: var(--survey-auth-ink);
+}
+
+.mod_lostPassword.block .survey-auth-hint {
+ margin: 0;
+ padding-left: 0.85rem;
+ border-left: 3px solid var(--survey-auth-green);
+ font-size: 0.94rem;
+ line-height: 1.45;
+ color: var(--survey-auth-muted);
+}
+
+.mod_lostPassword.block form,
+.mod_lostPassword.block .formbody {
+ margin: 0;
+}
+
+.mod_lostPassword.block .formbody {
+ display: grid;
+ gap: 1rem;
+}
+
+.mod_lostPassword.block .widget {
+ display: grid;
+ gap: 0.45rem;
+ margin: 0;
+}
+
+.mod_lostPassword.block label {
+ font-family: "Blogger Sans", sans-serif;
+ font-size: 1rem;
+ font-weight: 500;
+ color: var(--survey-auth-blue);
+ letter-spacing: 0.01em;
+}
+
+.mod_lostPassword.block label .mandatory {
+ margin-left: 0.15rem;
+ color: var(--survey-auth-orange);
+}
+
+.mod_lostPassword.block .invisible {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ border: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+.mod_lostPassword.block input.text,
+.mod_lostPassword.block input.password,
+.mod_lostPassword.block input.captcha,
+.mod_lostPassword.block input[type="text"],
+.mod_lostPassword.block input[type="email"],
+.mod_lostPassword.block input[type="password"] {
+ width: 100%;
+ min-height: 3.2rem;
+ padding: 0.8rem 0.95rem;
+ border: 1px solid rgba(162, 168, 180, 0.48);
+ border-radius: 1rem;
+ background: rgba(255, 255, 255, 0.94);
+ box-shadow: inset 0 1px 2px rgba(34, 34, 34, 0.04);
+ box-sizing: border-box;
+ font: inherit;
+ color: var(--survey-auth-ink);
+ transition: border-color 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
+}
+
+.mod_lostPassword.block input.text:focus,
+.mod_lostPassword.block input.password:focus,
+.mod_lostPassword.block input.captcha:focus,
+.mod_lostPassword.block input[type="text"]:focus,
+.mod_lostPassword.block input[type="email"]:focus,
+.mod_lostPassword.block input[type="password"]:focus {
+ outline: none;
+ border-color: rgba(0, 100, 173, 0.72);
+ box-shadow: 0 0 0 4px rgba(0, 100, 173, 0.12);
+ background: #fff;
+}
+
+/* Sicherheitsfrage: Rechenaufgabe als Hinweistext unter dem Feld */
+.mod_lostPassword.block .widget-captcha .captcha_text {
+ order: 3;
+ font-size: 0.9rem;
+ color: var(--survey-auth-muted);
+}
+
+.mod_lostPassword.block .widget-captcha input.captcha {
+ max-width: 9rem;
+}
+
+/* Zweiter Schritt: Passwortbestaetigung liegt als eigenes Feld direkt darunter */
+.mod_lostPassword.block .widget-password + .widget-password {
+ margin-top: -0.15rem;
+}
+
+.mod_lostPassword.block .widget-submit {
+ margin-top: 0.3rem;
+}
+
+.mod_lostPassword.block button.submit {
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 3.2rem;
+ padding: 0.8rem 1.35rem;
+ border: 0;
+ border-radius: 999px;
+ background: linear-gradient(135deg, var(--survey-auth-blue), #0d7acb);
+ color: #fff;
+ font-family: "Blogger Sans", sans-serif;
+ font-size: 1rem;
+ font-weight: 500;
+ letter-spacing: 0.01em;
+ cursor: pointer;
+ box-shadow: 0 16px 28px rgba(0, 100, 173, 0.24);
+ transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease;
+}
+
+.mod_lostPassword.block button.submit:hover,
+.mod_lostPassword.block button.submit:focus {
+ transform: translateY(-1px);
+ box-shadow: 0 18px 34px rgba(0, 100, 173, 0.28);
+ filter: saturate(1.05);
+ outline: none;
+}
+
+/* Fehler- und Bestaetigungsmeldungen (auch das Contao-Template mod_message) */
+.mod_lostPassword.block p.error,
+.mod_lostPassword.block p.confirm,
+.mod_lostPassword.block p.message {
+ margin: 0;
+ padding: 0.85rem 1rem;
+ border-radius: 1rem;
+ border: 1px solid transparent;
+ font-size: 0.98rem;
+ line-height: 1.45;
+}
+
+.mod_lostPassword.block p.error {
+ border-color: rgba(236, 124, 50, 0.4);
+ background: rgba(236, 124, 50, 0.12);
+ color: #a4501a;
+}
+
+.mod_lostPassword.block p.confirm {
+ border-color: rgba(0, 174, 151, 0.4);
+ background: rgba(0, 174, 151, 0.12);
+ color: #00705f;
+}
+
+.mod_lostPassword.block .survey-auth-back {
+ margin: 0;
+ text-align: right;
+}
+
+.mod_lostPassword.block .survey-auth-back a {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ padding: 0.15rem 0;
+ border-bottom: 1px solid rgba(0, 100, 173, 0.3);
+ color: var(--survey-auth-blue);
+ font-family: "Blogger Sans", sans-serif;
+ font-size: 0.95rem;
+ font-weight: 500;
+ text-decoration: none;
+ transition: color 0.18s ease, border-color 0.18s ease;
+}
+
+.mod_lostPassword.block .survey-auth-back a::before {
+ content: "\2039";
+ font-size: 1.05em;
+ line-height: 1;
+}
+
+.mod_lostPassword.block .survey-auth-back a:hover,
+.mod_lostPassword.block .survey-auth-back a:focus {
+ color: var(--survey-auth-orange);
+ border-color: rgba(236, 124, 50, 0.55);
+ outline: none;
+}
+
+/* ------------------------------------------------------------------
+ Bestaetigungs-/Hinweiskarte als normales Contao-Textelement
+ (Inhaltselement mit CSS-Klasse "survey-auth-note", z. B. auf der
+ Seite "Passwortlink versendet"). Der Text bleibt damit im Backend
+ ganz normal redaktionell pflegbar.
+ ------------------------------------------------------------------ */
+
+.survey-auth-note {
+ max-width: min(100%, 36rem);
+ margin: clamp(1.5rem, 4vw, 3rem) auto;
+ padding: clamp(1.5rem, 2.6vw, 2.4rem);
+ border: 1px solid var(--survey-auth-border);
+ border-radius: 1.8rem;
+ background:
+ radial-gradient(circle at top left, rgba(0, 174, 151, 0.2), transparent 36%),
+ radial-gradient(circle at bottom right, rgba(0, 100, 173, 0.16), transparent 38%),
+ linear-gradient(180deg, rgba(255, 255, 255, 0.97) 0%, rgba(248, 251, 255, 0.96) 100%);
+ box-shadow: var(--survey-auth-shadow);
+ box-sizing: border-box;
+ font-family: "PT Sans Narrow", sans-serif;
+ color: var(--survey-auth-ink);
+}
+
+.survey-auth-note > h1,
+.survey-auth-note > h2,
+.survey-auth-note > h3 {
+ margin: 0 0 0.9rem;
+ font-family: "Blogger Sans", sans-serif;
+ font-size: clamp(1.35rem, 2.4vw, 1.7rem);
+ font-weight: 500;
+ line-height: 1.2;
+ color: var(--survey-auth-blue);
+}
+
+.survey-auth-note .rte > p {
+ margin: 0 0 0.8rem;
+ font-size: 1.02rem;
+ line-height: 1.5;
+}
+
+.survey-auth-note .rte > p:last-child {
+ margin-bottom: 0;
+}
+
+/* Primaeraktion (z. B. "Zurueck zur Anmeldung") */
+.survey-auth-actions {
+ margin: 1.4rem 0 0;
+}
+
+.survey-auth-note .rte > p.survey-auth-actions {
+ margin: 1.4rem 0 0;
+}
+
+a.survey-auth-button {
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ gap: 0.4rem;
+ min-height: 3.2rem;
+ padding: 0.8rem 1.6rem;
+ border: 0;
+ border-radius: 999px;
+ background: linear-gradient(135deg, var(--survey-auth-blue), #0d7acb);
+ color: #fff;
+ font-family: "Blogger Sans", sans-serif;
+ font-size: 1rem;
+ font-weight: 500;
+ letter-spacing: 0.01em;
+ text-decoration: none;
+ box-shadow: 0 16px 28px rgba(0, 100, 173, 0.24);
+ transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease;
+}
+
+a.survey-auth-button:hover,
+a.survey-auth-button:focus {
+ transform: translateY(-1px);
+ box-shadow: 0 18px 34px rgba(0, 100, 173, 0.28);
+ filter: saturate(1.05);
+ color: #fff;
+ outline: none;
+}
+
+/* Meldungsseiten des Passwort-vergessen-Moduls (Contao-Template mod_message) */
+.mod_lostPassword.block .survey-auth-actions {
+ margin: 0;
+ text-align: center;
+}
+
+/* Hilfetexte und Feldfehler im Passwort-vergessen-Modul */
+.mod_lostPassword.block p.help {
+ margin: 0;
+ font-size: 0.9rem;
+ line-height: 1.4;
+ color: var(--survey-auth-muted);
+}
+
+/* Fehlermeldung im Login-Modul im gleichen Stil */
+.mod_login.block.login p.error {
+ margin: 0;
+ padding: 0.85rem 1rem;
+ border: 1px solid rgba(236, 124, 50, 0.4);
+ border-radius: 1rem;
+ background: rgba(236, 124, 50, 0.12);
+ color: #a4501a;
+ font-size: 0.98rem;
+ line-height: 1.45;
+}
+
+@media (max-width: 767px) {
+ .mod_lostPassword.block {
+ padding: 1.2rem;
+ border-radius: 1.35rem;
+ }
+
+ .mod_lostPassword.block button.submit {
+ width: 100%;
+ }
+
+ .survey-auth-note {
+ padding: 1.2rem;
+ border-radius: 1.35rem;
+ }
+
+ a.survey-auth-button {
+ width: 100%;
+ }
+
+ .mod_login.block.login .password-reset,
+ .mod_lostPassword.block .survey-auth-back {
+ text-align: center;
+ }
+}
diff --git a/src/EventListener/CorporateMailListener.php b/src/EventListener/CorporateMailListener.php
new file mode 100644
index 0000000..c2fffbe
--- /dev/null
+++ b/src/EventListener/CorporateMailListener.php
@@ -0,0 +1,139 @@
+getMessage();
+
+ if (!$message instanceof Email) {
+ return;
+ }
+
+ // Der Event wird beim Einreihen in die Queue und beim tatsächlichen Versand
+ // ausgelöst. Der Check auf einen vorhandenen HTML-Teil macht den Listener
+ // idempotent und lässt bewusst gesetzte HTML-Mails unangetastet.
+ if (null !== $message->getHtmlBody()) {
+ return;
+ }
+
+ $text = $message->getTextBody();
+
+ if (null === $text || '' === trim($text)) {
+ return;
+ }
+
+ $blocks = $this->buildBlocks($text);
+
+ if ([] === $blocks) {
+ return;
+ }
+
+ $template = $this->twig->createTemplate(file_get_contents(self::TEMPLATE));
+
+ $message->html($template->render([
+ 'subject' => (string) $message->getSubject(),
+ 'blocks' => $blocks,
+ 'locale' => $this->resolveLocale(),
+ ]));
+ }
+
+ /**
+ * Zerlegt den Rohtext in Absätze. Ein Absatz, der nur aus einer URL besteht,
+ * wird als Call-to-Action-Button dargestellt.
+ *
+ * @return list
+ */
+ private function buildBlocks(string $text): array
+ {
+ $blocks = [];
+
+ foreach (preg_split('/\R\s*\R/', trim($text)) ?: [] as $paragraph) {
+ $paragraph = trim($paragraph);
+
+ if ('' === $paragraph) {
+ continue;
+ }
+
+ if (preg_match('#^https?://\S+$#', $paragraph)) {
+ $blocks[] = [
+ 'type' => 'button',
+ 'url' => $paragraph,
+ 'label' => $this->buttonLabel($paragraph),
+ ];
+
+ continue;
+ }
+
+ $blocks[] = [
+ 'type' => 'text',
+ 'html' => nl2br(htmlspecialchars($paragraph, ENT_QUOTES, 'UTF-8')),
+ ];
+ }
+
+ return $blocks;
+ }
+
+ private function buttonLabel(string $url): string
+ {
+ // Contao hängt an den Passwort-Link einen Opt-in-Token "pw-..." an.
+ $key = str_contains($url, 'token=pw-')
+ ? 'survey.email.cta_password'
+ : 'survey.email.cta_generic';
+
+ return $this->translator->trans($key, [], 'messages', $this->resolveLocale());
+ }
+
+ /**
+ * Mails werden auch von einem CLI-Worker verschickt. Dort gibt es keinen Request,
+ * aus dem der Translator eine Sprache ableiten könnte, und `kernel.default_locale`
+ * ist in Contao-Installationen standardmäßig "en". Deshalb wird die Sprache aus
+ * der Fallback-Startseite gelesen, damit der Text nicht je nach Versandweg wechselt.
+ */
+ private function resolveLocale(): string|null
+ {
+ if (null !== $this->locale) {
+ return $this->locale;
+ }
+
+ try {
+ $language = $this->connection->fetchOne(
+ "SELECT language FROM tl_page WHERE type = 'root' AND fallback = '1' ORDER BY sorting LIMIT 1"
+ );
+ } catch (\Throwable) {
+ $language = false;
+ }
+
+ return $this->locale = \is_string($language) && '' !== $language ? $language : null;
+ }
+}
diff --git a/src/Resources/views/email/corporate.html.twig b/src/Resources/views/email/corporate.html.twig
new file mode 100644
index 0000000..0e5abfa
--- /dev/null
+++ b/src/Resources/views/email/corporate.html.twig
@@ -0,0 +1,80 @@
+{% trans_default_domain 'messages' %}
+
+
+
+
+
+ {{ subject }}
+
+
+
+
+
+
+
+
+
+ |
+ Der Kinderschutzbund
+ Landesverband Sachsen e. V.
+ |
+
+
+ | |
+
+
+
+
+ {{ subject }}
+
+ {% for block in blocks %}
+ {% if block.type == 'button' %}
+
+
+ {{ 'survey.email.link_fallback'|trans({}, 'messages', locale) }}
+ {{ block.url }}
+
+ {% else %}
+ {{ block.html|raw }}
+ {% endif %}
+ {% endfor %}
+ |
+
+
+
+ |
+
+ |
+
+
+
+ |
+
+ Der Kinderschutzbund Landesverband Sachsen e. V.
+ Hamburger Straße 39b (Haus F)
+ 01067 Dresden
+ Telefon: 0351 4242044
+ E-Mail: info@kinderschutzbund-sachsen.de
+ Web: www.kinderschutzbund-sachsen.de
+
+ {{ 'survey.email.footer_note'|trans({}, 'messages', locale) }}
+ |
+
+
+
+
+ |
+
+
+
+
diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml
index 6e8aac8..833f3af 100644
--- a/translations/messages.de.yaml
+++ b/translations/messages.de.yaml
@@ -163,3 +163,18 @@ survey:
no_questions_title: "Keine Fragen vorhanden"
no_questions_text: "Für diese Umfrage sind noch keine Fragen angelegt."
footer: "PDF-Export des Survey-Bundles"
+ auth:
+ lost_password:
+ headline: "Passwort vergessen?"
+ intro: "Kein Problem. Tragen Sie hier die E-Mail-Adresse ein, mit der Sie für die Umfrageplattform registriert sind. Wir senden Ihnen anschließend eine E-Mail mit einem Link, über den Sie sich ein neues Passwort vergeben können."
+ hint: "Der Link ist aus Sicherheitsgründen nur eine begrenzte Zeit gültig. Falls keine E-Mail ankommt, prüfen Sie bitte auch Ihren Spam-Ordner."
+ set_password:
+ headline: "Neues Passwort vergeben"
+ intro: "Bitte vergeben Sie jetzt Ihr neues Passwort für die Umfrageplattform. Anschließend können Sie sich damit sofort anmelden."
+ hint: "Wählen Sie ein Passwort, das Sie nicht bereits an anderer Stelle verwenden."
+ back_to_login: "Zurück zur Anmeldung"
+ email:
+ cta_password: "Neues Passwort festlegen"
+ cta_generic: "Link öffnen"
+ link_fallback: "Falls sich der Button nicht anklicken lässt, kopieren Sie bitte diese Adresse in die Adresszeile Ihres Browsers:"
+ footer_note: "Diese E-Mail wurde automatisch von der Umfrageplattform des Kinderschutzbundes Landesverband Sachsen e. V. versendet. Bitte antworten Sie nicht direkt auf diese Nachricht."
diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml
index b3289a9..4c9764a 100644
--- a/translations/messages.en.yaml
+++ b/translations/messages.en.yaml
@@ -163,3 +163,18 @@ survey:
no_questions_title: "No questions available"
no_questions_text: "No questions have been created for this survey yet."
footer: "Survey bundle PDF export"
+ auth:
+ lost_password:
+ headline: "Forgot your password?"
+ intro: "No problem. Enter the e-mail address you are registered with. We will then send you an e-mail containing a link that lets you set a new password."
+ hint: "For security reasons the link is only valid for a limited time. If no e-mail arrives, please also check your spam folder."
+ set_password:
+ headline: "Set a new password"
+ intro: "Please choose your new password for the survey platform. You can log in with it right away."
+ hint: "Please pick a password that you do not already use elsewhere."
+ back_to_login: "Back to login"
+ email:
+ cta_password: "Set a new password"
+ cta_generic: "Open link"
+ link_fallback: "If the button does not work, please copy this address into your browser's address bar:"
+ footer_note: "This e-mail was sent automatically by the survey platform of Der Kinderschutzbund Landesverband Sachsen e. V. Please do not reply to this message."