From e0a9562fc6424e5624eb0fb645768e9e2c8e035a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 12 Aug 2026 15:47:04 +0200 Subject: [PATCH] feat: send mailings over MailJet smtp in dedicated worker process --- .env | 17 ++- config/packages/mailer.yaml | 7 +- config/packages/messenger.yaml | 36 ++++++ config/packages/zenstruck_schedule.yaml | 6 +- config/services.yaml | 20 ++++ .../Admin/Teamer/MailingController.php | 22 +++- src/Email/Mailer.php | 18 +++ .../RemoveBusTransportHeaderListener.php | 37 ++++++ src/Message/SendTeamerMailing.php | 71 ++++++++++++ .../SendTeamerMailingHandler.php | 30 +++++ src/Service/Teamer/TeamerMailingService.php | 64 +++++++---- .../RemoveBusTransportHeaderListenerTest.php | 61 ++++++++++ .../SendTeamerMailingHandlerTest.php | 29 +++++ .../Teamer/TeamerMailingServiceTest.php | 106 +++++++++++++++--- 14 files changed, 480 insertions(+), 44 deletions(-) create mode 100644 src/Email/RemoveBusTransportHeaderListener.php create mode 100644 src/Message/SendTeamerMailing.php create mode 100644 src/MessageHandler/SendTeamerMailingHandler.php create mode 100644 tests/Email/RemoveBusTransportHeaderListenerTest.php create mode 100644 tests/MessageHandler/SendTeamerMailingHandlerTest.php diff --git a/.env b/.env index 453697b..3852a79 100644 --- a/.env +++ b/.env @@ -38,6 +38,9 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 ###> symfony/mailer ### 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 ### 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 ###> symfony/mailjet-mailer ### -# MAILER_DSN=mailjet+api://PUBLIC_KEY:PRIVATE_KEY@api.mailjet.com -# #MAILER_DSN=mailjet+smtp://PUBLIC_KEY:PRIVATE_KEY@in-v3.mailjet.com +# Bulk teamer mailings only, everything else goes out over MAILER_DSN. Set the real +# 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:PRIVATE_KEY@in-v3.mailjet.com +# MAILING_MAILER_DSN=mailjet+api://PUBLIC_KEY:PRIVATE_KEY@default ###< symfony/mailjet-mailer ### BIN_GS=/usr/bin/gs diff --git a/config/packages/mailer.yaml b/config/packages/mailer.yaml index 56a650d..6473358 100644 --- a/config/packages/mailer.yaml +++ b/config/packages/mailer.yaml @@ -1,3 +1,8 @@ framework: 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)%' diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 13c2132..667acc5 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -12,6 +12,29 @@ framework: retry_strategy: max_retries: 3 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' sync: 'sync://' @@ -20,6 +43,9 @@ framework: Symfony\Component\Notifier\Message\ChatMessage: 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 # 'App\Message\YourMessage': async @@ -30,3 +56,13 @@ when@dev: Symfony\Component\Mailer\Messenger\SendEmailMessage: sync Symfony\Component\Notifier\Message\ChatMessage: 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 diff --git a/config/packages/zenstruck_schedule.yaml b/config/packages/zenstruck_schedule.yaml index 10aa40d..de3a920 100644 --- a/config/packages/zenstruck_schedule.yaml +++ b/config/packages/zenstruck_schedule.yaml @@ -3,13 +3,13 @@ zenstruck_schedule: mailer: service: mailer - default_to: fromme@dreipunktnull.com - default_from: info@ep-reisen.de + default_to: bjoern.fromme@ep-reisen.de + default_from: team@ep-reisen.de subject_prefix: "[MyE&P-Team]" schedule_extensions: email_on_failure: - to: fromme@dreipunktnull.com + to: bjoern.fromme@ep-reisen.de tasks: - task: app:bpn-import diff --git a/config/services.yaml b/config/services.yaml index 7c033dc..4a8ed06 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -9,6 +9,13 @@ parameters: 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. personal_data_check_deadlines: - '04-01' @@ -51,6 +58,7 @@ services: $xmlExport: '@xml_export.storage' $xmlDump: '@xml_dump.storage' $teamerInactivePeriod: '%teamer_inactive_period%' + $mailingBusTransport: '%mailing_bus_transport%' App\: resource: '../src/' @@ -243,3 +251,15 @@ services: 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_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' diff --git a/src/Controller/Admin/Teamer/MailingController.php b/src/Controller/Admin/Teamer/MailingController.php index dc7da1a..463b413 100644 --- a/src/Controller/Admin/Teamer/MailingController.php +++ b/src/Controller/Admin/Teamer/MailingController.php @@ -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')); } diff --git a/src/Email/Mailer.php b/src/Email/Mailer.php index 1082355..7f10b47 100644 --- a/src/Email/Mailer.php +++ b/src/Email/Mailer.php @@ -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); diff --git a/src/Email/RemoveBusTransportHeaderListener.php b/src/Email/RemoveBusTransportHeaderListener.php new file mode 100644 index 0000000..bb7b9ad --- /dev/null +++ b/src/Email/RemoveBusTransportHeaderListener.php @@ -0,0 +1,37 @@ +isQueued()) { + return; + } + + $message = $event->getMessage(); + + if ($message instanceof Message) { + $message->getHeaders()->remove('X-Bus-Transport'); + } + } +} diff --git a/src/Message/SendTeamerMailing.php b/src/Message/SendTeamerMailing.php new file mode 100644 index 0000000..6de6810 --- /dev/null +++ b/src/Message/SendTeamerMailing.php @@ -0,0 +1,71 @@ + $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 + */ + public function getRecipients(): array + { + return $this->recipients; + } + + public function getRecipientCount(): int + { + return count($this->recipients); + } +} diff --git a/src/MessageHandler/SendTeamerMailingHandler.php b/src/MessageHandler/SendTeamerMailingHandler.php new file mode 100644 index 0000000..3a7a067 --- /dev/null +++ b/src/MessageHandler/SendTeamerMailingHandler.php @@ -0,0 +1,30 @@ +mailingService->sendMailing($mailing); + } +} diff --git a/src/Service/Teamer/TeamerMailingService.php b/src/Service/Teamer/TeamerMailingService.php index 38029a6..c1a84e4 100644 --- a/src/Service/Teamer/TeamerMailingService.php +++ b/src/Service/Teamer/TeamerMailingService.php @@ -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, ]); } } diff --git a/tests/Email/RemoveBusTransportHeaderListenerTest.php b/tests/Email/RemoveBusTransportHeaderListenerTest.php new file mode 100644 index 0000000..035efe7 --- /dev/null +++ b/tests/Email/RemoveBusTransportHeaderListenerTest.php @@ -0,0 +1,61 @@ +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('team@example.org') + ->to('anna@example.org') + ->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('team@example.org'), [new Address('anna@example.org')]), + 'mailing', + $queued + ); + } +} diff --git a/tests/MessageHandler/SendTeamerMailingHandlerTest.php b/tests/MessageHandler/SendTeamerMailingHandlerTest.php new file mode 100644 index 0000000..98b3914 --- /dev/null +++ b/tests/MessageHandler/SendTeamerMailingHandlerTest.php @@ -0,0 +1,29 @@ + 'anna@example.org', '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); + } +} diff --git a/tests/Service/Teamer/TeamerMailingServiceTest.php b/tests/Service/Teamer/TeamerMailingServiceTest.php index 07fbf93..d321d0c 100644 --- a/tests/Service/Teamer/TeamerMailingServiceTest.php +++ b/tests/Service/Teamer/TeamerMailingServiceTest.php @@ -7,9 +7,11 @@ namespace App\Tests\Service\Teamer; use App\Email\Mailer; use App\Entity\Teamer; use App\Entity\User; +use App\Message\SendTeamerMailing; use App\Model\TeamerFilterDto; use App\Model\TeamerMailingDto; use App\Model\TeamerMailingRecipient; +use App\Model\TeamerMailingRecipientsDto; use App\Repository\TeamerRepository; use App\Service\Teamer\TeamerMailingService; use PHPUnit\Framework\MockObject\MockObject; @@ -32,6 +34,7 @@ class TeamerMailingServiceTest extends TestCase $this->teamerRepository, $this->mailer, $this->createMock(LoggerInterface::class), + 'mailing', ); } @@ -101,16 +104,8 @@ class TeamerMailingServiceTest extends TestCase $this->assertSame(5, $recipients->getTotal()); } - public function testSendPersonalisesSubjectAndMessagePerRecipient(): void + public function testSendMailingPersonalisesSubjectAndMessagePerRecipient(): void { - $this->teamerRepository - ->method('getMailingRecipients') - ->willReturn([ - $this->createRecipient('Anna', 'Berg', 'anna@example.org'), - $this->createRecipient('Bea', 'Ohm', 'bea@example.org'), - ]) - ; - $sent = []; $this->mailer ->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', 'anna@example.org'), + $this->createRecipient('Bea', 'Ohm', 'bea@example.org') + )); + + $this->assertSame([ + ['anna@example.org', 'Hallo Anna', 'Servus Anna Berg'], + ['bea@example.org', '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', 'anna@example.org') + )); + } + + 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()) ->setSubject('Hallo {{vorname}}') ->setMessage('Servus {{name}}') ; - $recipients = $this->service->resolveRecipients(new TeamerFilterDto()); - $count = $this->service->send($mailingDto, $recipients); + $this->service->sendPreview($mailingDto, (new User())->setEmail('admin@example.org')); + } - $this->assertSame(2, $count); - $this->assertSame([ - ['anna@example.org', 'Hallo Anna', 'Servus Anna Berg'], - ['bea@example.org', 'Hallo Bea', 'Servus Bea Ohm'], - ], $sent); + public function testMailingSnapshotSkipsRecipientsWithoutAnAddress(): void + { + $recipients = (new TeamerMailingRecipientsDto()) + ->addEligible($this->createRecipient('Anna', 'Berg', 'anna@example.org')) + ->addEligible($this->createRecipient('Dana', 'Elf', null)) + ; + + $mailing = SendTeamerMailing::fromRecipients('Betreff', 'Nachricht', $recipients); + + $this->assertSame(1, $mailing->getRecipientCount()); + $this->assertSame('anna@example.org', $mailing->getRecipients()[0]['email']); + $this->assertSame('Anna Berg', $mailing->getRecipients()[0]['fullName']); } public function testSendPreviewUsesAdminOwnNameAndMarksTheSubject(): void @@ -191,6 +254,17 @@ class TeamerMailingServiceTest extends TestCase $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( string $firstName, string $lastName,