feat: admin editable email templates
addresses #869eg7ptr
This commit is contained in:
1 parent
804dab335f
commit
3e08965e48
49 files changed
+1760
-276
No files matched your search
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Email;
|
||||
|
||||
use App\Config\EmailTextCatalog;
|
||||
use App\Config\EmailTextKey;
|
||||
use App\Model\RenderedEmailTextDto;
|
||||
use App\Repository\EmailTextRepository;
|
||||
|
||||
/**
|
||||
* Turns the plain 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.
|
||||
*/
|
||||
class EmailTextRenderer
|
||||
{
|
||||
/**
|
||||
* Printed for a placeholder that has no value, so a mail never shows a stray label
|
||||
* with nothing behind it (a teamer's disposition usually has no special agreements).
|
||||
*/
|
||||
private const EMPTY_VALUE = '-';
|
||||
|
||||
/**
|
||||
* Text only ever lands in element content here, never in an attribute, so single
|
||||
* quotes stay readable instead of turning into ' in the middle of a sentence.
|
||||
*/
|
||||
private const ESCAPE_FLAGS = ENT_COMPAT | ENT_SUBSTITUTE;
|
||||
|
||||
public function __construct(
|
||||
private readonly EmailTextCatalog $catalog,
|
||||
private readonly EmailTextRepository $emailTextRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|int|null> $placeholders
|
||||
*/
|
||||
public function render(EmailTextKey $key, array $placeholders): RenderedEmailTextDto
|
||||
{
|
||||
$definition = $this->catalog->get($key);
|
||||
$emailText = $this->emailTextRepository->findByKey($key);
|
||||
|
||||
$subject = $emailText?->getSubject() ?: $definition->defaultSubject;
|
||||
$headline = $emailText?->getHeadline() ?? $definition->defaultHeadline;
|
||||
$body = $emailText?->getBody() ?: $definition->defaultBody;
|
||||
|
||||
// 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 = [];
|
||||
$htmlTokens = [];
|
||||
|
||||
foreach ($definition->getPlaceholderNames() as $name) {
|
||||
$value = (string) ($placeholders[$name] ?? '');
|
||||
$value = '' === trim($value) ? self::EMPTY_VALUE : $value;
|
||||
|
||||
$plainTokens['{'.$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),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $tokens
|
||||
*/
|
||||
private function renderBody(string $body, array $tokens): string
|
||||
{
|
||||
$body = $this->prepare($body, $tokens);
|
||||
|
||||
// Blank lines separate paragraphs, single newlines are soft breaks. The mail
|
||||
// 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user