feat: markdown in email body for mailing and transactional
This commit is contained in:
+33
-2
@@ -19,7 +19,15 @@
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
margin-bottom: 0;
|
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 {
|
p {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -48,6 +56,18 @@
|
|||||||
strong {
|
strong {
|
||||||
font-weight: bold;
|
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-style>
|
||||||
</mj-head>
|
</mj-head>
|
||||||
<mj-body background-color="#666666">
|
<mj-body background-color="#666666">
|
||||||
@@ -70,8 +90,19 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto culpa delectus dolores
|
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>
|
</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>
|
<p>
|
||||||
<a href="#" class="button">
|
<a href="#" class="button">
|
||||||
Button
|
Button
|
||||||
|
|||||||
@@ -6,3 +6,4 @@
|
|||||||
@import "components/toast.css";
|
@import "components/toast.css";
|
||||||
@import "components/tooltip.css";
|
@import "components/tooltip.css";
|
||||||
@import "components/timeline.css";
|
@import "components/timeline.css";
|
||||||
|
@import "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;
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"flagception/flagception-bundle": "^6.0",
|
"flagception/flagception-bundle": "^6.0",
|
||||||
"knplabs/knp-menu-bundle": "^3.2",
|
"knplabs/knp-menu-bundle": "^3.2",
|
||||||
"knplabs/knp-paginator-bundle": "^6.2",
|
"knplabs/knp-paginator-bundle": "^6.2",
|
||||||
|
"league/commonmark": "^2.10",
|
||||||
"league/flysystem-bundle": "^3.4",
|
"league/flysystem-bundle": "^3.4",
|
||||||
"league/flysystem-sftp-v3": "^3.29",
|
"league/flysystem-sftp-v3": "^3.29",
|
||||||
"league/oauth2-client": "^2.9",
|
"league/oauth2-client": "^2.9",
|
||||||
|
|||||||
Generated
+423
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "b6c422d9533a5a1589f72c500a66cf90",
|
"content-hash": "8e7c82ac9ab513690dab33c12c4fa2c2",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "beberlei/doctrineextensions",
|
"name": "beberlei/doctrineextensions",
|
||||||
@@ -209,6 +209,81 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-07-18T12:35:13+00:00"
|
"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",
|
"name": "doctrine/cache",
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
@@ -2496,6 +2571,195 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-11-29T09:14:09+00:00"
|
"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",
|
"name": "league/flysystem",
|
||||||
"version": "3.35.2",
|
"version": "3.35.2",
|
||||||
@@ -3338,6 +3602,164 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-01-08T20:10:23+00:00"
|
"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",
|
"name": "oneup/uploader-bundle",
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
|
|||||||
+11
-9
@@ -5,10 +5,12 @@
|
|||||||
# Parsed on demand by App\Config\EmailTextCatalog. This file is NOT loaded by the
|
# 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/.
|
# container, which is why it does not live in config/packages/.
|
||||||
#
|
#
|
||||||
# The text is plain text, never markup: {platzhalter} tokens are substituted by
|
# The body is Markdown, never markup: **fett**, *kursiv*, "## Ueberschrift", "- Liste"
|
||||||
# App\Email\EmailTextRenderer after escaping, *Sternchen* make text bold (write \* for a
|
# and "1. Nummerierung" (write \* for a literal asterisk), a blank line starts a new
|
||||||
# literal one), a blank line starts a new paragraph, and bare URLs and mail addresses
|
# paragraph, a single newline stays a line break, and bare URLs and mail addresses become
|
||||||
# become links by themselves. A placeholder without a value prints "-".
|
# 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.
|
# 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'
|
subject: 'Dein Einsatz wurde angenommen'
|
||||||
headline: 'Herzlichen Glückwunsch!'
|
headline: 'Herzlichen Glückwunsch!'
|
||||||
body: |-
|
body: |-
|
||||||
*Dein Einsatz {destination} wurde angenommen!*
|
**Dein Einsatz {destination} wurde angenommen!**
|
||||||
|
|
||||||
Zusätzliche Absprachen: {specialAgreements}
|
Zusätzliche Absprachen: {specialAgreements}
|
||||||
|
|
||||||
@@ -86,7 +88,7 @@ texts:
|
|||||||
body: |-
|
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.
|
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:
|
reminder_invoice_upload:
|
||||||
label: 'Erinnerung: Honorarnote hochladen'
|
label: 'Erinnerung: Honorarnote hochladen'
|
||||||
@@ -105,7 +107,7 @@ texts:
|
|||||||
body: |-
|
body: |-
|
||||||
für deinen Einsatz {destination} liegt uns noch immer keine Honorarnote vor.
|
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]
|
Bei Fragen melde dich gerne unter [email protected]
|
||||||
|
|
||||||
@@ -115,11 +117,11 @@ texts:
|
|||||||
subject: 'Reminder: Dein Einsatz {destination}'
|
subject: 'Reminder: Dein Einsatz {destination}'
|
||||||
headline: 'Hallo aus Köln,'
|
headline: 'Hallo aus Köln,'
|
||||||
body: |-
|
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.
|
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,
|
Liebe Grüße,
|
||||||
dein Team Personalabteilung
|
dein Team Personalabteilung
|
||||||
|
|||||||
@@ -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;
|
namespace App\Controller\Admin\Teamer;
|
||||||
|
|
||||||
use App\Controller\Traits\ReturnUrlTrait;
|
use App\Controller\Traits\ReturnUrlTrait;
|
||||||
|
use App\Email\MailBodyRenderer;
|
||||||
use App\Form\TeamerMailingType;
|
use App\Form\TeamerMailingType;
|
||||||
use App\Htmx\HxRedirectResponse;
|
use App\Htmx\HxRedirectResponse;
|
||||||
use App\Message\SendTeamerMailing;
|
use App\Message\SendTeamerMailing;
|
||||||
|
use App\Model\TeamerMailingDto;
|
||||||
use App\Service\Common\TeamerFilterHandler;
|
use App\Service\Common\TeamerFilterHandler;
|
||||||
use App\Service\Teamer\TeamerMailingDraftHandler;
|
use App\Service\Teamer\TeamerMailingDraftHandler;
|
||||||
use App\Service\Teamer\TeamerMailingService;
|
use App\Service\Teamer\TeamerMailingService;
|
||||||
@@ -25,6 +27,7 @@ class MailingController extends AbstractController
|
|||||||
private readonly TeamerFilterHandler $filterHandler,
|
private readonly TeamerFilterHandler $filterHandler,
|
||||||
private readonly TeamerMailingService $mailingService,
|
private readonly TeamerMailingService $mailingService,
|
||||||
private readonly TeamerMailingDraftHandler $draftHandler,
|
private readonly TeamerMailingDraftHandler $draftHandler,
|
||||||
|
private readonly MailBodyRenderer $mailBodyRenderer,
|
||||||
private readonly MessageBusInterface $messageBus,
|
private readonly MessageBusInterface $messageBus,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -54,9 +57,12 @@ class MailingController extends AbstractController
|
|||||||
|
|
||||||
$filterDto = $this->filterHandler->getFilterSettings();
|
$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', [
|
return $this->render('admin/teamer/mailing.html.twig', [
|
||||||
'form' => $form->createView(),
|
'form' => $form->createView(),
|
||||||
|
'preview' => $this->renderPreview($form->getData()),
|
||||||
'filterDto' => $filterDto,
|
'filterDto' => $filterDto,
|
||||||
'recipients' => $this->mailingService->resolveRecipients($filterDto),
|
'recipients' => $this->mailingService->resolveRecipients($filterDto),
|
||||||
'placeholders' => TeamerMailingService::PLACEHOLDERS,
|
'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
|
* Park what has been typed so far, so that a trip to the filter and back does not throw
|
||||||
* throw the composed mail away. Answers nothing, the page stays as it is.
|
* 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'])]
|
#[Route('/admin/teamer/mailing/draft', name: 'app_admin_teamer_mailing_draft', methods: ['POST'])]
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_ADMIN')]
|
||||||
public function draft(Request $request): Response
|
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')]
|
#[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'));
|
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.
|
* A GET starts from the parked draft, a POST always carries the current one itself.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ use App\Model\RenderedEmailTextDto;
|
|||||||
use App\Repository\EmailTextRepository;
|
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,
|
* Admins write Markdown with {placeholder} tokens - never markup, never Twig. The body is
|
||||||
* never Twig. Everything an admin or a teamer typed is escaped here before any tag is
|
* handed to App\Email\MailBodyRenderer, which is where the escaping that makes
|
||||||
* introduced, which is the only reason email/generic.html.twig may print the result with
|
* email/generic.html.twig's |raw safe happens; subject and headline are plain text and
|
||||||
* |raw. The order of operations below is load-bearing; see the comments on each step.
|
* are dealt with here.
|
||||||
*/
|
*/
|
||||||
class EmailTextRenderer
|
class EmailTextRenderer
|
||||||
{
|
{
|
||||||
@@ -32,6 +32,7 @@ class EmailTextRenderer
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EmailTextCatalog $catalog,
|
private readonly EmailTextCatalog $catalog,
|
||||||
private readonly EmailTextRepository $emailTextRepository,
|
private readonly EmailTextRepository $emailTextRepository,
|
||||||
|
private readonly MailBodyRenderer $mailBodyRenderer,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,103 +69,39 @@ class EmailTextRenderer
|
|||||||
): RenderedEmailTextDto {
|
): RenderedEmailTextDto {
|
||||||
$definition = $this->catalog->get($key);
|
$definition = $this->catalog->get($key);
|
||||||
|
|
||||||
// Two token maps from the same values: the subject of a mail is plain text, the
|
// Three token maps from the same values, because the three parts of a mail are not
|
||||||
// body is HTML. Escaping happens here, before substitution, so that neither the
|
// the same kind of text: the subject is a plain header, the headline is escaped
|
||||||
// admin's copy nor a teamer's own words (reason, comment, specialAgreements) can
|
// plain text, and the body is Markdown the renderer escapes for us.
|
||||||
// introduce a tag.
|
$rawTokens = [];
|
||||||
$plainTokens = [];
|
|
||||||
$htmlTokens = [];
|
$htmlTokens = [];
|
||||||
|
|
||||||
foreach ($definition->getPlaceholderNames() as $name) {
|
foreach ($definition->getPlaceholderNames() as $name) {
|
||||||
$value = (string) ($placeholders[$name] ?? '');
|
$value = (string) ($placeholders[$name] ?? '');
|
||||||
$value = '' === trim($value) ? self::EMPTY_VALUE : $value;
|
$value = '' === trim($value) ? self::EMPTY_VALUE : $value;
|
||||||
|
|
||||||
$plainTokens['{'.$name.'}'] = $value;
|
$rawTokens['{'.$name.'}'] = $value;
|
||||||
$htmlTokens['{'.$name.'}'] = htmlspecialchars($value, self::ESCAPE_FLAGS, 'UTF-8');
|
$htmlTokens['{'.$name.'}'] = htmlspecialchars($value, self::ESCAPE_FLAGS, 'UTF-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
return new RenderedEmailTextDto(
|
return new RenderedEmailTextDto(
|
||||||
strtr($subject ?? '', $plainTokens),
|
strtr($subject ?? '', $rawTokens),
|
||||||
$this->prepare($headline ?? '', $htmlTokens),
|
$this->renderHeadline($headline ?? '', $htmlTokens),
|
||||||
$this->renderBody($body ?? '', $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
|
* @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);
|
return strtr(
|
||||||
|
htmlspecialchars(trim($headline), self::ESCAPE_FLAGS, 'UTF-8'),
|
||||||
// Blank lines separate paragraphs, single newlines are soft breaks. The mail
|
$tokens
|
||||||
// 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
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ' 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Service\Teamer;
|
namespace App\Service\Teamer;
|
||||||
|
|
||||||
|
use App\Email\MailBodyRenderer;
|
||||||
use App\Email\Mailer;
|
use App\Email\Mailer;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Message\SendTeamerMailing;
|
use App\Message\SendTeamerMailing;
|
||||||
@@ -37,6 +38,7 @@ class TeamerMailingService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TeamerRepository $teamerRepository,
|
private readonly TeamerRepository $teamerRepository,
|
||||||
private readonly Mailer $mailer,
|
private readonly Mailer $mailer,
|
||||||
|
private readonly MailBodyRenderer $mailBodyRenderer,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
// messenger transport, "mailing" in production and "sync" in dev and test,
|
// messenger transport, "mailing" in production and "sync" in dev and test,
|
||||||
// see the parameter of the same name in services.yaml
|
// see the parameter of the same name in services.yaml
|
||||||
@@ -98,7 +100,7 @@ class TeamerMailingService
|
|||||||
$this->sendTo(
|
$this->sendTo(
|
||||||
$recipient['email'],
|
$recipient['email'],
|
||||||
$this->render($mailing->getSubject(), $values),
|
$this->render($mailing->getSubject(), $values),
|
||||||
$this->render($mailing->getMessage(), $values),
|
$this->mailBodyRenderer->render($mailing->getMessage(), $values),
|
||||||
self::TRANSPORT,
|
self::TRANSPORT,
|
||||||
$this->mailingBusTransport
|
$this->mailingBusTransport
|
||||||
);
|
);
|
||||||
@@ -121,7 +123,7 @@ class TeamerMailingService
|
|||||||
$this->sendTo(
|
$this->sendTo(
|
||||||
$admin->getUserIdentifier(),
|
$admin->getUserIdentifier(),
|
||||||
self::PREVIEW_SUBJECT_PREFIX.$this->render($mailingDto->getSubject(), $values),
|
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', [
|
$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
|
* Replace the known placeholders in a subject line. Anything that merely looks like a
|
||||||
* left alone - a typo should not silently blank out part of the mail.
|
* 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
|
public function render(?string $text, array $values): string
|
||||||
{
|
{
|
||||||
@@ -195,12 +199,14 @@ class TeamerMailingService
|
|||||||
private function sendTo(
|
private function sendTo(
|
||||||
string $email,
|
string $email,
|
||||||
string $subject,
|
string $subject,
|
||||||
string $message,
|
string $bodyHtml,
|
||||||
?string $transport = null,
|
?string $transport = null,
|
||||||
?string $busTransport = null,
|
?string $busTransport = null,
|
||||||
): void {
|
): void {
|
||||||
|
// $bodyHtml is already rendered and escaped mail HTML, see MailBodyRenderer -
|
||||||
|
// email/teamer_mailing.html.twig prints it with |raw.
|
||||||
$this->mailer->createAndSendEmail([
|
$this->mailer->createAndSendEmail([
|
||||||
'message' => $message,
|
'bodyHtml' => $bodyHtml,
|
||||||
], [
|
], [
|
||||||
'to' => $email,
|
'to' => $email,
|
||||||
'subject' => $subject,
|
'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.headline) }}
|
||||||
{{ form_row(form.body) }}
|
{{ form_row(form.body) }}
|
||||||
|
|
||||||
|
{{ include('_partials/_markdown_hint.html.twig') }}
|
||||||
|
|
||||||
<div class="bg-gray-100 rounded-md p-4 text-sm">
|
<div class="bg-gray-100 rounded-md p-4 text-sm">
|
||||||
<div class="font-bold pb-2">
|
<div class="font-bold pb-2">
|
||||||
Verfügbare Platzhalter
|
Verfügbare Platzhalter
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
{% if text.headline is not empty %}
|
{% if text.headline is not empty %}
|
||||||
<h1 class="text-xl font-bold pb-2">{{ text.headline | raw }}</h1>
|
<h1 class="text-xl font-bold pb-2">{{ text.headline | raw }}</h1>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# The paragraphs come from the mail body, where email/layout.html.twig styles every
|
{# .mail-body carries the spacing and list styling of email/layout.html.twig, so this
|
||||||
<p> with padding-bottom:16px - pb-4 is the same spacing, so the preview matches. #}
|
pane and the mailing pane both show what the mail will look like. #}
|
||||||
<div class="[&>p]:pb-4 [&_a]:underline">
|
<div class="mail-body">
|
||||||
{{ text.bodyHtml | raw }}
|
{{ text.bodyHtml | raw }}
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<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>
|
||||||
@@ -27,29 +27,64 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# the draft is parked on every keystroke so that a trip to the filter and back keeps it,
|
{# One request per keystroke parks the draft - so that a trip to the filter and back
|
||||||
hx-trigger replaces htmx' default submit trigger, the preview button still posts natively #}
|
keeps it - and answers with the preview, which is why the response is swapped into
|
||||||
{{ form_start(form, { 'attr': {
|
the pane instead of being thrown away. hx-trigger replaces htmx' default submit
|
||||||
'hx-post': path('app_admin_teamer_mailing_draft'),
|
trigger, the "Vorschau an mich senden" button still posts natively.
|
||||||
'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>
|
|
||||||
|
|
||||||
<div class="pb-8 max-w-3xl text-sm text-gray-500">
|
Deliberately without htmx' "changed" modifier: it compares the listening element's
|
||||||
<div class="pb-2">
|
.value between events, and a <form> has none, so every event compares undefined to
|
||||||
Platzhalter für Betreff und Nachricht, sie werden beim Senden pro Person ersetzt:
|
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>
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{% for placeholder, label in placeholders %}
|
<div class="lg:sticky lg:top-4">
|
||||||
<span class="inline-flex items-center space-x-1 py-1 px-2 bg-gray-50 border border-gray-200 rounded">
|
<div class="font-bold pb-2">
|
||||||
<code>{{ placeholder }}</code>
|
Vorschau
|
||||||
<span>{{ label }}</span>
|
</div>
|
||||||
</span>
|
<div class="relative">
|
||||||
{% endfor %}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!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>
|
<head>
|
||||||
<title>MyE&P Team</title>
|
<title>MyE&P Team</title>
|
||||||
@@ -116,7 +116,16 @@
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
margin-bottom: 0;
|
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 {
|
p {
|
||||||
@@ -152,12 +161,28 @@
|
|||||||
font-weight: bold;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body style="word-spacing:normal;background-color:#666666;">
|
<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&P Team</div>
|
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">MyE&P Team</div>
|
||||||
<div style="background-color:#666666;">
|
<div aria-label="MyE&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]-->
|
<!--[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;">
|
<div style="margin:0 auto;max-width:600px;">
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
<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>
|
<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>
|
<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>
|
<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>
|
<p>
|
||||||
<a href="#" class="button"> Button </a>
|
<a href="#" class="button"> Button </a>
|
||||||
</p>
|
</p>
|
||||||
<p class="small"> Lorem ipsum dolor sit amet, consectetur adipisicing elit. Doloribus, eveniet! </p> {% endblock %}
|
<p class="small"> Lorem ipsum dolor sit amet, consectetur adipisicing elit. Doloribus, eveniet! </p> {% endblock %} {% block button %} <p>
|
||||||
{% block button %}
|
<a href="{{ url('app_index') }}" class="button">Zum Portal</a>
|
||||||
<p>
|
</p> {% endblock %}
|
||||||
<a href="{{ url('app_index') }}" class="button">Zum Portal</a>
|
|
||||||
</p>
|
|
||||||
{% endblock %}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{% extends 'email/layout.html.twig' %}
|
{% 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 %}
|
{% block body %}
|
||||||
<p>
|
{{ bodyHtml | raw }}
|
||||||
{{ message | nl2br }}
|
|
||||||
</p>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Tests\Email;
|
|||||||
use App\Config\EmailTextCatalog;
|
use App\Config\EmailTextCatalog;
|
||||||
use App\Config\EmailTextKey;
|
use App\Config\EmailTextKey;
|
||||||
use App\Email\EmailTextRenderer;
|
use App\Email\EmailTextRenderer;
|
||||||
|
use App\Email\MailBodyRenderer;
|
||||||
use App\Email\Mailer;
|
use App\Email\Mailer;
|
||||||
use App\Repository\EmailTextRepository;
|
use App\Repository\EmailTextRepository;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
@@ -42,7 +43,7 @@ class EmailTextMailRenderingTest extends KernelTestCase
|
|||||||
$repository = $this->createMock(EmailTextRepository::class);
|
$repository = $this->createMock(EmailTextRepository::class);
|
||||||
$repository->method('findByKey')->willReturn(null);
|
$repository->method('findByKey')->willReturn(null);
|
||||||
|
|
||||||
$text = (new EmailTextRenderer($catalog, $repository))
|
$text = (new EmailTextRenderer($catalog, $repository, $container->get(MailBodyRenderer::class)))
|
||||||
->render($key, $placeholders)
|
->render($key, $placeholders)
|
||||||
;
|
;
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Tests\Email;
|
|||||||
use App\Config\EmailTextCatalog;
|
use App\Config\EmailTextCatalog;
|
||||||
use App\Config\EmailTextKey;
|
use App\Config\EmailTextKey;
|
||||||
use App\Email\EmailTextRenderer;
|
use App\Email\EmailTextRenderer;
|
||||||
|
use App\Email\MailBodyRenderer;
|
||||||
use App\Entity\EmailText;
|
use App\Entity\EmailText;
|
||||||
use App\Repository\EmailTextRepository;
|
use App\Repository\EmailTextRepository;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
@@ -41,7 +42,11 @@ class EmailTextRendererTest extends KernelTestCase
|
|||||||
$this->assertStringContainsString('Skireise', $rendered->bodyHtml);
|
$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(
|
$rendered = $this->render(
|
||||||
$this->emailText(body: 'Hallo <script>alert(1)</script>'),
|
$this->emailText(body: 'Hallo <script>alert(1)</script>'),
|
||||||
@@ -49,7 +54,7 @@ class EmailTextRendererTest extends KernelTestCase
|
|||||||
);
|
);
|
||||||
|
|
||||||
$this->assertStringNotContainsString('<script>', $rendered->bodyHtml);
|
$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);
|
$this->assertStringContainsString('<img', $rendered->bodyHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAsterisksMakeTextBold(): void
|
public function testDoubleAsterisksMakeTextBold(): void
|
||||||
{
|
{
|
||||||
$rendered = $this->render(
|
$rendered = $this->render(
|
||||||
$this->emailText(body: 'Das ist *wichtig* und das nicht'),
|
$this->emailText(body: 'Das ist **wichtig** und das nicht'),
|
||||||
['destination' => 'Skireise']
|
['destination' => 'Skireise']
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertSame('<p>Das ist <strong>wichtig</strong> und das nicht</p>', $rendered->bodyHtml);
|
$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
|
public function testAnEscapedAsteriskStaysLiteral(): void
|
||||||
{
|
{
|
||||||
$rendered = $this->render(
|
$rendered = $this->render(
|
||||||
@@ -94,11 +136,11 @@ class EmailTextRendererTest extends KernelTestCase
|
|||||||
{
|
{
|
||||||
$rendered = $this->render(
|
$rendered = $this->render(
|
||||||
$this->emailText(body: 'Einsatz {destination}'),
|
$this->emailText(body: 'Einsatz {destination}'),
|
||||||
['destination' => '*nicht fett*']
|
['destination' => '**nicht fett**']
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertStringNotContainsString('<strong>', $rendered->bodyHtml);
|
$this->assertStringNotContainsString('<strong>', $rendered->bodyHtml);
|
||||||
$this->assertStringContainsString('*nicht fett*', $rendered->bodyHtml);
|
$this->assertStringContainsString('**nicht fett**', $rendered->bodyHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAnEmptyPlaceholderFallsBackToADash(): void
|
public function testAnEmptyPlaceholderFallsBackToADash(): void
|
||||||
@@ -196,14 +238,15 @@ class EmailTextRendererTest extends KernelTestCase
|
|||||||
|
|
||||||
$renderer = new EmailTextRenderer(
|
$renderer = new EmailTextRenderer(
|
||||||
self::getContainer()->get(EmailTextCatalog::class),
|
self::getContainer()->get(EmailTextCatalog::class),
|
||||||
$repository
|
$repository,
|
||||||
|
self::getContainer()->get(MailBodyRenderer::class)
|
||||||
);
|
);
|
||||||
|
|
||||||
$rendered = $renderer->renderValues(
|
$rendered = $renderer->renderValues(
|
||||||
EmailTextKey::APPLICATION_REJECTED,
|
EmailTextKey::APPLICATION_REJECTED,
|
||||||
'Entwurf {destination}',
|
'Entwurf {destination}',
|
||||||
'Überschrift',
|
'Überschrift',
|
||||||
'Entwurfstext *fett*',
|
'Entwurfstext **fett**',
|
||||||
['destination' => 'Skireise']
|
['destination' => 'Skireise']
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -221,7 +264,8 @@ class EmailTextRendererTest extends KernelTestCase
|
|||||||
|
|
||||||
$renderer = new EmailTextRenderer(
|
$renderer = new EmailTextRenderer(
|
||||||
self::getContainer()->get(EmailTextCatalog::class),
|
self::getContainer()->get(EmailTextCatalog::class),
|
||||||
$this->createMock(EmailTextRepository::class)
|
$this->createMock(EmailTextRepository::class),
|
||||||
|
self::getContainer()->get(MailBodyRenderer::class)
|
||||||
);
|
);
|
||||||
|
|
||||||
$rendered = $renderer->renderValues(
|
$rendered = $renderer->renderValues(
|
||||||
@@ -252,7 +296,8 @@ class EmailTextRendererTest extends KernelTestCase
|
|||||||
|
|
||||||
$renderer = new EmailTextRenderer(
|
$renderer = new EmailTextRenderer(
|
||||||
self::getContainer()->get(EmailTextCatalog::class),
|
self::getContainer()->get(EmailTextCatalog::class),
|
||||||
$repository
|
$repository,
|
||||||
|
self::getContainer()->get(MailBodyRenderer::class)
|
||||||
);
|
);
|
||||||
|
|
||||||
return $renderer->render(EmailTextKey::APPLICATION_REJECTED, $placeholders);
|
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, []));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
namespace App\Tests\Service\Teamer;
|
||||||
|
|
||||||
|
use App\Email\MailBodyRenderer;
|
||||||
use App\Email\Mailer;
|
use App\Email\Mailer;
|
||||||
use App\Entity\Teamer;
|
use App\Entity\Teamer;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
@@ -30,9 +31,12 @@ class TeamerMailingServiceTest extends TestCase
|
|||||||
$this->teamerRepository = $this->createMock(TeamerRepository::class);
|
$this->teamerRepository = $this->createMock(TeamerRepository::class);
|
||||||
$this->mailer = $this->createMock(Mailer::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->service = new TeamerMailingService(
|
||||||
$this->teamerRepository,
|
$this->teamerRepository,
|
||||||
$this->mailer,
|
$this->mailer,
|
||||||
|
new MailBodyRenderer(),
|
||||||
$this->createMock(LoggerInterface::class),
|
$this->createMock(LoggerInterface::class),
|
||||||
'mailing',
|
'mailing',
|
||||||
);
|
);
|
||||||
@@ -111,7 +115,7 @@ class TeamerMailingServiceTest extends TestCase
|
|||||||
->expects($this->exactly(2))
|
->expects($this->exactly(2))
|
||||||
->method('createAndSendEmail')
|
->method('createAndSendEmail')
|
||||||
->willReturnCallback(function (array $context, array $options) use (&$sent): void {
|
->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([
|
$this->assertSame([
|
||||||
['[email protected]', 'Hallo Anna', 'Servus Anna Berg'],
|
['[email protected]', 'Hallo Anna', '<p>Servus Anna Berg</p>'],
|
||||||
['[email protected]', 'Hallo Bea', 'Servus Bea Ohm'],
|
['[email protected]', 'Hallo Bea', '<p>Servus Bea Ohm</p>'],
|
||||||
], $sent);
|
], $sent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +214,7 @@ class TeamerMailingServiceTest extends TestCase
|
|||||||
->expects($this->once())
|
->expects($this->once())
|
||||||
->method('createAndSendEmail')
|
->method('createAndSendEmail')
|
||||||
->with(
|
->with(
|
||||||
['message' => 'Servus Rita Kern'],
|
['bodyHtml' => '<p>Servus Rita Kern</p>'],
|
||||||
$this->callback(function (array $options): bool {
|
$this->callback(function (array $options): bool {
|
||||||
$this->assertSame('[email protected]', $options['to']);
|
$this->assertSame('[email protected]', $options['to']);
|
||||||
$this->assertSame('[Vorschau] Hallo Rita', $options['subject']);
|
$this->assertSame('[Vorschau] Hallo Rita', $options['subject']);
|
||||||
|
|||||||
Reference in New Issue
Block a user