feat: admin editable email templates

addresses #869eg7ptr
This commit is contained in:
Björn Fromme
2026-08-17 10:39:19 +02:00
parent 804dab335f
commit 3e08965e48
49 changed files with 1760 additions and 276 deletions
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Config;
use Symfony\Component\Yaml\Yaml;
/**
* Reads the delivered wording of the admin-editable mails from config/email_texts.yaml.
*
* That wording is the fallback used whenever no email_text row exists for a key, so the
* mails keep working on a fresh database and "reset to default" needs nothing but deleting
* the row.
*
* The file is parsed lazily and memoised: a request that sends no mail and opens no admin
* screen never touches it, and one that does reads it once. Nothing here is validated at
* compile time, so a missing entry surfaces as an error the first time the catalogue is
* built - EmailTextCatalogTest builds all of them.
*/
class EmailTextCatalog
{
/**
* @var array<string, EmailTextDefinition>|null
*/
private ?array $definitions = null;
public function __construct(
private readonly string $emailTextsFile,
) {
}
/**
* @return array<string, EmailTextDefinition> keyed by EmailTextKey value
*/
public function all(): array
{
return $this->definitions ??= $this->build();
}
public function get(EmailTextKey $key): EmailTextDefinition
{
return $this->all()[$key->value];
}
/**
* @return array<string, EmailTextDefinition>
*/
private function build(): array
{
$config = Yaml::parseFile($this->emailTextsFile);
$descriptions = $config['placeholders'];
$definitions = [];
foreach (EmailTextKey::cases() as $key) {
$text = $config['texts'][$key->value];
$placeholders = [];
foreach ($text['placeholders'] as $name) {
$placeholders[$name] = $descriptions[$name];
}
$definitions[$key->value] = new EmailTextDefinition(
$key,
$text['label'],
$placeholders,
$text['subject'],
$text['headline'],
$text['body'],
);
}
return $definitions;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Config;
/**
* The parts of an editable mail that stay in code: its name in the admin UI, the
* placeholders it accepts, and the wording that applies until an admin overrides it.
*/
readonly class EmailTextDefinition
{
/**
* @param array<string, string> $placeholders name => description shown to the admin
*/
public function __construct(
public EmailTextKey $key,
public string $label,
public array $placeholders,
public string $defaultSubject,
public string $defaultHeadline,
public string $defaultBody,
) {
}
/**
* @return string[]
*/
public function getPlaceholderNames(): array
{
return array_keys($this->placeholders);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Config;
/**
* The transactional mails whose wording admins may edit.
*
* A case's value is what ends up in email_text.template_key, so renaming one orphans
* the stored override and silently falls back to the default in EmailTextCatalog.
*/
enum EmailTextKey: string
{
case APPLICATION_ACCEPTED = 'application_accepted';
case APPLICATION_REJECTED = 'application_rejected';
case ASSIGNMENT_CALLED_OFF = 'assignment_called_off';
case DISPOSITION_CALLED_OFF = 'disposition_called_off';
case DOCUMENT_REJECTED = 'document_rejected';
case REMINDER_CONTRACT_UPLOAD = 'reminder_contract_upload';
case REMINDER_INVOICE_UPLOAD = 'reminder_invoice_upload';
case REMINDER_DISPOSITION = 'reminder_disposition';
}
@@ -0,0 +1,74 @@
<?php
namespace App\Controller\Admin\System\EmailText;
use App\Config\EmailTextCatalog;
use App\Config\EmailTextKey;
use App\Entity\EmailText;
use App\Form\EmailTextType;
use App\Htmx\HxRedirectResponse;
use App\Repository\EmailTextRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class EditController extends AbstractController
{
public function __construct(
private readonly EmailTextCatalog $catalog,
private readonly EmailTextRepository $emailTextRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/system/email-text/edit/{key}', name: 'app_admin_system_email_text_edit')]
#[IsGranted('ROLE_ADMIN')]
public function index(EmailTextKey $key, Request $request): Response
{
$definition = $this->catalog->get($key);
$emailText = $this->emailTextRepository->findByKey($key);
$isNew = null === $emailText;
// Editing a mail for the first time starts from the delivered wording rather than
// from an empty form, so an admin adjusts a sentence instead of rewriting the mail.
if (true === $isNew) {
$emailText = (new EmailText($key))
->setSubject($definition->defaultSubject)
->setHeadline($definition->defaultHeadline)
->setBody($definition->defaultBody)
;
}
$form = $this->createForm(EmailTextType::class, $emailText, [
'definition' => $definition,
'hx_post' => $request->getUri(),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (true === $isNew) {
$this->entityManager->persist($emailText);
}
$this->entityManager->flush();
$this->addFlash('success', 'Der E-Mail-Text wurde aktualisiert');
$this->logger->info('Edit email text', [
'email_text_id' => $emailText->getId(),
'email_text_key' => $key->value,
]);
return new HxRedirectResponse($this->generateUrl('app_admin_system_email_text_index'));
}
return $this->render('admin/system/email_text/modal_edit.html.twig', [
'form' => $form->createView(),
'definition' => $definition,
]);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Controller\Admin\System\EmailText;
use App\Config\EmailTextCatalog;
use App\Repository\EmailTextRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController
{
public function __construct(
private readonly EmailTextCatalog $catalog,
private readonly EmailTextRepository $emailTextRepository,
) {
}
#[Route('/admin/system/email-text', name: 'app_admin_system_email_text_index')]
#[IsGranted('ROLE_ADMIN')]
public function index(): Response
{
// The list is driven by the catalogue, not by the table: a mail that has never
// been edited has no row and still has to show up here.
return $this->render('admin/system/email_text/index.html.twig', [
'definitions' => $this->catalog->all(),
'emailTexts' => $this->emailTextRepository->findAllIndexedByKey(),
]);
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Controller\Admin\System\EmailText;
use App\Config\EmailTextCatalog;
use App\Config\EmailTextKey;
use App\Email\EmailTextRenderer;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class PreviewController extends AbstractController
{
public function __construct(
private readonly EmailTextCatalog $catalog,
private readonly EmailTextRenderer $emailTextRenderer,
) {
}
/**
* Shows the editable part of the mail - subject, headline, text, button - so an admin
* can check their wording without triggering the event behind it. The surrounding
* layout is left out on purpose: it is not editable, and its logo only resolves while
* a real mail is being assembled.
*/
#[Route('/admin/system/email-text/preview/{key}', name: 'app_admin_system_email_text_preview')]
#[IsGranted('ROLE_ADMIN')]
public function index(EmailTextKey $key): Response
{
$definition = $this->catalog->get($key);
// The placeholders stand in for themselves: no disposition is at hand here, and a
// visible [destination] tells an admin where a real value lands better than an
// invented one would.
$placeholders = [];
foreach ($definition->getPlaceholderNames() as $name) {
$placeholders[$name] = '['.$name.']';
}
return $this->render('admin/system/email_text/modal_preview.html.twig', [
'definition' => $definition,
'text' => $this->emailTextRenderer->render($key, $placeholders),
]);
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Controller\Admin\System\EmailText;
use App\Config\EmailTextCatalog;
use App\Config\EmailTextKey;
use App\Htmx\HxRedirectResponse;
use App\Repository\EmailTextRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ResetController extends AbstractController
{
public function __construct(
private readonly EmailTextCatalog $catalog,
private readonly EmailTextRepository $emailTextRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/system/email-text/reset/{key}', name: 'app_admin_system_email_text_reset')]
#[IsGranted('ROLE_ADMIN')]
public function index(EmailTextKey $key, Request $request): Response
{
$emailText = $this->emailTextRepository->findByKey($key);
if (true === $request->isMethod('POST')) {
// Removing the override is the reset: without a row the mail falls back to
// the wording in EmailTextCatalog.
if (null !== $emailText) {
$this->entityManager->remove($emailText);
$this->entityManager->flush();
}
$this->addFlash('success', 'Der E-Mail-Text wurde zurückgesetzt');
$this->logger->info('Reset email text', [
'email_text_key' => $key->value,
]);
return new HxRedirectResponse($this->generateUrl('app_admin_system_email_text_index'));
}
return $this->render('admin/system/email_text/modal_reset.html.twig', [
'definition' => $this->catalog->get($key),
]);
}
}
+149
View File
@@ -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 &#039; 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
);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Email;
use App\Entity\Disposition;
/**
* Builds the placeholder maps that more than one caller needs.
*
* The disposition reminder is sent both by the nightly cron and by
* EmailNotificationSubscriber when a contract is confirmed shortly before departure; both
* have to fill the same placeholders, and a map that drifts between them would show "-"
* in one of the two mails.
*/
class MailPlaceholderFactory
{
public function __construct(
private readonly int $invoiceUploadDeadlineDays,
) {
}
/**
* @return array<string, string|int|null>
*/
public function forDispositionReminder(Disposition $disposition, int $diffInDays): array
{
$assignment = $disposition->getAssignment();
$destination = $assignment->getDestination();
return [
'destination' => (string) $destination,
'jobProfile' => $assignment->getJobProfile()?->getName(),
'product' => $destination->getProduct(),
'dateFrom' => $destination->getDateFrom()?->format('d.m.Y'),
'dateTo' => $destination->getDateTo()?->format('d.m.Y'),
'diffInDays' => $diffInDays,
'invoiceUploadDeadlineDays' => $this->invoiceUploadDeadlineDays,
];
}
}
+21
View File
@@ -2,6 +2,7 @@
namespace App\Email;
use App\Config\EmailTextKey;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
@@ -17,11 +18,31 @@ class Mailer
private readonly MailerInterface $mailer,
private readonly BodyRendererInterface $bodyRenderer,
private readonly TranslatorInterface $translator,
private readonly EmailTextRenderer $emailTextRenderer,
private readonly array $defaults,
private readonly LoggerInterface $logger,
) {
}
/**
* Sends one of the mails whose wording admins maintain (see App\Config\EmailTextKey).
*
* Subject and body come from the database or, until an admin edits them, from
* EmailTextCatalog - so unlike createAndSendEmail() no 'subject' or 'template'
* option is passed here. Everything else (to, attachments, transport) behaves the same.
*
* @param array<string, string|int|null> $placeholders
*/
public function createAndSendText(EmailTextKey $key, array $placeholders, array $options): void
{
$text = $this->emailTextRenderer->render($key, $placeholders);
$this->createAndSendEmail(['text' => $text], $options + [
'subject' => $text->subject,
'template' => 'email/generic.html.twig',
]);
}
public function createAndSendEmail(array $context, array $options): void
{
$config = $this->resolveConfig($options);
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Entity;
use App\Config\EmailTextKey;
use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Repository\EmailTextRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* An admin's override of the wording of one transactional mail.
*
* A row exists only once a mail has been edited; without one the defaults from
* App\Config\EmailTextCatalog apply. That is also why there is no soft delete here -
* removing the row is exactly "reset to the delivered wording".
*/
#[ORM\Entity(repositoryClass: EmailTextRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_email_text_template_key', columns: ['template_key'])]
class EmailText implements BlameableEntityInterface, TimestampableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 64)]
private string $templateKey;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'Bitte gib einen Betreff an')]
private ?string $subject = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $headline = null;
#[ORM\Column(type: Types::TEXT)]
#[Assert\NotBlank(message: 'Bitte gib einen Text an')]
private ?string $body = null;
public function __construct(EmailTextKey $key)
{
$this->templateKey = $key->value;
}
public function getId(): ?int
{
return $this->id;
}
public function getTemplateKey(): string
{
return $this->templateKey;
}
public function getKey(): EmailTextKey
{
return EmailTextKey::from($this->templateKey);
}
public function getSubject(): ?string
{
return $this->subject;
}
public function setSubject(?string $subject): static
{
$this->subject = $subject;
return $this;
}
public function getHeadline(): ?string
{
return $this->headline;
}
public function setHeadline(?string $headline): static
{
$this->headline = $headline;
return $this;
}
public function getBody(): ?string
{
return $this->body;
}
public function setBody(?string $body): static
{
$this->body = $body;
return $this;
}
}
@@ -2,7 +2,9 @@
namespace App\EventListener;
use App\Config\EmailTextKey;
use App\Email\Mailer;
use App\Email\MailPlaceholderFactory;
use App\Entity\Application;
use App\Entity\Teamer;
use App\Entity\Upload;
@@ -24,6 +26,8 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
private readonly Mailer $mailer,
private readonly UserRepository $userRepository,
private readonly ContractRenderer $contractRenderer,
private readonly MailPlaceholderFactory $placeholderFactory,
private readonly int $contractUploadDeadlineDays,
) {
}
@@ -66,12 +70,12 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
'application/pdf'
);
$this->mailer->createAndSendEmail([
'disposition' => $disposition,
$this->mailer->createAndSendText(EmailTextKey::APPLICATION_ACCEPTED, [
'destination' => (string) $destination,
'specialAgreements' => $disposition->getSpecialAgreements(),
'contractUploadDeadlineDays' => $this->contractUploadDeadlineDays,
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => 'Dein Einsatz wurde angenommen',
'template' => 'email/application_accepted.html.twig',
'attachments' => [$attachment],
]);
}
@@ -91,8 +95,7 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
return;
}
$assignment = $disposition->getAssignment();
$destination = $assignment->getDestination();
$destination = $disposition->getAssignment()->getDestination();
$today = new \DateTimeImmutable();
$diffInDays = $destination->getDateFrom()->diff($today)->days;
@@ -101,14 +104,11 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
return;
}
$this->mailer->createAndSendEmail([
'disposition' => $disposition,
'diffInDays' => $diffInDays,
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => 'Reminder: Dein Einsatz '.$destination,
'template' => 'email/reminder_disposition.html.twig',
]);
$this->mailer->createAndSendText(
EmailTextKey::REMINDER_DISPOSITION,
$this->placeholderFactory->forDispositionReminder($disposition, $diffInDays),
['to' => $teamer->getCommunication()->getEmail()],
);
}
/**
@@ -169,20 +169,19 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
return;
}
// Capitalised because both the subject and the body of the mail open with it.
$documentTypeLabel = match ($document->getType()) {
Upload::TYPE_CONTRACT => 'dein Honorarvertrag',
Upload::TYPE_INVOICE => 'deine Honorarnote',
default => 'Dokument '.$document->getOriginalFilename(),
Upload::TYPE_CONTRACT => 'Dein Honorarvertrag',
Upload::TYPE_INVOICE => 'Deine Honorarnote',
default => 'Dein Dokument '.$document->getOriginalFilename(),
};
$this->mailer->createAndSendEmail([
'disposition' => $disposition,
'documentTypeLabel' => $documentTypeLabel,
$this->mailer->createAndSendText(EmailTextKey::DOCUMENT_REJECTED, [
'destination' => (string) $disposition->getAssignment()->getDestination(),
'documentType' => $documentTypeLabel,
'comment' => $event->getComment(),
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => ucfirst($documentTypeLabel).' wurde abgelehnt',
'template' => 'email/document_rejected.html.twig',
]);
}
@@ -203,12 +202,10 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
return;
}
$this->mailer->createAndSendEmail([
'application' => $application,
$this->mailer->createAndSendText(EmailTextKey::APPLICATION_REJECTED, [
'destination' => (string) $application->getAssignment()->getDestination(),
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => 'Deine Bewerbung wurde abgelehnt',
'template' => 'email/application_rejected.html.twig',
]);
}
@@ -228,13 +225,11 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
return;
}
$this->mailer->createAndSendEmail([
'assignment' => $disposition->getAssignment(),
$this->mailer->createAndSendText(EmailTextKey::DISPOSITION_CALLED_OFF, [
'destination' => (string) $disposition->getAssignment()->getDestination(),
'reason' => $disposition->getCalledOffReason(),
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => 'Dein Einsatz wurde abgesagt',
'template' => 'email/disposition_called_off.html.twig',
]);
}
@@ -251,12 +246,10 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
continue;
}
$this->mailer->createAndSendEmail([
'assignment' => $assignment,
$this->mailer->createAndSendText(EmailTextKey::ASSIGNMENT_CALLED_OFF, [
'destination' => (string) $assignment->getDestination(),
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => 'Die Reise zu deinem Einsatz wurde abgesagt',
'template' => 'email/assignment_called_off.html.twig',
]);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Form;
use App\Config\EmailTextDefinition;
use App\Entity\EmailText;
use App\Validator\KnownPlaceholders;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class EmailTextType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
/** @var EmailTextDefinition $definition */
$definition = $options['definition'];
$placeholders = new KnownPlaceholders($definition->getPlaceholderNames());
$builder
->add('subject', TextType::class, [
'label' => 'Betreff',
'constraints' => [$placeholders],
])
->add('headline', TextType::class, [
'label' => 'Überschrift',
'required' => false,
'constraints' => [$placeholders],
])
->add('body', TextareaType::class, [
'label' => 'Text',
'attr' => [
'data-controller' => 'textarea-autosize',
'data-action' => 'textarea-autosize#resize',
'data-textarea-autosize-min-rows-value' => 12,
],
'constraints' => [$placeholders],
])
;
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
// The list of placeholders is rendered next to the fields, so an admin does not
// have to remember which of them this particular mail can fill.
$view->vars['definition'] = $options['definition'];
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setDefaults([
'data_class' => EmailText::class,
])
->setRequired('definition')
->setAllowedTypes('definition', EmailTextDefinition::class)
;
}
}
+11
View File
@@ -177,6 +177,17 @@ class AdminMenuBuilder extends AbstractMenuBuilder
],
],
]);
$settingsMenu->addChild('E-Mail-Texte', [
'route' => 'app_admin_system_email_text_index',
'linkAttributes' => [
'title' => 'E-Mail-Texte',
],
'extras' => [
'routes' => [
['pattern' => '/^app_admin_system_email_text_/'],
],
],
]);
$settingsMenu->addChild('Jobprofile', [
'route' => 'app_admin_system_job_profile_index',
'linkAttributes' => [
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Model;
/**
* The finished, already escaped parts of an editable mail.
*
* Every string in here is HTML-safe by construction (see App\Email\EmailTextRenderer),
* which is what lets email/generic.html.twig print them with |raw. Do not build one of
* these anywhere but in the renderer.
*/
readonly class RenderedEmailTextDto
{
public function __construct(
public string $subject,
public string $headline,
public string $bodyHtml,
) {
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Config\EmailTextKey;
use App\Entity\EmailText;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EmailText>
*
* @method EmailText|null find($id, $lockMode = null, $lockVersion = null)
* @method EmailText|null findOneBy(array $criteria, array $orderBy = null)
* @method EmailText[] findAll()
* @method EmailText[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class EmailTextRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EmailText::class);
}
public function findByKey(EmailTextKey $key): ?EmailText
{
return $this->findOneBy(['templateKey' => $key->value]);
}
/**
* @return array<string, EmailText> keyed by EmailTextKey value
*/
public function findAllIndexedByKey(): array
{
$indexed = [];
foreach ($this->findAll() as $emailText) {
$indexed[$emailText->getTemplateKey()] = $emailText;
}
return $indexed;
}
}
+15 -11
View File
@@ -2,7 +2,9 @@
namespace App\Service\Cron;
use App\Config\EmailTextKey;
use App\Email\Mailer;
use App\Email\MailPlaceholderFactory;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\Upload;
@@ -12,9 +14,16 @@ use Psr\Log\LoggerInterface;
class DispositionReminderService
{
/**
* How long before an assignment begins the reminder goes out. Also what the mail
* tells the teamer, so the two cannot disagree.
*/
private const REMINDER_LEAD_DAYS = 5;
public function __construct(
private readonly DispositionRepository $dispositionRepository,
private readonly Mailer $mailer,
private readonly MailPlaceholderFactory $placeholderFactory,
private readonly LoggerInterface $logger,
) {
}
@@ -22,7 +31,7 @@ class DispositionReminderService
public function sendDispositionReminders(): string
{
// find dispositions starting in five days from now
$dateFrom = (new \DateTimeImmutable())->modify('+5 days');
$dateFrom = (new \DateTimeImmutable())->modify(sprintf('+%d days', self::REMINDER_LEAD_DAYS));
$qb = $this->dispositionRepository->createQueryBuilder('disposition');
$dispositions = $qb
@@ -55,17 +64,12 @@ class DispositionReminderService
foreach ($dispositions as $disposition) {
/** @var Disposition $disposition */
$teamer = $disposition->getTeamer();
$assignment = $disposition->getAssignment();
$destination = $assignment->getDestination();
$this->mailer->createAndSendEmail([
'disposition' => $disposition,
'diffInDays' => 5,
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => 'Reminder: Dein Einsatz '.$destination,
'template' => 'email/reminder_disposition.html.twig',
]);
$this->mailer->createAndSendText(
EmailTextKey::REMINDER_DISPOSITION,
$this->placeholderFactory->forDispositionReminder($disposition, self::REMINDER_LEAD_DAYS),
['to' => $teamer->getCommunication()->getEmail()],
);
}
$message = 'Sent '.$count.' disposition reminders to teamers';
+18 -11
View File
@@ -2,6 +2,7 @@
namespace App\Service\Cron;
use App\Config\EmailTextKey;
use App\Email\Mailer;
use App\Entity\Disposition;
use App\Entity\Upload;
@@ -13,10 +14,16 @@ use Psr\Log\LoggerInterface;
class UploadReminderService
{
/**
* How long before the invoice deadline the reminder goes out.
*/
private const INVOICE_REMINDER_LEAD_DAYS = 3;
public function __construct(
private readonly DispositionRepository $dispositionRepository,
private readonly Mailer $mailer,
private readonly LoggerInterface $logger,
private readonly int $invoiceUploadDeadlineDays,
) {
}
@@ -65,12 +72,10 @@ class UploadReminderService
foreach ($dispositions as $disposition) {
$teamer = $disposition->getTeamer();
$this->mailer->createAndSendEmail([
'disposition' => $disposition,
$this->mailer->createAndSendText(EmailTextKey::REMINDER_CONTRACT_UPLOAD, [
'destination' => (string) $disposition->getAssignment()->getDestination(),
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => 'Reminder: Fehlende Dokumente',
'template' => 'email/reminder_contract_upload.html.twig',
]);
}
@@ -82,9 +87,12 @@ class UploadReminderService
public function sendInvoiceUploadReminders(): string
{
// Invoices have to be uploaded until 14 days after end of assignment. Reminder
// is sent three days before end of this period thus on the 11th day after end of assignment.
$invoiceDueDate = (new \DateTimeImmutable())->modify('-11 days');
// Invoices have to be uploaded until invoiceUploadDeadlineDays after end of
// assignment. Reminder is sent three days before end of this period, so the
// offset follows the deadline the mail itself quotes.
$invoiceDueDate = (new \DateTimeImmutable())
->modify(sprintf('-%d days', $this->invoiceUploadDeadlineDays - self::INVOICE_REMINDER_LEAD_DAYS))
;
// Find dispositions of assignments with due invoice upload
$qb = $this->dispositionRepository->createQueryBuilder('disposition');
@@ -125,12 +133,11 @@ class UploadReminderService
foreach ($dispositions as $disposition) {
$teamer = $disposition->getTeamer();
$this->mailer->createAndSendEmail([
'disposition' => $disposition,
$this->mailer->createAndSendText(EmailTextKey::REMINDER_INVOICE_UPLOAD, [
'destination' => (string) $disposition->getAssignment()->getDestination(),
'invoiceUploadDeadlineDays' => $this->invoiceUploadDeadlineDays,
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => 'Reminder: Fehlende Dokumente',
'template' => 'email/reminder_invoice_upload.html.twig',
]);
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
/**
* Rejects {tokens} the mail being edited does not know.
*
* Without this a typo like {destinaton} would reach a teamer's inbox as a literal token
* or, worse, silently as a dash.
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class KnownPlaceholders extends Constraint
{
public string $message = 'Unbekannter Platzhalter: {{ placeholder }}. Erlaubt sind: {{ allowed }}';
/**
* @var string[]
*/
public array $allowed = [];
/**
* @param string[] $allowed
*/
public function __construct(array $allowed = [], ?string $message = null, ?array $groups = null, mixed $payload = null)
{
parent::__construct([], $groups, $payload);
$this->allowed = $allowed;
$this->message = $message ?? $this->message;
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class KnownPlaceholdersValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof KnownPlaceholders) {
throw new UnexpectedTypeException($constraint, KnownPlaceholders::class);
}
if (null === $value || '' === $value) {
return;
}
preg_match_all('/\{(\w+)\}/', (string) $value, $matches);
foreach (array_unique($matches[1]) as $placeholder) {
if (in_array($placeholder, $constraint->allowed, true)) {
continue;
}
$this->context
->buildViolation($constraint->message)
->setParameter('{{ placeholder }}', '{'.$placeholder.'}')
->setParameter('{{ allowed }}', implode(', ', array_map(
static fn (string $name): string => '{'.$name.'}',
$constraint->allowed
)))
->addViolation()
;
}
}
}