feat: markdown in email body for mailing and transactional

This commit is contained in:
Björn Fromme committed 2026-08-20 14:00:53 +02:00
1 parent f0e850978b
commit 5912a75c81
23 files changed
+1251 -160

No files matched your search

+22 -85
View File
@@ -8,12 +8,12 @@ use App\Model\RenderedEmailTextDto;
use App\Repository\EmailTextRepository;
/**
* Turns the plain text an admin wrote into the HTML of a transactional mail.
* Turns the text an admin wrote into the HTML of a transactional mail.
*
* Admins write plain text with {placeholder} tokens and *bold* markers - never markup,
* never Twig. Everything an admin or a teamer typed is escaped here before any tag is
* introduced, which is the only reason email/generic.html.twig may print the result with
* |raw. The order of operations below is load-bearing; see the comments on each step.
* Admins write Markdown with {placeholder} tokens - never markup, never Twig. The body is
* handed to App\Email\MailBodyRenderer, which is where the escaping that makes
* email/generic.html.twig's |raw safe happens; subject and headline are plain text and
* are dealt with here.
*/
class EmailTextRenderer
{
@@ -32,6 +32,7 @@ class EmailTextRenderer
public function __construct(
private readonly EmailTextCatalog $catalog,
private readonly EmailTextRepository $emailTextRepository,
private readonly MailBodyRenderer $mailBodyRenderer,
) {
}
@@ -68,103 +69,39 @@ class EmailTextRenderer
): RenderedEmailTextDto {
$definition = $this->catalog->get($key);
// Two token maps from the same values: the subject of a mail is plain text, the
// body is HTML. Escaping happens here, before substitution, so that neither the
// admin's copy nor a teamer's own words (reason, comment, specialAgreements) can
// introduce a tag.
$plainTokens = [];
// Three token maps from the same values, because the three parts of a mail are not
// the same kind of text: the subject is a plain header, the headline is escaped
// plain text, and the body is Markdown the renderer escapes for us.
$rawTokens = [];
$htmlTokens = [];
foreach ($definition->getPlaceholderNames() as $name) {
$value = (string) ($placeholders[$name] ?? '');
$value = '' === trim($value) ? self::EMPTY_VALUE : $value;
$plainTokens['{'.$name.'}'] = $value;
$rawTokens['{'.$name.'}'] = $value;
$htmlTokens['{'.$name.'}'] = htmlspecialchars($value, self::ESCAPE_FLAGS, 'UTF-8');
}
return new RenderedEmailTextDto(
strtr($subject ?? '', $plainTokens),
$this->prepare($headline ?? '', $htmlTokens),
$this->renderBody($body ?? '', $htmlTokens),
strtr($subject ?? '', $rawTokens),
$this->renderHeadline($headline ?? '', $htmlTokens),
$this->mailBodyRenderer->render($body ?? '', $rawTokens),
);
}
/**
* The headline is set as the mail's own <h1>, so it deliberately takes no Markdown:
* emphasis inside an already bold heading says nothing, and a stray "-" or "#" at the
* start of one should stay the character it is.
*
* @param array<string, string> $tokens
*/
private function renderBody(string $body, array $tokens): string
private function renderHeadline(string $headline, array $tokens): string
{
$body = $this->prepare($body, $tokens);
// Blank lines separate paragraphs, single newlines are soft breaks. The mail
// layout styles <p> with its own margins, so keeping real paragraphs preserves
// the spacing the hand-written templates had - a single <p> full of <br> would
// collapse it. nl2br then handles the breaks inside a block, which is what
// multi-line values such as a rejection comment need.
$paragraphs = [];
foreach (preg_split('/\R{2,}/', $body) as $block) {
$block = trim($block);
if ('' === $block) {
continue;
}
$paragraphs[] = '<p>'.nl2br($block).'</p>';
}
return $this->linkify(implode("\n", $paragraphs));
}
/**
* @param array<string, string> $tokens
*/
private function prepare(string $text, array $tokens): string
{
$text = $this->emphasise(htmlspecialchars(trim($text), self::ESCAPE_FLAGS, 'UTF-8'));
// Substituting last means asterisks and URLs inside a value stay literal: only
// what an admin wrote can turn into a tag.
return strtr($text, $tokens);
}
/**
* Markdown-style *bold*. Runs on the already escaped string, so the only tag it can
* ever produce is <strong>. A literal asterisk is written \*.
*/
private function emphasise(string $text): string
{
$text = preg_replace(
'/(?<!\\\\)\*([^*\n]+)(?<!\\\\)\*/',
'<strong>$1</strong>',
$text
);
return str_replace('\*', '*', $text);
}
/**
* Makes bare URLs and mail addresses clickable, so dropping the inline links the
* hand-written templates had does not cost the reader anything. One pass over an
* alternation, so a match is never linked twice.
*/
private function linkify(string $html): string
{
return preg_replace_callback(
'~(?<url>https?://[^\s<]+)|(?<email>[\w.+-]+@[\w-]+(?:\.[\w-]+)+)~',
static function (array $match): string {
if ('' !== ($match['url'] ?? '')) {
// Sentence punctuation is not part of the address.
$url = rtrim($match['url'], '.,;:!?)');
$trailing = substr($match['url'], strlen($url));
return sprintf('<a href="%s">%s</a>%s', $url, $url, $trailing);
}
return sprintf('<a href="mailto:%1$s">%1$s</a>', $match['email']);
},
$html
return strtr(
htmlspecialchars(trim($headline), self::ESCAPE_FLAGS, 'UTF-8'),
$tokens
);
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Email;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\CommonMark\Node\Block\Heading;
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
use League\CommonMark\MarkdownConverter;
use League\CommonMark\Node\Inline\Text;
use League\CommonMark\Node\Node;
/**
* Turns the Markdown an admin wrote into the HTML body of a mail.
*
* Admins write Markdown with {platzhalter} tokens - never markup, never Twig. Two rules
* carry the whole safety argument, and both are load-bearing:
*
* 1. Only what an admin typed goes through the parser. Everything that is substituted for
* a token afterwards is escaped, so a teamer's own words (a rejection comment, their
* name) can never introduce a tag, a heading or a list - no matter what they contain.
* 2. Markup an admin types is stripped rather than passed through, so even the admin side
* cannot smuggle a <script> in.
*
* That is what lets email/generic.html.twig and email/teamer_mailing.html.twig print the
* result with |raw. Do not pass anything into those templates that did not come out of here.
*/
class MailBodyRenderer
{
/**
* Substituted text only ever lands in element content, never in an attribute, so
* single quotes stay readable instead of turning into &#039; mid-sentence.
*/
private const ESCAPE_FLAGS = ENT_COMPAT | ENT_SUBSTITUTE;
/**
* The mail layout puts its own <h1> above the body (the headline field), and styles
* nothing below <h3>. An admin's "#" must not outrank the one, "####" must not fall
* out of the other, so every heading is folded into that range.
*/
private const MIN_HEADING_LEVEL = 2;
private const MAX_HEADING_LEVEL = 3;
private readonly MarkdownConverter $converter;
public function __construct()
{
$environment = new Environment([
// Markup an admin types is dropped instead of passed through.
'html_input' => 'strip',
'allow_unsafe_links' => false,
'renderer' => [
// CommonMark would turn a single newline into a bare "\n", which a mail
// client does not show. The hand-written texts predate Markdown and use
// single newlines as real line breaks, so they stay visible.
'soft_break' => "<br />\n",
],
]);
$environment->addExtension(new CommonMarkCoreExtension());
// Replaces the hand-rolled linkify(): bare URLs and mail addresses become links.
$environment->addExtension(new AutolinkExtension());
$environment->addEventListener(DocumentParsedEvent::class, $this->constrainDocument(...));
$this->converter = new MarkdownConverter($environment);
}
/**
* @param array<string, string> $tokenValues keyed by the whole token as it appears in
* the text, "{destination}" or "{{vorname}}"
*/
public function render(?string $markdown, array $tokenValues): string
{
// Trimmed on both ends: the converter closes with a newline, which is nothing but
// noise in a mail body and makes the rendered HTML awkward to assert against.
$html = trim($this->converter->convert(trim((string) $markdown))->getContent());
// Substituting last is the point: braces mean nothing to CommonMark, so the tokens
// survive the parser untouched and only the admin's own text has become markup by
// the time any value is put in.
return strtr($html, array_map($this->escapeValue(...), $tokenValues));
}
/**
* Values are escaped but keep their line breaks - a multi-line rejection comment must
* not run together into one line. Blank lines inside a value do not start a new
* paragraph: the structure of a mail is the admin's to decide, not the teamer's.
*/
private function escapeValue(string $value): string
{
return nl2br(htmlspecialchars($value, self::ESCAPE_FLAGS, 'UTF-8'));
}
/**
* Trims the parsed document down to what a mail can actually show. Runs once over the
* tree after parsing, which is cheaper and far more reliable than trying to forbid the
* syntax on the way in.
*/
private function constrainDocument(DocumentParsedEvent $event): void
{
$nodes = [];
foreach ($event->getDocument()->iterator() as $node) {
$nodes[] = $node;
}
// Collected first, changed after: replacing a node while the iterator is walking
// the very same tree would skip its neighbours.
foreach ($nodes as $node) {
if ($node instanceof Heading) {
$node->setLevel(max(self::MIN_HEADING_LEVEL, min(self::MAX_HEADING_LEVEL, $node->getLevel())));
continue;
}
// An image in a mailing would be an external image loaded from an address
// nobody reviewed, so only its alt text survives.
if ($node instanceof Image) {
$node->replaceWith(new Text($this->textOf($node)));
}
}
}
private function textOf(Node $node): string
{
$text = '';
foreach ($node->iterator() as $child) {
if ($child instanceof Text) {
$text .= $child->getLiteral();
}
}
return $text;
}
}