'Vorname', self::PLACEHOLDER_LAST_NAME => 'Nachname', self::PLACEHOLDER_FULL_NAME => 'Vor- und Nachname', ]; private const TEMPLATE = 'email/teamer_mailing.html.twig'; private const PREVIEW_SUBJECT_PREFIX = '[Vorschau] '; /** * The mailer transport a mailing goes out over: Mailjet, rather than the webhoster's * relay that carries every other mail, which would risk the hosting account over a few * hundred recipients. Configured in mailer.yaml. */ private const TRANSPORT = 'mailing'; public function __construct( private readonly TeamerRepository $teamerRepository, private readonly Mailer $mailer, private readonly LoggerInterface $logger, // messenger transport, "mailing" in production and "sync" in dev and test, // see the parameter of the same name in services.yaml private readonly string $mailingBusTransport, ) { } /** * Split everyone the filter matches into those who will be written to and those who * are skipped. Deleted and disabled teamers only show up here at all when the admin * asked for them in the filter. */ public function resolveRecipients(TeamerFilterDto $filterDto): TeamerMailingRecipientsDto { $recipients = new TeamerMailingRecipientsDto(); foreach ($this->teamerRepository->getMailingRecipients($filterDto) as $recipient) { if (true === $recipient->isDeleted()) { $recipients->addSkippedDeleted(); continue; } if (true === $recipient->isDisabled()) { $recipients->addSkippedDisabled(); continue; } if (null === $recipient->getEmail()) { $recipients->addSkippedNoEmail(); continue; } $recipients->addEligible($recipient); } return $recipients; } /** * Hand every recipient of a confirmed mailing to the mailer. * * This runs in the worker, not in the request that confirmed the mailing, so it * reports nothing back: each mail becomes a queued message of its own and whether it * reaches anyone is decided long after this method returns. A caller that wants to * know how many mails were actually delivered has to ask the mail provider. */ public function sendMailing(SendTeamerMailing $mailing): void { foreach ($mailing->getRecipients() as $recipient) { $values = $this->buildValues( $recipient['firstName'], $recipient['lastName'], $recipient['fullName'] ); $this->sendTo( $recipient['email'], $this->render($mailing->getSubject(), $values), $this->render($mailing->getMessage(), $values), self::TRANSPORT, $this->mailingBusTransport ); } $this->logger->info('Send teamer mailing', [ 'subject' => $mailing->getSubject(), 'recipients' => $mailing->getRecipientCount(), ]); } /** * Send the very same mail the recipients would get to the composing admin, with their * own name filled in for the placeholders. */ public function sendPreview(TeamerMailingDto $mailingDto, User $admin): void { $values = $this->placeholderValuesForUser($admin); $this->sendTo( $admin->getUserIdentifier(), self::PREVIEW_SUBJECT_PREFIX.$this->render($mailingDto->getSubject(), $values), $this->render($mailingDto->getMessage(), $values) ); $this->logger->info('Send teamer mailing preview', [ 'to' => $admin->getEmail(), 'subject' => $mailingDto->getSubject(), ]); } /** * Replace the known placeholders. Anything that merely looks like a placeholder is * left alone - a typo should not silently blank out part of the mail. */ public function render(?string $text, array $values): string { return strtr((string) $text, $values); } public function placeholderValuesFor(TeamerMailingRecipient $recipient): array { return $this->buildValues( $recipient->getFirstName(), $recipient->getLastName(), $recipient->getFullName() ); } /** * The preview uses the admin's own teamer record when they have one, so it is exactly * what a recipient would see. Admins without one fall back to their user account, and * to the local part of their address when even that has no name on it - an empty * "Hallo ," in the preview would only be confusing, it says nothing about the real mail. */ public function placeholderValuesForUser(User $user): array { if (null !== $teamer = $user->getTeamer()) { return $this->buildValues( (string) $teamer->getFirstName(), (string) $teamer->getLastName(), $teamer->getFullName() ); } $firstName = (string) $user->getFirstName(); $lastName = (string) $user->getLastName(); $fullName = trim($user->getFullName()); if ('' === $firstName && '' === $lastName) { $fallback = strstr((string) $user->getEmail(), '@', true) ?: (string) $user->getEmail(); $firstName = $fallback; $fullName = $fallback; } return $this->buildValues($firstName, $lastName, $fullName); } private function buildValues(string $firstName, string $lastName, string $fullName): array { return [ self::PLACEHOLDER_FIRST_NAME => $firstName, self::PLACEHOLDER_LAST_NAME => $lastName, self::PLACEHOLDER_FULL_NAME => $fullName, ]; } /** * The preview leaves the transports unset on purpose: it goes out over the same relay * and the same queue as the rest of the application, so it arrives while the admin is * still looking at the compose page. */ private function sendTo( string $email, string $subject, string $message, ?string $transport = null, ?string $busTransport = null, ): void { $this->mailer->createAndSendEmail([ 'message' => $message, ], [ 'to' => $email, 'subject' => $subject, 'template' => self::TEMPLATE, 'transport' => $transport, 'bus_transport' => $busTransport, ]); } }