feat: markdown in email body for mailing and transactional
This commit is contained in:
@@ -3,9 +3,11 @@
|
||||
namespace App\Controller\Admin\Teamer;
|
||||
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Email\MailBodyRenderer;
|
||||
use App\Form\TeamerMailingType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Message\SendTeamerMailing;
|
||||
use App\Model\TeamerMailingDto;
|
||||
use App\Service\Common\TeamerFilterHandler;
|
||||
use App\Service\Teamer\TeamerMailingDraftHandler;
|
||||
use App\Service\Teamer\TeamerMailingService;
|
||||
@@ -25,6 +27,7 @@ class MailingController extends AbstractController
|
||||
private readonly TeamerFilterHandler $filterHandler,
|
||||
private readonly TeamerMailingService $mailingService,
|
||||
private readonly TeamerMailingDraftHandler $draftHandler,
|
||||
private readonly MailBodyRenderer $mailBodyRenderer,
|
||||
private readonly MessageBusInterface $messageBus,
|
||||
) {
|
||||
}
|
||||
@@ -54,9 +57,12 @@ class MailingController extends AbstractController
|
||||
|
||||
$filterDto = $this->filterHandler->getFilterSettings();
|
||||
|
||||
// no redirect after the preview, the composed mail has to survive it
|
||||
// No redirect after the preview mail, the composed mail has to survive it. The
|
||||
// preview pane is rendered here so the page arrives complete; from then on the
|
||||
// draft route above keeps it up to date.
|
||||
return $this->render('admin/teamer/mailing.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'preview' => $this->renderPreview($form->getData()),
|
||||
'filterDto' => $filterDto,
|
||||
'recipients' => $this->mailingService->resolveRecipients($filterDto),
|
||||
'placeholders' => TeamerMailingService::PLACEHOLDERS,
|
||||
@@ -66,16 +72,22 @@ class MailingController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* Park what has been typed so far, so that a trip to the filter and back does not
|
||||
* throw the composed mail away. Answers nothing, the page stays as it is.
|
||||
* Park what has been typed so far, so that a trip to the filter and back does not throw
|
||||
* the composed mail away, and answer with the preview of that very draft.
|
||||
*
|
||||
* Both hang off the same form content, so they are deliberately one request rather than
|
||||
* two routes on the same keystroke: the preview can never show wording other than the
|
||||
* one that was parked.
|
||||
*/
|
||||
#[Route('/admin/teamer/mailing/draft', name: 'app_admin_teamer_mailing_draft', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function draft(Request $request): Response
|
||||
{
|
||||
$this->draftHandler->saveDraft($this->createMailingForm($request)->getData());
|
||||
$mailingDto = $this->createMailingForm($request)->getData();
|
||||
|
||||
return new Response(null, Response::HTTP_NO_CONTENT);
|
||||
$this->draftHandler->saveDraft($mailingDto);
|
||||
|
||||
return $this->render('admin/teamer/_mailing_preview.html.twig', $this->renderPreview($mailingDto));
|
||||
}
|
||||
|
||||
#[Route('/admin/teamer/mailing/discard', name: 'app_admin_teamer_mailing_discard')]
|
||||
@@ -140,6 +152,26 @@ class MailingController extends AbstractController
|
||||
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
|
||||
}
|
||||
|
||||
/**
|
||||
* What the composing admin would receive. The placeholders are filled from their own
|
||||
* record, the same way sendPreview() fills the test mail, so the pane and the mail that
|
||||
* lands in their inbox show the same name.
|
||||
*
|
||||
* Deliberately rendered whether or not the form validates: a mail that is still missing
|
||||
* its subject is exactly the one an admin is looking at while writing it.
|
||||
*
|
||||
* @return array{subject: string, bodyHtml: string}
|
||||
*/
|
||||
private function renderPreview(TeamerMailingDto $mailingDto): array
|
||||
{
|
||||
$values = $this->mailingService->placeholderValuesForUser($this->getUser());
|
||||
|
||||
return [
|
||||
'subject' => $this->mailingService->render($mailingDto->getSubject(), $values),
|
||||
'bodyHtml' => $this->mailBodyRenderer->render($mailingDto->getMessage(), $values),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A GET starts from the parked draft, a POST always carries the current one itself.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ' 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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Service\Teamer;
|
||||
|
||||
use App\Email\MailBodyRenderer;
|
||||
use App\Email\Mailer;
|
||||
use App\Entity\User;
|
||||
use App\Message\SendTeamerMailing;
|
||||
@@ -37,6 +38,7 @@ class TeamerMailingService
|
||||
public function __construct(
|
||||
private readonly TeamerRepository $teamerRepository,
|
||||
private readonly Mailer $mailer,
|
||||
private readonly MailBodyRenderer $mailBodyRenderer,
|
||||
private readonly LoggerInterface $logger,
|
||||
// messenger transport, "mailing" in production and "sync" in dev and test,
|
||||
// see the parameter of the same name in services.yaml
|
||||
@@ -98,7 +100,7 @@ class TeamerMailingService
|
||||
$this->sendTo(
|
||||
$recipient['email'],
|
||||
$this->render($mailing->getSubject(), $values),
|
||||
$this->render($mailing->getMessage(), $values),
|
||||
$this->mailBodyRenderer->render($mailing->getMessage(), $values),
|
||||
self::TRANSPORT,
|
||||
$this->mailingBusTransport
|
||||
);
|
||||
@@ -121,7 +123,7 @@ class TeamerMailingService
|
||||
$this->sendTo(
|
||||
$admin->getUserIdentifier(),
|
||||
self::PREVIEW_SUBJECT_PREFIX.$this->render($mailingDto->getSubject(), $values),
|
||||
$this->render($mailingDto->getMessage(), $values)
|
||||
$this->mailBodyRenderer->render($mailingDto->getMessage(), $values)
|
||||
);
|
||||
|
||||
$this->logger->info('Send teamer mailing preview', [
|
||||
@@ -131,8 +133,10 @@ class TeamerMailingService
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the known placeholders. Anything that merely looks like a placeholder is
|
||||
* left alone - a typo should not silently blank out part of the mail.
|
||||
* Replace the known placeholders in a subject line. Anything that merely looks like a
|
||||
* placeholder is left alone - a typo should not silently blank out part of the mail.
|
||||
* The message body does not come through here: it is Markdown, and
|
||||
* App\Email\MailBodyRenderer has to escape the values before it puts them in.
|
||||
*/
|
||||
public function render(?string $text, array $values): string
|
||||
{
|
||||
@@ -195,12 +199,14 @@ class TeamerMailingService
|
||||
private function sendTo(
|
||||
string $email,
|
||||
string $subject,
|
||||
string $message,
|
||||
string $bodyHtml,
|
||||
?string $transport = null,
|
||||
?string $busTransport = null,
|
||||
): void {
|
||||
// $bodyHtml is already rendered and escaped mail HTML, see MailBodyRenderer -
|
||||
// email/teamer_mailing.html.twig prints it with |raw.
|
||||
$this->mailer->createAndSendEmail([
|
||||
'message' => $message,
|
||||
'bodyHtml' => $bodyHtml,
|
||||
], [
|
||||
'to' => $email,
|
||||
'subject' => $subject,
|
||||
|
||||
Reference in New Issue
Block a user