From d8891315f941cb4da08ca8521b5c0d73a628f73a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 16:08:58 +0200 Subject: [PATCH] Style password reset flow and send corporate design mails Give the lost password module the same centred card as the login, right-align the reset link, add explanatory copy and a way back to the login from every dead end Contao routes through mod_message. Contao only sets a text body on the opt-in mail, so a MessageEvent listener adds an HTML alternative in the corporate design. It reads the locale from the fallback root page because mails can go out from a CLI worker, where kernel.default_locale is "en". Co-Authored-By: Claude Opus 5 --- AI_HANDOVER.md | 72 ++++ .../frontend/mod_lostPassword.html.twig | 37 ++ .../templates/frontend/mod_message.html.twig | 26 ++ public/css/auth-module.css | 403 +++++++++++++++++- src/EventListener/CorporateMailListener.php | 139 ++++++ src/Resources/views/email/corporate.html.twig | 80 ++++ translations/messages.de.yaml | 15 + translations/messages.en.yaml | 15 + 8 files changed, 786 insertions(+), 1 deletion(-) create mode 100644 contao/templates/frontend/mod_lostPassword.html.twig create mode 100644 contao/templates/frontend/mod_message.html.twig create mode 100644 src/EventListener/CorporateMailListener.php create mode 100644 src/Resources/views/email/corporate.html.twig 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 }}

+
+ +
+
+ + + {% if error|default %} +

{{ error }}

+ {% endif %} + {{ fields|raw }} +
+ +
+
+
+ +

+ {{ '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 %} +
+

{{ message }}

+

+ {{ 'survey.auth.back_to_login'|trans }} +

+
+ {% 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' %} + + + + +
+ {{ block.label }} +
+

+ {{ '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."