From d4a5172047c02ab649d772e41a1d76988e516c1f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 10 Sep 2026 10:41:10 +0400 Subject: [PATCH 1/9] feat: add messaging services for campaign management and notification --- config/services/services.yml | 24 + .../CampaignProcessorMessageHandler.php | 348 +------- .../Service/CampaignAdminNotifier.php | 55 ++ .../Messaging/Service/CampaignEmailSender.php | 147 ++++ .../Service/CampaignExclusionService.php | 90 ++ .../Messaging/Service/CampaignSendingLoop.php | 75 ++ .../Messaging/Service/MessageDataLoader.php | 1 + .../Service/MessageStatusUpdater.php | 29 + .../Service/SystemNotificationMailer.php | 52 ++ .../CampaignProcessorMessageHandlerTest.php | 808 +++--------------- .../Service/CampaignAdminNotifierTest.php | 85 ++ .../Service/CampaignEmailSenderTest.php | 210 +++++ .../Service/CampaignExclusionServiceTest.php | 147 ++++ .../Service/CampaignSendingLoopTest.php | 156 ++++ .../Service/MessageStatusUpdaterTest.php | 78 ++ .../Service/SystemNotificationMailerTest.php | 68 ++ 16 files changed, 1357 insertions(+), 1016 deletions(-) create mode 100644 src/Domain/Messaging/Service/CampaignAdminNotifier.php create mode 100644 src/Domain/Messaging/Service/CampaignEmailSender.php create mode 100644 src/Domain/Messaging/Service/CampaignExclusionService.php create mode 100644 src/Domain/Messaging/Service/CampaignSendingLoop.php create mode 100644 src/Domain/Messaging/Service/MessageStatusUpdater.php create mode 100644 src/Domain/Messaging/Service/SystemNotificationMailer.php create mode 100644 tests/Unit/Domain/Messaging/Service/CampaignAdminNotifierTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/CampaignEmailSenderTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/CampaignExclusionServiceTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/CampaignSendingLoopTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/MessageStatusUpdaterTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/SystemNotificationMailerTest.php diff --git a/config/services/services.yml b/config/services/services.yml index 4d0b4514..e80096a3 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -199,6 +199,30 @@ services: autowire: true autoconfigure: true + PhpList\Core\Domain\Messaging\Service\MessageStatusUpdater: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\SystemNotificationMailer: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\CampaignExclusionService: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\CampaignAdminNotifier: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\CampaignEmailSender: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\CampaignSendingLoop: + autowire: true + autoconfigure: true + _instanceof: PhpList\Core\Domain\Messaging\Service\Handler\BounceActionHandlerInterface: tags: diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php index 1e44e45d..386396b8 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php @@ -4,78 +4,43 @@ namespace PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor; -use DateTime; -use DateTimeImmutable; -use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; -use PhpList\Core\Domain\Configuration\Model\ConfigOption; -use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; -use PhpList\Core\Domain\Messaging\Exception\AttachmentCopyException; -use PhpList\Core\Domain\Messaging\Exception\MessageCacheMissingException; -use PhpList\Core\Domain\Messaging\Exception\MessageSizeLimitExceededException; use PhpList\Core\Domain\Messaging\Message\CampaignProcessor\CampaignProcessorMessage; use PhpList\Core\Domain\Messaging\Message\CampaignProcessor\SyncCampaignProcessorMessage; -use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; -use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; -use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus; -use PhpList\Core\Domain\Messaging\Model\MessageData; -use PhpList\Core\Domain\Messaging\Model\UserMessage; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; -use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; -use PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder; -use PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder; -use PhpList\Core\Domain\Messaging\Service\DomainRateLimiter; +use PhpList\Core\Domain\Messaging\Service\CampaignAdminNotifier; +use PhpList\Core\Domain\Messaging\Service\CampaignExclusionService; +use PhpList\Core\Domain\Messaging\Service\CampaignSendingLoop; use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; -use PhpList\Core\Domain\Messaging\Service\MailSizeChecker; -use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; use PhpList\Core\Domain\Messaging\Service\MessageDataLoader; use PhpList\Core\Domain\Messaging\Service\MessagePrecacheService; -use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; -use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer; -use PhpList\Core\Domain\Subscription\Model\Subscriber; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; +use PhpList\Core\Domain\Messaging\Service\MessageStatusUpdater; use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider; use Psr\Log\LoggerInterface; -use Psr\SimpleCache\CacheInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\Mailer\Envelope; -use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; -use Symfony\Component\Mime\Address; use Symfony\Contracts\Translation\TranslatorInterface; -use Throwable; /** - * @SuppressWarnings("PHPMD.CouplingBetweenObjects") * @SuppressWarnings("PHPMD.ExcessiveParameterList") */ #[AsMessageHandler] class CampaignProcessorMessageHandler { public function __construct( - private readonly MailerInterface $mailer, - private readonly RateLimitedCampaignMailer $rateLimitedCampaignMailer, - private readonly EntityManagerInterface $entityManager, + private readonly MessageRepository $messageRepository, + private readonly MessageDataLoader $messageDataLoader, + private readonly MessagePrecacheService $precacheService, + private readonly MessageStatusUpdater $messageStatusUpdater, + private readonly CampaignAdminNotifier $adminNotifier, + private readonly CampaignExclusionService $exclusionService, private readonly SubscriberProvider $subscriberProvider, - private readonly MessageProcessingPreparator $messagePreparator, - private readonly LoggerInterface $logger, - private readonly CacheInterface $cache, - private readonly UserMessageRepository $userMessageRepository, - private readonly MaxProcessTimeLimiter $timeLimiter, + private readonly CampaignSendingLoop $sendingLoop, private readonly RequeueHandler $requeueHandler, + private readonly EntityManagerInterface $entityManager, + private readonly LoggerInterface $logger, private readonly TranslatorInterface $translator, - private readonly SubscriberHistoryManager $subscriberHistoryManager, - private readonly MessageRepository $messageRepository, - private readonly MessagePrecacheService $precacheService, - private readonly MessageDataLoader $messageDataLoader, - private readonly SystemEmailBuilder $systemEmailBuilder, - private readonly EmailBuilder $campaignEmailBuilder, - private readonly MailSizeChecker $mailSizeChecker, - private readonly ConfigProvider $configProvider, - private readonly DomainRateLimiter $domainRateLimiter, - #[Autowire('%imap_bounce.email%')] private readonly string $bounceEmail, - #[Autowire('%messaging.use_list_exclude%')] private readonly bool $useListExclude = false, #[Autowire('%messaging.stuck_campaign_threshold%')] private readonly int $stuckCampaignThresholdSeconds = 0, ) { } @@ -122,304 +87,31 @@ public function __invoke(CampaignProcessorMessage|SyncCampaignProcessorMessage $ loadedMessageData: $loadedMessageData, isTest: false )) { - $this->updateMessageStatus($campaign, MessageStatus::Suspended); + $this->messageStatusUpdater->update($campaign, MessageStatus::Suspended); return; } - $this->handleAdminNotifications($campaign, $loadedMessageData, $data->getMessageId()); + $this->adminNotifier->notifyStart($campaign, $loadedMessageData, $data->getMessageId()); // Campaign was already atomically claimed into Prepared status above. - $excludeListIds = $this->getExcludeListIds($loadedMessageData); - $this->markExcludedSubscribers($campaign, $data, $excludeListIds); + $excludeListIds = $this->exclusionService->resolveExcludeListIds($loadedMessageData); + $this->exclusionService->markExcludedSubscribers($campaign, $data, $excludeListIds); $subscribers = $this->subscriberProvider->getSubscribersForMessageOrLists( $data, $campaign, $excludeListIds ); - $this->updateMessageStatus($campaign, MessageStatus::InProcess); + $this->messageStatusUpdater->update($campaign, MessageStatus::InProcess); - $stoppedEarly = $this->processSubscribersForCampaign($campaign, $subscribers, $cacheKey); + $stoppedEarly = $this->sendingLoop->run($campaign, $subscribers, $cacheKey); if ($stoppedEarly && $this->requeueHandler->handle($campaign)) { $this->entityManager->flush(); return; } - $this->updateMessageStatus($campaign, MessageStatus::Sent); - } - - /** - * Exclude-list IDs are stored via MessageData as an array keyed by list ID e.g. [3 => 1, 7 => 1]. - * - * @return int[] - */ - private function getExcludeListIds(array $loadedMessageData): array - { - if (!$this->useListExclude) { - return []; - } - - $excludeList = $loadedMessageData['excludelist'] ?? []; - if (!is_array($excludeList) || $excludeList === []) { - return []; - } - - return array_values(array_filter(array_map( - static fn (mixed $key): ?int => is_numeric($key) ? (int) $key : null, - array_keys($excludeList) - ), static fn (?int $id): bool => $id !== null)); - } - - /** - * pre-marking of exclude-list members as "excluded" in usermessage before the main send loop runs, - * so there's a persisted audit trail for why a subscriber wasn't sent to. Skips - * subscribers who already have a nontodo UserMessage for this campaign, so a later run - * can't clobber an already-recorded Sent/NotSent/etc. status from an earlier partial run. - * Only campaign recipients (i.e. subscribers who'd otherwise be sent this campaign) are - * marked, since a subscriber on an exclude list who isn't a campaign recipient anyway - * shouldn't get an exclusion record. - */ - private function markExcludedSubscribers( - Message $campaign, - CampaignProcessorMessage|SyncCampaignProcessorMessage $data, - array $excludeListIds, - ): void { - if ($excludeListIds === []) { - return; - } - - $excludedSubscribers = $this->subscriberProvider->getExcludedSubscribers($excludeListIds); - if ($excludedSubscribers === []) { - return; - } - - $sendableSubscribers = $this->subscriberProvider->getSendableSubscribersForMessageOrLists( - $data, - $campaign - ); - - foreach ($excludedSubscribers as $subscriber) { - if (!isset($sendableSubscribers[$subscriber->getEmail()])) { - continue; - } - - $existing = $this->userMessageRepository->findByUserAndMessage($subscriber, $campaign); - if ($existing && $existing->getStatus() !== UserMessageStatus::Todo) { - continue; - } - - $userMessage = $existing ?? new UserMessage($subscriber, $campaign); - $userMessage->setStatus(UserMessageStatus::Excluded); - $this->userMessageRepository->save($userMessage); - } - } - - private function unconfirmSubscriber(Subscriber $subscriber): void - { - if ($subscriber->isConfirmed()) { - $subscriber->setConfirmed(false); - $this->entityManager->flush(); - } - } - - private function updateMessageStatus(Message $message, MessageStatus $status): void - { - if ($status === MessageStatus::InProcess && $message->getMetadata()->getSendStart() === null) { - $message->getMetadata()->setSendStart(new DateTime()); - } - if ($status === MessageStatus::Sent) { - $message->getMetadata()->setSent(new DateTime()); - } - $message->getMetadata()->setStatus($status); - $this->entityManager->flush(); - } - - private function updateUserMessageStatus(UserMessage $userMessage, UserMessageStatus $status): void - { - $userMessage->setStatus($status); - $this->entityManager->flush(); - } - - private function handleInvalidEmail(UserMessage $userMessage, Subscriber $subscriber, Message $campaign): void - { - $this->updateUserMessageStatus($userMessage, UserMessageStatus::InvalidEmailAddress); - $this->unconfirmSubscriber($subscriber); - $this->logger->warning($this->translator->trans('Invalid email, marking unconfirmed: %email%', [ - '%email%' => $subscriber->getEmail(), - ])); - $this->subscriberHistoryManager->addHistory( - subscriber: $subscriber, - message: $this->translator->trans('Subscriber marked unconfirmed for invalid email address'), - details: $this->translator->trans( - 'Marked unconfirmed while sending campaign %message_id%', - ['%message_id%' => $campaign->getId()] - ) - ); - } - - private function handleEmailSending( - Message $campaign, - Subscriber $subscriber, - UserMessage $userMessage, - MessagePrecacheDto $precachedContent, - ): void { - // todo: check at which point link tracking should be applied (maybe after constructing full text?) - $processed = $this->messagePreparator->processMessageLinks( - campaignId: $campaign->getId(), - cachedMessageDto: $precachedContent, - subscriber: $subscriber - ); - - try { - $result = $this->campaignEmailBuilder->buildCampaignEmail( - messageId: $campaign->getId(), - data: $processed, - toEmail: $subscriber->getEmail(), - skipBlacklistCheck: false, - inBlast: true, - htmlPref: $subscriber->hasHtmlEmail(), - ); - if ($result === null) { - $status = $subscriber->isBlacklisted() ? UserMessageStatus::Excluded : UserMessageStatus::NotSent; - $this->updateUserMessageStatus($userMessage, $status); - - return; - } - [$email, $sentAs] = $result; - $this->campaignEmailBuilder->applyCampaignHeaders(email: $email, subscriber: $subscriber); - - $this->rateLimitedCampaignMailer->send($email); - ($this->mailSizeChecker)($campaign, $email, $subscriber->hasHtmlEmail()); - $this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent); - $this->messageRepository->incrementSentCounts($campaign->getId(), $sentAs); - } catch (MessageSizeLimitExceededException $e) { - // stop after the first message if size is exceeded - $this->updateMessageStatus($campaign, MessageStatus::Suspended); - $this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent); - - throw $e; - } catch (AttachmentCopyException $e) { - // stop after the first message if size is exceeded - $this->updateMessageStatus($campaign, MessageStatus::Suspended); - $this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent); - - $data = new MessagePrecacheDto(); - $data->subject = $this->translator->trans('phpList system error'); - $data->content = $this->translator->trans($e->getMessage()); - - $email = $this->systemEmailBuilder->buildCampaignEmail( - messageId: $campaign->getId(), - data: $data, - toEmail: $this->configProvider->getValue(ConfigOption::ReportAddress) ?? '', - ); - - $envelope = new Envelope( - sender: new Address($this->bounceEmail, 'PHPList'), - recipients: [new Address($email->getTo()[0]->getAddress())], - ); - $this->mailer->send(message: $email, envelope: $envelope); - - throw $e; - } catch (Throwable $e) { - $this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent); - $this->logger->error($e->getMessage(), [ - 'subscriber_id' => $subscriber->getId(), - 'campaign_id' => $campaign->getId(), - ]); - $this->logger->warning($this->translator->trans('Failed to send to: %email%', [ - '%email%' => $subscriber->getEmail(), - ])); - } - } - - private function handleAdminNotifications(Message $campaign, array $loadedMessageData, int $messageId): void - { - if (!empty($loadedMessageData['notify_start']) && !isset($loadedMessageData['start_notified'])) { - $notifications = explode(',', $loadedMessageData['notify_start']); - foreach ($notifications as $notification) { - $data = new MessagePrecacheDto(); - $data->subject = $this->translator->trans('Campaign started'); - $data->content = $this->translator->trans( - 'phplist has started sending the campaign with subject %subject%', - ['%subject%' => $loadedMessageData['subject']] - ); - - $email = $this->systemEmailBuilder->buildCampaignEmail( - messageId: $campaign->getId(), - data: $data, - toEmail: $notification - ); - - if (!$email) { - continue; - } - - // todo: check if from name should be from config - $envelope = new Envelope( - sender: new Address($this->bounceEmail, 'PHPList'), - recipients: [new Address($email->getTo()[0]->getAddress())], - ); - $this->mailer->send(message: $email, envelope: $envelope); - } - $messageData = new MessageData(); - $messageData->setName('start_notified'); - $messageData->setId($messageId); - $messageData->setData((new DateTimeImmutable())->format('Y-m-d H:i:s')); - - try { - $this->entityManager->persist($messageData); - $this->entityManager->flush(); - } catch (UniqueConstraintViolationException $e) { - $this->logger->debug('Duplicate message ignored', [ - 'exception' => $e, - ]); - } - } - } - - private function processSubscribersForCampaign(Message $campaign, array $subscribers, string $cacheKey): bool - { - $this->timeLimiter->start(); - $stoppedEarly = false; - - foreach ($subscribers as $subscriber) { - if ($this->timeLimiter->shouldStop()) { - $stoppedEarly = true; - break; - } - - $existing = $this->userMessageRepository->findByUserAndMessage($subscriber, $campaign); - if ($existing && $existing->getStatus() !== UserMessageStatus::Todo) { - continue; - } - - if (!$this->domainRateLimiter->attemptSend($subscriber->getEmail())->allowed) { - // Leave no UserMessage record so this subscriber is picked up again on a - // later run, once their domain's throttle window has passed. - $stoppedEarly = true; - continue; - } - - $userMessage = $existing ?? new UserMessage($subscriber, $campaign); - $userMessage->setStatus(UserMessageStatus::Active); - $this->userMessageRepository->save($userMessage); - - if (!filter_var($subscriber->getEmail(), FILTER_VALIDATE_EMAIL)) { - $this->handleInvalidEmail($userMessage, $subscriber, $campaign); - $this->entityManager->flush(); - continue; - } - - $messagePrecacheDto = $this->cache->get($cacheKey); - if ($messagePrecacheDto === null) { - throw new MessageCacheMissingException(); - } - // todo: maybe catch exception and return false to stop early? - $this->handleEmailSending($campaign, $subscriber, $userMessage, $messagePrecacheDto); - } - - return $stoppedEarly; + $this->messageStatusUpdater->update($campaign, MessageStatus::Sent); } } diff --git a/src/Domain/Messaging/Service/CampaignAdminNotifier.php b/src/Domain/Messaging/Service/CampaignAdminNotifier.php new file mode 100644 index 00000000..daa4a7e2 --- /dev/null +++ b/src/Domain/Messaging/Service/CampaignAdminNotifier.php @@ -0,0 +1,55 @@ +translator->trans('Campaign started'); + $content = $this->translator->trans( + 'phplist has started sending the campaign with subject %subject%', + ['%subject%' => $loadedMessageData['subject']] + ); + + foreach (explode(',', $loadedMessageData['notify_start']) as $notification) { + $this->notificationMailer->send($campaign->getId(), $notification, $subject, $content); + } + + $messageData = new MessageData(); + $messageData->setName('start_notified'); + $messageData->setId($messageId); + $messageData->setData((new DateTimeImmutable())->format('Y-m-d H:i:s')); + + try { + $this->entityManager->persist($messageData); + $this->entityManager->flush(); + } catch (UniqueConstraintViolationException $e) { + $this->logger->debug('Duplicate message ignored', [ + 'exception' => $e, + ]); + } + } +} diff --git a/src/Domain/Messaging/Service/CampaignEmailSender.php b/src/Domain/Messaging/Service/CampaignEmailSender.php new file mode 100644 index 00000000..e20cc701 --- /dev/null +++ b/src/Domain/Messaging/Service/CampaignEmailSender.php @@ -0,0 +1,147 @@ +messagePreparator->processMessageLinks( + campaignId: $campaign->getId(), + cachedMessageDto: $precachedContent, + subscriber: $subscriber + ); + + try { + $result = $this->campaignEmailBuilder->buildCampaignEmail( + messageId: $campaign->getId(), + data: $processed, + toEmail: $subscriber->getEmail(), + skipBlacklistCheck: false, + inBlast: true, + htmlPref: $subscriber->hasHtmlEmail(), + ); + if ($result === null) { + $status = $subscriber->isBlacklisted() ? UserMessageStatus::Excluded : UserMessageStatus::NotSent; + $this->updateUserMessageStatus($userMessage, $status); + + return; + } + [$email, $sentAs] = $result; + $this->campaignEmailBuilder->applyCampaignHeaders(email: $email, subscriber: $subscriber); + + $this->rateLimitedCampaignMailer->send($email); + ($this->mailSizeChecker)($campaign, $email, $subscriber->hasHtmlEmail()); + $this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent); + $this->messageRepository->incrementSentCounts($campaign->getId(), $sentAs); + } catch (MessageSizeLimitExceededException $e) { + // stop after the first message if size is exceeded + $this->messageStatusUpdater->update($campaign, MessageStatus::Suspended); + $this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent); + + throw $e; + } catch (AttachmentCopyException $e) { + // stop after the first message if size is exceeded + $this->messageStatusUpdater->update($campaign, MessageStatus::Suspended); + $this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent); + + $this->notificationMailer->send( + $campaign->getId(), + $this->configProvider->getValue(ConfigOption::ReportAddress) ?? '', + $this->translator->trans('phplist system error'), + $this->translator->trans($e->getMessage()), + ); + + throw $e; + } catch (Throwable $e) { + $this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent); + $this->logger->error($e->getMessage(), [ + 'subscriber_id' => $subscriber->getId(), + 'campaign_id' => $campaign->getId(), + ]); + $this->logger->warning($this->translator->trans('Failed to send to: %email%', [ + '%email%' => $subscriber->getEmail(), + ])); + } + } + + public function handleInvalidEmail(UserMessage $userMessage, Subscriber $subscriber, Message $campaign): void + { + $this->updateUserMessageStatus($userMessage, UserMessageStatus::InvalidEmailAddress); + $this->unconfirmSubscriber($subscriber); + $this->logger->warning($this->translator->trans('Invalid email, marking unconfirmed: %email%', [ + '%email%' => $subscriber->getEmail(), + ])); + $this->subscriberHistoryManager->addHistory( + subscriber: $subscriber, + message: $this->translator->trans('Subscriber marked unconfirmed for invalid email address'), + details: $this->translator->trans( + 'Marked unconfirmed while sending campaign %message_id%', + ['%message_id%' => $campaign->getId()] + ) + ); + } + + private function unconfirmSubscriber(Subscriber $subscriber): void + { + if ($subscriber->isConfirmed()) { + $subscriber->setConfirmed(false); + $this->entityManager->flush(); + } + } + + private function updateUserMessageStatus(UserMessage $userMessage, UserMessageStatus $status): void + { + $userMessage->setStatus($status); + $this->entityManager->flush(); + } +} diff --git a/src/Domain/Messaging/Service/CampaignExclusionService.php b/src/Domain/Messaging/Service/CampaignExclusionService.php new file mode 100644 index 00000000..3fe69fb9 --- /dev/null +++ b/src/Domain/Messaging/Service/CampaignExclusionService.php @@ -0,0 +1,90 @@ + 1, 7 => 1]. + * + * @return int[] + */ + public function resolveExcludeListIds(array $loadedMessageData): array + { + if (!$this->useListExclude) { + return []; + } + + $excludeList = $loadedMessageData['excludelist'] ?? []; + if (!is_array($excludeList) || $excludeList === []) { + return []; + } + + return array_values(array_filter(array_map( + static fn (mixed $key): ?int => is_numeric($key) ? (int) $key : null, + array_keys($excludeList) + ), static fn (?int $id): bool => $id !== null)); + } + + /** + * pre-marking of exclude-list members as "excluded" in usermessage before the main send loop runs, + * so there's a persisted audit trail for why a subscriber wasn't sent to. Skips + * subscribers who already have a nontodo UserMessage for this campaign, so a later run + * can't clobber an already-recorded Sent/NotSent/etc. status from an earlier partial run. + * Only campaign recipients (i.e. subscribers who'd otherwise be sent this campaign) are + * marked, since a subscriber on an exclude list who isn't a campaign recipient anyway + * shouldn't get an exclusion record. + */ + public function markExcludedSubscribers( + Message $campaign, + CampaignProcessorMessage|SyncCampaignProcessorMessage $data, + array $excludeListIds, + ): void { + if ($excludeListIds === []) { + return; + } + + $excludedSubscribers = $this->subscriberProvider->getExcludedSubscribers($excludeListIds); + if ($excludedSubscribers === []) { + return; + } + + $sendableSubscribers = $this->subscriberProvider->getSendableSubscribersForMessageOrLists( + $data, + $campaign + ); + + foreach ($excludedSubscribers as $subscriber) { + if (!isset($sendableSubscribers[$subscriber->getEmail()])) { + continue; + } + + $existing = $this->userMessageRepository->findByUserAndMessage($subscriber, $campaign); + if ($existing && $existing->getStatus() !== UserMessageStatus::Todo) { + continue; + } + + $userMessage = $existing ?? new UserMessage($subscriber, $campaign); + $userMessage->setStatus(UserMessageStatus::Excluded); + $this->userMessageRepository->save($userMessage); + } + } +} diff --git a/src/Domain/Messaging/Service/CampaignSendingLoop.php b/src/Domain/Messaging/Service/CampaignSendingLoop.php new file mode 100644 index 00000000..7c854b89 --- /dev/null +++ b/src/Domain/Messaging/Service/CampaignSendingLoop.php @@ -0,0 +1,75 @@ +timeLimiter->start(); + $stoppedEarly = false; + + foreach ($subscribers as $subscriber) { + if ($this->timeLimiter->shouldStop()) { + $stoppedEarly = true; + break; + } + + $existing = $this->userMessageRepository->findByUserAndMessage($subscriber, $campaign); + if ($existing && $existing->getStatus() !== UserMessageStatus::Todo) { + continue; + } + + if (!$this->domainRateLimiter->attemptSend($subscriber->getEmail())->allowed) { + // Leave no UserMessage record so this subscriber is picked up again on a + // later run, once their domain's throttle window has passed. + $stoppedEarly = true; + continue; + } + + $userMessage = $existing ?? new UserMessage($subscriber, $campaign); + $userMessage->setStatus(UserMessageStatus::Active); + $this->userMessageRepository->save($userMessage); + + if (!filter_var($subscriber->getEmail(), FILTER_VALIDATE_EMAIL)) { + $this->emailSender->handleInvalidEmail($userMessage, $subscriber, $campaign); + continue; + } + + $messagePrecacheDto = $this->cache->get($cacheKey); + if ($messagePrecacheDto === null) { + throw new MessageCacheMissingException(); + } + // todo: maybe catch exception and return false to stop early? + $this->emailSender->send($campaign, $subscriber, $userMessage, $messagePrecacheDto); + } + + return $stoppedEarly; + } +} diff --git a/src/Domain/Messaging/Service/MessageDataLoader.php b/src/Domain/Messaging/Service/MessageDataLoader.php index 9e5d07c8..dc9714a4 100644 --- a/src/Domain/Messaging/Service/MessageDataLoader.php +++ b/src/Domain/Messaging/Service/MessageDataLoader.php @@ -94,6 +94,7 @@ private function buildDefaultMessageData(): array value: $this->configProvider->getValue(ConfigOption::AlwaysAddGoogleTracking), filter: FILTER_VALIDATE_BOOL ), + // todo: check where this is set 'excludelist' => [], 'sentastest' => '0', ]; diff --git a/src/Domain/Messaging/Service/MessageStatusUpdater.php b/src/Domain/Messaging/Service/MessageStatusUpdater.php new file mode 100644 index 00000000..98546ae4 --- /dev/null +++ b/src/Domain/Messaging/Service/MessageStatusUpdater.php @@ -0,0 +1,29 @@ +getMetadata()->getSendStart() === null) { + $message->getMetadata()->setSendStart(new DateTime()); + } + if ($status === MessageStatus::Sent) { + $message->getMetadata()->setSent(new DateTime()); + } + $message->getMetadata()->setStatus($status); + $this->entityManager->flush(); + } +} diff --git a/src/Domain/Messaging/Service/SystemNotificationMailer.php b/src/Domain/Messaging/Service/SystemNotificationMailer.php new file mode 100644 index 00000000..62c60628 --- /dev/null +++ b/src/Domain/Messaging/Service/SystemNotificationMailer.php @@ -0,0 +1,52 @@ +subject = $subject; + $data->content = $content; + + $email = $this->systemEmailBuilder->buildCampaignEmail( + messageId: $messageId, + data: $data, + toEmail: $toEmail, + ); + + if (!$email) { + return false; + } + + // todo: check if from name should be from config + $envelope = new Envelope( + sender: new Address($this->bounceEmail, 'PHPList'), + recipients: [new Address($email->getTo()[0]->getAddress())], + ); + $this->mailer->send(message: $email, envelope: $envelope); + + return true; + } +} diff --git a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php index fc7d5802..1a2fb56b 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php @@ -5,117 +5,76 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\MessageHandler; use Doctrine\ORM\EntityManagerInterface; -use Exception; -use PhpList\Core\Domain\Configuration\Model\OutputFormat; -use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Messaging\Message\CampaignProcessor\CampaignProcessorMessage; use PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor\CampaignProcessorMessageHandler; -use PhpList\Core\Domain\Messaging\Model\Dto\DomainThrottleResult; -use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Model\Message; -use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata; -use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus; -use PhpList\Core\Domain\Messaging\Model\UserMessage; +use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; -use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; -use PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder; -use PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder; -use PhpList\Core\Domain\Messaging\Service\DomainRateLimiter; +use PhpList\Core\Domain\Messaging\Service\CampaignAdminNotifier; +use PhpList\Core\Domain\Messaging\Service\CampaignExclusionService; +use PhpList\Core\Domain\Messaging\Service\CampaignSendingLoop; use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; -use PhpList\Core\Domain\Messaging\Service\MailSizeChecker; -use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; use PhpList\Core\Domain\Messaging\Service\MessageDataLoader; use PhpList\Core\Domain\Messaging\Service\MessagePrecacheService; -use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; -use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer; -use PhpList\Core\Domain\Subscription\Model\Subscriber; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; +use PhpList\Core\Domain\Messaging\Service\MessageStatusUpdater; use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -use Psr\SimpleCache\CacheInterface; -use ReflectionClass; -use Symfony\Component\Mailer\MailerInterface; -use Symfony\Component\Mime\Email; use Symfony\Component\Translation\Translator; use Symfony\Contracts\Translation\TranslatorInterface; class CampaignProcessorMessageHandlerTest extends TestCase { - private RateLimitedCampaignMailer|MockObject $mailer; - private EntityManagerInterface|MockObject $entityManager; - private SubscriberProvider|MockObject $subscriberProvider; - private MessageProcessingPreparator|MockObject $messagePreparator; - private LoggerInterface|MockObject $logger; - private CampaignProcessorMessageHandler $handler; private MessageRepository|MockObject $messageRepository; - private TranslatorInterface|MockObject $translator; + private MessageDataLoader|MockObject $messageDataLoader; private MessagePrecacheService|MockObject $precacheService; - private CacheInterface|MockObject $cache; - private MailerInterface|MockObject $symfonyMailer; - private UserMessageRepository|MockObject $userMessageRepository; - private MaxProcessTimeLimiter|MockObject $timeLimiter; + private MessageStatusUpdater|MockObject $messageStatusUpdater; + private CampaignAdminNotifier|MockObject $adminNotifier; + private CampaignExclusionService|MockObject $exclusionService; + private SubscriberProvider|MockObject $subscriberProvider; + private CampaignSendingLoop|MockObject $sendingLoop; private RequeueHandler|MockObject $requeueHandler; - private DomainRateLimiter|MockObject $domainRateLimiter; + private EntityManagerInterface|MockObject $entityManager; + private LoggerInterface|MockObject $logger; + private TranslatorInterface|MockObject $translator; + private CampaignProcessorMessageHandler $handler; protected function setUp(): void { - $this->mailer = $this->createMock(RateLimitedCampaignMailer::class); - $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->messageRepository = $this->createMock(MessageRepository::class); + $this->messageDataLoader = $this->createMock(MessageDataLoader::class); + $this->precacheService = $this->createMock(MessagePrecacheService::class); + $this->messageStatusUpdater = $this->createMock(MessageStatusUpdater::class); + $this->adminNotifier = $this->createMock(CampaignAdminNotifier::class); + $this->exclusionService = $this->createMock(CampaignExclusionService::class); $this->subscriberProvider = $this->createMock(SubscriberProvider::class); - $this->messagePreparator = $this->createMock(MessageProcessingPreparator::class); + $this->sendingLoop = $this->createMock(CampaignSendingLoop::class); + $this->requeueHandler = $this->createMock(RequeueHandler::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); $this->logger = $this->createMock(LoggerInterface::class); - $this->messageRepository = $this->createMock(MessageRepository::class); - $userMessageRepository = $this->createMock(UserMessageRepository::class); - $timeLimiter = $this->createMock(MaxProcessTimeLimiter::class); - $requeueHandler = $this->createMock(RequeueHandler::class); $this->translator = $this->createMock(Translator::class); - $this->precacheService = $this->createMock(MessagePrecacheService::class); - $this->cache = $this->createMock(CacheInterface::class); - $this->symfonyMailer = $this->createMock(MailerInterface::class); - - $timeLimiter->method('start'); - $timeLimiter->method('shouldStop')->willReturn(false); - - $this->userMessageRepository = $userMessageRepository; - $this->timeLimiter = $timeLimiter; - $this->requeueHandler = $requeueHandler; - $this->domainRateLimiter = $this->createMock(DomainRateLimiter::class); - $this->domainRateLimiter->method('attemptSend') - ->willReturn(new DomainThrottleResult(allowed: true, domain: null)); + $this->translator->method('trans')->willReturnCallback(fn (string $msg) => $msg); $this->handler = $this->createHandler(); } - private function createHandler( - bool $useListExclude = false, - int $stuckCampaignThresholdSeconds = 0, - ): CampaignProcessorMessageHandler { + private function createHandler(int $stuckCampaignThresholdSeconds = 0): CampaignProcessorMessageHandler + { return new CampaignProcessorMessageHandler( - mailer: $this->symfonyMailer, - rateLimitedCampaignMailer: $this->mailer, - entityManager: $this->entityManager, + messageRepository: $this->messageRepository, + messageDataLoader: $this->messageDataLoader, + precacheService: $this->precacheService, + messageStatusUpdater: $this->messageStatusUpdater, + adminNotifier: $this->adminNotifier, + exclusionService: $this->exclusionService, subscriberProvider: $this->subscriberProvider, - messagePreparator: $this->messagePreparator, - logger: $this->logger, - cache: $this->cache, - userMessageRepository: $this->userMessageRepository, - timeLimiter: $this->timeLimiter, + sendingLoop: $this->sendingLoop, requeueHandler: $this->requeueHandler, + entityManager: $this->entityManager, + logger: $this->logger, translator: $this->translator, - subscriberHistoryManager: $this->createMock(SubscriberHistoryManager::class), - messageRepository: $this->messageRepository, - precacheService: $this->precacheService, - messageDataLoader: $this->createMock(MessageDataLoader::class), - systemEmailBuilder: $this->createMock(SystemEmailBuilder::class), - campaignEmailBuilder: $this->createMock(EmailBuilder::class), - mailSizeChecker: $this->createMock(MailSizeChecker::class), - configProvider: $this->createMock(ConfigProvider::class), - domainRateLimiter: $this->domainRateLimiter, - bounceEmail: 'bounce@email.com', - useListExclude: $useListExclude, stuckCampaignThresholdSeconds: $stuckCampaignThresholdSeconds, ); } @@ -129,12 +88,12 @@ public function testInvokeWhenCampaignNotFound(): void ->with(999, 0) ->willReturn(null); - $this->translator->method('trans')->willReturnCallback(fn(string $msg) => $msg); - $this->logger->expects($this->once()) ->method('warning') ->with('Campaign not found or not in submitted status', ['campaign_id' => 999]); + $this->precacheService->expects($this->never())->method('precacheMessage'); + ($this->handler)($message); } @@ -149,676 +108,149 @@ public function testInvokePassesStuckCampaignThresholdToTryClaimForProcessing(): ->with(999, 1800) ->willReturn(null); - $this->translator->method('trans')->willReturnCallback(fn(string $msg) => $msg); - $handler($message); } - public function testInvokeWithNoSubscribers(): void + public function testInvokeSuspendsCampaignWhenPrecacheFails(): void { $campaign = $this->createCampaignMock(); - $metadata = $this->createMock(MessageMetadata::class); - $campaign->method('getMetadata')->willReturn($metadata); - $campaign->method('getId')->willReturn(1); $data = new CampaignProcessorMessage(1); - $this->messageRepository->method('tryClaimForProcessing') - ->with(1, 0) - ->willReturn($campaign); - + $this->messageRepository->method('tryClaimForProcessing')->willReturn($campaign); + $this->messageDataLoader->method('__invoke')->willReturn([]); $this->precacheService->expects($this->once()) ->method('precacheMessage') - ->with($campaign, $this->anything()) - ->willReturn(true); - - $this->subscriberProvider->expects($this->once()) - ->method('getSubscribersForMessageOrLists') - ->with($data, $campaign) - ->willReturn([]); + ->willReturn(false); - $metadata->expects($this->atLeastOnce()) - ->method('setStatus'); + $this->messageStatusUpdater->expects($this->once()) + ->method('update') + ->with($campaign, MessageStatus::Suspended); - $this->entityManager->expects($this->atLeastOnce()) - ->method('flush'); - - $this->symfonyMailer->expects($this->never()) - ->method('send'); + $this->adminNotifier->expects($this->never())->method('notifyStart'); + $this->sendingLoop->expects($this->never())->method('run'); ($this->handler)($data); } - public function testInvokePassesExcludeListIdsFromMessageDataToSubscriberProviderWhenEnabled(): void - { - $handler = $this->createHandler(useListExclude: true); - - $campaign = $this->createCampaignMock(); - $metadata = $this->createMock(MessageMetadata::class); - $campaign->method('getMetadata')->willReturn($metadata); - $campaign->method('getId')->willReturn(1); - $data = new CampaignProcessorMessage(1); - - $this->messageRepository->method('tryClaimForProcessing') - ->with(1, 0) - ->willReturn($campaign); - - $messageDataLoaderProperty = (new ReflectionClass($handler))->getProperty('messageDataLoader'); - /** @var MessageDataLoader|MockObject $messageDataLoaderMock */ - $messageDataLoaderMock = $messageDataLoaderProperty->getValue($handler); - $messageDataLoaderMock->method('__invoke')->willReturn([ - 'excludelist' => [55 => 1, 66 => 1], - ]); - - $this->precacheService->expects($this->once()) - ->method('precacheMessage') - ->with($campaign, $this->anything()) - ->willReturn(true); - - $this->subscriberProvider->expects($this->once()) - ->method('getSubscribersForMessageOrLists') - ->with($data, $campaign, [55, 66]) - ->willReturn([]); - - $metadata->expects($this->atLeastOnce()) - ->method('setStatus'); - - $handler($data); - } - - public function testInvokeIgnoresExcludeListWhenUseListExcludeDisabled(): void - { - $handler = $this->createHandler(useListExclude: false); - - $campaign = $this->createCampaignMock(); - $metadata = $this->createMock(MessageMetadata::class); - $campaign->method('getMetadata')->willReturn($metadata); - $campaign->method('getId')->willReturn(1); - $data = new CampaignProcessorMessage(1); - - $this->messageRepository->method('tryClaimForProcessing') - ->with(1, 0) - ->willReturn($campaign); - - $messageDataLoaderProperty = (new ReflectionClass($handler))->getProperty('messageDataLoader'); - /** @var MessageDataLoader|MockObject $messageDataLoaderMock */ - $messageDataLoaderMock = $messageDataLoaderProperty->getValue($handler); - $messageDataLoaderMock->method('__invoke')->willReturn([ - 'excludelist' => [55 => 1, 66 => 1], - ]); - - $this->precacheService->expects($this->once()) - ->method('precacheMessage') - ->with($campaign, $this->anything()) - ->willReturn(true); - - $this->subscriberProvider->expects($this->once()) - ->method('getSubscribersForMessageOrLists') - ->with($data, $campaign, []) - ->willReturn([]); - - $metadata->expects($this->atLeastOnce()) - ->method('setStatus'); - - $handler($data); - } - - public function testInvokeMarksExcludedSubscribersAsExcludedInUserMessage(): void - { - $handler = $this->createHandler(useListExclude: true); - - $campaign = $this->createCampaignMock(); - $metadata = $this->createMock(MessageMetadata::class); - $campaign->method('getMetadata')->willReturn($metadata); - $campaign->method('getId')->willReturn(1); - $data = new CampaignProcessorMessage(1); - - $this->messageRepository->method('tryClaimForProcessing') - ->with(1, 0) - ->willReturn($campaign); - - $messageDataLoaderProperty = (new ReflectionClass($handler))->getProperty('messageDataLoader'); - /** @var MessageDataLoader|MockObject $messageDataLoaderMock */ - $messageDataLoaderMock = $messageDataLoaderProperty->getValue($handler); - $messageDataLoaderMock->method('__invoke')->willReturn([ - 'excludelist' => [55 => 1], - ]); - - $this->precacheService->expects($this->once()) - ->method('precacheMessage') - ->with($campaign, $this->anything()) - ->willReturn(true); - - $excludedSubscriber = $this->createMock(Subscriber::class); - $excludedSubscriber->method('getEmail')->willReturn('excluded@example.com'); - - $this->subscriberProvider->expects($this->once()) - ->method('getExcludedSubscribers') - ->with([55]) - ->willReturn([$excludedSubscriber]); - - $this->subscriberProvider->expects($this->once()) - ->method('getSendableSubscribersForMessageOrLists') - ->with($data, $campaign) - ->willReturn(['excluded@example.com' => $excludedSubscriber]); - - $this->subscriberProvider->expects($this->once()) - ->method('getSubscribersForMessageOrLists') - ->with($data, $campaign, [55]) - ->willReturn([]); - - $this->userMessageRepository->expects($this->once()) - ->method('findByUserAndMessage') - ->with($excludedSubscriber, $campaign) - ->willReturn(null); - - $this->userMessageRepository->expects($this->once()) - ->method('save') - ->with($this->callback( - fn (UserMessage $userMessage): bool => $userMessage->getUser() === $excludedSubscriber - && $userMessage->getStatus() === UserMessageStatus::Excluded - )); - - $metadata->expects($this->atLeastOnce()) - ->method('setStatus'); - - $handler($data); - } - - public function testInvokeDoesNotOverwriteExistingNonTodoUserMessageWhenMarkingExcluded(): void - { - $handler = $this->createHandler(useListExclude: true); - - $campaign = $this->createCampaignMock(); - $metadata = $this->createMock(MessageMetadata::class); - $campaign->method('getMetadata')->willReturn($metadata); - $campaign->method('getId')->willReturn(1); - $data = new CampaignProcessorMessage(1); - - $this->messageRepository->method('tryClaimForProcessing') - ->with(1, 0) - ->willReturn($campaign); - - $messageDataLoaderProperty = (new ReflectionClass($handler))->getProperty('messageDataLoader'); - /** @var MessageDataLoader|MockObject $messageDataLoaderMock */ - $messageDataLoaderMock = $messageDataLoaderProperty->getValue($handler); - $messageDataLoaderMock->method('__invoke')->willReturn([ - 'excludelist' => [55 => 1], - ]); - - $this->precacheService->expects($this->once()) - ->method('precacheMessage') - ->with($campaign, $this->anything()) - ->willReturn(true); - - $excludedSubscriber = $this->createMock(Subscriber::class); - $excludedSubscriber->method('getEmail')->willReturn('already-sent@example.com'); - - $this->subscriberProvider->expects($this->once()) - ->method('getExcludedSubscribers') - ->with([55]) - ->willReturn([$excludedSubscriber]); - - $this->subscriberProvider->expects($this->once()) - ->method('getSendableSubscribersForMessageOrLists') - ->with($data, $campaign) - ->willReturn(['already-sent@example.com' => $excludedSubscriber]); - - $this->subscriberProvider->expects($this->once()) - ->method('getSubscribersForMessageOrLists') - ->willReturn([]); - - $existingUserMessage = $this->createMock(UserMessage::class); - $existingUserMessage->method('getStatus')->willReturn(UserMessageStatus::Sent); - - $this->userMessageRepository->expects($this->once()) - ->method('findByUserAndMessage') - ->with($excludedSubscriber, $campaign) - ->willReturn($existingUserMessage); - - $this->userMessageRepository->expects($this->never()) - ->method('save'); - - $metadata->expects($this->atLeastOnce()) - ->method('setStatus'); - - $handler($data); - } - - public function testInvokeDoesNotMarkExcludedSubscriberWhoIsNotACampaignRecipient(): void + public function testInvokeRunsFullPipelineAndMarksCampaignSent(): void { - $handler = $this->createHandler(useListExclude: true); - $campaign = $this->createCampaignMock(); - $metadata = $this->createMock(MessageMetadata::class); - $campaign->method('getMetadata')->willReturn($metadata); - $campaign->method('getId')->willReturn(1); $data = new CampaignProcessorMessage(1); + $loadedMessageData = ['subject' => 'hello']; - $this->messageRepository->method('tryClaimForProcessing') - ->with(1, 0) - ->willReturn($campaign); - - $messageDataLoaderProperty = (new ReflectionClass($handler))->getProperty('messageDataLoader'); - /** @var MessageDataLoader|MockObject $messageDataLoaderMock */ - $messageDataLoaderMock = $messageDataLoaderProperty->getValue($handler); - $messageDataLoaderMock->method('__invoke')->willReturn([ - 'excludelist' => [55 => 1], - ]); - - $this->precacheService->expects($this->once()) - ->method('precacheMessage') - ->with($campaign, $this->anything()) - ->willReturn(true); - - $nonRecipientExcludedSubscriber = $this->createMock(Subscriber::class); - $nonRecipientExcludedSubscriber->method('getEmail')->willReturn('not-a-recipient@example.com'); - - $this->subscriberProvider->expects($this->once()) - ->method('getExcludedSubscribers') - ->with([55]) - ->willReturn([$nonRecipientExcludedSubscriber]); - - $this->subscriberProvider->expects($this->once()) - ->method('getSendableSubscribersForMessageOrLists') - ->with($data, $campaign) - ->willReturn([]); - - $this->subscriberProvider->expects($this->once()) - ->method('getSubscribersForMessageOrLists') - ->with($data, $campaign, [55]) - ->willReturn([]); - - $this->userMessageRepository->expects($this->never()) - ->method('findByUserAndMessage'); - - $this->userMessageRepository->expects($this->never()) - ->method('save'); - - $metadata->expects($this->atLeastOnce()) - ->method('setStatus'); - - $handler($data); - } - - public function testInvokeWithInvalidSubscriberEmail(): void - { - $campaign = $this->createCampaignMock(); - $metadata = $this->createMock(MessageMetadata::class); - $campaign->method('getMetadata')->willReturn($metadata); - $campaign->method('getId')->willReturn(1); - $data = new CampaignProcessorMessage(1); - - $this->messageRepository->method('tryClaimForProcessing') - ->with(1, 0) - ->willReturn($campaign); - - $this->precacheService->expects($this->once()) - ->method('precacheMessage') - ->with($campaign, $this->anything()) - ->willReturn(true); - - $subscriber = $this->createMock(Subscriber::class); - $subscriber->method('getEmail')->willReturn('invalid-email'); - $subscriber->method('getId')->willReturn(1); - - $this->subscriberProvider->expects($this->once()) - ->method('getSubscribersForMessageOrLists') - ->with($data, $campaign) - ->willReturn([$subscriber]); - - $metadata->expects($this->atLeastOnce()) - ->method('setStatus'); - - $this->entityManager->expects($this->atLeastOnce()) - ->method('flush'); - - $this->messagePreparator->expects($this->never()) - ->method('processMessageLinks'); - - $this->symfonyMailer->expects($this->never()) - ->method('send'); - - ($this->handler)($data); - } - - public function testInvokeWithValidSubscriberEmail(): void - { - $campaign = $this->createMock(Message::class); - $precached = new MessagePrecacheDto(); - $precached->subject = 'Test Subject'; - $precached->content = '

Test HTML message

'; - $precached->textContent = 'Test text message'; - $precached->footer = 'Test footer message'; - $campaign->method('getContent')->willReturn($this->createContentMock()); - $metadata = $this->createMock(MessageMetadata::class); - $campaign->method('getMetadata')->willReturn($metadata); - $campaign->method('getId')->willReturn(1); - $data = new CampaignProcessorMessage(1); - - $this->messageRepository->method('tryClaimForProcessing') - ->with(1, 0) - ->willReturn($campaign); - + $this->messageRepository->method('tryClaimForProcessing')->with(1, 0)->willReturn($campaign); + $this->messageDataLoader->method('__invoke')->with($campaign)->willReturn($loadedMessageData); $this->precacheService->expects($this->once()) ->method('precacheMessage') - ->with($campaign, $this->anything()) + ->with($campaign, $loadedMessageData, false) ->willReturn(true); - $this->cache->method('get')->willReturn($precached); + $this->adminNotifier->expects($this->once()) + ->method('notifyStart') + ->with($campaign, $loadedMessageData, 1); - $subscriber = $this->createMock(Subscriber::class); - $subscriber->method('getEmail')->willReturn('test@example.com'); - $subscriber->method('getId')->willReturn(1); + $this->exclusionService->expects($this->once()) + ->method('resolveExcludeListIds') + ->with($loadedMessageData) + ->willReturn([5]); + $this->exclusionService->expects($this->once()) + ->method('markExcludedSubscribers') + ->with($campaign, $data, [5]); + $subscribers = ['a subscriber']; $this->subscriberProvider->expects($this->once()) ->method('getSubscribersForMessageOrLists') - ->with($data, $campaign) - ->willReturn([$subscriber]); - - $this->messagePreparator->expects($this->once()) - ->method('processMessageLinks') - ->with(1, $precached, $subscriber) - ->willReturn($precached); - - // campaign emails are built via campaignEmailBuilder and sent via RateLimitedCampaignMailer - $campaignEmailBuilder = (new ReflectionClass($this->handler)) - ->getProperty('campaignEmailBuilder'); - /** @var EmailBuilder|MockObject $campaignBuilderMock */ - $campaignBuilderMock = $campaignEmailBuilder->getValue($this->handler); - - $campaignBuilderMock->expects($this->once()) - ->method('buildCampaignEmail') - ->willReturn([ - (new Email()) - ->from('news@example.com') - ->to('test@example.com') - ->subject('Test Subject') - ->text('Test text message') - ->html('

Test HTML message

'), - OutputFormat::Html - ]); - - $this->mailer->expects($this->any())->method('send'); - - $metadata->expects($this->atLeastOnce()) - ->method('setStatus'); - - $this->entityManager->expects($this->atLeastOnce()) - ->method('flush'); + ->with($data, $campaign, [5]) + ->willReturn($subscribers); + + $this->sendingLoop->expects($this->once()) + ->method('run') + ->with($campaign, $subscribers, $this->stringContains((string) $campaign->getId())) + ->willReturn(false); + + $this->requeueHandler->expects($this->never())->method('handle'); + + $this->messageStatusUpdater->expects($this->exactly(2)) + ->method('update') + ->willReturnCallback(function (Message $m, MessageStatus $status) use ($campaign) { + $this->assertSame($campaign, $m); + static $calls = 0; + $calls++; + $expected = $calls === 1 ? MessageStatus::InProcess : MessageStatus::Sent; + $this->assertSame($expected, $status); + }); ($this->handler)($data); } - public function testInvokeWithMailerException(): void - { - $campaign = $this->createMock(Message::class); - $precached = new MessagePrecacheDto(); - $precached->subject = 'Test Subject'; - $precached->content = '

Test HTML message

'; - $precached->textContent = 'Test text message'; - $precached->footer = 'Test footer message'; - $metadata = $this->createMock(MessageMetadata::class); - $campaign->method('getContent')->willReturn($this->createContentMock()); - $campaign->method('getMetadata')->willReturn($metadata); - $campaign->method('getId')->willReturn(123); - $data = new CampaignProcessorMessage(123); - - $this->messageRepository->method('tryClaimForProcessing') - ->with(123, 0) - ->willReturn($campaign); - - $this->precacheService->expects($this->once()) - ->method('precacheMessage') - ->with($campaign, $this->anything()) - ->willReturn(true); - - $this->cache->method('get')->willReturn($precached); - - $subscriber = $this->createMock(Subscriber::class); - $subscriber->method('getEmail')->willReturn('test@example.com'); - $subscriber->method('getId')->willReturn(1); - - $this->subscriberProvider->expects($this->once()) - ->method('getSubscribersForMessageOrLists') - ->with($data, $campaign) - ->willReturn([$subscriber]); - - $this->messagePreparator->expects($this->once()) - ->method('processMessageLinks') - ->with(123, $precached, $subscriber) - ->willReturn($precached); - - // Build email and throw on rate-limited sender - $campaignEmailBuilder = (new ReflectionClass($this->handler)) - ->getProperty('campaignEmailBuilder'); - - /** @var EmailBuilder|MockObject $campaignBuilderMock */ - $campaignBuilderMock = $campaignEmailBuilder->getValue($this->handler); - $campaignBuilderMock->expects($this->once()) - ->method('buildCampaignEmail') - ->willReturn([ - (new Email())->to('test@example.com')->subject('Test Subject')->text('x'), - OutputFormat::Text - ]); - - $exception = new Exception('Test exception'); - $this->mailer->expects($this->once()) - ->method('send') - ->willThrowException($exception); - - $this->logger->expects($this->once()) - ->method('error') - ->with('Test exception', [ - 'subscriber_id' => 1, - 'campaign_id' => 123, - ]); - - $metadata->expects($this->atLeastOnce()) - ->method('setStatus'); - - $this->entityManager->expects($this->atLeastOnce()) - ->method('flush'); - - ($this->handler)($data); - } - - public function testInvokeWithMultipleSubscribers(): void + public function testInvokeRequeuesAndSkipsSentStatusWhenStoppedEarlyAndRequeued(): void { $campaign = $this->createCampaignMock(); - - $precached = new MessagePrecacheDto(); - $precached->subject = 'Test Subject'; - $precached->content = '

Test HTML message

'; - $precached->textContent = 'Test text message'; - $precached->footer = 'Test footer message'; - - $metadata = $this->createMock(MessageMetadata::class); - - $campaign->method('getMetadata')->willReturn($metadata); - $campaign->method('getId')->willReturn(1); - $data = new CampaignProcessorMessage(1); - $this->messageRepository - ->method('tryClaimForProcessing') - ->with(1, 0) - ->willReturn($campaign); + $this->messageRepository->method('tryClaimForProcessing')->willReturn($campaign); + $this->messageDataLoader->method('__invoke')->willReturn([]); + $this->precacheService->method('precacheMessage')->willReturn(true); + $this->exclusionService->method('resolveExcludeListIds')->willReturn([]); + $this->subscriberProvider->method('getSubscribersForMessageOrLists')->willReturn([]); - $this->precacheService - ->expects($this->once()) - ->method('precacheMessage') - ->with($campaign, $this->anything()) + $this->sendingLoop->expects($this->once()) + ->method('run') ->willReturn(true); - $this->cache - ->method('get') - ->willReturn($precached); - - $subscriber1 = $this->createMock(Subscriber::class); - $subscriber1->method('getEmail')->willReturn('test1@example.com'); - $subscriber1->method('getId')->willReturn(1); - - $subscriber2 = $this->createMock(Subscriber::class); - $subscriber2->method('getEmail')->willReturn('test2@example.com'); - $subscriber2->method('getId')->willReturn(2); + $this->requeueHandler->expects($this->once()) + ->method('handle') + ->with($campaign) + ->willReturn(true); - $subscriber3 = $this->createMock(Subscriber::class); - $subscriber3->method('getEmail')->willReturn('invalid-email'); - $subscriber3->method('getId')->willReturn(3); + $this->entityManager->expects($this->once())->method('flush'); - $this->subscriberProvider - ->expects($this->once()) - ->method('getSubscribersForMessageOrLists') - ->with($data, $campaign) - ->willReturn([ - $subscriber1, - $subscriber2, - $subscriber3, - ]); - - $processMessageLinksCalls = []; - - $this->messagePreparator - ->expects($this->exactly(2)) - ->method('processMessageLinks') - ->willReturnCallback( - function ( - int $campaignId, - MessagePrecacheDto $dto, - Subscriber $subscriber - ) use ( - &$processMessageLinksCalls, - $precached - ): MessagePrecacheDto { - $processMessageLinksCalls[] = [ - $campaignId, - $dto, - $subscriber, - ]; - - return $precached; - } - ); - - $campaignEmailBuilder = (new ReflectionClass($this->handler)) - ->getProperty('campaignEmailBuilder'); - - /** @var EmailBuilder|MockObject $campaignBuilderMock */ - $campaignBuilderMock = $campaignEmailBuilder->getValue($this->handler); - - $buildCampaignEmailCalls = []; - - $campaignBuilderMock - ->expects($this->exactly(2)) - ->method('buildCampaignEmail') - ->willReturnCallback( - function () use (&$buildCampaignEmailCalls): array { - $buildCampaignEmailCalls[] = func_get_args(); - - static $emails = [ - 'test1@example.com', - 'test2@example.com', - ]; - - $email = array_shift($emails); - - return [ - (new Email()) - ->to($email) - ->subject('Test Subject') - ->text('x'), - OutputFormat::Text, - ]; - } - ); - - $this->mailer - ->expects($this->exactly(2)) - ->method('send'); - - $metadata->expects($this->atLeastOnce()) - ->method('setStatus'); - - $this->entityManager - ->expects($this->atLeastOnce()) - ->method('flush'); + $this->messageStatusUpdater->expects($this->once()) + ->method('update') + ->with($campaign, MessageStatus::InProcess); ($this->handler)($data); - - $this->assertSame( - [ - [1, $precached, $subscriber1], - [1, $precached, $subscriber2], - ], - $processMessageLinksCalls, - ); - - $this->assertCount(2, $buildCampaignEmailCalls); } - public function testInvokeSkipsDomainThrottledSubscriberWithoutCreatingUserMessage(): void + public function testInvokeMarksSentWhenStoppedEarlyButRequeueDeclines(): void { $campaign = $this->createCampaignMock(); - $metadata = $this->createMock(MessageMetadata::class); - $campaign->method('getMetadata')->willReturn($metadata); - $campaign->method('getId')->willReturn(1); $data = new CampaignProcessorMessage(1); - $this->messageRepository->method('tryClaimForProcessing') - ->with(1, 0) - ->willReturn($campaign); + $this->messageRepository->method('tryClaimForProcessing')->willReturn($campaign); + $this->messageDataLoader->method('__invoke')->willReturn([]); + $this->precacheService->method('precacheMessage')->willReturn(true); + $this->exclusionService->method('resolveExcludeListIds')->willReturn([]); + $this->subscriberProvider->method('getSubscribersForMessageOrLists')->willReturn([]); - $this->precacheService->expects($this->once()) - ->method('precacheMessage') - ->with($campaign, $this->anything()) + $this->sendingLoop->expects($this->once()) + ->method('run') ->willReturn(true); - $throttledSubscriber = $this->createMock(Subscriber::class); - $throttledSubscriber->method('getEmail')->willReturn('throttled@example.com'); - - $this->subscriberProvider->expects($this->once()) - ->method('getSubscribersForMessageOrLists') - ->willReturn([$throttledSubscriber]); - - $this->domainRateLimiter = $this->createMock(DomainRateLimiter::class); - $this->domainRateLimiter->method('attemptSend') - ->willReturn(new DomainThrottleResult(allowed: false, domain: 'example.com', blockedAttempts: 1)); - $handler = $this->createHandler(); - - $this->userMessageRepository->expects($this->never()) - ->method('save'); - $this->requeueHandler->expects($this->once()) ->method('handle') ->with($campaign) - ->willReturn(true); + ->willReturn(false); - $metadata->expects($this->atLeastOnce()) - ->method('setStatus'); + $this->messageStatusUpdater->expects($this->exactly(2)) + ->method('update') + ->willReturnCallback(function (Message $m, MessageStatus $status) { + static $calls = 0; + $calls++; + $expected = $calls === 1 ? MessageStatus::InProcess : MessageStatus::Sent; + $this->assertSame($expected, $status); + }); - $handler($data); + ($this->handler)($data); } - /** - * Creates a mock for the Message class with content - */ private function createCampaignMock(): Message|MockObject { $campaign = $this->createMock(Message::class); - $content = $this->createContentMock(); - $campaign->method('getContent')->willReturn($content); - - return $campaign; - } - - private function createContentMock(): MessageContent|MockObject - { - $content = $this->createMock(MessageContent::class); - - $content->method('getSubject')->willReturn('Test Subject'); - $content->method('getTextMessage')->willReturn('Test text message'); - $content->method('getText')->willReturn('

Test HTML message

'); + $campaign->method('getId')->willReturn(1); + $metadata = $this->createMock(MessageMetadata::class); + $campaign->method('getMetadata')->willReturn($metadata); - return $content; + return $campaign; } } diff --git a/tests/Unit/Domain/Messaging/Service/CampaignAdminNotifierTest.php b/tests/Unit/Domain/Messaging/Service/CampaignAdminNotifierTest.php new file mode 100644 index 00000000..b982bc37 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/CampaignAdminNotifierTest.php @@ -0,0 +1,85 @@ +notificationMailer = $this->createMock(SystemNotificationMailer::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->translator = $this->createMock(Translator::class); + $this->translator->method('trans')->willReturnCallback(fn (string $msg) => $msg); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->notifier = new CampaignAdminNotifier( + $this->notificationMailer, + $this->entityManager, + $this->translator, + $this->logger, + ); + } + + public function testNotifyStartSendsToEachConfiguredAddressAndRecordsStartNotified(): void + { + $campaign = $this->createMock(Message::class); + $campaign->method('getId')->willReturn(1); + + $this->notificationMailer->expects($this->exactly(2)) + ->method('send') + ->willReturnCallback(function (int $messageId, string $toEmail) { + $this->assertSame(1, $messageId); + $this->assertContains($toEmail, ['admin1@example.com', 'admin2@example.com']); + + return true; + }); + + $this->entityManager->expects($this->once())->method('persist'); + $this->entityManager->expects($this->once())->method('flush'); + + $this->notifier->notifyStart($campaign, [ + 'notify_start' => 'admin1@example.com,admin2@example.com', + 'subject' => 'Hello', + ], 1); + } + + public function testNotifyStartDoesNothingWhenNotifyStartMissing(): void + { + $campaign = $this->createMock(Message::class); + + $this->notificationMailer->expects($this->never())->method('send'); + $this->entityManager->expects($this->never())->method('persist'); + + $this->notifier->notifyStart($campaign, [], 1); + } + + public function testNotifyStartDoesNothingWhenAlreadyNotified(): void + { + $campaign = $this->createMock(Message::class); + + $this->notificationMailer->expects($this->never())->method('send'); + + $this->notifier->notifyStart($campaign, [ + 'notify_start' => 'admin1@example.com', + 'start_notified' => '2024-01-01 00:00:00', + ], 1); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/CampaignEmailSenderTest.php b/tests/Unit/Domain/Messaging/Service/CampaignEmailSenderTest.php new file mode 100644 index 00000000..34895478 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/CampaignEmailSenderTest.php @@ -0,0 +1,210 @@ +campaignEmailBuilder = $this->createMock(EmailBuilder::class); + $this->rateLimitedCampaignMailer = $this->createMock(RateLimitedCampaignMailer::class); + $this->mailSizeChecker = $this->createMock(MailSizeChecker::class); + $this->messagePreparator = $this->createMock(MessageProcessingPreparator::class); + $this->messageRepository = $this->createMock(MessageRepository::class); + $this->messageStatusUpdater = $this->createMock(MessageStatusUpdater::class); + $this->notificationMailer = $this->createMock(SystemNotificationMailer::class); + $this->subscriberHistoryManager = $this->createMock(SubscriberHistoryManager::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->configProvider = $this->createMock(ConfigProvider::class); + $this->translator = $this->createMock(Translator::class); + $this->translator->method('trans')->willReturnCallback(fn (string $msg) => $msg); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->sender = new CampaignEmailSender( + $this->campaignEmailBuilder, + $this->rateLimitedCampaignMailer, + $this->mailSizeChecker, + $this->messagePreparator, + $this->messageRepository, + $this->messageStatusUpdater, + $this->notificationMailer, + $this->subscriberHistoryManager, + $this->entityManager, + $this->configProvider, + $this->translator, + $this->logger, + ); + + $this->precached = new MessagePrecacheDto(); + $this->campaign = $this->createMock(Message::class); + $this->campaign->method('getId')->willReturn(123); + + $this->subscriber = $this->createMock(Subscriber::class); + $this->subscriber->method('getId')->willReturn(1); + $this->subscriber->method('getEmail')->willReturn('test@example.com'); + + $this->userMessage = $this->createMock(UserMessage::class); + + $this->messagePreparator->method('processMessageLinks')->willReturn($this->precached); + } + + public function testSendMarksSentAndIncrementsCountsOnSuccess(): void + { + $email = (new Email())->to('test@example.com')->subject('s')->text('x'); + + $this->campaignEmailBuilder->method('buildCampaignEmail')->willReturn([$email, OutputFormat::Text]); + + $this->rateLimitedCampaignMailer->expects($this->once())->method('send')->with($email); + $this->messageRepository->expects($this->once()) + ->method('incrementSentCounts') + ->with(123, OutputFormat::Text); + + $this->userMessage->expects($this->once())->method('setStatus')->with(UserMessageStatus::Sent); + + $this->sender->send($this->campaign, $this->subscriber, $this->userMessage, $this->precached); + } + + public function testSendMarksExcludedWhenBuilderReturnsNullAndSubscriberBlacklisted(): void + { + $this->subscriber->method('isBlacklisted')->willReturn(true); + $this->campaignEmailBuilder->method('buildCampaignEmail')->willReturn(null); + + $this->userMessage->expects($this->once())->method('setStatus')->with(UserMessageStatus::Excluded); + $this->rateLimitedCampaignMailer->expects($this->never())->method('send'); + + $this->sender->send($this->campaign, $this->subscriber, $this->userMessage, $this->precached); + } + + public function testSendMarksNotSentWhenBuilderReturnsNullAndSubscriberNotBlacklisted(): void + { + $this->subscriber->method('isBlacklisted')->willReturn(false); + $this->campaignEmailBuilder->method('buildCampaignEmail')->willReturn(null); + + $this->userMessage->expects($this->once())->method('setStatus')->with(UserMessageStatus::NotSent); + + $this->sender->send($this->campaign, $this->subscriber, $this->userMessage, $this->precached); + } + + public function testSendSuspendsCampaignAndRethrowsOnSizeLimitExceeded(): void + { + $email = (new Email())->to('test@example.com')->subject('s')->text('x'); + $this->campaignEmailBuilder->method('buildCampaignEmail')->willReturn([$email, OutputFormat::Text]); + + $exception = new MessageSizeLimitExceededException(2000000, 1000000); + $this->rateLimitedCampaignMailer->method('send')->willThrowException($exception); + + $this->messageStatusUpdater->expects($this->once()) + ->method('update') + ->with($this->campaign, MessageStatus::Suspended); + + $this->userMessage->expects($this->once())->method('setStatus')->with(UserMessageStatus::Sent); + + $this->expectException(MessageSizeLimitExceededException::class); + + $this->sender->send($this->campaign, $this->subscriber, $this->userMessage, $this->precached); + } + + public function testSendSuspendsCampaignNotifiesAdminsAndRethrowsOnAttachmentCopyFailure(): void + { + $email = (new Email())->to('test@example.com')->subject('s')->text('x'); + $this->campaignEmailBuilder->method('buildCampaignEmail')->willReturn([$email, OutputFormat::Text]); + + $exception = new AttachmentCopyException('copy failed'); + $this->rateLimitedCampaignMailer->method('send')->willThrowException($exception); + + $this->configProvider->method('getValue')->willReturn('report@example.com'); + + $this->messageStatusUpdater->expects($this->once()) + ->method('update') + ->with($this->campaign, MessageStatus::Suspended); + + $this->userMessage->expects($this->once())->method('setStatus')->with(UserMessageStatus::NotSent); + + $this->notificationMailer->expects($this->once()) + ->method('send') + ->with(123, 'report@example.com', 'phplist system error', 'copy failed'); + + $this->expectException(AttachmentCopyException::class); + + $this->sender->send($this->campaign, $this->subscriber, $this->userMessage, $this->precached); + } + + public function testSendMarksNotSentAndLogsOnGenericFailureWithoutRethrowing(): void + { + $email = (new Email())->to('test@example.com')->subject('s')->text('x'); + $this->campaignEmailBuilder->method('buildCampaignEmail')->willReturn([$email, OutputFormat::Text]); + + $exception = new Exception('boom'); + $this->rateLimitedCampaignMailer->method('send')->willThrowException($exception); + + $this->userMessage->expects($this->once())->method('setStatus')->with(UserMessageStatus::NotSent); + + $this->logger->expects($this->once()) + ->method('error') + ->with('boom', ['subscriber_id' => 1, 'campaign_id' => 123]); + + $this->sender->send($this->campaign, $this->subscriber, $this->userMessage, $this->precached); + } + + public function testHandleInvalidEmailMarksStatusUnconfirmsAndRecordsHistory(): void + { + $this->subscriber->method('isConfirmed')->willReturn(true); + + $this->userMessage->expects($this->once()) + ->method('setStatus') + ->with(UserMessageStatus::InvalidEmailAddress); + + $this->subscriber->expects($this->once())->method('setConfirmed')->with(false); + $this->subscriberHistoryManager->expects($this->once())->method('addHistory'); + + $this->sender->handleInvalidEmail($this->userMessage, $this->subscriber, $this->campaign); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/CampaignExclusionServiceTest.php b/tests/Unit/Domain/Messaging/Service/CampaignExclusionServiceTest.php new file mode 100644 index 00000000..f7014fe6 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/CampaignExclusionServiceTest.php @@ -0,0 +1,147 @@ +subscriberProvider = $this->createMock(SubscriberProvider::class); + $this->userMessageRepository = $this->createMock(UserMessageRepository::class); + } + + private function createService(bool $useListExclude): CampaignExclusionService + { + return new CampaignExclusionService( + $this->subscriberProvider, + $this->userMessageRepository, + $useListExclude, + ); + } + + public function testResolveExcludeListIdsReturnsEmptyWhenDisabled(): void + { + $service = $this->createService(useListExclude: false); + + $this->assertSame([], $service->resolveExcludeListIds(['excludelist' => [55 => 1, 66 => 1]])); + } + + public function testResolveExcludeListIdsReturnsNumericKeysWhenEnabled(): void + { + $service = $this->createService(useListExclude: true); + + $this->assertSame([55, 66], $service->resolveExcludeListIds(['excludelist' => [55 => 1, 66 => 1]])); + } + + public function testResolveExcludeListIdsReturnsEmptyWhenNoExcludeListPresent(): void + { + $service = $this->createService(useListExclude: true); + + $this->assertSame([], $service->resolveExcludeListIds([])); + } + + public function testMarkExcludedSubscribersMarksSendableRecipientAsExcluded(): void + { + $service = $this->createService(useListExclude: true); + + $campaign = $this->createMock(Message::class); + $data = new CampaignProcessorMessage(1); + + $excludedSubscriber = $this->createMock(Subscriber::class); + $excludedSubscriber->method('getEmail')->willReturn('excluded@example.com'); + + $this->subscriberProvider->expects($this->once()) + ->method('getExcludedSubscribers') + ->with([55]) + ->willReturn([$excludedSubscriber]); + + $this->subscriberProvider->expects($this->once()) + ->method('getSendableSubscribersForMessageOrLists') + ->with($data, $campaign) + ->willReturn(['excluded@example.com' => $excludedSubscriber]); + + $this->userMessageRepository->expects($this->once()) + ->method('findByUserAndMessage') + ->with($excludedSubscriber, $campaign) + ->willReturn(null); + + $this->userMessageRepository->expects($this->once()) + ->method('save') + ->with($this->callback( + fn (UserMessage $userMessage): bool => $userMessage->getUser() === $excludedSubscriber + && $userMessage->getStatus() === UserMessageStatus::Excluded + )); + + $service->markExcludedSubscribers($campaign, $data, [55]); + } + + public function testMarkExcludedSubscribersDoesNothingWhenExcludeListIdsEmpty(): void + { + $service = $this->createService(useListExclude: true); + + $this->subscriberProvider->expects($this->never())->method('getExcludedSubscribers'); + + $service->markExcludedSubscribers($this->createMock(Message::class), new CampaignProcessorMessage(1), []); + } + + public function testMarkExcludedSubscribersDoesNotOverwriteExistingNonTodoUserMessage(): void + { + $service = $this->createService(useListExclude: true); + + $campaign = $this->createMock(Message::class); + $data = new CampaignProcessorMessage(1); + + $excludedSubscriber = $this->createMock(Subscriber::class); + $excludedSubscriber->method('getEmail')->willReturn('already-sent@example.com'); + + $this->subscriberProvider->method('getExcludedSubscribers')->willReturn([$excludedSubscriber]); + $this->subscriberProvider->method('getSendableSubscribersForMessageOrLists') + ->willReturn(['already-sent@example.com' => $excludedSubscriber]); + + $existingUserMessage = $this->createMock(UserMessage::class); + $existingUserMessage->method('getStatus')->willReturn(UserMessageStatus::Sent); + + $this->userMessageRepository->expects($this->once()) + ->method('findByUserAndMessage') + ->willReturn($existingUserMessage); + + $this->userMessageRepository->expects($this->never())->method('save'); + + $service->markExcludedSubscribers($campaign, $data, [55]); + } + + public function testMarkExcludedSubscribersSkipsSubscriberWhoIsNotACampaignRecipient(): void + { + $service = $this->createService(useListExclude: true); + + $campaign = $this->createMock(Message::class); + $data = new CampaignProcessorMessage(1); + + $nonRecipient = $this->createMock(Subscriber::class); + $nonRecipient->method('getEmail')->willReturn('not-a-recipient@example.com'); + + $this->subscriberProvider->method('getExcludedSubscribers')->willReturn([$nonRecipient]); + $this->subscriberProvider->method('getSendableSubscribersForMessageOrLists')->willReturn([]); + + $this->userMessageRepository->expects($this->never())->method('findByUserAndMessage'); + $this->userMessageRepository->expects($this->never())->method('save'); + + $service->markExcludedSubscribers($campaign, $data, [55]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/CampaignSendingLoopTest.php b/tests/Unit/Domain/Messaging/Service/CampaignSendingLoopTest.php new file mode 100644 index 00000000..a6661a52 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/CampaignSendingLoopTest.php @@ -0,0 +1,156 @@ +userMessageRepository = $this->createMock(UserMessageRepository::class); + $this->timeLimiter = $this->createMock(MaxProcessTimeLimiter::class); + $this->domainRateLimiter = $this->createMock(DomainRateLimiter::class); + $this->cache = $this->createMock(CacheInterface::class); + $this->emailSender = $this->createMock(CampaignEmailSender::class); + + $this->loop = new CampaignSendingLoop( + $this->userMessageRepository, + $this->timeLimiter, + $this->domainRateLimiter, + $this->cache, + $this->emailSender, + ); + + $this->campaign = $this->createMock(Message::class); + } + + public function testRunReturnsFalseAndSendsToEachEligibleSubscriber(): void + { + $this->timeLimiter->method('shouldStop')->willReturn(false); + $this->domainRateLimiter->method('attemptSend') + ->willReturn(new DomainThrottleResult(allowed: true, domain: null)); + + $precached = new MessagePrecacheDto(); + $this->cache->method('get')->willReturn($precached); + + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getEmail')->willReturn('test@example.com'); + + $this->userMessageRepository->method('findByUserAndMessage')->willReturn(null); + + $this->emailSender->expects($this->once()) + ->method('send') + ->with($this->campaign, $subscriber, $this->isInstanceOf(UserMessage::class), $precached); + + $stoppedEarly = $this->loop->run($this->campaign, [$subscriber], 'cache-key'); + + $this->assertFalse($stoppedEarly); + } + + public function testRunStopsEarlyWhenTimeLimitReached(): void + { + $this->timeLimiter->method('shouldStop')->willReturn(true); + + $subscriber = $this->createMock(Subscriber::class); + $this->emailSender->expects($this->never())->method('send'); + + $stoppedEarly = $this->loop->run($this->campaign, [$subscriber], 'cache-key'); + + $this->assertTrue($stoppedEarly); + } + + public function testRunStopsEarlyAndLeavesNoUserMessageWhenDomainThrottled(): void + { + $this->domainRateLimiter->method('attemptSend') + ->willReturn(new DomainThrottleResult(allowed: false, domain: 'example.com', blockedAttempts: 1)); + + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getEmail')->willReturn('throttled@example.com'); + + $this->userMessageRepository->expects($this->never())->method('save'); + $this->emailSender->expects($this->never())->method('send'); + + $stoppedEarly = $this->loop->run($this->campaign, [$subscriber], 'cache-key'); + + $this->assertTrue($stoppedEarly); + } + + public function testRunSkipsSubscriberWithExistingNonTodoUserMessage(): void + { + $this->timeLimiter->method('shouldStop')->willReturn(false); + + $subscriber = $this->createMock(Subscriber::class); + + $existing = $this->createMock(UserMessage::class); + $existing->method('getStatus')->willReturn(UserMessageStatus::Sent); + $this->userMessageRepository->method('findByUserAndMessage')->willReturn($existing); + + $this->userMessageRepository->expects($this->never())->method('save'); + $this->emailSender->expects($this->never())->method('send'); + + $stoppedEarly = $this->loop->run($this->campaign, [$subscriber], 'cache-key'); + + $this->assertFalse($stoppedEarly); + } + + public function testRunDelegatesInvalidEmailToEmailSender(): void + { + $this->timeLimiter->method('shouldStop')->willReturn(false); + $this->domainRateLimiter->method('attemptSend') + ->willReturn(new DomainThrottleResult(allowed: true, domain: null)); + + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getEmail')->willReturn('not-an-email'); + + $this->userMessageRepository->method('findByUserAndMessage')->willReturn(null); + + $this->emailSender->expects($this->once()) + ->method('handleInvalidEmail') + ->with($this->isInstanceOf(UserMessage::class), $subscriber, $this->campaign); + $this->emailSender->expects($this->never())->method('send'); + + $this->loop->run($this->campaign, [$subscriber], 'cache-key'); + } + + public function testRunThrowsWhenPrecachedMessageMissingFromCache(): void + { + $this->timeLimiter->method('shouldStop')->willReturn(false); + $this->domainRateLimiter->method('attemptSend') + ->willReturn(new DomainThrottleResult(allowed: true, domain: null)); + + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getEmail')->willReturn('test@example.com'); + + $this->userMessageRepository->method('findByUserAndMessage')->willReturn(null); + $this->cache->method('get')->willReturn(null); + + $this->expectException(MessageCacheMissingException::class); + + $this->loop->run($this->campaign, [$subscriber], 'cache-key'); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/MessageStatusUpdaterTest.php b/tests/Unit/Domain/Messaging/Service/MessageStatusUpdaterTest.php new file mode 100644 index 00000000..38b4a43c --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/MessageStatusUpdaterTest.php @@ -0,0 +1,78 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->updater = new MessageStatusUpdater($this->entityManager); + } + + public function testUpdateSetsSendStartOnlyOnceWhenTransitioningToInProcess(): void + { + $metadata = $this->createMock(MessageMetadata::class); + $metadata->method('getSendStart')->willReturn(null); + $metadata->expects($this->once())->method('setSendStart'); + $metadata->expects($this->once())->method('setStatus')->with(MessageStatus::InProcess); + + $message = $this->createMock(Message::class); + $message->method('getMetadata')->willReturn($metadata); + + $this->entityManager->expects($this->once())->method('flush'); + + $this->updater->update($message, MessageStatus::InProcess); + } + + public function testUpdateDoesNotOverwriteExistingSendStart(): void + { + $metadata = $this->createMock(MessageMetadata::class); + $metadata->method('getSendStart')->willReturn(new DateTime()); + $metadata->expects($this->never())->method('setSendStart'); + + $message = $this->createMock(Message::class); + $message->method('getMetadata')->willReturn($metadata); + + $this->updater->update($message, MessageStatus::InProcess); + } + + public function testUpdateSetsSentTimestampWhenTransitioningToSent(): void + { + $metadata = $this->createMock(MessageMetadata::class); + $metadata->expects($this->once())->method('setSent'); + $metadata->expects($this->once())->method('setStatus')->with(MessageStatus::Sent); + + $message = $this->createMock(Message::class); + $message->method('getMetadata')->willReturn($metadata); + + $this->updater->update($message, MessageStatus::Sent); + } + + public function testUpdateDoesNotTouchTimestampsForOtherStatuses(): void + { + $metadata = $this->createMock(MessageMetadata::class); + $metadata->expects($this->never())->method('setSendStart'); + $metadata->expects($this->never())->method('setSent'); + $metadata->expects($this->once())->method('setStatus')->with(MessageStatus::Suspended); + + $message = $this->createMock(Message::class); + $message->method('getMetadata')->willReturn($metadata); + + $this->updater->update($message, MessageStatus::Suspended); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/SystemNotificationMailerTest.php b/tests/Unit/Domain/Messaging/Service/SystemNotificationMailerTest.php new file mode 100644 index 00000000..38acc54c --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/SystemNotificationMailerTest.php @@ -0,0 +1,68 @@ +systemEmailBuilder = $this->createMock(SystemEmailBuilder::class); + $this->mailer = $this->createMock(MailerInterface::class); + $this->notificationMailer = new SystemNotificationMailer( + $this->systemEmailBuilder, + $this->mailer, + 'bounce@example.com', + ); + } + + public function testSendBuildsAndSendsEmailWithBounceEnvelope(): void + { + $email = (new Email())->to('admin@example.com')->subject('Campaign started')->text('body'); + + $this->systemEmailBuilder->expects($this->once()) + ->method('buildCampaignEmail') + ->with(1, $this->anything(), 'admin@example.com') + ->willReturn($email); + + $this->mailer->expects($this->once()) + ->method('send') + ->with( + $this->identicalTo($email), + $this->callback(function (Envelope $envelope): bool { + $this->assertSame('bounce@example.com', $envelope->getSender()->getAddress()); + $this->assertSame('admin@example.com', $envelope->getRecipients()[0]->getAddress()); + + return true; + }) + ); + + $result = $this->notificationMailer->send(1, 'admin@example.com', 'Campaign started', 'body'); + + $this->assertTrue($result); + } + + public function testSendReturnsFalseWithoutSendingWhenBuilderReturnsNull(): void + { + $this->systemEmailBuilder->method('buildCampaignEmail')->willReturn(null); + + $this->mailer->expects($this->never())->method('send'); + + $result = $this->notificationMailer->send(1, 'admin@example.com', 'Campaign started', 'body'); + + $this->assertFalse($result); + } +} From 3c660a5ff8d363bba7b8497703c266f367dbf91c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 10 Sep 2026 10:48:16 +0400 Subject: [PATCH 2/9] ref: rename CampaignProcessor message handler classes and update namespaces --- config/services/messenger.yml | 4 ++-- .../CampaignProcessorMessageHandler.php | 2 +- ...ageHandler.php => CampaignProcessorTestMessageHandler.php} | 4 ++-- .../MessageHandler/CampaignProcessorMessageHandlerTest.php | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) rename src/Domain/Messaging/MessageHandler/{CampaignProcessor => }/CampaignProcessorMessageHandler.php (98%) rename src/Domain/Messaging/MessageHandler/{CampaignProcessor/TestCampaignProcessorMessageHandler.php => CampaignProcessorTestMessageHandler.php} (98%) diff --git a/config/services/messenger.yml b/config/services/messenger.yml index 16e02ff4..40d70b93 100644 --- a/config/services/messenger.yml +++ b/config/services/messenger.yml @@ -17,10 +17,10 @@ services: resource: '../../src/Domain/Search/MessageHandler' tags: [ 'messenger.message_handler' ] - PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor\CampaignProcessorMessageHandler: + PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor\TestCampaignProcessorMessageHandler: + PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorTestMessageHandler: autowire: true autoconfigure: true diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php similarity index 98% rename from src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php rename to src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php index 386396b8..f1b31189 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor; +namespace PhpList\Core\Domain\Messaging\MessageHandler; use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Messaging\Message\CampaignProcessor\CampaignProcessorMessage; diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessor/TestCampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessorTestMessageHandler.php similarity index 98% rename from src/Domain/Messaging/MessageHandler/CampaignProcessor/TestCampaignProcessorMessageHandler.php rename to src/Domain/Messaging/MessageHandler/CampaignProcessorTestMessageHandler.php index 95b0a107..e8ad3e4f 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessor/TestCampaignProcessorMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessorTestMessageHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor; +namespace PhpList\Core\Domain\Messaging\MessageHandler; use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; @@ -36,7 +36,7 @@ * @SuppressWarnings("PHPMD.ExcessiveParameterList") */ #[AsMessageHandler] -class TestCampaignProcessorMessageHandler +class CampaignProcessorTestMessageHandler { public function __construct( private readonly MailerInterface $mailer, diff --git a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php index 1a2fb56b..117fa917 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php @@ -6,7 +6,7 @@ use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Messaging\Message\CampaignProcessor\CampaignProcessorMessage; -use PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor\CampaignProcessorMessageHandler; +use PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata; use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; From 0f9cab9434bc579fa69e462cd85064d52ee00439 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 10 Sep 2026 11:28:08 +0400 Subject: [PATCH 3/9] ref: standardize configuration parameter naming conventions --- .env.dist | 2 +- config/config.yml | 2 +- config/config_dev.yml | 4 +-- config/config_prod.yml | 4 +-- config/config_test.yml | 12 ++++---- config/doctrine.yml | 14 +++++----- config/parameters.yml | 28 +++++++++---------- config/services.yml | 2 +- config/services/managers.yml | 4 +-- config/services/repositories.yml | 4 +-- .../Service/Manager/ConfigManager.php | 2 +- .../Service/Manager/SubscribePageManager.php | 2 +- 12 files changed, 40 insertions(+), 40 deletions(-) diff --git a/.env.dist b/.env.dist index a9738072..07a41b6a 100644 --- a/.env.dist +++ b/.env.dist @@ -26,7 +26,7 @@ PREFERENCEPAGE_SHOW_PRIVATE_LISTS=0 API_BASE_URL=http://api.phplist.local/ FRONT_END_BASE_URL=http://frontend.phplist.local -PARALLER_USE_WITH_PHPLIST3=0 +PARALLEL_USE_WITH_PHPLIST3=0 # Email configuration MAILER_FROM=noreply@phplist.com diff --git a/config/config.yml b/config/config.yml index 7de6dca6..332b3eb3 100644 --- a/config/config.yml +++ b/config/config.yml @@ -14,7 +14,7 @@ framework: default_path: '%kernel.project_dir%/resources/translations' fallbacks: ['%locale%'] - secret: '%secret%' + secret: '%app.secret%' router: resource: '%kernel.project_dir%/config/routing.yml' strict_requirements: ~ diff --git a/config/config_dev.yml b/config/config_dev.yml index c6cbdfd8..561d1a43 100644 --- a/config/config_dev.yml +++ b/config/config_dev.yml @@ -18,8 +18,8 @@ monolog: # graylog: # type: gelf # publisher: - # hostname: '%app.config.graylog_host%' - # port: '%app.config.graylog_port%' + # hostname: '%graylog.host%' + # port: '%graylog.port%' # level: debug # channels: ['!event'] console: diff --git a/config/config_prod.yml b/config/config_prod.yml index 157dc5cf..c21ec90c 100644 --- a/config/config_prod.yml +++ b/config/config_prod.yml @@ -7,8 +7,8 @@ monolog: # graylog: # type: gelf # publisher: -# hostname: '%graylog_host%' -# port: '%graylog_port%' +# hostname: '%graylog.host%' +# port: '%graylog.port%' # level: error # Local file logging as backup main: diff --git a/config/config_test.yml b/config/config_test.yml index fe97391a..c98f659c 100644 --- a/config/config_test.yml +++ b/config/config_test.yml @@ -15,12 +15,12 @@ doctrine: # in-memory SQLite database instead (no MySQL server needed), set in .env.test.local: # PHPLIST_DATABASE_DRIVER=pdo_sqlite # PHPLIST_DATABASE_PATH=:memory: - driver: '%database_driver%' - path: '%database_path%' - host: '%database_host%' - port: '%database_port%' + driver: '%database.driver%' + path: '%database.path%' + host: '%database.host%' + port: '%database.port%' dbname: 'phplist' - user: '%database_user%' - password: '%database_password%' + user: '%database.user%' + password: '%database.password%' charset: UTF8 diff --git a/config/doctrine.yml b/config/doctrine.yml index caaaec71..4e1cce9b 100644 --- a/config/doctrine.yml +++ b/config/doctrine.yml @@ -3,13 +3,13 @@ doctrine: dbal: # These variables come from parameters.yml. There, the values are read from environment variables # and can also be set directly in the parameters.yml file. - driver: '%database_driver%' - host: '%database_host%' - path: '%database_path%' - port: '%database_port%' - dbname: '%database_name%' - user: '%database_user%' - password: '%database_password%' + driver: '%database.driver%' + host: '%database.host%' + path: '%database.path%' + port: '%database.port%' + dbname: '%database.name%' + user: '%database.user%' + password: '%database.password%' charset: UTF8 use_savepoints: true diff --git a/config/parameters.yml b/config/parameters.yml index 772774d1..28d4ed9c 100644 --- a/config/parameters.yml +++ b/config/parameters.yml @@ -6,25 +6,25 @@ # The environment variables themselves are defined in the ".env" file (see ".env.dist" for the template) # and/or in the actual environment (e.g. Apache host configuration, command line). parameters: - database_driver: '%env(PHPLIST_DATABASE_DRIVER)%' - database_path: '%env(PHPLIST_DATABASE_PATH)%' - database_host: '%env(PHPLIST_DATABASE_HOST)%' - database_port: '%env(PHPLIST_DATABASE_PORT)%' - database_name: '%env(PHPLIST_DATABASE_NAME)%' - database_user: '%env(PHPLIST_DATABASE_USER)%' - database_password: '%env(PHPLIST_DATABASE_PASSWORD)%' - database_prefix: '%env(DATABASE_PREFIX)%' - list_table_prefix: '%env(LIST_TABLE_PREFIX)%' + database.driver: '%env(PHPLIST_DATABASE_DRIVER)%' + database.path: '%env(PHPLIST_DATABASE_PATH)%' + database.host: '%env(PHPLIST_DATABASE_HOST)%' + database.port: '%env(PHPLIST_DATABASE_PORT)%' + database.name: '%env(PHPLIST_DATABASE_NAME)%' + database.user: '%env(PHPLIST_DATABASE_USER)%' + database.password: '%env(PHPLIST_DATABASE_PASSWORD)%' + database.prefix: '%env(DATABASE_PREFIX)%' + database.list_table_prefix: '%env(LIST_TABLE_PREFIX)%' + app.dev_version: '%env(APP_DEV_VERSION)%' app.dev_email: '%env(APP_DEV_EMAIL)%' app.powered_by_phplist: '%env(APP_POWERED_BY_PHPLIST)%' app.preference_page_show_private_lists: '%env(PREFERENCEPAGE_SHOW_PRIVATE_LISTS)%' - app.rest_api_base_url: '%env(API_BASE_URL)%/api/v2' app.api_base_url: '%env(API_BASE_URL)%' app.frontend_base_url: '%env(FRONT_END_BASE_URL)%' - parallel_use_with_phplist3: '%env(PARALLER_USE_WITH_PHPLIST3)%' + app.parallel_use_with_phplist3: '%env(PARALLEL_USE_WITH_PHPLIST3)%' # Email configuration app.mailer_from: '%env(MAILER_FROM)%' @@ -63,11 +63,11 @@ parameters: elasticsearch.purge.subscriber_history_retention: '%env(ELASTICSEARCH_PURGE_SUBSCRIBER_HISTORY_RETENTION)%' # A secret key that's used to generate certain security-related tokens - secret: '%env(PHPLIST_SECRET)%' + app.secret: '%env(PHPLIST_SECRET)%' phplist.verify_ssl: '%env(VERIFY_SSL)%' - graylog_host: 'graylog.phplist.local' - graylog_port: 12201 + graylog.host: 'graylog.phplist.local' + graylog.port: 12201 app.phplist_isp_conf_path: '%env(APP_PHPLIST_ISP_CONF_PATH)%' diff --git a/config/services.yml b/config/services.yml index 768a4ca4..bfb06823 100644 --- a/config/services.yml +++ b/config/services.yml @@ -53,7 +53,7 @@ services: PhpList\Core\Core\Doctrine\TablePrefixListener: arguments: - $tablePrefix: '%database_prefix%' + $tablePrefix: '%database.prefix%' PhpList\Core\Core\Doctrine\SearchIndexDoctrineListener: arguments: diff --git a/config/services/managers.yml b/config/services/managers.yml index 936bf38f..f82cdbb7 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -16,5 +16,5 @@ services: autoconfigure: true public: true arguments: - $dbPrefix: '%database_prefix%' - $dynamicListTablePrefix: '%list_table_prefix%' + $dbPrefix: '%database.prefix%' + $dynamicListTablePrefix: '%database.list_table_prefix%' diff --git a/config/services/repositories.yml b/config/services/repositories.yml index 4cb9d01b..95952280 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -77,8 +77,8 @@ services: PhpList\Core\Domain\Subscription\Repository\DynamicListAttrRepository: autowire: true arguments: - $dbPrefix: '%database_prefix%' - $dynamicListTablePrefix: '%list_table_prefix%' + $dbPrefix: '%database.prefix%' + $dynamicListTablePrefix: '%database.list_table_prefix%' PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: diff --git a/src/Domain/Configuration/Service/Manager/ConfigManager.php b/src/Domain/Configuration/Service/Manager/ConfigManager.php index 58820692..ad283ffc 100644 --- a/src/Domain/Configuration/Service/Manager/ConfigManager.php +++ b/src/Domain/Configuration/Service/Manager/ConfigManager.php @@ -14,7 +14,7 @@ class ConfigManager { public function __construct( private readonly ConfigRepository $configRepository, - #[Autowire('%parallel_use_with_phplist3%')] + #[Autowire('%app.parallel_use_with_phplist3%')] private readonly bool $parallelUseWithPhpList3, ) { } diff --git a/src/Domain/Subscription/Service/Manager/SubscribePageManager.php b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php index 00955649..e9d27925 100644 --- a/src/Domain/Subscription/Service/Manager/SubscribePageManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php @@ -23,7 +23,7 @@ public function __construct( private readonly SubscribePageConfigMigrationService $configMigrationService, private readonly EntityManagerInterface $entityManager, private readonly SubscribePagePlaceholderProcessor $placeholderProcessor, - #[Autowire('%parallel_use_with_phplist3%')] + #[Autowire('%app.parallel_use_with_phplist3%')] private readonly bool $parallelUseWithPhpList3, ) { } From 350db9b676520f99837fae1096098803863eecb0 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 10 Sep 2026 11:42:42 +0400 Subject: [PATCH 4/9] ref: update template ID casting in MessagePrecacheService and enhance output in ProcessQueueCommand --- README.md | 50 +++++++++++-------- .../Messaging/Command/ProcessQueueCommand.php | 2 + .../Service/MessagePrecacheService.php | 2 +- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 1f6a71cd..7c2ed6f7 100755 --- a/README.md +++ b/README.md @@ -117,12 +117,14 @@ If your module provides any Symfony bundles, the bundle class names need to be listed in the `extra` section of the module's `composer.json` like this: ```json -"extra": { - "phplist/core": { - "bundles": [ - "Symfony\\Bundle\\FrameworkBundle\\FrameworkBundle", - "PhpList\\Core\\EmptyStartPageBundle\\PhpListEmptyStartPageBundle" - ] +{ + "extra": { + "phplist/core": { + "bundles": [ + "Symfony\\Bundle\\FrameworkBundle\\FrameworkBundle", + "PhpList\\Core\\EmptyStartPageBundle\\PhpListEmptyStartPageBundle" + ] + } } } ``` @@ -137,12 +139,14 @@ Similarly, if your module provides any routes, those also need to be listed in the `extra` section of the module's `composer.json` like this: ```json -"extra": { - "phplist/core": { - "routes": { - "homepage": { - "resource": "@PhpListEmptyStartPageBundle/Controller/", - "type": "annotation" +{ + "extra": { + "phplist/core": { + "routes": { + "homepage": { + "resource": "@PhpListEmptyStartPageBundle/Controller/", + "type": "annotation" + } } } } @@ -152,18 +156,20 @@ the `extra` section of the module's `composer.json` like this: You can also provide system configuration for your module: ```json -"extra": { - "phplist/core": { - "configuration": { - "framework": { - "templating": { - "engines": [ - "twig" - ] +{ + "extra": { + "phplist/core": { + "configuration": { + "framework": { + "templating": { + "engines": [ + "twig" + ] + } } } } - } + } } ``` @@ -203,7 +209,7 @@ To extract translation strings from the source into an XLIFF catalog: ```bash php bin/console translation:extract --force en --format=xlf php bin/console messenger:setup-transports -php bin/console messenger:consume async --limit=1 +php bin/console messenger:consume async_email --limit=1 php bin/console phplist:search:init-indices ``` diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php index 69bf967b..f42355c7 100644 --- a/src/Domain/Messaging/Command/ProcessQueueCommand.php +++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php @@ -85,6 +85,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $lock->release(); } + $output->writeln('Processed ' . count($campaigns) . ' campaigns from the queue.'); + return Command::SUCCESS; } } diff --git a/src/Domain/Messaging/Service/MessagePrecacheService.php b/src/Domain/Messaging/Service/MessagePrecacheService.php index 9ea7e873..79c9a108 100644 --- a/src/Domain/Messaging/Service/MessagePrecacheService.php +++ b/src/Domain/Messaging/Service/MessagePrecacheService.php @@ -177,7 +177,7 @@ private function populateBasicFields( private function applyTemplate(MessagePrecacheDto $messagePrecacheDto, $loadedMessageData): void { if ($loadedMessageData['template']) { - $template = $this->templateRepository->findOneById($loadedMessageData['template']); + $template = $this->templateRepository->findOneById((int) $loadedMessageData['template']); if ($template) { $messagePrecacheDto->template = stripslashes($template->getContent()); $messagePrecacheDto->templateText = stripslashes($template->getText()); From ae2e337242e621305b9ae602a5e7dddfd5faace4 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 10 Sep 2026 12:01:14 +0400 Subject: [PATCH 5/9] fix: improve error handling in message precaching and ensure template content is safely processed --- .../CampaignProcessorMessageHandler.php | 26 +++++++++++++++---- .../Service/MessagePrecacheService.php | 4 +-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php index f1b31189..ab87ec1d 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php @@ -21,6 +21,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Contracts\Translation\TranslatorInterface; +use Throwable; /** * @SuppressWarnings("PHPMD.ExcessiveParameterList") @@ -82,11 +83,26 @@ public function __invoke(CampaignProcessorMessage|SyncCampaignProcessorMessage $ // $userSelection = $loadedMessageData['userselection']; $cacheKey = sprintf('messaging.message.base.%d.%d', $campaign->getId(), 0); - if (!$this->precacheService->precacheMessage( - campaign: $campaign, - loadedMessageData: $loadedMessageData, - isTest: false - )) { + try { + $precached = $this->precacheService->precacheMessage( + campaign: $campaign, + loadedMessageData: $loadedMessageData, + isTest: false + ); + } catch (Throwable $exception) { + $this->logger->error( + $this->translator->trans( + 'Error precaching campaign message: {error}', + ['error' => $exception->getMessage()] + ), + ['campaign_id' => $campaign->getId(), 'exception' => $exception] + ); + $this->messageStatusUpdater->update($campaign, MessageStatus::Suspended); + + return; + } + + if (!$precached) { $this->messageStatusUpdater->update($campaign, MessageStatus::Suspended); return; diff --git a/src/Domain/Messaging/Service/MessagePrecacheService.php b/src/Domain/Messaging/Service/MessagePrecacheService.php index 79c9a108..63c35446 100644 --- a/src/Domain/Messaging/Service/MessagePrecacheService.php +++ b/src/Domain/Messaging/Service/MessagePrecacheService.php @@ -179,8 +179,8 @@ private function applyTemplate(MessagePrecacheDto $messagePrecacheDto, $loadedMe if ($loadedMessageData['template']) { $template = $this->templateRepository->findOneById((int) $loadedMessageData['template']); if ($template) { - $messagePrecacheDto->template = stripslashes($template->getContent()); - $messagePrecacheDto->templateText = stripslashes($template->getText()); + $messagePrecacheDto->template = stripslashes($template->getContent() ?? ''); + $messagePrecacheDto->templateText = stripslashes($template->getText() ?? ''); $messagePrecacheDto->templateId = $template->getId(); } } From ced76149eea8cc5f7a3edd004e50a36bc6085e29 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 10 Sep 2026 12:03:32 +0400 Subject: [PATCH 6/9] fix: prevent processing of messages without an owner in MessagePrecacheService --- src/Domain/Messaging/Service/MessagePrecacheService.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Domain/Messaging/Service/MessagePrecacheService.php b/src/Domain/Messaging/Service/MessagePrecacheService.php index 63c35446..650c3bbe 100644 --- a/src/Domain/Messaging/Service/MessagePrecacheService.php +++ b/src/Domain/Messaging/Service/MessagePrecacheService.php @@ -232,6 +232,10 @@ private function applyBasicReplacements(MessagePrecacheDto $messagePrecacheDto, private function populateAdminAttributes(MessagePrecacheDto $messagePrecacheDto, Message $campaign): void { + if (!$campaign->getOwner()) { + return; + } + $ownerAttrValues = $this->adminAttreDefRepository->getForAdmin($campaign->getOwner()); foreach ($ownerAttrValues as $attr) { $messagePrecacheDto->adminAttributes['OWNER.' . $attr['name']] = $attr['value']; From 98beb65b8315783928f4ecc65ce7102d5355b2cc Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 10 Sep 2026 12:37:34 +0400 Subject: [PATCH 7/9] ref: refactor cache key generation in messaging services for consistency --- .../CampaignProcessorMessageHandler.php | 2 +- .../CampaignProcessorTestMessageHandler.php | 2 +- .../Messaging/Service/ForwardContentService.php | 2 +- .../Service/MessagePrecacheService.php | 17 +++++++++++------ .../CampaignProcessorMessageHandlerTest.php | 3 +++ .../Service/ForwardContentServiceTest.php | 4 ++-- 6 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php index ab87ec1d..c32ced1a 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php @@ -82,7 +82,7 @@ public function __invoke(CampaignProcessorMessage|SyncCampaignProcessorMessage $ // } // $userSelection = $loadedMessageData['userselection']; - $cacheKey = sprintf('messaging.message.base.%d.%d', $campaign->getId(), 0); + $cacheKey = $this->precacheService->getCacheKey($campaign->getId()); try { $precached = $this->precacheService->precacheMessage( campaign: $campaign, diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessorTestMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessorTestMessageHandler.php index e8ad3e4f..25ee2d4a 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessorTestMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessorTestMessageHandler.php @@ -70,7 +70,7 @@ public function __invoke(TestCampaignProcessorMessage $data): void $loadedMessageData = ($this->messageDataLoader)($campaign); - $cacheKey = sprintf('messaging.message.base.%d.%d.%d', $campaign->getId(), 0, 1); + $cacheKey = $this->precacheService->getCacheKey($campaign->getId(), false, true); if (!$this->precacheService->precacheMessage( campaign: $campaign, loadedMessageData: $loadedMessageData, diff --git a/src/Domain/Messaging/Service/ForwardContentService.php b/src/Domain/Messaging/Service/ForwardContentService.php index a260bead..2fa0e17d 100644 --- a/src/Domain/Messaging/Service/ForwardContentService.php +++ b/src/Domain/Messaging/Service/ForwardContentService.php @@ -33,7 +33,7 @@ public function getContents( string $friendEmail, MessageForwardDto $forwardDto ): array { - $messagePrecacheDto = $this->cache->get(sprintf('messaging.message.base.%d.%d', $campaign->getId(), 1)); + $messagePrecacheDto = $this->cache->get(sprintf('messaging.message.base.%d.%d.%d', $campaign->getId(), 1, 0)); if ($messagePrecacheDto === null) { throw new MessageCacheMissingException(); diff --git a/src/Domain/Messaging/Service/MessagePrecacheService.php b/src/Domain/Messaging/Service/MessagePrecacheService.php index 650c3bbe..59641c5f 100644 --- a/src/Domain/Messaging/Service/MessagePrecacheService.php +++ b/src/Domain/Messaging/Service/MessagePrecacheService.php @@ -51,12 +51,7 @@ public function precacheMessage( ?bool $forwardContent = false, ?bool $isTest = false, ): bool { - $cacheKey = sprintf( - 'messaging.message.base.%d.%d.%d', - $campaign->getId(), - (int) $forwardContent, - (int) $isTest - ); + $cacheKey = $this->getCacheKey($campaign->getId(), (bool) $forwardContent, (bool) $isTest); $cached = $this->cache->get($cacheKey); if ($cached !== null && $isTest === false) { return true; @@ -109,6 +104,16 @@ public function precacheMessage( return true; } + public function getCacheKey(int $campaignId, bool $forwardContent = false, bool $isTest = false): string + { + return sprintf( + 'messaging.message.base.%d.%d.%d', + $campaignId, + (int) $forwardContent, + (int) $isTest + ); + } + private function isHtml(string $content): bool { return strip_tags($content) !== $content; diff --git a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php index 117fa917..de56d72d 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php @@ -144,6 +144,9 @@ public function testInvokeRunsFullPipelineAndMarksCampaignSent(): void ->method('precacheMessage') ->with($campaign, $loadedMessageData, false) ->willReturn(true); + $this->precacheService->method('getCacheKey') + ->with($campaign->getId()) + ->willReturn('messaging.message.base.' . $campaign->getId() . '.0.0'); $this->adminNotifier->expects($this->once()) ->method('notifyStart') diff --git a/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php b/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php index 1ead919a..28891f78 100644 --- a/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php @@ -47,7 +47,7 @@ public function testThrowsWhenCacheMissing(): void $this->cache ->expects(self::once()) ->method('get') - ->with('messaging.message.base.10.1') + ->with('messaging.message.base.10.1.0') ->willReturn(null); $this->expectException(MessageCacheMissingException::class); @@ -85,7 +85,7 @@ public function testProcessesLinksAndDelegatesToBuilder(): void $this->cache ->expects(self::once()) ->method('get') - ->with('messaging.message.base.42.1') + ->with('messaging.message.base.42.1.0') ->willReturn($cached); $this->preparator From 4de15544f17de9e418856d5e9cc8191c40bac68b Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 10 Sep 2026 13:10:19 +0400 Subject: [PATCH 8/9] fix: update countSentSince method to accurately count messages based on creation date --- .../Repository/UserMessageRepository.php | 2 +- .../Repository/UserMessageRepositoryTest.php | 91 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/Domain/Messaging/Repository/UserMessageRepositoryTest.php diff --git a/src/Domain/Messaging/Repository/UserMessageRepository.php b/src/Domain/Messaging/Repository/UserMessageRepository.php index 514afc93..bf307737 100644 --- a/src/Domain/Messaging/Repository/UserMessageRepository.php +++ b/src/Domain/Messaging/Repository/UserMessageRepository.php @@ -45,7 +45,7 @@ public function countSentBetween(DateTimeInterface $start, DateTimeInterface $en public function countSentSince(DateTimeInterface $since): int { $queryBuilder = $this->createQueryBuilder('um'); - $queryBuilder->select('COUNT(um)') + $queryBuilder->select('COUNT(um.createdAt)') ->where('um.createdAt > :since') ->andWhere('um.status = :status') ->setParameter('since', $since) diff --git a/tests/Integration/Domain/Messaging/Repository/UserMessageRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/UserMessageRepositoryTest.php new file mode 100644 index 00000000..7caced29 --- /dev/null +++ b/tests/Integration/Domain/Messaging/Repository/UserMessageRepositoryTest.php @@ -0,0 +1,91 @@ +loadSchema(); + + $this->repository = self::getContainer()->get(UserMessageRepository::class); + } + + protected function tearDown(): void + { + $schemaTool = new SchemaTool($this->entityManager); + $schemaTool->dropDatabase(); + parent::tearDown(); + } + + public function testCountSentSinceCountsOnlySentMessagesAfterGivenTime(): void + { + $admin = (new Administrator())->setLoginName('t'); + $this->entityManager->persist($admin); + + $campaign = new Message( + new MessageFormat(true, 'text'), + new MessageSchedule(1, null, 3, null, null), + new MessageMetadata(MessageStatus::Sent), + new MessageContent('Hello world!'), + new MessageOptions(), + $admin + ); + $this->entityManager->persist($campaign); + + $sentBeforeThreshold = new Subscriber('before@example.com'); + $sentAfterThreshold = new Subscriber('after@example.com'); + $notSentAfterThreshold = new Subscriber('not-sent@example.com'); + $this->entityManager->persist($sentBeforeThreshold); + $this->entityManager->persist($sentAfterThreshold); + $this->entityManager->persist($notSentAfterThreshold); + $this->entityManager->flush(); + + $since = new DateTime('2026-01-01 00:00:00'); + + $before = new UserMessage($sentBeforeThreshold, $campaign); + $before->setStatus(UserMessageStatus::Sent); + $this->setSubjectProperty($before, 'createdAt', new DateTime('2025-12-31 23:00:00')); + $this->entityManager->persist($before); + + $after = new UserMessage($sentAfterThreshold, $campaign); + $after->setStatus(UserMessageStatus::Sent); + $this->setSubjectProperty($after, 'createdAt', new DateTime('2026-01-01 01:00:00')); + $this->entityManager->persist($after); + + $notSent = new UserMessage($notSentAfterThreshold, $campaign); + $notSent->setStatus(UserMessageStatus::Todo); + $this->setSubjectProperty($notSent, 'createdAt', new DateTime('2026-01-01 02:00:00')); + $this->entityManager->persist($notSent); + + $this->entityManager->flush(); + + self::assertSame(1, $this->repository->countSentSince($since)); + } +} \ No newline at end of file From 81f50845babce1f9a6f571b0ff214f09be55c06f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 10 Sep 2026 13:10:31 +0400 Subject: [PATCH 9/9] logs --- .../CampaignProcessorMessageHandler.php | 2 + .../Messaging/Service/CampaignSendingLoop.php | 58 ++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php index c32ced1a..af8f38ab 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php @@ -119,6 +119,8 @@ public function __invoke(CampaignProcessorMessage|SyncCampaignProcessorMessage $ $excludeListIds ); + $this->logger->info('Subscribers: ' . count($subscribers)); + $this->messageStatusUpdater->update($campaign, MessageStatus::InProcess); $stoppedEarly = $this->sendingLoop->run($campaign, $subscribers, $cacheKey); diff --git a/src/Domain/Messaging/Service/CampaignSendingLoop.php b/src/Domain/Messaging/Service/CampaignSendingLoop.php index 7c854b89..8d50ff70 100644 --- a/src/Domain/Messaging/Service/CampaignSendingLoop.php +++ b/src/Domain/Messaging/Service/CampaignSendingLoop.php @@ -9,6 +9,7 @@ use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus; use PhpList\Core\Domain\Messaging\Model\UserMessage; use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; +use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; /** @@ -23,6 +24,7 @@ public function __construct( private readonly DomainRateLimiter $domainRateLimiter, private readonly CacheInterface $cache, private readonly CampaignEmailSender $emailSender, + private readonly LoggerInterface $logger, ) { } @@ -35,14 +37,36 @@ public function run(Message $campaign, array $subscribers, string $cacheKey): bo $this->timeLimiter->start(); $stoppedEarly = false; - foreach ($subscribers as $subscriber) { + $total = count($subscribers); + $sentAttempted = 0; + $skippedAlreadyProcessed = 0; + $throttled = 0; + $invalidEmails = 0; + + $this->logger->info('Campaign send loop starting', [ + 'campaign_id' => $campaign->getId(), + 'recipient_count' => $total, + ]); + + foreach ($subscribers as $index => $subscriber) { if ($this->timeLimiter->shouldStop()) { $stoppedEarly = true; + $this->logger->info('Campaign send loop stopping: time limit reached', [ + 'campaign_id' => $campaign->getId(), + 'processed' => $index, + 'remaining' => $total - $index, + ]); break; } $existing = $this->userMessageRepository->findByUserAndMessage($subscriber, $campaign); if ($existing && $existing->getStatus() !== UserMessageStatus::Todo) { + $skippedAlreadyProcessed++; + $this->logger->debug('Skipping subscriber: already processed', [ + 'campaign_id' => $campaign->getId(), + 'subscriber_id' => $subscriber->getId(), + 'existing_status' => $existing->getStatus()->value, + ]); continue; } @@ -50,6 +74,12 @@ public function run(Message $campaign, array $subscribers, string $cacheKey): bo // Leave no UserMessage record so this subscriber is picked up again on a // later run, once their domain's throttle window has passed. $stoppedEarly = true; + $throttled++; + $this->logger->debug('Skipping subscriber: domain rate limit hit', [ + 'campaign_id' => $campaign->getId(), + 'subscriber_id' => $subscriber->getId(), + 'email_domain' => substr((string) strrchr($subscriber->getEmail(), '@'), 1), + ]); continue; } @@ -58,18 +88,44 @@ public function run(Message $campaign, array $subscribers, string $cacheKey): bo $this->userMessageRepository->save($userMessage); if (!filter_var($subscriber->getEmail(), FILTER_VALIDATE_EMAIL)) { + $invalidEmails++; + $this->logger->warning('Invalid email address, skipping send', [ + 'campaign_id' => $campaign->getId(), + 'subscriber_id' => $subscriber->getId(), + ]); $this->emailSender->handleInvalidEmail($userMessage, $subscriber, $campaign); continue; } $messagePrecacheDto = $this->cache->get($cacheKey); if ($messagePrecacheDto === null) { + $this->logger->error('Message precache missing, aborting loop', [ + 'campaign_id' => $campaign->getId(), + 'cache_key' => $cacheKey, + 'processed' => $index, + ]); throw new MessageCacheMissingException(); } + + $sentAttempted++; + $this->logger->debug('Sending to subscriber', [ + 'campaign_id' => $campaign->getId(), + 'subscriber_id' => $subscriber->getId(), + ]); // todo: maybe catch exception and return false to stop early? $this->emailSender->send($campaign, $subscriber, $userMessage, $messagePrecacheDto); } + $this->logger->info('Campaign send loop finished', [ + 'campaign_id' => $campaign->getId(), + 'total_recipients' => $total, + 'send_attempted' => $sentAttempted, + 'skipped_already_processed' => $skippedAlreadyProcessed, + 'throttled' => $throttled, + 'invalid_emails' => $invalidEmails, + 'stopped_early' => $stoppedEarly, + ]); + return $stoppedEarly; } }