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/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/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/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/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/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/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/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/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php deleted file mode 100644 index 1e44e45d..00000000 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php +++ /dev/null @@ -1,425 +0,0 @@ -messageRepository->tryClaimForProcessing( - $data->getMessageId(), - $this->stuckCampaignThresholdSeconds - ); - if (!$campaign) { - $this->logger->warning( - $this->translator->trans('Campaign not found or not in submitted status'), - ['campaign_id' => $data->getMessageId()] - ); - - return; - } - - $loadedMessageData = ($this->messageDataLoader)($campaign); -// if (!empty($loadedMessageData['resetstats'])) { -// resetMessageStatistics($loadedMessageData['id']); -// setMessageData($loadedMessageData['id'], 'resetstats', 0); -// } -// $stopSending = false; -// if (!empty($loadedMessageData['finishsending'])) { -// $finishSendingBefore = mktime( -// $loadedMessageData['finishsending']['hour'], -// $loadedMessageData['finishsending']['minute'], -// 0, -// $loadedMessageData['finishsending']['month'], -// $loadedMessageData['finishsending']['day'], -// $loadedMessageData['finishsending']['year'], -// ); -// $secondsTogo = $finishSendingBefore - time(); -// $stopSending = $secondsTogo < 0; -// } -// $userSelection = $loadedMessageData['userselection']; - - $cacheKey = sprintf('messaging.message.base.%d.%d', $campaign->getId(), 0); - if (!$this->precacheService->precacheMessage( - campaign: $campaign, - loadedMessageData: $loadedMessageData, - isTest: false - )) { - $this->updateMessageStatus($campaign, MessageStatus::Suspended); - - return; - } - - $this->handleAdminNotifications($campaign, $loadedMessageData, $data->getMessageId()); - - // Campaign was already atomically claimed into Prepared status above. - $excludeListIds = $this->getExcludeListIds($loadedMessageData); - $this->markExcludedSubscribers($campaign, $data, $excludeListIds); - $subscribers = $this->subscriberProvider->getSubscribersForMessageOrLists( - $data, - $campaign, - $excludeListIds - ); - - $this->updateMessageStatus($campaign, MessageStatus::InProcess); - - $stoppedEarly = $this->processSubscribersForCampaign($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; - } -} diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php new file mode 100644 index 00000000..af8f38ab --- /dev/null +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php @@ -0,0 +1,135 @@ +messageRepository->tryClaimForProcessing( + $data->getMessageId(), + $this->stuckCampaignThresholdSeconds + ); + if (!$campaign) { + $this->logger->warning( + $this->translator->trans('Campaign not found or not in submitted status'), + ['campaign_id' => $data->getMessageId()] + ); + + return; + } + + $loadedMessageData = ($this->messageDataLoader)($campaign); +// if (!empty($loadedMessageData['resetstats'])) { +// resetMessageStatistics($loadedMessageData['id']); +// setMessageData($loadedMessageData['id'], 'resetstats', 0); +// } +// $stopSending = false; +// if (!empty($loadedMessageData['finishsending'])) { +// $finishSendingBefore = mktime( +// $loadedMessageData['finishsending']['hour'], +// $loadedMessageData['finishsending']['minute'], +// 0, +// $loadedMessageData['finishsending']['month'], +// $loadedMessageData['finishsending']['day'], +// $loadedMessageData['finishsending']['year'], +// ); +// $secondsTogo = $finishSendingBefore - time(); +// $stopSending = $secondsTogo < 0; +// } +// $userSelection = $loadedMessageData['userselection']; + + $cacheKey = $this->precacheService->getCacheKey($campaign->getId()); + 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; + } + + $this->adminNotifier->notifyStart($campaign, $loadedMessageData, $data->getMessageId()); + + // Campaign was already atomically claimed into Prepared status above. + $excludeListIds = $this->exclusionService->resolveExcludeListIds($loadedMessageData); + $this->exclusionService->markExcludedSubscribers($campaign, $data, $excludeListIds); + $subscribers = $this->subscriberProvider->getSubscribersForMessageOrLists( + $data, + $campaign, + $excludeListIds + ); + + $this->logger->info('Subscribers: ' . count($subscribers)); + + $this->messageStatusUpdater->update($campaign, MessageStatus::InProcess); + + $stoppedEarly = $this->sendingLoop->run($campaign, $subscribers, $cacheKey); + + if ($stoppedEarly && $this->requeueHandler->handle($campaign)) { + $this->entityManager->flush(); + return; + } + + $this->messageStatusUpdater->update($campaign, MessageStatus::Sent); + } +} diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessor/TestCampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessorTestMessageHandler.php similarity index 97% rename from src/Domain/Messaging/MessageHandler/CampaignProcessor/TestCampaignProcessorMessageHandler.php rename to src/Domain/Messaging/MessageHandler/CampaignProcessorTestMessageHandler.php index 95b0a107..25ee2d4a 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, @@ -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/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/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..8d50ff70 --- /dev/null +++ b/src/Domain/Messaging/Service/CampaignSendingLoop.php @@ -0,0 +1,131 @@ +timeLimiter->start(); + $stoppedEarly = false; + + $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; + } + + 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; + $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; + } + + $userMessage = $existing ?? new UserMessage($subscriber, $campaign); + $userMessage->setStatus(UserMessageStatus::Active); + $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; + } +} 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/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/MessagePrecacheService.php b/src/Domain/Messaging/Service/MessagePrecacheService.php index 9ea7e873..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; @@ -177,10 +182,10 @@ 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()); + $messagePrecacheDto->template = stripslashes($template->getContent() ?? ''); + $messagePrecacheDto->templateText = stripslashes($template->getText() ?? ''); $messagePrecacheDto->templateId = $template->getId(); } } @@ -232,6 +237,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']; 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/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, ) { } 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 diff --git a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php index fc7d5802..de56d72d 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\MessageHandler\CampaignProcessorMessageHandler; 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,152 @@ 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 + 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->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); - - $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->precacheService->method('getCacheKey') + ->with($campaign->getId()) + ->willReturn('messaging.message.base.' . $campaign->getId() . '.0.0'); + + $this->adminNotifier->expects($this->once()) + ->method('notifyStart') + ->with($campaign, $loadedMessageData, 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') - ->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 - { - $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); - - $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->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(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/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 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); + } +}