feat: send mailings over MailJet smtp in dedicated worker process
This commit is contained in:
@@ -38,6 +38,9 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
|||||||
|
|
||||||
###> symfony/mailer ###
|
###> symfony/mailer ###
|
||||||
MAILER_DSN=null://null
|
MAILER_DSN=null://null
|
||||||
|
# Has to resolve in every environment or the container does not compile, see the
|
||||||
|
# mailjet section below for the production value
|
||||||
|
MAILING_MAILER_DSN=null://null
|
||||||
###< symfony/mailer ###
|
###< symfony/mailer ###
|
||||||
|
|
||||||
APP_BASE_URI=https://myep-team.ddev.site
|
APP_BASE_URI=https://myep-team.ddev.site
|
||||||
@@ -68,8 +71,18 @@ MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS=https://my.ep-reisen.de/api/userinfo
|
|||||||
MYEP_OAUTH2_SCOPES=email,id,roles,profile
|
MYEP_OAUTH2_SCOPES=email,id,roles,profile
|
||||||
|
|
||||||
###> symfony/mailjet-mailer ###
|
###> symfony/mailjet-mailer ###
|
||||||
# MAILER_DSN=mailjet+api://PUBLIC_KEY:[email protected]
|
# Bulk teamer mailings only, everything else goes out over MAILER_DSN. Set the real
|
||||||
# #MAILER_DSN=mailjet+smtp://PUBLIC_KEY:[email protected]
|
# credentials in .env.local - the sending domain has to be verified in Mailjet (SPF,
|
||||||
|
# DKIM, DMARC) or the mail is delivered but lands in spam.
|
||||||
|
#
|
||||||
|
# Use smtp. The api transport does not send the message we built: it takes the parts as
|
||||||
|
# json fields and has Mailjet assemble the mail, which drops the embedded logo into an
|
||||||
|
# ordinary attachment. Over smtp our own MIME goes out unchanged and the mail arrives as
|
||||||
|
# it was rendered. Deliverability is identical either way, it is the same account, the
|
||||||
|
# same ip pool and the same dkim signature. The api is only worth revisiting if the host
|
||||||
|
# ever blocks outbound port 587, since it talks https instead.
|
||||||
|
# MAILING_MAILER_DSN=mailjet+smtp://PUBLIC_KEY:[email protected]
|
||||||
|
# MAILING_MAILER_DSN=mailjet+api://PUBLIC_KEY:PRIVATE_KEY@default
|
||||||
###< symfony/mailjet-mailer ###
|
###< symfony/mailjet-mailer ###
|
||||||
|
|
||||||
BIN_GS=/usr/bin/gs
|
BIN_GS=/usr/bin/gs
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
framework:
|
framework:
|
||||||
mailer:
|
mailer:
|
||||||
dsn: '%env(MAILER_DSN)%'
|
# "main" has to stay first: it is the transport every mail without an explicit
|
||||||
|
# X-Transport header goes out over, which is all of them apart from the teamer
|
||||||
|
# mailing. Reordering this quietly moves the whole application to Mailjet.
|
||||||
|
transports:
|
||||||
|
main: '%env(MAILER_DSN)%'
|
||||||
|
mailing: '%env(MAILING_MAILER_DSN)%'
|
||||||
|
|||||||
@@ -12,6 +12,29 @@ framework:
|
|||||||
retry_strategy:
|
retry_strategy:
|
||||||
max_retries: 3
|
max_retries: 3
|
||||||
multiplier: 2
|
multiplier: 2
|
||||||
|
# Bulk teamer mailings, kept off "async" so that a mailing of several hundred
|
||||||
|
# recipients cannot delay a password reset. Its own cron worker consumes this
|
||||||
|
# queue and nothing else; the two never see each other's messages, because the
|
||||||
|
# doctrine transport filters on the queue_name column. See docs/operations.md
|
||||||
|
# for both cron entries.
|
||||||
|
#
|
||||||
|
# The retry delays are spelled out because the default is one second: with a
|
||||||
|
# multiplier alone, all three retries of a mail are spent within seconds, so a
|
||||||
|
# short hiccup at the mail provider would drop a whole mailing into "failed" at
|
||||||
|
# once - the very thing sending one message per recipient is meant to avoid.
|
||||||
|
# A minute, three and nine outlive any realistic blip and span several runs of
|
||||||
|
# a worker that only lives for a few minutes at a time.
|
||||||
|
mailing:
|
||||||
|
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||||
|
options:
|
||||||
|
queue_name: mailing
|
||||||
|
use_notify: true
|
||||||
|
check_delayed_interval: 60000
|
||||||
|
retry_strategy:
|
||||||
|
max_retries: 3
|
||||||
|
delay: 60000
|
||||||
|
multiplier: 3
|
||||||
|
max_delay: 900000
|
||||||
failed: 'doctrine://default?queue_name=failed'
|
failed: 'doctrine://default?queue_name=failed'
|
||||||
sync: 'sync://'
|
sync: 'sync://'
|
||||||
|
|
||||||
@@ -20,6 +43,9 @@ framework:
|
|||||||
Symfony\Component\Notifier\Message\ChatMessage: async
|
Symfony\Component\Notifier\Message\ChatMessage: async
|
||||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||||
|
|
||||||
|
# only the fan-out, the mails it produces carry an X-Bus-Transport header
|
||||||
|
App\Message\SendTeamerMailing: mailing
|
||||||
|
|
||||||
# Route your messages to the transports
|
# Route your messages to the transports
|
||||||
# 'App\Message\YourMessage': async
|
# 'App\Message\YourMessage': async
|
||||||
|
|
||||||
@@ -30,3 +56,13 @@ when@dev:
|
|||||||
Symfony\Component\Mailer\Messenger\SendEmailMessage: sync
|
Symfony\Component\Mailer\Messenger\SendEmailMessage: sync
|
||||||
Symfony\Component\Notifier\Message\ChatMessage: sync
|
Symfony\Component\Notifier\Message\ChatMessage: sync
|
||||||
Symfony\Component\Notifier\Message\SmsMessage: sync
|
Symfony\Component\Notifier\Message\SmsMessage: sync
|
||||||
|
App\Message\SendTeamerMailing: sync
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
framework:
|
||||||
|
messenger:
|
||||||
|
routing:
|
||||||
|
Symfony\Component\Mailer\Messenger\SendEmailMessage: sync
|
||||||
|
Symfony\Component\Notifier\Message\ChatMessage: sync
|
||||||
|
Symfony\Component\Notifier\Message\SmsMessage: sync
|
||||||
|
App\Message\SendTeamerMailing: sync
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ zenstruck_schedule:
|
|||||||
|
|
||||||
mailer:
|
mailer:
|
||||||
service: mailer
|
service: mailer
|
||||||
default_to: [email protected]
|
default_to: [email protected]
|
||||||
default_from: info@ep-reisen.de
|
default_from: team@ep-reisen.de
|
||||||
subject_prefix: "[MyE&P-Team]"
|
subject_prefix: "[MyE&P-Team]"
|
||||||
|
|
||||||
schedule_extensions:
|
schedule_extensions:
|
||||||
email_on_failure:
|
email_on_failure:
|
||||||
to: [email protected]
|
to: [email protected]
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
- task: app:bpn-import
|
- task: app:bpn-import
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ parameters:
|
|||||||
|
|
||||||
teamer_inactive_period: '-2 years'
|
teamer_inactive_period: '-2 years'
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# back to "sync" further down, or a mailing would need a running worker to arrive
|
||||||
|
# in dev and test while every other mail is sent right away.
|
||||||
|
mailing_bus_transport: 'mailing'
|
||||||
|
|
||||||
# Dates (MM-DD) on which teamers must re-confirm their personal data.
|
# Dates (MM-DD) on which teamers must re-confirm their personal data.
|
||||||
personal_data_check_deadlines:
|
personal_data_check_deadlines:
|
||||||
- '04-01'
|
- '04-01'
|
||||||
@@ -51,6 +58,7 @@ services:
|
|||||||
$xmlExport: '@xml_export.storage'
|
$xmlExport: '@xml_export.storage'
|
||||||
$xmlDump: '@xml_dump.storage'
|
$xmlDump: '@xml_dump.storage'
|
||||||
$teamerInactivePeriod: '%teamer_inactive_period%'
|
$teamerInactivePeriod: '%teamer_inactive_period%'
|
||||||
|
$mailingBusTransport: '%mailing_bus_transport%'
|
||||||
|
|
||||||
App\:
|
App\:
|
||||||
resource: '../src/'
|
resource: '../src/'
|
||||||
@@ -243,3 +251,15 @@ services:
|
|||||||
myep_oauth2_url_access_token: '%env(MYEP_OAUTH2_URL_ACCESS_TOKEN)%'
|
myep_oauth2_url_access_token: '%env(MYEP_OAUTH2_URL_ACCESS_TOKEN)%'
|
||||||
myep_oauth2_url_resource_owner_details: '%env(MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS)%'
|
myep_oauth2_url_resource_owner_details: '%env(MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS)%'
|
||||||
myep_oauth2_scopes: '%env(csv:MYEP_OAUTH2_SCOPES)%'
|
myep_oauth2_scopes: '%env(csv:MYEP_OAUTH2_SCOPES)%'
|
||||||
|
|
||||||
|
# The mails of a mailing are sent right away here, like every other mail, instead of
|
||||||
|
# waiting for a worker on the dedicated queue. The mailer transport is deliberately not
|
||||||
|
# switched: a mailing still goes out over MAILING_MAILER_DSN, so the path being exercised
|
||||||
|
# locally is the one that runs in production.
|
||||||
|
when@dev:
|
||||||
|
parameters:
|
||||||
|
mailing_bus_transport: 'sync'
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
parameters:
|
||||||
|
mailing_bus_transport: 'sync'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Controller\Admin\Teamer;
|
|||||||
use App\Controller\Traits\ReturnUrlTrait;
|
use App\Controller\Traits\ReturnUrlTrait;
|
||||||
use App\Form\TeamerMailingType;
|
use App\Form\TeamerMailingType;
|
||||||
use App\Htmx\HxRedirectResponse;
|
use App\Htmx\HxRedirectResponse;
|
||||||
|
use App\Message\SendTeamerMailing;
|
||||||
use App\Service\Common\TeamerFilterHandler;
|
use App\Service\Common\TeamerFilterHandler;
|
||||||
use App\Service\Teamer\TeamerMailingDraftHandler;
|
use App\Service\Teamer\TeamerMailingDraftHandler;
|
||||||
use App\Service\Teamer\TeamerMailingService;
|
use App\Service\Teamer\TeamerMailingService;
|
||||||
@@ -12,6 +13,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|||||||
use Symfony\Component\Form\FormInterface;
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ class MailingController extends AbstractController
|
|||||||
private readonly TeamerFilterHandler $filterHandler,
|
private readonly TeamerFilterHandler $filterHandler,
|
||||||
private readonly TeamerMailingService $mailingService,
|
private readonly TeamerMailingService $mailingService,
|
||||||
private readonly TeamerMailingDraftHandler $draftHandler,
|
private readonly TeamerMailingDraftHandler $draftHandler,
|
||||||
|
private readonly MessageBusInterface $messageBus,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,12 +117,25 @@ class MailingController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$recipients = $this->mailingService->resolveRecipients($this->filterHandler->getFilterSettings());
|
$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->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'));
|
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ class Mailer
|
|||||||
->context($context)
|
->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) {
|
foreach ($config['attachments'] as $attachment) {
|
||||||
/* @var EmailAttachmentInterface $attachment */
|
/* @var EmailAttachmentInterface $attachment */
|
||||||
$attachment->attachTo($email);
|
$attachment->attachTo($email);
|
||||||
@@ -83,6 +97,8 @@ class Mailer
|
|||||||
'to' => $this->defaults['to'],
|
'to' => $this->defaults['to'],
|
||||||
'subject_parameters' => [],
|
'subject_parameters' => [],
|
||||||
'attachments' => [],
|
'attachments' => [],
|
||||||
|
'transport' => null,
|
||||||
|
'bus_transport' => null,
|
||||||
])
|
])
|
||||||
->setRequired([
|
->setRequired([
|
||||||
'template',
|
'template',
|
||||||
@@ -93,6 +109,8 @@ class Mailer
|
|||||||
->setAllowedTypes('subject', 'string')
|
->setAllowedTypes('subject', 'string')
|
||||||
->setAllowedTypes('subject_parameters', 'array')
|
->setAllowedTypes('subject_parameters', 'array')
|
||||||
->setAllowedTypes('attachments', 'array')
|
->setAllowedTypes('attachments', 'array')
|
||||||
|
->setAllowedTypes('transport', ['null', 'string'])
|
||||||
|
->setAllowedTypes('bus_transport', ['null', 'string'])
|
||||||
;
|
;
|
||||||
|
|
||||||
return $resolver->resolve($options);
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Service\Teamer;
|
|||||||
|
|
||||||
use App\Email\Mailer;
|
use App\Email\Mailer;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Message\SendTeamerMailing;
|
||||||
use App\Model\TeamerFilterDto;
|
use App\Model\TeamerFilterDto;
|
||||||
use App\Model\TeamerMailingDto;
|
use App\Model\TeamerMailingDto;
|
||||||
use App\Model\TeamerMailingRecipient;
|
use App\Model\TeamerMailingRecipient;
|
||||||
@@ -26,10 +27,20 @@ class TeamerMailingService
|
|||||||
private const TEMPLATE = 'email/teamer_mailing.html.twig';
|
private const TEMPLATE = 'email/teamer_mailing.html.twig';
|
||||||
private const PREVIEW_SUBJECT_PREFIX = '[Vorschau] ';
|
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(
|
public function __construct(
|
||||||
private readonly TeamerRepository $teamerRepository,
|
private readonly TeamerRepository $teamerRepository,
|
||||||
private readonly Mailer $mailer,
|
private readonly Mailer $mailer,
|
||||||
private readonly LoggerInterface $logger,
|
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) {
|
foreach ($mailing->getRecipients() as $recipient) {
|
||||||
// resolveRecipients() already guaranteed the address, this only narrows the type
|
$values = $this->buildValues(
|
||||||
if (null === $email = $recipient->getEmail()) {
|
$recipient['firstName'],
|
||||||
continue;
|
$recipient['lastName'],
|
||||||
}
|
$recipient['fullName']
|
||||||
|
);
|
||||||
$values = $this->placeholderValuesFor($recipient);
|
|
||||||
|
|
||||||
$this->sendTo(
|
$this->sendTo(
|
||||||
$email,
|
$recipient['email'],
|
||||||
$this->render($mailingDto->getSubject(), $values),
|
$this->render($mailing->getSubject(), $values),
|
||||||
$this->render($mailingDto->getMessage(), $values)
|
$this->render($mailing->getMessage(), $values),
|
||||||
|
self::TRANSPORT,
|
||||||
|
$this->mailingBusTransport
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logger->info('Send teamer mailing', [
|
$this->logger->info('Send teamer mailing', [
|
||||||
'subject' => $mailingDto->getSubject(),
|
'subject' => $mailing->getSubject(),
|
||||||
'recipients' => $recipients->getEligibleCount(),
|
'recipients' => $mailing->getRecipientCount(),
|
||||||
'skipped' => $recipients->getSkippedCount(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
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([
|
$this->mailer->createAndSendEmail([
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
], [
|
], [
|
||||||
'to' => $email,
|
'to' => $email,
|
||||||
'subject' => $subject,
|
'subject' => $subject,
|
||||||
'template' => self::TEMPLATE,
|
'template' => self::TEMPLATE,
|
||||||
|
'transport' => $transport,
|
||||||
|
'bus_transport' => $busTransport,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Email;
|
||||||
|
|
||||||
|
use App\Email\RemoveBusTransportHeaderListener;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Mailer\Envelope;
|
||||||
|
use Symfony\Component\Mailer\Event\MessageEvent;
|
||||||
|
use Symfony\Component\Mime\Address;
|
||||||
|
use Symfony\Component\Mime\Email;
|
||||||
|
|
||||||
|
class RemoveBusTransportHeaderListenerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testTheHeaderIsGoneFromTheDeliveredMail(): void
|
||||||
|
{
|
||||||
|
$email = $this->createEmail();
|
||||||
|
|
||||||
|
(new RemoveBusTransportHeaderListener())($this->createEvent($email, queued: false));
|
||||||
|
|
||||||
|
$this->assertFalse($email->getHeaders()->has('X-Bus-Transport'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symfony reads the header off the queued message to pick the messenger transport, so
|
||||||
|
* removing it here would send the mailing over the shared queue after all.
|
||||||
|
*/
|
||||||
|
public function testTheHeaderSurvivesQueueing(): void
|
||||||
|
{
|
||||||
|
$email = $this->createEmail();
|
||||||
|
|
||||||
|
(new RemoveBusTransportHeaderListener())($this->createEvent($email, queued: true));
|
||||||
|
|
||||||
|
$this->assertTrue($email->getHeaders()->has('X-Bus-Transport'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createEmail(): Email
|
||||||
|
{
|
||||||
|
$email = (new Email())
|
||||||
|
->from('[email protected]')
|
||||||
|
->to('[email protected]')
|
||||||
|
->subject('Betreff')
|
||||||
|
->text('Nachricht')
|
||||||
|
;
|
||||||
|
|
||||||
|
$email->getHeaders()->addTextHeader('X-Bus-Transport', 'mailing');
|
||||||
|
|
||||||
|
return $email;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createEvent(Email $email, bool $queued): MessageEvent
|
||||||
|
{
|
||||||
|
return new MessageEvent(
|
||||||
|
$email,
|
||||||
|
new Envelope(new Address('[email protected]'), [new Address('[email protected]')]),
|
||||||
|
'mailing',
|
||||||
|
$queued
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\MessageHandler;
|
||||||
|
|
||||||
|
use App\Message\SendTeamerMailing;
|
||||||
|
use App\MessageHandler\SendTeamerMailingHandler;
|
||||||
|
use App\Service\Teamer\TeamerMailingService;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class SendTeamerMailingHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testTheMailingIsHandedToTheServiceUnchanged(): void
|
||||||
|
{
|
||||||
|
$mailing = new SendTeamerMailing('Betreff', 'Nachricht', [
|
||||||
|
['email' => '[email protected]', 'firstName' => 'Anna', 'lastName' => 'Berg', 'fullName' => 'Anna Berg'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = $this->createMock(TeamerMailingService::class);
|
||||||
|
$service
|
||||||
|
->expects($this->once())
|
||||||
|
->method('sendMailing')
|
||||||
|
->with($this->identicalTo($mailing))
|
||||||
|
;
|
||||||
|
|
||||||
|
(new SendTeamerMailingHandler($service))($mailing);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,11 @@ namespace App\Tests\Service\Teamer;
|
|||||||
use App\Email\Mailer;
|
use App\Email\Mailer;
|
||||||
use App\Entity\Teamer;
|
use App\Entity\Teamer;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Message\SendTeamerMailing;
|
||||||
use App\Model\TeamerFilterDto;
|
use App\Model\TeamerFilterDto;
|
||||||
use App\Model\TeamerMailingDto;
|
use App\Model\TeamerMailingDto;
|
||||||
use App\Model\TeamerMailingRecipient;
|
use App\Model\TeamerMailingRecipient;
|
||||||
|
use App\Model\TeamerMailingRecipientsDto;
|
||||||
use App\Repository\TeamerRepository;
|
use App\Repository\TeamerRepository;
|
||||||
use App\Service\Teamer\TeamerMailingService;
|
use App\Service\Teamer\TeamerMailingService;
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
@@ -32,6 +34,7 @@ class TeamerMailingServiceTest extends TestCase
|
|||||||
$this->teamerRepository,
|
$this->teamerRepository,
|
||||||
$this->mailer,
|
$this->mailer,
|
||||||
$this->createMock(LoggerInterface::class),
|
$this->createMock(LoggerInterface::class),
|
||||||
|
'mailing',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,16 +104,8 @@ class TeamerMailingServiceTest extends TestCase
|
|||||||
$this->assertSame(5, $recipients->getTotal());
|
$this->assertSame(5, $recipients->getTotal());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSendPersonalisesSubjectAndMessagePerRecipient(): void
|
public function testSendMailingPersonalisesSubjectAndMessagePerRecipient(): void
|
||||||
{
|
{
|
||||||
$this->teamerRepository
|
|
||||||
->method('getMailingRecipients')
|
|
||||||
->willReturn([
|
|
||||||
$this->createRecipient('Anna', 'Berg', '[email protected]'),
|
|
||||||
$this->createRecipient('Bea', 'Ohm', '[email protected]'),
|
|
||||||
])
|
|
||||||
;
|
|
||||||
|
|
||||||
$sent = [];
|
$sent = [];
|
||||||
$this->mailer
|
$this->mailer
|
||||||
->expects($this->exactly(2))
|
->expects($this->exactly(2))
|
||||||
@@ -120,19 +115,87 @@ class TeamerMailingServiceTest extends TestCase
|
|||||||
})
|
})
|
||||||
;
|
;
|
||||||
|
|
||||||
|
$this->service->sendMailing($this->createMailing(
|
||||||
|
'Hallo {{vorname}}',
|
||||||
|
'Servus {{name}}',
|
||||||
|
$this->createRecipient('Anna', 'Berg', '[email protected]'),
|
||||||
|
$this->createRecipient('Bea', 'Ohm', '[email protected]')
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->assertSame([
|
||||||
|
['[email protected]', 'Hallo Anna', 'Servus Anna Berg'],
|
||||||
|
['[email protected]', 'Hallo Bea', 'Servus Bea Ohm'],
|
||||||
|
], $sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Without both transports a mailing silently leaves over the webhoster's relay on the
|
||||||
|
* shared queue - it still arrives, so nothing about it looks broken, which is exactly
|
||||||
|
* why it is pinned here.
|
||||||
|
*/
|
||||||
|
public function testSendMailingGoesOutOverTheMailingTransports(): void
|
||||||
|
{
|
||||||
|
$this->mailer
|
||||||
|
->expects($this->once())
|
||||||
|
->method('createAndSendEmail')
|
||||||
|
->willReturnCallback(function (array $context, array $options): void {
|
||||||
|
$this->assertSame('mailing', $options['transport']);
|
||||||
|
$this->assertSame('mailing', $options['bus_transport']);
|
||||||
|
})
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->service->sendMailing($this->createMailing(
|
||||||
|
'Betreff',
|
||||||
|
'Nachricht',
|
||||||
|
$this->createRecipient('Anna', 'Berg', '[email protected]')
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSendMailingWithoutRecipientsSendsNothing(): void
|
||||||
|
{
|
||||||
|
$this->mailer
|
||||||
|
->expects($this->never())
|
||||||
|
->method('createAndSendEmail')
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->service->sendMailing($this->createMailing('Betreff', 'Nachricht'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A preview has to stay on the default relay and the default queue, or composing a
|
||||||
|
* mailing would wait on the same worker the mailing itself is queued behind.
|
||||||
|
*/
|
||||||
|
public function testPreviewLeavesTheTransportsAlone(): void
|
||||||
|
{
|
||||||
|
$this->mailer
|
||||||
|
->expects($this->once())
|
||||||
|
->method('createAndSendEmail')
|
||||||
|
->willReturnCallback(function (array $context, array $options): void {
|
||||||
|
$this->assertNull($options['transport']);
|
||||||
|
$this->assertNull($options['bus_transport']);
|
||||||
|
})
|
||||||
|
;
|
||||||
|
|
||||||
$mailingDto = (new TeamerMailingDto())
|
$mailingDto = (new TeamerMailingDto())
|
||||||
->setSubject('Hallo {{vorname}}')
|
->setSubject('Hallo {{vorname}}')
|
||||||
->setMessage('Servus {{name}}')
|
->setMessage('Servus {{name}}')
|
||||||
;
|
;
|
||||||
|
|
||||||
$recipients = $this->service->resolveRecipients(new TeamerFilterDto());
|
$this->service->sendPreview($mailingDto, (new User())->setEmail('[email protected]'));
|
||||||
$count = $this->service->send($mailingDto, $recipients);
|
}
|
||||||
|
|
||||||
$this->assertSame(2, $count);
|
public function testMailingSnapshotSkipsRecipientsWithoutAnAddress(): void
|
||||||
$this->assertSame([
|
{
|
||||||
['[email protected]', 'Hallo Anna', 'Servus Anna Berg'],
|
$recipients = (new TeamerMailingRecipientsDto())
|
||||||
['[email protected]', 'Hallo Bea', 'Servus Bea Ohm'],
|
->addEligible($this->createRecipient('Anna', 'Berg', '[email protected]'))
|
||||||
], $sent);
|
->addEligible($this->createRecipient('Dana', 'Elf', null))
|
||||||
|
;
|
||||||
|
|
||||||
|
$mailing = SendTeamerMailing::fromRecipients('Betreff', 'Nachricht', $recipients);
|
||||||
|
|
||||||
|
$this->assertSame(1, $mailing->getRecipientCount());
|
||||||
|
$this->assertSame('[email protected]', $mailing->getRecipients()[0]['email']);
|
||||||
|
$this->assertSame('Anna Berg', $mailing->getRecipients()[0]['fullName']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSendPreviewUsesAdminOwnNameAndMarksTheSubject(): void
|
public function testSendPreviewUsesAdminOwnNameAndMarksTheSubject(): void
|
||||||
@@ -191,6 +254,17 @@ class TeamerMailingServiceTest extends TestCase
|
|||||||
$this->assertSame('rita.kern', $values[TeamerMailingService::PLACEHOLDER_FULL_NAME]);
|
$this->assertSame('rita.kern', $values[TeamerMailingService::PLACEHOLDER_FULL_NAME]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function createMailing(string $subject, string $message, TeamerMailingRecipient ...$recipients): SendTeamerMailing
|
||||||
|
{
|
||||||
|
$dto = new TeamerMailingRecipientsDto();
|
||||||
|
|
||||||
|
foreach ($recipients as $recipient) {
|
||||||
|
$dto->addEligible($recipient);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SendTeamerMailing::fromRecipients($subject, $message, $dto);
|
||||||
|
}
|
||||||
|
|
||||||
private function createRecipient(
|
private function createRecipient(
|
||||||
string $firstName,
|
string $firstName,
|
||||||
string $lastName,
|
string $lastName,
|
||||||
|
|||||||
Reference in New Issue
Block a user