feat: markdown in email body for mailing and transactional
This commit is contained in:
@@ -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)
|
||||
;
|
||||
|
||||
|
||||
@@ -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('<script>', $rendered->bodyHtml);
|
||||
$this->assertStringNotContainsString('<script>', $rendered->bodyHtml);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,16 +71,53 @@ class EmailTextRendererTest extends KernelTestCase
|
||||
$this->assertStringContainsString('<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);
|
||||
|
||||
@@ -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('', []);
|
||||
|
||||
$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('<b>x</b>', $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, []));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user