diff --git a/assets/mjml/layout.mjml b/assets/mjml/layout.mjml index 3285ded..7454392 100644 --- a/assets/mjml/layout.mjml +++ b/assets/mjml/layout.mjml @@ -81,6 +81,11 @@ Lorem ipsum dolor sit amet, consectetur adipisicing elit. Doloribus, eveniet!

{% endblock %} + {% block button %} +

+ Zum Portal +

+ {% endblock %} diff --git a/config/email_texts.yaml b/config/email_texts.yaml new file mode 100644 index 0000000..8c546ec --- /dev/null +++ b/config/email_texts.yaml @@ -0,0 +1,128 @@ +# Default wording of the admin-editable mails (App\Config\EmailTextKey), used until an +# admin overrides a mail under /admin/system/email-text. Removing that override is what +# "Zurücksetzen" does, so a wording fix made here reaches every mail nobody has edited. +# +# 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/. +# +# The text is plain text, never markup: {platzhalter} tokens are substituted by +# App\Email\EmailTextRenderer after escaping, *Sternchen* make text bold (write \* for a +# literal one), a blank line starts a new paragraph, and bare URLs and mail addresses +# become links by themselves. A placeholder without a value prints "-". +# +# Every mail closes with the same "Zum Portal" button, rendered by email/layout.html.twig. + +texts: + application_accepted: + label: 'Bewerbung angenommen' + placeholders: ['destination', 'specialAgreements', 'contractUploadDeadlineDays'] + subject: 'Dein Einsatz wurde angenommen' + headline: 'Herzlichen Glückwunsch!' + body: |- + *Dein Einsatz {destination} wurde angenommen!* + + Zusätzliche Absprachen: {specialAgreements} + + Schau einmal in das My E&P-Team Portal, um die Einsatzdetails einzusehen und deinen Honorarvertrag zu unterschreiben. + + Mit dem Erhalt dieser E-Mail hast du {contractUploadDeadlineDays} Tage Zeit deinen Einsatz zu bestätigen, indem du den unterschriebenen Vertrag hochlädst. Ist diese Frist vergangen, wird der Einsatz für deine Teamkolleg:innen freigeschaltet. + + Schön, dass du dabei bist und ganz viel Spaß in den Bergen! 😊 + + Bitte wende dich bei Rückfragen an team@ep-reisen.de. + + application_rejected: + label: 'Bewerbung abgelehnt' + placeholders: ['destination'] + subject: 'Deine Bewerbung wurde abgelehnt' + headline: 'Hallo aus Köln,' + body: |- + leider konnte deine Bewerbung für den Einsatz '{destination}' nicht angenommen werden. Dies kann unterschiedliche Gründe haben, wie bspw. Unpassende Buszustiege, zu späte Bewerbung, unpassendes Jobprofil, zu viele andere Bewerbungen o.Ä. + + Bewirb dich doch gerne auf weitere Einsätze, vielleicht hast du diesmal Glück! 😊 + + assignment_called_off: + label: 'Reise abgesagt' + placeholders: ['destination'] + subject: 'Die Reise zu deinem Einsatz wurde abgesagt' + headline: 'Hallo aus Köln,' + body: |- + leider mussten wir die Reise zu deinem Einsatz '{destination}' absagen, sodass dein Einsatz nicht stattfinden kann. + + Bitte entschuldige die Umstände! + + Bei Fragen melde dich gerne unter team@ep-reisen.de + + disposition_called_off: + label: 'Einsatz abgesagt' + placeholders: ['destination', 'reason'] + subject: 'Dein Einsatz wurde abgesagt' + headline: 'Hallo aus Köln,' + body: |- + leider mussten wir deinen Einsatz '{destination}' absagen, sodass dein Einsatz nicht stattfinden kann: + + {reason} + + Bei Fragen melde dich gerne unter team@ep-reisen.de + + document_rejected: + label: 'Dokument abgelehnt' + # documentType arrives capitalised, because subject and body both open with it. + placeholders: ['destination', 'documentType', 'comment'] + subject: '{documentType} wurde abgelehnt' + headline: 'Hallo aus Köln,' + body: |- + {documentType} wurde für den Einsatz {destination} leider mit folgender Begründung abgelehnt: + + {comment} + + Überarbeite das Dokument bitte und lade es erneut hoch. + + reminder_contract_upload: + label: 'Erinnerung: Honorarvertrag hochladen' + placeholders: ['destination'] + subject: 'Reminder: Fehlende Dokumente' + headline: 'Hallo aus Köln,' + 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. + + *{destination}* + + reminder_invoice_upload: + label: 'Erinnerung: Honorarnote hochladen' + placeholders: ['destination', 'invoiceUploadDeadlineDays'] + subject: 'Reminder: Fehlende Dokumente' + headline: 'Hallo aus Köln,' + body: |- + vielen Dank für deinen Einsatz {destination}. Bitte lade deine Honorarnote innerhalb der nächsten {invoiceUploadDeadlineDays} Tage hoch, damit wir dein Honorar bearbeiten können. Solltest du deine Honorarnote zu spät hochladen, können wir sie eventuell nicht mehr annehmen. + + reminder_disposition: + label: 'Erinnerung: Einsatz steht bevor' + placeholders: ['destination', 'jobProfile', 'product', 'dateFrom', 'dateTo', 'diffInDays', 'invoiceUploadDeadlineDays'] + subject: 'Reminder: Dein Einsatz {destination}' + headline: 'Hallo aus Köln,' + body: |- + 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. + + Bitte denk daran, deine *ausgefüllte Honorarnote bis zu {invoiceUploadDeadlineDays} Tage nach deinem Einsatz* in My E&P Team hochzuladen. + + Liebe Grüße, + dein Team Personalabteilung + +# Descriptions shown next to the form fields. They describe the placeholder itself rather +# than any one mail, so they are shared: a name means the same thing wherever it appears. +placeholders: + destination: 'Einsatz, z. B. "12.01.25 - 19.01.25 Skireise"' + specialAgreements: 'Zusätzliche Absprachen zum Einsatz' + contractUploadDeadlineDays: 'Frist zum Hochladen des Honorarvertrags in Tagen' + reason: 'Begründung der Absage' + documentType: 'Art des Dokuments, z. B. "Dein Honorarvertrag"' + comment: 'Begründung der Ablehnung' + invoiceUploadDeadlineDays: 'Frist zum Hochladen der Honorarnote in Tagen' + jobProfile: 'Jobprofil des Einsatzes' + product: 'Produkt der Reise' + dateFrom: 'Beginn des Einsatzes' + dateTo: 'Ende des Einsatzes' + diffInDays: 'Tage bis zum Beginn des Einsatzes' diff --git a/config/services.yaml b/config/services.yaml index 4a8ed06..cd4c912 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -9,6 +9,13 @@ parameters: teamer_inactive_period: '-2 years' + # Deadlines quoted to teamers in the transactional mails. They are passed to the mail + # texts as placeholders rather than typed into the wording, so what an admin edits can + # never drift from the period the application actually works with. + contract_upload_deadline_days: 7 + # Also drives when the reminder goes out: three days before the deadline expires. + invoice_upload_deadline_days: 14 + # Messenger transport the mails of a teamer mailing are queued on. An X-Bus-Transport # header wins over the routing in messenger.yaml (see SendersLocator::getSenders), # so this is what keeps a mailing off the shared queue - and what has to be switched @@ -58,6 +65,11 @@ services: $xmlExport: '@xml_export.storage' $xmlDump: '@xml_dump.storage' $teamerInactivePeriod: '%teamer_inactive_period%' + $contractUploadDeadlineDays: '%contract_upload_deadline_days%' + $invoiceUploadDeadlineDays: '%invoice_upload_deadline_days%' + # Only the path enters the container - the file itself is parsed on demand by + # App\Config\EmailTextCatalog, since its contents matter on few requests. + $emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml' $mailingBusTransport: '%mailing_bus_transport%' App\: diff --git a/migrations/Version20260817074008.php b/migrations/Version20260817074008.php new file mode 100644 index 0000000..670a3d2 --- /dev/null +++ b/migrations/Version20260817074008.php @@ -0,0 +1,26 @@ +addSql('CREATE TABLE email_text (id INT AUTO_INCREMENT NOT NULL, template_key VARCHAR(64) NOT NULL, subject VARCHAR(255) NOT NULL, headline VARCHAR(255) DEFAULT NULL, body LONGTEXT NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX uniq_email_text_template_key (template_key), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE email_text'); + } +} diff --git a/src/Config/EmailTextCatalog.php b/src/Config/EmailTextCatalog.php new file mode 100644 index 0000000..0006764 --- /dev/null +++ b/src/Config/EmailTextCatalog.php @@ -0,0 +1,75 @@ +|null + */ + private ?array $definitions = null; + + public function __construct( + private readonly string $emailTextsFile, + ) { + } + + /** + * @return array 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 + */ + 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; + } +} diff --git a/src/Config/EmailTextDefinition.php b/src/Config/EmailTextDefinition.php new file mode 100644 index 0000000..dcf3836 --- /dev/null +++ b/src/Config/EmailTextDefinition.php @@ -0,0 +1,31 @@ + $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); + } +} diff --git a/src/Config/EmailTextKey.php b/src/Config/EmailTextKey.php new file mode 100644 index 0000000..0b7046e --- /dev/null +++ b/src/Config/EmailTextKey.php @@ -0,0 +1,21 @@ +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, + ]); + } +} diff --git a/src/Controller/Admin/System/EmailText/IndexController.php b/src/Controller/Admin/System/EmailText/IndexController.php new file mode 100644 index 0000000..c584777 --- /dev/null +++ b/src/Controller/Admin/System/EmailText/IndexController.php @@ -0,0 +1,31 @@ +render('admin/system/email_text/index.html.twig', [ + 'definitions' => $this->catalog->all(), + 'emailTexts' => $this->emailTextRepository->findAllIndexedByKey(), + ]); + } +} diff --git a/src/Controller/Admin/System/EmailText/PreviewController.php b/src/Controller/Admin/System/EmailText/PreviewController.php new file mode 100644 index 0000000..644795f --- /dev/null +++ b/src/Controller/Admin/System/EmailText/PreviewController.php @@ -0,0 +1,47 @@ +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), + ]); + } +} diff --git a/src/Controller/Admin/System/EmailText/ResetController.php b/src/Controller/Admin/System/EmailText/ResetController.php new file mode 100644 index 0000000..d8c22cd --- /dev/null +++ b/src/Controller/Admin/System/EmailText/ResetController.php @@ -0,0 +1,53 @@ +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), + ]); + } +} diff --git a/src/Email/EmailTextRenderer.php b/src/Email/EmailTextRenderer.php new file mode 100644 index 0000000..f7f4989 --- /dev/null +++ b/src/Email/EmailTextRenderer.php @@ -0,0 +1,149 @@ + $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 $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

with its own margins, so keeping real paragraphs preserves + // the spacing the hand-written templates had - a single

full of
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[] = '

'.nl2br($block).'

'; + } + + return $this->linkify(implode("\n", $paragraphs)); + } + + /** + * @param array $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 . A literal asterisk is written \*. + */ + private function emphasise(string $text): string + { + $text = preg_replace( + '/(?$1', + $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( + '~(?https?://[^\s<]+)|(?[\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('%s%s', $url, $url, $trailing); + } + + return sprintf('%1$s', $match['email']); + }, + $html + ); + } +} diff --git a/src/Email/MailPlaceholderFactory.php b/src/Email/MailPlaceholderFactory.php new file mode 100644 index 0000000..27c21ff --- /dev/null +++ b/src/Email/MailPlaceholderFactory.php @@ -0,0 +1,40 @@ + + */ + 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, + ]; + } +} diff --git a/src/Email/Mailer.php b/src/Email/Mailer.php index 7f10b47..1cacf9d 100644 --- a/src/Email/Mailer.php +++ b/src/Email/Mailer.php @@ -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 $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); diff --git a/src/Entity/EmailText.php b/src/Entity/EmailText.php new file mode 100644 index 0000000..2c7cea6 --- /dev/null +++ b/src/Entity/EmailText.php @@ -0,0 +1,101 @@ +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; + } +} diff --git a/src/EventListener/EmailNotificationSubscriber.php b/src/EventListener/EmailNotificationSubscriber.php index fc55012..176f656 100644 --- a/src/EventListener/EmailNotificationSubscriber.php +++ b/src/EventListener/EmailNotificationSubscriber.php @@ -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', ]); } } diff --git a/src/Form/EmailTextType.php b/src/Form/EmailTextType.php new file mode 100644 index 0000000..238e82e --- /dev/null +++ b/src/Form/EmailTextType.php @@ -0,0 +1,63 @@ +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) + ; + } +} diff --git a/src/Menu/AdminMenuBuilder.php b/src/Menu/AdminMenuBuilder.php index a8584ed..78dd046 100644 --- a/src/Menu/AdminMenuBuilder.php +++ b/src/Menu/AdminMenuBuilder.php @@ -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' => [ diff --git a/src/Model/RenderedEmailTextDto.php b/src/Model/RenderedEmailTextDto.php new file mode 100644 index 0000000..250d6be --- /dev/null +++ b/src/Model/RenderedEmailTextDto.php @@ -0,0 +1,20 @@ + + * + * @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 keyed by EmailTextKey value + */ + public function findAllIndexedByKey(): array + { + $indexed = []; + + foreach ($this->findAll() as $emailText) { + $indexed[$emailText->getTemplateKey()] = $emailText; + } + + return $indexed; + } +} diff --git a/src/Service/Cron/DispositionReminderService.php b/src/Service/Cron/DispositionReminderService.php index a8dcfd9..6f3368a 100644 --- a/src/Service/Cron/DispositionReminderService.php +++ b/src/Service/Cron/DispositionReminderService.php @@ -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'; diff --git a/src/Service/Cron/UploadReminderService.php b/src/Service/Cron/UploadReminderService.php index 9fc95a5..ed2dd4e 100644 --- a/src/Service/Cron/UploadReminderService.php +++ b/src/Service/Cron/UploadReminderService.php @@ -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', ]); } diff --git a/src/Validator/KnownPlaceholders.php b/src/Validator/KnownPlaceholders.php new file mode 100644 index 0000000..25727b6 --- /dev/null +++ b/src/Validator/KnownPlaceholders.php @@ -0,0 +1,33 @@ +allowed = $allowed; + $this->message = $message ?? $this->message; + } +} diff --git a/src/Validator/KnownPlaceholdersValidator.php b/src/Validator/KnownPlaceholdersValidator.php new file mode 100644 index 0000000..ebdd9e9 --- /dev/null +++ b/src/Validator/KnownPlaceholdersValidator.php @@ -0,0 +1,39 @@ +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() + ; + } + } +} diff --git a/templates/admin/system/email_text/_form.html.twig b/templates/admin/system/email_text/_form.html.twig new file mode 100644 index 0000000..a23762a --- /dev/null +++ b/templates/admin/system/email_text/_form.html.twig @@ -0,0 +1,27 @@ +{{ form_start(form) }} +
+ {{ form_row(form.subject) }} + {{ form_row(form.headline) }} + {{ form_row(form.body) }} + +
+
+ Verfügbare Platzhalter +
+
    + {% for name, description in definition.placeholders %} +
  • + {{ '{' ~ name ~ '}' }} – {{ description }} +
  • + {% endfor %} +
+
+ Platzhalter ohne Wert werden als - ausgegeben. +
+
+
+ +{{ form_rest(form) }} +{{ form_end(form) }} diff --git a/templates/admin/system/email_text/index.html.twig b/templates/admin/system/email_text/index.html.twig new file mode 100644 index 0000000..0931101 --- /dev/null +++ b/templates/admin/system/email_text/index.html.twig @@ -0,0 +1,77 @@ +{% extends 'admin/layout.html.twig' %} + +{% block title %}Einstellungen - E-Mail-Texte{% endblock %} + +{% block content %} +

+ E-Mail-Texte +

+
+
+ + + + + + + + + + + {% for key, definition in definitions %} + {% set emailText = emailTexts[key]|default(null) %} + + + + + + + {% endfor %} + +
+ E-Mail + + Betreff + + Status +
+ {{ definition.label }} + + {{ emailText ? emailText.subject : definition.defaultSubject }} + + {% if emailText %} + Angepasst + {% else %} + Standard + {% endif %} + +
+ {% if emailText %} + + {% endif %} + + +
+
+
+
+{% endblock %} diff --git a/templates/admin/system/email_text/modal_edit.html.twig b/templates/admin/system/email_text/modal_edit.html.twig new file mode 100644 index 0000000..1cf3acd --- /dev/null +++ b/templates/admin/system/email_text/modal_edit.html.twig @@ -0,0 +1,7 @@ +{% extends 'htmx_modal.html.twig' %} + +{% block title %}{{ definition.label }}{% endblock %} + +{% block content %} + {% include 'admin/system/email_text/_form.html.twig' %} +{% endblock %} diff --git a/templates/admin/system/email_text/modal_preview.html.twig b/templates/admin/system/email_text/modal_preview.html.twig new file mode 100644 index 0000000..591c69f --- /dev/null +++ b/templates/admin/system/email_text/modal_preview.html.twig @@ -0,0 +1,27 @@ +{% extends 'htmx_modal.html.twig' %} + +{% block title %}Vorschau: {{ definition.label }}{% endblock %} + +{% block content %} +
+ Betreff: {{ text.subject }} +
+ {# The renderer escaped everything before it added a tag, see RenderedEmailTextDto. #} +
+ {% if text.headline is not empty %} +

{{ text.headline | raw }}

+ {% endif %} + {# The paragraphs come from the mail body, where email/layout.html.twig styles every +

with padding-bottom:16px - pb-4 is the same spacing, so the preview matches. #} +

+ {{ text.bodyHtml | raw }} +
+

+ Zum Portal +

+
+
+ Platzhalter sind als [name] dargestellt und werden beim Versand durch + die echten Werte ersetzt. Logo und Fußzeile der E-Mail sind hier nicht abgebildet. +
+{% endblock %} diff --git a/templates/admin/system/email_text/modal_reset.html.twig b/templates/admin/system/email_text/modal_reset.html.twig new file mode 100644 index 0000000..d0600ee --- /dev/null +++ b/templates/admin/system/email_text/modal_reset.html.twig @@ -0,0 +1,10 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du den Text der E-Mail {{ definition.label }} wirklich auf den + Standardtext zurücksetzen? Deine Änderungen gehen dabei verloren. +
+{% endblock %} + +{% block button_confirm %}Zurücksetzen{% endblock %} diff --git a/templates/email/application_accepted.html.twig b/templates/email/application_accepted.html.twig deleted file mode 100644 index fd8e31b..0000000 --- a/templates/email/application_accepted.html.twig +++ /dev/null @@ -1,39 +0,0 @@ -{% extends 'email/layout.html.twig' %} - -{% block body %} -

- Herzlichen Glückwunsch! -

-

- - Dein Einsatz {{ disposition.assignment.destination }} - wurde angenommen! - -

- {% if disposition.specialAgreements %} -

- Zusätzliche Absprachen: -
- {{ disposition.specialAgreements | nl2br }} -

- {% endif %} -

- Schau einmal in das My E&P-Team Portal, - um die Einsatzdetails einzusehen und deinen Honorarvertrag zu unterschreiben. -

-

- Mit dem Erhalt dieser E-Mail hast du 7 Tage Zeit deinen Einsatz zu bestätigen, indem du den unterschriebenen - Vertrag hochlädst. Ist diese Frist vergangen, wird der Einsatz für deine Teamkolleg:innen freigeschaltet. -

-

- Schön, dass du dabei bist und ganz viel Spaß in den Bergen! 😊 -

-

- Bitte wende dich bei Rückfragen an team@ep-reisen.de. -

-

- - Zum Portal - -

-{% endblock %} diff --git a/templates/email/application_rejected.html.twig b/templates/email/application_rejected.html.twig deleted file mode 100644 index f723662..0000000 --- a/templates/email/application_rejected.html.twig +++ /dev/null @@ -1,20 +0,0 @@ -{% extends 'email/layout.html.twig' %} - -{% block body %} -

- Hallo aus Köln, -

-

- leider konnte deine Bewerbung für den Einsatz '{{ application.assignment.destination }}' nicht angenommen - werden. Dies kann unterschiedliche Gründe haben, wie bspw. Unpassende Buszustiege, zu späte Bewerbung, - unpassendes Jobprofil, zu viele andere Bewerbungen o.Ä. -

-

- Bewirb dich doch gerne auf weitere Einsätze, vielleicht hast du diesmal Glück! 😊 -

-

- - Zum Portal - -

-{% endblock %} \ No newline at end of file diff --git a/templates/email/assignment_called_off.html.twig b/templates/email/assignment_called_off.html.twig deleted file mode 100644 index 2110ca8..0000000 --- a/templates/email/assignment_called_off.html.twig +++ /dev/null @@ -1,22 +0,0 @@ -{% extends 'email/layout.html.twig' %} - -{% block body %} -

- Hallo aus Köln, -

-

- leider mussten wir die Reise zu deinem Einsatz '{{ assignment.destination }}' absagen, sodass dein Einsatz - nicht stattfinden kann. -

-

- Bitte entschuldige die Umstände! -

-

- Bei Fragen melde dich gerne unter team@ep-reisen.de -

-

- - Zum Portal - -

-{% endblock %} \ No newline at end of file diff --git a/templates/email/contract_uploaded.html.twig b/templates/email/contract_uploaded.html.twig index f59c0f2..0cf3f3c 100644 --- a/templates/email/contract_uploaded.html.twig +++ b/templates/email/contract_uploaded.html.twig @@ -7,9 +7,4 @@

Es wurde ein neuer Honorarvertrag von {{ disposition.teamer }} hochgeladen, schau ihn dir an.

-

- - Zum Portal - -

{% endblock %} \ No newline at end of file diff --git a/templates/email/datev_invoice.html.twig b/templates/email/datev_invoice.html.twig index fa55547..ec70ce3 100644 --- a/templates/email/datev_invoice.html.twig +++ b/templates/email/datev_invoice.html.twig @@ -9,3 +9,5 @@ wurde freigegeben.

{% endblock %} + +{% block button %}{% endblock %} diff --git a/templates/email/disposition_called_off.html.twig b/templates/email/disposition_called_off.html.twig deleted file mode 100644 index e7f84e7..0000000 --- a/templates/email/disposition_called_off.html.twig +++ /dev/null @@ -1,22 +0,0 @@ -{% extends 'email/layout.html.twig' %} - -{% block body %} -

- Hallo aus Köln, -

-

- leider mussten wir deinen Einsatz '{{ assignment.destination }}' absagen, sodass dein Einsatz - nicht stattfinden kann: -

-

- {{ reason | nl2br }} -

-

- Bei Fragen melde dich gerne unter team@ep-reisen.de -

-

- - Zum Portal - -

-{% endblock %} diff --git a/templates/email/document_rejected.html.twig b/templates/email/document_rejected.html.twig deleted file mode 100644 index 6015a05..0000000 --- a/templates/email/document_rejected.html.twig +++ /dev/null @@ -1,22 +0,0 @@ -{% extends 'email/layout.html.twig' %} - -{% block body %} -

- Hallo aus Köln, -

-

- leider wurde {{ documentTypeLabel }} für den Einsatz {{ disposition.assignment.destination}} mit folgender - Begründung abgelehnt: -

-

- {{ comment | nl2br }} -

-

- Überarbeite das Dokument bitte und lade es erneut hoch. -

-

- - Zum Portal - -

-{% endblock %} \ No newline at end of file diff --git a/templates/email/generic.html.twig b/templates/email/generic.html.twig new file mode 100644 index 0000000..0d78bbd --- /dev/null +++ b/templates/email/generic.html.twig @@ -0,0 +1,15 @@ +{% extends 'email/layout.html.twig' %} + +{# Every string on `text` was escaped by App\Email\EmailTextRenderer before the , +

and tags in it were introduced, which is what makes |raw safe here. Do not + pass anything into this template that did not come out of that renderer. + The closing button is rendered by the layout. #} +{% block body %} + {% if text.headline is not empty %} +

+ {{ text.headline | raw }} +

+ {% endif %} + + {{ text.bodyHtml | raw }} +{% endblock %} diff --git a/templates/email/invoice_uploaded.html.twig b/templates/email/invoice_uploaded.html.twig index 403ee47..ac8203a 100644 --- a/templates/email/invoice_uploaded.html.twig +++ b/templates/email/invoice_uploaded.html.twig @@ -7,9 +7,4 @@

Es wurde eine neue Honorarnote von {{ disposition.teamer }} hochgeladen, gib diese bitte frei.

-

- - Zum Portal - -

{% endblock %} \ No newline at end of file diff --git a/templates/email/layout.html.twig b/templates/email/layout.html.twig index 37a44ef..f89f923 100644 --- a/templates/email/layout.html.twig +++ b/templates/email/layout.html.twig @@ -220,6 +220,11 @@ Button

Lorem ipsum dolor sit amet, consectetur adipisicing elit. Doloribus, eveniet!

{% endblock %} + {% block button %} +

+ Zum Portal +

+ {% endblock %} diff --git a/templates/email/reminder_contract_upload.html.twig b/templates/email/reminder_contract_upload.html.twig deleted file mode 100644 index f8dad08..0000000 --- a/templates/email/reminder_contract_upload.html.twig +++ /dev/null @@ -1,23 +0,0 @@ -{% extends 'email/layout.html.twig' %} - -{% block body %} -

- Hallo aus Köln, -

-

- 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. -

-

- - - {{ disposition.assignment.destination}} - . - -

-

- - Zum Portal - -

-{% endblock %} \ No newline at end of file diff --git a/templates/email/reminder_disposition.html.twig b/templates/email/reminder_disposition.html.twig deleted file mode 100644 index cf32735..0000000 --- a/templates/email/reminder_disposition.html.twig +++ /dev/null @@ -1,31 +0,0 @@ -{% extends 'email/layout.html.twig' %} - -{% block body %} - {% set destination = disposition.assignment.destination %} -

- Hallo aus Köln, -

-

- dein Einsatz als {{ disposition.assignment.jobProfile.name }}, {{ destination.product }} vom - {{ destination.dateFrom | date('d.m.Y') }} bis {{ destination.dateTo | date('d.m.Y') }} 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. -

-

- Bitte denk daran, deine ausgefüllte Honorarnote bis zu 14 Tage nach deinem Einsatz in My - E&P Team hochzuladen. -

-

- Liebe Grüße, -
- dein Team Personalabteilung -

-

- - Zum Portal - -

-{% endblock %} \ No newline at end of file diff --git a/templates/email/reminder_invoice_upload.html.twig b/templates/email/reminder_invoice_upload.html.twig deleted file mode 100644 index 7c1639d..0000000 --- a/templates/email/reminder_invoice_upload.html.twig +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'email/layout.html.twig' %} - -{% block body %} -

- Hallo aus Köln, -

-

- vielen Dank für deinen Einsatz {{ disposition.assignment.destination}}. Bitte lade deine Honorarnote innerhalb - der nächsten 14 Tage hoch, damit wir dein Honorar bearbeiten können. Solltest du deine Honorarnote zu spät - hochladen, können wir sie eventuell nicht mehr annehmen. -

-

- - Zum Portal - -

-{% endblock %} \ No newline at end of file diff --git a/templates/email/reminder_pending_feedback.html.twig b/templates/email/reminder_pending_feedback.html.twig index e4888bb..1ac3942 100644 --- a/templates/email/reminder_pending_feedback.html.twig +++ b/templates/email/reminder_pending_feedback.html.twig @@ -4,9 +4,4 @@

Reminder zu ausstehenden Feedbacks

-

- - Zum Portal - -

{% endblock %} \ No newline at end of file diff --git a/templates/email/teamer_mailing.html.twig b/templates/email/teamer_mailing.html.twig index 7d02483..e0f181b 100644 --- a/templates/email/teamer_mailing.html.twig +++ b/templates/email/teamer_mailing.html.twig @@ -4,9 +4,4 @@

{{ message | nl2br }}

-

- - Zum Portal - -

{% endblock %} diff --git a/tests/Config/EmailTextCatalogTest.php b/tests/Config/EmailTextCatalogTest.php new file mode 100644 index 0000000..0390c23 --- /dev/null +++ b/tests/Config/EmailTextCatalogTest.php @@ -0,0 +1,98 @@ +catalog(); + + foreach (EmailTextKey::cases() as $key) { + $definition = $catalog->get($key); + + $this->assertSame($key, $definition->key); + $this->assertNotSame('', $definition->label); + $this->assertNotSame('', $definition->defaultSubject); + $this->assertNotSame('', $definition->defaultBody); + } + } + + public function testDefaultsOnlyUseDeclaredPlaceholders(): void + { + $catalog = $this->catalog(); + + foreach (EmailTextKey::cases() as $key) { + $definition = $catalog->get($key); + $declared = $definition->getPlaceholderNames(); + + preg_match_all( + '/\{(\w+)\}/', + $definition->defaultSubject.' '.$definition->defaultHeadline.' '.$definition->defaultBody, + $matches + ); + $used = array_unique($matches[1]); + + $this->assertSame( + [], + array_diff($used, $declared), + sprintf('Default wording of "%s" uses undeclared placeholders', $key->value) + ); + } + } + + /** + * A placeholder nobody prints is either a leftover or a hint that the wording lost a + * detail it used to carry. + */ + public function testEveryDeclaredPlaceholderIsUsedByTheDefaults(): void + { + $catalog = $this->catalog(); + + foreach (EmailTextKey::cases() as $key) { + $definition = $catalog->get($key); + $text = $definition->defaultSubject.' '.$definition->defaultHeadline.' '.$definition->defaultBody; + + foreach ($definition->getPlaceholderNames() as $name) { + $this->assertStringContainsString( + '{'.$name.'}', + $text, + sprintf('Placeholder "%s" of "%s" is declared but never used', $name, $key->value) + ); + } + } + } + + public function testEveryPlaceholderIsDescribedForTheAdmin(): void + { + $catalog = $this->catalog(); + + foreach (EmailTextKey::cases() as $key) { + foreach ($catalog->get($key)->placeholders as $name => $description) { + $this->assertNotSame('', trim($description), sprintf('Placeholder "%s" has no description', $name)); + } + } + } + + private function catalog(): EmailTextCatalog + { + self::bootKernel(); + + return self::getContainer()->get(EmailTextCatalog::class); + } +} diff --git a/tests/Email/EmailTextMailRenderingTest.php b/tests/Email/EmailTextMailRenderingTest.php new file mode 100644 index 0000000..bb4c43c --- /dev/null +++ b/tests/Email/EmailTextMailRenderingTest.php @@ -0,0 +1,87 @@ +get(EmailTextCatalog::class); + $definition = $catalog->get($key); + + $placeholders = []; + + foreach ($definition->getPlaceholderNames() as $name) { + $placeholders[$name] = '['.$name.']'; + } + + // Renders the delivered wording: the test environment has no database, and the + // defaults are what a mail falls back to anyway. + $repository = $this->createMock(EmailTextRepository::class); + $repository->method('findByKey')->willReturn(null); + + $text = (new EmailTextRenderer($catalog, $repository)) + ->render($key, $placeholders) + ; + + $email = $container->get(Mailer::class)->create(['text' => $text], [ + 'from' => 'test@example.com', + 'to' => 'teamer@example.com', + 'subject' => $text->subject, + 'template' => 'email/generic.html.twig', + 'subject_parameters' => [], + 'attachments' => [], + 'transport' => null, + 'bus_transport' => null, + ]); + + $html = $email->getHtmlBody(); + + $this->assertNotEmpty($html); + $this->assertStringContainsString($text->headline, $html); + // The layout closes every mail with the same button, pointing at the site root + // rather than a deep link so that a client's link preview cannot trip the + // unauthorized-access logging. + $this->assertStringContainsString('class="button">Zum Portal', $html); + $this->assertStringNotContainsString('/teamer/disposition/detail', $html); + + // Every placeholder the mail declares has to reach the rendered body, or the + // wording lost a detail somewhere between catalogue and template. + foreach ($definition->getPlaceholderNames() as $name) { + $this->assertStringContainsString( + '['.$name.']', + $html.$email->getSubject(), + sprintf('Placeholder "%s" does not show up in the rendered mail', $name) + ); + } + } + + public static function provideKeys(): iterable + { + foreach (EmailTextKey::cases() as $key) { + yield $key->value => [$key]; + } + } +} diff --git a/tests/Email/EmailTextRendererTest.php b/tests/Email/EmailTextRendererTest.php new file mode 100644 index 0000000..40b612b --- /dev/null +++ b/tests/Email/EmailTextRendererTest.php @@ -0,0 +1,211 @@ +render( + $this->emailText(subject: 'Neuer Betreff', body: 'Neuer Text'), + ['destination' => 'Skireise'] + ); + + $this->assertSame('Neuer Betreff', $rendered->subject); + $this->assertSame('

Neuer Text

', $rendered->bodyHtml); + } + + /** + * A fresh database has no rows at all, so the delivered wording has to carry the mails + * on its own. + */ + public function testTheDefaultWordingIsUsedWithoutAStoredRow(): void + { + $rendered = $this->render(null, ['destination' => 'Skireise']); + + $this->assertSame('Deine Bewerbung wurde abgelehnt', $rendered->subject); + $this->assertStringContainsString('Skireise', $rendered->bodyHtml); + } + + public function testMarkupTypedByAnAdminIsEscaped(): void + { + $rendered = $this->render( + $this->emailText(body: 'Hallo '), + ['destination' => 'Skireise'] + ); + + $this->assertStringNotContainsString('