feat: answer received form submissions by mail
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user