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
+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 <script> 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, []));
}
}