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 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-08-18 16:08:58 +02:00
parent 4fe142422a
commit d8891315f9
8 changed files with 786 additions and 1 deletions
+72
View File
@@ -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`
@@ -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 %}
<div class="survey-auth-card">
<div class="survey-auth-intro">
<h2 class="survey-auth-intro-headline">{{ ('survey.auth.' ~ surveyAuthScope ~ '.headline')|trans }}</h2>
<p class="survey-auth-intro-text">{{ ('survey.auth.' ~ surveyAuthScope ~ '.intro')|trans }}</p>
<p class="survey-auth-hint">{{ ('survey.auth.' ~ surveyAuthScope ~ '.hint')|trans }}</p>
</div>
<form id="{{ formId }}" method="post">
<div class="formbody">
<input type="hidden" name="FORM_SUBMIT" value="{{ formId }}">
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
{% if error|default %}
<p class="error">{{ error }}</p>
{% endif %}
{{ fields|raw }}
<div class="widget widget-submit">
<button type="submit" class="submit">{{ slabel }}</button>
</div>
</div>
</form>
<p class="survey-auth-back">
<a href="{{ app.request.baseUrl }}/">{{ 'survey.auth.back_to_login'|trans }}</a>
</p>
</div>
{% endblock %}
@@ -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 %}
<div class="survey-auth-card">
<p class="{{ type }} message">{{ message }}</p>
<p class="survey-auth-actions">
<a class="survey-auth-button" href="{{ app.request.baseUrl }}/">{{ 'survey.auth.back_to_login'|trans }}</a>
</p>
</div>
{% else %}
<p class="{{ type }} message">{{ message }}</p>
{% endif %}
{% endblock %}
+402 -1
View File
@@ -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%;
}
}
}
/* ------------------------------------------------------------------
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;
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\EventListener;
use Doctrine\DBAL\Connection;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Mailer\Event\MessageEvent;
use Symfony\Component\Mime\Email;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
/**
* Ergänzt reine Text-Mails (z. B. die Contao-Kernmail "Passwort vergessen")
* um eine HTML-Variante im Corporate Design des Kinderschutzbundes.
*
* Der Textteil bleibt unverändert erhalten, die HTML-Variante wird nur als
* Alternative ergänzt. Mails, die bereits HTML mitbringen, werden nicht angefasst.
*/
#[AsEventListener]
class CorporateMailListener
{
private const TEMPLATE = __DIR__.'/../Resources/views/email/corporate.html.twig';
private string|null $locale = null;
public function __construct(
private readonly Environment $twig,
private readonly TranslatorInterface $translator,
private readonly Connection $connection,
) {
}
public function __invoke(MessageEvent $event): void
{
$message = $event->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<array{type: string, html?: string, url?: string, label?: string}>
*/
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;
}
}
@@ -0,0 +1,80 @@
{% trans_default_domain 'messages' %}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ subject }}</title>
</head>
<body style="margin:0; padding:0; background-color:#eef3f8;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#eef3f8;">
<tr>
<td align="center" style="padding:24px 12px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border-radius:14px; overflow:hidden; border:1px solid #dde4ec;">
<tr>
<td style="background-color:#0064ad; padding:22px 28px;">
<div style="margin:0; font-family:Arial,Helvetica,sans-serif; font-size:17px; line-height:1.3; font-weight:bold; color:#ffffff;">Der Kinderschutzbund</div>
<div style="margin:2px 0 0; font-family:Arial,Helvetica,sans-serif; font-size:13px; line-height:1.3; color:#cfe4f4;">Landesverband Sachsen e.&nbsp;V.</div>
</td>
</tr>
<tr>
<td style="height:4px; line-height:4px; font-size:0; background-color:#ec7c32;">&nbsp;</td>
</tr>
<tr>
<td style="padding:28px 28px 8px;">
<h1 style="margin:0 0 18px; font-family:Arial,Helvetica,sans-serif; font-size:20px; line-height:1.3; font-weight:bold; color:#0064ad;">{{ subject }}</h1>
{% for block in blocks %}
{% if block.type == 'button' %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:22px 0;">
<tr>
<td align="center" bgcolor="#0064ad" style="border-radius:26px;">
<a href="{{ block.url }}" style="display:inline-block; padding:14px 28px; font-family:Arial,Helvetica,sans-serif; font-size:15px; line-height:1; font-weight:bold; color:#ffffff; text-decoration:none; border-radius:26px;">{{ block.label }}</a>
</td>
</tr>
</table>
<p style="margin:0 0 16px; font-family:Arial,Helvetica,sans-serif; font-size:13px; line-height:1.5; color:#5f6975;">
{{ 'survey.email.link_fallback'|trans({}, 'messages', locale) }}<br>
<a href="{{ block.url }}" style="color:#0064ad; word-break:break-all;">{{ block.url }}</a>
</p>
{% else %}
<p style="margin:0 0 14px; font-family:Arial,Helvetica,sans-serif; font-size:15px; line-height:1.6; color:#22313f;">{{ block.html|raw }}</p>
{% endif %}
{% endfor %}
</td>
</tr>
<tr>
<td style="padding:8px 28px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td style="height:1px; line-height:1px; font-size:0; background-color:#dde4ec;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:18px 28px 26px;">
<p style="margin:0 0 10px; font-family:Arial,Helvetica,sans-serif; font-size:13px; line-height:1.6; color:#5f6975;">
<strong style="color:#22313f;">Der Kinderschutzbund Landesverband Sachsen e.&nbsp;V.</strong><br>
Hamburger Straße 39b (Haus F)<br>
01067 Dresden<br>
Telefon: <a href="tel:+493514242044" style="color:#0064ad; text-decoration:none;">0351 4242044</a><br>
E-Mail: <a href="mailto:info@kinderschutzbund-sachsen.de" style="color:#0064ad; text-decoration:none;">info@kinderschutzbund-sachsen.de</a><br>
Web: <a href="https://www.kinderschutzbund-sachsen.de" style="color:#0064ad; text-decoration:none;">www.kinderschutzbund-sachsen.de</a>
</p>
<p style="margin:0; font-family:Arial,Helvetica,sans-serif; font-size:12px; line-height:1.5; color:#8a929d;">{{ 'survey.email.footer_note'|trans({}, 'messages', locale) }}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+15
View File
@@ -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."
+15
View File
@@ -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."