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
+33 -2
View File
@@ -19,7 +19,15 @@
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 {
font-size: 16px;
@@ -48,6 +56,18 @@
strong {
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;
}
</mj-style>
</mj-head>
<mj-body background-color="#666666">
@@ -70,8 +90,19 @@
</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto culpa delectus dolores
<strong>earum eius fugiat in nesciunt quas quidem vitae</strong>?
<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
+1
View File
@@ -6,3 +6,4 @@
@import "components/toast.css";
@import "components/tooltip.css";
@import "components/timeline.css";
@import "components/mail-body.css";
+41
View File
@@ -0,0 +1,41 @@
/*
* Mirrors the rules the mail layout carries in assets/mjml/layout.mjml, so both admin
* previews show what the rendered mail will look like. Kept out of Tailwind's typography
* plugin on purpose: prose brings its own type scale, and the point here is to match the
* mail rather than to look good on its own.
*/
.mail-body > p {
@apply pb-4;
}
.mail-body h2 {
@apply text-xl pb-2;
}
.mail-body h3 {
@apply text-lg font-bold pb-1;
}
.mail-body strong {
@apply font-bold;
}
.mail-body em {
@apply italic;
}
.mail-body ul {
@apply list-disc ml-6 pb-4;
}
.mail-body ol {
@apply list-decimal ml-6 pb-4;
}
.mail-body li {
@apply pb-1;
}
.mail-body a {
@apply underline;
}
+1
View File
@@ -22,6 +22,7 @@
"flagception/flagception-bundle": "^6.0",
"knplabs/knp-menu-bundle": "^3.2",
"knplabs/knp-paginator-bundle": "^6.2",
"league/commonmark": "^2.10",
"league/flysystem-bundle": "^3.4",
"league/flysystem-sftp-v3": "^3.29",
"league/oauth2-client": "^2.9",
Generated
+423 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "b6c422d9533a5a1589f72c500a66cf90",
"content-hash": "8e7c82ac9ab513690dab33c12c4fa2c2",
"packages": [
{
"name": "beberlei/doctrineextensions",
@@ -209,6 +209,81 @@
],
"time": "2026-07-18T12:35:13+00:00"
},
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
"source": {
"type": "git",
"url": "https://github.com/dflydev/dflydev-dot-access-data.git",
"reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f",
"reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^0.12.42",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.3",
"scrutinizer/ocular": "1.6.0",
"squizlabs/php_codesniffer": "^3.5",
"vimeo/psalm": "^4.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Dflydev\\DotAccessData\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Dragonfly Development Inc.",
"email": "[email protected]",
"homepage": "http://dflydev.com"
},
{
"name": "Beau Simensen",
"email": "[email protected]",
"homepage": "http://beausimensen.com"
},
{
"name": "Carlos Frutos",
"email": "[email protected]",
"homepage": "https://github.com/cfrutos"
},
{
"name": "Colin O'Dell",
"email": "[email protected]",
"homepage": "https://www.colinodell.com"
}
],
"description": "Given a deep data structure, access data by dot notation.",
"homepage": "https://github.com/dflydev/dflydev-dot-access-data",
"keywords": [
"access",
"data",
"dot",
"notation"
],
"support": {
"issues": "https://github.com/dflydev/dflydev-dot-access-data/issues",
"source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3"
},
"time": "2024-07-08T12:26:09+00:00"
},
{
"name": "doctrine/cache",
"version": "2.2.0",
@@ -2496,6 +2571,195 @@
},
"time": "2025-11-29T09:14:09+00:00"
},
{
"name": "league/commonmark",
"version": "2.10.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"league/config": "^1.1.1",
"php": "^7.4 || ^8.0",
"psr/event-dispatcher": "^1.0",
"symfony/deprecation-contracts": "^2.1 || ^3.0",
"symfony/polyfill-php80": "^1.16"
},
"require-dev": {
"cebe/markdown": "^1.0",
"commonmark/cmark": "0.31.1",
"commonmark/commonmark.js": "0.31.1",
"composer/package-versions-deprecated": "^1.8",
"embed/embed": "^4.4",
"erusev/parsedown": "^1.0",
"ext-json": "*",
"github/gfm": "0.29.0",
"michelf/php-markdown": "^1.4 || ^2.0",
"nyholm/psr7": "^1.5",
"phpstan/phpstan": "^2.0.0",
"phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0",
"scrutinizer/ocular": "^1.8.1",
"symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0",
"symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0",
"symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0",
"unleashedtech/php-coding-standard": "^3.1.1",
"vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0"
},
"suggest": {
"symfony/yaml": "v2.3+ required if using the Front Matter extension"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "2.11-dev"
}
},
"autoload": {
"psr-4": {
"League\\CommonMark\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Colin O'Dell",
"email": "[email protected]",
"homepage": "https://www.colinodell.com",
"role": "Lead Developer"
}
],
"description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)",
"homepage": "https://commonmark.thephpleague.com",
"keywords": [
"commonmark",
"flavored",
"gfm",
"github",
"github-flavored",
"markdown",
"md",
"parser"
],
"support": {
"docs": "https://commonmark.thephpleague.com/",
"forum": "https://github.com/thephpleague/commonmark/discussions",
"issues": "https://github.com/thephpleague/commonmark/issues",
"rss": "https://github.com/thephpleague/commonmark/releases.atom",
"source": "https://github.com/thephpleague/commonmark"
},
"funding": [
{
"url": "https://www.colinodell.com/sponsor",
"type": "custom"
},
{
"url": "https://www.paypal.me/colinpodell/10.00",
"type": "custom"
},
{
"url": "https://github.com/colinodell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/league/commonmark",
"type": "tidelift"
}
],
"time": "2026-08-11T16:06:25+00:00"
},
{
"name": "league/config",
"version": "v1.2.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/config.git",
"reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
"reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
"shasum": ""
},
"require": {
"dflydev/dot-access-data": "^3.0.1",
"nette/schema": "^1.2",
"php": "^7.4 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.8.2",
"phpunit/phpunit": "^9.5.5",
"scrutinizer/ocular": "^1.8.1",
"unleashedtech/php-coding-standard": "^3.1",
"vimeo/psalm": "^4.7.3"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.2-dev"
}
},
"autoload": {
"psr-4": {
"League\\Config\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Colin O'Dell",
"email": "[email protected]",
"homepage": "https://www.colinodell.com",
"role": "Lead Developer"
}
],
"description": "Define configuration arrays with strict schemas and access values with dot notation",
"homepage": "https://config.thephpleague.com",
"keywords": [
"array",
"config",
"configuration",
"dot",
"dot-access",
"nested",
"schema"
],
"support": {
"docs": "https://config.thephpleague.com/",
"issues": "https://github.com/thephpleague/config/issues",
"rss": "https://github.com/thephpleague/config/releases.atom",
"source": "https://github.com/thephpleague/config"
},
"funding": [
{
"url": "https://www.colinodell.com/sponsor",
"type": "custom"
},
{
"url": "https://www.paypal.me/colinpodell/10.00",
"type": "custom"
},
{
"url": "https://github.com/colinodell",
"type": "github"
}
],
"time": "2022-12-11T20:36:23+00:00"
},
{
"name": "league/flysystem",
"version": "3.35.2",
@@ -3338,6 +3602,164 @@
],
"time": "2025-01-08T20:10:23+00:00"
},
{
"name": "nette/schema",
"version": "v1.3.6",
"source": {
"type": "git",
"url": "https://github.com/nette/schema.git",
"reference": "c54350438cd6914616f790a49cb424605f421562"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562",
"reference": "c54350438cd6914616f790a49cb424605f421562",
"shasum": ""
},
"require": {
"nette/utils": "^4.0",
"php": "8.1 - 8.5"
},
"require-dev": {
"nette/phpstan-rules": "^1.0",
"nette/tester": "^2.6",
"phpstan/extension-installer": "^1.4@stable",
"phpstan/phpstan": "^2.1.39@stable",
"tracy/tracy": "^2.8"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.3-dev"
}
},
"autoload": {
"psr-4": {
"Nette\\": "src"
},
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause",
"GPL-2.0-only",
"GPL-3.0-only"
],
"authors": [
{
"name": "David Grudl",
"homepage": "https://davidgrudl.com"
},
{
"name": "Nette Community",
"homepage": "https://nette.org/contributors"
}
],
"description": "📐 Nette Schema: validating data structures against a given Schema.",
"homepage": "https://nette.org",
"keywords": [
"config",
"nette"
],
"support": {
"issues": "https://github.com/nette/schema/issues",
"source": "https://github.com/nette/schema/tree/v1.3.6"
},
"time": "2026-08-16T21:58:41+00:00"
},
{
"name": "nette/utils",
"version": "v4.1.5",
"source": {
"type": "git",
"url": "https://github.com/nette/utils.git",
"reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0",
"reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0",
"shasum": ""
},
"require": {
"php": "8.2 - 8.5"
},
"conflict": {
"nette/finder": "<3",
"nette/schema": "<1.2.2"
},
"require-dev": {
"jetbrains/phpstorm-attributes": "^1.2",
"nette/phpstan-rules": "^1.0",
"nette/tester": "^2.5",
"phpstan/extension-installer": "^1.4@stable",
"phpstan/phpstan": "^2.1@stable",
"tracy/tracy": "^2.9"
},
"suggest": {
"ext-gd": "to use Image",
"ext-iconv": "to use Strings::chr(), ord() and reverse()",
"ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
"ext-json": "to use Nette\\Utils\\Json",
"ext-mbstring": "to use Strings::lower() etc...",
"ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.1-dev"
}
},
"autoload": {
"psr-4": {
"Nette\\": "src"
},
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause",
"GPL-2.0-only",
"GPL-3.0-only"
],
"authors": [
{
"name": "David Grudl",
"homepage": "https://davidgrudl.com"
},
{
"name": "Nette Community",
"homepage": "https://nette.org/contributors"
}
],
"description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.",
"homepage": "https://nette.org",
"keywords": [
"array",
"core",
"datetime",
"images",
"json",
"nette",
"paginator",
"password",
"slugify",
"string",
"unicode",
"utf-8",
"utility",
"validation"
],
"support": {
"issues": "https://github.com/nette/utils/issues",
"source": "https://github.com/nette/utils/tree/v4.1.5"
},
"time": "2026-07-17T23:02:45+00:00"
},
{
"name": "oneup/uploader-bundle",
"version": "4.0.1",
+11 -9
View File
@@ -5,10 +5,12 @@
# Parsed on demand by App\Config\EmailTextCatalog. This file is NOT loaded by the
# container, which is why it does not live in config/packages/.
#
# The text is plain text, never markup: {platzhalter} tokens are substituted by
# App\Email\EmailTextRenderer after escaping, *Sternchen* make text bold (write \* for a
# literal one), a blank line starts a new paragraph, and bare URLs and mail addresses
# become links by themselves. A placeholder without a value prints "-".
# The body is Markdown, never markup: **fett**, *kursiv*, "## Ueberschrift", "- Liste"
# and "1. Nummerierung" (write \* for a literal asterisk), a blank line starts a new
# paragraph, a single newline stays a line break, and bare URLs and mail addresses become
# links by themselves. Rendered by App\Email\MailBodyRenderer. The headline is plain text
# and takes no Markdown. {platzhalter} tokens are substituted after rendering, escaped, so
# what a teamer wrote can never become markup. A placeholder without a value prints "-".
#
# Every mail closes with the same "Zum Portal" button, rendered by email/layout.html.twig.
@@ -19,7 +21,7 @@ texts:
subject: 'Dein Einsatz wurde angenommen'
headline: 'Herzlichen Glückwunsch!'
body: |-
*Dein Einsatz {destination} wurde angenommen!*
**Dein Einsatz {destination} wurde angenommen!**
Zusätzliche Absprachen: {specialAgreements}
@@ -86,7 +88,7 @@ texts:
body: |-
bitte denke daran, deinen ausgefüllten Honorarvertrag für folgenden Einsatz hochzuladen. Vorher können wir nicht fest mit dir planen und behalten uns vor den Einsatz anderweitig zu vergeben.
*{destination}*
**{destination}**
reminder_invoice_upload:
label: 'Erinnerung: Honorarnote hochladen'
@@ -105,7 +107,7 @@ texts:
body: |-
für deinen Einsatz {destination} liegt uns noch immer keine Honorarnote vor.
Heute endet die Frist von {invoiceUploadDeadlineDays} Tagen nach deinem Einsatz. *Bitte lade deine Honorarnote noch heute hoch* - danach können wir sie eventuell nicht mehr annehmen.
Heute endet die Frist von {invoiceUploadDeadlineDays} Tagen nach deinem Einsatz. **Bitte lade deine Honorarnote noch heute hoch** - danach können wir sie eventuell nicht mehr annehmen.
Bei Fragen melde dich gerne unter [email protected]
@@ -115,11 +117,11 @@ texts:
subject: 'Reminder: Dein Einsatz {destination}'
headline: 'Hallo aus Köln,'
body: |-
dein Einsatz als {jobProfile}, {product} vom {dateFrom} bis {dateTo} beginnt *in {diffInDays} Tagen*.
dein Einsatz als {jobProfile}, {product} vom {dateFrom} bis {dateTo} beginnt **in {diffInDays} Tagen**.
Wir hoffen, dass du ich schon freust und wünschen dir viel Spaß und Erfolg! Finale Infos erhältst du, falls noch nicht geschehen, ein paar Tage vor deinem Einsatz von deinen zuständigen Haus- oder Reisemanager:innen.
Bitte denk daran, deine *ausgefüllte Honorarnote bis zu {invoiceUploadDeadlineDays} Tage nach deinem Einsatz* in My E&P Team hochzuladen.
Bitte denk daran, deine **ausgefüllte Honorarnote bis zu {invoiceUploadDeadlineDays} Tage nach deinem Einsatz** in My E&P Team hochzuladen.
Liebe Grüße,
dein Team Personalabteilung
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Admin-edited mail bodies used to be plain text in which a single *asterisk* made a word
* bold. They are Markdown now (App\Email\MailBodyRenderer), where that is italic and bold
* is written **twice**, so every saved body is rewritten once.
*
* Only `body` is touched: `subject` never took markers, and `headline` is plain text under
* the new renderer, so a marker left in one is meant to stay the character it is.
*/
final class Version20260820000000 extends AbstractMigration
{
/**
* A single asterisk pair on one line. The lookarounds keep three things out: an already
* doubled **bold**, an escaped \* an admin wrote to get a literal asterisk, and a marker
* with whitespace next to it - "a * b * c" and "5 * 3" are arithmetic, not emphasis.
* That last rule is CommonMark's own, and slightly stricter than the renderer this
* replaces: a text that leant on the old, laxer matching loses its bold here rather
* than turning into something nobody wrote.
*/
private const SINGLE_EMPHASIS = '/(?<![\\\\*])\*(?!\*)(?!\s)([^*\n]+?)(?<![\s\\\\*])\*(?!\*)/u';
private const DOUBLE_EMPHASIS = '/(?<![\\\\*])\*\*(?!\*)(?!\s)([^*\n]+?)(?<![\s\\\\*])\*\*(?!\*)/u';
public function getDescription(): string
{
return 'Rewrite *bold* to **bold** in saved email texts, which are Markdown now';
}
public function up(Schema $schema): void
{
$this->rewriteBodies(self::SINGLE_EMPHASIS, '**$1**');
}
public function down(Schema $schema): void
{
$this->rewriteBodies(self::DOUBLE_EMPHASIS, '*$1*');
}
/**
* The rewrite happens in PHP, one row at a time: MySQL's REGEXP_REPLACE has no
* lookbehind, and getting this wrong would quietly mangle wording that goes out to
* every teamer. Only the read is done here and now - the writes go through addSql, so
* they are logged and rolled back like any other migration statement.
*/
private function rewriteBodies(string $pattern, string $replacement): void
{
foreach ($this->connection->fetchAllAssociative('SELECT id, body FROM email_text') as $row) {
$body = preg_replace($pattern, $replacement, (string) $row['body']);
if (null === $body || $body === $row['body']) {
continue;
}
$this->addSql('UPDATE email_text SET body = ? WHERE id = ?', [$body, $row['id']]);
}
}
}
@@ -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.
*/
+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;
}
}
+12 -6
View File
@@ -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,
@@ -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 %}
+2 -1
View File
@@ -7,6 +7,7 @@ namespace App\Tests\Email;
use App\Config\EmailTextCatalog;
use App\Config\EmailTextKey;
use App\Email\EmailTextRenderer;
use App\Email\MailBodyRenderer;
use App\Email\Mailer;
use App\Repository\EmailTextRepository;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
@@ -42,7 +43,7 @@ class EmailTextMailRenderingTest extends KernelTestCase
$repository = $this->createMock(EmailTextRepository::class);
$repository->method('findByKey')->willReturn(null);
$text = (new EmailTextRenderer($catalog, $repository))
$text = (new EmailTextRenderer($catalog, $repository, $container->get(MailBodyRenderer::class)))
->render($key, $placeholders)
;
+55 -10
View File
@@ -7,6 +7,7 @@ namespace App\Tests\Email;
use App\Config\EmailTextCatalog;
use App\Config\EmailTextKey;
use App\Email\EmailTextRenderer;
use App\Email\MailBodyRenderer;
use App\Entity\EmailText;
use App\Repository\EmailTextRepository;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
@@ -41,7 +42,11 @@ class EmailTextRendererTest extends KernelTestCase
$this->assertStringContainsString('Skireise', $rendered->bodyHtml);
}
public function testMarkupTypedByAnAdminIsEscaped(): void
/**
* Admins write Markdown, so markup they type is dropped rather than shown back to them
* as entities - a mail full of &lt;script&gt; would be nobody's intention either.
*/
public function testMarkupTypedByAnAdminIsStripped(): void
{
$rendered = $this->render(
$this->emailText(body: 'Hallo <script>alert(1)</script>'),
@@ -49,7 +54,7 @@ class EmailTextRendererTest extends KernelTestCase
);
$this->assertStringNotContainsString('<script>', $rendered->bodyHtml);
$this->assertStringContainsString('&lt;script&gt;', $rendered->bodyHtml);
$this->assertStringNotContainsString('&lt;script&gt;', $rendered->bodyHtml);
}
/**
@@ -66,16 +71,53 @@ class EmailTextRendererTest extends KernelTestCase
$this->assertStringContainsString('&lt;img', $rendered->bodyHtml);
}
public function testAsterisksMakeTextBold(): void
public function testDoubleAsterisksMakeTextBold(): void
{
$rendered = $this->render(
$this->emailText(body: 'Das ist *wichtig* und das nicht'),
$this->emailText(body: 'Das ist **wichtig** und das nicht'),
['destination' => 'Skireise']
);
$this->assertSame('<p>Das ist <strong>wichtig</strong> und das nicht</p>', $rendered->bodyHtml);
}
public function testSingleAsterisksMakeTextItalic(): void
{
$rendered = $this->render(
$this->emailText(body: 'Das ist *anders* gemeint'),
['destination' => 'Skireise']
);
$this->assertSame('<p>Das ist <em>anders</em> gemeint</p>', $rendered->bodyHtml);
}
public function testDashesAndNumbersBecomeLists(): void
{
$rendered = $this->render(
$this->emailText(body: "- Ausweis\n- Bankverbindung"),
['destination' => 'Skireise']
);
$this->assertSame(
"<ul>\n<li>Ausweis</li>\n<li>Bankverbindung</li>\n</ul>",
$rendered->bodyHtml
);
}
/**
* The mail sets its own h1 above the body and styles nothing below h3, so an admin's
* heading is folded into what is left.
*/
public function testHeadingsAreFoldedIntoTheLevelsTheMailStyles(): void
{
$rendered = $this->render(
$this->emailText(body: "# Ganz gross\n\n#### Ganz klein"),
['destination' => 'Skireise']
);
$this->assertSame("<h2>Ganz gross</h2>\n<h3>Ganz klein</h3>", $rendered->bodyHtml);
}
public function testAnEscapedAsteriskStaysLiteral(): void
{
$rendered = $this->render(
@@ -94,11 +136,11 @@ class EmailTextRendererTest extends KernelTestCase
{
$rendered = $this->render(
$this->emailText(body: 'Einsatz {destination}'),
['destination' => '*nicht fett*']
['destination' => '**nicht fett**']
);
$this->assertStringNotContainsString('<strong>', $rendered->bodyHtml);
$this->assertStringContainsString('*nicht fett*', $rendered->bodyHtml);
$this->assertStringContainsString('**nicht fett**', $rendered->bodyHtml);
}
public function testAnEmptyPlaceholderFallsBackToADash(): void
@@ -196,14 +238,15 @@ class EmailTextRendererTest extends KernelTestCase
$renderer = new EmailTextRenderer(
self::getContainer()->get(EmailTextCatalog::class),
$repository
$repository,
self::getContainer()->get(MailBodyRenderer::class)
);
$rendered = $renderer->renderValues(
EmailTextKey::APPLICATION_REJECTED,
'Entwurf {destination}',
'Überschrift',
'Entwurfstext *fett*',
'Entwurfstext **fett**',
['destination' => 'Skireise']
);
@@ -221,7 +264,8 @@ class EmailTextRendererTest extends KernelTestCase
$renderer = new EmailTextRenderer(
self::getContainer()->get(EmailTextCatalog::class),
$this->createMock(EmailTextRepository::class)
$this->createMock(EmailTextRepository::class),
self::getContainer()->get(MailBodyRenderer::class)
);
$rendered = $renderer->renderValues(
@@ -252,7 +296,8 @@ class EmailTextRendererTest extends KernelTestCase
$renderer = new EmailTextRenderer(
self::getContainer()->get(EmailTextCatalog::class),
$repository
$repository,
self::getContainer()->get(MailBodyRenderer::class)
);
return $renderer->render(EmailTextKey::APPLICATION_REJECTED, $placeholders);
+174
View File
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Tests\Email;
use App\Email\MailBodyRenderer;
use PHPUnit\Framework\TestCase;
/**
* The renderer is the only thing standing between what an admin typed and the HTML of a
* mail, and between what a teamer typed and that same HTML. Most of what is asserted here
* is where that line runs: admin text may become markup, a substituted value may not.
*/
class MailBodyRendererTest extends TestCase
{
private MailBodyRenderer $renderer;
protected function setUp(): void
{
$this->renderer = new MailBodyRenderer();
}
public function testDoubleAsterisksMakeTextBoldAndSingleOnesItalic(): void
{
$this->assertSame(
'<p><strong>fett</strong> und <em>kursiv</em></p>',
$this->renderer->render('**fett** und *kursiv*', [])
);
}
public function testDashesBecomeABulletList(): void
{
$this->assertSame(
"<ul>\n<li>Ausweis</li>\n<li>Bankverbindung</li>\n</ul>",
$this->renderer->render("- Ausweis\n- Bankverbindung", [])
);
}
public function testNumbersBecomeANumberedList(): void
{
$this->assertSame(
"<ol>\n<li>Erstens</li>\n<li>Zweitens</li>\n</ol>",
$this->renderer->render("1. Erstens\n2. Zweitens", [])
);
}
/**
* The mail layout sets its own h1 above the body and styles nothing below h3, so every
* heading is folded into that range rather than producing a tag nobody styled.
*/
public function testHeadingsAreFoldedIntoTheLevelsTheMailStyles(): void
{
$this->assertSame(
"<h2>Gross</h2>\n<h2>Mittel</h2>\n<h3>Klein</h3>\n<h3>Winzig</h3>",
$this->renderer->render("# Gross\n\n## Mittel\n\n### Klein\n\n###### Winzig", [])
);
}
public function testBlankLinesBecomeParagraphsAndSingleBreaksStay(): void
{
$this->assertSame(
"<p>Erste Zeile<br />\nZweite Zeile</p>\n<p>Neuer Absatz</p>",
$this->renderer->render("Erste Zeile\nZweite Zeile\n\nNeuer Absatz", [])
);
}
public function testBareUrlsAndMailAddressesBecomeLinks(): void
{
$html = $this->renderer->render('Schreib an [email protected] oder https://ep-reisen.de/team.', []);
$this->assertStringContainsString('<a href="mailto:[email protected]">[email protected]</a>', $html);
// The full stop ends the sentence, it is not part of the address.
$this->assertStringContainsString('<a href="https://ep-reisen.de/team">https://ep-reisen.de/team</a>.', $html);
}
public function testAnEscapedAsteriskStaysLiteral(): void
{
$this->assertSame('<p>Ein Sternchen: *</p>', $this->renderer->render('Ein Sternchen: \*', []));
}
/**
* Admins write Markdown, so markup they type is dropped rather than passed through.
*/
public function testMarkupTypedByAnAdminIsStripped(): void
{
$html = $this->renderer->render('Hallo <b>alle</b> <script>alert(1)</script>', []);
$this->assertStringNotContainsString('<b>', $html);
$this->assertStringNotContainsString('<script', $html);
}
/**
* An image in a mailing would be loaded from an address nobody reviewed, so only the
* alt text survives.
*/
public function testAnImageIsReducedToItsAltText(): void
{
$html = $this->renderer->render('![Ein Bild](https://example.org/pixel.png)', []);
$this->assertStringNotContainsString('<img', $html);
$this->assertStringContainsString('Ein Bild', $html);
}
public function testAnUnsafeLinkLosesItsTarget(): void
{
$html = $this->renderer->render('[klick](javascript:alert(1))', []);
$this->assertStringNotContainsString('javascript:', $html);
$this->assertStringContainsString('klick', $html);
}
public function testPlaceholdersAreSubstituted(): void
{
$this->assertSame(
'<p>Hallo Anna, dein Einsatz Skireise</p>',
$this->renderer->render(
'Hallo {{vorname}}, dein Einsatz {destination}',
['{{vorname}}' => 'Anna', '{destination}' => 'Skireise']
)
);
}
/**
* The load-bearing case. A teamer's own words go in after the parser has run, so
* nothing in them can become a tag, a heading, a list or emphasis - whatever they
* happen to contain.
*/
public function testNothingInAPlaceholderValueBecomesMarkup(): void
{
$html = $this->renderer->render('Kommentar: {kommentar}', [
'{kommentar}' => "**nicht fett**\n- keine Liste\n# keine Überschrift <b>x</b>",
]);
$this->assertStringNotContainsString('<strong>', $html);
$this->assertStringNotContainsString('<ul>', $html);
$this->assertStringNotContainsString('<h1', $html);
$this->assertStringNotContainsString('<h2', $html);
$this->assertStringNotContainsString('<b>', $html);
$this->assertStringContainsString('**nicht fett**', $html);
$this->assertStringContainsString('- keine Liste', $html);
$this->assertStringContainsString('&lt;b&gt;x&lt;/b&gt;', $html);
}
/**
* A rejection comment arrives as one multi-line value inside a single paragraph.
*/
public function testLineBreaksInsideAPlaceholderValueSurvive(): void
{
$this->assertSame(
"<p>Erste Zeile<br />\nZweite Zeile</p>",
$this->renderer->render('{kommentar}', ['{kommentar}' => "Erste Zeile\nZweite Zeile"])
);
}
/**
* Documented rather than desired: substitution runs over the finished HTML, so a token
* an admin put in backticks is replaced like any other. Harmless, but surprising enough
* to pin down.
*/
public function testATokenInACodeSpanIsStillSubstituted(): void
{
$this->assertSame(
'<p><code>Skireise</code></p>',
$this->renderer->render('`{destination}`', ['{destination}' => 'Skireise'])
);
}
public function testAnEmptyBodyRendersAsNothing(): void
{
$this->assertSame('', $this->renderer->render('', []));
$this->assertSame('', $this->renderer->render(null, []));
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Tests\Migrations;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Yaml\Yaml;
/**
* Version20260820000000 rewrites the wording of mails that go out to every teamer, and it
* runs exactly once with nobody watching, so the patterns it does that with are pinned
* here. The migration is not autoloaded (only src/ and tests/ are), hence the require.
*/
class EmphasisRewriteTest extends TestCase
{
private string $single;
private string $double;
protected function setUp(): void
{
require_once __DIR__.'/../../migrations/Version20260820000000.php';
$reflection = new \ReflectionClass(\DoctrineMigrations\Version20260820000000::class);
$this->single = $reflection->getConstant('SINGLE_EMPHASIS');
$this->double = $reflection->getConstant('DOUBLE_EMPHASIS');
}
/**
* @dataProvider upCases
*/
public function testTheUpwardRewrite(string $before, string $after): void
{
$this->assertSame($after, preg_replace($this->single, '**$1**', $before));
}
/**
* @return iterable<string, array{string, string}>
*/
public static function upCases(): iterable
{
yield 'one word' => ['Das ist *wichtig*', 'Das ist **wichtig**'];
yield 'several words' => ['*Dein Einsatz wurde angenommen!*', '**Dein Einsatz wurde angenommen!**'];
yield 'around a placeholder' => ['*{destination}*', '**{destination}**'];
yield 'already bold' => ['Das ist **wichtig**', 'Das ist **wichtig**'];
yield 'escaped asterisk' => ['Ein Sternchen: \*', 'Ein Sternchen: \*'];
// Whitespace next to a marker means it is not emphasis - CommonMark's own rule, and
// the reason arithmetic and stray asterisks come through untouched.
yield 'multiplication' => ['5 * 3 = 15', '5 * 3 = 15'];
yield 'spaced asterisks' => ['a * b * c', 'a * b * c'];
yield 'bullet at line start' => ["* Ausweis\n* Bankverbindung", "* Ausweis\n* Bankverbindung"];
yield 'not across lines' => ["*offen\ngeblieben*", "*offen\ngeblieben*"];
}
public function testTheRewriteIsReversible(): void
{
$before = 'Das ist *wichtig* und *{destination}* auch';
$up = preg_replace($this->single, '**$1**', $before);
$this->assertSame($before, preg_replace($this->double, '*$1*', $up));
}
/**
* The delivered defaults were rewritten by hand in the same commit, so nothing should
* be left in them for the migration to find. Read through the parser rather than off
* the raw file: the header comment documents *kursiv* on purpose.
*/
public function testTheDeliveredDefaultsAreAlreadyMigrated(): void
{
$texts = Yaml::parseFile(__DIR__.'/../../config/email_texts.yaml')['texts'];
foreach ($texts as $key => $text) {
$body = (string) ($text['body'] ?? '');
$this->assertSame(
$body,
preg_replace($this->single, '**$1**', $body),
sprintf('Default wording of "%s" still carries single-asterisk emphasis', $key)
);
}
}
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Tests\Service\Teamer;
use App\Email\MailBodyRenderer;
use App\Email\Mailer;
use App\Entity\Teamer;
use App\Entity\User;
@@ -30,9 +31,12 @@ class TeamerMailingServiceTest extends TestCase
$this->teamerRepository = $this->createMock(TeamerRepository::class);
$this->mailer = $this->createMock(Mailer::class);
// The real renderer, not a mock: what the mailing puts in front of a teamer is
// Markdown turned into HTML, and getting that wrong is the whole risk here.
$this->service = new TeamerMailingService(
$this->teamerRepository,
$this->mailer,
new MailBodyRenderer(),
$this->createMock(LoggerInterface::class),
'mailing',
);
@@ -111,7 +115,7 @@ class TeamerMailingServiceTest extends TestCase
->expects($this->exactly(2))
->method('createAndSendEmail')
->willReturnCallback(function (array $context, array $options) use (&$sent): void {
$sent[] = [$options['to'], $options['subject'], $context['message']];
$sent[] = [$options['to'], $options['subject'], $context['bodyHtml']];
})
;
@@ -123,8 +127,8 @@ class TeamerMailingServiceTest extends TestCase
));
$this->assertSame([
['[email protected]', 'Hallo Anna', 'Servus Anna Berg'],
['[email protected]', 'Hallo Bea', 'Servus Bea Ohm'],
['[email protected]', 'Hallo Anna', '<p>Servus Anna Berg</p>'],
['[email protected]', 'Hallo Bea', '<p>Servus Bea Ohm</p>'],
], $sent);
}
@@ -210,7 +214,7 @@ class TeamerMailingServiceTest extends TestCase
->expects($this->once())
->method('createAndSendEmail')
->with(
['message' => 'Servus Rita Kern'],
['bodyHtml' => '<p>Servus Rita Kern</p>'],
$this->callback(function (array $options): bool {
$this->assertSame('[email protected]', $options['to']);
$this->assertSame('[Vorschau] Hallo Rita', $options['subject']);