feat: markdown in email body for mailing and transactional

This commit is contained in:
Björn Fromme
2026-08-20 14:00:53 +02:00
parent f0e850978b
commit 5912a75c81
23 changed files with 1251 additions and 160 deletions
@@ -0,0 +1,24 @@
{# Shown under both mail editors, so an admin meets the same syntax in each of them. #}
<div class="text-sm text-gray-500">
<div class="pb-2">
Formatierung der Nachricht:
</div>
<div class="flex flex-wrap gap-2">
{% for example, label in {
'**fett**': 'fett',
'*kursiv*': 'kursiv',
'## Überschrift': 'Überschrift',
'- Punkt': 'Aufzählung',
'1. Punkt': 'Nummerierung',
} %}
<span class="inline-flex items-center space-x-1 py-1 px-2 bg-gray-50 border border-gray-200 rounded">
<code>{{ example }}</code>
<span>{{ label }}</span>
</span>
{% endfor %}
</div>
<div class="pt-2">
Eine Leerzeile beginnt einen neuen Absatz, Links und E-Mail-Adressen werden
automatisch verlinkt.
</div>
</div>
@@ -4,6 +4,8 @@
{{ form_row(form.headline) }}
{{ form_row(form.body) }}
{{ include('_partials/_markdown_hint.html.twig') }}
<div class="bg-gray-100 rounded-md p-4 text-sm">
<div class="font-bold pb-2">
Verfügbare Platzhalter
@@ -6,9 +6,9 @@
{% if text.headline is not empty %}
<h1 class="text-xl font-bold pb-2">{{ text.headline | raw }}</h1>
{% endif %}
{# The paragraphs come from the mail body, where email/layout.html.twig styles every
<p> with padding-bottom:16px - pb-4 is the same spacing, so the preview matches. #}
<div class="[&>p]:pb-4 [&_a]:underline">
{# .mail-body carries the spacing and list styling of email/layout.html.twig, so this
pane and the mailing pane both show what the mail will look like. #}
<div class="mail-body">
{{ text.bodyHtml | raw }}
</div>
<p>
@@ -0,0 +1,15 @@
<div class="pb-4">
<span class="font-bold">Betreff:</span> {{ subject }}
</div>
{# App\Email\MailBodyRenderer escaped everything before it introduced a tag, which is what
makes |raw safe here - see the contract on that class. #}
<div class="mail-body border border-gray-200 rounded-md p-4">
{{ bodyHtml | raw }}
<p>
<span class="btn inline-block">Zum Portal</span>
</p>
</div>
<div class="pt-4 text-sm text-gray-500">
Platzhalter sind mit deinem eigenen Namen gefüllt, beim Versand steht dort der Name der
jeweiligen Person. Logo und Fußzeile der E-Mail sind hier nicht abgebildet.
</div>
+56 -21
View File
@@ -27,29 +27,64 @@
{% endif %}
</div>
{# the draft is parked on every keystroke so that a trip to the filter and back keeps it,
hx-trigger replaces htmx' default submit trigger, the preview button still posts natively #}
{{ form_start(form, { 'attr': {
'hx-post': path('app_admin_teamer_mailing_draft'),
'hx-trigger': 'input changed delay:500ms',
'hx-swap': 'none',
} }) }}
<div class="flex flex-col space-y-4 pb-4 max-w-3xl">
{{ form_row(form.subject) }}
{{ form_row(form.message) }}
</div>
{# One request per keystroke parks the draft - so that a trip to the filter and back
keeps it - and answers with the preview, which is why the response is swapped into
the pane instead of being thrown away. hx-trigger replaces htmx' default submit
trigger, the "Vorschau an mich senden" button still posts natively.
<div class="pb-8 max-w-3xl text-sm text-gray-500">
<div class="pb-2">
Platzhalter für Betreff und Nachricht, sie werden beim Senden pro Person ersetzt:
Deliberately without htmx' "changed" modifier: it compares the listening element's
.value between events, and a <form> has none, so every event compares undefined to
undefined and the request is never sent. The 500ms delay is what keeps the typing
from flooding the route. #}
{{ form_start(form, { 'attr': {
'id': 'mailing-form',
'hx-post': path('app_admin_teamer_mailing_draft'),
'hx-trigger': 'input delay:500ms',
'hx-target': '#mailing-preview',
'hx-swap': 'innerHTML',
'hx-indicator': '#mailing-preview-indicator',
} }) }}
<div class="grid lg:grid-cols-2 gap-8 items-start">
<div>
<div class="flex flex-col space-y-4 pb-4">
{{ form_row(form.subject) }}
{{ form_row(form.message) }}
</div>
<div class="pb-6">
{{ include('_partials/_markdown_hint.html.twig') }}
</div>
<div class="pb-8 text-sm text-gray-500">
<div class="pb-2">
Platzhalter für Betreff und Nachricht, sie werden beim Senden pro Person ersetzt:
</div>
<div class="flex flex-wrap gap-2">
{% for placeholder, label in placeholders %}
<span class="inline-flex items-center space-x-1 py-1 px-2 bg-gray-50 border border-gray-200 rounded">
<code>{{ placeholder }}</code>
<span>{{ label }}</span>
</span>
{% endfor %}
</div>
</div>
</div>
<div class="flex flex-wrap gap-2">
{% for placeholder, label in placeholders %}
<span class="inline-flex items-center space-x-1 py-1 px-2 bg-gray-50 border border-gray-200 rounded">
<code>{{ placeholder }}</code>
<span>{{ label }}</span>
</span>
{% endfor %}
<div class="lg:sticky lg:top-4">
<div class="font-bold pb-2">
Vorschau
</div>
<div class="relative">
<div id="mailing-preview">
{{ include('admin/teamer/_mailing_preview.html.twig', preview) }}
</div>
{# Deliberately a sibling of the swap target, not a child: hx-swap replaces
the target's contents, which would carry the indicator away with them. #}
<div class="htmx-indicator absolute inset-0 bg-white/75 flex items-center justify-center"
id="mailing-preview-indicator">
{{ include('_partials/_spinner.html.twig', { 'class': 'w-8 h-8' }) }}
</div>
</div>
</div>
</div>
+41 -10
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>MyE&amp;P Team</title>
@@ -116,7 +116,16 @@
font-size: 20px;
line-height: 24px;
margin-bottom: 0;
padding-bottom: 0;
/* Was 0 while an h2 only ever preceded a p, which brings its own spacing. */
padding-bottom: 8px;
}
h3 {
font-weight: bold;
font-size: 17px;
line-height: 22px;
margin-bottom: 0;
padding-bottom: 4px;
}
p {
@@ -152,12 +161,28 @@
font-weight: bold;
}
em {
font-style: italic;
}
ul,
ol {
/* Margin, not padding: Outlook on Windows renders list padding unreliably. */
margin: 0 0 16px 24px;
padding: 0;
font-size: 16px;
}
li {
padding-bottom: 4px;
}
</style>
</head>
<body style="word-spacing:normal;background-color:#666666;">
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">MyE&amp;P Team</div>
<div style="background-color:#666666;">
<div aria-label="MyE&amp;P Team" aria-roledescription="email" style="background-color:#666666;" role="article" lang="und" dir="auto">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0;font-size:0;mso-line-height-rule:exactly;"><v:image style="border:0;mso-position-horizontal:center;position:absolute;top:0;width:600px;z-index:-3;" xmlns:v="urn:schemas-microsoft-com:vml" /><![endif]-->
<div style="margin:0 auto;max-width:600px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
@@ -215,16 +240,22 @@
<div style="font-family:Lato, Verdana, Arial, 'Helvetica neue', sans-serif;font-size:16px;line-height:24px;text-align:left;color:#666666;">{% block body %} <h1> Lorem ipsum dolor sit amet </h1>
<p> Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto culpa delectus dolores earum eius fugiat in nesciunt quas quidem vitae? </p>
<h2> Lorem ipsum dolor </h2>
<p> Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto culpa delectus dolores <strong>earum eius fugiat in nesciunt quas quidem vitae</strong>? </p>
<p> Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto culpa delectus dolores <strong>earum eius fugiat in nesciunt quas quidem vitae</strong>, <em>eius fugiat</em>? </p>
<ul>
<li>Lorem ipsum dolor</li>
<li>Consectetur adipisicing elit</li>
</ul>
<h3> Lorem ipsum </h3>
<ol>
<li>Lorem ipsum dolor</li>
<li>Consectetur adipisicing elit</li>
</ol>
<p>
<a href="#" class="button"> Button </a>
</p>
<p class="small"> Lorem ipsum dolor sit amet, consectetur adipisicing elit. Doloribus, eveniet! </p> {% endblock %}
{% block button %}
<p>
<a href="{{ url('app_index') }}" class="button">Zum Portal</a>
</p>
{% endblock %}
<p class="small"> Lorem ipsum dolor sit amet, consectetur adipisicing elit. Doloribus, eveniet! </p> {% endblock %} {% block button %} <p>
<a href="{{ url('app_index') }}" class="button">Zum Portal</a>
</p> {% endblock %}
</div>
</td>
</tr>
+4 -3
View File
@@ -1,7 +1,8 @@
{% extends 'email/layout.html.twig' %}
{# Everything on `bodyHtml` was escaped by App\Email\MailBodyRenderer before any tag was
introduced, which is what makes |raw safe here - same contract as email/generic.html.twig.
Do not pass anything into this template that did not come out of that renderer. #}
{% block body %}
<p>
{{ message | nl2br }}
</p>
{{ bodyHtml | raw }}
{% endblock %}