141 lines
5.5 KiB
PHP
141 lines
5.5 KiB
PHP
<?php
|
|
|
|
namespace App\Email;
|
|
|
|
use App\Config\EmailTextCatalog;
|
|
use App\Config\EmailTextKey;
|
|
use App\Model\RenderedEmailTextDto;
|
|
use App\Repository\EmailTextRepository;
|
|
|
|
/**
|
|
* Turns the text an admin wrote into the HTML of a transactional mail.
|
|
*
|
|
* 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
|
|
{
|
|
/**
|
|
* 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;
|
|
|
|
/**
|
|
* The token for the booking link, and why it is not handled like the other placeholders:
|
|
* a placeholder value is substituted into the *rendered HTML* and escaped there, which is
|
|
* what makes a teamer's own words harmless - but it also means such a value can never be a
|
|
* link target, because CommonMark has already percent-encoded the braces by then
|
|
* ("%7BbookingUrl%7D"). This one is substituted into the Markdown *before* parsing instead,
|
|
* so the admin can write it as a proper link and CommonMark still gets to vet the URL
|
|
* through allow_unsafe_links. That is only defensible because the value comes from the
|
|
* deployment's own environment, exactly like the wording it is substituted into - never
|
|
* from a teamer or an applicant. Do not extend this to anything a user can influence.
|
|
*/
|
|
private const BOOKING_URL_TOKEN = '{bookingUrl}';
|
|
|
|
public function __construct(
|
|
private readonly EmailTextCatalog $catalog,
|
|
private readonly EmailTextRepository $emailTextRepository,
|
|
private readonly MailBodyRenderer $mailBodyRenderer,
|
|
private readonly string $bookingUrl = '',
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
|
|
return $this->renderValues(
|
|
$key,
|
|
$emailText?->getSubject() ?: $definition->defaultSubject,
|
|
$emailText?->getHeadline() ?? $definition->defaultHeadline,
|
|
$emailText?->getBody() ?: $definition->defaultBody,
|
|
$placeholders
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Renders wording that is not (or not yet) the saved one - the admin preview shows
|
|
* what is currently in the form, which is the whole point of previewing before saving.
|
|
* The values come straight from a half-filled form, so all three may be null.
|
|
*
|
|
* @param array<string, string|int|null> $placeholders
|
|
*/
|
|
public function renderValues(
|
|
EmailTextKey $key,
|
|
?string $subject,
|
|
?string $headline,
|
|
?string $body,
|
|
array $placeholders,
|
|
): RenderedEmailTextDto {
|
|
$definition = $this->catalog->get($key);
|
|
|
|
// 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;
|
|
|
|
$rawTokens['{'.$name.'}'] = $value;
|
|
$htmlTokens['{'.$name.'}'] = htmlspecialchars($value, self::ESCAPE_FLAGS, 'UTF-8');
|
|
}
|
|
|
|
return new RenderedEmailTextDto(
|
|
strtr($subject ?? '', $rawTokens),
|
|
$this->renderHeadline($headline ?? '', $htmlTokens),
|
|
$this->mailBodyRenderer->render($this->insertBookingUrl($body ?? ''), $rawTokens),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Refuses rather than sending a broken link: an unconfigured APPLICATION_FORM_BOOKING_URL
|
|
* would otherwise render as <a href=""> and invite an applicant to book a call they cannot
|
|
* reach. The caller decides what that means - the endpoint logs it and answers the
|
|
* submission anyway, a reviewer sending by hand gets to see it.
|
|
*/
|
|
private function insertBookingUrl(string $body): string
|
|
{
|
|
if (false === str_contains($body, self::BOOKING_URL_TOKEN)) {
|
|
return $body;
|
|
}
|
|
|
|
if ('' === trim($this->bookingUrl)) {
|
|
throw new \RuntimeException('The mail wording uses {bookingUrl}, but APPLICATION_FORM_BOOKING_URL is not configured.');
|
|
}
|
|
|
|
return str_replace(self::BOOKING_URL_TOKEN, trim($this->bookingUrl), $body);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
private function renderHeadline(string $headline, array $tokens): string
|
|
{
|
|
return strtr(
|
|
htmlspecialchars(trim($headline), self::ESCAPE_FLAGS, 'UTF-8'),
|
|
$tokens
|
|
);
|
|
}
|
|
}
|