diff --git a/assets/mjml/layout.mjml b/assets/mjml/layout.mjml index 7454392..9d74aac 100644 --- a/assets/mjml/layout.mjml +++ b/assets/mjml/layout.mjml @@ -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; + } @@ -70,8 +90,19 @@

Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto culpa delectus dolores - earum eius fugiat in nesciunt quas quidem vitae? + earum eius fugiat in nesciunt quas quidem vitae, eius fugiat?

+ +

+ Lorem ipsum +

+
    +
  1. Lorem ipsum dolor
  2. +
  3. Consectetur adipisicing elit
  4. +

Button diff --git a/assets/styles/_components.css b/assets/styles/_components.css index e86fd04..9bef4ec 100644 --- a/assets/styles/_components.css +++ b/assets/styles/_components.css @@ -6,3 +6,4 @@ @import "components/toast.css"; @import "components/tooltip.css"; @import "components/timeline.css"; +@import "components/mail-body.css"; diff --git a/assets/styles/components/mail-body.css b/assets/styles/components/mail-body.css new file mode 100644 index 0000000..0690404 --- /dev/null +++ b/assets/styles/components/mail-body.css @@ -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; +} diff --git a/composer.json b/composer.json index 19c35c6..d34a178 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 8e6ca0c..eb90068 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "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": "colinodell@gmail.com", + "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": "colinodell@gmail.com", + "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", diff --git a/config/email_texts.yaml b/config/email_texts.yaml index a2f6296..52aad82 100644 --- a/config/email_texts.yaml +++ b/config/email_texts.yaml @@ -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 team@ep-reisen.de @@ -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 diff --git a/migrations/Version20260820000000.php b/migrations/Version20260820000000.php new file mode 100644 index 0000000..481812c --- /dev/null +++ b/migrations/Version20260820000000.php @@ -0,0 +1,64 @@ +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']]); + } + } +} diff --git a/src/Controller/Admin/Teamer/MailingController.php b/src/Controller/Admin/Teamer/MailingController.php index 463b413..4b22413 100644 --- a/src/Controller/Admin/Teamer/MailingController.php +++ b/src/Controller/Admin/Teamer/MailingController.php @@ -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. */ diff --git a/src/Email/EmailTextRenderer.php b/src/Email/EmailTextRenderer.php index 60afc5b..23ad950 100644 --- a/src/Email/EmailTextRenderer.php +++ b/src/Email/EmailTextRenderer.php @@ -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

, 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 $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

with its own margins, so keeping real paragraphs preserves - // the spacing the hand-written templates had - a single

full of
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[] = '

'.nl2br($block).'

'; - } - - return $this->linkify(implode("\n", $paragraphs)); - } - - /** - * @param array $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 . A literal asterisk is written \*. - */ - private function emphasise(string $text): string - { - $text = preg_replace( - '/(?$1', - $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( - '~(?https?://[^\s<]+)|(?[\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('
%s%s', $url, $url, $trailing); - } - - return sprintf('%1$s', $match['email']); - }, - $html + return strtr( + htmlspecialchars(trim($headline), self::ESCAPE_FLAGS, 'UTF-8'), + $tokens ); } } diff --git a/src/Email/MailBodyRenderer.php b/src/Email/MailBodyRenderer.php new file mode 100644 index 0000000..1b30d36 --- /dev/null +++ b/src/Email/MailBodyRenderer.php @@ -0,0 +1,138 @@ + 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

above the body (the headline field), and styles + * nothing below

. 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' => "
\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 $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; + } +} diff --git a/src/Service/Teamer/TeamerMailingService.php b/src/Service/Teamer/TeamerMailingService.php index c1a84e4..917a95f 100644 --- a/src/Service/Teamer/TeamerMailingService.php +++ b/src/Service/Teamer/TeamerMailingService.php @@ -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, diff --git a/templates/_partials/_markdown_hint.html.twig b/templates/_partials/_markdown_hint.html.twig new file mode 100644 index 0000000..465534a --- /dev/null +++ b/templates/_partials/_markdown_hint.html.twig @@ -0,0 +1,24 @@ +{# Shown under both mail editors, so an admin meets the same syntax in each of them. #} +
+
+ Formatierung der Nachricht: +
+
+ {% for example, label in { + '**fett**': 'fett', + '*kursiv*': 'kursiv', + '## Überschrift': 'Überschrift', + '- Punkt': 'Aufzählung', + '1. Punkt': 'Nummerierung', + } %} + + {{ example }} + {{ label }} + + {% endfor %} +
+
+ Eine Leerzeile beginnt einen neuen Absatz, Links und E-Mail-Adressen werden + automatisch verlinkt. +
+
diff --git a/templates/admin/system/email_text/_form.html.twig b/templates/admin/system/email_text/_form.html.twig index 8627b1a..7862ef9 100644 --- a/templates/admin/system/email_text/_form.html.twig +++ b/templates/admin/system/email_text/_form.html.twig @@ -4,6 +4,8 @@ {{ form_row(form.headline) }} {{ form_row(form.body) }} + {{ include('_partials/_markdown_hint.html.twig') }} +
Verfügbare Platzhalter diff --git a/templates/admin/system/email_text/_preview.html.twig b/templates/admin/system/email_text/_preview.html.twig index bdbf567..44a399c 100644 --- a/templates/admin/system/email_text/_preview.html.twig +++ b/templates/admin/system/email_text/_preview.html.twig @@ -6,9 +6,9 @@ {% if text.headline is not empty %}

{{ text.headline | raw }}

{% endif %} - {# The paragraphs come from the mail body, where email/layout.html.twig styles every -

with padding-bottom:16px - pb-4 is the same spacing, so the preview matches. #} -

+ {# .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. #} +
{{ text.bodyHtml | raw }}

diff --git a/templates/admin/teamer/_mailing_preview.html.twig b/templates/admin/teamer/_mailing_preview.html.twig new file mode 100644 index 0000000..1f420a2 --- /dev/null +++ b/templates/admin/teamer/_mailing_preview.html.twig @@ -0,0 +1,15 @@ +

+ Betreff: {{ subject }} +
+{# App\Email\MailBodyRenderer escaped everything before it introduced a tag, which is what + makes |raw safe here - see the contract on that class. #} +
+ {{ bodyHtml | raw }} +

+ Zum Portal +

+
+
+ 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. +
diff --git a/templates/admin/teamer/mailing.html.twig b/templates/admin/teamer/mailing.html.twig index f93349e..a1430d8 100644 --- a/templates/admin/teamer/mailing.html.twig +++ b/templates/admin/teamer/mailing.html.twig @@ -27,29 +27,64 @@ {% endif %}
- {# 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', - } }) }} -
- {{ form_row(form.subject) }} - {{ form_row(form.message) }} -
+ {# 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. -
-
- 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
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', + } }) }} +
+
+
+ {{ form_row(form.subject) }} + {{ form_row(form.message) }} +
+ +
+ {{ include('_partials/_markdown_hint.html.twig') }} +
+ +
+
+ Platzhalter für Betreff und Nachricht, sie werden beim Senden pro Person ersetzt: +
+
+ {% for placeholder, label in placeholders %} + + {{ placeholder }} + {{ label }} + + {% endfor %} +
+
-
- {% for placeholder, label in placeholders %} - - {{ placeholder }} - {{ label }} - - {% endfor %} + +
+
+ Vorschau +
+
+
+ {{ include('admin/teamer/_mailing_preview.html.twig', preview) }} +
+ {# 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. #} +
+ {{ include('_partials/_spinner.html.twig', { 'class': 'w-8 h-8' }) }} +
+
diff --git a/templates/email/layout.html.twig b/templates/email/layout.html.twig index f89f923..07fff40 100644 --- a/templates/email/layout.html.twig +++ b/templates/email/layout.html.twig @@ -1,5 +1,5 @@ - + MyE&P Team @@ -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; + } +
MyE&P Team
-
+
@@ -215,16 +240,22 @@
{% block body %}

Lorem ipsum dolor sit amet

Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto culpa delectus dolores earum eius fugiat in nesciunt quas quidem vitae?

Lorem ipsum dolor

-

Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto culpa delectus dolores earum eius fugiat in nesciunt quas quidem vitae?

+

Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto culpa delectus dolores earum eius fugiat in nesciunt quas quidem vitae, eius fugiat?

+
    +
  • Lorem ipsum dolor
  • +
  • Consectetur adipisicing elit
  • +
+

Lorem ipsum

+
    +
  1. Lorem ipsum dolor
  2. +
  3. Consectetur adipisicing elit
  4. +

Button

-

Lorem ipsum dolor sit amet, consectetur adipisicing elit. Doloribus, eveniet!

{% endblock %} - {% block button %} -

- Zum Portal -

- {% endblock %} +

Lorem ipsum dolor sit amet, consectetur adipisicing elit. Doloribus, eveniet!

{% endblock %} {% block button %}

+ Zum Portal +

{% endblock %}
diff --git a/templates/email/teamer_mailing.html.twig b/templates/email/teamer_mailing.html.twig index e0f181b..4a6b789 100644 --- a/templates/email/teamer_mailing.html.twig +++ b/templates/email/teamer_mailing.html.twig @@ -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 %} -

- {{ message | nl2br }} -

+ {{ bodyHtml | raw }} {% endblock %} diff --git a/tests/Email/EmailTextMailRenderingTest.php b/tests/Email/EmailTextMailRenderingTest.php index bb4c43c..3e8bce2 100644 --- a/tests/Email/EmailTextMailRenderingTest.php +++ b/tests/Email/EmailTextMailRenderingTest.php @@ -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) ; diff --git a/tests/Email/EmailTextRendererTest.php b/tests/Email/EmailTextRendererTest.php index 40577dc..f14dfd7 100644 --- a/tests/Email/EmailTextRendererTest.php +++ b/tests/Email/EmailTextRendererTest.php @@ -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 '), @@ -49,7 +54,7 @@ class EmailTextRendererTest extends KernelTestCase ); $this->assertStringNotContainsString('', []); + + $this->assertStringNotContainsString('', $html); + $this->assertStringNotContainsString('renderer->render('![Ein Bild](https://example.org/pixel.png)', []); + + $this->assertStringNotContainsString('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( + '

Hallo Anna, dein Einsatz Skireise

', + $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 x", + ]); + + $this->assertStringNotContainsString('', $html); + $this->assertStringNotContainsString('
    ', $html); + $this->assertStringNotContainsString('assertStringNotContainsString('assertStringNotContainsString('', $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( + "

    Erste Zeile
    \nZweite Zeile

    ", + $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( + '

    Skireise

    ', + $this->renderer->render('`{destination}`', ['{destination}' => 'Skireise']) + ); + } + + public function testAnEmptyBodyRendersAsNothing(): void + { + $this->assertSame('', $this->renderer->render('', [])); + $this->assertSame('', $this->renderer->render(null, [])); + } +} diff --git a/tests/Migrations/EmphasisRewriteTest.php b/tests/Migrations/EmphasisRewriteTest.php new file mode 100644 index 0000000..162bf73 --- /dev/null +++ b/tests/Migrations/EmphasisRewriteTest.php @@ -0,0 +1,84 @@ +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 + */ + 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) + ); + } + } +} diff --git a/tests/Service/Teamer/TeamerMailingServiceTest.php b/tests/Service/Teamer/TeamerMailingServiceTest.php index d321d0c..7e8968b 100644 --- a/tests/Service/Teamer/TeamerMailingServiceTest.php +++ b/tests/Service/Teamer/TeamerMailingServiceTest.php @@ -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([ - ['anna@example.org', 'Hallo Anna', 'Servus Anna Berg'], - ['bea@example.org', 'Hallo Bea', 'Servus Bea Ohm'], + ['anna@example.org', 'Hallo Anna', '

    Servus Anna Berg

    '], + ['bea@example.org', 'Hallo Bea', '

    Servus Bea Ohm

    '], ], $sent); } @@ -210,7 +214,7 @@ class TeamerMailingServiceTest extends TestCase ->expects($this->once()) ->method('createAndSendEmail') ->with( - ['message' => 'Servus Rita Kern'], + ['bodyHtml' => '

    Servus Rita Kern

    '], $this->callback(function (array $options): bool { $this->assertSame('admin@example.org', $options['to']); $this->assertSame('[Vorschau] Hallo Rita', $options['subject']);