Files
myep-team/src/Email/EmailTextRenderer.php
T

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 &#039; 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
);
}
}