feat: send mailings over MailJet smtp in dedicated worker process

This commit is contained in:
Björn Fromme
2026-08-12 15:47:04 +02:00
parent a5c98025d0
commit e0a9562fc6
14 changed files with 480 additions and 44 deletions
@@ -5,6 +5,7 @@ namespace App\Controller\Admin\Teamer;
use App\Controller\Traits\ReturnUrlTrait;
use App\Form\TeamerMailingType;
use App\Htmx\HxRedirectResponse;
use App\Message\SendTeamerMailing;
use App\Service\Common\TeamerFilterHandler;
use App\Service\Teamer\TeamerMailingDraftHandler;
use App\Service\Teamer\TeamerMailingService;
@@ -12,6 +13,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -23,6 +25,7 @@ class MailingController extends AbstractController
private readonly TeamerFilterHandler $filterHandler,
private readonly TeamerMailingService $mailingService,
private readonly TeamerMailingDraftHandler $draftHandler,
private readonly MessageBusInterface $messageBus,
) {
}
@@ -114,12 +117,25 @@ class MailingController extends AbstractController
}
$recipients = $this->mailingService->resolveRecipients($this->filterHandler->getFilterSettings());
$count = $this->mailingService->send($form->getData(), $recipients);
$mailingDto = $form->getData();
// it went out, the next mailing starts on a blank page
// the recipients travel with the message, so the mailing reaches exactly the
// people the confirmation modal counted even though it is sent from a worker
$this->messageBus->dispatch(SendTeamerMailing::fromRecipients(
(string) $mailingDto->getSubject(),
(string) $mailingDto->getMessage(),
$recipients
));
// it is on its way, the next mailing starts on a blank page
$this->draftHandler->resetDraft();
$this->addFlash('success', sprintf('Die Mail wurde an %d Teamer:innen gesendet', $count));
// deliberately not "wurde gesendet": at this point nothing has been handed to a
// mail server yet, and saying otherwise would make a failed mailing look fine
$this->addFlash('success', sprintf(
'Die Mail wird an %d Teamer:innen gesendet',
$recipients->getEligibleCount()
));
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
}
+18
View File
@@ -44,6 +44,20 @@ class Mailer
->context($context)
;
// Both headers are read and removed further down the stack: X-Transport by
// Mailer\Transport\Transports when the mail is handed to a transport, and
// X-Bus-Transport by Mailer\EventListener\MessengerTransportListener while the
// mail is queued. Without them a mail takes the first mailer transport and the
// routing configured for SendEmailMessage, which is what every caller but the
// teamer mailing wants.
if (null !== $config['transport']) {
$email->getHeaders()->addTextHeader('X-Transport', $config['transport']);
}
if (null !== $config['bus_transport']) {
$email->getHeaders()->addTextHeader('X-Bus-Transport', $config['bus_transport']);
}
foreach ($config['attachments'] as $attachment) {
/* @var EmailAttachmentInterface $attachment */
$attachment->attachTo($email);
@@ -83,6 +97,8 @@ class Mailer
'to' => $this->defaults['to'],
'subject_parameters' => [],
'attachments' => [],
'transport' => null,
'bus_transport' => null,
])
->setRequired([
'template',
@@ -93,6 +109,8 @@ class Mailer
->setAllowedTypes('subject', 'string')
->setAllowedTypes('subject_parameters', 'array')
->setAllowedTypes('attachments', 'array')
->setAllowedTypes('transport', ['null', 'string'])
->setAllowedTypes('bus_transport', ['null', 'string'])
;
return $resolver->resolve($options);
@@ -0,0 +1,37 @@
<?php
namespace App\Email;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Mailer\Event\MessageEvent;
use Symfony\Component\Mime\Message;
/**
* Keeps the X-Bus-Transport header out of the mail that is actually delivered.
*
* Symfony's own listener removes the header while queueing, but it is handed a clone of
* the message and the original is what goes onto the bus (see Mailer::send(), which says
* so in as many words). The routing works either way - the stamp is read off that clone -
* but without this the header travels all the way to the recipient, and with the Mailjet
* API transport it is forwarded as a custom header on top, telling everyone which queue
* we sort our mail into.
*
* Only the delivering pass is of interest here: while the mail is queued the header still
* has to be there for Symfony to route on.
*/
#[AsEventListener(event: MessageEvent::class)]
class RemoveBusTransportHeaderListener
{
public function __invoke(MessageEvent $event): void
{
if (true === $event->isQueued()) {
return;
}
$message = $event->getMessage();
if ($message instanceof Message) {
$message->getHeaders()->remove('X-Bus-Transport');
}
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Message;
use App\Model\TeamerMailingRecipientsDto;
/**
* One composed mailing, on its way to the recipients the admin confirmed.
*
* The recipients travel as a snapshot of plain scalars rather than as a filter to run
* later: re-running the filter in the worker could reach a different set of people than
* the one the confirmation modal showed, and the filter itself carries hydrated entities
* that have no business being serialized into a queue row. Plain arrays also survive a
* deploy that changes TeamerMailingRecipient while messages are still queued.
*/
class SendTeamerMailing
{
/**
* @param array<int, array{email: string, firstName: string, lastName: string, fullName: string}> $recipients
*/
public function __construct(
private readonly string $subject,
private readonly string $message,
private readonly array $recipients,
) {
}
public static function fromRecipients(string $subject, string $message, TeamerMailingRecipientsDto $recipients): self
{
$snapshot = [];
foreach ($recipients->getEligible() as $recipient) {
// resolveRecipients() already guaranteed the address, this only narrows the type
if (null === $email = $recipient->getEmail()) {
continue;
}
$snapshot[] = [
'email' => $email,
'firstName' => $recipient->getFirstName(),
'lastName' => $recipient->getLastName(),
'fullName' => $recipient->getFullName(),
];
}
return new self($subject, $message, $snapshot);
}
public function getSubject(): string
{
return $this->subject;
}
public function getMessage(): string
{
return $this->message;
}
/**
* @return array<int, array{email: string, firstName: string, lastName: string, fullName: string}>
*/
public function getRecipients(): array
{
return $this->recipients;
}
public function getRecipientCount(): int
{
return count($this->recipients);
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\MessageHandler;
use App\Message\SendTeamerMailing;
use App\Service\Teamer\TeamerMailingService;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Fans a confirmed mailing out into one queued mail per recipient.
*
* The request that confirmed the mailing only enqueues this single message, so it answers
* in constant time no matter how many teamers the filter matched, and a mailing is either
* enqueued whole or not at all. Every mail this produces is queued separately, so a
* recipient whose address bounces is retried on its own instead of dragging the rest of
* the mailing through a retry with it.
*/
#[AsMessageHandler]
class SendTeamerMailingHandler
{
public function __construct(
private readonly TeamerMailingService $mailingService,
) {
}
public function __invoke(SendTeamerMailing $mailing): void
{
$this->mailingService->sendMailing($mailing);
}
}
+45 -19
View File
@@ -4,6 +4,7 @@ namespace App\Service\Teamer;
use App\Email\Mailer;
use App\Entity\User;
use App\Message\SendTeamerMailing;
use App\Model\TeamerFilterDto;
use App\Model\TeamerMailingDto;
use App\Model\TeamerMailingRecipient;
@@ -26,10 +27,20 @@ class TeamerMailingService
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,
) {
}
@@ -68,32 +79,35 @@ class TeamerMailingService
}
/**
* Send the mailing to everyone eligible and return how many mails went out.
* 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 send(TeamerMailingDto $mailingDto, TeamerMailingRecipientsDto $recipients): int
public function sendMailing(SendTeamerMailing $mailing): void
{
foreach ($recipients->getEligible() as $recipient) {
// resolveRecipients() already guaranteed the address, this only narrows the type
if (null === $email = $recipient->getEmail()) {
continue;
}
$values = $this->placeholderValuesFor($recipient);
foreach ($mailing->getRecipients() as $recipient) {
$values = $this->buildValues(
$recipient['firstName'],
$recipient['lastName'],
$recipient['fullName']
);
$this->sendTo(
$email,
$this->render($mailingDto->getSubject(), $values),
$this->render($mailingDto->getMessage(), $values)
$recipient['email'],
$this->render($mailing->getSubject(), $values),
$this->render($mailing->getMessage(), $values),
self::TRANSPORT,
$this->mailingBusTransport
);
}
$this->logger->info('Send teamer mailing', [
'subject' => $mailingDto->getSubject(),
'recipients' => $recipients->getEligibleCount(),
'skipped' => $recipients->getSkippedCount(),
'subject' => $mailing->getSubject(),
'recipients' => $mailing->getRecipientCount(),
]);
return $recipients->getEligibleCount();
}
/**
@@ -173,14 +187,26 @@ class TeamerMailingService
];
}
private function sendTo(string $email, string $subject, string $message): void
{
/**
* 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,
]);
}
}