feat: answer received form submissions by mail

This commit is contained in:
fromme committed 2026-09-24 16:35:31 +02:00
1 parent 19364df2db
commit aa728ddc99
22 files changed
+974 -9

No files matched your search

@@ -0,0 +1,185 @@
<?php
namespace App\Email;
use App\Config\EmailTextKey;
use App\Config\SeasonCalendar;
use App\Config\UploadToken;
use App\Entity\ApplicationFormSubmission;
use App\Entity\Upload;
use App\Model\EmailPathAttachmentDto;
use App\Repository\UploadRepository;
use App\Service\Upload\UploadHandler;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* The two mails a received form submission gets: the receipt sent when it arrives, and the
* follow-up a reviewer triggers by hand. Both are admin-editable (App\Config\EmailTextKey) and
* both stamp the date they went out, which is what the reviewer's list shows.
*
* "Sent" means handed to the mailer. Symfony routes SendEmailMessage to the async transport in
* production, so the SMTP delivery happens in a worker afterwards - deliberately, because this
* is called inside the visitor's own form submission on another website. A delivery
* confirmation would need machinery nothing else here has.
*
* Goes over MAILER_DSN like every transactional mail; MAILING_MAILER_DSN is for the bulk teamer
* mailing only.
*/
class ApplicationFormSubmissionMailer
{
public function __construct(
private readonly Mailer $mailer,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly SeasonCalendar $seasonCalendar,
private readonly UploadRepository $uploadRepository,
private readonly UploadHandler $uploadHandler,
) {
}
/**
* The document library file the follow-up mail attaches, or null while no document carries the
* slot. Public so the confirmation modal can name it - a reviewer should see whether the
* handout the wording promises is actually going out before clicking send.
*/
public function findHandout(): ?Upload
{
return $this->uploadRepository->findOneBy(['token' => UploadToken::APPLICATION_FORM_HANDOUT]);
}
/**
* The receipt as the endpoint sends it: only for an applicant the form reported as holding an
* EU (incl. Switzerland) nationality, and never throwing. The submission is stored and
* answered by the time this runs, so a template or transport problem must not turn that into
* a 500 - TYPO3 would drop a submission this application already has.
*/
public function sendAutomaticReceipt(ApplicationFormSubmission $submission): bool
{
// Strictly true: false is "asked and not EU", null is "this form does not ask at all",
// and neither is a yes. A form that wants the receipt has to map the "eu" field.
if (true !== $submission->isEu()) {
$this->logger->info('Application form receipt mail skipped, not eligible', [
'uuid' => $submission->getUuid(),
// The value, not the applicant's nationality: a flag the form set (§7).
'euFieldPresent' => null !== $submission->isEu(),
]);
return false;
}
try {
return $this->sendReceipt($submission);
} catch (\Throwable $e) {
$this->logger->error('Application form receipt mail failed', [
'uuid' => $submission->getUuid(),
'exception' => $e,
]);
return false;
}
}
/**
* The receipt sent by hand from the detail view. No eligibility check: a reviewer looking at
* the submission has decided, and that decision outranks what the form reported.
*/
public function sendReceipt(ApplicationFormSubmission $submission): bool
{
return $this->send($submission, EmailTextKey::APPLICATION_FORM_RECEIVED);
}
/**
* The follow-up, triggered from the detail view. Unlike the receipt this one is allowed to
* throw: a reviewer who clicked the button has to learn that nothing went out.
*/
public function sendFollowUp(ApplicationFormSubmission $submission): bool
{
return $this->send($submission, EmailTextKey::APPLICATION_FORM_FOLLOW_UP);
}
/**
* Both mails may be sent again - a bounced address that got corrected, or wording that was
* fixed after the fact - so the stamp is overwritten rather than guarded.
*/
/**
* The follow-up mail's wording promises a handout. A missing one is a warning and not a
* refusal: the send is a reviewer's deliberate action, the confirmation modal has already told
* them whether the attachment exists, and blocking them on something only an admin can fix
* would be worse than a mail without it.
*
* @return list<EmailPathAttachmentDto>
*/
private function attachmentsFor(EmailTextKey $key): array
{
if (EmailTextKey::APPLICATION_FORM_FOLLOW_UP !== $key) {
return [];
}
$handout = $this->findHandout();
if (null === $handout) {
$this->logger->warning('Application form follow-up mail sent without its handout', [
'token' => UploadToken::APPLICATION_FORM_HANDOUT->value,
]);
return [];
}
return [new EmailPathAttachmentDto(
// Resolved, not raw: outside production this hands back UPLOAD_REPLACEMENT_FILE when
// one is configured, so a staging test does not mail the real document.
$this->uploadHandler->getResolvedUploadFilepath($handout),
$handout->getDisplayName() ?: $handout->getOriginalFilename(),
$handout->getMimeType(),
)];
}
private function send(ApplicationFormSubmission $submission, EmailTextKey $key): bool
{
$email = $submission->getEmail();
if (null === $email) {
$this->logger->warning('Application form mail skipped, no email address', [
'uuid' => $submission->getUuid(),
'mail' => $key->value,
]);
return false;
}
$this->mailer->createAndSendText($key, [
'firstName' => $submission->getFirstName(),
'lastName' => $submission->getLastName(),
'formLabel' => $submission->getFormLabel(),
// The season the applicant applied for, not the one that happens to be current when a
// reviewer re-sends months later - hence the submission's own date.
'season' => $this->seasonCalendar->shortLabel(
$this->seasonCalendar->currentOrUpcomingSeasonYear($submission->getSubmittedAt())
),
], [
'to' => $email,
// Without the layout's "Zum Portal" button - the recipient has no account here.
'template' => 'email/generic_no_button.html.twig',
'attachments' => $this->attachmentsFor($key),
]);
$sentAt = new \DateTimeImmutable();
match ($key) {
EmailTextKey::APPLICATION_FORM_RECEIVED => $submission->setFirstMailSentAt($sentAt),
EmailTextKey::APPLICATION_FORM_FOLLOW_UP => $submission->setSecondMailSentAt($sentAt),
default => throw new \LogicException(sprintf('%s is not one of the form submission mails.', $key->value)),
};
$this->entityManager->flush();
$this->logger->info('Application form mail sent', [
'uuid' => $submission->getUuid(),
'mail' => $key->value,
'sentAt' => $sentAt->format(\DateTimeInterface::ATOM),
]);
return true;
}
}
+34 -1
View File
@@ -29,10 +29,24 @@ class EmailTextRenderer
*/
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 = '',
) {
}
@@ -86,10 +100,29 @@ class EmailTextRenderer
return new RenderedEmailTextDto(
strtr($subject ?? '', $rawTokens),
$this->renderHeadline($headline ?? '', $htmlTokens),
$this->mailBodyRenderer->render($body ?? '', $rawTokens),
$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