feat: admin editable email templates

addresses #869eg7ptr
This commit is contained in:
Björn Fromme
2026-08-17 10:39:19 +02:00
parent 804dab335f
commit 3e08965e48
49 changed files with 1760 additions and 276 deletions
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Tests\Email;
use App\Config\EmailTextCatalog;
use App\Config\EmailTextKey;
use App\Email\EmailTextRenderer;
use App\Email\Mailer;
use App\Repository\EmailTextRepository;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Renders every editable mail through the real template stack.
*
* The unit tests cover what the renderer produces; this covers that the result actually
* survives email/generic.html.twig and the layout around it - a mail that only breaks
* when it is assembled would otherwise go unnoticed until a teamer fails to receive it.
*/
class EmailTextMailRenderingTest extends KernelTestCase
{
/**
* @dataProvider provideKeys
*/
public function testTheMailRenders(EmailTextKey $key): void
{
self::bootKernel();
$container = self::getContainer();
$catalog = $container->get(EmailTextCatalog::class);
$definition = $catalog->get($key);
$placeholders = [];
foreach ($definition->getPlaceholderNames() as $name) {
$placeholders[$name] = '['.$name.']';
}
// Renders the delivered wording: the test environment has no database, and the
// defaults are what a mail falls back to anyway.
$repository = $this->createMock(EmailTextRepository::class);
$repository->method('findByKey')->willReturn(null);
$text = (new EmailTextRenderer($catalog, $repository))
->render($key, $placeholders)
;
$email = $container->get(Mailer::class)->create(['text' => $text], [
'from' => '[email protected]',
'to' => '[email protected]',
'subject' => $text->subject,
'template' => 'email/generic.html.twig',
'subject_parameters' => [],
'attachments' => [],
'transport' => null,
'bus_transport' => null,
]);
$html = $email->getHtmlBody();
$this->assertNotEmpty($html);
$this->assertStringContainsString($text->headline, $html);
// The layout closes every mail with the same button, pointing at the site root
// rather than a deep link so that a client's link preview cannot trip the
// unauthorized-access logging.
$this->assertStringContainsString('class="button">Zum Portal</a>', $html);
$this->assertStringNotContainsString('/teamer/disposition/detail', $html);
// Every placeholder the mail declares has to reach the rendered body, or the
// wording lost a detail somewhere between catalogue and template.
foreach ($definition->getPlaceholderNames() as $name) {
$this->assertStringContainsString(
'['.$name.']',
$html.$email->getSubject(),
sprintf('Placeholder "%s" does not show up in the rendered mail', $name)
);
}
}
public static function provideKeys(): iterable
{
foreach (EmailTextKey::cases() as $key) {
yield $key->value => [$key];
}
}
}
+211
View File
@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace App\Tests\Email;
use App\Config\EmailTextCatalog;
use App\Config\EmailTextKey;
use App\Email\EmailTextRenderer;
use App\Entity\EmailText;
use App\Repository\EmailTextRepository;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* The renderer is the only thing standing between text an admin (or a teamer) typed and
* the HTML of a mail, so most of what is asserted here is that nothing but the renderer
* itself can introduce a tag.
*/
class EmailTextRendererTest extends KernelTestCase
{
public function testTheStoredWordingWinsOverTheDefault(): void
{
$rendered = $this->render(
$this->emailText(subject: 'Neuer Betreff', body: 'Neuer Text'),
['destination' => 'Skireise']
);
$this->assertSame('Neuer Betreff', $rendered->subject);
$this->assertSame('<p>Neuer Text</p>', $rendered->bodyHtml);
}
/**
* A fresh database has no rows at all, so the delivered wording has to carry the mails
* on its own.
*/
public function testTheDefaultWordingIsUsedWithoutAStoredRow(): void
{
$rendered = $this->render(null, ['destination' => 'Skireise']);
$this->assertSame('Deine Bewerbung wurde abgelehnt', $rendered->subject);
$this->assertStringContainsString('Skireise', $rendered->bodyHtml);
}
public function testMarkupTypedByAnAdminIsEscaped(): void
{
$rendered = $this->render(
$this->emailText(body: 'Hallo <script>alert(1)</script>'),
['destination' => 'Skireise']
);
$this->assertStringNotContainsString('<script>', $rendered->bodyHtml);
$this->assertStringContainsString('&lt;script&gt;', $rendered->bodyHtml);
}
/**
* destination, reason and comment are all typed by people, not by the application.
*/
public function testMarkupInsideAPlaceholderValueIsEscaped(): void
{
$rendered = $this->render(
$this->emailText(body: 'Einsatz {destination}'),
['destination' => '<img src=x onerror=alert(1)>']
);
$this->assertStringNotContainsString('<img', $rendered->bodyHtml);
$this->assertStringContainsString('&lt;img', $rendered->bodyHtml);
}
public function testAsterisksMakeTextBold(): void
{
$rendered = $this->render(
$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 testAnEscapedAsteriskStaysLiteral(): void
{
$rendered = $this->render(
$this->emailText(body: 'Ein Sternchen: \*'),
['destination' => 'Skireise']
);
$this->assertSame('<p>Ein Sternchen: *</p>', $rendered->bodyHtml);
}
/**
* Emphasis is the admin's to give. If a teamer could hand one over in a rejection
* comment, the substitution order would be wrong and escaping would be next.
*/
public function testAsterisksInsideAPlaceholderValueStayLiteral(): void
{
$rendered = $this->render(
$this->emailText(body: 'Einsatz {destination}'),
['destination' => '*nicht fett*']
);
$this->assertStringNotContainsString('<strong>', $rendered->bodyHtml);
$this->assertStringContainsString('*nicht fett*', $rendered->bodyHtml);
}
public function testAnEmptyPlaceholderFallsBackToADash(): void
{
$rendered = $this->render(
$this->emailText(body: 'Absprachen: {destination}'),
['destination' => ' ']
);
$this->assertSame('<p>Absprachen: -</p>', $rendered->bodyHtml);
}
/**
* A caller that forgets a placeholder should print the same dash, not the raw token.
*/
public function testAMissingPlaceholderFallsBackToADash(): void
{
$rendered = $this->render($this->emailText(body: 'Absprachen: {destination}'), []);
$this->assertSame('<p>Absprachen: -</p>', $rendered->bodyHtml);
}
public function testBlankLinesBecomeParagraphsAndSingleBreaksStay(): void
{
$rendered = $this->render(
$this->emailText(body: "Erste Zeile\nZweite Zeile\n\nNeuer Absatz"),
['destination' => 'Skireise']
);
$this->assertSame(
"<p>Erste Zeile<br />\nZweite Zeile</p>\n<p>Neuer Absatz</p>",
$rendered->bodyHtml
);
}
/**
* A rejection comment arrives as one multi-line value inside a single paragraph.
*/
public function testLineBreaksInsideAPlaceholderValueSurvive(): void
{
$rendered = $this->render(
$this->emailText(body: '{destination}'),
['destination' => "Erste Zeile\nZweite Zeile"]
);
$this->assertSame("<p>Erste Zeile<br />\nZweite Zeile</p>", $rendered->bodyHtml);
}
public function testMailAddressesAndUrlsBecomeLinks(): void
{
$rendered = $this->render(
$this->emailText(body: 'Schreib an [email protected] oder https://ep-reisen.de/team.'),
['destination' => 'Skireise']
);
$this->assertStringContainsString(
'<a href="mailto:[email protected]">[email protected]</a>',
$rendered->bodyHtml
);
// 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>.',
$rendered->bodyHtml
);
}
/**
* Mail clients show the subject as plain text, so escaping it would leak entities
* into the inbox.
*/
public function testTheSubjectIsPlainText(): void
{
$rendered = $this->render(
$this->emailText(subject: 'Einsatz {destination}'),
['destination' => 'Ski & Snowboard']
);
$this->assertSame('Einsatz Ski & Snowboard', $rendered->subject);
}
/**
* @param array<string, string|int|null> $placeholders
*/
private function render(?EmailText $emailText, array $placeholders): \App\Model\RenderedEmailTextDto
{
$repository = $this->createMock(EmailTextRepository::class);
$repository
->method('findByKey')
->willReturn($emailText)
;
self::bootKernel();
$renderer = new EmailTextRenderer(
self::getContainer()->get(EmailTextCatalog::class),
$repository
);
return $renderer->render(EmailTextKey::APPLICATION_REJECTED, $placeholders);
}
private function emailText(string $subject = 'Betreff', string $body = 'Text'): EmailText
{
return (new EmailText(EmailTextKey::APPLICATION_REJECTED))
->setSubject($subject)
->setBody($body)
->setHeadline('')
;
}
}