From 9284660e895c3d8e7e5d5579ca1837c42c7cd149 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 19 Aug 2025 10:09:11 +0400 Subject: [PATCH 01/24] BounceManager --- config/services/managers.yml | 4 + config/services/repositories.yml | 5 + .../Service/Manager/BounceManager.php | 62 ++++++++++ .../Service/Manager/BounceManagerTest.php | 107 ++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 src/Domain/Messaging/Service/Manager/BounceManager.php create mode 100644 tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php diff --git a/config/services/managers.yml b/config/services/managers.yml index 0f6bb119..b5138505 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -72,6 +72,10 @@ services: autowire: true autoconfigure: true + PhpList\Core\Domain\Messaging\Service\Manager\BounceManager: + autowire: true + autoconfigure: true + PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager: autowire: true autoconfigure: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml index 69bdb6ce..bd966628 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -135,3 +135,8 @@ services: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Messaging\Model\BounceRegex + + PhpList\Core\Domain\Messaging\Repository\BounceRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\Bounce diff --git a/src/Domain/Messaging/Service/Manager/BounceManager.php b/src/Domain/Messaging/Service/Manager/BounceManager.php new file mode 100644 index 00000000..6f18851b --- /dev/null +++ b/src/Domain/Messaging/Service/Manager/BounceManager.php @@ -0,0 +1,62 @@ +bounceRepository = $bounceRepository; + } + + public function create( + ?DateTime $date = null, + ?string $header = null, + ?string $data = null, + ?string $status = null, + ?string $comment = null + ): Bounce { + $bounce = new Bounce( + date: $date, + header: $header, + data: $data, + status: $status, + comment: $comment + ); + + $this->bounceRepository->save($bounce); + + return $bounce; + } + + public function save(Bounce $bounce): void + { + $this->bounceRepository->save($bounce); + } + + public function delete(Bounce $bounce): void + { + $this->bounceRepository->remove($bounce); + } + + /** @return Bounce[] */ + public function getAll(): array + { + return $this->bounceRepository->findAll(); + } + + public function getById(int $id): ?Bounce + { + /** @var Bounce|null $found */ + $found = $this->bounceRepository->find($id); + return $found; + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php new file mode 100644 index 00000000..9926e916 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php @@ -0,0 +1,107 @@ +repository = $this->createMock(BounceRepository::class); + $this->manager = new BounceManager($this->repository); + } + + public function testCreatePersistsAndReturnsBounce(): void + { + $date = new DateTime('2020-01-01 00:00:00'); + $header = 'X-Test: Header'; + $data = 'raw bounce'; + $status = 'new'; + $comment = 'created by test'; + + $this->repository->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(Bounce::class)); + + $bounce = $this->manager->create( + date: $date, + header: $header, + data: $data, + status: $status, + comment: $comment + ); + + $this->assertInstanceOf(Bounce::class, $bounce); + $this->assertSame($date, $bounce->getDate()); + $this->assertSame($header, $bounce->getHeader()); + $this->assertSame($data, $bounce->getData()); + $this->assertSame($status, $bounce->getStatus()); + $this->assertSame($comment, $bounce->getComment()); + } + + public function testSaveDelegatesToRepository(): void + { + $model = new Bounce(); + + $this->repository->expects($this->once()) + ->method('save') + ->with($model); + + $this->manager->save($model); + } + + public function testDeleteDelegatesToRepository(): void + { + $model = new Bounce(); + + $this->repository->expects($this->once()) + ->method('remove') + ->with($model); + + $this->manager->delete($model); + } + + public function testGetAllReturnsArray(): void + { + $expected = [new Bounce(), new Bounce()]; + + $this->repository->expects($this->once()) + ->method('findAll') + ->willReturn($expected); + + $this->assertSame($expected, $this->manager->getAll()); + } + + public function testGetByIdReturnsBounce(): void + { + $expected = new Bounce(); + + $this->repository->expects($this->once()) + ->method('find') + ->with(123) + ->willReturn($expected); + + $this->assertSame($expected, $this->manager->getById(123)); + } + + public function testGetByIdReturnsNullWhenNotFound(): void + { + $this->repository->expects($this->once()) + ->method('find') + ->with(999) + ->willReturn(null); + + $this->assertNull($this->manager->getById(999)); + } +} From 59068b2e384e16768d230dc946c14502465ac84f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 19 Aug 2025 11:38:48 +0400 Subject: [PATCH 02/24] Add bounce email --- config/parameters.yml.dist | 12 ++++++++++++ config/services/services.yml | 1 + src/Domain/Messaging/Service/EmailService.php | 17 +++++++++++++---- .../Messaging/Service/EmailServiceTest.php | 8 +++++++- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index 621a8b81..292ebf21 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -32,6 +32,18 @@ parameters: app.password_reset_url: '%%env(PASSWORD_RESET_URL)%%' env(PASSWORD_RESET_URL): 'https://example.com/reset/' + # bounce email settings + imap_bounce.email: '%%env(BOUNCE_EMAIL)%%' + env(BOUNCE_EMAIL): 'bounce@phplist.com' + imap_bounce.password: '%%env(BOUNCE_IMAP_PASS)%%' + env(BOUNCE_IMAP_PASS): 'bounce@phplist.com' + imap_bounce.host: '%%env(BOUNCE_IMAP_HOST)%%' + env(BOUNCE_IMAP_HOST): 'imap.phplist.com' + imap_bounce.port: '%%env(BOUNCE_IMAP_PORT)%%' + env(BOUNCE_IMAP_PORT): 993 + imap_bounce.encryption: '%%env(BOUNCE_IMAP_ENCRYPTION)%%' + env(BOUNCE_IMAP_ENCRYPTION): 'ssl' + # Messenger configuration for asynchronous processing app.messenger_transport_dsn: '%%env(MESSENGER_TRANSPORT_DSN)%%' env(MESSENGER_TRANSPORT_DSN): 'doctrine://default?auto_setup=true' diff --git a/config/services/services.yml b/config/services/services.yml index 7b9f921c..76d6769f 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -14,6 +14,7 @@ services: autoconfigure: true arguments: $defaultFromEmail: '%app.mailer_from%' + $bounceEmail: '%imap_bounce.email%' PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService: autowire: true diff --git a/src/Domain/Messaging/Service/EmailService.php b/src/Domain/Messaging/Service/EmailService.php index 86b17ec5..2a45b0fd 100644 --- a/src/Domain/Messaging/Service/EmailService.php +++ b/src/Domain/Messaging/Service/EmailService.php @@ -6,6 +6,7 @@ use PhpList\Core\Domain\Messaging\Message\AsyncEmailMessage; use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Mailer\Envelope; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Address; @@ -13,17 +14,20 @@ class EmailService { private MailerInterface $mailer; - private string $defaultFromEmail; private MessageBusInterface $messageBus; + private string $defaultFromEmail; + private string $bounceEmail; public function __construct( MailerInterface $mailer, + MessageBusInterface $messageBus, string $defaultFromEmail, - MessageBusInterface $messageBus + string $bounceEmail, ) { $this->mailer = $mailer; - $this->defaultFromEmail = $defaultFromEmail; $this->messageBus = $messageBus; + $this->defaultFromEmail = $defaultFromEmail; + $this->bounceEmail = $bounceEmail; } public function sendEmail( @@ -68,7 +72,12 @@ public function sendEmailSync( $email->attachFromPath($attachment); } - $this->mailer->send($email); + $envelope = new Envelope( + sender: new Address($this->bounceEmail, 'PHPList Bounce'), + recipients: [new Address($email->getTo()[0]->getAddress())] + ); + + $this->mailer->send(message: $email, envelope: $envelope); } public function sendBulkEmail( diff --git a/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php b/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php index 9409320b..950f1021 100644 --- a/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php @@ -19,12 +19,18 @@ class EmailServiceTest extends TestCase private MailerInterface&MockObject $mailer; private MessageBusInterface&MockObject $messageBus; private string $defaultFromEmail = 'default@example.com'; + private string $bounceEmail = 'bounce@example.com'; protected function setUp(): void { $this->mailer = $this->createMock(MailerInterface::class); $this->messageBus = $this->createMock(MessageBusInterface::class); - $this->emailService = new EmailService($this->mailer, $this->defaultFromEmail, $this->messageBus); + $this->emailService = new EmailService( + mailer: $this->mailer, + messageBus: $this->messageBus, + defaultFromEmail: $this->defaultFromEmail, + bounceEmail: $this->bounceEmail, + ); } public function testSendEmailWithDefaultFrom(): void From d50d840151ba2bab0879ca709dc7c1544993412f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 20 Aug 2025 11:12:44 +0400 Subject: [PATCH 03/24] Move to the processor dir --- src/Domain/Messaging/Command/ProcessQueueCommand.php | 8 ++++---- .../Service/{ => Processor}/CampaignProcessor.php | 3 ++- .../Domain/Messaging/Command/ProcessQueueCommandTest.php | 2 +- .../Domain/Messaging/Service/CampaignProcessorTest.php | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) rename src/Domain/Messaging/Service/{ => Processor}/CampaignProcessor.php (95%) diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php index 43937f91..820d403d 100644 --- a/src/Domain/Messaging/Command/ProcessQueueCommand.php +++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php @@ -4,14 +4,14 @@ namespace PhpList\Core\Domain\Messaging\Command; -use PhpList\Core\Domain\Messaging\Service\CampaignProcessor; +use PhpList\Core\Domain\Messaging\Repository\MessageRepository; +use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; +use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use PhpList\Core\Domain\Messaging\Repository\MessageRepository; -use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; use Symfony\Component\Lock\LockFactory; -use Symfony\Component\Console\Attribute\AsCommand; use Throwable; #[AsCommand( diff --git a/src/Domain/Messaging/Service/CampaignProcessor.php b/src/Domain/Messaging/Service/Processor/CampaignProcessor.php similarity index 95% rename from src/Domain/Messaging/Service/CampaignProcessor.php rename to src/Domain/Messaging/Service/Processor/CampaignProcessor.php index 48e9acc5..13a100a3 100644 --- a/src/Domain/Messaging/Service/CampaignProcessor.php +++ b/src/Domain/Messaging/Service/Processor/CampaignProcessor.php @@ -2,10 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service; +namespace PhpList\Core\Domain\Messaging\Service\Processor; use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Output\OutputInterface; diff --git a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php index 489b5d60..79ece9bd 100644 --- a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php +++ b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php @@ -8,8 +8,8 @@ use PhpList\Core\Domain\Messaging\Command\ProcessQueueCommand; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; -use PhpList\Core\Domain\Messaging\Service\CampaignProcessor; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; +use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Application; diff --git a/tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php b/tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php index f8bb28d3..3d685ed4 100644 --- a/tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php @@ -9,8 +9,8 @@ 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\Service\CampaignProcessor; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; +use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider; use PHPUnit\Framework\MockObject\MockObject; From 5362bdf9e15bfb141de4884f4fb45e0f34183493 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 20 Aug 2025 11:13:56 +0400 Subject: [PATCH 04/24] SendProcess lock service --- .../Repository/SendProcessRepository.php | 67 +++++++++++ src/Domain/Messaging/Service/LockService.php | 110 ++++++++++++++++++ .../Service/Manager/SendProcessManager.php | 59 ++++++++++ 3 files changed, 236 insertions(+) create mode 100644 src/Domain/Messaging/Service/LockService.php create mode 100644 src/Domain/Messaging/Service/Manager/SendProcessManager.php diff --git a/src/Domain/Messaging/Repository/SendProcessRepository.php b/src/Domain/Messaging/Repository/SendProcessRepository.php index 496adf9b..2a234a5a 100644 --- a/src/Domain/Messaging/Repository/SendProcessRepository.php +++ b/src/Domain/Messaging/Repository/SendProcessRepository.php @@ -7,8 +7,75 @@ use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; +use PhpList\Core\Domain\Messaging\Model\SendProcess; class SendProcessRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + public function deleteByPage(string $page): void + { + $this->createQueryBuilder('sp') + ->delete() + ->where('sp.page = :page') + ->setParameter('page', $page) + ->getQuery() + ->execute(); + } + + public function countAliveByPage(string $page): int + { + return (int)$this->createQueryBuilder('sp') + ->select('COUNT(sp.id)') + ->where('sp.page = :page') + ->andWhere('sp.alive > 0') + ->setParameter('page', $page) + ->getQuery() + ->getSingleScalarResult(); + } + + public function findNewestAlive(string $page): ?SendProcess + { + return $this->createQueryBuilder('sp') + ->where('sp.page = :page') + ->andWhere('sp.alive > 0') + ->setParameter('page', $page) + ->orderBy('sp.started', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + + public function markDeadById(int $id): void + { + $this->createQueryBuilder('sp') + ->update() + ->set('sp.alive', ':zero') + ->where('sp.id = :id') + ->setParameter('zero', 0) + ->setParameter('id', $id) + ->getQuery() + ->execute(); + } + + public function incrementAlive(int $id): void + { + $this->createQueryBuilder('sp') + ->update() + ->set('sp.alive', 'sp.alive + 1') + ->where('sp.id = :id') + ->setParameter('id', $id) + ->getQuery() + ->execute(); + } + + public function getAliveValue(int $id): int + { + return (int)$this->createQueryBuilder('sp') + ->select('sp.alive') + ->where('sp.id = :id') + ->setParameter('id', $id) + ->getQuery() + ->getSingleScalarResult(); + } } diff --git a/src/Domain/Messaging/Service/LockService.php b/src/Domain/Messaging/Service/LockService.php new file mode 100644 index 00000000..16740ccb --- /dev/null +++ b/src/Domain/Messaging/Service/LockService.php @@ -0,0 +1,110 @@ +sanitizePage($page); + $max = $isCli ? ($multiSend ? max(1, $maxSendProcesses) : 1) : 1; + + if ($force) { + $this->logger->info('Force set, killing other send processes (deleting lock rows).'); + $this->repo->deleteByPage($page); + } + + $waited = 0; + while (true) { + $count = $this->repo->countAliveByPage($page); + $running = $this->manager->findNewestAliveWithAge($page); + + if ($count >= $max) { + $age = (int)($running['age'] ?? 0); + + if ($age > $this->staleAfterSeconds && isset($running['id'])) { + $this->repo->markDeadById((int)$running['id']); + + continue; + } + + $this->logger->info(sprintf( + 'A process for this page is already running and it was still alive %d seconds ago', + $age + )); + + if ($isCli) { + $this->logger->info("Running commandline, quitting. We'll find out what to do in the next run."); + return null; + } + + $this->logger->info('Sleeping for 20 seconds, aborting will quit'); + sleep($this->sleepSeconds); + + if (++$waited > $this->maxWaitCycles) { + $this->logger->info('We have been waiting too long, I guess the other process is still going ok'); + return null; + } + + continue; + } + + $processIdentifier = $isCli + ? (php_uname('n') ?: 'localhost') . ':' . getmypid() + : ($clientIp ?? '0.0.0.0'); + + $sendProcess = $this->manager->create($page, $processIdentifier); + + return $sendProcess->getId(); + } + } + + public function keepLock(int $processId): void + { + $this->repo->incrementAlive($processId); + } + + public function checkLock(int $processId): int + { + return $this->repo->getAliveValue($processId); + } + + public function release(int $processId): void + { + $this->repo->markDeadById($processId); + } + + private function sanitizePage(string $page): string + { + $u = new UnicodeString($page); + $clean = preg_replace('/\W/', '', (string)$u); + return $clean === '' ? 'default' : $clean; + } +} diff --git a/src/Domain/Messaging/Service/Manager/SendProcessManager.php b/src/Domain/Messaging/Service/Manager/SendProcessManager.php new file mode 100644 index 00000000..3dc3a39b --- /dev/null +++ b/src/Domain/Messaging/Service/Manager/SendProcessManager.php @@ -0,0 +1,59 @@ +repository = $repository; + $this->entityManager = $entityManager; + } + + public function create(string $page, string $processIdentifier): SendProcess + { + $sendProcess = new SendProcess(); + $sendProcess->setStartedDate(new DateTime('now')); + $sendProcess->setAlive(1); + $sendProcess->setIpaddress($processIdentifier); + $sendProcess->setPage($page); + + $this->entityManager->persist($sendProcess); + $this->entityManager->flush(); + + return $sendProcess; + } + + + /** + * @return array{id:int, age:int}|null + */ + public function findNewestAliveWithAge(string $page): ?array + { + $row = $this->repository->findNewestAlive($page); + + if (!$row instanceof SendProcess) { + return null; + } + + $modified = $row->getUpdatedAt(); + $age = $modified + ? max(0, time() - (int)$modified->format('U')) + : 0; + + return [ + 'id' => $row->getId(), + 'age' => $age, + ]; + } +} From 30413a70fc025a6f9161c5aeb25c30f207cc5d0c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 21 Aug 2025 10:03:47 +0400 Subject: [PATCH 05/24] ClientIp + SystemInfo --- config/services/services.yml | 10 ++- src/Domain/Common/ClientIpResolver.php | 23 ++++++ src/Domain/Common/SystemInfoCollector.php | 87 +++++++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/Domain/Common/ClientIpResolver.php create mode 100644 src/Domain/Common/SystemInfoCollector.php diff --git a/config/services/services.yml b/config/services/services.yml index 76d6769f..abbbd588 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -31,7 +31,15 @@ services: autoconfigure: true public: true - PhpList\Core\Domain\Messaging\Service\CampaignProcessor: + PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor: autowire: true autoconfigure: true public: true + + PhpList\Core\Domain\Common\ClientIpResolver: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Common\SystemInfoCollector: + autowire: true + autoconfigure: true diff --git a/src/Domain/Common/ClientIpResolver.php b/src/Domain/Common/ClientIpResolver.php new file mode 100644 index 00000000..44e049bb --- /dev/null +++ b/src/Domain/Common/ClientIpResolver.php @@ -0,0 +1,23 @@ +requestStack->getCurrentRequest(); + + if ($request !== null) { + return $request->getClientIp() ?? ''; + } + + return (gethostname() ?: 'localhost') . ':' . getmypid(); + } +} diff --git a/src/Domain/Common/SystemInfoCollector.php b/src/Domain/Common/SystemInfoCollector.php new file mode 100644 index 00000000..b6376cfb --- /dev/null +++ b/src/Domain/Common/SystemInfoCollector.php @@ -0,0 +1,87 @@ + use defaults) + */ + public function __construct( + RequestStack $requestStack, + array $configuredKeys = [] + ) { + $this->requestStack = $requestStack; + $this->configuredKeys = $configuredKeys; + } + + /** + * Return key=>value pairs (already sanitized for safe logging/HTML display). + * + * @return array + */ + public function collect(): array + { + $request = $this->requestStack->getCurrentRequest(); + + $data = []; + + if ($request) { + $headers = $request->headers; + + $data['HTTP_USER_AGENT'] = (string) $headers->get('User-Agent', ''); + $data['HTTP_REFERER'] = (string) $headers->get('Referer', ''); + $data['HTTP_X_FORWARDED_FOR'] = (string) $headers->get('X-Forwarded-For', ''); + $data['REQUEST_URI'] = $request->getRequestUri(); + $data['REMOTE_ADDR'] = $request->getClientIp() ?? ''; + } else { + $server = $_SERVER; + $data['HTTP_USER_AGENT'] = (string) ($server['HTTP_USER_AGENT'] ?? ''); + $data['HTTP_REFERER'] = (string) ($server['HTTP_REFERER'] ?? ''); + $data['HTTP_X_FORWARDED_FOR'] = (string) ($server['HTTP_X_FORWARDED_FOR'] ?? ''); + $data['REQUEST_URI'] = (string) ($server['REQUEST_URI'] ?? ''); + $data['REMOTE_ADDR'] = (string) ($server['REMOTE_ADDR'] ?? ''); + } + + $keys = $this->configuredKeys ?: $this->defaultKeys; + + $out = []; + foreach ($keys as $key) { + if (!array_key_exists($key, $data)) { + continue; + } + $val = $data[$key]; + + $safeKey = strip_tags($key); + $safeVal = htmlspecialchars((string) $val, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + $out[$safeKey] = $safeVal; + } + + return $out; + } + + /** + * Convenience to match the legacy multi-line string format. + */ + public function collectAsString(): string + { + $pairs = $this->collect(); + if (!$pairs) { + return ''; + } + $lines = []; + foreach ($pairs as $k => $v) { + $lines[] = sprintf("%s = %s", $k, $v); + } + return "\n" . implode("\n", $lines); + } +} From 981ca1ea30989bc6e2a8eb94ab806b1665ffd2b9 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 21 Aug 2025 11:49:59 +0400 Subject: [PATCH 06/24] ProcessBouncesCommand --- .../Command/ProcessBouncesCommand.php | 542 ++++++++++++++++++ .../Messaging/Model/BounceRegexBounce.php | 30 +- .../Messaging/Model/UserMessageBounce.php | 16 +- .../Repository/BounceRegexRepository.php | 12 + .../Messaging/Repository/BounceRepository.php | 7 + .../Repository/MessageRepository.php | 11 + .../UserMessageBounceRepository.php | 33 ++ .../Service/Manager/BounceManager.php | 57 +- .../Service/Manager/BounceRuleManager.php | 100 ++++ .../Repository/SubscriberRepository.php | 11 + .../Manager/SubscriberBlacklistManager.php | 11 + .../Manager/SubscriberHistoryManager.php | 28 +- .../Service/Manager/SubscriberManager.php | 46 +- .../Service/SubscriberBlacklistService.php | 54 ++ 14 files changed, 927 insertions(+), 31 deletions(-) create mode 100644 src/Domain/Messaging/Command/ProcessBouncesCommand.php create mode 100644 src/Domain/Messaging/Service/Manager/BounceRuleManager.php create mode 100644 src/Domain/Subscription/Service/SubscriberBlacklistService.php diff --git a/src/Domain/Messaging/Command/ProcessBouncesCommand.php b/src/Domain/Messaging/Command/ProcessBouncesCommand.php new file mode 100644 index 00000000..0bdd6ff8 --- /dev/null +++ b/src/Domain/Messaging/Command/ProcessBouncesCommand.php @@ -0,0 +1,542 @@ +addOption('protocol', null, InputOption::VALUE_REQUIRED, 'Mailbox protocol: pop or mbox', 'pop') + ->addOption('host', null, InputOption::VALUE_OPTIONAL, 'POP host (without braces) e.g. mail.example.com') + ->addOption('port', null, InputOption::VALUE_OPTIONAL, 'POP port/options, e.g. 110/pop3/notls', '110/pop3/notls') + ->addOption('user', null, InputOption::VALUE_OPTIONAL, 'Mailbox username') + ->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Mailbox password') + ->addOption('mailbox', null, InputOption::VALUE_OPTIONAL, 'Mailbox name(s) for POP (comma separated) or mbox file path', 'INBOX') + ->addOption('maximum', null, InputOption::VALUE_OPTIONAL, 'Max messages to process per run', '1000') + ->addOption('purge', null, InputOption::VALUE_NONE, 'Delete processed messages from mailbox') + ->addOption('purge-unprocessed', null, InputOption::VALUE_NONE, 'Delete unprocessed messages from mailbox') + ->addOption('rules-batch-size', null, InputOption::VALUE_OPTIONAL, 'Advanced rules batch size', '1000') + ->addOption('unsubscribe-threshold', null, InputOption::VALUE_OPTIONAL, 'Consecutive bounces threshold to unconfirm user', '3') + ->addOption('blacklist-threshold', null, InputOption::VALUE_OPTIONAL, 'Consecutive bounces threshold to blacklist email (0 to disable)', '0') + ->addOption('test', 't', InputOption::VALUE_NONE, 'Test mode: do not delete from mailbox') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force run: kill other processes if locked'); + } + + public function __construct( + private readonly BounceManager $bounceManager, + private readonly SubscriberRepository $users, + private readonly MessageRepository $messages, + private readonly BounceRuleManager $ruleManager, + private readonly LockService $lockService, + private readonly LoggerInterface $logger, + private readonly SubscriberManager $subscriberManager, + private readonly SubscriberHistoryManager $subscriberHistoryManager, + ) { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if (!function_exists('imap_open')) { + $io->error('IMAP extension not available. Cannot continue.'); + + return Command::FAILURE; + } + + $force = (bool)$input->getOption('force'); + $lock = $this->lockService->acquirePageLock('bounce_processor', $force); + + if (!$lock) { + $io->warning('Another bounce processing is already running. Aborting.'); + + return Command::SUCCESS; + } + + try { + $io->title('Processing bounces'); + $protocol = (string)$input->getOption('protocol'); + $testMode = (bool)$input->getOption('test'); + $max = (int)$input->getOption('maximum'); + $purgeProcessed = $input->getOption('purge') && !$testMode; + $purgeUnprocessed = $input->getOption('purge-unprocessed') && !$testMode; + + $downloadReport = ''; + + if ($protocol === 'pop') { + $host = (string)$input->getOption('host'); + $user = (string)$input->getOption('user'); + $password = (string)$input->getOption('password'); + $port = (string)$input->getOption('port'); + $mailboxes = (string)$input->getOption('mailbox'); + + if (!$host || !$user || !$password) { + $io->error('POP configuration incomplete: host, user, and password are required.'); + + return Command::FAILURE; + } + + foreach (explode(',', $mailboxes) as $mailboxName) { + $mailboxName = trim($mailboxName); + if ($mailboxName === '') { $mailboxName = 'INBOX'; } + $mailbox = sprintf('{%s:%s}%s', $host, $port, $mailboxName); + $io->section("Connecting to $mailbox"); + + $link = @imap_open($mailbox, $user, $password); + if (!$link) { + $io->error('Cannot create connection to '.$mailbox.': '.imap_last_error()); + + return Command::FAILURE; + } + + $downloadReport .= $this->processMessages($io, $link, $max, $purgeProcessed, $purgeUnprocessed, $testMode); + } + } elseif ($protocol === 'mbox') { + $file = (string)$input->getOption('mailbox'); + if (!$file) { + $io->error('mbox file path must be provided with --mailbox.'); + + return Command::FAILURE; + } + $io->section("Opening mbox $file"); + $link = @imap_open($file, '', '', $testMode ? 0 : CL_EXPUNGE); + if (!$link) { + $io->error('Cannot open mailbox file: '.imap_last_error()); + + return Command::FAILURE; + } + $downloadReport .= $this->processMessages($io, $link, $max, $purgeProcessed, $purgeUnprocessed, $testMode); + } else { + $io->error('Unsupported protocol: '.$protocol); + + return Command::FAILURE; + } + + // Reprocess unidentified bounces (status = "unidentified bounce") + $this->reprocessUnidentified($io); + + // Advanced bounce rules + $this->processAdvancedRules($io, (int)$input->getOption('rules-batch-size')); + + // Identify and unconfirm users with consecutive bounces + $this->handleConsecutiveBounces( + $io, + (int)$input->getOption('unsubscribe-threshold'), + (int)$input->getOption('blacklist-threshold') + ); + + // Summarize and report (email or log) + $this->logger->info('Bounce processing completed', [ + 'downloadReport' => $downloadReport, + ]); + + $io->success('Bounce processing completed.'); + + return Command::SUCCESS; + } catch (Exception $e) { + $this->logger->error('Bounce processing failed', ['exception' => $e]); + $io->error('Error: '.$e->getMessage()); + + return Command::FAILURE; + } finally { + $this->lockService->release($lock); + } + } + + private function processMessages(SymfonyStyle $io, $link, int $max, bool $purgeProcessed, bool $purgeUnprocessed, bool $testMode): string + { + $num = imap_num_msg($link); + $io->writeln(sprintf('%d bounces to fetch from the mailbox', $num)); + if ($num === 0) { + imap_close($link); + + return ''; + } + $io->writeln('Please do not interrupt this process'); + if ($num > $max) { + $io->writeln(sprintf('Processing first %d bounces', $max)); + $num = $max; + } + $io->writeln($testMode ? 'Running in test mode, not deleting messages from mailbox' : 'Processed messages will be deleted from the mailbox'); + + for ($x = 1; $x <= $num; $x++) { + $header = imap_fetchheader($link, $x); + $processed = $this->processImapBounce($link, $x, $header, $io); + if ($processed) { + if (!$testMode && $purgeProcessed) { + imap_delete($link, (string)$x); + } + } else { + if (!$testMode && $purgeUnprocessed) { + imap_delete($link, (string)$x); + } + } + } + + $io->writeln('Closing mailbox, and purging messages'); + if (!$testMode) { + imap_close($link, CL_EXPUNGE); + } else { + imap_close($link); + } + + return ''; + } + + private function processImapBounce($link, int $num, string $header, SymfonyStyle $io): bool + { + $headerInfo = imap_headerinfo($link, $num); + $date = $headerInfo->date ?? null; + $bounceDate = $date ? new DateTimeImmutable($date) : new DateTimeImmutable(); + $body = imap_body($link, $num); + $body = $this->decodeBody($header, $body); + + // Quick hack: ignore MsExchange delayed notices (as in original) + if (preg_match('/Action: delayed\s+Status: 4\.4\.7/im', $body)) { + return true; + } + + $msgId = $this->findMessageId($body); + $userId = $this->findUserId($body); + + $bounce = $this->bounceManager->create($bounceDate, $header, $body); + + return $this->processBounceData($bounce, $msgId, $userId, $bounceDate); + } + + private function processBounceData( + Bounce $bounce, + string|int|null $msgId, + ?int $userId, + DateTimeImmutable $bounceDate, + ): bool { + $msgId = $msgId ?: null; + if ($userId) { + $user = $this->subscriberManager->getSubscriberById($userId); + } + + if ($msgId === 'systemmessage' && $userId) { + $this->bounceManager->update( + bounce: $bounce, + status: 'bounced system message', + comment: sprintf('%d marked unconfirmed', $userId)) + ; + $this->bounceManager->linkUserMessageBounce($bounce,$bounceDate, $userId); + $this->subscriberManager->markUnconfirmed($userId); + $this->logger->info('system message bounced, user marked unconfirmed', ['userId' => $userId]); + $this->subscriberHistoryManager->addHistory( + subscriber: $user, + message: 'Bounced system message', + details: sprintf('User marked unconfirmed. Bounce #%d', $bounce->getId()) + ); + + return true; + } + + if ($msgId && $userId) { + if (!$this->bounceManager->existsUserMessageBounce($userId, (int)$msgId)) { + $this->bounceManager->linkUserMessageBounce($bounce, $bounceDate,$userId, (int)$msgId); + $this->bounceManager->update( + bounce: $bounce, + status: sprintf('bounced list message %d', $msgId), + comment: sprintf('%d bouncecount increased', $userId) + ); + $this->messages->incrementBounceCount((int)$msgId); + $this->users->incrementBounceCount($userId); + } else { + $this->bounceManager->linkUserMessageBounce($bounce, $bounceDate, $userId, (int)$msgId); + $this->bounceManager->update( + bounce: $bounce, + status: sprintf('duplicate bounce for %d', $userId), + comment: sprintf('duplicate bounce for subscriber %d on message %d', $userId, $msgId) + ); + } + return true; + } + + if ($userId) { + $this->bounceManager->update( + bounce: $bounce, + status: 'bounced unidentified message', + comment: sprintf('%d bouncecount increased', $userId) + ); + $this->users->incrementBounceCount($userId); + return true; + } + + if ($msgId === 'systemmessage') { + $this->bounceManager->update($bounce, 'bounced system message', 'unknown user'); + $this->logger->info('system message bounced, but unknown user'); + return true; + } + + if ($msgId) { + $this->bounceManager->update($bounce, sprintf('bounced list message %d', $msgId), 'unknown user'); + $this->messages->incrementBounceCount((int)$msgId); + return true; + } + + $this->bounceManager->update($bounce, 'unidentified bounce', 'not processed'); + + return false; + } + + private function reprocessUnidentified(SymfonyStyle $io): void + { + $io->section('Reprocessing unidentified bounces'); + $bounces = $this->bounceManager->findByStatus('unidentified bounce'); + $total = count($bounces); + $io->writeln(sprintf('%d bounces to reprocess', $total)); + $count = 0; $reparsed = 0; $reidentified = 0; + foreach ($bounces as $bounce) { + $count++; + if ($count % 25 === 0) { + $io->writeln(sprintf('%d out of %d processed', $count, $total)); + } + $decodedBody = $this->decodeBody($bounce->getHeader(), $bounce->getData()); + $userId = $this->findUserId($decodedBody); + $messageId = $this->findMessageId($decodedBody); + if ($userId || $messageId) { + $reparsed++; + if ($this->processBounceData($bounce->getId(), $messageId, $userId, new DateTimeImmutable())) { + $reidentified++; + } + } + } + $io->writeln(sprintf('%d out of %d processed', $count, $total)); + $io->writeln(sprintf('%d bounces were re-processed and %d bounces were re-identified', $reparsed, $reidentified)); + } + + private function processAdvancedRules(SymfonyStyle $io, int $batchSize): void + { + $io->section('Processing bounces based on active bounce rules'); + $rules = $this->ruleManager->loadActiveRules(); + if (!$rules) { + $io->writeln('No active rules'); + return; + } + + $total = $this->bounceManager->getUserMessageBounceCount(); + $fromId = 0; + $matched = 0; + $notmatched = 0; + $counter = 0; + + while ($counter < $total) { + $batch = $this->bounceManager->fetchUserMessageBounceBatch($fromId, $batchSize); + $counter += count($batch); + $io->writeln(sprintf('processed %d out of %d bounces for advanced bounce rules', min($counter, $total), $total)); + foreach ($batch as $row) { + $fromId = $row['umb']->getId(); + // $row has: bounce(header,data,id), umb(user,message,bounce) + $text = $row['bounce']->getHeader()."\n\n".$row['bounce']->getData(); + $rule = $this->ruleManager->matchBounceRules($text, $rules); + $userId = (int)$row['umb']->getUserId(); + $bounce = $row['bounce']; + $userdata = $userId ? $this->subscriberManager->getSubscriberById($userId) : null; + $confirmed = $userdata?->isConfirmed() ?? false; + $blacklisted = $userdata?->isBlacklisted() ?? false; + + if ($rule) { + $this->ruleManager->incrementCount($rule); + $rule->setCount($rule->getCount() + 1); + $this->ruleManager->linkRuleToBounce($rule, $bounce); + + switch ($rule->getAction()) { + case 'deleteuser': + if ($userdata) { + $this->logger->info('User deleted by bounce rule', ['user' => $userdata->getEmail(), 'rule' => $rule->getId()]); + $this->subscriberManager->deleteSubscriber($userdata); + } + break; + case 'unconfirmuser': + if ($userdata && $confirmed) { + $this->subscriberManager->markUnconfirmed($userId); + $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unconfirmed', 'Subscriber auto unconfirmed for bounce rule '.$rule->getId()); + } + break; + case 'deleteuserandbounce': + if ($userdata) { + $this->subscriberManager->deleteSubscriber($userdata); + } + $this->bounceManager->delete($bounce); + break; + case 'unconfirmuseranddeletebounce': + if ($userdata && $confirmed) { + $this->subscriberManager->markUnconfirmed($userId); + $this->subscriberHistoryManager->addHistory($userdata, 'Auto unconfirmed', 'Subscriber auto unconfirmed for bounce rule '.$rule->getId()); + } + $this->bounceManager->delete($bounce); + break; + case 'decreasecountconfirmuseranddeletebounce': + if ($userdata) { + $this->subscriberManager->decrementBounceCount($userdata); + if (!$confirmed) { + $this->subscriberManager->markConfirmed($userId); + $this->subscriberHistoryManager->addHistory($userdata, 'Auto confirmed', 'Subscriber auto confirmed for bounce rule '.$rule->getId()); + } + } + $this->bounceManager->delete($bounce); + break; + case 'blacklistuser': + if ($userdata && !$blacklisted) { + $this->subscriberManager->blacklist($userdata, 'Subscriber auto blacklisted by bounce rule '.$rule->getId()); + $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unsubscribed', 'User auto unsubscribed for bounce rule '.$rule->getId()); + } + break; + case 'blacklistuseranddeletebounce': + if ($userdata && !$blacklisted) { + $this->subscriberManager->blacklist($userdata, 'Subscriber auto blacklisted by bounce rule '.$rule->getId()); + $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unsubscribed', 'User auto unsubscribed for bounce rule '.$rule->getId()); + } + $this->bounceManager->delete($bounce); + break; + case 'blacklistemail': + if ($userdata) { + $this->subscriberManager->blacklist($userdata, 'Email address auto blacklisted by bounce rule '.$rule->getId()); + $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unsubscribed', 'email auto unsubscribed for bounce rule '.$rule->getId()); + } + break; + case 'blacklistemailanddeletebounce': + if ($userdata) { + $this->subscriberManager->blacklist($userdata, 'Email address auto blacklisted by bounce rule '.$rule->getId()); + $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unsubscribed', 'User auto unsubscribed for bounce rule '.$rule->getId()); + } + $this->bounceManager->delete($bounce); + break; + case 'deletebounce': + $this->bounceManager->delete($bounce); + break; + } + $matched++; + } else { + $notmatched++; + } + } + } + $io->writeln(sprintf('%d bounces processed by advanced processing', $matched)); + $io->writeln(sprintf('%d bounces were not matched by advanced processing rules', $notmatched)); + } + + // --- Consecutive bounces logic (mirrors final section) --- + private function handleConsecutiveBounces(SymfonyStyle $io, int $unsubscribeThreshold, int $blacklistThreshold): void + { + $io->section('Identifying consecutive bounces'); + $userIds = $this->bounces->distinctUsersWithBouncesConfirmedNotBlacklisted(); + $total = \count($userIds); + if ($total === 0) { + $io->writeln('Nothing to do'); + return; + } + $usercnt = 0; + foreach ($userIds as $userId) { + $usercnt++; + $history = $this->bounces->userMessageHistoryWithBounces($userId); // ordered desc, includes bounce status/comment + $cnt = 0; $removed = false; $msgokay = false; $unsubscribed = false; + foreach ($history as $bounce) { + if (stripos($bounce->status ?? '', 'duplicate') === false && stripos($bounce->comment ?? '', 'duplicate') === false) { + if ($bounce->bounceId) { // there is a bounce + $cnt++; + if ($cnt >= $unsubscribeThreshold) { + if (!$unsubscribed) { + $email = $this->users->emailById($userId); + $this->users->markUnconfirmed($userId); + $this->users->addHistory($email, 'Auto Unconfirmed', sprintf('Subscriber auto unconfirmed for %d consecutive bounces', $cnt)); + $unsubscribed = true; + } + if ($blacklistThreshold > 0 && $cnt >= $blacklistThreshold) { + $email = $this->users->emailById($userId); + $this->users->blacklistByEmail($email, sprintf('%d consecutive bounces, threshold reached', $cnt)); + $removed = true; + } + } + } else { // empty bounce means message received ok + $cnt = 0; + $msgokay = true; + break; + } + } + if ($removed || $msgokay) { break; } + } + if ($usercnt % 5 === 0) { + $io->writeln(sprintf('processed %d out of %d subscribers', $usercnt, $total)); + } + } + $io->writeln(sprintf('total of %d subscribers processed', $total)); + } + + // --- Helpers: decoding and parsing --- + private function decodeBody(string $header, string $body): string + { + $transferEncoding = ''; + if (preg_match('/Content-Transfer-Encoding: ([\w-]+)/i', $header, $regs)) { + $transferEncoding = strtolower($regs[1]); + } + $decoded = null; + switch ($transferEncoding) { + case 'quoted-printable': + $decoded = quoted_printable_decode($body); + break; + case 'base64': + $decoded = base64_decode($body) ?: ''; + break; + default: + $decoded = $body; + } + return $decoded; + } + + private function findMessageId(string $text): string|int|null + { + if (preg_match('/(?:X-MessageId|X-Message): (.*)\r\n/iU', $text, $match)) { + return trim($match[1]); + } + return null; + } + + private function findUserId(string $text): ?int + { + // Try X-ListMember / X-User first + if (preg_match('/(?:X-ListMember|X-User): (.*)\r\n/iU', $text, $match)) { + $user = trim($match[1]); + if (str_contains($user, '@')) { + return $this->users->idByEmail($user); + } elseif (preg_match('/^\d+$/', $user)) { + return (int)$user; + } elseif ($user !== '') { + return $this->users->idByUniqId($user); + } + } + // Fallback: parse any email in the body and see if it is a subscriber + if (preg_match_all('/[._a-zA-Z0-9-]+@[.a-zA-Z0-9-]+/', $text, $regs)) { + foreach ($regs[0] as $email) { + $id = $this->users->idByEmail($email); + if ($id) { return $id; } + } + } + return null; + } +} diff --git a/src/Domain/Messaging/Model/BounceRegexBounce.php b/src/Domain/Messaging/Model/BounceRegexBounce.php index 9dbd3168..e815cd1f 100644 --- a/src/Domain/Messaging/Model/BounceRegexBounce.php +++ b/src/Domain/Messaging/Model/BounceRegexBounce.php @@ -13,38 +13,38 @@ class BounceRegexBounce implements DomainModel { #[ORM\Id] - #[ORM\Column(type: 'integer')] - private int $regex; + #[ORM\Column(name: 'regex', type: 'integer')] + private int $regexId; #[ORM\Id] - #[ORM\Column(type: 'integer')] - private int $bounce; + #[ORM\Column(name: 'bounce', type: 'integer')] + private int $bounceId; - public function __construct(int $regex, int $bounce) + public function __construct(int $regexId, int $bounceId) { - $this->regex = $regex; - $this->bounce = $bounce; + $this->regexId = $regexId; + $this->bounceId = $bounceId; } - public function getRegex(): int + public function getRegexId(): int { - return $this->regex; + return $this->regexId; } - public function setRegex(int $regex): self + public function setRegexId(int $regexId): self { - $this->regex = $regex; + $this->regexId = $regexId; return $this; } - public function getBounce(): int + public function getBounceId(): int { - return $this->bounce; + return $this->bounceId; } - public function setBounce(int $bounce): self + public function setBounceId(int $bounceId): self { - $this->bounce = $bounce; + $this->bounceId = $bounceId; return $this; } } diff --git a/src/Domain/Messaging/Model/UserMessageBounce.php b/src/Domain/Messaging/Model/UserMessageBounce.php index ccb05597..5da0d139 100644 --- a/src/Domain/Messaging/Model/UserMessageBounce.php +++ b/src/Domain/Messaging/Model/UserMessageBounce.php @@ -31,15 +31,15 @@ class UserMessageBounce implements DomainModel, Identity private int $messageId; #[ORM\Column(name: 'bounce', type: 'integer')] - private int $bounce; + private int $bounceId; #[ORM\Column(name: 'time', type: 'datetime', options: ['default' => 'CURRENT_TIMESTAMP'])] private DateTime $createdAt; - public function __construct(int $bounce) + public function __construct(int $bounceId, DateTime $createdAt) { - $this->bounce = $bounce; - $this->createdAt = new DateTime(); + $this->bounceId = $bounceId; + $this->createdAt = $createdAt; } public function getId(): ?int @@ -57,9 +57,9 @@ public function getMessageId(): int return $this->messageId; } - public function getBounce(): int + public function getBounceId(): int { - return $this->bounce; + return $this->bounceId; } public function getCreatedAt(): DateTime @@ -79,9 +79,9 @@ public function setMessageId(int $messageId): self return $this; } - public function setBounce(int $bounce): self + public function setBounceId(int $bounceId): self { - $this->bounce = $bounce; + $this->bounceId = $bounceId; return $this; } } diff --git a/src/Domain/Messaging/Repository/BounceRegexRepository.php b/src/Domain/Messaging/Repository/BounceRegexRepository.php index f5088376..2696cac5 100644 --- a/src/Domain/Messaging/Repository/BounceRegexRepository.php +++ b/src/Domain/Messaging/Repository/BounceRegexRepository.php @@ -17,4 +17,16 @@ public function findOneByRegexHash(string $regexHash): ?BounceRegex { return $this->findOneBy(['regexHash' => $regexHash]); } + + /** @return BounceRegex[] */ + public function fetchAllOrdered(): array + { + return $this->findBy([], ['listOrder' => 'ASC']); + } + + /** @return BounceRegex[] */ + public function fetchActiveOrdered(): array + { + return $this->findBy(['active' => true], ['listOrder' => 'ASC']); + } } diff --git a/src/Domain/Messaging/Repository/BounceRepository.php b/src/Domain/Messaging/Repository/BounceRepository.php index fa691a28..410f5da1 100644 --- a/src/Domain/Messaging/Repository/BounceRepository.php +++ b/src/Domain/Messaging/Repository/BounceRepository.php @@ -7,8 +7,15 @@ use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; +use PhpList\Core\Domain\Messaging\Model\Bounce; class BounceRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + /** @return Bounce[] */ + public function findByStatus(string $status): array + { + return $this->findBy(['status' => $status]); + } } diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php index cf802300..3da7ebf3 100644 --- a/src/Domain/Messaging/Repository/MessageRepository.php +++ b/src/Domain/Messaging/Repository/MessageRepository.php @@ -63,4 +63,15 @@ public function getMessagesByList(SubscriberList $list): array ->getQuery() ->getResult(); } + + public function incrementBounceCount(int $messageId): void + { + $this->createQueryBuilder('m') + ->update() + ->set('m.bounceCount', 'm.bounceCount + 1') + ->where('m.id = :messageId') + ->setParameter('messageId', $messageId) + ->getQuery() + ->execute(); + } } diff --git a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php index 16f07f79..51c96fa2 100644 --- a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php +++ b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php @@ -7,6 +7,8 @@ use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; +use PhpList\Core\Domain\Messaging\Model\Bounce; +use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; class UserMessageBounceRepository extends AbstractRepository implements PaginatableRepositoryInterface { @@ -21,4 +23,35 @@ public function getCountByMessageId(int $messageId): int ->getQuery() ->getSingleScalarResult(); } + + public function existsByMessageIdAndUserId(int $messageId, int $subscriberId): bool + { + $qb = $this->createQueryBuilder('umb') + ->select('1') + ->where('umb.messageId = :messageId') + ->andWhere('umb.userId = :userId') + ->setParameter('messageId', $messageId) + ->setParameter('userId', $subscriberId) + ->setMaxResults(1); + + return (bool) $qb->getQuery()->getOneOrNullResult(); + } + + /** + * @return array + */ + public function getPaginatedWithJoinNoRelation(int $fromId, int $limit): array + { + return $this->getEntityManager() + ->createQueryBuilder() + ->select('umb', 'bounce') + ->from(UserMessageBounce::class, 'umb') + ->innerJoin(Bounce::class, 'bounce', 'WITH', 'bounce.id = umb.bounce') + ->where('umb.id > :id') + ->setParameter('id', $fromId) + ->orderBy('umb.id', 'ASC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } } diff --git a/src/Domain/Messaging/Service/Manager/BounceManager.php b/src/Domain/Messaging/Service/Manager/BounceManager.php index 6f18851b..d3de1295 100644 --- a/src/Domain/Messaging/Service/Manager/BounceManager.php +++ b/src/Domain/Messaging/Service/Manager/BounceManager.php @@ -5,27 +5,34 @@ namespace PhpList\Core\Domain\Messaging\Service\Manager; use DateTime; +use DateTimeImmutable; use PhpList\Core\Domain\Messaging\Model\Bounce; +use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; use PhpList\Core\Domain\Messaging\Repository\BounceRepository; +use PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository; class BounceManager { private BounceRepository $bounceRepository; + private UserMessageBounceRepository $userMessageBounceRepository; - public function __construct(BounceRepository $bounceRepository) - { + public function __construct( + BounceRepository $bounceRepository, + UserMessageBounceRepository $userMessageBounceRepository + ) { $this->bounceRepository = $bounceRepository; + $this->userMessageBounceRepository = $userMessageBounceRepository; } public function create( - ?DateTime $date = null, + ?DateTimeImmutable $date = null, ?string $header = null, ?string $data = null, ?string $status = null, ?string $comment = null ): Bounce { $bounce = new Bounce( - date: $date, + date: DateTime::createFromImmutable($date), header: $header, data: $data, status: $status, @@ -37,9 +44,13 @@ public function create( return $bounce; } - public function save(Bounce $bounce): void + public function update(Bounce $bounce, ?string $status = null, ?string $comment = null): Bounce { + $bounce->setStatus($status); + $bounce->setComment($comment); $this->bounceRepository->save($bounce); + + return $bounce; } public function delete(Bounce $bounce): void @@ -59,4 +70,40 @@ public function getById(int $id): ?Bounce $found = $this->bounceRepository->find($id); return $found; } + + public function linkUserMessageBounce( + Bounce $bounce, + DateTimeImmutable $date, + int $subscriberId, + ?int $messageId = -1 + ): UserMessageBounce { + $userMessageBounce = new UserMessageBounce($bounce->getId(), DateTime::createFromImmutable($date)); + $userMessageBounce->setUserId($subscriberId); + $userMessageBounce->setMessageId($messageId); + + return $userMessageBounce; + } + + public function existsUserMessageBounce(int $subscriberId, int $messageId): bool + { + return $this->userMessageBounceRepository->existsByMessageIdAndUserId($messageId, $subscriberId); + } + + /** @return Bounce[] */ + public function findByStatus(string $status): array + { + return $this->bounceRepository->findByStatus($status); + } + + public function getUserMessageBounceCount(): int + { + return $this->userMessageBounceRepository->count(); + } + + /** + * @return array + */ public function fetchUserMessageBounceBatch(int $fromId, int $batchSize): array + { + return $this->userMessageBounceRepository->getPaginatedWithJoinNoRelation($fromId, $batchSize); + } } diff --git a/src/Domain/Messaging/Service/Manager/BounceRuleManager.php b/src/Domain/Messaging/Service/Manager/BounceRuleManager.php new file mode 100644 index 00000000..bc0a64a3 --- /dev/null +++ b/src/Domain/Messaging/Service/Manager/BounceRuleManager.php @@ -0,0 +1,100 @@ + + */ + public function loadActiveRules(): array + { + return $this->mapRows($this->repository->fetchActiveOrdered()); + } + + /** + * @return array + */ + public function loadAllRules(): array + { + return $this->mapRows($this->repository->fetchAllOrdered()); + } + + /** + * Internal helper to normalize repository rows into the legacy shape. + * + * @param BounceRegex[] $rows + * @return array + */ + private function mapRows(array $rows): array + { + $result = []; + + foreach ($rows as $row) { + $regex = $row->getRegex(); + $action = $row->getAction(); + $id = $row->getId(); + + if ( + !is_string($regex) + || $regex === '' + || !is_string($action) + || $action === '' + || !is_int($id) + ) { + continue; + } + + $result[$regex] = $row; + } + + return $result; + } + + + /** + * @param array $rules + */ + public function matchBounceRules(string $text, array $rules): ?BounceRegex + { + foreach ($rules as $pattern => $rule) { + $pattern = str_replace(' ', '\s+', $pattern); + if (@preg_match('/'.preg_quote($pattern).'/iUm', $text)) { + return $rule; + } elseif (@preg_match('/'.$pattern.'/iUm', $text)) { + return $rule; + } + } + + return null; + } + + public function incrementCount(BounceRegex $rule): void + { + $rule->setCount($rule->getCount() + 1); + + $this->repository->save($rule); + } + + public function linkRuleToBounce(BounceRegex $rule, Bounce $bounce): BounceregexBounce + { + $relation = new BounceRegexBounce($rule->getId(), $bounce->getId()); + $this->bounceRegexBounceRepository->save($relation); + + return $relation; + } +} diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index 6ebaee70..46bb767b 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -141,4 +141,15 @@ public function isEmailBlacklisted(string $email): bool return !($queryBuilder->getQuery()->getOneOrNullResult() === null); } + + public function incrementBounceCount(int $subscriberId): void + { + $this->createQueryBuilder('s') + ->update() + ->set('s.bounceCount', 's.bounceCount + 1') + ->where('s.id = :subscriberId') + ->setParameter('subscriberId', $subscriberId) + ->getQuery() + ->execute(); + } } diff --git a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php index d30bae2d..ce31b0fb 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php @@ -58,6 +58,17 @@ public function addEmailToBlacklist(string $email, ?string $reasonData = null): return $blacklistEntry; } + public function addBlacklistData(string $email, string $name, string $data): void + { + $blacklistData = new UserBlacklistData(); + $blacklistData->setEmail($email); + $blacklistData->setName($name); + $blacklistData->setData($data); + $this->entityManager->persist($blacklistData); + $this->entityManager->flush(); + + } + public function removeEmailFromBlacklist(string $email): void { $blacklistEntry = $this->userBlacklistRepository->findOneByEmail($email); diff --git a/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php b/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php index 4760acd8..bac2ef8d 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php @@ -4,20 +4,44 @@ namespace PhpList\Core\Domain\Subscription\Service\Manager; +use PhpList\Core\Domain\Common\ClientIpResolver; +use PhpList\Core\Domain\Common\SystemInfoCollector; use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberHistoryFilter; +use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Model\SubscriberHistory; use PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository; class SubscriberHistoryManager { private SubscriberHistoryRepository $repository; + private ClientIpResolver $clientIpResolver; + private SystemInfoCollector $systemInfoCollector; - public function __construct(SubscriberHistoryRepository $repository) - { + public function __construct( + SubscriberHistoryRepository $repository, + ClientIpResolver $clientIpResolver, + SystemInfoCollector $systemInfoCollector, + ) { $this->repository = $repository; + $this->clientIpResolver = $clientIpResolver; + $this->systemInfoCollector = $systemInfoCollector; } public function getHistory(int $lastId, int $limit, SubscriberHistoryFilter $filter): array { return $this->repository->getFilteredAfterId($lastId, $limit, $filter); } + + public function addHistory(Subscriber $subscriber, string $message, ?string $details = null): SubscriberHistory + { + $subscriberHistory = new SubscriberHistory($subscriber); + $subscriberHistory->setSummary($message); + $subscriberHistory->setDetail($details ?? $message); + $subscriberHistory->setSystemInfo($this->systemInfoCollector->collectAsString()); + $subscriberHistory->setIp($this->clientIpResolver->resolve()); + + $this->repository->save($subscriberHistory); + + return $subscriberHistory; + } } diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php index e036f195..a8c1e0df 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php @@ -11,6 +11,7 @@ use PhpList\Core\Domain\Subscription\Model\Dto\UpdateSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; +use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Messenger\MessageBusInterface; @@ -21,17 +22,20 @@ class SubscriberManager private EntityManagerInterface $entityManager; private MessageBusInterface $messageBus; private SubscriberDeletionService $subscriberDeletionService; + private SubscriberBlacklistService $blacklistService; public function __construct( SubscriberRepository $subscriberRepository, EntityManagerInterface $entityManager, MessageBusInterface $messageBus, - SubscriberDeletionService $subscriberDeletionService + SubscriberDeletionService $subscriberDeletionService, + SubscriberBlacklistService $blacklistService ) { $this->subscriberRepository = $subscriberRepository; $this->entityManager = $entityManager; $this->messageBus = $messageBus; $this->subscriberDeletionService = $subscriberDeletionService; + $this->blacklistService = $blacklistService; } public function createSubscriber(CreateSubscriberDto $subscriberDto): Subscriber @@ -75,6 +79,35 @@ public function getSubscriber(int $subscriberId): Subscriber return $subscriber; } + public function getSubscriberById(int $subscriberId): ?Subscriber + { + return $this->subscriberRepository->find($subscriberId); + } + + public function markUnconfirmed(int $subscriberId): void + { + $this->subscriberRepository->createQueryBuilder('s') + ->update() + ->set('s.confirmed', ':confirmed') + ->where('s.id = :id') + ->setParameter('confirmed', false) + ->setParameter('id', $subscriberId) + ->getQuery() + ->execute(); + } + + public function markConfirmed(int $subscriberId): void + { + $this->subscriberRepository->createQueryBuilder('s') + ->update() + ->set('s.confirmed', ':confirmed') + ->where('s.id = :id') + ->setParameter('confirmed', true) + ->setParameter('id', $subscriberId) + ->getQuery() + ->execute(); + } + public function updateSubscriber(UpdateSubscriberDto $subscriberDto): Subscriber { /** @var Subscriber $subscriber */ @@ -140,4 +173,15 @@ public function updateFromImport(Subscriber $existingSubscriber, ImportSubscribe return $existingSubscriber; } + + public function blacklist(Subscriber $subscriber, string $reason): void + { + $this->blacklistService->blacklist($subscriber, $reason); + } + + public function decrementBounceCount(Subscriber $subscriber): void + { + $subscriber->addToBounceCount(-1); + $this->entityManager->flush(); + } } diff --git a/src/Domain/Subscription/Service/SubscriberBlacklistService.php b/src/Domain/Subscription/Service/SubscriberBlacklistService.php new file mode 100644 index 00000000..b89a7f04 --- /dev/null +++ b/src/Domain/Subscription/Service/SubscriberBlacklistService.php @@ -0,0 +1,54 @@ +entityManager = $entityManager; + $this->blacklistManager = $blacklistManager; + $this->historyManager = $historyManager; + } + + public function blacklist(Subscriber $subscriber, string $reason): void + { + $subscriber->setBlacklisted(true); + $this->entityManager->flush($subscriber); + $this->blacklistManager->addEmailToBlacklist($subscriber->getEmail(), $reason); + + foreach (array('REMOTE_ADDR','HTTP_X_FORWARDED_FOR') as $item) { + if (isset($_SERVER[$item])) { + $this->blacklistManager->addBlacklistData($subscriber->getEmail(), $item, $_SERVER[$item]); + } + } + + $this->historyManager->addHistory( + subscriber: $subscriber, + message: 'Added to blacklist', + details: sprintf('Added to blacklist for reason %s', $reason) + ); + + if (isset($GLOBALS['plugins']) && is_array($GLOBALS['plugins'])) { + foreach ($GLOBALS['plugins'] as $pluginName => $plugin) { + if (method_exists($plugin, 'blacklistEmail')) { + $plugin->blacklistEmail($subscriber->getEmail(), $reason); + } + } + } + } +} From 7630187f54f024bf83ea41335722e936687f6e5e Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 22 Aug 2025 11:50:59 +0400 Subject: [PATCH 07/24] ProcessBouncesCommand all methods --- .../Command/ProcessBouncesCommand.php | 65 ++++++++++--------- .../UserMessageBounceRepository.php | 36 ++++++++++ .../Service/Manager/BounceManager.php | 13 +++- .../Repository/SubscriberRepository.php | 12 ++++ 4 files changed, 94 insertions(+), 32 deletions(-) diff --git a/src/Domain/Messaging/Command/ProcessBouncesCommand.php b/src/Domain/Messaging/Command/ProcessBouncesCommand.php index 0bdd6ff8..eb8b8743 100644 --- a/src/Domain/Messaging/Command/ProcessBouncesCommand.php +++ b/src/Domain/Messaging/Command/ProcessBouncesCommand.php @@ -7,6 +7,8 @@ use DateTimeImmutable; use Exception; use PhpList\Core\Domain\Messaging\Model\Bounce; +use PhpList\Core\Domain\Messaging\Model\UserMessage; +use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\LockService; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; @@ -35,8 +37,8 @@ protected function configure(): void ->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Mailbox password') ->addOption('mailbox', null, InputOption::VALUE_OPTIONAL, 'Mailbox name(s) for POP (comma separated) or mbox file path', 'INBOX') ->addOption('maximum', null, InputOption::VALUE_OPTIONAL, 'Max messages to process per run', '1000') - ->addOption('purge', null, InputOption::VALUE_NONE, 'Delete processed messages from mailbox') - ->addOption('purge-unprocessed', null, InputOption::VALUE_NONE, 'Delete unprocessed messages from mailbox') + ->addOption('purge', null, InputOption::VALUE_NONE, 'Delete/remove processed messages from mailbox') + ->addOption('purge-unprocessed', null, InputOption::VALUE_NONE, 'Delete/remove unprocessed messages from mailbox') ->addOption('rules-batch-size', null, InputOption::VALUE_OPTIONAL, 'Advanced rules batch size', '1000') ->addOption('unsubscribe-threshold', null, InputOption::VALUE_OPTIONAL, 'Consecutive bounces threshold to unconfirm user', '3') ->addOption('blacklist-threshold', null, InputOption::VALUE_OPTIONAL, 'Consecutive bounces threshold to blacklist email (0 to disable)', '0') @@ -53,6 +55,7 @@ public function __construct( private readonly LoggerInterface $logger, private readonly SubscriberManager $subscriberManager, private readonly SubscriberHistoryManager $subscriberHistoryManager, + private readonly SubscriberRepository $subscriberRepository, ) { parent::__construct(); } @@ -441,45 +444,53 @@ private function processAdvancedRules(SymfonyStyle $io, int $batchSize): void $io->writeln(sprintf('%d bounces were not matched by advanced processing rules', $notmatched)); } - // --- Consecutive bounces logic (mirrors final section) --- private function handleConsecutiveBounces(SymfonyStyle $io, int $unsubscribeThreshold, int $blacklistThreshold): void { $io->section('Identifying consecutive bounces'); - $userIds = $this->bounces->distinctUsersWithBouncesConfirmedNotBlacklisted(); - $total = \count($userIds); + $users = $this->subscriberRepository->distinctUsersWithBouncesConfirmedNotBlacklisted(); + $total = count($users); if ($total === 0) { $io->writeln('Nothing to do'); return; } $usercnt = 0; - foreach ($userIds as $userId) { + foreach ($users as $user) { $usercnt++; - $history = $this->bounces->userMessageHistoryWithBounces($userId); // ordered desc, includes bounce status/comment + $history = $this->bounceManager->getUserMessageHistoryWithBounces($user); $cnt = 0; $removed = false; $msgokay = false; $unsubscribed = false; foreach ($history as $bounce) { - if (stripos($bounce->status ?? '', 'duplicate') === false && stripos($bounce->comment ?? '', 'duplicate') === false) { - if ($bounce->bounceId) { // there is a bounce + /** @var $bounce array{um: UserMessage, umb: UserMessageBounce|null, b: Bounce|null} */ + if ( + stripos($bounce['b']->getStatus() ?? '', 'duplicate') === false + && stripos($bounce['b']->getComment() ?? '', 'duplicate') === false + ) { + if ($bounce['b']->getId()) { $cnt++; if ($cnt >= $unsubscribeThreshold) { if (!$unsubscribed) { - $email = $this->users->emailById($userId); - $this->users->markUnconfirmed($userId); - $this->users->addHistory($email, 'Auto Unconfirmed', sprintf('Subscriber auto unconfirmed for %d consecutive bounces', $cnt)); + $this->subscriberManager->markUnconfirmed($user->getId()); + $this->subscriberHistoryManager->addHistory( + subscriber: $user, + message: 'Auto Unconfirmed', + details: sprintf('Subscriber auto unconfirmed for %d consecutive bounces', $cnt) + ); $unsubscribed = true; } if ($blacklistThreshold > 0 && $cnt >= $blacklistThreshold) { - $email = $this->users->emailById($userId); - $this->users->blacklistByEmail($email, sprintf('%d consecutive bounces, threshold reached', $cnt)); + $this->subscriberManager->blacklist( + subscriber: $user, + reason: sprintf('%d consecutive bounces, threshold reached', $cnt) + ); $removed = true; } } - } else { // empty bounce means message received ok - $cnt = 0; - $msgokay = true; + } else { break; } } - if ($removed || $msgokay) { break; } + if ($removed || $msgokay) { + break; + } } if ($usercnt % 5 === 0) { $io->writeln(sprintf('processed %d out of %d subscribers', $usercnt, $total)); @@ -488,25 +499,17 @@ private function handleConsecutiveBounces(SymfonyStyle $io, int $unsubscribeThre $io->writeln(sprintf('total of %d subscribers processed', $total)); } - // --- Helpers: decoding and parsing --- private function decodeBody(string $header, string $body): string { $transferEncoding = ''; if (preg_match('/Content-Transfer-Encoding: ([\w-]+)/i', $header, $regs)) { $transferEncoding = strtolower($regs[1]); } - $decoded = null; - switch ($transferEncoding) { - case 'quoted-printable': - $decoded = quoted_printable_decode($body); - break; - case 'base64': - $decoded = base64_decode($body) ?: ''; - break; - default: - $decoded = $body; - } - return $decoded; + return match ($transferEncoding) { + 'quoted-printable' => quoted_printable_decode($body), + 'base64' => base64_decode($body) ?: '', + default => $body, + }; } private function findMessageId(string $text): string|int|null diff --git a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php index 51c96fa2..5afe91f0 100644 --- a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php +++ b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php @@ -8,7 +8,9 @@ use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; use PhpList\Core\Domain\Messaging\Model\Bounce; +use PhpList\Core\Domain\Messaging\Model\UserMessage; use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; +use PhpList\Core\Domain\Subscription\Model\Subscriber; class UserMessageBounceRepository extends AbstractRepository implements PaginatableRepositoryInterface { @@ -54,4 +56,38 @@ public function getPaginatedWithJoinNoRelation(int $fromId, int $limit): array ->getQuery() ->getResult(); } + + /** + * @return array + */ + public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array + { + $qb = $this->getEntityManager() + ->createQueryBuilder() + ->select('um', 'umb', 'b') + ->from(UserMessage::class, 'um') + ->leftJoin( + join: UserMessageBounce::class, + alias: 'umb', + conditionType: 'WITH', + condition: 'umb.messageId = IDENTITY(um.message) AND umb.userId = IDENTITY(um.user)' + ) + ->leftJoin( + join: Bounce::class, + alias: 'b', + conditionType: 'WITH', + condition: 'b.id = umb.bounceId' + ) + ->where('um.user = :userId') + ->andWhere('um.status = :status') + ->setParameter('userId', $subscriber->getId()) + ->setParameter('status', 'sent') + ->orderBy('um.entered', 'DESC'); + + return $qb->getQuery()->getResult(); + } } diff --git a/src/Domain/Messaging/Service/Manager/BounceManager.php b/src/Domain/Messaging/Service/Manager/BounceManager.php index d3de1295..aa313861 100644 --- a/src/Domain/Messaging/Service/Manager/BounceManager.php +++ b/src/Domain/Messaging/Service/Manager/BounceManager.php @@ -7,9 +7,11 @@ use DateTime; use DateTimeImmutable; use PhpList\Core\Domain\Messaging\Model\Bounce; +use PhpList\Core\Domain\Messaging\Model\UserMessage; use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; use PhpList\Core\Domain\Messaging\Repository\BounceRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository; +use PhpList\Core\Domain\Subscription\Model\Subscriber; class BounceManager { @@ -102,8 +104,17 @@ public function getUserMessageBounceCount(): int /** * @return array - */ public function fetchUserMessageBounceBatch(int $fromId, int $batchSize): array + */ + public function fetchUserMessageBounceBatch(int $fromId, int $batchSize): array { return $this->userMessageBounceRepository->getPaginatedWithJoinNoRelation($fromId, $batchSize); } + + /** + * @return array + */ + public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array + { + return $this->userMessageBounceRepository->getUserMessageHistoryWithBounces($subscriber); + } } diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index 46bb767b..71e835de 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -152,4 +152,16 @@ public function incrementBounceCount(int $subscriberId): void ->getQuery() ->execute(); } + + /** @return Subscriber[] */ + public function distinctUsersWithBouncesConfirmedNotBlacklisted(): array + { + return $this->createQueryBuilder('s') + ->select('s.id') + ->where('s.bounceCount > 0') + ->andWhere('s.confirmed = 1') + ->andWhere('s.blacklisted = 0') + ->getQuery() + ->getScalarResult(); + } } From 538ffae58b38eee477a854204c3739907a7ad0f5 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 22 Aug 2025 12:31:06 +0400 Subject: [PATCH 08/24] BounceProcessingService --- config/services/commands.yml | 4 + config/services/processor.yml | 11 + .../Command/ProcessBouncesCommand.php | 255 ++---------------- .../Service/BounceProcessingService.php | 228 ++++++++++++++++ .../Processor/BounceProtocolProcessor.php | 24 ++ .../Service/Processor/MboxBounceProcessor.php | 52 ++++ .../Service/Processor/PopBounceProcessor.php | 66 +++++ .../Service/Manager/SubscriberManager.php | 5 + 8 files changed, 409 insertions(+), 236 deletions(-) create mode 100644 config/services/processor.yml create mode 100644 src/Domain/Messaging/Service/BounceProcessingService.php create mode 100644 src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php create mode 100644 src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php create mode 100644 src/Domain/Messaging/Service/Processor/PopBounceProcessor.php diff --git a/config/services/commands.yml b/config/services/commands.yml index 5cc1a241..d9305748 100644 --- a/config/services/commands.yml +++ b/config/services/commands.yml @@ -11,3 +11,7 @@ services: PhpList\Core\Domain\Identity\Command\: resource: '../../src/Domain/Identity/Command' tags: ['console.command'] + + PhpList\Core\Domain\Messaging\Command\ProcessBouncesCommand: + arguments: + $protocolProcessors: !tagged_iterator 'phplist.bounce_protocol_processor' diff --git a/config/services/processor.yml b/config/services/processor.yml new file mode 100644 index 00000000..794e4df5 --- /dev/null +++ b/config/services/processor.yml @@ -0,0 +1,11 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + PhpList\Core\Domain\Messaging\Service\Processor\PopBounceProcessor: + tags: ['phplist.bounce_protocol_processor'] + + PhpList\Core\Domain\Messaging\Service\Processor\MboxBounceProcessor: + tags: ['phplist.bounce_protocol_processor'] diff --git a/src/Domain/Messaging/Command/ProcessBouncesCommand.php b/src/Domain/Messaging/Command/ProcessBouncesCommand.php index eb8b8743..01f39586 100644 --- a/src/Domain/Messaging/Command/ProcessBouncesCommand.php +++ b/src/Domain/Messaging/Command/ProcessBouncesCommand.php @@ -9,7 +9,8 @@ use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Model\UserMessage; use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; -use PhpList\Core\Domain\Messaging\Repository\MessageRepository; +use PhpList\Core\Domain\Messaging\Service\BounceProcessingService; +use PhpList\Core\Domain\Messaging\Service\Processor\BounceProtocolProcessor; use PhpList\Core\Domain\Messaging\Service\LockService; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager; @@ -48,14 +49,15 @@ protected function configure(): void public function __construct( private readonly BounceManager $bounceManager, - private readonly SubscriberRepository $users, - private readonly MessageRepository $messages, private readonly BounceRuleManager $ruleManager, private readonly LockService $lockService, private readonly LoggerInterface $logger, private readonly SubscriberManager $subscriberManager, private readonly SubscriberHistoryManager $subscriberHistoryManager, private readonly SubscriberRepository $subscriberRepository, + private readonly BounceProcessingService $processingService, + /** @var iterable */ + private readonly iterable $protocolProcessors, ) { parent::__construct(); } @@ -82,62 +84,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { $io->title('Processing bounces'); $protocol = (string)$input->getOption('protocol'); - $testMode = (bool)$input->getOption('test'); - $max = (int)$input->getOption('maximum'); - $purgeProcessed = $input->getOption('purge') && !$testMode; - $purgeUnprocessed = $input->getOption('purge-unprocessed') && !$testMode; $downloadReport = ''; - if ($protocol === 'pop') { - $host = (string)$input->getOption('host'); - $user = (string)$input->getOption('user'); - $password = (string)$input->getOption('password'); - $port = (string)$input->getOption('port'); - $mailboxes = (string)$input->getOption('mailbox'); - - if (!$host || !$user || !$password) { - $io->error('POP configuration incomplete: host, user, and password are required.'); - - return Command::FAILURE; - } - - foreach (explode(',', $mailboxes) as $mailboxName) { - $mailboxName = trim($mailboxName); - if ($mailboxName === '') { $mailboxName = 'INBOX'; } - $mailbox = sprintf('{%s:%s}%s', $host, $port, $mailboxName); - $io->section("Connecting to $mailbox"); - - $link = @imap_open($mailbox, $user, $password); - if (!$link) { - $io->error('Cannot create connection to '.$mailbox.': '.imap_last_error()); - - return Command::FAILURE; - } - - $downloadReport .= $this->processMessages($io, $link, $max, $purgeProcessed, $purgeUnprocessed, $testMode); - } - } elseif ($protocol === 'mbox') { - $file = (string)$input->getOption('mailbox'); - if (!$file) { - $io->error('mbox file path must be provided with --mailbox.'); - - return Command::FAILURE; + $processor = null; + foreach ($this->protocolProcessors as $p) { + if ($p->getProtocol() === $protocol) { + $processor = $p; + break; } - $io->section("Opening mbox $file"); - $link = @imap_open($file, '', '', $testMode ? 0 : CL_EXPUNGE); - if (!$link) { - $io->error('Cannot open mailbox file: '.imap_last_error()); + } - return Command::FAILURE; - } - $downloadReport .= $this->processMessages($io, $link, $max, $purgeProcessed, $purgeUnprocessed, $testMode); - } else { + if ($processor === null) { $io->error('Unsupported protocol: '.$protocol); - return Command::FAILURE; } + $downloadReport .= $processor->process($input, $io); + // Reprocess unidentified bounces (status = "unidentified bounce") $this->reprocessUnidentified($io); @@ -169,144 +133,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - private function processMessages(SymfonyStyle $io, $link, int $max, bool $purgeProcessed, bool $purgeUnprocessed, bool $testMode): string - { - $num = imap_num_msg($link); - $io->writeln(sprintf('%d bounces to fetch from the mailbox', $num)); - if ($num === 0) { - imap_close($link); - - return ''; - } - $io->writeln('Please do not interrupt this process'); - if ($num > $max) { - $io->writeln(sprintf('Processing first %d bounces', $max)); - $num = $max; - } - $io->writeln($testMode ? 'Running in test mode, not deleting messages from mailbox' : 'Processed messages will be deleted from the mailbox'); - - for ($x = 1; $x <= $num; $x++) { - $header = imap_fetchheader($link, $x); - $processed = $this->processImapBounce($link, $x, $header, $io); - if ($processed) { - if (!$testMode && $purgeProcessed) { - imap_delete($link, (string)$x); - } - } else { - if (!$testMode && $purgeUnprocessed) { - imap_delete($link, (string)$x); - } - } - } - - $io->writeln('Closing mailbox, and purging messages'); - if (!$testMode) { - imap_close($link, CL_EXPUNGE); - } else { - imap_close($link); - } - - return ''; - } - - private function processImapBounce($link, int $num, string $header, SymfonyStyle $io): bool - { - $headerInfo = imap_headerinfo($link, $num); - $date = $headerInfo->date ?? null; - $bounceDate = $date ? new DateTimeImmutable($date) : new DateTimeImmutable(); - $body = imap_body($link, $num); - $body = $this->decodeBody($header, $body); - - // Quick hack: ignore MsExchange delayed notices (as in original) - if (preg_match('/Action: delayed\s+Status: 4\.4\.7/im', $body)) { - return true; - } - - $msgId = $this->findMessageId($body); - $userId = $this->findUserId($body); - - $bounce = $this->bounceManager->create($bounceDate, $header, $body); - - return $this->processBounceData($bounce, $msgId, $userId, $bounceDate); - } - - private function processBounceData( - Bounce $bounce, - string|int|null $msgId, - ?int $userId, - DateTimeImmutable $bounceDate, - ): bool { - $msgId = $msgId ?: null; - if ($userId) { - $user = $this->subscriberManager->getSubscriberById($userId); - } - - if ($msgId === 'systemmessage' && $userId) { - $this->bounceManager->update( - bounce: $bounce, - status: 'bounced system message', - comment: sprintf('%d marked unconfirmed', $userId)) - ; - $this->bounceManager->linkUserMessageBounce($bounce,$bounceDate, $userId); - $this->subscriberManager->markUnconfirmed($userId); - $this->logger->info('system message bounced, user marked unconfirmed', ['userId' => $userId]); - $this->subscriberHistoryManager->addHistory( - subscriber: $user, - message: 'Bounced system message', - details: sprintf('User marked unconfirmed. Bounce #%d', $bounce->getId()) - ); - - return true; - } - - if ($msgId && $userId) { - if (!$this->bounceManager->existsUserMessageBounce($userId, (int)$msgId)) { - $this->bounceManager->linkUserMessageBounce($bounce, $bounceDate,$userId, (int)$msgId); - $this->bounceManager->update( - bounce: $bounce, - status: sprintf('bounced list message %d', $msgId), - comment: sprintf('%d bouncecount increased', $userId) - ); - $this->messages->incrementBounceCount((int)$msgId); - $this->users->incrementBounceCount($userId); - } else { - $this->bounceManager->linkUserMessageBounce($bounce, $bounceDate, $userId, (int)$msgId); - $this->bounceManager->update( - bounce: $bounce, - status: sprintf('duplicate bounce for %d', $userId), - comment: sprintf('duplicate bounce for subscriber %d on message %d', $userId, $msgId) - ); - } - return true; - } - - if ($userId) { - $this->bounceManager->update( - bounce: $bounce, - status: 'bounced unidentified message', - comment: sprintf('%d bouncecount increased', $userId) - ); - $this->users->incrementBounceCount($userId); - return true; - } - - if ($msgId === 'systemmessage') { - $this->bounceManager->update($bounce, 'bounced system message', 'unknown user'); - $this->logger->info('system message bounced, but unknown user'); - return true; - } - - if ($msgId) { - $this->bounceManager->update($bounce, sprintf('bounced list message %d', $msgId), 'unknown user'); - $this->messages->incrementBounceCount((int)$msgId); - return true; - } - - $this->bounceManager->update($bounce, 'unidentified bounce', 'not processed'); - - return false; - } - private function reprocessUnidentified(SymfonyStyle $io): void { $io->section('Reprocessing unidentified bounces'); @@ -319,12 +145,12 @@ private function reprocessUnidentified(SymfonyStyle $io): void if ($count % 25 === 0) { $io->writeln(sprintf('%d out of %d processed', $count, $total)); } - $decodedBody = $this->decodeBody($bounce->getHeader(), $bounce->getData()); - $userId = $this->findUserId($decodedBody); - $messageId = $this->findMessageId($decodedBody); + $decodedBody = $this->processingService->decodeBody($bounce->getHeader(), $bounce->getData()); + $userId = $this->processingService->findUserId($decodedBody); + $messageId = $this->processingService->findMessageId($decodedBody); if ($userId || $messageId) { $reparsed++; - if ($this->processBounceData($bounce->getId(), $messageId, $userId, new DateTimeImmutable())) { + if ($this->processingService->processBounceData($bounce, $messageId, $userId, new DateTimeImmutable())) { $reidentified++; } } @@ -488,7 +314,7 @@ private function handleConsecutiveBounces(SymfonyStyle $io, int $unsubscribeThre break; } } - if ($removed || $msgokay) { + if ($removed) { break; } } @@ -499,47 +325,4 @@ private function handleConsecutiveBounces(SymfonyStyle $io, int $unsubscribeThre $io->writeln(sprintf('total of %d subscribers processed', $total)); } - private function decodeBody(string $header, string $body): string - { - $transferEncoding = ''; - if (preg_match('/Content-Transfer-Encoding: ([\w-]+)/i', $header, $regs)) { - $transferEncoding = strtolower($regs[1]); - } - return match ($transferEncoding) { - 'quoted-printable' => quoted_printable_decode($body), - 'base64' => base64_decode($body) ?: '', - default => $body, - }; - } - - private function findMessageId(string $text): string|int|null - { - if (preg_match('/(?:X-MessageId|X-Message): (.*)\r\n/iU', $text, $match)) { - return trim($match[1]); - } - return null; - } - - private function findUserId(string $text): ?int - { - // Try X-ListMember / X-User first - if (preg_match('/(?:X-ListMember|X-User): (.*)\r\n/iU', $text, $match)) { - $user = trim($match[1]); - if (str_contains($user, '@')) { - return $this->users->idByEmail($user); - } elseif (preg_match('/^\d+$/', $user)) { - return (int)$user; - } elseif ($user !== '') { - return $this->users->idByUniqId($user); - } - } - // Fallback: parse any email in the body and see if it is a subscriber - if (preg_match_all('/[._a-zA-Z0-9-]+@[.a-zA-Z0-9-]+/', $text, $regs)) { - foreach ($regs[0] as $email) { - $id = $this->users->idByEmail($email); - if ($id) { return $id; } - } - } - return null; - } } diff --git a/src/Domain/Messaging/Service/BounceProcessingService.php b/src/Domain/Messaging/Service/BounceProcessingService.php new file mode 100644 index 00000000..5068e2db --- /dev/null +++ b/src/Domain/Messaging/Service/BounceProcessingService.php @@ -0,0 +1,228 @@ +writeln(sprintf('%d bounces to fetch from the mailbox', $num)); + if ($num === 0) { + imap_close($link); + + return ''; + } + + $io->writeln('Please do not interrupt this process'); + if ($num > $max) { + $io->writeln(sprintf('Processing first %d bounces', $max)); + $num = $max; + } + $io->writeln($testMode ? 'Running in test mode, not deleting messages from mailbox' : 'Processed messages will be deleted from the mailbox'); + + for ($x = 1; $x <= $num; $x++) { + $header = imap_fetchheader($link, $x); + $processed = $this->processImapBounce($link, $x, $header, $io); + if ($processed) { + if (!$testMode && $purgeProcessed) { + imap_delete($link, (string)$x); + } + } else { + if (!$testMode && $purgeUnprocessed) { + imap_delete($link, (string)$x); + } + } + } + + $io->writeln('Closing mailbox, and purging messages'); + if (!$testMode) { + imap_close($link, CL_EXPUNGE); + } else { + imap_close($link); + } + + return ''; + } + + private function processImapBounce($link, int $num, string $header, SymfonyStyle $io): bool + { + $headerInfo = imap_headerinfo($link, $num); + $date = $headerInfo->date ?? null; + $bounceDate = $date ? new DateTimeImmutable($date) : new DateTimeImmutable(); + $body = imap_body($link, $num); + $body = $this->decodeBody($header, $body); + + // Quick hack: ignore MsExchange delayed notices (as in original) + if (preg_match('/Action: delayed\s+Status: 4\.4\.7/im', $body)) { + return true; + } + + $msgId = $this->findMessageId($body); + $userId = $this->findUserId($body); + + $bounce = $this->bounceManager->create($bounceDate, $header, $body); + + return $this->processBounceData($bounce, $msgId, $userId, $bounceDate); + } + + public function processBounceData( + Bounce $bounce, + string|int|null $msgId, + ?int $userId, + DateTimeImmutable $bounceDate, + ): bool { + $msgId = $msgId ?: null; + $user = null; + if ($userId) { + $user = $this->subscriberManager->getSubscriberById($userId); + } + + if ($msgId === 'systemmessage' && $userId) { + $this->bounceManager->update( + bounce: $bounce, + status: 'bounced system message', + comment: sprintf('%d marked unconfirmed', $userId) + ); + $this->bounceManager->linkUserMessageBounce($bounce, $bounceDate, $userId); + $this->subscriberManager->markUnconfirmed($userId); + $this->logger->info('system message bounced, user marked unconfirmed', ['userId' => $userId]); + if ($user) { + $this->subscriberHistoryManager->addHistory( + subscriber: $user, + message: 'Bounced system message', + details: sprintf('User marked unconfirmed. Bounce #%d', $bounce->getId()) + ); + } + + return true; + } + + if ($msgId && $userId) { + if (!$this->bounceManager->existsUserMessageBounce($userId, (int)$msgId)) { + $this->bounceManager->linkUserMessageBounce($bounce, $bounceDate, $userId, (int)$msgId); + $this->bounceManager->update( + bounce: $bounce, + status: sprintf('bounced list message %d', $msgId), + comment: sprintf('%d bouncecount increased', $userId) + ); + $this->messages->incrementBounceCount((int)$msgId); + $this->users->incrementBounceCount($userId); + } else { + $this->bounceManager->linkUserMessageBounce($bounce, $bounceDate, $userId, (int)$msgId); + $this->bounceManager->update( + bounce: $bounce, + status: sprintf('duplicate bounce for %d', $userId), + comment: sprintf('duplicate bounce for subscriber %d on message %d', $userId, $msgId) + ); + } + + return true; + } + + if ($userId) { + $this->bounceManager->update( + bounce: $bounce, + status: 'bounced unidentified message', + comment: sprintf('%d bouncecount increased', $userId) + ); + $this->users->incrementBounceCount($userId); + + return true; + } + + if ($msgId === 'systemmessage') { + $this->bounceManager->update($bounce, 'bounced system message', 'unknown user'); + $this->logger->info('system message bounced, but unknown user'); + + return true; + } + + if ($msgId) { + $this->bounceManager->update($bounce, sprintf('bounced list message %d', $msgId), 'unknown user'); + $this->messages->incrementBounceCount((int)$msgId); + + return true; + } + + $this->bounceManager->update($bounce, 'unidentified bounce', 'not processed'); + + return false; + } + + public function decodeBody(string $header, string $body): string + { + $transferEncoding = ''; + if (preg_match('/Content-Transfer-Encoding: ([\w-]+)/i', $header, $regs)) { + $transferEncoding = strtolower($regs[1]); + } + return match ($transferEncoding) { + 'quoted-printable' => quoted_printable_decode($body), + 'base64' => base64_decode($body) ?: '', + default => $body, + }; + } + + public function findMessageId(string $text): string|int|null + { + if (preg_match('/(?:X-MessageId|X-Message): (.*)\r\n/iU', $text, $match)) { + return trim($match[1]); + } + return null; + } + + public function findUserId(string $text): ?int + { + // Try X-ListMember / X-User first + if (preg_match('/(?:X-ListMember|X-User): (.*)\r\n/iU', $text, $match)) { + $user = trim($match[1]); + if (str_contains($user, '@')) { + return $this->subscriberManager->getSubscriberByEmail($user)?->getId(); + } elseif (preg_match('/^\d+$/', $user)) { + return (int)$user; + } elseif ($user !== '') { + return $this->subscriberManager->getSubscriberByEmail($user)?->getId(); + } + } + // Fallback: parse any email in the body and see if it is a subscriber + if (preg_match_all('/[._a-zA-Z0-9-]+@[.a-zA-Z0-9-]+/', $text, $regs)) { + foreach ($regs[0] as $email) { + $id = $this->subscriberManager->getSubscriberByEmail($email)?->getId(); + if ($id) { return $id; } + } + } + return null; + } +} diff --git a/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php b/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php new file mode 100644 index 00000000..b888ba08 --- /dev/null +++ b/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php @@ -0,0 +1,24 @@ +getOption('test'); + $max = (int)$input->getOption('maximum'); + $purgeProcessed = $input->getOption('purge') && !$testMode; + $purgeUnprocessed = $input->getOption('purge-unprocessed') && !$testMode; + + $file = (string)$input->getOption('mailbox'); + if (!$file) { + $io->error('mbox file path must be provided with --mailbox.'); + throw new RuntimeException('Missing --mailbox for mbox protocol'); + } + + $io->section("Opening mbox $file"); + $link = @imap_open($file, '', '', $testMode ? 0 : CL_EXPUNGE); + if (!$link) { + $io->error('Cannot open mailbox file: '.imap_last_error()); + throw new RuntimeException('Cannot open mbox file'); + } + + return $this->processingService->processMailbox( + $io, + $link, + $max, + $purgeProcessed, + $purgeUnprocessed, + $testMode + ); + } +} diff --git a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php new file mode 100644 index 00000000..57c79b80 --- /dev/null +++ b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php @@ -0,0 +1,66 @@ +getOption('test'); + $max = (int)$input->getOption('maximum'); + $purgeProcessed = $input->getOption('purge') && !$testMode; + $purgeUnprocessed = $input->getOption('purge-unprocessed') && !$testMode; + + $host = (string)$input->getOption('host'); + $user = (string)$input->getOption('user'); + $password = (string)$input->getOption('password'); + $port = (string)$input->getOption('port'); + $mailboxes = (string)$input->getOption('mailbox'); + + if (!$host || !$user || !$password) { + $io->error('POP configuration incomplete: host, user, and password are required.'); + throw new RuntimeException('POP configuration incomplete'); + } + + $downloadReport = ''; + foreach (explode(',', $mailboxes) as $mailboxName) { + $mailboxName = trim($mailboxName); + if ($mailboxName === '') { $mailboxName = 'INBOX'; } + $mailbox = sprintf('{%s:%s}%s', $host, $port, $mailboxName); + $io->section("Connecting to $mailbox"); + + $link = @imap_open($mailbox, $user, $password); + if (!$link) { + $io->error('Cannot create connection to '.$mailbox.': '.imap_last_error()); + throw new RuntimeException('Cannot connect to mailbox'); + } + + $downloadReport .= $this->processingService->processMailbox( + $io, + $link, + $max, + $purgeProcessed, + $purgeUnprocessed, + $testMode + ); + } + + return $downloadReport; + } +} diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php index a8c1e0df..bc98fdb5 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php @@ -79,6 +79,11 @@ public function getSubscriber(int $subscriberId): Subscriber return $subscriber; } + public function getSubscriberByEmail(string $mail): ?Subscriber + { + return $this->subscriberRepository->findOneByEmail($mail); + } + public function getSubscriberById(int $subscriberId): ?Subscriber { return $this->subscriberRepository->find($subscriberId); From 92ff09d8619e870235760564e3db0dcbdbbe0168 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 22 Aug 2025 12:56:07 +0400 Subject: [PATCH 09/24] AdvancedBounceRulesProcessor --- config/services/processor.yml | 2 + .../Command/ProcessBouncesCommand.php | 127 +--------------- .../AdvancedBounceRulesProcessor.php | 135 ++++++++++++++++++ 3 files changed, 142 insertions(+), 122 deletions(-) create mode 100644 src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php diff --git a/config/services/processor.yml b/config/services/processor.yml index 794e4df5..fe1361d5 100644 --- a/config/services/processor.yml +++ b/config/services/processor.yml @@ -9,3 +9,5 @@ services: PhpList\Core\Domain\Messaging\Service\Processor\MboxBounceProcessor: tags: ['phplist.bounce_protocol_processor'] + + PhpList\Core\Domain\Messaging\Service\Processor\AdvancedBounceRulesProcessor: ~ diff --git a/src/Domain/Messaging/Command/ProcessBouncesCommand.php b/src/Domain/Messaging/Command/ProcessBouncesCommand.php index 01f39586..411d2e0c 100644 --- a/src/Domain/Messaging/Command/ProcessBouncesCommand.php +++ b/src/Domain/Messaging/Command/ProcessBouncesCommand.php @@ -11,9 +11,9 @@ use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; use PhpList\Core\Domain\Messaging\Service\BounceProcessingService; use PhpList\Core\Domain\Messaging\Service\Processor\BounceProtocolProcessor; +use PhpList\Core\Domain\Messaging\Service\Processor\AdvancedBounceRulesProcessor; use PhpList\Core\Domain\Messaging\Service\LockService; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; @@ -49,7 +49,6 @@ protected function configure(): void public function __construct( private readonly BounceManager $bounceManager, - private readonly BounceRuleManager $ruleManager, private readonly LockService $lockService, private readonly LoggerInterface $logger, private readonly SubscriberManager $subscriberManager, @@ -58,6 +57,7 @@ public function __construct( private readonly BounceProcessingService $processingService, /** @var iterable */ private readonly iterable $protocolProcessors, + private readonly AdvancedBounceRulesProcessor $advancedRulesProcessor, ) { parent::__construct(); } @@ -97,28 +97,23 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($processor === null) { $io->error('Unsupported protocol: '.$protocol); + return Command::FAILURE; } $downloadReport .= $processor->process($input, $io); - // Reprocess unidentified bounces (status = "unidentified bounce") $this->reprocessUnidentified($io); - // Advanced bounce rules - $this->processAdvancedRules($io, (int)$input->getOption('rules-batch-size')); + $this->advancedRulesProcessor->process($io, (int)$input->getOption('rules-batch-size')); - // Identify and unconfirm users with consecutive bounces $this->handleConsecutiveBounces( $io, (int)$input->getOption('unsubscribe-threshold'), (int)$input->getOption('blacklist-threshold') ); - // Summarize and report (email or log) - $this->logger->info('Bounce processing completed', [ - 'downloadReport' => $downloadReport, - ]); + $this->logger->info('Bounce processing completed', ['downloadReport' => $downloadReport]); $io->success('Bounce processing completed.'); @@ -159,117 +154,6 @@ private function reprocessUnidentified(SymfonyStyle $io): void $io->writeln(sprintf('%d bounces were re-processed and %d bounces were re-identified', $reparsed, $reidentified)); } - private function processAdvancedRules(SymfonyStyle $io, int $batchSize): void - { - $io->section('Processing bounces based on active bounce rules'); - $rules = $this->ruleManager->loadActiveRules(); - if (!$rules) { - $io->writeln('No active rules'); - return; - } - - $total = $this->bounceManager->getUserMessageBounceCount(); - $fromId = 0; - $matched = 0; - $notmatched = 0; - $counter = 0; - - while ($counter < $total) { - $batch = $this->bounceManager->fetchUserMessageBounceBatch($fromId, $batchSize); - $counter += count($batch); - $io->writeln(sprintf('processed %d out of %d bounces for advanced bounce rules', min($counter, $total), $total)); - foreach ($batch as $row) { - $fromId = $row['umb']->getId(); - // $row has: bounce(header,data,id), umb(user,message,bounce) - $text = $row['bounce']->getHeader()."\n\n".$row['bounce']->getData(); - $rule = $this->ruleManager->matchBounceRules($text, $rules); - $userId = (int)$row['umb']->getUserId(); - $bounce = $row['bounce']; - $userdata = $userId ? $this->subscriberManager->getSubscriberById($userId) : null; - $confirmed = $userdata?->isConfirmed() ?? false; - $blacklisted = $userdata?->isBlacklisted() ?? false; - - if ($rule) { - $this->ruleManager->incrementCount($rule); - $rule->setCount($rule->getCount() + 1); - $this->ruleManager->linkRuleToBounce($rule, $bounce); - - switch ($rule->getAction()) { - case 'deleteuser': - if ($userdata) { - $this->logger->info('User deleted by bounce rule', ['user' => $userdata->getEmail(), 'rule' => $rule->getId()]); - $this->subscriberManager->deleteSubscriber($userdata); - } - break; - case 'unconfirmuser': - if ($userdata && $confirmed) { - $this->subscriberManager->markUnconfirmed($userId); - $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unconfirmed', 'Subscriber auto unconfirmed for bounce rule '.$rule->getId()); - } - break; - case 'deleteuserandbounce': - if ($userdata) { - $this->subscriberManager->deleteSubscriber($userdata); - } - $this->bounceManager->delete($bounce); - break; - case 'unconfirmuseranddeletebounce': - if ($userdata && $confirmed) { - $this->subscriberManager->markUnconfirmed($userId); - $this->subscriberHistoryManager->addHistory($userdata, 'Auto unconfirmed', 'Subscriber auto unconfirmed for bounce rule '.$rule->getId()); - } - $this->bounceManager->delete($bounce); - break; - case 'decreasecountconfirmuseranddeletebounce': - if ($userdata) { - $this->subscriberManager->decrementBounceCount($userdata); - if (!$confirmed) { - $this->subscriberManager->markConfirmed($userId); - $this->subscriberHistoryManager->addHistory($userdata, 'Auto confirmed', 'Subscriber auto confirmed for bounce rule '.$rule->getId()); - } - } - $this->bounceManager->delete($bounce); - break; - case 'blacklistuser': - if ($userdata && !$blacklisted) { - $this->subscriberManager->blacklist($userdata, 'Subscriber auto blacklisted by bounce rule '.$rule->getId()); - $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unsubscribed', 'User auto unsubscribed for bounce rule '.$rule->getId()); - } - break; - case 'blacklistuseranddeletebounce': - if ($userdata && !$blacklisted) { - $this->subscriberManager->blacklist($userdata, 'Subscriber auto blacklisted by bounce rule '.$rule->getId()); - $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unsubscribed', 'User auto unsubscribed for bounce rule '.$rule->getId()); - } - $this->bounceManager->delete($bounce); - break; - case 'blacklistemail': - if ($userdata) { - $this->subscriberManager->blacklist($userdata, 'Email address auto blacklisted by bounce rule '.$rule->getId()); - $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unsubscribed', 'email auto unsubscribed for bounce rule '.$rule->getId()); - } - break; - case 'blacklistemailanddeletebounce': - if ($userdata) { - $this->subscriberManager->blacklist($userdata, 'Email address auto blacklisted by bounce rule '.$rule->getId()); - $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unsubscribed', 'User auto unsubscribed for bounce rule '.$rule->getId()); - } - $this->bounceManager->delete($bounce); - break; - case 'deletebounce': - $this->bounceManager->delete($bounce); - break; - } - $matched++; - } else { - $notmatched++; - } - } - } - $io->writeln(sprintf('%d bounces processed by advanced processing', $matched)); - $io->writeln(sprintf('%d bounces were not matched by advanced processing rules', $notmatched)); - } - private function handleConsecutiveBounces(SymfonyStyle $io, int $unsubscribeThreshold, int $blacklistThreshold): void { $io->section('Identifying consecutive bounces'); @@ -324,5 +208,4 @@ private function handleConsecutiveBounces(SymfonyStyle $io, int $unsubscribeThre } $io->writeln(sprintf('total of %d subscribers processed', $total)); } - } diff --git a/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php new file mode 100644 index 00000000..104c4692 --- /dev/null +++ b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php @@ -0,0 +1,135 @@ +section('Processing bounces based on active bounce rules'); + $rules = $this->ruleManager->loadActiveRules(); + if (!$rules) { + $io->writeln('No active rules'); + return; + } + + $total = $this->bounceManager->getUserMessageBounceCount(); + $fromId = 0; + $matched = 0; + $notmatched = 0; + $counter = 0; + + while ($counter < $total) { + $batch = $this->bounceManager->fetchUserMessageBounceBatch($fromId, $batchSize); + $counter += count($batch); + $io->writeln(sprintf('processed %d out of %d bounces for advanced bounce rules', min($counter, $total), $total)); + foreach ($batch as $row) { + $fromId = $row['umb']->getId(); + // $row has: bounce(header,data,id), umb(user,message,bounce) + $text = $row['bounce']->getHeader()."\n\n".$row['bounce']->getData(); + $rule = $this->ruleManager->matchBounceRules($text, $rules); + $userId = (int)$row['umb']->getUserId(); + $bounce = $row['bounce']; + $userdata = $userId ? $this->subscriberManager->getSubscriberById($userId) : null; + $confirmed = $userdata?->isConfirmed() ?? false; + $blacklisted = $userdata?->isBlacklisted() ?? false; + + if ($rule) { + $this->ruleManager->incrementCount($rule); + $rule->setCount($rule->getCount() + 1); + $this->ruleManager->linkRuleToBounce($rule, $bounce); + + switch ($rule->getAction()) { + case 'deleteuser': + if ($userdata) { + $this->logger->info('User deleted by bounce rule', ['user' => $userdata->getEmail(), 'rule' => $rule->getId()]); + $this->subscriberManager->deleteSubscriber($userdata); + } + break; + case 'unconfirmuser': + if ($userdata && $confirmed) { + $this->subscriberManager->markUnconfirmed($userId); + $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unconfirmed', 'Subscriber auto unconfirmed for bounce rule '.$rule->getId()); + } + break; + case 'deleteuserandbounce': + if ($userdata) { + $this->subscriberManager->deleteSubscriber($userdata); + } + $this->bounceManager->delete($bounce); + break; + case 'unconfirmuseranddeletebounce': + if ($userdata && $confirmed) { + $this->subscriberManager->markUnconfirmed($userId); + $this->subscriberHistoryManager->addHistory($userdata, 'Auto unconfirmed', 'Subscriber auto unconfirmed for bounce rule '.$rule->getId()); + } + $this->bounceManager->delete($bounce); + break; + case 'decreasecountconfirmuseranddeletebounce': + if ($userdata) { + $this->subscriberManager->decrementBounceCount($userdata); + if (!$confirmed) { + $this->subscriberManager->markConfirmed($userId); + $this->subscriberHistoryManager->addHistory($userdata, 'Auto confirmed', 'Subscriber auto confirmed for bounce rule '.$rule->getId()); + } + } + $this->bounceManager->delete($bounce); + break; + case 'blacklistuser': + if ($userdata && !$blacklisted) { + $this->subscriberManager->blacklist($userdata, 'Subscriber auto blacklisted by bounce rule '.$rule->getId()); + $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unsubscribed', 'User auto unsubscribed for bounce rule '.$rule->getId()); + } + break; + case 'blacklistuseranddeletebounce': + if ($userdata && !$blacklisted) { + $this->subscriberManager->blacklist($userdata, 'Subscriber auto blacklisted by bounce rule '.$rule->getId()); + $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unsubscribed', 'User auto unsubscribed for bounce rule '.$rule->getId()); + } + $this->bounceManager->delete($bounce); + break; + case 'blacklistemail': + if ($userdata) { + $this->subscriberManager->blacklist($userdata, 'Email address auto blacklisted by bounce rule '.$rule->getId()); + $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unsubscribed', 'email auto unsubscribed for bounce rule '.$rule->getId()); + } + break; + case 'blacklistemailanddeletebounce': + if ($userdata) { + $this->subscriberManager->blacklist($userdata, 'Email address auto blacklisted by bounce rule '.$rule->getId()); + $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unsubscribed', 'User auto unsubscribed for bounce rule '.$rule->getId()); + } + $this->bounceManager->delete($bounce); + break; + case 'deletebounce': + $this->bounceManager->delete($bounce); + break; + } + $matched++; + } else { + $notmatched++; + } + } + } + $io->writeln(sprintf('%d bounces processed by advanced processing', $matched)); + $io->writeln(sprintf('%d bounces were not matched by advanced processing rules', $notmatched)); + } +} From 741e6e647731e00fc46087274badd8e33143557d Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 22 Aug 2025 13:02:47 +0400 Subject: [PATCH 10/24] UnidentifiedBounceReprocessor --- config/services/processor.yml | 2 + .../Command/ProcessBouncesCommand.php | 35 +---------- .../UnidentifiedBounceReprocessor.php | 59 +++++++++++++++++++ 3 files changed, 64 insertions(+), 32 deletions(-) create mode 100644 src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php diff --git a/config/services/processor.yml b/config/services/processor.yml index fe1361d5..102ef16c 100644 --- a/config/services/processor.yml +++ b/config/services/processor.yml @@ -11,3 +11,5 @@ services: tags: ['phplist.bounce_protocol_processor'] PhpList\Core\Domain\Messaging\Service\Processor\AdvancedBounceRulesProcessor: ~ + + PhpList\Core\Domain\Messaging\Service\Processor\UnidentifiedBounceReprocessor: ~ diff --git a/src/Domain/Messaging/Command/ProcessBouncesCommand.php b/src/Domain/Messaging/Command/ProcessBouncesCommand.php index 411d2e0c..9dce2b9e 100644 --- a/src/Domain/Messaging/Command/ProcessBouncesCommand.php +++ b/src/Domain/Messaging/Command/ProcessBouncesCommand.php @@ -4,14 +4,13 @@ namespace PhpList\Core\Domain\Messaging\Command; -use DateTimeImmutable; use Exception; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Model\UserMessage; use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; -use PhpList\Core\Domain\Messaging\Service\BounceProcessingService; use PhpList\Core\Domain\Messaging\Service\Processor\BounceProtocolProcessor; use PhpList\Core\Domain\Messaging\Service\Processor\AdvancedBounceRulesProcessor; +use PhpList\Core\Domain\Messaging\Service\Processor\UnidentifiedBounceReprocessor; use PhpList\Core\Domain\Messaging\Service\LockService; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; @@ -54,10 +53,10 @@ public function __construct( private readonly SubscriberManager $subscriberManager, private readonly SubscriberHistoryManager $subscriberHistoryManager, private readonly SubscriberRepository $subscriberRepository, - private readonly BounceProcessingService $processingService, /** @var iterable */ private readonly iterable $protocolProcessors, private readonly AdvancedBounceRulesProcessor $advancedRulesProcessor, + private readonly UnidentifiedBounceReprocessor $unidentifiedBounceReprocessor, ) { parent::__construct(); } @@ -102,9 +101,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $downloadReport .= $processor->process($input, $io); - - $this->reprocessUnidentified($io); - + $this->unidentifiedBounceReprocessor->process($io); $this->advancedRulesProcessor->process($io, (int)$input->getOption('rules-batch-size')); $this->handleConsecutiveBounces( @@ -128,32 +125,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - private function reprocessUnidentified(SymfonyStyle $io): void - { - $io->section('Reprocessing unidentified bounces'); - $bounces = $this->bounceManager->findByStatus('unidentified bounce'); - $total = count($bounces); - $io->writeln(sprintf('%d bounces to reprocess', $total)); - $count = 0; $reparsed = 0; $reidentified = 0; - foreach ($bounces as $bounce) { - $count++; - if ($count % 25 === 0) { - $io->writeln(sprintf('%d out of %d processed', $count, $total)); - } - $decodedBody = $this->processingService->decodeBody($bounce->getHeader(), $bounce->getData()); - $userId = $this->processingService->findUserId($decodedBody); - $messageId = $this->processingService->findMessageId($decodedBody); - if ($userId || $messageId) { - $reparsed++; - if ($this->processingService->processBounceData($bounce, $messageId, $userId, new DateTimeImmutable())) { - $reidentified++; - } - } - } - $io->writeln(sprintf('%d out of %d processed', $count, $total)); - $io->writeln(sprintf('%d bounces were re-processed and %d bounces were re-identified', $reparsed, $reidentified)); - } - private function handleConsecutiveBounces(SymfonyStyle $io, int $unsubscribeThreshold, int $blacklistThreshold): void { $io->section('Identifying consecutive bounces'); diff --git a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php new file mode 100644 index 00000000..9bc558d7 --- /dev/null +++ b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php @@ -0,0 +1,59 @@ +section('Reprocessing unidentified bounces'); + $bounces = $this->bounceManager->findByStatus('unidentified bounce'); + $total = count($bounces); + $io->writeln(sprintf('%d bounces to reprocess', $total)); + + $count = 0; + $reparsed = 0; + $reidentified = 0; + foreach ($bounces as $bounce) { + $count++; + if ($count % 25 === 0) { + $io->writeln(sprintf('%d out of %d processed', $count, $total)); + } + + $decodedBody = $this->processingService->decodeBody($bounce->getHeader(), $bounce->getData()); + $userId = $this->processingService->findUserId($decodedBody); + $messageId = $this->processingService->findMessageId($decodedBody); + + if ($userId || $messageId) { + $reparsed++; + if ($this->processingService->processBounceData( + $bounce, + $messageId, + $userId, + new DateTimeImmutable()) + ) { + $reidentified++; + } + } + } + + $io->writeln(sprintf('%d out of %d processed', $count, $total)); + $io->writeln(sprintf( + '%d bounces were re-processed and %d bounces were re-identified', + $reparsed, $reidentified + )); + } +} From 38ca099b75f5cdce929b5e36ec5f8f14ec919cd2 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 22 Aug 2025 13:14:46 +0400 Subject: [PATCH 11/24] ConsecutiveBounceHandler --- config/services/services.yml | 2 + .../Command/ProcessBouncesCommand.php | 78 ++---------------- .../Service/ConsecutiveBounceHandler.php | 80 +++++++++++++++++++ 3 files changed, 87 insertions(+), 73 deletions(-) create mode 100644 src/Domain/Messaging/Service/ConsecutiveBounceHandler.php diff --git a/config/services/services.yml b/config/services/services.yml index abbbd588..d05d1f34 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -43,3 +43,5 @@ services: PhpList\Core\Domain\Common\SystemInfoCollector: autowire: true autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler: ~ diff --git a/src/Domain/Messaging/Command/ProcessBouncesCommand.php b/src/Domain/Messaging/Command/ProcessBouncesCommand.php index 9dce2b9e..8ec01b29 100644 --- a/src/Domain/Messaging/Command/ProcessBouncesCommand.php +++ b/src/Domain/Messaging/Command/ProcessBouncesCommand.php @@ -5,17 +5,11 @@ namespace PhpList\Core\Domain\Messaging\Command; use Exception; -use PhpList\Core\Domain\Messaging\Model\Bounce; -use PhpList\Core\Domain\Messaging\Model\UserMessage; -use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; +use PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler; use PhpList\Core\Domain\Messaging\Service\Processor\BounceProtocolProcessor; use PhpList\Core\Domain\Messaging\Service\Processor\AdvancedBounceRulesProcessor; use PhpList\Core\Domain\Messaging\Service\Processor\UnidentifiedBounceReprocessor; use PhpList\Core\Domain\Messaging\Service\LockService; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; -use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -47,16 +41,13 @@ protected function configure(): void } public function __construct( - private readonly BounceManager $bounceManager, private readonly LockService $lockService, private readonly LoggerInterface $logger, - private readonly SubscriberManager $subscriberManager, - private readonly SubscriberHistoryManager $subscriberHistoryManager, - private readonly SubscriberRepository $subscriberRepository, /** @var iterable */ private readonly iterable $protocolProcessors, private readonly AdvancedBounceRulesProcessor $advancedRulesProcessor, private readonly UnidentifiedBounceReprocessor $unidentifiedBounceReprocessor, + private readonly ConsecutiveBounceHandler $consecutiveBounceHandler, ) { parent::__construct(); } @@ -83,6 +74,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { $io->title('Processing bounces'); $protocol = (string)$input->getOption('protocol'); + $unsubscribeThreshold = (int)$input->getOption('unsubscribe-threshold'); + $blacklistThreshold = (int)$input->getOption('blacklist-threshold'); $downloadReport = ''; @@ -103,15 +96,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $downloadReport .= $processor->process($input, $io); $this->unidentifiedBounceReprocessor->process($io); $this->advancedRulesProcessor->process($io, (int)$input->getOption('rules-batch-size')); - - $this->handleConsecutiveBounces( - $io, - (int)$input->getOption('unsubscribe-threshold'), - (int)$input->getOption('blacklist-threshold') - ); + $this->consecutiveBounceHandler->handle($io, $unsubscribeThreshold, $blacklistThreshold); $this->logger->info('Bounce processing completed', ['downloadReport' => $downloadReport]); - $io->success('Bounce processing completed.'); return Command::SUCCESS; @@ -124,59 +111,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->lockService->release($lock); } } - - private function handleConsecutiveBounces(SymfonyStyle $io, int $unsubscribeThreshold, int $blacklistThreshold): void - { - $io->section('Identifying consecutive bounces'); - $users = $this->subscriberRepository->distinctUsersWithBouncesConfirmedNotBlacklisted(); - $total = count($users); - if ($total === 0) { - $io->writeln('Nothing to do'); - return; - } - $usercnt = 0; - foreach ($users as $user) { - $usercnt++; - $history = $this->bounceManager->getUserMessageHistoryWithBounces($user); - $cnt = 0; $removed = false; $msgokay = false; $unsubscribed = false; - foreach ($history as $bounce) { - /** @var $bounce array{um: UserMessage, umb: UserMessageBounce|null, b: Bounce|null} */ - if ( - stripos($bounce['b']->getStatus() ?? '', 'duplicate') === false - && stripos($bounce['b']->getComment() ?? '', 'duplicate') === false - ) { - if ($bounce['b']->getId()) { - $cnt++; - if ($cnt >= $unsubscribeThreshold) { - if (!$unsubscribed) { - $this->subscriberManager->markUnconfirmed($user->getId()); - $this->subscriberHistoryManager->addHistory( - subscriber: $user, - message: 'Auto Unconfirmed', - details: sprintf('Subscriber auto unconfirmed for %d consecutive bounces', $cnt) - ); - $unsubscribed = true; - } - if ($blacklistThreshold > 0 && $cnt >= $blacklistThreshold) { - $this->subscriberManager->blacklist( - subscriber: $user, - reason: sprintf('%d consecutive bounces, threshold reached', $cnt) - ); - $removed = true; - } - } - } else { - break; - } - } - if ($removed) { - break; - } - } - if ($usercnt % 5 === 0) { - $io->writeln(sprintf('processed %d out of %d subscribers', $usercnt, $total)); - } - } - $io->writeln(sprintf('total of %d subscribers processed', $total)); - } } diff --git a/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php b/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php new file mode 100644 index 00000000..d83b3db2 --- /dev/null +++ b/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php @@ -0,0 +1,80 @@ +section('Identifying consecutive bounces'); + $users = $this->subscriberRepository->distinctUsersWithBouncesConfirmedNotBlacklisted(); + $total = count($users); + if ($total === 0) { + $io->writeln('Nothing to do'); + return; + } + $usercnt = 0; + foreach ($users as $user) { + $usercnt++; + $history = $this->bounceManager->getUserMessageHistoryWithBounces($user); + $cnt = 0; $removed = false; $unsubscribed = false; + foreach ($history as $bounce) { + /** @var $bounce array{um: UserMessage, umb: UserMessageBounce|null, b: Bounce|null} */ + if ( + stripos($bounce['b']->getStatus() ?? '', 'duplicate') === false + && stripos($bounce['b']->getComment() ?? '', 'duplicate') === false + ) { + if ($bounce['b']->getId()) { + $cnt++; + if ($cnt >= $unsubscribeThreshold) { + if (!$unsubscribed) { + $this->subscriberManager->markUnconfirmed($user->getId()); + $this->subscriberHistoryManager->addHistory( + subscriber: $user, + message: 'Auto Unconfirmed', + details: sprintf('Subscriber auto unconfirmed for %d consecutive bounces', $cnt) + ); + $unsubscribed = true; + } + if ($blacklistThreshold > 0 && $cnt >= $blacklistThreshold) { + $this->subscriberManager->blacklist( + subscriber: $user, + reason: sprintf('%d consecutive bounces, threshold reached', $cnt) + ); + $removed = true; + } + } + } else { + break; + } + } + if ($removed) { + break; + } + } + if ($usercnt % 5 === 0) { + $io->writeln(sprintf('processed %d out of %d subscribers', $usercnt, $total)); + } + } + $io->writeln(sprintf('total of %d subscribers processed', $total)); + } +} From 0f86880f69653b0c8f80006bc808bcc7082af260 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 25 Aug 2025 09:10:17 +0400 Subject: [PATCH 12/24] Refactor --- composer.json | 4 +- config/services/services.yml | 22 ++++ .../Common/Mail/MailReaderInterface.php | 19 +++ .../Common/Mail/NativeImapMailReader.php | 54 +++++++++ src/Domain/Common/Mail/WebklexMailReader.php | 14 +++ src/Domain/Common/Model/MessageDto.php | 36 ++++++ .../Messaging/Service/MessageParser.php | 63 ++++++++++ ....php => NativeBounceProcessingService.php} | 108 ++++++------------ .../Service/Processor/MboxBounceProcessor.php | 20 ++-- .../Service/Processor/PopBounceProcessor.php | 17 ++- .../UnidentifiedBounceReprocessor.php | 4 +- 11 files changed, 265 insertions(+), 96 deletions(-) create mode 100644 src/Domain/Common/Mail/MailReaderInterface.php create mode 100644 src/Domain/Common/Mail/NativeImapMailReader.php create mode 100644 src/Domain/Common/Mail/WebklexMailReader.php create mode 100644 src/Domain/Common/Model/MessageDto.php create mode 100644 src/Domain/Messaging/Service/MessageParser.php rename src/Domain/Messaging/Service/{BounceProcessingService.php => NativeBounceProcessingService.php} (65%) diff --git a/composer.json b/composer.json index be974681..2b391014 100644 --- a/composer.json +++ b/composer.json @@ -68,7 +68,9 @@ "symfony/sendgrid-mailer": "^6.4", "symfony/twig-bundle": "^6.4", "symfony/messenger": "^6.4", - "symfony/lock": "^6.4" + "symfony/lock": "^6.4", + "webklex/php-imap": "^6.2", + "ext-imap": "*" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/config/services/services.yml b/config/services/services.yml index d05d1f34..83e28fa5 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -45,3 +45,25 @@ services: autoconfigure: true PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler: ~ + + PhpList\Core\Domain\Common\Mail\MailReaderInterface: '@PhpList\Core\Domain\Common\Mail\NativeImapMailReader' + + Webklex\PHPIMAP\ClientManager: ~ + + PhpList\Core\Domain\Common\Mail\WebklexMailReader: + arguments: + $cm: '@Webklex\PHPIMAP\ClientManager' + $config: + host: '%imap_bounce.host%' + port: '%imap_bounce.port%' + encryption: '%imap_bounce.encryption%' + validate_cert: true + username: '%imap_bounce.email%' + password: '%imap_bounce.password%' + protocol: 'imap' + + PhpList\Core\Domain\Common\Mail\NativeImapMailReader: + arguments: + $mailbox: '%env(IMAP_MAILBOX)%' # e.g. "{imap.example.com:993/imap/ssl}INBOX" or "/var/mail/user" + $user: '%imap_bounce.email%' + $pass: '%imap_bounce.password%' diff --git a/src/Domain/Common/Mail/MailReaderInterface.php b/src/Domain/Common/Mail/MailReaderInterface.php new file mode 100644 index 00000000..14ca543f --- /dev/null +++ b/src/Domain/Common/Mail/MailReaderInterface.php @@ -0,0 +1,19 @@ +date ?? null; + + return $date ? new DateTimeImmutable($date) : new DateTimeImmutable(); + } + + public function body(Connection $link, int $msgNo): string + { + return imap_body($link, $msgNo) ?: ''; + } + + public function delete(Connection $link, int $msgNo): void + { + imap_delete($link, (string)$msgNo); + } + + public function close(Connection $link, bool $expunge): void + { + $expunge ? imap_close($link, CL_EXPUNGE) : imap_close($link); + } +} diff --git a/src/Domain/Common/Mail/WebklexMailReader.php b/src/Domain/Common/Mail/WebklexMailReader.php new file mode 100644 index 00000000..5192df6f --- /dev/null +++ b/src/Domain/Common/Mail/WebklexMailReader.php @@ -0,0 +1,14 @@ +uid; } + public function getMessageId(): string { return $this->messageId; } + public function getSubject(): string { return $this->subject; } + public function getFrom(): string { return $this->from; } + public function getTo(): array { return $this->to; } + public function getCc(): ?string { return $this->cc; } + public function getBcc(): ?string { return $this->bcc; } + public function getDate(): DateTimeImmutable { return $this->date; } + public function getBodyText(): string { return $this->bodyText; } + public function getBodyHtml(): string { return $this->bodyHtml; } + public function getAttachments(): array { return $this->attachments; } +} diff --git a/src/Domain/Messaging/Service/MessageParser.php b/src/Domain/Messaging/Service/MessageParser.php new file mode 100644 index 00000000..30ebcaa0 --- /dev/null +++ b/src/Domain/Messaging/Service/MessageParser.php @@ -0,0 +1,63 @@ + quoted_printable_decode($body), + 'base64' => base64_decode($body) ?: '', + default => $body, + }; + } + + public function findMessageId(string $text): ?string + { + if (preg_match('/(?:X-MessageId|X-Message): (.*)\r\n/iU', $text, $match)) { + return trim($match[1]); + } + + return null; + } + + public function findUserId(string $text): ?int + { + // Try X-ListMember / X-User first + if (preg_match('/(?:X-ListMember|X-User): (.*)\r\n/iU', $text, $match)) { + $user = trim($match[1]); + if (str_contains($user, '@')) { + return $this->subscriberManager->getSubscriberByEmail($user)?->getId(); + } elseif (preg_match('/^\d+$/', $user)) { + return (int)$user; + } elseif ($user !== '') { + return $this->subscriberManager->getSubscriberByEmail($user)?->getId(); + } + } + // Fallback: parse any email in the body and see if it is a subscriber + if (preg_match_all('/[._a-zA-Z0-9-]+@[.a-zA-Z0-9-]+/', $text, $regs)) { + foreach ($regs[0] as $email) { + $id = $this->subscriberManager->getSubscriberByEmail($email)?->getId(); + if ($id) { + return $id; + } + } + } + + return null; + } +} diff --git a/src/Domain/Messaging/Service/BounceProcessingService.php b/src/Domain/Messaging/Service/NativeBounceProcessingService.php similarity index 65% rename from src/Domain/Messaging/Service/BounceProcessingService.php rename to src/Domain/Messaging/Service/NativeBounceProcessingService.php index 5068e2db..506c9b4e 100644 --- a/src/Domain/Messaging/Service/BounceProcessingService.php +++ b/src/Domain/Messaging/Service/NativeBounceProcessingService.php @@ -5,7 +5,7 @@ namespace PhpList\Core\Domain\Messaging\Service; use DateTimeImmutable; -use IMAP\Connection; +use PhpList\Core\Domain\Common\Mail\MailReaderInterface; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; @@ -13,12 +13,11 @@ use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use Psr\Log\LoggerInterface; +use RuntimeException; use Symfony\Component\Console\Style\SymfonyStyle; +use Throwable; -/** - * Contains the core bounce processing logic shared across protocols. - */ -class BounceProcessingService +class NativeBounceProcessingService { public function __construct( private readonly BounceManager $bounceManager, @@ -27,21 +26,32 @@ public function __construct( private readonly LoggerInterface $logger, private readonly SubscriberManager $subscriberManager, private readonly SubscriberHistoryManager $subscriberHistoryManager, + private readonly MailReaderInterface $mailReader, + private readonly MessageParser $messageParser, ) { } public function processMailbox( SymfonyStyle $io, - Connection $link, + string $mailbox, + string $user, + string $password, int $max, bool $purgeProcessed, bool $purgeUnprocessed, bool $testMode ): string { - $num = imap_num_msg($link); + try { + $link = $this->mailReader->open($mailbox, $user, $password, $testMode ? 0 : CL_EXPUNGE); + } catch (Throwable $e) { + $io->error('Cannot open mailbox file: '.$e->getMessage()); + throw new RuntimeException('Cannot open mbox file'); + } + + $num = $this->mailReader->numMessages($link); $io->writeln(sprintf('%d bounces to fetch from the mailbox', $num)); if ($num === 0) { - imap_close($link); + $this->mailReader->close($link, false); return ''; } @@ -54,61 +64,51 @@ public function processMailbox( $io->writeln($testMode ? 'Running in test mode, not deleting messages from mailbox' : 'Processed messages will be deleted from the mailbox'); for ($x = 1; $x <= $num; $x++) { - $header = imap_fetchheader($link, $x); + $header = $this->mailReader->fetchHeader($link, $x); $processed = $this->processImapBounce($link, $x, $header, $io); if ($processed) { if (!$testMode && $purgeProcessed) { - imap_delete($link, (string)$x); + $this->mailReader->delete($link, $x); } } else { if (!$testMode && $purgeUnprocessed) { - imap_delete($link, (string)$x); + $this->mailReader->delete($link, $x); } } } $io->writeln('Closing mailbox, and purging messages'); if (!$testMode) { - imap_close($link, CL_EXPUNGE); + $this->mailReader->close($link, true); } else { - imap_close($link); + $this->mailReader->close($link, false); } return ''; } - private function processImapBounce($link, int $num, string $header, SymfonyStyle $io): bool + private function processImapBounce($link, int $num, string $header): bool { - $headerInfo = imap_headerinfo($link, $num); - $date = $headerInfo->date ?? null; - $bounceDate = $date ? new DateTimeImmutable($date) : new DateTimeImmutable(); - $body = imap_body($link, $num); - $body = $this->decodeBody($header, $body); + $bounceDate = $this->mailReader->headerDate($link, $num); + $body = $this->mailReader->body($link, $num); + $body = $this->messageParser->decodeBody($header, $body); // Quick hack: ignore MsExchange delayed notices (as in original) if (preg_match('/Action: delayed\s+Status: 4\.4\.7/im', $body)) { return true; } - $msgId = $this->findMessageId($body); - $userId = $this->findUserId($body); + $msgId = $this->messageParser->findMessageId($body); + $userId = $this->messageParser->findUserId($body); $bounce = $this->bounceManager->create($bounceDate, $header, $body); return $this->processBounceData($bounce, $msgId, $userId, $bounceDate); } - public function processBounceData( - Bounce $bounce, - string|int|null $msgId, - ?int $userId, - DateTimeImmutable $bounceDate, - ): bool { - $msgId = $msgId ?: null; - $user = null; - if ($userId) { - $user = $this->subscriberManager->getSubscriberById($userId); - } + public function processBounceData(Bounce $bounce, ?string $msgId, ?int $userId, DateTimeImmutable $bounceDate): bool + { + $user = $userId ? $this->subscriberManager->getSubscriberById($userId) : null; if ($msgId === 'systemmessage' && $userId) { $this->bounceManager->update( @@ -181,48 +181,4 @@ public function processBounceData( return false; } - - public function decodeBody(string $header, string $body): string - { - $transferEncoding = ''; - if (preg_match('/Content-Transfer-Encoding: ([\w-]+)/i', $header, $regs)) { - $transferEncoding = strtolower($regs[1]); - } - return match ($transferEncoding) { - 'quoted-printable' => quoted_printable_decode($body), - 'base64' => base64_decode($body) ?: '', - default => $body, - }; - } - - public function findMessageId(string $text): string|int|null - { - if (preg_match('/(?:X-MessageId|X-Message): (.*)\r\n/iU', $text, $match)) { - return trim($match[1]); - } - return null; - } - - public function findUserId(string $text): ?int - { - // Try X-ListMember / X-User first - if (preg_match('/(?:X-ListMember|X-User): (.*)\r\n/iU', $text, $match)) { - $user = trim($match[1]); - if (str_contains($user, '@')) { - return $this->subscriberManager->getSubscriberByEmail($user)?->getId(); - } elseif (preg_match('/^\d+$/', $user)) { - return (int)$user; - } elseif ($user !== '') { - return $this->subscriberManager->getSubscriberByEmail($user)?->getId(); - } - } - // Fallback: parse any email in the body and see if it is a subscriber - if (preg_match_all('/[._a-zA-Z0-9-]+@[.a-zA-Z0-9-]+/', $text, $regs)) { - foreach ($regs[0] as $email) { - $id = $this->subscriberManager->getSubscriberByEmail($email)?->getId(); - if ($id) { return $id; } - } - } - return null; - } } diff --git a/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php b/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php index 2cd37283..0aa48ac2 100644 --- a/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php +++ b/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php @@ -4,15 +4,22 @@ namespace PhpList\Core\Domain\Messaging\Service\Processor; -use PhpList\Core\Domain\Messaging\Service\BounceProcessingService; +use PhpList\Core\Domain\Messaging\Service\NativeBounceProcessingService; use RuntimeException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; class MboxBounceProcessor implements BounceProtocolProcessor { - public function __construct(private readonly BounceProcessingService $processingService) + private $processingService; + private string $user; + private string $pass; + + public function __construct(NativeBounceProcessingService $processingService, string $user, string $pass) { + $this->processingService = $processingService; + $this->user = $user; + $this->pass = $pass; } public function getProtocol(): string @@ -34,15 +41,12 @@ public function process(InputInterface $input, SymfonyStyle $io): string } $io->section("Opening mbox $file"); - $link = @imap_open($file, '', '', $testMode ? 0 : CL_EXPUNGE); - if (!$link) { - $io->error('Cannot open mailbox file: '.imap_last_error()); - throw new RuntimeException('Cannot open mbox file'); - } return $this->processingService->processMailbox( $io, - $link, + $file, + $this->user, + $this->pass, $max, $purgeProcessed, $purgeUnprocessed, diff --git a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php index 57c79b80..99a2de58 100644 --- a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php +++ b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php @@ -4,15 +4,18 @@ namespace PhpList\Core\Domain\Messaging\Service\Processor; -use PhpList\Core\Domain\Messaging\Service\BounceProcessingService; +use PhpList\Core\Domain\Messaging\Service\NativeBounceProcessingService; use RuntimeException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; class PopBounceProcessor implements BounceProtocolProcessor { - public function __construct(private readonly BounceProcessingService $processingService) + private $processingService; + + public function __construct(NativeBounceProcessingService $processingService) { + $this->processingService = $processingService; } public function getProtocol(): string @@ -45,15 +48,11 @@ public function process(InputInterface $input, SymfonyStyle $io): string $mailbox = sprintf('{%s:%s}%s', $host, $port, $mailboxName); $io->section("Connecting to $mailbox"); - $link = @imap_open($mailbox, $user, $password); - if (!$link) { - $io->error('Cannot create connection to '.$mailbox.': '.imap_last_error()); - throw new RuntimeException('Cannot connect to mailbox'); - } - $downloadReport .= $this->processingService->processMailbox( $io, - $link, + $mailbox, + $user, + $password, $max, $purgeProcessed, $purgeUnprocessed, diff --git a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php index 9bc558d7..28535ab1 100644 --- a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php +++ b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php @@ -5,7 +5,7 @@ namespace PhpList\Core\Domain\Messaging\Service\Processor; use DateTimeImmutable; -use PhpList\Core\Domain\Messaging\Service\BounceProcessingService; +use PhpList\Core\Domain\Messaging\Service\NativeBounceProcessingService; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use Symfony\Component\Console\Style\SymfonyStyle; @@ -13,7 +13,7 @@ class UnidentifiedBounceReprocessor { public function __construct( private readonly BounceManager $bounceManager, - private readonly BounceProcessingService $processingService, + private readonly NativeBounceProcessingService $processingService, ) { } From 34ec99cb6c8fa3e921e486e90a29fced8bf36ebe Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 25 Aug 2025 11:42:47 +0400 Subject: [PATCH 13/24] BounceDataProcessor --- config/services/services.yml | 21 +- .../Common/Mail/MailReaderInterface.php | 19 -- .../Common/Mail/NativeImapMailReader.php | 2 +- src/Domain/Common/Mail/WebklexMailReader.php | 14 - .../Command/ProcessBouncesCommand.php | 13 +- .../Service/ConsecutiveBounceHandler.php | 29 +- .../Service/NativeBounceProcessingService.php | 109 ++------ .../Service/Processor/BounceDataProcessor.php | 103 +++++++ .../WebklexBounceProcessingService.php | 257 ++++++++++++++++++ 9 files changed, 406 insertions(+), 161 deletions(-) delete mode 100644 src/Domain/Common/Mail/MailReaderInterface.php delete mode 100644 src/Domain/Common/Mail/WebklexMailReader.php create mode 100644 src/Domain/Messaging/Service/Processor/BounceDataProcessor.php create mode 100644 src/Domain/Messaging/Service/WebklexBounceProcessingService.php diff --git a/config/services/services.yml b/config/services/services.yml index 83e28fa5..e87bd276 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -44,24 +44,15 @@ services: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler: ~ - - PhpList\Core\Domain\Common\Mail\MailReaderInterface: '@PhpList\Core\Domain\Common\Mail\NativeImapMailReader' + PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler: + autowire: true + autoconfigure: true + arguments: + $unsubscribeThreshold: '%app.unsubscribe_threshold%' + $blacklistThreshold: '%app.blacklist_threshold%' Webklex\PHPIMAP\ClientManager: ~ - PhpList\Core\Domain\Common\Mail\WebklexMailReader: - arguments: - $cm: '@Webklex\PHPIMAP\ClientManager' - $config: - host: '%imap_bounce.host%' - port: '%imap_bounce.port%' - encryption: '%imap_bounce.encryption%' - validate_cert: true - username: '%imap_bounce.email%' - password: '%imap_bounce.password%' - protocol: 'imap' - PhpList\Core\Domain\Common\Mail\NativeImapMailReader: arguments: $mailbox: '%env(IMAP_MAILBOX)%' # e.g. "{imap.example.com:993/imap/ssl}INBOX" or "/var/mail/user" diff --git a/src/Domain/Common/Mail/MailReaderInterface.php b/src/Domain/Common/Mail/MailReaderInterface.php deleted file mode 100644 index 14ca543f..00000000 --- a/src/Domain/Common/Mail/MailReaderInterface.php +++ /dev/null @@ -1,19 +0,0 @@ -addOption('protocol', null, InputOption::VALUE_REQUIRED, 'Mailbox protocol: pop or mbox', 'pop') - ->addOption('host', null, InputOption::VALUE_OPTIONAL, 'POP host (without braces) e.g. mail.example.com') - ->addOption('port', null, InputOption::VALUE_OPTIONAL, 'POP port/options, e.g. 110/pop3/notls', '110/pop3/notls') - ->addOption('user', null, InputOption::VALUE_OPTIONAL, 'Mailbox username') - ->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Mailbox password') - ->addOption('mailbox', null, InputOption::VALUE_OPTIONAL, 'Mailbox name(s) for POP (comma separated) or mbox file path', 'INBOX') - ->addOption('maximum', null, InputOption::VALUE_OPTIONAL, 'Max messages to process per run', '1000') - ->addOption('purge', null, InputOption::VALUE_NONE, 'Delete/remove processed messages from mailbox') ->addOption('purge-unprocessed', null, InputOption::VALUE_NONE, 'Delete/remove unprocessed messages from mailbox') ->addOption('rules-batch-size', null, InputOption::VALUE_OPTIONAL, 'Advanced rules batch size', '1000') - ->addOption('unsubscribe-threshold', null, InputOption::VALUE_OPTIONAL, 'Consecutive bounces threshold to unconfirm user', '3') - ->addOption('blacklist-threshold', null, InputOption::VALUE_OPTIONAL, 'Consecutive bounces threshold to blacklist email (0 to disable)', '0') ->addOption('test', 't', InputOption::VALUE_NONE, 'Test mode: do not delete from mailbox') ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force run: kill other processes if locked'); } @@ -74,8 +65,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { $io->title('Processing bounces'); $protocol = (string)$input->getOption('protocol'); - $unsubscribeThreshold = (int)$input->getOption('unsubscribe-threshold'); - $blacklistThreshold = (int)$input->getOption('blacklist-threshold'); $downloadReport = ''; @@ -96,7 +85,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $downloadReport .= $processor->process($input, $io); $this->unidentifiedBounceReprocessor->process($io); $this->advancedRulesProcessor->process($io, (int)$input->getOption('rules-batch-size')); - $this->consecutiveBounceHandler->handle($io, $unsubscribeThreshold, $blacklistThreshold); + $this->consecutiveBounceHandler->handle($io); $this->logger->info('Bounce processing completed', ['downloadReport' => $downloadReport]); $io->success('Bounce processing completed.'); diff --git a/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php b/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php index d83b3db2..7d703c22 100644 --- a/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php +++ b/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php @@ -15,15 +15,30 @@ class ConsecutiveBounceHandler { + private BounceManager $bounceManager; + private SubscriberRepository $subscriberRepository; + private SubscriberManager $subscriberManager; + private SubscriberHistoryManager $subscriberHistoryManager; + private int $unsubscribeThreshold; + private int $blacklistThreshold; + public function __construct( - private readonly BounceManager $bounceManager, - private readonly SubscriberRepository $subscriberRepository, - private readonly SubscriberManager $subscriberManager, - private readonly SubscriberHistoryManager $subscriberHistoryManager, + BounceManager $bounceManager, + SubscriberRepository $subscriberRepository, + SubscriberManager $subscriberManager, + SubscriberHistoryManager $subscriberHistoryManager, + int $unsubscribeThreshold, + int $blacklistThreshold, ) { + $this->bounceManager = $bounceManager; + $this->subscriberRepository = $subscriberRepository; + $this->subscriberManager = $subscriberManager; + $this->subscriberHistoryManager = $subscriberHistoryManager; + $this->unsubscribeThreshold = $unsubscribeThreshold; + $this->blacklistThreshold = $blacklistThreshold; } - public function handle(SymfonyStyle $io, int $unsubscribeThreshold, int $blacklistThreshold): void + public function handle(SymfonyStyle $io): void { $io->section('Identifying consecutive bounces'); $users = $this->subscriberRepository->distinctUsersWithBouncesConfirmedNotBlacklisted(); @@ -45,7 +60,7 @@ public function handle(SymfonyStyle $io, int $unsubscribeThreshold, int $blackli ) { if ($bounce['b']->getId()) { $cnt++; - if ($cnt >= $unsubscribeThreshold) { + if ($cnt >= $this->unsubscribeThreshold) { if (!$unsubscribed) { $this->subscriberManager->markUnconfirmed($user->getId()); $this->subscriberHistoryManager->addHistory( @@ -55,7 +70,7 @@ public function handle(SymfonyStyle $io, int $unsubscribeThreshold, int $blackli ); $unsubscribed = true; } - if ($blacklistThreshold > 0 && $cnt >= $blacklistThreshold) { + if ($this->blacklistThreshold > 0 && $cnt >= $this->blacklistThreshold) { $this->subscriberManager->blacklist( subscriber: $user, reason: sprintf('%d consecutive bounces, threshold reached', $cnt) diff --git a/src/Domain/Messaging/Service/NativeBounceProcessingService.php b/src/Domain/Messaging/Service/NativeBounceProcessingService.php index 506c9b4e..b1df7470 100644 --- a/src/Domain/Messaging/Service/NativeBounceProcessingService.php +++ b/src/Domain/Messaging/Service/NativeBounceProcessingService.php @@ -4,31 +4,30 @@ namespace PhpList\Core\Domain\Messaging\Service; -use DateTimeImmutable; -use PhpList\Core\Domain\Common\Mail\MailReaderInterface; -use PhpList\Core\Domain\Messaging\Model\Bounce; -use PhpList\Core\Domain\Messaging\Repository\MessageRepository; +use PhpList\Core\Domain\Common\Mail\NativeImapMailReader; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; -use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; -use Psr\Log\LoggerInterface; +use PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor; use RuntimeException; use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; class NativeBounceProcessingService { + private BounceManager $bounceManager; + private NativeImapMailReader $mailReader; + private MessageParser $messageParser; + private BounceDataProcessor $bounceDataProcessor; + public function __construct( - private readonly BounceManager $bounceManager, - private readonly SubscriberRepository $users, - private readonly MessageRepository $messages, - private readonly LoggerInterface $logger, - private readonly SubscriberManager $subscriberManager, - private readonly SubscriberHistoryManager $subscriberHistoryManager, - private readonly MailReaderInterface $mailReader, - private readonly MessageParser $messageParser, + BounceManager $bounceManager, + NativeImapMailReader $mailReader, + MessageParser $messageParser, + BounceDataProcessor $bounceDataProcessor, ) { + $this->bounceManager = $bounceManager; + $this->mailReader = $mailReader; + $this->messageParser = $messageParser; + $this->bounceDataProcessor = $bounceDataProcessor; } public function processMailbox( @@ -103,82 +102,6 @@ private function processImapBounce($link, int $num, string $header): bool $bounce = $this->bounceManager->create($bounceDate, $header, $body); - return $this->processBounceData($bounce, $msgId, $userId, $bounceDate); - } - - public function processBounceData(Bounce $bounce, ?string $msgId, ?int $userId, DateTimeImmutable $bounceDate): bool - { - $user = $userId ? $this->subscriberManager->getSubscriberById($userId) : null; - - if ($msgId === 'systemmessage' && $userId) { - $this->bounceManager->update( - bounce: $bounce, - status: 'bounced system message', - comment: sprintf('%d marked unconfirmed', $userId) - ); - $this->bounceManager->linkUserMessageBounce($bounce, $bounceDate, $userId); - $this->subscriberManager->markUnconfirmed($userId); - $this->logger->info('system message bounced, user marked unconfirmed', ['userId' => $userId]); - if ($user) { - $this->subscriberHistoryManager->addHistory( - subscriber: $user, - message: 'Bounced system message', - details: sprintf('User marked unconfirmed. Bounce #%d', $bounce->getId()) - ); - } - - return true; - } - - if ($msgId && $userId) { - if (!$this->bounceManager->existsUserMessageBounce($userId, (int)$msgId)) { - $this->bounceManager->linkUserMessageBounce($bounce, $bounceDate, $userId, (int)$msgId); - $this->bounceManager->update( - bounce: $bounce, - status: sprintf('bounced list message %d', $msgId), - comment: sprintf('%d bouncecount increased', $userId) - ); - $this->messages->incrementBounceCount((int)$msgId); - $this->users->incrementBounceCount($userId); - } else { - $this->bounceManager->linkUserMessageBounce($bounce, $bounceDate, $userId, (int)$msgId); - $this->bounceManager->update( - bounce: $bounce, - status: sprintf('duplicate bounce for %d', $userId), - comment: sprintf('duplicate bounce for subscriber %d on message %d', $userId, $msgId) - ); - } - - return true; - } - - if ($userId) { - $this->bounceManager->update( - bounce: $bounce, - status: 'bounced unidentified message', - comment: sprintf('%d bouncecount increased', $userId) - ); - $this->users->incrementBounceCount($userId); - - return true; - } - - if ($msgId === 'systemmessage') { - $this->bounceManager->update($bounce, 'bounced system message', 'unknown user'); - $this->logger->info('system message bounced, but unknown user'); - - return true; - } - - if ($msgId) { - $this->bounceManager->update($bounce, sprintf('bounced list message %d', $msgId), 'unknown user'); - $this->messages->incrementBounceCount((int)$msgId); - - return true; - } - - $this->bounceManager->update($bounce, 'unidentified bounce', 'not processed'); - - return false; + return $this->bounceDataProcessor->process($bounce, $msgId, $userId, $bounceDate); } } diff --git a/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php new file mode 100644 index 00000000..2fbcd7ba --- /dev/null +++ b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php @@ -0,0 +1,103 @@ +subscriberManager->getSubscriberById($userId) : null; + + if ($msgId === 'systemmessage' && $userId) { + $this->bounceManager->update( + bounce: $bounce, + status: 'bounced system message', + comment: sprintf('%d marked unconfirmed', $userId) + ); + $this->bounceManager->linkUserMessageBounce($bounce, $bounceDate, $userId); + $this->subscriberManager->markUnconfirmed($userId); + $this->logger->info('system message bounced, user marked unconfirmed', ['userId' => $userId]); + if ($user) { + $this->subscriberHistoryManager->addHistory( + subscriber: $user, + message: 'Bounced system message', + details: sprintf('User marked unconfirmed. Bounce #%d', $bounce->getId()) + ); + } + + return true; + } + + if ($msgId && $userId) { + if (!$this->bounceManager->existsUserMessageBounce($userId, (int)$msgId)) { + $this->bounceManager->linkUserMessageBounce($bounce, $bounceDate, $userId, (int)$msgId); + $this->bounceManager->update( + bounce: $bounce, + status: sprintf('bounced list message %d', $msgId), + comment: sprintf('%d bouncecount increased', $userId) + ); + $this->messages->incrementBounceCount((int)$msgId); + $this->users->incrementBounceCount($userId); + } else { + $this->bounceManager->linkUserMessageBounce($bounce, $bounceDate, $userId, (int)$msgId); + $this->bounceManager->update( + bounce: $bounce, + status: sprintf('duplicate bounce for %d', $userId), + comment: sprintf('duplicate bounce for subscriber %d on message %d', $userId, $msgId) + ); + } + + return true; + } + + if ($userId) { + $this->bounceManager->update( + bounce: $bounce, + status: 'bounced unidentified message', + comment: sprintf('%d bouncecount increased', $userId) + ); + $this->users->incrementBounceCount($userId); + + return true; + } + + if ($msgId === 'systemmessage') { + $this->bounceManager->update($bounce, 'bounced system message', 'unknown user'); + $this->logger->info('system message bounced, but unknown user'); + + return true; + } + + if ($msgId) { + $this->bounceManager->update($bounce, sprintf('bounced list message %d', $msgId), 'unknown user'); + $this->messages->incrementBounceCount((int)$msgId); + + return true; + } + + $this->bounceManager->update($bounce, 'unidentified bounce', 'not processed'); + + return false; + } +} diff --git a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php new file mode 100644 index 00000000..431f9101 --- /dev/null +++ b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php @@ -0,0 +1,257 @@ +bounceManager = $bounceManager; + $this->logger = $logger; + $this->messageParser = $messageParser; + $this->clientManager = $clientManager; + $this->bounceDataProcessor = $bounceDataProcessor; + } + + /** + * Process unseen messages from the given mailbox using Webklex. + * + * $mailbox: IMAP host; if you pass "host#FOLDER", FOLDER will be used instead of INBOX. + */ + public function processMailbox( + SymfonyStyle $io, + string $mailbox, + string $user, + string $password, + int $max, + bool $purgeProcessed, + bool $purgeUnprocessed, + bool $testMode + ): string { + [$host, $folderName] = $this->parseMailbox($mailbox); + + $client = $this->clientManager->make([ + 'host' => $host, + 'port' => 993, + 'encryption' => 'ssl', + 'validate_cert' => true, + 'username' => $user, + 'password' => $password, + 'protocol' => 'imap', + ]); + + try { + $client->connect(); + } catch (Throwable $e) { + $io->error('Cannot connect to mailbox: '.$e->getMessage()); + throw new RuntimeException('Cannot connect to IMAP server'); + } + + try { + $folder = $client->getFolder($folderName); + + // Pull unseen messages (optionally you can add .since(...) if you want time-bounded scans) + $query = $folder->query()->unseen()->limit($max); + + $messages = $query->get(); + $num = $messages->count(); + + $io->writeln(sprintf('%d bounces to fetch from the mailbox %s/%s', $num, $host, $folderName)); + if ($num === 0) { + return ''; + } + + $io->writeln('Please do not interrupt this process'); + $io->writeln($testMode + ? 'Running in test mode, not deleting messages from mailbox' + : 'Processed messages will be deleted from the mailbox' + ); + + foreach ($messages as $message) { + $header = $this->headerToStringSafe($message); + $body = $this->bodyBestEffort($message); + $body = $this->messageParser->decodeBody($header, $body); + + if (\preg_match('/Action: delayed\s+Status: 4\.4\.7/im', $body)) { + if (!$testMode && $purgeProcessed) { + $this->safeDelete($message); + } + continue; + } + + $msgId = $this->messageParser->findMessageId($body."\r\n".$header); + $userId = $this->messageParser->findUserId($body."\r\n".$header); + + $bounceDate = $this->extractDate($message); + $bounce = $this->bounceManager->create($bounceDate, $header, $body); + + $processed = $this->bounceDataProcessor->process($bounce, $msgId, $userId, $bounceDate); + + if (!$testMode) { + if ($processed && $purgeProcessed) { + $this->safeDelete($message); + } elseif (!$processed && $purgeUnprocessed) { + $this->safeDelete($message); + } + } + } + + $io->writeln('Closing mailbox, and purging messages'); + if (!$testMode) { + try { + if (method_exists($folder, 'expunge')) { + $folder->expunge(); + } elseif (method_exists($client, 'expunge')) { + $client->expunge(); + } + } catch (Throwable $e) { + $this->logger->warning('EXPUNGE failed', ['error' => $e->getMessage()]); + } + } + + return ''; + } finally { + try { + $client->disconnect(); + } catch (Throwable $e) { + // swallow + } + } + } + + private function parseMailbox(string $mailbox): array + { + if (str_contains($mailbox, '#')) { + [$host, $folder] = explode('#', $mailbox, 2); + $host = trim($host); + $folder = trim($folder) ?: 'INBOX'; + return [$host, $folder]; + } + return [trim($mailbox), 'INBOX']; + } + + private function headerToStringSafe($message): string + { + // Prefer raw header string if available: + if (method_exists($message, 'getHeader')) { + try { + $headerObj = $message->getHeader(); + if ($headerObj && method_exists($headerObj, 'toString')) { + $raw = (string) $headerObj->toString(); + if ($raw !== '') { + return $raw; + } + } + } catch (Throwable) { + // fall back below + } + } + + $lines = []; + $subj = $message->getSubject() ?? ''; + $from = $this->addrFirstToString($message->getFrom()); + $to = $this->addrManyToString($message->getTo()); + $date = $this->extractDate($message)?->format(\DATE_RFC2822); + + if ($subj !== '') { $lines[] = 'Subject: '.$subj; } + if ($from !== '') { $lines[] = 'From: '.$from; } + if ($to !== '') { $lines[] = 'To: '.$to; } + if ($date) { $lines[] = 'Date: '.$date; } + + $mid = (string) ($message->getMessageId() ?? ''); + if ($mid !== '') { $lines[] = 'Message-ID: '.$mid; } + + return implode("\r\n", $lines)."\r\n"; + } + + private function bodyBestEffort($message): string + { + $text = ($message->getTextBody() ?? ''); + if ($text !== '') { + return $text; + } + $html = ($message->getHTMLBody() ?? ''); + if ($html !== '') { + return trim(strip_tags($html)); + } + return ''; + } + + private function extractDate($message): DateTimeImmutable + { + $d = $message->getDate(); + if ($d instanceof DateTimeInterface) { + return DateTimeImmutable::createFromInterface($d); + } + // fallback to internal date if exposed; else "now" + if (method_exists($message, 'getInternalDate')) { + $ts = (int) $message->getInternalDate(); + if ($ts > 0) { + return new DateTimeImmutable('@'.$ts); + } + } + return new DateTimeImmutable(); + } + + private function addrFirstToString($addresses): string + { + $many = $this->addrManyToArray($addresses); + return $many[0] ?? ''; + } + + private function addrManyToString($addresses): string + { + $arr = $this->addrManyToArray($addresses); + return implode(', ', $arr); + } + + private function addrManyToArray($addresses): array + { + if ($addresses === null) { + return []; + } + $out = []; + foreach ($addresses as $addr) { + $email = ($addr->mail ?? $addr->getAddress() ?? ''); + $name = ($addr->personal ?? $addr->getName() ?? ''); + $out[] = $name !== '' ? sprintf('%s <%s>', $name, $email) : $email; + } + return $out; + } + + private function safeDelete($message): void + { + try { + if (method_exists($message, 'delete')) { + $message->delete(); + } elseif (method_exists($message, 'setFlag')) { + $message->setFlag('DELETED'); + } + } catch (Throwable $e) { + $this->logger->warning('Failed to delete message', ['error' => $e->getMessage()]); + } + } +} From d179107f5bf9e880b64074a5c1181949e678346f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 25 Aug 2025 13:11:16 +0400 Subject: [PATCH 14/24] ClientFactory + refactor --- config/parameters.yml.dist | 14 ++++ config/services/processor.yml | 4 + config/services/services.yml | 43 +++++++--- .../Common/Mail/NativeImapMailReader.php | 14 +++- .../BounceProcessingServiceInterface.php | 12 +++ .../Service/NativeBounceProcessingService.php | 18 +++-- .../Service/Processor/MboxBounceProcessor.php | 16 +--- .../Service/Processor/PopBounceProcessor.php | 42 ++++------ .../WebklexBounceProcessingService.php | 58 +++++-------- .../Service/WebklexImapClientFactory.php | 81 +++++++++++++++++++ 10 files changed, 205 insertions(+), 97 deletions(-) create mode 100644 src/Domain/Messaging/Service/BounceProcessingServiceInterface.php create mode 100644 src/Domain/Messaging/Service/WebklexImapClientFactory.php diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index 292ebf21..be26b517 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -43,6 +43,20 @@ parameters: env(BOUNCE_IMAP_PORT): 993 imap_bounce.encryption: '%%env(BOUNCE_IMAP_ENCRYPTION)%%' env(BOUNCE_IMAP_ENCRYPTION): 'ssl' + imap_bounce.mailbox: '%%env(BOUNCE_IMAP_MAILBOX)%%' + env(BOUNCE_IMAP_MAILBOX): '/var/spool/mail/bounces' + imap_bounce.mailbox_name: '%%env(BOUNCE_IMAP_MAILBOX_NAME)%%' + env(BOUNCE_IMAP_MAILBOX_NAME): 'INBOX,ONE_MORE' + imap_bounce.protocol: '%%env(BOUNCE_IMAP_PROTOCOL)%%' + env(BOUNCE_IMAP_PROTOCOL): 'imap' + imap_bounce.unsubscribe_threshold: '%%env(BOUNCE_IMAP_UNSUBSCRIBE_THRESHOLD)%%' + env(BOUNCE_IMAP_UNSUBSCRIBE_THRESHOLD): 5 + imap_bounce.blacklist_threshold: '%%env(BOUNCE_IMAP_BLACKLIST_THRESHOLD)%%' + env(BOUNCE_IMAP_BLACKLIST_THRESHOLD): 3 + imap_bounce.purge: '%%env(BOUNCE_IMAP_PURGE)%%' + env(BOUNCE_IMAP_PURGE): 0 + imap_bounce.purge_unprocessed: '%%env(BOUNCE_IMAP_PURGE_UNPROCESSED)%%' + env(BOUNCE_IMAP_PURGE_UNPROCESSED): 0 # Messenger configuration for asynchronous processing app.messenger_transport_dsn: '%%env(MESSENGER_TRANSPORT_DSN)%%' diff --git a/config/services/processor.yml b/config/services/processor.yml index 102ef16c..6ea339c3 100644 --- a/config/services/processor.yml +++ b/config/services/processor.yml @@ -5,6 +5,10 @@ services: public: false PhpList\Core\Domain\Messaging\Service\Processor\PopBounceProcessor: + arguments: + $host: '%imap_bounce.host%' + $port: '%imap_bounce.port%' + $mailboxNames: '%imap_bounce.mailbox_name%' tags: ['phplist.bounce_protocol_processor'] PhpList\Core\Domain\Messaging\Service\Processor\MboxBounceProcessor: diff --git a/config/services/services.yml b/config/services/services.yml index e87bd276..340a22c7 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -45,16 +45,41 @@ services: autoconfigure: true PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler: - autowire: true - autoconfigure: true - arguments: - $unsubscribeThreshold: '%app.unsubscribe_threshold%' - $blacklistThreshold: '%app.blacklist_threshold%' + autowire: true + autoconfigure: true + arguments: + $unsubscribeThreshold: '%imap_bounce.unsubscribe_threshold%' + $blacklistThreshold: '%imap_bounce.blacklist_threshold%' Webklex\PHPIMAP\ClientManager: ~ + PhpList\Core\Domain\Messaging\Service\WebklexImapClientFactory: + autowire: true + autoconfigure: true + arguments: + $mailbox: '%imap_bounce.mailbox%'# e.g. "{imap.example.com:993/imap/ssl}INBOX" or "/var/mail/user" + $host: '%imap_bounce.host%' + $port: '%imap_bounce.port%' + $encryption: '%imap_bounce.encryption%' + $username: '%imap_bounce.email%' + $password: '%imap_bounce.password%' + $protocol: '%imap_bounce.protocol%' + PhpList\Core\Domain\Common\Mail\NativeImapMailReader: - arguments: - $mailbox: '%env(IMAP_MAILBOX)%' # e.g. "{imap.example.com:993/imap/ssl}INBOX" or "/var/mail/user" - $user: '%imap_bounce.email%' - $pass: '%imap_bounce.password%' + arguments: + $username: '%imap_bounce.email%' + $password: '%imap_bounce.password%' + + PhpList\Core\Domain\Messaging\Service\NativeBounceProcessingService: + autowire: true + autoconfigure: true + arguments: + $purgeProcessed: '%imap_bounce.purge%' + $purgeUnprocessed: '%imap_bounce.purge_unprocessed%' + + PhpList\Core\Domain\Messaging\Service\WebklexBounceProcessingService: + autowire: true + autoconfigure: true + arguments: + $purgeProcessed: '%imap_bounce.purge%' + $purgeUnprocessed: '%imap_bounce.purge_unprocessed%' diff --git a/src/Domain/Common/Mail/NativeImapMailReader.php b/src/Domain/Common/Mail/NativeImapMailReader.php index dfd5bebc..7cf6436f 100644 --- a/src/Domain/Common/Mail/NativeImapMailReader.php +++ b/src/Domain/Common/Mail/NativeImapMailReader.php @@ -10,12 +10,22 @@ class NativeImapMailReader { - public function open(string $mailbox, ?string $user = null, ?string $password = null, int $options = 0): Connection + private string $username; + private string $password; + + public function __construct(string $username, string $password) { - $link = @imap_open($mailbox, (string)$user, (string)$password, $options); + $this->username = $username; + $this->password = $password; + } + + public function open(string $mailbox, int $options = 0): Connection + { + $link = @imap_open($mailbox, $this->username, $this->password, $options); if ($link === false) { throw new RuntimeException('Cannot open mailbox: '.(imap_last_error() ?: 'unknown error')); } + return $link; } diff --git a/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php b/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php new file mode 100644 index 00000000..fc1a59b0 --- /dev/null +++ b/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php @@ -0,0 +1,12 @@ +bounceManager = $bounceManager; $this->mailReader = $mailReader; $this->messageParser = $messageParser; $this->bounceDataProcessor = $bounceDataProcessor; + $this->purgeProcessed = $purgeProcessed; + $this->purgeUnprocessed = $purgeUnprocessed; } public function processMailbox( SymfonyStyle $io, string $mailbox, - string $user, - string $password, int $max, - bool $purgeProcessed, - bool $purgeUnprocessed, bool $testMode ): string { try { - $link = $this->mailReader->open($mailbox, $user, $password, $testMode ? 0 : CL_EXPUNGE); + $link = $this->mailReader->open($mailbox, $testMode ? 0 : CL_EXPUNGE); } catch (Throwable $e) { $io->error('Cannot open mailbox file: '.$e->getMessage()); throw new RuntimeException('Cannot open mbox file'); @@ -66,11 +68,11 @@ public function processMailbox( $header = $this->mailReader->fetchHeader($link, $x); $processed = $this->processImapBounce($link, $x, $header, $io); if ($processed) { - if (!$testMode && $purgeProcessed) { + if (!$testMode && $this->purgeProcessed) { $this->mailReader->delete($link, $x); } } else { - if (!$testMode && $purgeUnprocessed) { + if (!$testMode && $this->purgeUnprocessed) { $this->mailReader->delete($link, $x); } } diff --git a/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php b/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php index 0aa48ac2..fc57f114 100644 --- a/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php +++ b/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php @@ -4,22 +4,18 @@ namespace PhpList\Core\Domain\Messaging\Service\Processor; -use PhpList\Core\Domain\Messaging\Service\NativeBounceProcessingService; +use PhpList\Core\Domain\Messaging\Service\BounceProcessingServiceInterface; use RuntimeException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; class MboxBounceProcessor implements BounceProtocolProcessor { - private $processingService; - private string $user; - private string $pass; + private BounceProcessingServiceInterface $processingService; - public function __construct(NativeBounceProcessingService $processingService, string $user, string $pass) + public function __construct(BounceProcessingServiceInterface $processingService) { $this->processingService = $processingService; - $this->user = $user; - $this->pass = $pass; } public function getProtocol(): string @@ -31,8 +27,6 @@ public function process(InputInterface $input, SymfonyStyle $io): string { $testMode = (bool)$input->getOption('test'); $max = (int)$input->getOption('maximum'); - $purgeProcessed = $input->getOption('purge') && !$testMode; - $purgeUnprocessed = $input->getOption('purge-unprocessed') && !$testMode; $file = (string)$input->getOption('mailbox'); if (!$file) { @@ -45,11 +39,7 @@ public function process(InputInterface $input, SymfonyStyle $io): string return $this->processingService->processMailbox( $io, $file, - $this->user, - $this->pass, $max, - $purgeProcessed, - $purgeUnprocessed, $testMode ); } diff --git a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php index 99a2de58..175738a4 100644 --- a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php +++ b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php @@ -4,18 +4,27 @@ namespace PhpList\Core\Domain\Messaging\Service\Processor; -use PhpList\Core\Domain\Messaging\Service\NativeBounceProcessingService; -use RuntimeException; +use PhpList\Core\Domain\Messaging\Service\BounceProcessingServiceInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; class PopBounceProcessor implements BounceProtocolProcessor { - private $processingService; - - public function __construct(NativeBounceProcessingService $processingService) - { + private BounceProcessingServiceInterface $processingService; + private string $host; + private int $port; + private string $mailboxNames; + + public function __construct( + BounceProcessingServiceInterface $processingService, + string $host, + int $port, + string $mailboxNames + ) { $this->processingService = $processingService; + $this->host = $host; + $this->port = $port; + $this->mailboxNames = $mailboxNames; } public function getProtocol(): string @@ -27,35 +36,18 @@ public function process(InputInterface $input, SymfonyStyle $io): string { $testMode = (bool)$input->getOption('test'); $max = (int)$input->getOption('maximum'); - $purgeProcessed = $input->getOption('purge') && !$testMode; - $purgeUnprocessed = $input->getOption('purge-unprocessed') && !$testMode; - - $host = (string)$input->getOption('host'); - $user = (string)$input->getOption('user'); - $password = (string)$input->getOption('password'); - $port = (string)$input->getOption('port'); - $mailboxes = (string)$input->getOption('mailbox'); - - if (!$host || !$user || !$password) { - $io->error('POP configuration incomplete: host, user, and password are required.'); - throw new RuntimeException('POP configuration incomplete'); - } $downloadReport = ''; - foreach (explode(',', $mailboxes) as $mailboxName) { + foreach (explode(',', $this->mailboxNames) as $mailboxName) { $mailboxName = trim($mailboxName); if ($mailboxName === '') { $mailboxName = 'INBOX'; } - $mailbox = sprintf('{%s:%s}%s', $host, $port, $mailboxName); + $mailbox = sprintf('{%s:%s}%s', $this->host, $this->port, $mailboxName); $io->section("Connecting to $mailbox"); $downloadReport .= $this->processingService->processMailbox( $io, $mailbox, - $user, - $password, $max, - $purgeProcessed, - $purgeUnprocessed, $testMode ); } diff --git a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php index 431f9101..db1298a4 100644 --- a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php +++ b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php @@ -12,28 +12,33 @@ use RuntimeException; use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; -use Webklex\PHPIMAP\ClientManager; -class WebklexBounceProcessingService +class WebklexBounceProcessingService implements BounceProcessingServiceInterface { private BounceManager $bounceManager; private LoggerInterface $logger; private MessageParser $messageParser; - private ClientManager $clientManager; + private WebklexImapClientFactory $clientFactory; private BounceDataProcessor $bounceDataProcessor; + private bool $purgeProcessed; + private bool $purgeUnprocessed; public function __construct( BounceManager $bounceManager, LoggerInterface $logger, MessageParser $messageParser, - ClientManager $clientManager, + WebklexImapClientFactory $clientFactory, BounceDataProcessor $bounceDataProcessor, + bool $purgeProcessed, + bool $purgeUnprocessed ) { $this->bounceManager = $bounceManager; $this->logger = $logger; $this->messageParser = $messageParser; - $this->clientManager = $clientManager; + $this->clientFactory = $clientFactory; $this->bounceDataProcessor = $bounceDataProcessor; + $this->purgeProcessed = $purgeProcessed; + $this->purgeUnprocessed = $purgeUnprocessed; } /** @@ -44,24 +49,10 @@ public function __construct( public function processMailbox( SymfonyStyle $io, string $mailbox, - string $user, - string $password, int $max, - bool $purgeProcessed, - bool $purgeUnprocessed, bool $testMode ): string { - [$host, $folderName] = $this->parseMailbox($mailbox); - - $client = $this->clientManager->make([ - 'host' => $host, - 'port' => 993, - 'encryption' => 'ssl', - 'validate_cert' => true, - 'username' => $user, - 'password' => $password, - 'protocol' => 'imap', - ]); + $client = $this->clientFactory->makeForMailbox(); try { $client->connect(); @@ -71,15 +62,13 @@ public function processMailbox( } try { - $folder = $client->getFolder($folderName); - - // Pull unseen messages (optionally you can add .since(...) if you want time-bounded scans) + $folder = $client->getFolder($this->clientFactory->getFolderName()); $query = $folder->query()->unseen()->limit($max); $messages = $query->get(); $num = $messages->count(); - $io->writeln(sprintf('%d bounces to fetch from the mailbox %s/%s', $num, $host, $folderName)); + $io->writeln(sprintf('%d bounces to fetch from the mailbox', $num)); if ($num === 0) { return ''; } @@ -96,7 +85,7 @@ public function processMailbox( $body = $this->messageParser->decodeBody($header, $body); if (\preg_match('/Action: delayed\s+Status: 4\.4\.7/im', $body)) { - if (!$testMode && $purgeProcessed) { + if (!$testMode && $this->purgeProcessed) { $this->safeDelete($message); } continue; @@ -111,9 +100,9 @@ public function processMailbox( $processed = $this->bounceDataProcessor->process($bounce, $msgId, $userId, $bounceDate); if (!$testMode) { - if ($processed && $purgeProcessed) { + if ($processed && $this->purgeProcessed) { $this->safeDelete($message); - } elseif (!$processed && $purgeUnprocessed) { + } elseif (!$processed && $this->purgeUnprocessed) { $this->safeDelete($message); } } @@ -142,17 +131,6 @@ public function processMailbox( } } - private function parseMailbox(string $mailbox): array - { - if (str_contains($mailbox, '#')) { - [$host, $folder] = explode('#', $mailbox, 2); - $host = trim($host); - $folder = trim($folder) ?: 'INBOX'; - return [$host, $folder]; - } - return [trim($mailbox), 'INBOX']; - } - private function headerToStringSafe($message): string { // Prefer raw header string if available: @@ -165,7 +143,7 @@ private function headerToStringSafe($message): string return $raw; } } - } catch (Throwable) { + } catch (Throwable $e) { // fall back below } } @@ -174,7 +152,7 @@ private function headerToStringSafe($message): string $subj = $message->getSubject() ?? ''; $from = $this->addrFirstToString($message->getFrom()); $to = $this->addrManyToString($message->getTo()); - $date = $this->extractDate($message)?->format(\DATE_RFC2822); + $date = $this->extractDate($message)->format(\DATE_RFC2822); if ($subj !== '') { $lines[] = 'Subject: '.$subj; } if ($from !== '') { $lines[] = 'From: '.$from; } diff --git a/src/Domain/Messaging/Service/WebklexImapClientFactory.php b/src/Domain/Messaging/Service/WebklexImapClientFactory.php new file mode 100644 index 00000000..1657ac95 --- /dev/null +++ b/src/Domain/Messaging/Service/WebklexImapClientFactory.php @@ -0,0 +1,81 @@ +clientManager = $clientManager; + $this->mailbox = $mailbox; + $this->host = $host; + $this->username = $username; + $this->password = $password; + $this->protocol = $protocol; + $this->port = $port; + $this->encryption = $encryption; + } + + /** + * @param array $config + * @throws MaskNotFoundException + */ + public function make(array $config): Client + { + return $this->clientManager->make($config); + } + + public function makeForMailbox(): Client + { + return $this->make([ + 'host' => $this->host, + 'port' => $this->port, + 'encryption' => $this->encryption, + 'validate_cert' => true, + 'username' => $this->username, + 'password' => $this->password, + 'protocol' => $this->protocol, + ]); + } + + public function getFolderName(): string + { + // todo: check if folder logic is correct + return $this->parseMailbox($this->mailbox)[1]; + } + + private function parseMailbox(string $mailbox): array + { + if (str_contains($mailbox, '#')) { + [$host, $folder] = explode('#', $mailbox, 2); + $host = trim($host); + $folder = trim($folder) ?: 'INBOX'; + return [$host, $folder]; + } + return [trim($mailbox), 'INBOX']; + } +} From da12c7fdf1626a338394e3ae5431755709d2b6d2 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 25 Aug 2025 13:30:04 +0400 Subject: [PATCH 15/24] BounceProcessorPass --- src/Core/ApplicationKernel.php | 1 + src/Core/BounceProcessorPass.php | 29 +++++++++++++++++++ .../Command/ProcessBouncesCommand.php | 4 +-- 3 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 src/Core/BounceProcessorPass.php diff --git a/src/Core/ApplicationKernel.php b/src/Core/ApplicationKernel.php index 97249b45..8f43e62b 100644 --- a/src/Core/ApplicationKernel.php +++ b/src/Core/ApplicationKernel.php @@ -106,6 +106,7 @@ protected function build(ContainerBuilder $container): void { $container->setParameter('kernel.application_dir', $this->getApplicationDir()); $container->addCompilerPass(new DoctrineMappingPass()); + $container->addCompilerPass(new BounceProcessorPass()); } /** diff --git a/src/Core/BounceProcessorPass.php b/src/Core/BounceProcessorPass.php new file mode 100644 index 00000000..07a355a8 --- /dev/null +++ b/src/Core/BounceProcessorPass.php @@ -0,0 +1,29 @@ +hasDefinition($native) || !$container->hasDefinition($webklex)) { + return; + } + + $aliasTo = \extension_loaded('imap') ? $native : $webklex; + + $container->setAlias($iface, $aliasTo)->setPublic(false); + } +} diff --git a/src/Domain/Messaging/Command/ProcessBouncesCommand.php b/src/Domain/Messaging/Command/ProcessBouncesCommand.php index 04a03828..5a065081 100644 --- a/src/Domain/Messaging/Command/ProcessBouncesCommand.php +++ b/src/Domain/Messaging/Command/ProcessBouncesCommand.php @@ -48,9 +48,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new SymfonyStyle($input, $output); if (!function_exists('imap_open')) { - $io->error('IMAP extension not available. Cannot continue.'); - - return Command::FAILURE; + $io->note('PHP IMAP extension not available. Falling back to Webklex IMAP where applicable.'); } $force = (bool)$input->getOption('force'); From 77bf4284fdf94b335d8656c5aad805244ee6ae4b Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 26 Aug 2025 10:57:17 +0400 Subject: [PATCH 16/24] Register services + phpstan fix --- config/parameters.yml.dist | 10 +- config/services.yml | 96 +++--- config/services/builders.yml | 2 +- config/services/managers.yml | 8 + config/services/processor.yml | 2 + config/services/repositories.yml | 292 +++++++++--------- config/services/services.yml | 180 ++++++----- src/Core/BounceProcessorPass.php | 5 +- src/Domain/Common/Model/MessageDto.php | 36 --- .../Service/NativeBounceProcessingService.php | 2 +- .../UnidentifiedBounceReprocessor.php | 23 +- .../WebklexBounceProcessingService.php | 28 +- .../Service/SubscriberBlacklistService.php | 2 +- .../Service/SubscriberDeletionServiceTest.php | 3 +- .../Service/Manager/BounceManagerTest.php | 22 +- .../Manager/BounceRegexManagerTest.php | 2 +- .../Manager/SubscriberHistoryManagerTest.php | 6 +- .../Service/Manager/SubscriberManagerTest.php | 4 +- 18 files changed, 364 insertions(+), 359 deletions(-) delete mode 100644 src/Domain/Common/Model/MessageDto.php diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index be26b517..54c649d8 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -40,7 +40,7 @@ parameters: imap_bounce.host: '%%env(BOUNCE_IMAP_HOST)%%' env(BOUNCE_IMAP_HOST): 'imap.phplist.com' imap_bounce.port: '%%env(BOUNCE_IMAP_PORT)%%' - env(BOUNCE_IMAP_PORT): 993 + env(BOUNCE_IMAP_PORT): '993' imap_bounce.encryption: '%%env(BOUNCE_IMAP_ENCRYPTION)%%' env(BOUNCE_IMAP_ENCRYPTION): 'ssl' imap_bounce.mailbox: '%%env(BOUNCE_IMAP_MAILBOX)%%' @@ -50,13 +50,13 @@ parameters: imap_bounce.protocol: '%%env(BOUNCE_IMAP_PROTOCOL)%%' env(BOUNCE_IMAP_PROTOCOL): 'imap' imap_bounce.unsubscribe_threshold: '%%env(BOUNCE_IMAP_UNSUBSCRIBE_THRESHOLD)%%' - env(BOUNCE_IMAP_UNSUBSCRIBE_THRESHOLD): 5 + env(BOUNCE_IMAP_UNSUBSCRIBE_THRESHOLD): '5' imap_bounce.blacklist_threshold: '%%env(BOUNCE_IMAP_BLACKLIST_THRESHOLD)%%' - env(BOUNCE_IMAP_BLACKLIST_THRESHOLD): 3 + env(BOUNCE_IMAP_BLACKLIST_THRESHOLD): '3' imap_bounce.purge: '%%env(BOUNCE_IMAP_PURGE)%%' - env(BOUNCE_IMAP_PURGE): 0 + env(BOUNCE_IMAP_PURGE): '0' imap_bounce.purge_unprocessed: '%%env(BOUNCE_IMAP_PURGE_UNPROCESSED)%%' - env(BOUNCE_IMAP_PURGE_UNPROCESSED): 0 + env(BOUNCE_IMAP_PURGE_UNPROCESSED): '0' # Messenger configuration for asynchronous processing app.messenger_transport_dsn: '%%env(MESSENGER_TRANSPORT_DSN)%%' diff --git a/config/services.yml b/config/services.yml index b83adce3..47be8241 100644 --- a/config/services.yml +++ b/config/services.yml @@ -1,51 +1,51 @@ imports: - - { resource: 'services/*.yml' } + - { resource: 'services/*.yml' } services: - _defaults: - autowire: true - autoconfigure: true - public: false - - PhpList\Core\Core\ConfigProvider: - arguments: - $config: '%app.config%' - - PhpList\Core\Core\ApplicationStructure: - public: true - - PhpList\Core\Security\Authentication: - public: true - - PhpList\Core\Security\HashGenerator: - public: true - - PhpList\Core\Routing\ExtraLoader: - tags: [routing.loader] - - PhpList\Core\Domain\Common\Repository\AbstractRepository: - abstract: true - autowire: true - autoconfigure: false - public: true - factory: ['@doctrine.orm.entity_manager', getRepository] - - # controllers are imported separately to make sure they're public - # and have a tag that allows actions to type-hint services - PhpList\Core\EmptyStartPageBundle\Controller\: - resource: '../src/EmptyStartPageBundle/Controller' - public: true - tags: [controller.service_arguments] - - doctrine.orm.metadata.annotation_reader: - alias: doctrine.annotation_reader - - doctrine.annotation_reader: - class: Doctrine\Common\Annotations\AnnotationReader - autowire: true - - doctrine.orm.default_annotation_metadata_driver: - class: Doctrine\ORM\Mapping\Driver\AnnotationDriver - arguments: - - '@annotation_reader' - - '%kernel.project_dir%/src/Domain/Model/' + _defaults: + autowire: true + autoconfigure: true + public: false + + PhpList\Core\Core\ConfigProvider: + arguments: + $config: '%app.config%' + + PhpList\Core\Core\ApplicationStructure: + public: true + + PhpList\Core\Security\Authentication: + public: true + + PhpList\Core\Security\HashGenerator: + public: true + + PhpList\Core\Routing\ExtraLoader: + tags: [routing.loader] + + PhpList\Core\Domain\Common\Repository\AbstractRepository: + abstract: true + autowire: true + autoconfigure: false + public: true + factory: ['@doctrine.orm.entity_manager', getRepository] + + # controllers are imported separately to make sure they're public + # and have a tag that allows actions to type-hint services + PhpList\Core\EmptyStartPageBundle\Controller\: + resource: '../src/EmptyStartPageBundle/Controller' + public: true + tags: [controller.service_arguments] + + doctrine.orm.metadata.annotation_reader: + alias: doctrine.annotation_reader + + doctrine.annotation_reader: + class: Doctrine\Common\Annotations\AnnotationReader + autowire: true + + doctrine.orm.default_annotation_metadata_driver: + class: Doctrine\ORM\Mapping\Driver\AnnotationDriver + arguments: + - '@annotation_reader' + - '%kernel.project_dir%/src/Domain/Model/' diff --git a/config/services/builders.yml b/config/services/builders.yml index c18961d6..10a994a4 100644 --- a/config/services/builders.yml +++ b/config/services/builders.yml @@ -20,6 +20,6 @@ services: autowire: true autoconfigure: true - PhpListPhpList\Core\Domain\Messaging\Service\Builder\MessageOptionsBuilder: + PhpList\Core\Domain\Messaging\Service\Builder\MessageOptionsBuilder: autowire: true autoconfigure: true diff --git a/config/services/managers.yml b/config/services/managers.yml index b5138505..5ef215b3 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -83,3 +83,11 @@ services: PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: autowire: true autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\Manager\SendProcessManager: + autowire: true + autoconfigure: true diff --git a/config/services/processor.yml b/config/services/processor.yml index 6ea339c3..acbd11c0 100644 --- a/config/services/processor.yml +++ b/config/services/processor.yml @@ -17,3 +17,5 @@ services: PhpList\Core\Domain\Messaging\Service\Processor\AdvancedBounceRulesProcessor: ~ PhpList\Core\Domain\Messaging\Service\Processor\UnidentifiedBounceReprocessor: ~ + + PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor: ~ diff --git a/config/services/repositories.yml b/config/services/repositories.yml index bd966628..82ae6a82 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -1,142 +1,152 @@ services: - PhpList\Core\Domain\Identity\Repository\AdministratorRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Identity\Model\Administrator - - Doctrine\ORM\Mapping\ClassMetadata\ClassMetadata - - PhpList\Core\Security\HashGenerator - - PhpList\Core\Domain\Identity\Repository\AdminAttributeValueRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Identity\Model\AdminAttributeValue - - PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition - - PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Identity\Model\AdministratorToken - - PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Identity\Model\AdminPasswordRequest - - PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\SubscriberList - - PhpList\Core\Domain\Subscription\Repository\SubscriberRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\Subscriber - - PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue - - PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition - - PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\Subscription - - PhpList\Core\Domain\Messaging\Repository\MessageRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Messaging\Model\Message - - PhpList\Core\Domain\Messaging\Repository\TemplateRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Messaging\Model\Template - - PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Messaging\Model\TemplateImage - - PhpList\Core\Domain\Configuration\Repository\ConfigRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Configuration\Model\Config - - PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Messaging\Model\UserMessageBounce - - PhpList\Core\Domain\Messaging\Repository\UserMessageForwardRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Messaging\Model\UserMessageForward - - PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Analytics\Model\LinkTrack - - PhpList\Core\Domain\Analytics\Repository\UserMessageViewRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Analytics\Model\UserMessageView - - PhpList\Core\Domain\Analytics\Repository\LinkTrackUmlClickRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Analytics\Model\LinkTrackUmlClick - - PhpList\Core\Domain\Messaging\Repository\UserMessageRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Messaging\Model\UserMessage - - PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\SubscriberHistory - - PhpList\Core\Domain\Messaging\Repository\ListMessageRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Messaging\Model\ListMessage - - PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\UserBlacklist - - PhpList\Core\Domain\Subscription\Repository\UserBlacklistDataRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\UserBlacklistData - - PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\SubscribePage - - PhpList\Core\Domain\Subscription\Repository\SubscriberPageDataRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\SubscribePageData - - PhpList\Core\Domain\Messaging\Repository\BounceRegexRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Messaging\Model\BounceRegex - - PhpList\Core\Domain\Messaging\Repository\BounceRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Messaging\Model\Bounce + PhpList\Core\Domain\Identity\Repository\AdministratorRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Identity\Model\Administrator + - Doctrine\ORM\Mapping\ClassMetadata\ClassMetadata + - PhpList\Core\Security\HashGenerator + + PhpList\Core\Domain\Identity\Repository\AdminAttributeValueRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Identity\Model\AdminAttributeValue + + PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition + + PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Identity\Model\AdministratorToken + + PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Identity\Model\AdminPasswordRequest + + PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscriberList + + PhpList\Core\Domain\Subscription\Repository\SubscriberRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\Subscriber + + PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue + + PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition + + PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\Subscription + + PhpList\Core\Domain\Messaging\Repository\MessageRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\Message + + PhpList\Core\Domain\Messaging\Repository\TemplateRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\Template + + PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\TemplateImage + + PhpList\Core\Domain\Configuration\Repository\ConfigRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Configuration\Model\Config + + PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\UserMessageBounce + + PhpList\Core\Domain\Messaging\Repository\UserMessageForwardRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\UserMessageForward + + PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Analytics\Model\LinkTrack + + PhpList\Core\Domain\Analytics\Repository\UserMessageViewRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Analytics\Model\UserMessageView + + PhpList\Core\Domain\Analytics\Repository\LinkTrackUmlClickRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Analytics\Model\LinkTrackUmlClick + + PhpList\Core\Domain\Messaging\Repository\UserMessageRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\UserMessage + + PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscriberHistory + + PhpList\Core\Domain\Messaging\Repository\ListMessageRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\ListMessage + + PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\UserBlacklist + + PhpList\Core\Domain\Subscription\Repository\UserBlacklistDataRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\UserBlacklistData + + PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscribePage + + PhpList\Core\Domain\Subscription\Repository\SubscriberPageDataRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscribePageData + + PhpList\Core\Domain\Messaging\Repository\BounceRegexRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\BounceRegex + + PhpList\Core\Domain\Messaging\Repository\BounceRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\Bounce + + PhpList\Core\Domain\Messaging\Repository\BounceRegexBounceRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\BounceRegex + + PhpList\Core\Domain\Messaging\Repository\SendProcessRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\SendProcess diff --git a/config/services/services.yml b/config/services/services.yml index 340a22c7..ff21c0fc 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -1,85 +1,97 @@ services: - PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter: - autowire: true - autoconfigure: true - public: true - - PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter: - autowire: true - autoconfigure: true - public: true - - PhpList\Core\Domain\Messaging\Service\EmailService: - autowire: true - autoconfigure: true - arguments: - $defaultFromEmail: '%app.mailer_from%' - $bounceEmail: '%imap_bounce.email%' - - PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService: - autowire: true - autoconfigure: true - public: true - - PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator: - autowire: true - autoconfigure: true - public: true - - PhpList\Core\Domain\Analytics\Service\LinkTrackService: - autowire: true - autoconfigure: true - public: true - - PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor: - autowire: true - autoconfigure: true - public: true - - PhpList\Core\Domain\Common\ClientIpResolver: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Common\SystemInfoCollector: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler: - autowire: true - autoconfigure: true - arguments: - $unsubscribeThreshold: '%imap_bounce.unsubscribe_threshold%' - $blacklistThreshold: '%imap_bounce.blacklist_threshold%' - - Webklex\PHPIMAP\ClientManager: ~ - - PhpList\Core\Domain\Messaging\Service\WebklexImapClientFactory: - autowire: true - autoconfigure: true - arguments: - $mailbox: '%imap_bounce.mailbox%'# e.g. "{imap.example.com:993/imap/ssl}INBOX" or "/var/mail/user" - $host: '%imap_bounce.host%' - $port: '%imap_bounce.port%' - $encryption: '%imap_bounce.encryption%' - $username: '%imap_bounce.email%' - $password: '%imap_bounce.password%' - $protocol: '%imap_bounce.protocol%' - - PhpList\Core\Domain\Common\Mail\NativeImapMailReader: - arguments: - $username: '%imap_bounce.email%' - $password: '%imap_bounce.password%' - - PhpList\Core\Domain\Messaging\Service\NativeBounceProcessingService: - autowire: true - autoconfigure: true - arguments: - $purgeProcessed: '%imap_bounce.purge%' - $purgeUnprocessed: '%imap_bounce.purge_unprocessed%' - - PhpList\Core\Domain\Messaging\Service\WebklexBounceProcessingService: - autowire: true - autoconfigure: true - arguments: - $purgeProcessed: '%imap_bounce.purge%' - $purgeUnprocessed: '%imap_bounce.purge_unprocessed%' + PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter: + autowire: true + autoconfigure: true + public: true + + PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter: + autowire: true + autoconfigure: true + public: true + + PhpList\Core\Domain\Messaging\Service\EmailService: + autowire: true + autoconfigure: true + arguments: + $defaultFromEmail: '%app.mailer_from%' + $bounceEmail: '%imap_bounce.email%' + + PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService: + autowire: true + autoconfigure: true + public: true + + PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator: + autowire: true + autoconfigure: true + public: true + + PhpList\Core\Domain\Analytics\Service\LinkTrackService: + autowire: true + autoconfigure: true + public: true + + PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor: + autowire: true + autoconfigure: true + public: true + + PhpList\Core\Domain\Common\ClientIpResolver: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Common\SystemInfoCollector: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler: + autowire: true + autoconfigure: true + arguments: + $unsubscribeThreshold: '%imap_bounce.unsubscribe_threshold%' + $blacklistThreshold: '%imap_bounce.blacklist_threshold%' + + Webklex\PHPIMAP\ClientManager: ~ + + PhpList\Core\Domain\Messaging\Service\WebklexImapClientFactory: + autowire: true + autoconfigure: true + arguments: + $mailbox: '%imap_bounce.mailbox%'# e.g. "{imap.example.com:993/imap/ssl}INBOX" or "/var/mail/user" + $host: '%imap_bounce.host%' + $port: '%imap_bounce.port%' + $encryption: '%imap_bounce.encryption%' + $username: '%imap_bounce.email%' + $password: '%imap_bounce.password%' + $protocol: '%imap_bounce.protocol%' + + PhpList\Core\Domain\Common\Mail\NativeImapMailReader: + arguments: + $username: '%imap_bounce.email%' + $password: '%imap_bounce.password%' + + PhpList\Core\Domain\Messaging\Service\NativeBounceProcessingService: + autowire: true + autoconfigure: true + arguments: + $purgeProcessed: '%imap_bounce.purge%' + $purgeUnprocessed: '%imap_bounce.purge_unprocessed%' + + PhpList\Core\Domain\Messaging\Service\WebklexBounceProcessingService: + autowire: true + autoconfigure: true + arguments: + $purgeProcessed: '%imap_bounce.purge%' + $purgeUnprocessed: '%imap_bounce.purge_unprocessed%' + + PhpList\Core\Domain\Messaging\Service\LockService: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\MessageParser: + autowire: true + autoconfigure: true diff --git a/src/Core/BounceProcessorPass.php b/src/Core/BounceProcessorPass.php index 07a355a8..2ab5c9c5 100644 --- a/src/Core/BounceProcessorPass.php +++ b/src/Core/BounceProcessorPass.php @@ -14,7 +14,6 @@ class BounceProcessorPass implements CompilerPassInterface { public function process(ContainerBuilder $container): void { - $iface = BounceProcessingServiceInterface::class; $native = NativeBounceProcessingService::class; $webklex = WebklexBounceProcessingService::class; @@ -22,8 +21,8 @@ public function process(ContainerBuilder $container): void return; } - $aliasTo = \extension_loaded('imap') ? $native : $webklex; + $aliasTo = extension_loaded('imap') ? $native : $webklex; - $container->setAlias($iface, $aliasTo)->setPublic(false); + $container->setAlias(BounceProcessingServiceInterface::class, $aliasTo)->setPublic(false); } } diff --git a/src/Domain/Common/Model/MessageDto.php b/src/Domain/Common/Model/MessageDto.php deleted file mode 100644 index 7f49ff46..00000000 --- a/src/Domain/Common/Model/MessageDto.php +++ /dev/null @@ -1,36 +0,0 @@ -uid; } - public function getMessageId(): string { return $this->messageId; } - public function getSubject(): string { return $this->subject; } - public function getFrom(): string { return $this->from; } - public function getTo(): array { return $this->to; } - public function getCc(): ?string { return $this->cc; } - public function getBcc(): ?string { return $this->bcc; } - public function getDate(): DateTimeImmutable { return $this->date; } - public function getBodyText(): string { return $this->bodyText; } - public function getBodyHtml(): string { return $this->bodyHtml; } - public function getAttachments(): array { return $this->attachments; } -} diff --git a/src/Domain/Messaging/Service/NativeBounceProcessingService.php b/src/Domain/Messaging/Service/NativeBounceProcessingService.php index 606d5385..47f39370 100644 --- a/src/Domain/Messaging/Service/NativeBounceProcessingService.php +++ b/src/Domain/Messaging/Service/NativeBounceProcessingService.php @@ -66,7 +66,7 @@ public function processMailbox( for ($x = 1; $x <= $num; $x++) { $header = $this->mailReader->fetchHeader($link, $x); - $processed = $this->processImapBounce($link, $x, $header, $io); + $processed = $this->processImapBounce($link, $x, $header); if ($processed) { if (!$testMode && $this->purgeProcessed) { $this->mailReader->delete($link, $x); diff --git a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php index 28535ab1..83073baa 100644 --- a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php +++ b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php @@ -5,16 +5,25 @@ namespace PhpList\Core\Domain\Messaging\Service\Processor; use DateTimeImmutable; -use PhpList\Core\Domain\Messaging\Service\NativeBounceProcessingService; +use PhpList\Core\Domain\Messaging\Service\MessageParser; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use Symfony\Component\Console\Style\SymfonyStyle; class UnidentifiedBounceReprocessor { + private BounceManager $bounceManager; + private MessageParser $messageParser; + private BounceDataProcessor $bounceDataProcessor; + + public function __construct( - private readonly BounceManager $bounceManager, - private readonly NativeBounceProcessingService $processingService, + BounceManager $bounceManager, + MessageParser $messageParser, + BounceDataProcessor $bounceDataProcessor, ) { + $this->bounceManager = $bounceManager; + $this->messageParser = $messageParser; + $this->bounceDataProcessor = $bounceDataProcessor; } public function process(SymfonyStyle $io): void @@ -33,13 +42,13 @@ public function process(SymfonyStyle $io): void $io->writeln(sprintf('%d out of %d processed', $count, $total)); } - $decodedBody = $this->processingService->decodeBody($bounce->getHeader(), $bounce->getData()); - $userId = $this->processingService->findUserId($decodedBody); - $messageId = $this->processingService->findMessageId($decodedBody); + $decodedBody = $this->messageParser->decodeBody($bounce->getHeader(), $bounce->getData()); + $userId = $this->messageParser->findUserId($decodedBody); + $messageId = $this->messageParser->findMessageId($decodedBody); if ($userId || $messageId) { $reparsed++; - if ($this->processingService->processBounceData( + if ($this->bounceDataProcessor->process( $bounce, $messageId, $userId, diff --git a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php index db1298a4..f0629a18 100644 --- a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php +++ b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php @@ -91,13 +91,13 @@ public function processMailbox( continue; } - $msgId = $this->messageParser->findMessageId($body."\r\n".$header); + $messageId = $this->messageParser->findMessageId($body."\r\n".$header); $userId = $this->messageParser->findUserId($body."\r\n".$header); $bounceDate = $this->extractDate($message); $bounce = $this->bounceManager->create($bounceDate, $header, $body); - $processed = $this->bounceDataProcessor->process($bounce, $msgId, $userId, $bounceDate); + $processed = $this->bounceDataProcessor->process($bounce, $messageId, $userId, $bounceDate); if (!$testMode) { if ($processed && $this->purgeProcessed) { @@ -131,9 +131,8 @@ public function processMailbox( } } - private function headerToStringSafe($message): string + private function headerToStringSafe(mixed $message): string { - // Prefer raw header string if available: if (method_exists($message, 'getHeader')) { try { $headerObj = $message->getHeader(); @@ -149,17 +148,17 @@ private function headerToStringSafe($message): string } $lines = []; - $subj = $message->getSubject() ?? ''; - $from = $this->addrFirstToString($message->getFrom()); - $to = $this->addrManyToString($message->getTo()); - $date = $this->extractDate($message)->format(\DATE_RFC2822); + $subj = $message->getSubject() ?? ''; + $from = $this->addrFirstToString($message->getFrom()); + $to = $this->addrManyToString($message->getTo()); + $date = $this->extractDate($message)->format(\DATE_RFC2822); if ($subj !== '') { $lines[] = 'Subject: '.$subj; } if ($from !== '') { $lines[] = 'From: '.$from; } - if ($to !== '') { $lines[] = 'To: '.$to; } - if ($date) { $lines[] = 'Date: '.$date; } + if ($to !== '') { $lines[] = 'To: '.$to; } + $lines[] = 'Date: '.$date; - $mid = (string) ($message->getMessageId() ?? ''); + $mid = $message->getMessageId() ?? ''; if ($mid !== '') { $lines[] = 'Message-ID: '.$mid; } return implode("\r\n", $lines)."\r\n"; @@ -175,22 +174,24 @@ private function bodyBestEffort($message): string if ($html !== '') { return trim(strip_tags($html)); } + return ''; } - private function extractDate($message): DateTimeImmutable + private function extractDate(mixed $message): DateTimeImmutable { $d = $message->getDate(); if ($d instanceof DateTimeInterface) { return DateTimeImmutable::createFromInterface($d); } - // fallback to internal date if exposed; else "now" + if (method_exists($message, 'getInternalDate')) { $ts = (int) $message->getInternalDate(); if ($ts > 0) { return new DateTimeImmutable('@'.$ts); } } + return new DateTimeImmutable(); } @@ -217,6 +218,7 @@ private function addrManyToArray($addresses): array $name = ($addr->personal ?? $addr->getName() ?? ''); $out[] = $name !== '' ? sprintf('%s <%s>', $name, $email) : $email; } + return $out; } diff --git a/src/Domain/Subscription/Service/SubscriberBlacklistService.php b/src/Domain/Subscription/Service/SubscriberBlacklistService.php index b89a7f04..01a18430 100644 --- a/src/Domain/Subscription/Service/SubscriberBlacklistService.php +++ b/src/Domain/Subscription/Service/SubscriberBlacklistService.php @@ -28,7 +28,7 @@ public function __construct( public function blacklist(Subscriber $subscriber, string $reason): void { $subscriber->setBlacklisted(true); - $this->entityManager->flush($subscriber); + $this->entityManager->flush(); $this->blacklistManager->addEmailToBlacklist($subscriber->getEmail(), $reason); foreach (array('REMOTE_ADDR','HTTP_X_FORWARDED_FOR') as $item) { diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php index e6d42236..b3bfda0c 100644 --- a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php +++ b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Tests\Integration\Domain\Subscription\Service; +use DateTime; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Tools\SchemaTool; use Exception; @@ -94,7 +95,7 @@ public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError(): $userMessage->setStatus('sent'); $this->entityManager->persist($userMessage); - $userMessageBounce = new UserMessageBounce(1); + $userMessageBounce = new UserMessageBounce(1, new DateTime()); $userMessageBounce->setUserId($subscriberId); $userMessageBounce->setMessageId(1); $this->entityManager->persist($userMessageBounce); diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php index 9926e916..77bcb3cb 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php @@ -4,9 +4,10 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager; -use DateTime; +use DateTimeImmutable; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Repository\BounceRepository; +use PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -14,17 +15,19 @@ class BounceManagerTest extends TestCase { private BounceRepository&MockObject $repository; + private UserMessageBounceRepository&MockObject $userMessageBounceRepository; private BounceManager $manager; protected function setUp(): void { $this->repository = $this->createMock(BounceRepository::class); - $this->manager = new BounceManager($this->repository); + $this->userMessageBounceRepository = $this->createMock(UserMessageBounceRepository::class); + $this->manager = new BounceManager($this->repository, $this->userMessageBounceRepository); } public function testCreatePersistsAndReturnsBounce(): void { - $date = new DateTime('2020-01-01 00:00:00'); + $date = new DateTimeImmutable('2020-01-01 00:00:00'); $header = 'X-Test: Header'; $data = 'raw bounce'; $status = 'new'; @@ -43,24 +46,13 @@ public function testCreatePersistsAndReturnsBounce(): void ); $this->assertInstanceOf(Bounce::class, $bounce); - $this->assertSame($date, $bounce->getDate()); + $this->assertSame( $date->format('Y-m-d h:m:s'), $bounce->getDate()->format('Y-m-d h:m:s')); $this->assertSame($header, $bounce->getHeader()); $this->assertSame($data, $bounce->getData()); $this->assertSame($status, $bounce->getStatus()); $this->assertSame($comment, $bounce->getComment()); } - public function testSaveDelegatesToRepository(): void - { - $model = new Bounce(); - - $this->repository->expects($this->once()) - ->method('save') - ->with($model); - - $this->manager->save($model); - } - public function testDeleteDelegatesToRepository(): void { $model = new Bounce(); diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php index 1cd432bc..fd526a64 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php @@ -131,7 +131,7 @@ public function testAssociateBounceIncrementsCountAndPersistsRelation(): void ->method('persist') ->with($this->callback(function ($entity) use ($regex) { return $entity instanceof BounceRegexBounce - && $entity->getRegex() === $regex->getId(); + && $entity->getRegexId() === $regex->getId(); })); $this->entityManager->expects($this->once()) diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php index 8df0f4d8..43ae2fcc 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php @@ -4,6 +4,8 @@ namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service\Manager; +use PhpList\Core\Domain\Common\ClientIpResolver; +use PhpList\Core\Domain\Common\SystemInfoCollector; use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberHistoryFilter; use PhpList\Core\Domain\Subscription\Model\SubscriberHistory; use PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository; @@ -20,7 +22,9 @@ protected function setUp(): void { $this->subscriberHistoryRepository = $this->createMock(SubscriberHistoryRepository::class); $this->subscriptionHistoryService = new SubscriberHistoryManager( - repository: $this->subscriberHistoryRepository + repository: $this->subscriberHistoryRepository, + clientIpResolver: $this->createMock(ClientIpResolver::class), + systemInfoCollector: $this->createMock(SystemInfoCollector::class), ); } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php index 9a177312..f4ad3eef 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php @@ -10,6 +10,7 @@ use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; +use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -34,7 +35,8 @@ protected function setUp(): void subscriberRepository: $this->subscriberRepository, entityManager: $this->entityManager, messageBus: $this->messageBus, - subscriberDeletionService: $subscriberDeletionService + subscriberDeletionService: $subscriberDeletionService, + blacklistService: $this->createMock(SubscriberBlacklistService::class), ); } From df15676ad1cac9fb24a1b7a427d84d77fd9a7b75 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 26 Aug 2025 11:35:00 +0400 Subject: [PATCH 17/24] PhpMd --- config/PHPMD/rules.xml | 2 +- config/services/providers.yml | 4 + config/services/services.yml | 12 ++ src/Domain/Common/SystemInfoCollector.php | 28 +-- .../Command/ProcessBouncesCommand.php | 24 +-- .../UserMessageBounceRepository.php | 16 +- .../Service/BounceActionResolver.php | 62 +++++++ .../BounceProcessingServiceInterface.php | 2 +- .../Service/ConsecutiveBounceHandler.php | 126 +++++++++----- .../BlacklistEmailAndDeleteBounceHandler.php | 39 +++++ .../Service/Handler/BlacklistEmailHandler.php | 36 ++++ .../BlacklistUserAndDeleteBounceHandler.php | 39 +++++ .../Service/Handler/BlacklistUserHandler.php | 36 ++++ .../Handler/BounceActionHandlerInterface.php | 11 ++ ...CountConfirmUserAndDeleteBounceHandler.php | 39 +++++ .../Service/Handler/DeleteBounceHandler.php | 24 +++ .../Handler/DeleteUserAndBounceHandler.php | 29 ++++ .../Service/Handler/DeleteUserHandler.php | 32 ++++ .../UnconfirmUserAndDeleteBounceHandler.php | 36 ++++ .../Service/Handler/UnconfirmUserHandler.php | 33 ++++ src/Domain/Messaging/Service/LockService.php | 119 +++++++++---- .../Service/Manager/BounceManager.php | 18 +- .../Service/Manager/BounceRuleManager.php | 4 +- .../Service/NativeBounceProcessingService.php | 92 +++++++--- .../AdvancedBounceRulesProcessor.php | 161 ++++++++---------- .../Service/Processor/BounceDataProcessor.php | 144 ++++++++++------ .../Processor/BounceProtocolProcessor.php | 2 +- .../Service/Processor/MboxBounceProcessor.php | 8 +- .../Service/Processor/PopBounceProcessor.php | 6 +- .../UnidentifiedBounceReprocessor.php | 12 +- .../WebklexBounceProcessingService.php | 41 ++--- .../Service/SubscriberBlacklistService.php | 18 +- 32 files changed, 927 insertions(+), 328 deletions(-) create mode 100644 src/Domain/Messaging/Service/BounceActionResolver.php create mode 100644 src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php create mode 100644 src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/DeleteUserHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php diff --git a/config/PHPMD/rules.xml b/config/PHPMD/rules.xml index 2d88410b..a0fbf650 100644 --- a/config/PHPMD/rules.xml +++ b/config/PHPMD/rules.xml @@ -51,7 +51,7 @@ - + diff --git a/config/services/providers.yml b/config/services/providers.yml index 226c4e81..cb784988 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -2,3 +2,7 @@ services: PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider: autowire: true autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\Provider\BounceActionProvider: + autowire: true + autoconfigure: true diff --git a/config/services/services.yml b/config/services/services.yml index ff21c0fc..1ac73757 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -95,3 +95,15 @@ services: PhpList\Core\Domain\Messaging\Service\MessageParser: autowire: true autoconfigure: true + + _instanceof: + PhpList\Core\Domain\Messaging\Service\Handler\BounceActionHandlerInterface: + tags: + - { name: 'phplist.bounce_action_handler' } + + PhpList\Core\Domain\Messaging\Service\Handler\: + resource: '../../src/Domain/Messaging/Service/Handler/*Handler.php' + + PhpList\Core\Domain\Messaging\Service\BounceActionResolver: + arguments: + - !tagged_iterator { tag: 'phplist.bounce_action_handler', default_index_method: 'supports' } diff --git a/src/Domain/Common/SystemInfoCollector.php b/src/Domain/Common/SystemInfoCollector.php index b6376cfb..dc483510 100644 --- a/src/Domain/Common/SystemInfoCollector.php +++ b/src/Domain/Common/SystemInfoCollector.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Domain\Common; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Request; class SystemInfoCollector { @@ -25,31 +26,20 @@ public function __construct( /** * Return key=>value pairs (already sanitized for safe logging/HTML display). - * + * @SuppressWarnings(PHPMD.StaticAccess) * @return array */ public function collect(): array { - $request = $this->requestStack->getCurrentRequest(); - + $request = $this->requestStack->getCurrentRequest() ?? Request::createFromGlobals(); $data = []; + $headers = $request->headers; - if ($request) { - $headers = $request->headers; - - $data['HTTP_USER_AGENT'] = (string) $headers->get('User-Agent', ''); - $data['HTTP_REFERER'] = (string) $headers->get('Referer', ''); - $data['HTTP_X_FORWARDED_FOR'] = (string) $headers->get('X-Forwarded-For', ''); - $data['REQUEST_URI'] = $request->getRequestUri(); - $data['REMOTE_ADDR'] = $request->getClientIp() ?? ''; - } else { - $server = $_SERVER; - $data['HTTP_USER_AGENT'] = (string) ($server['HTTP_USER_AGENT'] ?? ''); - $data['HTTP_REFERER'] = (string) ($server['HTTP_REFERER'] ?? ''); - $data['HTTP_X_FORWARDED_FOR'] = (string) ($server['HTTP_X_FORWARDED_FOR'] ?? ''); - $data['REQUEST_URI'] = (string) ($server['REQUEST_URI'] ?? ''); - $data['REMOTE_ADDR'] = (string) ($server['REMOTE_ADDR'] ?? ''); - } + $data['HTTP_USER_AGENT'] = (string) $headers->get('User-Agent', ''); + $data['HTTP_REFERER'] = (string) $headers->get('Referer', ''); + $data['HTTP_X_FORWARDED_FOR'] = (string) $headers->get('X-Forwarded-For', ''); + $data['REQUEST_URI'] = $request->getRequestUri(); + $data['REMOTE_ADDR'] = $request->getClientIp() ?? ''; $keys = $this->configuredKeys ?: $this->defaultKeys; diff --git a/src/Domain/Messaging/Command/ProcessBouncesCommand.php b/src/Domain/Messaging/Command/ProcessBouncesCommand.php index 5a065081..348e5f6d 100644 --- a/src/Domain/Messaging/Command/ProcessBouncesCommand.php +++ b/src/Domain/Messaging/Command/ProcessBouncesCommand.php @@ -37,7 +37,7 @@ public function __construct( /** @var iterable */ private readonly iterable $protocolProcessors, private readonly AdvancedBounceRulesProcessor $advancedRulesProcessor, - private readonly UnidentifiedBounceReprocessor $unidentifiedBounceReprocessor, + private readonly UnidentifiedBounceReprocessor $unidentifiedReprocessor, private readonly ConsecutiveBounceHandler $consecutiveBounceHandler, ) { parent::__construct(); @@ -45,23 +45,23 @@ public function __construct( protected function execute(InputInterface $input, OutputInterface $output): int { - $io = new SymfonyStyle($input, $output); + $inputOutput = new SymfonyStyle($input, $output); if (!function_exists('imap_open')) { - $io->note('PHP IMAP extension not available. Falling back to Webklex IMAP where applicable.'); + $inputOutput->note('PHP IMAP extension not available. Falling back to Webklex IMAP where applicable.'); } $force = (bool)$input->getOption('force'); $lock = $this->lockService->acquirePageLock('bounce_processor', $force); if (!$lock) { - $io->warning('Another bounce processing is already running. Aborting.'); + $inputOutput->warning('Another bounce processing is already running. Aborting.'); return Command::SUCCESS; } try { - $io->title('Processing bounces'); + $inputOutput->title('Processing bounces'); $protocol = (string)$input->getOption('protocol'); $downloadReport = ''; @@ -75,23 +75,23 @@ protected function execute(InputInterface $input, OutputInterface $output): int } if ($processor === null) { - $io->error('Unsupported protocol: '.$protocol); + $inputOutput->error('Unsupported protocol: '.$protocol); return Command::FAILURE; } - $downloadReport .= $processor->process($input, $io); - $this->unidentifiedBounceReprocessor->process($io); - $this->advancedRulesProcessor->process($io, (int)$input->getOption('rules-batch-size')); - $this->consecutiveBounceHandler->handle($io); + $downloadReport .= $processor->process($input, $inputOutput); + $this->unidentifiedReprocessor->process($inputOutput); + $this->advancedRulesProcessor->process($inputOutput, (int)$input->getOption('rules-batch-size')); + $this->consecutiveBounceHandler->handle($inputOutput); $this->logger->info('Bounce processing completed', ['downloadReport' => $downloadReport]); - $io->success('Bounce processing completed.'); + $inputOutput->success('Bounce processing completed.'); return Command::SUCCESS; } catch (Exception $e) { $this->logger->error('Bounce processing failed', ['exception' => $e]); - $io->error('Error: '.$e->getMessage()); + $inputOutput->error('Error: '.$e->getMessage()); return Command::FAILURE; } finally { diff --git a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php index 5afe91f0..1b315f5e 100644 --- a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php +++ b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php @@ -28,15 +28,15 @@ public function getCountByMessageId(int $messageId): int public function existsByMessageIdAndUserId(int $messageId, int $subscriberId): bool { - $qb = $this->createQueryBuilder('umb') + return (bool) $this->createQueryBuilder('umb') ->select('1') ->where('umb.messageId = :messageId') ->andWhere('umb.userId = :userId') ->setParameter('messageId', $messageId) ->setParameter('userId', $subscriberId) - ->setMaxResults(1); - - return (bool) $qb->getQuery()->getOneOrNullResult(); + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); } /** @@ -66,7 +66,7 @@ public function getPaginatedWithJoinNoRelation(int $fromId, int $limit): array */ public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array { - $qb = $this->getEntityManager() + return $this->getEntityManager() ->createQueryBuilder() ->select('um', 'umb', 'b') ->from(UserMessage::class, 'um') @@ -86,8 +86,8 @@ public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array ->andWhere('um.status = :status') ->setParameter('userId', $subscriber->getId()) ->setParameter('status', 'sent') - ->orderBy('um.entered', 'DESC'); - - return $qb->getQuery()->getResult(); + ->orderBy('um.entered', 'DESC') + ->getQuery() + ->getResult(); } } diff --git a/src/Domain/Messaging/Service/BounceActionResolver.php b/src/Domain/Messaging/Service/BounceActionResolver.php new file mode 100644 index 00000000..84740630 --- /dev/null +++ b/src/Domain/Messaging/Service/BounceActionResolver.php @@ -0,0 +1,62 @@ + */ + private array $cache = []; + + /** + * @param iterable $handlers + */ + public function __construct(iterable $handlers) + { + foreach ($handlers as $handler) { + $this->handlers[] = $handler; + } + } + + public function has(string $action): bool + { + return isset($this->cache[$action]) || $this->find($action) !== null; + } + + public function resolve(string $action): BounceActionHandlerInterface + { + if (isset($this->cache[$action])) { + return $this->cache[$action]; + } + + $handler = $this->find($action); + if ($handler === null) { + throw new RuntimeException(sprintf('No handler found for action "%s".', $action)); + } + + return $this->cache[$action] = $handler; + } + + /** Convenience: resolve + execute */ + public function handle(string $action, array $context): void + { + $this->resolve($action)->handle($context); + } + + private function find(string $action): ?BounceActionHandlerInterface + { + foreach ($this->handlers as $handler) { + if ($handler->supports($action)) { + return $handler; + } + } + return null; + } +} diff --git a/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php b/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php index fc1a59b0..b478c3a1 100644 --- a/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php +++ b/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php @@ -8,5 +8,5 @@ interface BounceProcessingServiceInterface { - public function processMailbox(SymfonyStyle $io, string $mailbox, int $max, bool $testMode): string; + public function processMailbox(SymfonyStyle $inputOutput, string $mailbox, int $max, bool $testMode): string; } diff --git a/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php b/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php index 7d703c22..93584fd2 100644 --- a/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php +++ b/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php @@ -8,6 +8,7 @@ use PhpList\Core\Domain\Messaging\Model\UserMessage; use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; @@ -41,55 +42,100 @@ public function __construct( public function handle(SymfonyStyle $io): void { $io->section('Identifying consecutive bounces'); + $users = $this->subscriberRepository->distinctUsersWithBouncesConfirmedNotBlacklisted(); $total = count($users); + if ($total === 0) { $io->writeln('Nothing to do'); return; } - $usercnt = 0; + + $processed = 0; foreach ($users as $user) { - $usercnt++; - $history = $this->bounceManager->getUserMessageHistoryWithBounces($user); - $cnt = 0; $removed = false; $unsubscribed = false; - foreach ($history as $bounce) { - /** @var $bounce array{um: UserMessage, umb: UserMessageBounce|null, b: Bounce|null} */ - if ( - stripos($bounce['b']->getStatus() ?? '', 'duplicate') === false - && stripos($bounce['b']->getComment() ?? '', 'duplicate') === false - ) { - if ($bounce['b']->getId()) { - $cnt++; - if ($cnt >= $this->unsubscribeThreshold) { - if (!$unsubscribed) { - $this->subscriberManager->markUnconfirmed($user->getId()); - $this->subscriberHistoryManager->addHistory( - subscriber: $user, - message: 'Auto Unconfirmed', - details: sprintf('Subscriber auto unconfirmed for %d consecutive bounces', $cnt) - ); - $unsubscribed = true; - } - if ($this->blacklistThreshold > 0 && $cnt >= $this->blacklistThreshold) { - $this->subscriberManager->blacklist( - subscriber: $user, - reason: sprintf('%d consecutive bounces, threshold reached', $cnt) - ); - $removed = true; - } - } - } else { - break; - } - } - if ($removed) { - break; - } + $this->processUser($user); + $processed++; + + if ($processed % 5 === 0) { + $io->writeln(\sprintf('processed %d out of %d subscribers', $processed, $total)); + } + } + + $io->writeln(\sprintf('total of %d subscribers processed', $total)); + } + + private function processUser(Subscriber $user): void + { + $history = $this->bounceManager->getUserMessageHistoryWithBounces($user); + if (count($history) === 0) { + return; + } + + $consecutive = 0; + $unsubscribed = false; + + foreach ($history as $row) { + /** @var array{um: UserMessage, umb: UserMessageBounce|null, b: Bounce|null} $row */ + $bounce = $row['b'] ?? null; + + if ($this->isDuplicate($bounce)) { + continue; + } + + if (!$this->hasRealId($bounce)) { + break; + } + + $consecutive++; + + if ($this->applyThresholdActions($user, $consecutive, $unsubscribed)) { + break; } - if ($usercnt % 5 === 0) { - $io->writeln(sprintf('processed %d out of %d subscribers', $usercnt, $total)); + + if (!$unsubscribed && $consecutive >= $this->unsubscribeThreshold) { + $unsubscribed = true; } } - $io->writeln(sprintf('total of %d subscribers processed', $total)); + } + + private function isDuplicate(?Bounce $bounce): bool + { + if ($bounce === null) { + return false; + } + $status = strtolower($bounce->getStatus() ?? ''); + $comment = strtolower($bounce->getComment() ?? ''); + + return str_contains($status, 'duplicate') || str_contains($comment, 'duplicate'); + } + + private function hasRealId(?Bounce $bounce): bool + { + return $bounce !== null && (int) $bounce->getId() > 0; + } + + /** + * Returns true if processing should stop for this user (e.g., blacklisted). + */ + private function applyThresholdActions($user, int $consecutive, bool $alreadyUnsubscribed): bool + { + if ($consecutive >= $this->unsubscribeThreshold && !$alreadyUnsubscribed) { + $this->subscriberManager->markUnconfirmed($user->getId()); + $this->subscriberHistoryManager->addHistory( + subscriber: $user, + message: 'Auto Unconfirmed', + details: sprintf('Subscriber auto unconfirmed for %d consecutive bounces', $consecutive) + ); + } + + if ($this->blacklistThreshold > 0 && $consecutive >= $this->blacklistThreshold) { + $this->subscriberManager->blacklist( + subscriber: $user, + reason: sprintf('%d consecutive bounces, threshold reached', $consecutive) + ); + return true; + } + + return false; } } diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php new file mode 100644 index 00000000..896cffab --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php @@ -0,0 +1,39 @@ +subscriberManager->blacklist( + $closureData['subscriber'], + 'Email address auto blacklisted by bounce rule '.$closureData['ruleId'] + ); + $this->subscriberHistoryManager->addHistory( + $closureData['subscriber'], + 'Auto Unsubscribed', + 'User auto unsubscribed for bounce rule '.$closureData['ruleId'] + ); + } + $this->bounceManager->delete($closureData['bounce']); + } +} diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php new file mode 100644 index 00000000..3fc31d07 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php @@ -0,0 +1,36 @@ +subscriberManager->blacklist( + $closureData['subscriber'], + 'Email address auto blacklisted by bounce rule '.$closureData['ruleId'] + ); + $this->subscriberHistoryManager->addHistory( + $closureData['subscriber'], + 'Auto Unsubscribed', + 'email auto unsubscribed for bounce rule '.$closureData['ruleId'] + ); + } + } +} diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php new file mode 100644 index 00000000..7002aef8 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php @@ -0,0 +1,39 @@ +subscriberManager->blacklist( + $closureData['subscriber'], + 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId'] + ); + $this->subscriberHistoryManager->addHistory( + $closureData['subscriber'], + 'Auto Unsubscribed', + 'User auto unsubscribed for bounce rule '.$closureData['ruleId'] + ); + } + $this->bounceManager->delete($closureData['bounce']); + } +} diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php new file mode 100644 index 00000000..6e849a36 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php @@ -0,0 +1,36 @@ +subscriberManager->blacklist( + $closureData['subscriber'], + 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId'] + ); + $this->subscriberHistoryManager->addHistory( + $closureData['subscriber'], + 'Auto Unsubscribed', + 'User auto unsubscribed for bounce rule '.$closureData['ruleId'] + ); + } + } +} diff --git a/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php b/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php new file mode 100644 index 00000000..6b90cb49 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php @@ -0,0 +1,11 @@ +subscriberManager->decrementBounceCount($closureData['subscriber']); + if (!$closureData['confirmed']) { + $this->subscriberManager->markConfirmed($closureData['userId']); + $this->subscriberHistoryManager->addHistory( + $closureData['subscriber'], + 'Auto confirmed', + 'Subscriber auto confirmed for bounce rule '.$closureData['ruleId'] + ); + } + } + $this->bounceManager->delete($closureData['bounce']); + } +} diff --git a/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php new file mode 100644 index 00000000..d491a888 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php @@ -0,0 +1,24 @@ +bounceManager->delete($closureData['bounce']); + } +} diff --git a/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php b/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php new file mode 100644 index 00000000..a130e46b --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php @@ -0,0 +1,29 @@ +subscriberManager->deleteSubscriber($closureData['subscriber']); + } + $this->bounceManager->delete($closureData['bounce']); + } +} diff --git a/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php b/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php new file mode 100644 index 00000000..4b8b409c --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php @@ -0,0 +1,32 @@ +logger->info('User deleted by bounce rule', [ + 'user' => $closureData['subscriber']->getEmail(), + 'rule' => $closureData['ruleId'], + ]); + $this->subscriberManager->deleteSubscriber($closureData['subscriber']); + } + } +} diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php new file mode 100644 index 00000000..61d825a7 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php @@ -0,0 +1,36 @@ +subscriberManager->markUnconfirmed($closureData['userId']); + $this->subscriberHistoryManager->addHistory( + $closureData['subscriber'], + 'Auto unconfirmed', + 'Subscriber auto unconfirmed for bounce rule '.$closureData['ruleId'] + ); + } + $this->bounceManager->delete($closureData['bounce']); + } +} diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php new file mode 100644 index 00000000..8064c90b --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php @@ -0,0 +1,33 @@ +subscriberManager->markUnconfirmed($closureData['userId']); + $this->subscriberHistoryManager->addHistory( + $closureData['subscriber'], + 'Auto Unconfirmed', + 'Subscriber auto unconfirmed for bounce rule '.$closureData['ruleId'] + ); + } + } +} diff --git a/src/Domain/Messaging/Service/LockService.php b/src/Domain/Messaging/Service/LockService.php index 16740ccb..3cc9e80d 100644 --- a/src/Domain/Messaging/Service/LockService.php +++ b/src/Domain/Messaging/Service/LockService.php @@ -11,19 +11,31 @@ class LockService { + private SendProcessRepository $repo; + private SendProcessManager $manager; + private LoggerInterface $logger; + private int $staleAfterSeconds; + private int $sleepSeconds; + private int $maxWaitCycles; + public function __construct( - private readonly SendProcessRepository $repo, - private readonly SendProcessManager $manager, - private readonly LoggerInterface $logger, - private readonly int $staleAfterSeconds = 600, - private readonly int $sleepSeconds = 20, - private readonly int $maxWaitCycles = 10 - ) {} + SendProcessRepository $repo, + SendProcessManager $manager, + LoggerInterface $logger, + int $staleAfterSeconds = 600, + int $sleepSeconds = 20, + int $maxWaitCycles = 10 + ) { + $this->repo = $repo; + $this->manager = $manager; + $this->logger = $logger; + $this->staleAfterSeconds = $staleAfterSeconds; + $this->sleepSeconds = $sleepSeconds; + $this->maxWaitCycles = $maxWaitCycles; + } /** - * Acquire a per-page lock (phpList getPageLock behavior). - * - * @return int|null inserted row id when acquired; null if we gave up + * @SuppressWarnings("BooleanArgumentFlag") */ public function acquirePageLock( string $page, @@ -34,7 +46,7 @@ public function acquirePageLock( ?string $clientIp = null, ): ?int { $page = $this->sanitizePage($page); - $max = $isCli ? ($multiSend ? max(1, $maxSendProcesses) : 1) : 1; + $max = $this->resolveMax($isCli, $multiSend, $maxSendProcesses); if ($force) { $this->logger->info('Force set, killing other send processes (deleting lock rows).'); @@ -42,33 +54,25 @@ public function acquirePageLock( } $waited = 0; + while (true) { $count = $this->repo->countAliveByPage($page); $running = $this->manager->findNewestAliveWithAge($page); if ($count >= $max) { - $age = (int)($running['age'] ?? 0); - - if ($age > $this->staleAfterSeconds && isset($running['id'])) { - $this->repo->markDeadById((int)$running['id']); + if ($this->tryStealIfStale($running)) { continue; } - $this->logger->info(sprintf( - 'A process for this page is already running and it was still alive %d seconds ago', - $age - )); + $this->logAliveAge($running); if ($isCli) { $this->logger->info("Running commandline, quitting. We'll find out what to do in the next run."); return null; } - $this->logger->info('Sleeping for 20 seconds, aborting will quit'); - sleep($this->sleepSeconds); - - if (++$waited > $this->maxWaitCycles) { + if (!$this->waitOrGiveUp($waited)) { $this->logger->info('We have been waiting too long, I guess the other process is still going ok'); return null; } @@ -76,10 +80,7 @@ public function acquirePageLock( continue; } - $processIdentifier = $isCli - ? (php_uname('n') ?: 'localhost') . ':' . getmypid() - : ($clientIp ?? '0.0.0.0'); - + $processIdentifier = $this->buildProcessIdentifier($isCli, $clientIp); $sendProcess = $this->manager->create($page, $processIdentifier); return $sendProcess->getId(); @@ -103,8 +104,68 @@ public function release(int $processId): void private function sanitizePage(string $page): string { - $u = new UnicodeString($page); - $clean = preg_replace('/\W/', '', (string)$u); + $unicodeString = new UnicodeString($page); + $clean = preg_replace('/\W/', '', (string) $unicodeString); + return $clean === '' ? 'default' : $clean; } + + private function resolveMax(bool $isCli, bool $multiSend, int $maxSendProcesses): int + { + if (!$isCli) { + return 1; + } + return $multiSend ? \max(1, $maxSendProcesses) : 1; + } + + /** + * Returns true if it detected a stale process and killed it (so caller should loop again). + * + * @param array{id?: int, age?: int}|null $running + */ + private function tryStealIfStale(?array $running): bool + { + $age = (int)($running['age'] ?? 0); + if ($age > $this->staleAfterSeconds && isset($running['id'])) { + $this->repo->markDeadById((int)$running['id']); + + return true; + } + + return false; + } + + /** + * @param array{id?: int, age?: int}|null $running + */ + private function logAliveAge(?array $running): void + { + $age = (int)($running['age'] ?? 0); + $this->logger->info( + \sprintf( + 'A process for this page is already running and it was still alive %d seconds ago', + $age + ) + ); + } + + /** + * Sleeps once and increments $waited. Returns false if we exceeded max wait cycles. + */ + private function waitOrGiveUp(int &$waited): bool + { + $this->logger->info(\sprintf('Sleeping for %d seconds, aborting will quit', $this->sleepSeconds)); + \sleep($this->sleepSeconds); + $waited++; + return $waited <= $this->maxWaitCycles; + } + + private function buildProcessIdentifier(bool $isCli, ?string $clientIp): string + { + if ($isCli) { + $host = \php_uname('n') ?: 'localhost'; + return $host . ':' . \getmypid(); + } + return $clientIp ?? '0.0.0.0'; + } } diff --git a/src/Domain/Messaging/Service/Manager/BounceManager.php b/src/Domain/Messaging/Service/Manager/BounceManager.php index aa313861..34108574 100644 --- a/src/Domain/Messaging/Service/Manager/BounceManager.php +++ b/src/Domain/Messaging/Service/Manager/BounceManager.php @@ -16,14 +16,14 @@ class BounceManager { private BounceRepository $bounceRepository; - private UserMessageBounceRepository $userMessageBounceRepository; + private UserMessageBounceRepository $userMessageBounceRepo; public function __construct( BounceRepository $bounceRepository, - UserMessageBounceRepository $userMessageBounceRepository + UserMessageBounceRepository $userMessageBounceRepo ) { $this->bounceRepository = $bounceRepository; - $this->userMessageBounceRepository = $userMessageBounceRepository; + $this->userMessageBounceRepo = $userMessageBounceRepo; } public function create( @@ -34,7 +34,7 @@ public function create( ?string $comment = null ): Bounce { $bounce = new Bounce( - date: DateTime::createFromImmutable($date), + date: new DateTime($date->format('Y-m-d H:i:s')), header: $header, data: $data, status: $status, @@ -79,7 +79,7 @@ public function linkUserMessageBounce( int $subscriberId, ?int $messageId = -1 ): UserMessageBounce { - $userMessageBounce = new UserMessageBounce($bounce->getId(), DateTime::createFromImmutable($date)); + $userMessageBounce = new UserMessageBounce($bounce->getId(), new DateTime($date->format('Y-m-d H:i:s'))); $userMessageBounce->setUserId($subscriberId); $userMessageBounce->setMessageId($messageId); @@ -88,7 +88,7 @@ public function linkUserMessageBounce( public function existsUserMessageBounce(int $subscriberId, int $messageId): bool { - return $this->userMessageBounceRepository->existsByMessageIdAndUserId($messageId, $subscriberId); + return $this->userMessageBounceRepo->existsByMessageIdAndUserId($messageId, $subscriberId); } /** @return Bounce[] */ @@ -99,7 +99,7 @@ public function findByStatus(string $status): array public function getUserMessageBounceCount(): int { - return $this->userMessageBounceRepository->count(); + return $this->userMessageBounceRepo->count(); } /** @@ -107,7 +107,7 @@ public function getUserMessageBounceCount(): int */ public function fetchUserMessageBounceBatch(int $fromId, int $batchSize): array { - return $this->userMessageBounceRepository->getPaginatedWithJoinNoRelation($fromId, $batchSize); + return $this->userMessageBounceRepo->getPaginatedWithJoinNoRelation($fromId, $batchSize); } /** @@ -115,6 +115,6 @@ public function fetchUserMessageBounceBatch(int $fromId, int $batchSize): array */ public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array { - return $this->userMessageBounceRepository->getUserMessageHistoryWithBounces($subscriber); + return $this->userMessageBounceRepo->getUserMessageHistoryWithBounces($subscriber); } } diff --git a/src/Domain/Messaging/Service/Manager/BounceRuleManager.php b/src/Domain/Messaging/Service/Manager/BounceRuleManager.php index bc0a64a3..025659d4 100644 --- a/src/Domain/Messaging/Service/Manager/BounceRuleManager.php +++ b/src/Domain/Messaging/Service/Manager/BounceRuleManager.php @@ -14,7 +14,7 @@ class BounceRuleManager { public function __construct( private readonly BounceRegexRepository $repository, - private readonly BounceRegexBounceRepository $bounceRegexBounceRepository + private readonly BounceRegexBounceRepository $bounceRelationRepository ) { } @@ -93,7 +93,7 @@ public function incrementCount(BounceRegex $rule): void public function linkRuleToBounce(BounceRegex $rule, Bounce $bounce): BounceregexBounce { $relation = new BounceRegexBounce($rule->getId(), $bounce->getId()); - $this->bounceRegexBounceRepository->save($relation); + $this->bounceRelationRepository->save($relation); return $relation; } diff --git a/src/Domain/Messaging/Service/NativeBounceProcessingService.php b/src/Domain/Messaging/Service/NativeBounceProcessingService.php index 47f39370..feca4b91 100644 --- a/src/Domain/Messaging/Service/NativeBounceProcessingService.php +++ b/src/Domain/Messaging/Service/NativeBounceProcessingService.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Domain\Messaging\Service; +use IMAP\Connection; use PhpList\Core\Domain\Common\Mail\NativeImapMailReader; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor; @@ -37,55 +38,90 @@ public function __construct( } public function processMailbox( - SymfonyStyle $io, + SymfonyStyle $inputOutput, string $mailbox, int $max, bool $testMode ): string { + $link = $this->openOrFail($inputOutput, $mailbox, $testMode); + + $num = $this->prepareAndCapCount($inputOutput, $link, $max); + if ($num === 0) { + $this->mailReader->close($link, false); + + return ''; + } + + $this->announceDeletionMode($inputOutput, $testMode); + + for ($messageNumber = 1; $messageNumber <= $num; $messageNumber++) { + $this->handleMessage($link, $messageNumber, $testMode); + } + + $this->finalize($inputOutput, $link, $testMode); + + return ''; + } + + private function openOrFail(SymfonyStyle $io, string $mailbox, bool $testMode): Connection + { try { - $link = $this->mailReader->open($mailbox, $testMode ? 0 : CL_EXPUNGE); + return $this->mailReader->open($mailbox, $testMode ? 0 : CL_EXPUNGE); } catch (Throwable $e) { $io->error('Cannot open mailbox file: '.$e->getMessage()); throw new RuntimeException('Cannot open mbox file'); } + } + private function prepareAndCapCount(SymfonyStyle $inputOutput, Connection $link, int $max): int + { $num = $this->mailReader->numMessages($link); - $io->writeln(sprintf('%d bounces to fetch from the mailbox', $num)); + $inputOutput->writeln(sprintf('%d bounces to fetch from the mailbox', $num)); if ($num === 0) { - $this->mailReader->close($link, false); - - return ''; + return 0; } - $io->writeln('Please do not interrupt this process'); + $inputOutput->writeln('Please do not interrupt this process'); if ($num > $max) { - $io->writeln(sprintf('Processing first %d bounces', $max)); + $inputOutput->writeln(sprintf('Processing first %d bounces', $max)); $num = $max; } - $io->writeln($testMode ? 'Running in test mode, not deleting messages from mailbox' : 'Processed messages will be deleted from the mailbox'); - - for ($x = 1; $x <= $num; $x++) { - $header = $this->mailReader->fetchHeader($link, $x); - $processed = $this->processImapBounce($link, $x, $header); - if ($processed) { - if (!$testMode && $this->purgeProcessed) { - $this->mailReader->delete($link, $x); - } - } else { - if (!$testMode && $this->purgeUnprocessed) { - $this->mailReader->delete($link, $x); - } - } + + return $num; + } + + private function announceDeletionMode(SymfonyStyle $io, bool $testMode): void + { + $io->writeln( + $testMode + ? 'Running in test mode, not deleting messages from mailbox' + : 'Processed messages will be deleted from the mailbox' + ); + } + + private function handleMessage(Connection $link, int $messageNumber, bool $testMode): void + { + $header = $this->mailReader->fetchHeader($link, $messageNumber); + $processed = $this->processImapBounce($link, $messageNumber, $header); + + if ($testMode) { + return; + } + + if ($processed && $this->purgeProcessed) { + $this->mailReader->delete($link, $messageNumber); + return; } - $io->writeln('Closing mailbox, and purging messages'); - if (!$testMode) { - $this->mailReader->close($link, true); - } else { - $this->mailReader->close($link, false); + if (!$processed && $this->purgeUnprocessed) { + $this->mailReader->delete($link, $messageNumber); } + } - return ''; + private function finalize(SymfonyStyle $io, Connection $link, bool $testMode): void + { + $io->writeln('Closing mailbox, and purging messages'); + $this->mailReader->close($link, !$testMode); } private function processImapBounce($link, int $num, string $header): bool diff --git a/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php index 104c4692..b8664fc2 100644 --- a/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php +++ b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php @@ -4,11 +4,12 @@ namespace PhpList\Core\Domain\Messaging\Service\Processor; +use PhpList\Core\Domain\Messaging\Model\Bounce; +use PhpList\Core\Domain\Messaging\Service\BounceActionResolver; +use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; -use Psr\Log\LoggerInterface; use Symfony\Component\Console\Style\SymfonyStyle; class AdvancedBounceRulesProcessor @@ -16,15 +17,15 @@ class AdvancedBounceRulesProcessor public function __construct( private readonly BounceManager $bounceManager, private readonly BounceRuleManager $ruleManager, - private readonly LoggerInterface $logger, + private readonly BounceActionResolver $actionResolver, private readonly SubscriberManager $subscriberManager, - private readonly SubscriberHistoryManager $subscriberHistoryManager, ) { } public function process(SymfonyStyle $io, int $batchSize): void { $io->section('Processing bounces based on active bounce rules'); + $rules = $this->ruleManager->loadActiveRules(); if (!$rules) { $io->writeln('No active rules'); @@ -34,102 +35,86 @@ public function process(SymfonyStyle $io, int $batchSize): void $total = $this->bounceManager->getUserMessageBounceCount(); $fromId = 0; $matched = 0; - $notmatched = 0; - $counter = 0; + $notMatched = 0; + $processed = 0; - while ($counter < $total) { + while ($processed < $total) { $batch = $this->bounceManager->fetchUserMessageBounceBatch($fromId, $batchSize); - $counter += count($batch); - $io->writeln(sprintf('processed %d out of %d bounces for advanced bounce rules', min($counter, $total), $total)); + if (!$batch) { + break; + } + foreach ($batch as $row) { $fromId = $row['umb']->getId(); - // $row has: bounce(header,data,id), umb(user,message,bounce) - $text = $row['bounce']->getHeader()."\n\n".$row['bounce']->getData(); - $rule = $this->ruleManager->matchBounceRules($text, $rules); - $userId = (int)$row['umb']->getUserId(); + $bounce = $row['bounce']; - $userdata = $userId ? $this->subscriberManager->getSubscriberById($userId) : null; - $confirmed = $userdata?->isConfirmed() ?? false; - $blacklisted = $userdata?->isBlacklisted() ?? false; + $userId = (int) $row['umb']->getUserId(); + $text = $this->composeText($bounce); + $rule = $this->ruleManager->matchBounceRules($text, $rules); if ($rule) { - $this->ruleManager->incrementCount($rule); - $rule->setCount($rule->getCount() + 1); - $this->ruleManager->linkRuleToBounce($rule, $bounce); - - switch ($rule->getAction()) { - case 'deleteuser': - if ($userdata) { - $this->logger->info('User deleted by bounce rule', ['user' => $userdata->getEmail(), 'rule' => $rule->getId()]); - $this->subscriberManager->deleteSubscriber($userdata); - } - break; - case 'unconfirmuser': - if ($userdata && $confirmed) { - $this->subscriberManager->markUnconfirmed($userId); - $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unconfirmed', 'Subscriber auto unconfirmed for bounce rule '.$rule->getId()); - } - break; - case 'deleteuserandbounce': - if ($userdata) { - $this->subscriberManager->deleteSubscriber($userdata); - } - $this->bounceManager->delete($bounce); - break; - case 'unconfirmuseranddeletebounce': - if ($userdata && $confirmed) { - $this->subscriberManager->markUnconfirmed($userId); - $this->subscriberHistoryManager->addHistory($userdata, 'Auto unconfirmed', 'Subscriber auto unconfirmed for bounce rule '.$rule->getId()); - } - $this->bounceManager->delete($bounce); - break; - case 'decreasecountconfirmuseranddeletebounce': - if ($userdata) { - $this->subscriberManager->decrementBounceCount($userdata); - if (!$confirmed) { - $this->subscriberManager->markConfirmed($userId); - $this->subscriberHistoryManager->addHistory($userdata, 'Auto confirmed', 'Subscriber auto confirmed for bounce rule '.$rule->getId()); - } - } - $this->bounceManager->delete($bounce); - break; - case 'blacklistuser': - if ($userdata && !$blacklisted) { - $this->subscriberManager->blacklist($userdata, 'Subscriber auto blacklisted by bounce rule '.$rule->getId()); - $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unsubscribed', 'User auto unsubscribed for bounce rule '.$rule->getId()); - } - break; - case 'blacklistuseranddeletebounce': - if ($userdata && !$blacklisted) { - $this->subscriberManager->blacklist($userdata, 'Subscriber auto blacklisted by bounce rule '.$rule->getId()); - $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unsubscribed', 'User auto unsubscribed for bounce rule '.$rule->getId()); - } - $this->bounceManager->delete($bounce); - break; - case 'blacklistemail': - if ($userdata) { - $this->subscriberManager->blacklist($userdata, 'Email address auto blacklisted by bounce rule '.$rule->getId()); - $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unsubscribed', 'email auto unsubscribed for bounce rule '.$rule->getId()); - } - break; - case 'blacklistemailanddeletebounce': - if ($userdata) { - $this->subscriberManager->blacklist($userdata, 'Email address auto blacklisted by bounce rule '.$rule->getId()); - $this->subscriberHistoryManager->addHistory($userdata, 'Auto Unsubscribed', 'User auto unsubscribed for bounce rule '.$rule->getId()); - } - $this->bounceManager->delete($bounce); - break; - case 'deletebounce': - $this->bounceManager->delete($bounce); - break; - } + $this->incrementRuleCounters($rule, $bounce); + + $subscriber = $userId ? $this->subscriberManager->getSubscriberById($userId) : null; + $ctx = $this->makeContext($subscriber, $bounce, (int)$rule->getId()); + + $action = (string) $rule->getAction(); + $this->actionResolver->handle($action, $ctx); + $matched++; } else { - $notmatched++; + $notMatched++; } + + $processed++; } + + $io->writeln(sprintf( + 'processed %d out of %d bounces for advanced bounce rules', + min($processed, $total), + $total + )); } + $io->writeln(sprintf('%d bounces processed by advanced processing', $matched)); - $io->writeln(sprintf('%d bounces were not matched by advanced processing rules', $notmatched)); + $io->writeln(sprintf('%d bounces were not matched by advanced processing rules', $notMatched)); + } + + private function composeText(Bounce $bounce): string + { + return $bounce->getHeader() . "\n\n" . $bounce->getData(); + } + + private function incrementRuleCounters($rule, Bounce $bounce): void + { + $this->ruleManager->incrementCount($rule); + $rule->setCount($rule->getCount() + 1); + $this->ruleManager->linkRuleToBounce($rule, $bounce); + } + + /** + * @return array{ + * subscriber: ?Subscriber, + * bounce: Bounce, + * userId: int, + * confirmed: bool, + * blacklisted: bool, + * ruleId: int + * } + */ + private function makeContext(?Subscriber $subscriber, Bounce $bounce, int $ruleId): array + { + $userId = $subscriber?->getId() ?? 0; + $confirmed = $subscriber?->isConfirmed() ?? false; + $blacklisted = $subscriber?->isBlacklisted() ?? false; + + return [ + 'subscriber' => $subscriber, + 'bounce' => $bounce, + 'userId' => $userId, + 'confirmed' => $confirmed, + 'blacklisted'=> $blacklisted, + 'ruleId' => $ruleId, + ]; } } diff --git a/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php index 2fbcd7ba..fd9d96dc 100644 --- a/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php +++ b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php @@ -29,75 +29,111 @@ public function process(Bounce $bounce, ?string $msgId, ?int $userId, DateTimeIm { $user = $userId ? $this->subscriberManager->getSubscriberById($userId) : null; - if ($msgId === 'systemmessage' && $userId) { - $this->bounceManager->update( - bounce: $bounce, - status: 'bounced system message', - comment: sprintf('%d marked unconfirmed', $userId) - ); - $this->bounceManager->linkUserMessageBounce($bounce, $bounceDate, $userId); - $this->subscriberManager->markUnconfirmed($userId); - $this->logger->info('system message bounced, user marked unconfirmed', ['userId' => $userId]); - if ($user) { - $this->subscriberHistoryManager->addHistory( - subscriber: $user, - message: 'Bounced system message', - details: sprintf('User marked unconfirmed. Bounce #%d', $bounce->getId()) - ); - } - - return true; + if ($msgId === 'systemmessage') { + return $userId + ? $this->handleSystemMessageWithUser($bounce, $bounceDate, $userId, $user) + : $this->handleSystemMessageUnknownUser($bounce); } if ($msgId && $userId) { - if (!$this->bounceManager->existsUserMessageBounce($userId, (int)$msgId)) { - $this->bounceManager->linkUserMessageBounce($bounce, $bounceDate, $userId, (int)$msgId); - $this->bounceManager->update( - bounce: $bounce, - status: sprintf('bounced list message %d', $msgId), - comment: sprintf('%d bouncecount increased', $userId) - ); - $this->messages->incrementBounceCount((int)$msgId); - $this->users->incrementBounceCount($userId); - } else { - $this->bounceManager->linkUserMessageBounce($bounce, $bounceDate, $userId, (int)$msgId); - $this->bounceManager->update( - bounce: $bounce, - status: sprintf('duplicate bounce for %d', $userId), - comment: sprintf('duplicate bounce for subscriber %d on message %d', $userId, $msgId) - ); - } - - return true; + return $this->handleKnownMessageAndUser($bounce, $bounceDate, (int)$msgId, $userId); } if ($userId) { - $this->bounceManager->update( - bounce: $bounce, - status: 'bounced unidentified message', - comment: sprintf('%d bouncecount increased', $userId) - ); - $this->users->incrementBounceCount($userId); + return $this->handleUserOnly($bounce, $userId); + } - return true; + if ($msgId) { + return $this->handleMessageOnly($bounce, (int)$msgId); } - if ($msgId === 'systemmessage') { - $this->bounceManager->update($bounce, 'bounced system message', 'unknown user'); - $this->logger->info('system message bounced, but unknown user'); + $this->bounceManager->update($bounce, 'unidentified bounce', 'not processed'); - return true; + return false; + } + + private function handleSystemMessageWithUser( + Bounce $bounce, + DateTimeImmutable $date, + int $userId, + $userOrNull + ): bool { + $this->bounceManager->update( + bounce: $bounce, + status: 'bounced system message', + comment: sprintf('%d marked unconfirmed', $userId) + ); + $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId); + $this->subscriberManager->markUnconfirmed($userId); + $this->logger->info('system message bounced, user marked unconfirmed', ['userId' => $userId]); + + if ($userOrNull) { + $this->subscriberHistoryManager->addHistory( + subscriber: $userOrNull, + message: 'Bounced system message', + details: sprintf('User marked unconfirmed. Bounce #%d', $bounce->getId()) + ); } - if ($msgId) { - $this->bounceManager->update($bounce, sprintf('bounced list message %d', $msgId), 'unknown user'); - $this->messages->incrementBounceCount((int)$msgId); + return true; + } - return true; + private function handleSystemMessageUnknownUser(Bounce $bounce): bool + { + $this->bounceManager->update($bounce, 'bounced system message', 'unknown user'); + $this->logger->info('system message bounced, but unknown user'); + + return true; + } + + private function handleKnownMessageAndUser( + Bounce $bounce, + DateTimeImmutable $date, + int $msgId, + int $userId + ): bool { + if (!$this->bounceManager->existsUserMessageBounce($userId, $msgId)) { + $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId, $msgId); + $this->bounceManager->update( + bounce: $bounce, + status: sprintf('bounced list message %d', $msgId), + comment: sprintf('%d bouncecount increased', $userId) + ); + $this->messages->incrementBounceCount($msgId); + $this->users->incrementBounceCount($userId); + } else { + $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId, $msgId); + $this->bounceManager->update( + bounce: $bounce, + status: sprintf('duplicate bounce for %d', $userId), + comment: sprintf('duplicate bounce for subscriber %d on message %d', $userId, $msgId) + ); } - $this->bounceManager->update($bounce, 'unidentified bounce', 'not processed'); + return true; + } - return false; + private function handleUserOnly(Bounce $bounce, int $userId): bool + { + $this->bounceManager->update( + bounce: $bounce, + status: 'bounced unidentified message', + comment: sprintf('%d bouncecount increased', $userId) + ); + $this->users->incrementBounceCount($userId); + + return true; + } + + private function handleMessageOnly(Bounce $bounce, int $msgId): bool + { + $this->bounceManager->update( + $bounce, + sprintf('bounced list message %d', $msgId), + 'unknown user' + ); + $this->messages->incrementBounceCount($msgId); + + return true; } } diff --git a/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php b/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php index b888ba08..a0e7d904 100644 --- a/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php +++ b/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php @@ -15,7 +15,7 @@ interface BounceProtocolProcessor * * @return string A textual report (reserved for future use) */ - public function process(InputInterface $input, SymfonyStyle $io): string; + public function process(InputInterface $input, SymfonyStyle $inputOutput): string; /** * Returns a protocol name handled by this processor (e.g. "pop", "mbox"). diff --git a/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php b/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php index fc57f114..0a4c9f4b 100644 --- a/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php +++ b/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php @@ -23,21 +23,21 @@ public function getProtocol(): string return 'mbox'; } - public function process(InputInterface $input, SymfonyStyle $io): string + public function process(InputInterface $input, SymfonyStyle $inputOutput): string { $testMode = (bool)$input->getOption('test'); $max = (int)$input->getOption('maximum'); $file = (string)$input->getOption('mailbox'); if (!$file) { - $io->error('mbox file path must be provided with --mailbox.'); + $inputOutput->error('mbox file path must be provided with --mailbox.'); throw new RuntimeException('Missing --mailbox for mbox protocol'); } - $io->section("Opening mbox $file"); + $inputOutput->section("Opening mbox $file"); return $this->processingService->processMailbox( - $io, + $inputOutput, $file, $max, $testMode diff --git a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php index 175738a4..45388e64 100644 --- a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php +++ b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php @@ -32,7 +32,7 @@ public function getProtocol(): string return 'pop'; } - public function process(InputInterface $input, SymfonyStyle $io): string + public function process(InputInterface $input, SymfonyStyle $inputOutput): string { $testMode = (bool)$input->getOption('test'); $max = (int)$input->getOption('maximum'); @@ -42,10 +42,10 @@ public function process(InputInterface $input, SymfonyStyle $io): string $mailboxName = trim($mailboxName); if ($mailboxName === '') { $mailboxName = 'INBOX'; } $mailbox = sprintf('{%s:%s}%s', $this->host, $this->port, $mailboxName); - $io->section("Connecting to $mailbox"); + $inputOutput->section("Connecting to $mailbox"); $downloadReport .= $this->processingService->processMailbox( - $io, + $inputOutput, $mailbox, $max, $testMode diff --git a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php index 83073baa..3276d675 100644 --- a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php +++ b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php @@ -26,12 +26,12 @@ public function __construct( $this->bounceDataProcessor = $bounceDataProcessor; } - public function process(SymfonyStyle $io): void + public function process(SymfonyStyle $inputOutput): void { - $io->section('Reprocessing unidentified bounces'); + $inputOutput->section('Reprocessing unidentified bounces'); $bounces = $this->bounceManager->findByStatus('unidentified bounce'); $total = count($bounces); - $io->writeln(sprintf('%d bounces to reprocess', $total)); + $inputOutput->writeln(sprintf('%d bounces to reprocess', $total)); $count = 0; $reparsed = 0; @@ -39,7 +39,7 @@ public function process(SymfonyStyle $io): void foreach ($bounces as $bounce) { $count++; if ($count % 25 === 0) { - $io->writeln(sprintf('%d out of %d processed', $count, $total)); + $inputOutput->writeln(sprintf('%d out of %d processed', $count, $total)); } $decodedBody = $this->messageParser->decodeBody($bounce->getHeader(), $bounce->getData()); @@ -59,8 +59,8 @@ public function process(SymfonyStyle $io): void } } - $io->writeln(sprintf('%d out of %d processed', $count, $total)); - $io->writeln(sprintf( + $inputOutput->writeln(sprintf('%d out of %d processed', $count, $total)); + $inputOutput->writeln(sprintf( '%d bounces were re-processed and %d bounces were re-identified', $reparsed, $reidentified )); diff --git a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php index f0629a18..9998878f 100644 --- a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php +++ b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php @@ -12,6 +12,7 @@ use RuntimeException; use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; +use const DATE_RFC2822; class WebklexBounceProcessingService implements BounceProcessingServiceInterface { @@ -47,7 +48,7 @@ public function __construct( * $mailbox: IMAP host; if you pass "host#FOLDER", FOLDER will be used instead of INBOX. */ public function processMailbox( - SymfonyStyle $io, + SymfonyStyle $inputOutput, string $mailbox, int $max, bool $testMode @@ -57,7 +58,7 @@ public function processMailbox( try { $client->connect(); } catch (Throwable $e) { - $io->error('Cannot connect to mailbox: '.$e->getMessage()); + $inputOutput->error('Cannot connect to mailbox: '.$e->getMessage()); throw new RuntimeException('Cannot connect to IMAP server'); } @@ -68,13 +69,13 @@ public function processMailbox( $messages = $query->get(); $num = $messages->count(); - $io->writeln(sprintf('%d bounces to fetch from the mailbox', $num)); + $inputOutput->writeln(sprintf('%d bounces to fetch from the mailbox', $num)); if ($num === 0) { return ''; } - $io->writeln('Please do not interrupt this process'); - $io->writeln($testMode + $inputOutput->writeln('Please do not interrupt this process'); + $inputOutput->writeln($testMode ? 'Running in test mode, not deleting messages from mailbox' : 'Processed messages will be deleted from the mailbox' ); @@ -108,7 +109,7 @@ public function processMailbox( } } - $io->writeln('Closing mailbox, and purging messages'); + $inputOutput->writeln('Closing mailbox, and purging messages'); if (!$testMode) { try { if (method_exists($folder, 'expunge')) { @@ -150,18 +151,18 @@ private function headerToStringSafe(mixed $message): string $lines = []; $subj = $message->getSubject() ?? ''; $from = $this->addrFirstToString($message->getFrom()); - $to = $this->addrManyToString($message->getTo()); - $date = $this->extractDate($message)->format(\DATE_RFC2822); + $messageTo = $this->addrManyToString($message->getTo()); + $date = $this->extractDate($message)->format(DATE_RFC2822); - if ($subj !== '') { $lines[] = 'Subject: '.$subj; } - if ($from !== '') { $lines[] = 'From: '.$from; } - if ($to !== '') { $lines[] = 'To: '.$to; } - $lines[] = 'Date: '.$date; + if ($subj !== '') { $lines[] = 'Subject: ' . $subj; } + if ($from !== '') { $lines[] = 'From: ' . $from; } + if ($messageTo !== '') { $lines[] = 'To: ' . $messageTo; } + $lines[] = 'Date: ' . $date; $mid = $message->getMessageId() ?? ''; - if ($mid !== '') { $lines[] = 'Message-ID: '.$mid; } + if ($mid !== '') { $lines[] = 'Message-ID: ' . $mid; } - return implode("\r\n", $lines)."\r\n"; + return implode("\r\n", $lines) . "\r\n"; } private function bodyBestEffort($message): string @@ -180,15 +181,15 @@ private function bodyBestEffort($message): string private function extractDate(mixed $message): DateTimeImmutable { - $d = $message->getDate(); - if ($d instanceof DateTimeInterface) { - return DateTimeImmutable::createFromInterface($d); + $date = $message->getDate(); + if ($date instanceof DateTimeInterface) { + return new DateTimeImmutable($date->format('Y-m-d H:i:s')); } if (method_exists($message, 'getInternalDate')) { - $ts = (int) $message->getInternalDate(); - if ($ts > 0) { - return new DateTimeImmutable('@'.$ts); + $internalDate = (int) $message->getInternalDate(); + if ($internalDate > 0) { + return new DateTimeImmutable('@'.$internalDate); } } diff --git a/src/Domain/Subscription/Service/SubscriberBlacklistService.php b/src/Domain/Subscription/Service/SubscriberBlacklistService.php index 01a18430..b02806f2 100644 --- a/src/Domain/Subscription/Service/SubscriberBlacklistService.php +++ b/src/Domain/Subscription/Service/SubscriberBlacklistService.php @@ -8,21 +8,25 @@ use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; +use Symfony\Component\HttpFoundation\RequestStack; class SubscriberBlacklistService { private EntityManagerInterface $entityManager; private SubscriberBlacklistManager $blacklistManager; private SubscriberHistoryManager $historyManager; + private RequestStack $requestStack; public function __construct( EntityManagerInterface $entityManager, SubscriberBlacklistManager $blacklistManager, SubscriberHistoryManager $historyManager, + RequestStack $requestStack, ) { $this->entityManager = $entityManager; $this->blacklistManager = $blacklistManager; $this->historyManager = $historyManager; + $this->requestStack = $requestStack; } public function blacklist(Subscriber $subscriber, string $reason): void @@ -32,8 +36,16 @@ public function blacklist(Subscriber $subscriber, string $reason): void $this->blacklistManager->addEmailToBlacklist($subscriber->getEmail(), $reason); foreach (array('REMOTE_ADDR','HTTP_X_FORWARDED_FOR') as $item) { - if (isset($_SERVER[$item])) { - $this->blacklistManager->addBlacklistData($subscriber->getEmail(), $item, $_SERVER[$item]); + $request = $this->requestStack->getCurrentRequest(); + if (!$request) { + return; + } + if ($request->server->get($item)) { + $this->blacklistManager->addBlacklistData( + email: $subscriber->getEmail(), + name: $item, + data: $request->server->get($item) + ); } } @@ -44,7 +56,7 @@ public function blacklist(Subscriber $subscriber, string $reason): void ); if (isset($GLOBALS['plugins']) && is_array($GLOBALS['plugins'])) { - foreach ($GLOBALS['plugins'] as $pluginName => $plugin) { + foreach ($GLOBALS['plugins'] as $plugin) { if (method_exists($plugin, 'blacklistEmail')) { $plugin->blacklistEmail($subscriber->getEmail(), $reason); } From 85c1709182f834d8f8f3603bcd446c06163fe440 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 27 Aug 2025 10:46:21 +0400 Subject: [PATCH 18/24] PhpMd CyclomaticComplexity --- .../BounceProcessingServiceInterface.php | 4 +- .../Service/ConsecutiveBounceHandler.php | 2 +- ...CountConfirmUserAndDeleteBounceHandler.php | 28 +++-- .../UnconfirmUserAndDeleteBounceHandler.php | 26 +++-- .../Service/Handler/UnconfirmUserHandler.php | 16 ++- .../Messaging/Service/MessageParser.php | 17 +-- .../Service/NativeBounceProcessingService.php | 34 +++--- .../Service/Processor/BounceDataProcessor.php | 41 ++++--- .../Service/Processor/MboxBounceProcessor.php | 8 +- .../Service/Processor/PopBounceProcessor.php | 8 +- .../WebklexBounceProcessingService.php | 101 +++++++++++------- .../Repository/SubscriberRepository.php | 24 +++++ .../Service/Manager/SubscriberManager.php | 40 ------- .../Service/SubscriberBlacklistService.php | 3 + 14 files changed, 202 insertions(+), 150 deletions(-) diff --git a/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php b/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php index b478c3a1..9d16702f 100644 --- a/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php +++ b/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php @@ -4,9 +4,7 @@ namespace PhpList\Core\Domain\Messaging\Service; -use Symfony\Component\Console\Style\SymfonyStyle; - interface BounceProcessingServiceInterface { - public function processMailbox(SymfonyStyle $inputOutput, string $mailbox, int $max, bool $testMode): string; + public function processMailbox(string $mailbox, int $max, bool $testMode): string; } diff --git a/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php b/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php index 93584fd2..d4acc2fd 100644 --- a/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php +++ b/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php @@ -120,7 +120,7 @@ private function hasRealId(?Bounce $bounce): bool private function applyThresholdActions($user, int $consecutive, bool $alreadyUnsubscribed): bool { if ($consecutive >= $this->unsubscribeThreshold && !$alreadyUnsubscribed) { - $this->subscriberManager->markUnconfirmed($user->getId()); + $this->subscriberRepository->markUnconfirmed($user->getId()); $this->subscriberHistoryManager->addHistory( subscriber: $user, message: 'Auto Unconfirmed', diff --git a/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php index 23ab45f9..a8ecdfb5 100644 --- a/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php @@ -5,16 +5,28 @@ namespace PhpList\Core\Domain\Messaging\Service\Handler; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; class DecreaseCountConfirmUserAndDeleteBounceHandler implements BounceActionHandlerInterface { + private SubscriberHistoryManager $subscriberHistoryManager; + private SubscriberManager $subscriberManager; + private BounceManager $bounceManager; + private SubscriberRepository $subscriberRepository; + public function __construct( - private readonly SubscriberHistoryManager $subscriberHistoryManager, - private readonly SubscriberManager $subscriberManager, - private readonly BounceManager $bounceManager, - ) {} + SubscriberHistoryManager $subscriberHistoryManager, + SubscriberManager $subscriberManager, + BounceManager $bounceManager, + SubscriberRepository $subscriberRepository, + ) { + $this->subscriberHistoryManager = $subscriberHistoryManager; + $this->subscriberManager = $subscriberManager; + $this->bounceManager = $bounceManager; + $this->subscriberRepository = $subscriberRepository; + } public function supports(string $action): bool { @@ -26,11 +38,11 @@ public function handle(array $closureData): void if (!empty($closureData['subscriber'])) { $this->subscriberManager->decrementBounceCount($closureData['subscriber']); if (!$closureData['confirmed']) { - $this->subscriberManager->markConfirmed($closureData['userId']); + $this->subscriberRepository->markConfirmed($closureData['userId']); $this->subscriberHistoryManager->addHistory( - $closureData['subscriber'], - 'Auto confirmed', - 'Subscriber auto confirmed for bounce rule '.$closureData['ruleId'] + subscriber: $closureData['subscriber'], + message: 'Auto confirmed', + details: 'Subscriber auto confirmed for bounce rule '.$closureData['ruleId'] ); } } diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php index 61d825a7..7ca39be8 100644 --- a/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php @@ -5,16 +5,24 @@ namespace PhpList\Core\Domain\Messaging\Service\Handler; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; class UnconfirmUserAndDeleteBounceHandler implements BounceActionHandlerInterface { + private SubscriberHistoryManager $subscriberHistoryManager; + private SubscriberRepository $subscriberRepository; + private BounceManager $bounceManager; + public function __construct( - private readonly SubscriberHistoryManager $subscriberHistoryManager, - private readonly SubscriberManager $subscriberManager, - private readonly BounceManager $bounceManager, - ) {} + SubscriberHistoryManager $subscriberHistoryManager, + SubscriberRepository $subscriberRepository, + BounceManager $bounceManager, + ) { + $this->subscriberHistoryManager = $subscriberHistoryManager; + $this->subscriberRepository = $subscriberRepository; + $this->bounceManager = $bounceManager; + } public function supports(string $action): bool { @@ -24,11 +32,11 @@ public function supports(string $action): bool public function handle(array $closureData): void { if (!empty($closureData['subscriber']) && $closureData['confirmed']) { - $this->subscriberManager->markUnconfirmed($closureData['userId']); + $this->subscriberRepository->markUnconfirmed($closureData['userId']); $this->subscriberHistoryManager->addHistory( - $closureData['subscriber'], - 'Auto unconfirmed', - 'Subscriber auto unconfirmed for bounce rule '.$closureData['ruleId'] + subscriber: $closureData['subscriber'], + message: 'Auto unconfirmed', + details: 'Subscriber auto unconfirmed for bounce rule '.$closureData['ruleId'] ); } $this->bounceManager->delete($closureData['bounce']); diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php index 8064c90b..a5bdd0fe 100644 --- a/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php +++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php @@ -4,15 +4,21 @@ namespace PhpList\Core\Domain\Messaging\Service\Handler; +use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; class UnconfirmUserHandler implements BounceActionHandlerInterface { + private SubscriberRepository $subscriberRepository; + private SubscriberHistoryManager $subscriberHistoryManager; + public function __construct( - private readonly SubscriberManager $subscriberManager, - private readonly SubscriberHistoryManager $subscriberHistoryManager, - ) {} + SubscriberRepository $subscriberRepository, + SubscriberHistoryManager $subscriberHistoryManager, + ) { + $this->subscriberRepository = $subscriberRepository; + $this->subscriberHistoryManager = $subscriberHistoryManager; + } public function supports(string $action): bool { @@ -22,7 +28,7 @@ public function supports(string $action): bool public function handle(array $closureData): void { if (!empty($closureData['subscriber']) && $closureData['confirmed']) { - $this->subscriberManager->markUnconfirmed($closureData['userId']); + $this->subscriberRepository->markUnconfirmed($closureData['userId']); $this->subscriberHistoryManager->addHistory( $closureData['subscriber'], 'Auto Unconfirmed', diff --git a/src/Domain/Messaging/Service/MessageParser.php b/src/Domain/Messaging/Service/MessageParser.php index 30ebcaa0..dbbf0ac9 100644 --- a/src/Domain/Messaging/Service/MessageParser.php +++ b/src/Domain/Messaging/Service/MessageParser.php @@ -4,14 +4,17 @@ namespace PhpList\Core\Domain\Messaging\Service; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; +use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; class MessageParser { - public function __construct( - private readonly SubscriberManager $subscriberManager, - ) { + private SubscriberRepository $subscriberRepository; + + public function __construct(SubscriberRepository $subscriberRepository) + { + $this->subscriberRepository = $subscriberRepository; } + public function decodeBody(string $header, string $body): string { $transferEncoding = ''; @@ -41,17 +44,17 @@ public function findUserId(string $text): ?int if (preg_match('/(?:X-ListMember|X-User): (.*)\r\n/iU', $text, $match)) { $user = trim($match[1]); if (str_contains($user, '@')) { - return $this->subscriberManager->getSubscriberByEmail($user)?->getId(); + return $this->subscriberRepository->findOneByEmail($user)?->getId(); } elseif (preg_match('/^\d+$/', $user)) { return (int)$user; } elseif ($user !== '') { - return $this->subscriberManager->getSubscriberByEmail($user)?->getId(); + return $this->subscriberRepository->findOneByEmail($user)?->getId(); } } // Fallback: parse any email in the body and see if it is a subscriber if (preg_match_all('/[._a-zA-Z0-9-]+@[.a-zA-Z0-9-]+/', $text, $regs)) { foreach ($regs[0] as $email) { - $id = $this->subscriberManager->getSubscriberByEmail($email)?->getId(); + $id = $this->subscriberRepository->findOneByEmail($email)?->getId(); if ($id) { return $id; } diff --git a/src/Domain/Messaging/Service/NativeBounceProcessingService.php b/src/Domain/Messaging/Service/NativeBounceProcessingService.php index feca4b91..a431d063 100644 --- a/src/Domain/Messaging/Service/NativeBounceProcessingService.php +++ b/src/Domain/Messaging/Service/NativeBounceProcessingService.php @@ -8,8 +8,8 @@ use PhpList\Core\Domain\Common\Mail\NativeImapMailReader; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor; +use Psr\Log\LoggerInterface; use RuntimeException; -use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; class NativeBounceProcessingService implements BounceProcessingServiceInterface @@ -18,6 +18,7 @@ class NativeBounceProcessingService implements BounceProcessingServiceInterface private NativeImapMailReader $mailReader; private MessageParser $messageParser; private BounceDataProcessor $bounceDataProcessor; + private LoggerInterface $logger; private bool $purgeProcessed; private bool $purgeUnprocessed; @@ -26,6 +27,7 @@ public function __construct( NativeImapMailReader $mailReader, MessageParser $messageParser, BounceDataProcessor $bounceDataProcessor, + LoggerInterface $logger, bool $purgeProcessed, bool $purgeUnprocessed ) { @@ -33,66 +35,66 @@ public function __construct( $this->mailReader = $mailReader; $this->messageParser = $messageParser; $this->bounceDataProcessor = $bounceDataProcessor; + $this->logger = $logger; $this->purgeProcessed = $purgeProcessed; $this->purgeUnprocessed = $purgeUnprocessed; } public function processMailbox( - SymfonyStyle $inputOutput, string $mailbox, int $max, bool $testMode ): string { - $link = $this->openOrFail($inputOutput, $mailbox, $testMode); + $link = $this->openOrFail($mailbox, $testMode); - $num = $this->prepareAndCapCount($inputOutput, $link, $max); + $num = $this->prepareAndCapCount( $link, $max); if ($num === 0) { $this->mailReader->close($link, false); return ''; } - $this->announceDeletionMode($inputOutput, $testMode); + $this->announceDeletionMode($testMode); for ($messageNumber = 1; $messageNumber <= $num; $messageNumber++) { $this->handleMessage($link, $messageNumber, $testMode); } - $this->finalize($inputOutput, $link, $testMode); + $this->finalize($link, $testMode); return ''; } - private function openOrFail(SymfonyStyle $io, string $mailbox, bool $testMode): Connection + private function openOrFail(string $mailbox, bool $testMode): Connection { try { return $this->mailReader->open($mailbox, $testMode ? 0 : CL_EXPUNGE); } catch (Throwable $e) { - $io->error('Cannot open mailbox file: '.$e->getMessage()); + $this->logger->error('Cannot open mailbox file: '.$e->getMessage()); throw new RuntimeException('Cannot open mbox file'); } } - private function prepareAndCapCount(SymfonyStyle $inputOutput, Connection $link, int $max): int + private function prepareAndCapCount(Connection $link, int $max): int { $num = $this->mailReader->numMessages($link); - $inputOutput->writeln(sprintf('%d bounces to fetch from the mailbox', $num)); + $this->logger->info(sprintf('%d bounces to fetch from the mailbox', $num)); if ($num === 0) { return 0; } - $inputOutput->writeln('Please do not interrupt this process'); + $this->logger->info('Please do not interrupt this process'); if ($num > $max) { - $inputOutput->writeln(sprintf('Processing first %d bounces', $max)); + $this->logger->info(sprintf('Processing first %d bounces', $max)); $num = $max; } return $num; } - private function announceDeletionMode(SymfonyStyle $io, bool $testMode): void + private function announceDeletionMode(bool $testMode): void { - $io->writeln( + $this->logger->info( $testMode ? 'Running in test mode, not deleting messages from mailbox' : 'Processed messages will be deleted from the mailbox' @@ -118,9 +120,9 @@ private function handleMessage(Connection $link, int $messageNumber, bool $testM } } - private function finalize(SymfonyStyle $io, Connection $link, bool $testMode): void + private function finalize(Connection $link, bool $testMode): void { - $io->writeln('Closing mailbox, and purging messages'); + $this->logger->info('Closing mailbox, and purging messages'); $this->mailReader->close($link, !$testMode); } diff --git a/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php index fd9d96dc..10f7de84 100644 --- a/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php +++ b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php @@ -15,14 +15,27 @@ class BounceDataProcessor { + private readonly BounceManager $bounceManager; + private readonly SubscriberRepository $subscriberRepository; + private readonly MessageRepository $messageRepository; + private readonly LoggerInterface $logger; + private readonly SubscriberManager $subscriberManager; + private readonly SubscriberHistoryManager $subscriberHistoryManager; + public function __construct( - private readonly BounceManager $bounceManager, - private readonly SubscriberRepository $users, - private readonly MessageRepository $messages, - private readonly LoggerInterface $logger, - private readonly SubscriberManager $subscriberManager, - private readonly SubscriberHistoryManager $subscriberHistoryManager, + BounceManager $bounceManager, + SubscriberRepository $subscriberRepository, + MessageRepository $messageRepository, + LoggerInterface $logger, + SubscriberManager $subscriberManager, + SubscriberHistoryManager $subscriberHistoryManager, ) { + $this->bounceManager = $bounceManager; + $this->subscriberRepository = $subscriberRepository; + $this->messageRepository = $messageRepository; + $this->logger = $logger; + $this->subscriberManager = $subscriberManager; + $this->subscriberHistoryManager = $subscriberHistoryManager; } public function process(Bounce $bounce, ?string $msgId, ?int $userId, DateTimeImmutable $bounceDate): bool @@ -64,7 +77,7 @@ private function handleSystemMessageWithUser( comment: sprintf('%d marked unconfirmed', $userId) ); $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId); - $this->subscriberManager->markUnconfirmed($userId); + $this->subscriberRepository->markUnconfirmed($userId); $this->logger->info('system message bounced, user marked unconfirmed', ['userId' => $userId]); if ($userOrNull) { @@ -99,8 +112,8 @@ private function handleKnownMessageAndUser( status: sprintf('bounced list message %d', $msgId), comment: sprintf('%d bouncecount increased', $userId) ); - $this->messages->incrementBounceCount($msgId); - $this->users->incrementBounceCount($userId); + $this->messageRepository->incrementBounceCount($msgId); + $this->subscriberRepository->incrementBounceCount($userId); } else { $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId, $msgId); $this->bounceManager->update( @@ -120,7 +133,7 @@ private function handleUserOnly(Bounce $bounce, int $userId): bool status: 'bounced unidentified message', comment: sprintf('%d bouncecount increased', $userId) ); - $this->users->incrementBounceCount($userId); + $this->subscriberRepository->incrementBounceCount($userId); return true; } @@ -128,11 +141,11 @@ private function handleUserOnly(Bounce $bounce, int $userId): bool private function handleMessageOnly(Bounce $bounce, int $msgId): bool { $this->bounceManager->update( - $bounce, - sprintf('bounced list message %d', $msgId), - 'unknown user' + bounce: $bounce, + status: sprintf('bounced list message %d', $msgId), + comment: 'unknown user' ); - $this->messages->incrementBounceCount($msgId); + $this->messageRepository->incrementBounceCount($msgId); return true; } diff --git a/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php b/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php index 0a4c9f4b..6a862ea3 100644 --- a/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php +++ b/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php @@ -35,12 +35,12 @@ public function process(InputInterface $input, SymfonyStyle $inputOutput): strin } $inputOutput->section("Opening mbox $file"); + $inputOutput->writeln('Please do not interrupt this process'); return $this->processingService->processMailbox( - $inputOutput, - $file, - $max, - $testMode + mailbox: $file, + max: $max, + testMode: $testMode ); } } diff --git a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php index 45388e64..57031ccc 100644 --- a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php +++ b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php @@ -43,12 +43,12 @@ public function process(InputInterface $input, SymfonyStyle $inputOutput): strin if ($mailboxName === '') { $mailboxName = 'INBOX'; } $mailbox = sprintf('{%s:%s}%s', $this->host, $this->port, $mailboxName); $inputOutput->section("Connecting to $mailbox"); + $inputOutput->writeln('Please do not interrupt this process'); $downloadReport .= $this->processingService->processMailbox( - $inputOutput, - $mailbox, - $max, - $testMode + mailbox: $mailbox, + max: $max, + testMode: $testMode ); } diff --git a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php index 9998878f..0f589851 100644 --- a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php +++ b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php @@ -10,10 +10,13 @@ use PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor; use Psr\Log\LoggerInterface; use RuntimeException; -use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; -use const DATE_RFC2822; +use Webklex\PHPIMAP\Client; +use Webklex\PHPIMAP\Folder; +/** + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) + */ class WebklexBounceProcessingService implements BounceProcessingServiceInterface { private BounceManager $bounceManager; @@ -48,7 +51,6 @@ public function __construct( * $mailbox: IMAP host; if you pass "host#FOLDER", FOLDER will be used instead of INBOX. */ public function processMailbox( - SymfonyStyle $inputOutput, string $mailbox, int $max, bool $testMode @@ -58,7 +60,7 @@ public function processMailbox( try { $client->connect(); } catch (Throwable $e) { - $inputOutput->error('Cannot connect to mailbox: '.$e->getMessage()); + $this->logger->error('Cannot connect to mailbox: '.$e->getMessage()); throw new RuntimeException('Cannot connect to IMAP server'); } @@ -69,13 +71,12 @@ public function processMailbox( $messages = $query->get(); $num = $messages->count(); - $inputOutput->writeln(sprintf('%d bounces to fetch from the mailbox', $num)); + $this->logger->info(sprintf('%d bounces to fetch from the mailbox', $num)); if ($num === 0) { return ''; } - $inputOutput->writeln('Please do not interrupt this process'); - $inputOutput->writeln($testMode + $this->logger->info($testMode ? 'Running in test mode, not deleting messages from mailbox' : 'Processed messages will be deleted from the mailbox' ); @@ -100,27 +101,11 @@ public function processMailbox( $processed = $this->bounceDataProcessor->process($bounce, $messageId, $userId, $bounceDate); - if (!$testMode) { - if ($processed && $this->purgeProcessed) { - $this->safeDelete($message); - } elseif (!$processed && $this->purgeUnprocessed) { - $this->safeDelete($message); - } - } + $this->processDelete($testMode, $processed, $message); } - $inputOutput->writeln('Closing mailbox, and purging messages'); - if (!$testMode) { - try { - if (method_exists($folder, 'expunge')) { - $folder->expunge(); - } elseif (method_exists($client, 'expunge')) { - $client->expunge(); - } - } catch (Throwable $e) { - $this->logger->warning('EXPUNGE failed', ['error' => $e->getMessage()]); - } - } + $this->logger->info('Closing mailbox, and purging messages'); + $this->processExpunge($testMode, $folder, $client); return ''; } finally { @@ -134,25 +119,16 @@ public function processMailbox( private function headerToStringSafe(mixed $message): string { - if (method_exists($message, 'getHeader')) { - try { - $headerObj = $message->getHeader(); - if ($headerObj && method_exists($headerObj, 'toString')) { - $raw = (string) $headerObj->toString(); - if ($raw !== '') { - return $raw; - } - } - } catch (Throwable $e) { - // fall back below - } + $raw = $this->tryRawHeader($message); + if ($raw !== null) { + return $raw; } $lines = []; $subj = $message->getSubject() ?? ''; $from = $this->addrFirstToString($message->getFrom()); $messageTo = $this->addrManyToString($message->getTo()); - $date = $this->extractDate($message)->format(DATE_RFC2822); + $date = $this->extractDate($message)->format(\DATE_RFC2822); if ($subj !== '') { $lines[] = 'Subject: ' . $subj; } if ($from !== '') { $lines[] = 'From: ' . $from; } @@ -165,6 +141,27 @@ private function headerToStringSafe(mixed $message): string return implode("\r\n", $lines) . "\r\n"; } + private function tryRawHeader(mixed $message): ?string + { + if (!method_exists($message, 'getHeader')) { + return null; + } + + try { + $headerObj = $message->getHeader(); + if ($headerObj && method_exists($headerObj, 'toString')) { + $raw = (string) $headerObj->toString(); + if ($raw !== '') { + return $raw; + } + } + } catch (Throwable $e) { + return null; + } + + return null; + } + private function bodyBestEffort($message): string { $text = ($message->getTextBody() ?? ''); @@ -223,6 +220,17 @@ private function addrManyToArray($addresses): array return $out; } + private function processDelete(bool $testMode, bool $processed, mixed $message): void + { + if (!$testMode) { + if ($processed && $this->purgeProcessed) { + $this->safeDelete($message); + } elseif (!$processed && $this->purgeUnprocessed) { + $this->safeDelete($message); + } + } + } + private function safeDelete($message): void { try { @@ -235,4 +243,19 @@ private function safeDelete($message): void $this->logger->warning('Failed to delete message', ['error' => $e->getMessage()]); } } + + private function processExpunge(bool $testMode, ?Folder $folder, Client $client): void + { + if (!$testMode) { + try { + if (method_exists($folder, 'expunge')) { + $folder->expunge(); + } elseif (method_exists($client, 'expunge')) { + $client->expunge(); + } + } catch (Throwable $e) { + $this->logger->warning('EXPUNGE failed', ['error' => $e->getMessage()]); + } + } + } } diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index 71e835de..3c3583b4 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -153,6 +153,30 @@ public function incrementBounceCount(int $subscriberId): void ->execute(); } + public function markUnconfirmed(int $subscriberId): void + { + $this->createQueryBuilder('s') + ->update() + ->set('s.confirmed', ':confirmed') + ->where('s.id = :id') + ->setParameter('confirmed', false) + ->setParameter('id', $subscriberId) + ->getQuery() + ->execute(); + } + + public function markConfirmed(int $subscriberId): void + { + $this->createQueryBuilder('s') + ->update() + ->set('s.confirmed', ':confirmed') + ->where('s.id = :id') + ->setParameter('confirmed', true) + ->setParameter('id', $subscriberId) + ->getQuery() + ->execute(); + } + /** @return Subscriber[] */ public function distinctUsersWithBouncesConfirmedNotBlacklisted(): array { diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php index bc98fdb5..8ae48964 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php @@ -68,51 +68,11 @@ private function sendConfirmationEmail(Subscriber $subscriber): void $this->messageBus->dispatch($message); } - public function getSubscriber(int $subscriberId): Subscriber - { - $subscriber = $this->subscriberRepository->findSubscriberWithSubscriptions($subscriberId); - - if (!$subscriber) { - throw new NotFoundHttpException('Subscriber not found'); - } - - return $subscriber; - } - - public function getSubscriberByEmail(string $mail): ?Subscriber - { - return $this->subscriberRepository->findOneByEmail($mail); - } - public function getSubscriberById(int $subscriberId): ?Subscriber { return $this->subscriberRepository->find($subscriberId); } - public function markUnconfirmed(int $subscriberId): void - { - $this->subscriberRepository->createQueryBuilder('s') - ->update() - ->set('s.confirmed', ':confirmed') - ->where('s.id = :id') - ->setParameter('confirmed', false) - ->setParameter('id', $subscriberId) - ->getQuery() - ->execute(); - } - - public function markConfirmed(int $subscriberId): void - { - $this->subscriberRepository->createQueryBuilder('s') - ->update() - ->set('s.confirmed', ':confirmed') - ->where('s.id = :id') - ->setParameter('confirmed', true) - ->setParameter('id', $subscriberId) - ->getQuery() - ->execute(); - } - public function updateSubscriber(UpdateSubscriberDto $subscriberDto): Subscriber { /** @var Subscriber $subscriber */ diff --git a/src/Domain/Subscription/Service/SubscriberBlacklistService.php b/src/Domain/Subscription/Service/SubscriberBlacklistService.php index b02806f2..a6422475 100644 --- a/src/Domain/Subscription/Service/SubscriberBlacklistService.php +++ b/src/Domain/Subscription/Service/SubscriberBlacklistService.php @@ -29,6 +29,9 @@ public function __construct( $this->requestStack = $requestStack; } + /** + * @SuppressWarnings(PHPMD.Superglobals) + */ public function blacklist(Subscriber $subscriber, string $reason): void { $subscriber->setBlacklisted(true); From 5f273320e1ae2227c2d80dd9cc6ca219d3e9e0ab Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 27 Aug 2025 11:47:09 +0400 Subject: [PATCH 19/24] PhpCodeSniffer --- config/PhpCodeSniffer/ruleset.xml | 9 --- src/Domain/Common/ClientIpResolver.php | 7 +- .../Common/Mail/NativeImapMailReader.php | 3 +- src/Domain/Common/SystemInfoCollector.php | 2 +- .../Command/ProcessBouncesCommand.php | 7 +- .../Repository/BounceRegexRepository.php | 4 +- .../Service/BounceActionResolver.php | 4 +- .../BlacklistEmailAndDeleteBounceHandler.php | 16 +++-- .../Service/Handler/BlacklistEmailHandler.php | 12 +++- .../BlacklistUserAndDeleteBounceHandler.php | 16 +++-- .../Service/Handler/BlacklistUserHandler.php | 12 +++- .../Service/Handler/DeleteBounceHandler.php | 9 ++- .../Handler/DeleteUserAndBounceHandler.php | 12 ++-- .../Service/Handler/DeleteUserHandler.php | 12 ++-- src/Domain/Messaging/Service/LockService.php | 1 - .../Service/Manager/BounceManager.php | 14 +++- .../Service/Manager/BounceRuleManager.php | 20 ++++-- .../Service/Manager/SendProcessManager.php | 4 +- .../Messaging/Service/MessageParser.php | 68 ++++++++++++++----- .../Service/NativeBounceProcessingService.php | 13 +--- .../AdvancedBounceRulesProcessor.php | 2 +- .../Service/Processor/BounceDataProcessor.php | 9 ++- .../Service/Processor/MboxBounceProcessor.php | 2 +- .../Service/Processor/PopBounceProcessor.php | 6 +- .../UnidentifiedBounceReprocessor.php | 6 +- .../WebklexBounceProcessingService.php | 25 ++++--- .../Service/WebklexImapClientFactory.php | 4 +- .../Manager/SubscriberBlacklistManager.php | 1 - .../Service/SubscriberBlacklistService.php | 2 +- .../Service/Manager/BounceManagerTest.php | 12 ++-- 30 files changed, 209 insertions(+), 105 deletions(-) diff --git a/config/PhpCodeSniffer/ruleset.xml b/config/PhpCodeSniffer/ruleset.xml index d0258304..fdba2edf 100644 --- a/config/PhpCodeSniffer/ruleset.xml +++ b/config/PhpCodeSniffer/ruleset.xml @@ -15,7 +15,6 @@ - @@ -41,9 +40,6 @@ - - - @@ -54,7 +50,6 @@ - @@ -66,9 +61,6 @@ - - - @@ -110,6 +102,5 @@ - diff --git a/src/Domain/Common/ClientIpResolver.php b/src/Domain/Common/ClientIpResolver.php index 44e049bb..65cbbb6c 100644 --- a/src/Domain/Common/ClientIpResolver.php +++ b/src/Domain/Common/ClientIpResolver.php @@ -8,7 +8,12 @@ class ClientIpResolver { - public function __construct(private readonly RequestStack $requestStack) {} + private RequestStack $requestStack; + + public function __construct(RequestStack $requestStack) + { + $this->requestStack = $requestStack; + } public function resolve(): string { diff --git a/src/Domain/Common/Mail/NativeImapMailReader.php b/src/Domain/Common/Mail/NativeImapMailReader.php index 7cf6436f..472fea54 100644 --- a/src/Domain/Common/Mail/NativeImapMailReader.php +++ b/src/Domain/Common/Mail/NativeImapMailReader.php @@ -21,7 +21,8 @@ public function __construct(string $username, string $password) public function open(string $mailbox, int $options = 0): Connection { - $link = @imap_open($mailbox, $this->username, $this->password, $options); + $link = imap_open($mailbox, $this->username, $this->password, $options); + if ($link === false) { throw new RuntimeException('Cannot open mailbox: '.(imap_last_error() ?: 'unknown error')); } diff --git a/src/Domain/Common/SystemInfoCollector.php b/src/Domain/Common/SystemInfoCollector.php index dc483510..e66d27b1 100644 --- a/src/Domain/Common/SystemInfoCollector.php +++ b/src/Domain/Common/SystemInfoCollector.php @@ -70,7 +70,7 @@ public function collectAsString(): string } $lines = []; foreach ($pairs as $k => $v) { - $lines[] = sprintf("%s = %s", $k, $v); + $lines[] = sprintf('%s = %s', $k, $v); } return "\n" . implode("\n", $lines); } diff --git a/src/Domain/Messaging/Command/ProcessBouncesCommand.php b/src/Domain/Messaging/Command/ProcessBouncesCommand.php index 348e5f6d..7c01ea4b 100644 --- a/src/Domain/Messaging/Command/ProcessBouncesCommand.php +++ b/src/Domain/Messaging/Command/ProcessBouncesCommand.php @@ -25,7 +25,12 @@ protected function configure(): void { $this ->addOption('protocol', null, InputOption::VALUE_REQUIRED, 'Mailbox protocol: pop or mbox', 'pop') - ->addOption('purge-unprocessed', null, InputOption::VALUE_NONE, 'Delete/remove unprocessed messages from mailbox') + ->addOption( + 'purge-unprocessed', + null, + InputOption::VALUE_NONE, + 'Delete/remove unprocessed messages from mailbox' + ) ->addOption('rules-batch-size', null, InputOption::VALUE_OPTIONAL, 'Advanced rules batch size', '1000') ->addOption('test', 't', InputOption::VALUE_NONE, 'Test mode: do not delete from mailbox') ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force run: kill other processes if locked'); diff --git a/src/Domain/Messaging/Repository/BounceRegexRepository.php b/src/Domain/Messaging/Repository/BounceRegexRepository.php index 2696cac5..9aecde78 100644 --- a/src/Domain/Messaging/Repository/BounceRegexRepository.php +++ b/src/Domain/Messaging/Repository/BounceRegexRepository.php @@ -21,12 +21,12 @@ public function findOneByRegexHash(string $regexHash): ?BounceRegex /** @return BounceRegex[] */ public function fetchAllOrdered(): array { - return $this->findBy([], ['listOrder' => 'ASC']); + return $this->findBy([], ['listOrder' => 'ASC']); } /** @return BounceRegex[] */ public function fetchActiveOrdered(): array { - return $this->findBy(['active' => true], ['listOrder' => 'ASC']); + return $this->findBy(['active' => true], ['listOrder' => 'ASC']); } } diff --git a/src/Domain/Messaging/Service/BounceActionResolver.php b/src/Domain/Messaging/Service/BounceActionResolver.php index 84740630..d2fc6eb8 100644 --- a/src/Domain/Messaging/Service/BounceActionResolver.php +++ b/src/Domain/Messaging/Service/BounceActionResolver.php @@ -41,7 +41,9 @@ public function resolve(string $action): BounceActionHandlerInterface throw new RuntimeException(sprintf('No handler found for action "%s".', $action)); } - return $this->cache[$action] = $handler; + $this->cache[$action] = $handler; + + return $handler; } /** Convenience: resolve + execute */ diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php index 896cffab..3c0cd45d 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php @@ -10,11 +10,19 @@ class BlacklistEmailAndDeleteBounceHandler implements BounceActionHandlerInterface { + private SubscriberHistoryManager $subscriberHistoryManager; + private SubscriberManager $subscriberManager; + private BounceManager $bounceManager; + public function __construct( - private readonly SubscriberHistoryManager $subscriberHistoryManager, - private readonly SubscriberManager $subscriberManager, - private readonly BounceManager $bounceManager, - ) {} + SubscriberHistoryManager $subscriberHistoryManager, + SubscriberManager $subscriberManager, + BounceManager $bounceManager, + ) { + $this->subscriberHistoryManager = $subscriberHistoryManager; + $this->subscriberManager = $subscriberManager; + $this->bounceManager = $bounceManager; + } public function supports(string $action): bool { diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php index 3fc31d07..900d0e12 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php @@ -9,10 +9,16 @@ class BlacklistEmailHandler implements BounceActionHandlerInterface { + private SubscriberHistoryManager $subscriberHistoryManager; + private SubscriberManager $subscriberManager; + public function __construct( - private readonly SubscriberHistoryManager $subscriberHistoryManager, - private readonly SubscriberManager $subscriberManager, - ) {} + SubscriberHistoryManager $subscriberHistoryManager, + SubscriberManager $subscriberManager, + ) { + $this->subscriberHistoryManager = $subscriberHistoryManager; + $this->subscriberManager = $subscriberManager; + } public function supports(string $action): bool { diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php index 7002aef8..f4b880b4 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php @@ -10,11 +10,19 @@ class BlacklistUserAndDeleteBounceHandler implements BounceActionHandlerInterface { + private SubscriberHistoryManager $subscriberHistoryManager; + private SubscriberManager $subscriberManager; + private BounceManager $bounceManager; + public function __construct( - private readonly SubscriberHistoryManager $subscriberHistoryManager, - private readonly SubscriberManager $subscriberManager, - private readonly BounceManager $bounceManager, - ) {} + SubscriberHistoryManager $subscriberHistoryManager, + SubscriberManager $subscriberManager, + BounceManager $bounceManager, + ) { + $this->subscriberHistoryManager = $subscriberHistoryManager; + $this->subscriberManager = $subscriberManager; + $this->bounceManager = $bounceManager; + } public function supports(string $action): bool { diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php index 6e849a36..d8b49f71 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php @@ -9,10 +9,16 @@ class BlacklistUserHandler implements BounceActionHandlerInterface { + private SubscriberHistoryManager $subscriberHistoryManager; + private SubscriberManager $subscriberManager; + public function __construct( - private readonly SubscriberHistoryManager $subscriberHistoryManager, - private readonly SubscriberManager $subscriberManager, - ) {} + SubscriberHistoryManager $subscriberHistoryManager, + SubscriberManager $subscriberManager, + ) { + $this->subscriberHistoryManager = $subscriberHistoryManager; + $this->subscriberManager = $subscriberManager; + } public function supports(string $action): bool { diff --git a/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php index d491a888..80c881a1 100644 --- a/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php @@ -8,9 +8,12 @@ class DeleteBounceHandler implements BounceActionHandlerInterface { - public function __construct( - private readonly BounceManager $bounceManager, - ) {} + private BounceManager $bounceManager; + + public function __construct(BounceManager $bounceManager) + { + $this->bounceManager = $bounceManager; + } public function supports(string $action): bool { diff --git a/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php b/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php index a130e46b..d8887545 100644 --- a/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php @@ -9,10 +9,14 @@ class DeleteUserAndBounceHandler implements BounceActionHandlerInterface { - public function __construct( - private readonly BounceManager $bounceManager, - private readonly SubscriberManager $subscriberManager, - ) {} + private BounceManager $bounceManager; + private SubscriberManager $subscriberManager; + + public function __construct(BounceManager $bounceManager, SubscriberManager $subscriberManager,) + { + $this->bounceManager = $bounceManager; + $this->subscriberManager = $subscriberManager; + } public function supports(string $action): bool { diff --git a/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php b/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php index 4b8b409c..64b1a073 100644 --- a/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php +++ b/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php @@ -9,10 +9,14 @@ class DeleteUserHandler implements BounceActionHandlerInterface { - public function __construct( - private readonly LoggerInterface $logger, - private readonly SubscriberManager $subscriberManager, - ) {} + private SubscriberManager $subscriberManager; + private LoggerInterface $logger; + + public function __construct(SubscriberManager $subscriberManager, LoggerInterface $logger) + { + $this->subscriberManager = $subscriberManager; + $this->logger = $logger; + } public function supports(string $action): bool { diff --git a/src/Domain/Messaging/Service/LockService.php b/src/Domain/Messaging/Service/LockService.php index 3cc9e80d..d912dc04 100644 --- a/src/Domain/Messaging/Service/LockService.php +++ b/src/Domain/Messaging/Service/LockService.php @@ -61,7 +61,6 @@ public function acquirePageLock( if ($count >= $max) { if ($this->tryStealIfStale($running)) { - continue; } diff --git a/src/Domain/Messaging/Service/Manager/BounceManager.php b/src/Domain/Messaging/Service/Manager/BounceManager.php index 34108574..0a1e5758 100644 --- a/src/Domain/Messaging/Service/Manager/BounceManager.php +++ b/src/Domain/Messaging/Service/Manager/BounceManager.php @@ -12,18 +12,24 @@ use PhpList\Core\Domain\Messaging\Repository\BounceRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository; use PhpList\Core\Domain\Subscription\Model\Subscriber; +use Psr\Log\LoggerInterface; class BounceManager { private BounceRepository $bounceRepository; private UserMessageBounceRepository $userMessageBounceRepo; + private LoggerInterface $logger; + private const TEST_MODE_MESSAGE = 'Running in test mode, not deleting messages from mailbox'; + private const LIVE_MODE_MESSAGE = 'Processed messages will be deleted from the mailbox'; public function __construct( BounceRepository $bounceRepository, - UserMessageBounceRepository $userMessageBounceRepo + UserMessageBounceRepository $userMessageBounceRepo, + LoggerInterface $logger ) { $this->bounceRepository = $bounceRepository; $this->userMessageBounceRepo = $userMessageBounceRepo; + $this->logger = $logger; } public function create( @@ -117,4 +123,10 @@ public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array { return $this->userMessageBounceRepo->getUserMessageHistoryWithBounces($subscriber); } + + public function announceDeletionMode(bool $testMode): void + { + $message = $testMode ? self::TEST_MODE_MESSAGE : self::LIVE_MODE_MESSAGE; + $this->logger->info($message); + } } diff --git a/src/Domain/Messaging/Service/Manager/BounceRuleManager.php b/src/Domain/Messaging/Service/Manager/BounceRuleManager.php index 025659d4..70a750a9 100644 --- a/src/Domain/Messaging/Service/Manager/BounceRuleManager.php +++ b/src/Domain/Messaging/Service/Manager/BounceRuleManager.php @@ -49,8 +49,7 @@ private function mapRows(array $rows): array $action = $row->getAction(); $id = $row->getId(); - if ( - !is_string($regex) + if (!is_string($regex) || $regex === '' || !is_string($action) || $action === '' @@ -72,10 +71,12 @@ private function mapRows(array $rows): array public function matchBounceRules(string $text, array $rules): ?BounceRegex { foreach ($rules as $pattern => $rule) { - $pattern = str_replace(' ', '\s+', $pattern); - if (@preg_match('/'.preg_quote($pattern).'/iUm', $text)) { + $quoted = '/'.preg_quote(str_replace(' ', '\s+', $pattern)).'/iUm'; + if ($this->safePregMatch($quoted, $text)) { return $rule; - } elseif (@preg_match('/'.$pattern.'/iUm', $text)) { + } + $raw = '/'.str_replace(' ', '\s+', $pattern).'/iUm'; + if ($this->safePregMatch($raw, $text)) { return $rule; } } @@ -83,6 +84,15 @@ public function matchBounceRules(string $text, array $rules): ?BounceRegex return null; } + private function safePregMatch(string $pattern, string $subject): bool + { + set_error_handler(static fn() => true); + $result = preg_match($pattern, $subject) === 1; + restore_error_handler(); + + return $result; + } + public function incrementCount(BounceRegex $rule): void { $rule->setCount($rule->getCount() + 1); diff --git a/src/Domain/Messaging/Service/Manager/SendProcessManager.php b/src/Domain/Messaging/Service/Manager/SendProcessManager.php index 3dc3a39b..0100ed29 100644 --- a/src/Domain/Messaging/Service/Manager/SendProcessManager.php +++ b/src/Domain/Messaging/Service/Manager/SendProcessManager.php @@ -47,9 +47,7 @@ public function findNewestAliveWithAge(string $page): ?array } $modified = $row->getUpdatedAt(); - $age = $modified - ? max(0, time() - (int)$modified->format('U')) - : 0; + $age = $modified ? max(0, time() - (int)$modified->format('U')) : 0; return [ 'id' => $row->getId(), diff --git a/src/Domain/Messaging/Service/MessageParser.php b/src/Domain/Messaging/Service/MessageParser.php index dbbf0ac9..14b4f952 100644 --- a/src/Domain/Messaging/Service/MessageParser.php +++ b/src/Domain/Messaging/Service/MessageParser.php @@ -40,24 +40,60 @@ public function findMessageId(string $text): ?string public function findUserId(string $text): ?int { - // Try X-ListMember / X-User first - if (preg_match('/(?:X-ListMember|X-User): (.*)\r\n/iU', $text, $match)) { - $user = trim($match[1]); - if (str_contains($user, '@')) { - return $this->subscriberRepository->findOneByEmail($user)?->getId(); - } elseif (preg_match('/^\d+$/', $user)) { - return (int)$user; - } elseif ($user !== '') { - return $this->subscriberRepository->findOneByEmail($user)?->getId(); + $candidate = $this->extractUserHeader($text); + if ($candidate) { + $id = $this->resolveUserIdentifier($candidate); + if ($id) { + return $id; } } - // Fallback: parse any email in the body and see if it is a subscriber - if (preg_match_all('/[._a-zA-Z0-9-]+@[.a-zA-Z0-9-]+/', $text, $regs)) { - foreach ($regs[0] as $email) { - $id = $this->subscriberRepository->findOneByEmail($email)?->getId(); - if ($id) { - return $id; - } + + $emails = $this->extractEmails($text); + + return $this->findFirstSubscriberId($emails); + } + + private function extractUserHeader(string $text): ?string + { + if (preg_match('/^(?:X-ListMember|X-User):\s*(?P[^\r\n]+)/mi', $text, $matches)) { + $user = trim($matches['user']); + + return $user !== '' ? $user : null; + } + + return null; + } + + private function resolveUserIdentifier(string $user): ?int + { + if (filter_var($user, FILTER_VALIDATE_EMAIL)) { + return $this->subscriberRepository->findOneByEmail($user)?->getId(); + } + + if (ctype_digit($user)) { + return (int) $user; + } + + return $this->subscriberRepository->findOneByEmail($user)?->getId(); + } + + private function extractEmails(string $text): array + { + preg_match_all('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+/i', $text, $matches); + if (empty($matches[0])) { + return []; + } + $norm = array_map('strtolower', $matches[0]); + + return array_values(array_unique($norm)); + } + + private function findFirstSubscriberId(array $emails): ?int + { + foreach ($emails as $email) { + $id = $this->subscriberRepository->findOneByEmail($email)?->getId(); + if ($id !== null) { + return $id; } } diff --git a/src/Domain/Messaging/Service/NativeBounceProcessingService.php b/src/Domain/Messaging/Service/NativeBounceProcessingService.php index a431d063..eee5bb98 100644 --- a/src/Domain/Messaging/Service/NativeBounceProcessingService.php +++ b/src/Domain/Messaging/Service/NativeBounceProcessingService.php @@ -47,14 +47,14 @@ public function processMailbox( ): string { $link = $this->openOrFail($mailbox, $testMode); - $num = $this->prepareAndCapCount( $link, $max); + $num = $this->prepareAndCapCount($link, $max); if ($num === 0) { $this->mailReader->close($link, false); return ''; } - $this->announceDeletionMode($testMode); + $this->bounceManager->announceDeletionMode($testMode); for ($messageNumber = 1; $messageNumber <= $num; $messageNumber++) { $this->handleMessage($link, $messageNumber, $testMode); @@ -92,15 +92,6 @@ private function prepareAndCapCount(Connection $link, int $max): int return $num; } - private function announceDeletionMode(bool $testMode): void - { - $this->logger->info( - $testMode - ? 'Running in test mode, not deleting messages from mailbox' - : 'Processed messages will be deleted from the mailbox' - ); - } - private function handleMessage(Connection $link, int $messageNumber, bool $testMode): void { $header = $this->mailReader->fetchHeader($link, $messageNumber); diff --git a/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php index b8664fc2..568bf874 100644 --- a/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php +++ b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php @@ -113,7 +113,7 @@ private function makeContext(?Subscriber $subscriber, Bounce $bounce, int $ruleI 'bounce' => $bounce, 'userId' => $userId, 'confirmed' => $confirmed, - 'blacklisted'=> $blacklisted, + 'blacklisted' => $blacklisted, 'ruleId' => $ruleId, ]; } diff --git a/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php index 10f7de84..6f502a8c 100644 --- a/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php +++ b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php @@ -43,9 +43,12 @@ public function process(Bounce $bounce, ?string $msgId, ?int $userId, DateTimeIm $user = $userId ? $this->subscriberManager->getSubscriberById($userId) : null; if ($msgId === 'systemmessage') { - return $userId - ? $this->handleSystemMessageWithUser($bounce, $bounceDate, $userId, $user) - : $this->handleSystemMessageUnknownUser($bounce); + return $userId ? $this->handleSystemMessageWithUser( + $bounce, + $bounceDate, + $userId, + $user + ) : $this->handleSystemMessageUnknownUser($bounce); } if ($msgId && $userId) { diff --git a/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php b/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php index 6a862ea3..a52b6f2f 100644 --- a/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php +++ b/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php @@ -34,7 +34,7 @@ public function process(InputInterface $input, SymfonyStyle $inputOutput): strin throw new RuntimeException('Missing --mailbox for mbox protocol'); } - $inputOutput->section("Opening mbox $file"); + $inputOutput->section('Opening mbox ' . $file); $inputOutput->writeln('Please do not interrupt this process'); return $this->processingService->processMailbox( diff --git a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php index 57031ccc..b6f59f65 100644 --- a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php +++ b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php @@ -40,9 +40,11 @@ public function process(InputInterface $input, SymfonyStyle $inputOutput): strin $downloadReport = ''; foreach (explode(',', $this->mailboxNames) as $mailboxName) { $mailboxName = trim($mailboxName); - if ($mailboxName === '') { $mailboxName = 'INBOX'; } + if ($mailboxName === '') { + $mailboxName = 'INBOX'; + } $mailbox = sprintf('{%s:%s}%s', $this->host, $this->port, $mailboxName); - $inputOutput->section("Connecting to $mailbox"); + $inputOutput->section('Connecting to ' . $mailbox); $inputOutput->writeln('Please do not interrupt this process'); $downloadReport .= $this->processingService->processMailbox( diff --git a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php index 3276d675..503fc459 100644 --- a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php +++ b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php @@ -52,7 +52,8 @@ public function process(SymfonyStyle $inputOutput): void $bounce, $messageId, $userId, - new DateTimeImmutable()) + new DateTimeImmutable() + ) ) { $reidentified++; } @@ -62,7 +63,8 @@ public function process(SymfonyStyle $inputOutput): void $inputOutput->writeln(sprintf('%d out of %d processed', $count, $total)); $inputOutput->writeln(sprintf( '%d bounces were re-processed and %d bounces were re-identified', - $reparsed, $reidentified + $reparsed, + $reidentified )); } } diff --git a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php index 0f589851..01a94aff 100644 --- a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php +++ b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php @@ -49,6 +49,8 @@ public function __construct( * Process unseen messages from the given mailbox using Webklex. * * $mailbox: IMAP host; if you pass "host#FOLDER", FOLDER will be used instead of INBOX. + * + * @throws RuntimeException If connection to the IMAP server cannot be established. */ public function processMailbox( string $mailbox, @@ -76,10 +78,7 @@ public function processMailbox( return ''; } - $this->logger->info($testMode - ? 'Running in test mode, not deleting messages from mailbox' - : 'Processed messages will be deleted from the mailbox' - ); + $this->bounceManager->announceDeletionMode($testMode); foreach ($messages as $message) { $header = $this->headerToStringSafe($message); @@ -112,7 +111,7 @@ public function processMailbox( try { $client->disconnect(); } catch (Throwable $e) { - // swallow + $this->logger->warning('Disconnect failed', ['error' => $e->getMessage()]); } } } @@ -130,13 +129,21 @@ private function headerToStringSafe(mixed $message): string $messageTo = $this->addrManyToString($message->getTo()); $date = $this->extractDate($message)->format(\DATE_RFC2822); - if ($subj !== '') { $lines[] = 'Subject: ' . $subj; } - if ($from !== '') { $lines[] = 'From: ' . $from; } - if ($messageTo !== '') { $lines[] = 'To: ' . $messageTo; } + if ($subj !== '') { + $lines[] = 'Subject: ' . $subj; + } + if ($from !== '') { + $lines[] = 'From: ' . $from; + } + if ($messageTo !== '') { + $lines[] = 'To: ' . $messageTo; + } $lines[] = 'Date: ' . $date; $mid = $message->getMessageId() ?? ''; - if ($mid !== '') { $lines[] = 'Message-ID: ' . $mid; } + if ($mid !== '') { + $lines[] = 'Message-ID: ' . $mid; + } return implode("\r\n", $lines) . "\r\n"; } diff --git a/src/Domain/Messaging/Service/WebklexImapClientFactory.php b/src/Domain/Messaging/Service/WebklexImapClientFactory.php index 1657ac95..10271e4c 100644 --- a/src/Domain/Messaging/Service/WebklexImapClientFactory.php +++ b/src/Domain/Messaging/Service/WebklexImapClientFactory.php @@ -28,8 +28,7 @@ public function __construct( string $protocol, int $port, string $encryption = 'ssl' - ) - { + ) { $this->clientManager = $clientManager; $this->mailbox = $mailbox; $this->host = $host; @@ -64,7 +63,6 @@ public function makeForMailbox(): Client public function getFolderName(): string { - // todo: check if folder logic is correct return $this->parseMailbox($this->mailbox)[1]; } diff --git a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php index ce31b0fb..d5828c2f 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php @@ -66,7 +66,6 @@ public function addBlacklistData(string $email, string $name, string $data): voi $blacklistData->setData($data); $this->entityManager->persist($blacklistData); $this->entityManager->flush(); - } public function removeEmailFromBlacklist(string $email): void diff --git a/src/Domain/Subscription/Service/SubscriberBlacklistService.php b/src/Domain/Subscription/Service/SubscriberBlacklistService.php index a6422475..d9ca5ea6 100644 --- a/src/Domain/Subscription/Service/SubscriberBlacklistService.php +++ b/src/Domain/Subscription/Service/SubscriberBlacklistService.php @@ -38,7 +38,7 @@ public function blacklist(Subscriber $subscriber, string $reason): void $this->entityManager->flush(); $this->blacklistManager->addEmailToBlacklist($subscriber->getEmail(), $reason); - foreach (array('REMOTE_ADDR','HTTP_X_FORWARDED_FOR') as $item) { + foreach (['REMOTE_ADDR','HTTP_X_FORWARDED_FOR'] as $item) { $request = $this->requestStack->getCurrentRequest(); if (!$request) { return; diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php index 77bcb3cb..9d076a4b 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php @@ -11,18 +11,22 @@ use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; class BounceManagerTest extends TestCase { private BounceRepository&MockObject $repository; - private UserMessageBounceRepository&MockObject $userMessageBounceRepository; private BounceManager $manager; protected function setUp(): void { $this->repository = $this->createMock(BounceRepository::class); - $this->userMessageBounceRepository = $this->createMock(UserMessageBounceRepository::class); - $this->manager = new BounceManager($this->repository, $this->userMessageBounceRepository); + $userMessageBounceRepository = $this->createMock(UserMessageBounceRepository::class); + $this->manager = new BounceManager( + bounceRepository: $this->repository, + userMessageBounceRepo: $userMessageBounceRepository, + logger: $this->createMock(LoggerInterface::class) + ); } public function testCreatePersistsAndReturnsBounce(): void @@ -46,7 +50,7 @@ public function testCreatePersistsAndReturnsBounce(): void ); $this->assertInstanceOf(Bounce::class, $bounce); - $this->assertSame( $date->format('Y-m-d h:m:s'), $bounce->getDate()->format('Y-m-d h:m:s')); + $this->assertSame($date->format('Y-m-d h:m:s'), $bounce->getDate()->format('Y-m-d h:m:s')); $this->assertSame($header, $bounce->getHeader()); $this->assertSame($data, $bounce->getData()); $this->assertSame($status, $bounce->getStatus()); From 7660f2dbee6f1d71193948bf5be38563a2b60bf0 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 27 Aug 2025 12:36:13 +0400 Subject: [PATCH 20/24] Tests --- config/services/services.yml | 2 +- src/Domain/Messaging/Service/BounceActionResolver.php | 3 ++- .../Service/Handler/BlacklistEmailAndDeleteBounceHandler.php | 2 +- src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php | 2 +- .../Service/Handler/BlacklistUserAndDeleteBounceHandler.php | 2 +- src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php | 2 +- .../Messaging/Service/Handler/BounceActionHandlerInterface.php | 2 +- .../Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php | 2 +- src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php | 2 +- .../Messaging/Service/Handler/DeleteUserAndBounceHandler.php | 2 +- src/Domain/Messaging/Service/Handler/DeleteUserHandler.php | 2 +- .../Service/Handler/UnconfirmUserAndDeleteBounceHandler.php | 2 +- src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php | 2 +- 13 files changed, 14 insertions(+), 13 deletions(-) diff --git a/config/services/services.yml b/config/services/services.yml index 1ac73757..19caddd8 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -106,4 +106,4 @@ services: PhpList\Core\Domain\Messaging\Service\BounceActionResolver: arguments: - - !tagged_iterator { tag: 'phplist.bounce_action_handler', default_index_method: 'supports' } + - !tagged_iterator { tag: 'phplist.bounce_action_handler' } diff --git a/src/Domain/Messaging/Service/BounceActionResolver.php b/src/Domain/Messaging/Service/BounceActionResolver.php index d2fc6eb8..83969c1f 100644 --- a/src/Domain/Messaging/Service/BounceActionResolver.php +++ b/src/Domain/Messaging/Service/BounceActionResolver.php @@ -55,10 +55,11 @@ public function handle(string $action, array $context): void private function find(string $action): ?BounceActionHandlerInterface { foreach ($this->handlers as $handler) { - if ($handler->supports($action)) { + if ($handler::supports($action)) { return $handler; } } + return null; } } diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php index 3c0cd45d..9df3deb2 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php @@ -24,7 +24,7 @@ public function __construct( $this->bounceManager = $bounceManager; } - public function supports(string $action): bool + public static function supports(string $action): bool { return $action === 'blacklistemailanddeletebounce'; } diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php index 900d0e12..1d482817 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php @@ -20,7 +20,7 @@ public function __construct( $this->subscriberManager = $subscriberManager; } - public function supports(string $action): bool + public static function supports(string $action): bool { return $action === 'blacklistemail'; } diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php index f4b880b4..ed1698df 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php @@ -24,7 +24,7 @@ public function __construct( $this->bounceManager = $bounceManager; } - public function supports(string $action): bool + public static function supports(string $action): bool { return $action === 'blacklistuseranddeletebounce'; } diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php index d8b49f71..b4282e83 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php @@ -20,7 +20,7 @@ public function __construct( $this->subscriberManager = $subscriberManager; } - public function supports(string $action): bool + public static function supports(string $action): bool { return $action === 'blacklistuser'; } diff --git a/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php b/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php index 6b90cb49..abbd2210 100644 --- a/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php +++ b/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php @@ -6,6 +6,6 @@ interface BounceActionHandlerInterface { - public function supports(string $action): bool; + public static function supports(string $action): bool; public function handle(array $closureData): void; } diff --git a/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php index a8ecdfb5..f5986627 100644 --- a/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php @@ -28,7 +28,7 @@ public function __construct( $this->subscriberRepository = $subscriberRepository; } - public function supports(string $action): bool + public static function supports(string $action): bool { return $action === 'decreasecountconfirmuseranddeletebounce'; } diff --git a/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php index 80c881a1..26cffda2 100644 --- a/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php @@ -15,7 +15,7 @@ public function __construct(BounceManager $bounceManager) $this->bounceManager = $bounceManager; } - public function supports(string $action): bool + public static function supports(string $action): bool { return $action === 'deletebounce'; } diff --git a/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php b/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php index d8887545..258a5974 100644 --- a/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php @@ -18,7 +18,7 @@ public function __construct(BounceManager $bounceManager, SubscriberManager $sub $this->subscriberManager = $subscriberManager; } - public function supports(string $action): bool + public static function supports(string $action): bool { return $action === 'deleteuserandbounce'; } diff --git a/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php b/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php index 64b1a073..ca29b4e4 100644 --- a/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php +++ b/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php @@ -18,7 +18,7 @@ public function __construct(SubscriberManager $subscriberManager, LoggerInterfac $this->logger = $logger; } - public function supports(string $action): bool + public static function supports(string $action): bool { return $action === 'deleteuser'; } diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php index 7ca39be8..e2da913c 100644 --- a/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php @@ -24,7 +24,7 @@ public function __construct( $this->bounceManager = $bounceManager; } - public function supports(string $action): bool + public static function supports(string $action): bool { return $action === 'unconfirmuseranddeletebounce'; } diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php index a5bdd0fe..99b37c5a 100644 --- a/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php +++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php @@ -20,7 +20,7 @@ public function __construct( $this->subscriberHistoryManager = $subscriberHistoryManager; } - public function supports(string $action): bool + public static function supports(string $action): bool { return $action === 'unconfirmuser'; } From 2750b1e89b394063946d0c7a06ec73aa583083c0 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 27 Aug 2025 12:57:41 +0400 Subject: [PATCH 21/24] Refactor --- .../Service/ConsecutiveBounceHandler.php | 10 +++++----- .../BlacklistEmailAndDeleteBounceHandler.php | 14 ++++++------- .../Service/Handler/BlacklistEmailHandler.php | 10 +++++----- .../BlacklistUserAndDeleteBounceHandler.php | 20 +++++++++---------- .../Service/Handler/BlacklistUserHandler.php | 20 +++++++++---------- .../Service/Manager/BounceManager.php | 12 ++++++++--- .../Service/Manager/SubscriberManager.php | 8 -------- .../Service/Manager/BounceManagerTest.php | 4 +++- .../Service/Manager/SubscriberManagerTest.php | 2 -- 9 files changed, 49 insertions(+), 51 deletions(-) diff --git a/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php b/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php index d4acc2fd..0805c156 100644 --- a/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php +++ b/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php @@ -11,30 +11,30 @@ use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; +use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use Symfony\Component\Console\Style\SymfonyStyle; class ConsecutiveBounceHandler { private BounceManager $bounceManager; private SubscriberRepository $subscriberRepository; - private SubscriberManager $subscriberManager; private SubscriberHistoryManager $subscriberHistoryManager; + private SubscriberBlacklistService $blacklistService; private int $unsubscribeThreshold; private int $blacklistThreshold; public function __construct( BounceManager $bounceManager, SubscriberRepository $subscriberRepository, - SubscriberManager $subscriberManager, SubscriberHistoryManager $subscriberHistoryManager, + SubscriberBlacklistService $blacklistService, int $unsubscribeThreshold, int $blacklistThreshold, ) { $this->bounceManager = $bounceManager; $this->subscriberRepository = $subscriberRepository; - $this->subscriberManager = $subscriberManager; $this->subscriberHistoryManager = $subscriberHistoryManager; + $this->blacklistService = $blacklistService; $this->unsubscribeThreshold = $unsubscribeThreshold; $this->blacklistThreshold = $blacklistThreshold; } @@ -129,7 +129,7 @@ private function applyThresholdActions($user, int $consecutive, bool $alreadyUns } if ($this->blacklistThreshold > 0 && $consecutive >= $this->blacklistThreshold) { - $this->subscriberManager->blacklist( + $this->blacklistService->blacklist( subscriber: $user, reason: sprintf('%d consecutive bounces, threshold reached', $consecutive) ); diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php index 9df3deb2..38b99beb 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php @@ -6,22 +6,22 @@ use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; +use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; class BlacklistEmailAndDeleteBounceHandler implements BounceActionHandlerInterface { private SubscriberHistoryManager $subscriberHistoryManager; - private SubscriberManager $subscriberManager; private BounceManager $bounceManager; + private SubscriberBlacklistService $blacklistService; public function __construct( SubscriberHistoryManager $subscriberHistoryManager, - SubscriberManager $subscriberManager, BounceManager $bounceManager, + SubscriberBlacklistService $blacklistService ) { $this->subscriberHistoryManager = $subscriberHistoryManager; - $this->subscriberManager = $subscriberManager; $this->bounceManager = $bounceManager; + $this->blacklistService = $blacklistService; } public static function supports(string $action): bool @@ -32,9 +32,9 @@ public static function supports(string $action): bool public function handle(array $closureData): void { if (!empty($closureData['subscriber'])) { - $this->subscriberManager->blacklist( - $closureData['subscriber'], - 'Email address auto blacklisted by bounce rule '.$closureData['ruleId'] + $this->blacklistService->blacklist( + subscriber: $closureData['subscriber'], + reason: 'Email address auto blacklisted by bounce rule '.$closureData['ruleId'] ); $this->subscriberHistoryManager->addHistory( $closureData['subscriber'], diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php index 1d482817..806e3369 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php @@ -5,19 +5,19 @@ namespace PhpList\Core\Domain\Messaging\Service\Handler; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; +use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; class BlacklistEmailHandler implements BounceActionHandlerInterface { private SubscriberHistoryManager $subscriberHistoryManager; - private SubscriberManager $subscriberManager; + private SubscriberBlacklistService $blacklistService; public function __construct( SubscriberHistoryManager $subscriberHistoryManager, - SubscriberManager $subscriberManager, + SubscriberBlacklistService $blacklistService, ) { $this->subscriberHistoryManager = $subscriberHistoryManager; - $this->subscriberManager = $subscriberManager; + $this->blacklistService = $blacklistService; } public static function supports(string $action): bool @@ -28,7 +28,7 @@ public static function supports(string $action): bool public function handle(array $closureData): void { if (!empty($closureData['subscriber'])) { - $this->subscriberManager->blacklist( + $this->blacklistService->blacklist( $closureData['subscriber'], 'Email address auto blacklisted by bounce rule '.$closureData['ruleId'] ); diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php index ed1698df..048bf139 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php @@ -6,22 +6,22 @@ use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; +use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; class BlacklistUserAndDeleteBounceHandler implements BounceActionHandlerInterface { private SubscriberHistoryManager $subscriberHistoryManager; - private SubscriberManager $subscriberManager; private BounceManager $bounceManager; + private SubscriberBlacklistService $blacklistService; public function __construct( SubscriberHistoryManager $subscriberHistoryManager, - SubscriberManager $subscriberManager, BounceManager $bounceManager, + SubscriberBlacklistService $blacklistService, ) { $this->subscriberHistoryManager = $subscriberHistoryManager; - $this->subscriberManager = $subscriberManager; $this->bounceManager = $bounceManager; + $this->blacklistService = $blacklistService; } public static function supports(string $action): bool @@ -32,14 +32,14 @@ public static function supports(string $action): bool public function handle(array $closureData): void { if (!empty($closureData['subscriber']) && !$closureData['blacklisted']) { - $this->subscriberManager->blacklist( - $closureData['subscriber'], - 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId'] + $this->blacklistService->blacklist( + subscriber: $closureData['subscriber'], + reason: 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId'] ); $this->subscriberHistoryManager->addHistory( - $closureData['subscriber'], - 'Auto Unsubscribed', - 'User auto unsubscribed for bounce rule '.$closureData['ruleId'] + subscriber: $closureData['subscriber'], + message: 'Auto Unsubscribed', + details: 'User auto unsubscribed for bounce rule '.$closureData['ruleId'] ); } $this->bounceManager->delete($closureData['bounce']); diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php index b4282e83..028823fa 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php @@ -5,19 +5,19 @@ namespace PhpList\Core\Domain\Messaging\Service\Handler; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; +use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; class BlacklistUserHandler implements BounceActionHandlerInterface { private SubscriberHistoryManager $subscriberHistoryManager; - private SubscriberManager $subscriberManager; + private SubscriberBlacklistService $blacklistService; public function __construct( SubscriberHistoryManager $subscriberHistoryManager, - SubscriberManager $subscriberManager, + SubscriberBlacklistService $blacklistService, ) { $this->subscriberHistoryManager = $subscriberHistoryManager; - $this->subscriberManager = $subscriberManager; + $this->blacklistService = $blacklistService; } public static function supports(string $action): bool @@ -28,14 +28,14 @@ public static function supports(string $action): bool public function handle(array $closureData): void { if (!empty($closureData['subscriber']) && !$closureData['blacklisted']) { - $this->subscriberManager->blacklist( - $closureData['subscriber'], - 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId'] + $this->blacklistService->blacklist( + subscriber: $closureData['subscriber'], + reason: 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId'] ); $this->subscriberHistoryManager->addHistory( - $closureData['subscriber'], - 'Auto Unsubscribed', - 'User auto unsubscribed for bounce rule '.$closureData['ruleId'] + subscriber: $closureData['subscriber'], + message: 'Auto Unsubscribed', + details: 'User auto unsubscribed for bounce rule '.$closureData['ruleId'] ); } } diff --git a/src/Domain/Messaging/Service/Manager/BounceManager.php b/src/Domain/Messaging/Service/Manager/BounceManager.php index 0a1e5758..f13c46ff 100644 --- a/src/Domain/Messaging/Service/Manager/BounceManager.php +++ b/src/Domain/Messaging/Service/Manager/BounceManager.php @@ -6,6 +6,7 @@ use DateTime; use DateTimeImmutable; +use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Model\UserMessage; use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; @@ -16,19 +17,23 @@ class BounceManager { + private const TEST_MODE_MESSAGE = 'Running in test mode, not deleting messages from mailbox'; + private const LIVE_MODE_MESSAGE = 'Processed messages will be deleted from the mailbox'; + private BounceRepository $bounceRepository; private UserMessageBounceRepository $userMessageBounceRepo; + private EntityManagerInterface $entityManager; private LoggerInterface $logger; - private const TEST_MODE_MESSAGE = 'Running in test mode, not deleting messages from mailbox'; - private const LIVE_MODE_MESSAGE = 'Processed messages will be deleted from the mailbox'; public function __construct( BounceRepository $bounceRepository, UserMessageBounceRepository $userMessageBounceRepo, - LoggerInterface $logger + EntityManagerInterface $entityManager, + LoggerInterface $logger, ) { $this->bounceRepository = $bounceRepository; $this->userMessageBounceRepo = $userMessageBounceRepo; + $this->entityManager = $entityManager; $this->logger = $logger; } @@ -88,6 +93,7 @@ public function linkUserMessageBounce( $userMessageBounce = new UserMessageBounce($bounce->getId(), new DateTime($date->format('Y-m-d H:i:s'))); $userMessageBounce->setUserId($subscriberId); $userMessageBounce->setMessageId($messageId); + $this->entityManager->flush(); return $userMessageBounce; } diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php index 8ae48964..73531fbb 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php @@ -22,20 +22,17 @@ class SubscriberManager private EntityManagerInterface $entityManager; private MessageBusInterface $messageBus; private SubscriberDeletionService $subscriberDeletionService; - private SubscriberBlacklistService $blacklistService; public function __construct( SubscriberRepository $subscriberRepository, EntityManagerInterface $entityManager, MessageBusInterface $messageBus, SubscriberDeletionService $subscriberDeletionService, - SubscriberBlacklistService $blacklistService ) { $this->subscriberRepository = $subscriberRepository; $this->entityManager = $entityManager; $this->messageBus = $messageBus; $this->subscriberDeletionService = $subscriberDeletionService; - $this->blacklistService = $blacklistService; } public function createSubscriber(CreateSubscriberDto $subscriberDto): Subscriber @@ -139,11 +136,6 @@ public function updateFromImport(Subscriber $existingSubscriber, ImportSubscribe return $existingSubscriber; } - public function blacklist(Subscriber $subscriber, string $reason): void - { - $this->blacklistService->blacklist($subscriber, $reason); - } - public function decrementBounceCount(Subscriber $subscriber): void { $subscriber->addToBounceCount(-1); diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php index 9d076a4b..8000b1c3 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager; use DateTimeImmutable; +use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Repository\BounceRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository; @@ -25,7 +26,8 @@ protected function setUp(): void $this->manager = new BounceManager( bounceRepository: $this->repository, userMessageBounceRepo: $userMessageBounceRepository, - logger: $this->createMock(LoggerInterface::class) + entityManager: $this->createMock(EntityManagerInterface::class), + logger: $this->createMock(LoggerInterface::class), ); } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php index f4ad3eef..b7a99366 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php @@ -10,7 +10,6 @@ use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; -use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -36,7 +35,6 @@ protected function setUp(): void entityManager: $this->entityManager, messageBus: $this->messageBus, subscriberDeletionService: $subscriberDeletionService, - blacklistService: $this->createMock(SubscriberBlacklistService::class), ); } From 2d9f46be1af2076da0f99292c949b8329a20a5b9 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 28 Aug 2025 22:20:33 +0400 Subject: [PATCH 22/24] Add tests --- .../Command/ProcessBouncesCommand.php | 32 ++- .../Service/BounceActionResolver.php | 2 +- .../BlacklistEmailAndDeleteBounceHandler.php | 2 +- .../Service/Handler/BlacklistEmailHandler.php | 2 +- .../BlacklistUserAndDeleteBounceHandler.php | 2 +- .../Service/Handler/BlacklistUserHandler.php | 2 +- .../Handler/BounceActionHandlerInterface.php | 2 +- ...CountConfirmUserAndDeleteBounceHandler.php | 2 +- .../Service/Handler/DeleteBounceHandler.php | 2 +- .../Handler/DeleteUserAndBounceHandler.php | 2 +- .../Service/Handler/DeleteUserHandler.php | 2 +- .../UnconfirmUserAndDeleteBounceHandler.php | 2 +- .../Service/Handler/UnconfirmUserHandler.php | 2 +- src/Domain/Messaging/Service/LockService.php | 2 + .../Domain/Common/ClientIpResolverTest.php | 61 +++++ .../Domain/Common/SystemInfoCollectorTest.php | 95 ++++++++ .../Command/ProcessBouncesCommandTest.php | 197 ++++++++++++++++ .../Service/BounceActionResolverTest.php | 66 ++++++ .../Service/ConsecutiveBounceHandlerTest.php | 212 ++++++++++++++++++ ...acklistEmailAndDeleteBounceHandlerTest.php | 78 +++++++ .../Handler/BlacklistEmailHandlerTest.php | 73 ++++++ ...lacklistUserAndDeleteBounceHandlerTest.php | 90 ++++++++ .../Handler/BlacklistUserHandlerTest.php | 84 +++++++ ...tConfirmUserAndDeleteBounceHandlerTest.php | 103 +++++++++ .../Handler/DeleteBounceHandlerTest.php | 40 ++++ .../DeleteUserAndBounceHandlerTest.php | 63 ++++++ .../Service/Handler/DeleteUserHandlerTest.php | 71 ++++++ ...nconfirmUserAndDeleteBounceHandlerTest.php | 90 ++++++++ .../Handler/UnconfirmUserHandlerTest.php | 77 +++++++ 29 files changed, 1434 insertions(+), 24 deletions(-) create mode 100644 tests/Unit/Domain/Common/ClientIpResolverTest.php create mode 100644 tests/Unit/Domain/Common/SystemInfoCollectorTest.php create mode 100644 tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php diff --git a/src/Domain/Messaging/Command/ProcessBouncesCommand.php b/src/Domain/Messaging/Command/ProcessBouncesCommand.php index 7c01ea4b..f1e3b403 100644 --- a/src/Domain/Messaging/Command/ProcessBouncesCommand.php +++ b/src/Domain/Messaging/Command/ProcessBouncesCommand.php @@ -21,6 +21,10 @@ #[AsCommand(name: 'phplist:bounces:process', description: 'Process bounce mailbox')] class ProcessBouncesCommand extends Command { + private const IMAP_NOT_AVAILABLE = 'PHP IMAP extension not available. Falling back to Webklex IMAP.'; + private const FORCE_LOCK_FAILED = 'Could not apply force lock. Aborting.'; + private const ALREADY_LOCKED = 'Another bounce processing is already running. Aborting.'; + protected function configure(): void { $this @@ -53,16 +57,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $inputOutput = new SymfonyStyle($input, $output); if (!function_exists('imap_open')) { - $inputOutput->note('PHP IMAP extension not available. Falling back to Webklex IMAP where applicable.'); + $inputOutput->note(self::IMAP_NOT_AVAILABLE); } $force = (bool)$input->getOption('force'); $lock = $this->lockService->acquirePageLock('bounce_processor', $force); - if (!$lock) { - $inputOutput->warning('Another bounce processing is already running. Aborting.'); + if (($lock ?? 0) === 0) { + $inputOutput->warning($force ? self::FORCE_LOCK_FAILED : self::ALREADY_LOCKED); - return Command::SUCCESS; + return $force ? Command::FAILURE : Command::SUCCESS; } try { @@ -71,14 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $downloadReport = ''; - $processor = null; - foreach ($this->protocolProcessors as $p) { - if ($p->getProtocol() === $protocol) { - $processor = $p; - break; - } - } - + $processor = $this->findProcessorFor($protocol); if ($processor === null) { $inputOutput->error('Unsupported protocol: '.$protocol); @@ -103,4 +100,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->lockService->release($lock); } } + + private function findProcessorFor(string $protocol): ?BounceProtocolProcessor + { + foreach ($this->protocolProcessors as $processor) { + if ($processor->getProtocol() === $protocol) { + return $processor; + } + } + + return null; + } } diff --git a/src/Domain/Messaging/Service/BounceActionResolver.php b/src/Domain/Messaging/Service/BounceActionResolver.php index 83969c1f..93d432dd 100644 --- a/src/Domain/Messaging/Service/BounceActionResolver.php +++ b/src/Domain/Messaging/Service/BounceActionResolver.php @@ -55,7 +55,7 @@ public function handle(string $action, array $context): void private function find(string $action): ?BounceActionHandlerInterface { foreach ($this->handlers as $handler) { - if ($handler::supports($action)) { + if ($handler->supports($action)) { return $handler; } } diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php index 38b99beb..d32cf68b 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php @@ -24,7 +24,7 @@ public function __construct( $this->blacklistService = $blacklistService; } - public static function supports(string $action): bool + public function supports(string $action): bool { return $action === 'blacklistemailanddeletebounce'; } diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php index 806e3369..9a92088c 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php @@ -20,7 +20,7 @@ public function __construct( $this->blacklistService = $blacklistService; } - public static function supports(string $action): bool + public function supports(string $action): bool { return $action === 'blacklistemail'; } diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php index 048bf139..b017fe9c 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php @@ -24,7 +24,7 @@ public function __construct( $this->blacklistService = $blacklistService; } - public static function supports(string $action): bool + public function supports(string $action): bool { return $action === 'blacklistuseranddeletebounce'; } diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php index 028823fa..75c8b810 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php @@ -20,7 +20,7 @@ public function __construct( $this->blacklistService = $blacklistService; } - public static function supports(string $action): bool + public function supports(string $action): bool { return $action === 'blacklistuser'; } diff --git a/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php b/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php index abbd2210..6b90cb49 100644 --- a/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php +++ b/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php @@ -6,6 +6,6 @@ interface BounceActionHandlerInterface { - public static function supports(string $action): bool; + public function supports(string $action): bool; public function handle(array $closureData): void; } diff --git a/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php index f5986627..a8ecdfb5 100644 --- a/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php @@ -28,7 +28,7 @@ public function __construct( $this->subscriberRepository = $subscriberRepository; } - public static function supports(string $action): bool + public function supports(string $action): bool { return $action === 'decreasecountconfirmuseranddeletebounce'; } diff --git a/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php index 26cffda2..80c881a1 100644 --- a/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php @@ -15,7 +15,7 @@ public function __construct(BounceManager $bounceManager) $this->bounceManager = $bounceManager; } - public static function supports(string $action): bool + public function supports(string $action): bool { return $action === 'deletebounce'; } diff --git a/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php b/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php index 258a5974..d8887545 100644 --- a/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php @@ -18,7 +18,7 @@ public function __construct(BounceManager $bounceManager, SubscriberManager $sub $this->subscriberManager = $subscriberManager; } - public static function supports(string $action): bool + public function supports(string $action): bool { return $action === 'deleteuserandbounce'; } diff --git a/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php b/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php index ca29b4e4..64b1a073 100644 --- a/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php +++ b/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php @@ -18,7 +18,7 @@ public function __construct(SubscriberManager $subscriberManager, LoggerInterfac $this->logger = $logger; } - public static function supports(string $action): bool + public function supports(string $action): bool { return $action === 'deleteuser'; } diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php index e2da913c..7ca39be8 100644 --- a/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php @@ -24,7 +24,7 @@ public function __construct( $this->bounceManager = $bounceManager; } - public static function supports(string $action): bool + public function supports(string $action): bool { return $action === 'unconfirmuseranddeletebounce'; } diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php index 99b37c5a..a5bdd0fe 100644 --- a/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php +++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php @@ -20,7 +20,7 @@ public function __construct( $this->subscriberHistoryManager = $subscriberHistoryManager; } - public static function supports(string $action): bool + public function supports(string $action): bool { return $action === 'unconfirmuser'; } diff --git a/src/Domain/Messaging/Service/LockService.php b/src/Domain/Messaging/Service/LockService.php index d912dc04..d2f1eb34 100644 --- a/src/Domain/Messaging/Service/LockService.php +++ b/src/Domain/Messaging/Service/LockService.php @@ -68,11 +68,13 @@ public function acquirePageLock( if ($isCli) { $this->logger->info("Running commandline, quitting. We'll find out what to do in the next run."); + return null; } if (!$this->waitOrGiveUp($waited)) { $this->logger->info('We have been waiting too long, I guess the other process is still going ok'); + return null; } diff --git a/tests/Unit/Domain/Common/ClientIpResolverTest.php b/tests/Unit/Domain/Common/ClientIpResolverTest.php new file mode 100644 index 00000000..e69e9f89 --- /dev/null +++ b/tests/Unit/Domain/Common/ClientIpResolverTest.php @@ -0,0 +1,61 @@ +requestStack = $this->createMock(RequestStack::class); + } + + public function testResolveReturnsClientIpFromCurrentRequest(): void + { + $request = $this->createMock(Request::class); + $request->method('getClientIp')->willReturn('203.0.113.10'); + + $this->requestStack + ->method('getCurrentRequest') + ->willReturn($request); + + $resolver = new ClientIpResolver($this->requestStack); + $this->assertSame('203.0.113.10', $resolver->resolve()); + } + + public function testResolveReturnsEmptyStringWhenClientIpIsNull(): void + { + $request = $this->createMock(Request::class); + $request->method('getClientIp')->willReturn(null); + + $this->requestStack + ->method('getCurrentRequest') + ->willReturn($request); + + $resolver = new ClientIpResolver($this->requestStack); + $this->assertSame('', $resolver->resolve()); + } + + public function testResolveReturnsHostAndPidWhenNoRequestAvailable(): void + { + $this->requestStack + ->method('getCurrentRequest') + ->willReturn(null); + + $resolver = new ClientIpResolver($this->requestStack); + + $expectedHost = gethostname() ?: 'localhost'; + $expected = $expectedHost . ':' . getmypid(); + + $this->assertSame($expected, $resolver->resolve()); + } +} diff --git a/tests/Unit/Domain/Common/SystemInfoCollectorTest.php b/tests/Unit/Domain/Common/SystemInfoCollectorTest.php new file mode 100644 index 00000000..7bf964d7 --- /dev/null +++ b/tests/Unit/Domain/Common/SystemInfoCollectorTest.php @@ -0,0 +1,95 @@ +requestStack = $this->createMock(RequestStack::class); + } + + public function testCollectReturnsSanitizedPairsWithDefaults(): void + { + $server = [ + 'HTTP_USER_AGENT' => 'Agent X"', + 'HTTP_REFERER' => 'https://example.com/?q=', + 'HTTP_X_FORWARDED_FOR' => '198.51.100.5, 203.0.113.7', + 'REQUEST_URI' => '/path?x=1&y="z"', + 'REMOTE_ADDR' => '203.0.113.10', + ]; + $request = new Request(query: [], request: [], attributes: [], cookies: [], files: [], server: $server); + + $this->requestStack->method('getCurrentRequest')->willReturn($request); + + $collector = new SystemInfoCollector($this->requestStack); + $result = $collector->collect(); + + $expected = [ + 'HTTP_USER_AGENT' => 'Agent <b>X</b>"', + 'HTTP_REFERER' => 'https://example.com/?q=<script>alert(1)</script>', + 'REMOTE_ADDR' => '203.0.113.10', + 'REQUEST_URI' => '/path?x=1&y="z"<w>', + 'HTTP_X_FORWARDED_FOR' => '198.51.100.5, 203.0.113.7', + ]; + + $this->assertSame($expected, $result); + } + + public function testCollectUsesConfiguredKeysAndSkipsMissing(): void + { + $server = [ + 'HTTP_USER_AGENT' => 'UA', + 'REQUEST_URI' => '/only/uri', + 'REMOTE_ADDR' => '198.51.100.10', + ]; + $request = new Request(query: [], request: [], attributes: [], cookies: [], files: [], server: $server); + $this->requestStack->method('getCurrentRequest')->willReturn($request); + + $collector = new SystemInfoCollector($this->requestStack, ['REQUEST_URI', 'UNKNOWN', 'REMOTE_ADDR']); + $result = $collector->collect(); + + $expected = [ + 'REQUEST_URI' => '/only/uri', + 'REMOTE_ADDR' => '198.51.100.10', + ]; + + $this->assertSame($expected, $result); + } + + public function testCollectAsStringFormatsLinesWithLeadingNewline(): void + { + $server = [ + 'HTTP_USER_AGENT' => 'UA', + 'HTTP_REFERER' => 'https://ref.example', + 'REMOTE_ADDR' => '192.0.2.5', + 'REQUEST_URI' => '/abc', + 'HTTP_X_FORWARDED_FOR' => '1.1.1.1', + ]; + $request = new Request(query: [], request: [], attributes: [], cookies: [], files: [], server: $server); + $this->requestStack->method('getCurrentRequest')->willReturn($request); + + $collector = new SystemInfoCollector($this->requestStack); + $string = $collector->collectAsString(); + + $expected = "\n" . implode("\n", [ + 'HTTP_USER_AGENT = UA', + 'HTTP_REFERER = https://ref.example', + 'REMOTE_ADDR = 192.0.2.5', + 'REQUEST_URI = /abc', + 'HTTP_X_FORWARDED_FOR = 1.1.1.1', + ]); + + $this->assertSame($expected, $string); + } +} diff --git a/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php new file mode 100644 index 00000000..50cce9fa --- /dev/null +++ b/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php @@ -0,0 +1,197 @@ +lockService = $this->createMock(LockService::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->protocolProcessor = $this->createMock(BounceProtocolProcessor::class); + $this->advancedRulesProcessor = $this->createMock(AdvancedBounceRulesProcessor::class); + $this->unidentifiedReprocessor = $this->createMock(UnidentifiedBounceReprocessor::class); + $this->consecutiveBounceHandler = $this->createMock(ConsecutiveBounceHandler::class); + + $command = new ProcessBouncesCommand( + lockService: $this->lockService, + logger: $this->logger, + protocolProcessors: [$this->protocolProcessor], + advancedRulesProcessor: $this->advancedRulesProcessor, + unidentifiedReprocessor: $this->unidentifiedReprocessor, + consecutiveBounceHandler: $this->consecutiveBounceHandler, + ); + + $this->commandTester = new CommandTester($command); + } + + public function testExecuteWhenLockNotAcquired(): void + { + $this->lockService->expects($this->once()) + ->method('acquirePageLock') + ->with('bounce_processor', false) + ->willReturn(null); + + $this->protocolProcessor->expects($this->never())->method('getProtocol'); + $this->protocolProcessor->expects($this->never())->method('process'); + $this->unidentifiedReprocessor->expects($this->never())->method('process'); + $this->advancedRulesProcessor->expects($this->never())->method('process'); + $this->consecutiveBounceHandler->expects($this->never())->method('handle'); + + $this->commandTester->execute([]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Another bounce processing is already running. Aborting.', $output); + $this->assertSame(0, $this->commandTester->getStatusCode()); + } + + public function testExecuteWithUnsupportedProtocol(): void + { + $this->lockService + ->expects($this->once()) + ->method('acquirePageLock') + ->with('bounce_processor', false) + ->willReturn(123); + $this->lockService + ->expects($this->once()) + ->method('release') + ->with(123); + + $this->protocolProcessor->method('getProtocol')->willReturn('pop'); + $this->protocolProcessor->expects($this->never())->method('process'); + + $this->commandTester->execute([ + '--protocol' => 'mbox', + ]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Unsupported protocol: mbox', $output); + $this->assertSame(1, $this->commandTester->getStatusCode()); + } + + public function testSuccessfulProcessingFlow(): void + { + $this->lockService + ->expects($this->once()) + ->method('acquirePageLock') + ->with('bounce_processor', false) + ->willReturn(456); + $this->lockService + ->expects($this->once()) + ->method('release') + ->with(456); + + $this->protocolProcessor->method('getProtocol')->willReturn('pop'); + $this->protocolProcessor + ->expects($this->once()) + ->method('process') + ->with( + $this->callback(function ($input) { + return $input->getOption('protocol') === 'pop' + && $input->getOption('test') === false + && $input->getOption('purge-unprocessed') === false; + }), + $this->anything() + ) + ->willReturn('downloaded 10 messages'); + + $this->unidentifiedReprocessor + ->expects($this->once()) + ->method('process') + ->with($this->anything()); + + $this->advancedRulesProcessor + ->expects($this->once()) + ->method('process') + ->with($this->anything(), 1000); + + $this->consecutiveBounceHandler + ->expects($this->once()) + ->method('handle') + ->with($this->anything()); + + $this->logger + ->expects($this->once()) + ->method('info') + ->with('Bounce processing completed', $this->arrayHasKey('downloadReport')); + + $this->commandTester->execute([]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Bounce processing completed.', $output); + $this->assertSame(0, $this->commandTester->getStatusCode()); + } + + public function testProcessingFlowWhenProcessorThrowsException(): void + { + $this->lockService + ->expects($this->once()) + ->method('acquirePageLock') + ->with('bounce_processor', false) + ->willReturn(42); + $this->lockService + ->expects($this->once()) + ->method('release') + ->with(42); + + $this->protocolProcessor->method('getProtocol')->willReturn('pop'); + + $this->protocolProcessor + ->expects($this->once()) + ->method('process') + ->willThrowException(new Exception('boom')); + + $this->unidentifiedReprocessor->expects($this->never())->method('process'); + $this->advancedRulesProcessor->expects($this->never())->method('process'); + $this->consecutiveBounceHandler->expects($this->never())->method('handle'); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with('Bounce processing failed', $this->arrayHasKey('exception')); + + $this->commandTester->execute([]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Error: boom', $output); + $this->assertSame(1, $this->commandTester->getStatusCode()); + } + + public function testForceOptionIsPassedToLockService(): void + { + $this->lockService->expects($this->once()) + ->method('acquirePageLock') + ->with('bounce_processor', true) + ->willReturn(1); + $this->protocolProcessor->method('getProtocol')->willReturn('pop'); + + $this->commandTester->execute([ + '--force' => true, + ]); + + $this->assertSame(0, $this->commandTester->getStatusCode()); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php b/tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php new file mode 100644 index 00000000..49d4aadb --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php @@ -0,0 +1,66 @@ +fooHandler = $this->createMock(BounceActionHandlerInterface::class); + $this->barHandler = $this->createMock(BounceActionHandlerInterface::class); + $this->fooHandler->method('supports')->willReturnCallback(fn ($action) => $action === 'foo'); + $this->barHandler->method('supports')->willReturnCallback(fn ($action) => $action === 'bar'); + + $this->resolver = new BounceActionResolver( + [ + $this->fooHandler, + $this->barHandler, + ] + ); + } + + public function testHasReturnsTrueWhenHandlerSupportsAction(): void + { + $this->assertTrue($this->resolver->has('foo')); + $this->assertTrue($this->resolver->has('bar')); + $this->assertFalse($this->resolver->has('baz')); + } + + public function testResolveReturnsSameInstanceAndCaches(): void + { + $first = $this->resolver->resolve('foo'); + $second = $this->resolver->resolve('foo'); + + $this->assertSame($first, $second); + + $this->assertInstanceOf(BounceActionHandlerInterface::class, $first); + } + + public function testResolveThrowsWhenNoHandlerFound(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No handler found for action "baz".'); + + $this->resolver->resolve('baz'); + } + + public function testHandleDelegatesToResolvedHandler(): void + { + $context = ['key' => 'value', 'n' => 42]; + $this->fooHandler->expects($this->once())->method('handle'); + $this->barHandler->expects($this->never())->method('handle'); + $this->resolver->handle('foo', $context); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php new file mode 100644 index 00000000..1cb1b6d2 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php @@ -0,0 +1,212 @@ +bounceManager = $this->createMock(BounceManager::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->subscriberHistoryManager = $this->createMock(SubscriberHistoryManager::class); + $this->blacklistService = $this->createMock(SubscriberBlacklistService::class); + $this->io = $this->createMock(SymfonyStyle::class); + + $this->io->method('section'); + $this->io->method('writeln'); + + $unsubscribeThreshold = 2; + $blacklistThreshold = 3; + + $this->handler = new ConsecutiveBounceHandler( + bounceManager: $this->bounceManager, + subscriberRepository: $this->subscriberRepository, + subscriberHistoryManager: $this->subscriberHistoryManager, + blacklistService: $this->blacklistService, + unsubscribeThreshold: $unsubscribeThreshold, + blacklistThreshold: $blacklistThreshold, + ); + } + + public function testHandleWithNoUsers(): void + { + $this->subscriberRepository + ->expects($this->once()) + ->method('distinctUsersWithBouncesConfirmedNotBlacklisted') + ->willReturn([]); + + $this->io->expects($this->once())->method('section')->with('Identifying consecutive bounces'); + $this->io->expects($this->once())->method('writeln')->with('Nothing to do'); + + $this->handler->handle($this->io); + } + + public function testUnsubscribeAtThresholdAddsHistoryAndMarksUnconfirmedOnce(): void + { + $user = $this->makeSubscriber(123); + $this->subscriberRepository + ->method('distinctUsersWithBouncesConfirmedNotBlacklisted') + ->willReturn([$user]); + + $history = [ + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(1)], + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(2)], + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(0)], + ]; + $this->bounceManager + ->expects($this->once()) + ->method('getUserMessageHistoryWithBounces') + ->with($user) + ->willReturn($history); + + $this->subscriberRepository + ->expects($this->once()) + ->method('markUnconfirmed') + ->with(123); + + $this->subscriberHistoryManager + ->expects($this->once()) + ->method('addHistory') + ->with( + $user, + 'Auto Unconfirmed', + $this->stringContains('2 consecutive bounces') + ); + + $this->blacklistService->expects($this->never())->method('blacklist'); + + $this->io->expects($this->once())->method('section')->with('Identifying consecutive bounces'); + $this->io->expects($this->once())->method('writeln')->with('total of 1 subscribers processed'); + + $this->handler->handle($this->io); + } + + public function testBlacklistAtThresholdStopsProcessingAndAlsoUnsubscribesIfReached(): void + { + $user = $this->makeSubscriber(7); + $this->subscriberRepository + ->method('distinctUsersWithBouncesConfirmedNotBlacklisted') + ->willReturn([$user]); + + $history = [ + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(11)], + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(12)], + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(13)], + // Any further entries should be ignored after blacklist stop + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(14)], + ]; + $this->bounceManager + ->expects($this->once()) + ->method('getUserMessageHistoryWithBounces') + ->with($user) + ->willReturn($history); + + // Unsubscribe reached at 2 + $this->subscriberRepository + ->expects($this->once()) + ->method('markUnconfirmed') + ->with(7); + + $this->subscriberHistoryManager + ->expects($this->once()) + ->method('addHistory') + ->with( + $user, + 'Auto Unconfirmed', + $this->stringContains('consecutive bounces') + ); + + // Blacklist at 3 + $this->blacklistService + ->expects($this->once()) + ->method('blacklist') + ->with( + $user, + $this->stringContains('3 consecutive bounces') + ); + + $this->handler->handle($this->io); + } + + public function testDuplicateBouncesAreIgnoredInCounting(): void + { + $user = $this->makeSubscriber(55); + $this->subscriberRepository->method('distinctUsersWithBouncesConfirmedNotBlacklisted')->willReturn([$user]); + + // First is duplicate (by status), ignored; then two real => unsubscribe triggered once + $history = [ + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(101, status: 'DUPLICATE bounce')], + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(102, comment: 'ok')], + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(103)], + ]; + $this->bounceManager->method('getUserMessageHistoryWithBounces')->willReturn($history); + + $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(55); + $this->subscriberHistoryManager->expects($this->once())->method('addHistory')->with( + $user, + 'Auto Unconfirmed', + $this->stringContains('2 consecutive bounces') + ); + $this->blacklistService->expects($this->never())->method('blacklist'); + + $this->handler->handle($this->io); + } + + public function testBreaksOnBounceWithoutRealId(): void + { + $user = $this->makeSubscriber(77); + $this->subscriberRepository->method('distinctUsersWithBouncesConfirmedNotBlacklisted')->willReturn([$user]); + + // The first entry has null bounce (no real id) => processing for the user stops immediately; no actions + $history = [ + ['um' => null, 'umb' => null, 'b' => null], + // should not be reached + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(1)], + ]; + $this->bounceManager->method('getUserMessageHistoryWithBounces')->willReturn($history); + + $this->subscriberRepository->expects($this->never())->method('markUnconfirmed'); + $this->subscriberHistoryManager->expects($this->never())->method('addHistory'); + $this->blacklistService->expects($this->never())->method('blacklist'); + + $this->handler->handle($this->io); + } + + private function makeSubscriber(int $id): Subscriber + { + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getId')->willReturn($id); + + return $subscriber; + } + + private function makeBounce(int $id, ?string $status = null, ?string $comment = null): Bounce + { + $bounce = $this->createMock(Bounce::class); + $bounce->method('getId')->willReturn($id); + $bounce->method('getStatus')->willReturn($status); + $bounce->method('getComment')->willReturn($comment); + + return $bounce; + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php new file mode 100644 index 00000000..8f5cdb11 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php @@ -0,0 +1,78 @@ +historyManager = $this->createMock(SubscriberHistoryManager::class); + $this->bounceManager = $this->createMock(BounceManager::class); + $this->blacklistService = $this->createMock(SubscriberBlacklistService::class); + $this->handler = new BlacklistEmailAndDeleteBounceHandler( + subscriberHistoryManager: $this->historyManager, + bounceManager: $this->bounceManager, + blacklistService: $this->blacklistService, + ); + } + + public function testSupportsOnlyBlacklistEmailAndDeleteBounce(): void + { + $this->assertTrue($this->handler->supports('blacklistemailanddeletebounce')); + $this->assertFalse($this->handler->supports('blacklistemail')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleBlacklistsAddsHistoryAndDeletesBounceWhenSubscriberPresent(): void + { + $subscriber = $this->createMock(Subscriber::class); + $bounce = $this->createMock(Bounce::class); + + $this->blacklistService->expects($this->once())->method('blacklist')->with( + $subscriber, + $this->stringContains('Email address auto blacklisted by bounce rule 9') + ); + $this->historyManager->expects($this->once())->method('addHistory')->with( + $subscriber, + 'Auto Unsubscribed', + $this->stringContains('User auto unsubscribed for bounce rule 9') + ); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'ruleId' => 9, + 'bounce' => $bounce, + ]); + } + + public function testHandleSkipsBlacklistAndHistoryWhenNoSubscriberButDeletesBounce(): void + { + $bounce = $this->createMock(Bounce::class); + + $this->blacklistService->expects($this->never())->method('blacklist'); + $this->historyManager->expects($this->never())->method('addHistory'); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'ruleId' => 9, + 'bounce' => $bounce, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php new file mode 100644 index 00000000..54f7362b --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php @@ -0,0 +1,73 @@ +historyManager = $this->createMock(SubscriberHistoryManager::class); + $this->blacklistService = $this->createMock(SubscriberBlacklistService::class); + $this->handler = new BlacklistEmailHandler( + subscriberHistoryManager: $this->historyManager, + blacklistService: $this->blacklistService, + ); + } + + public function testSupportsOnlyBlacklistEmail(): void + { + $this->assertTrue($this->handler->supports('blacklistemail')); + $this->assertFalse($this->handler->supports('blacklistuser')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleBlacklistsAndAddsHistoryWhenSubscriberPresent(): void + { + $subscriber = $this->createMock(Subscriber::class); + + $this->blacklistService + ->expects($this->once()) + ->method('blacklist') + ->with( + $subscriber, + $this->stringContains('Email address auto blacklisted by bounce rule 42') + ); + + $this->historyManager + ->expects($this->once()) + ->method('addHistory') + ->with( + $subscriber, + 'Auto Unsubscribed', + $this->stringContains('email auto unsubscribed for bounce rule 42') + ); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'ruleId' => 42, + ]); + } + + public function testHandleDoesNothingWhenNoSubscriber(): void + { + $this->blacklistService->expects($this->never())->method('blacklist'); + $this->historyManager->expects($this->never())->method('addHistory'); + + $this->handler->handle([ + 'ruleId' => 1, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php new file mode 100644 index 00000000..af1df32e --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php @@ -0,0 +1,90 @@ +historyManager = $this->createMock(SubscriberHistoryManager::class); + $this->bounceManager = $this->createMock(BounceManager::class); + $this->blacklistService = $this->createMock(SubscriberBlacklistService::class); + $this->handler = new BlacklistUserAndDeleteBounceHandler( + subscriberHistoryManager: $this->historyManager, + bounceManager: $this->bounceManager, + blacklistService: $this->blacklistService, + ); + } + + public function testSupportsOnlyBlacklistUserAndDeleteBounce(): void + { + $this->assertTrue($this->handler->supports('blacklistuseranddeletebounce')); + $this->assertFalse($this->handler->supports('blacklistuser')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleBlacklistsAddsHistoryAndDeletesBounceWhenSubscriberPresentAndNotBlacklisted(): void + { + $subscriber = $this->createMock(Subscriber::class); + $bounce = $this->createMock(Bounce::class); + + $this->blacklistService->expects($this->once())->method('blacklist')->with( + $subscriber, + $this->stringContains('Subscriber auto blacklisted by bounce rule 13') + ); + $this->historyManager->expects($this->once())->method('addHistory')->with( + $subscriber, + 'Auto Unsubscribed', + $this->stringContains('User auto unsubscribed for bounce rule 13') + ); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'blacklisted' => false, + 'ruleId' => 13, + 'bounce' => $bounce, + ]); + } + + public function testHandleSkipsBlacklistAndHistoryWhenNoSubscriberOrAlreadyBlacklistedButDeletesBounce(): void + { + $subscriber = $this->createMock(Subscriber::class); + $bounce = $this->createMock(Bounce::class); + + $this->blacklistService->expects($this->never())->method('blacklist'); + $this->historyManager->expects($this->never())->method('addHistory'); + $this->bounceManager->expects($this->exactly(2))->method('delete')->with($bounce); + + // Already blacklisted + $this->handler->handle([ + 'subscriber' => $subscriber, + 'blacklisted' => true, + 'ruleId' => 13, + 'bounce' => $bounce, + ]); + + // No subscriber + $this->handler->handle([ + 'blacklisted' => false, + 'ruleId' => 13, + 'bounce' => $bounce, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php new file mode 100644 index 00000000..72fe4584 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php @@ -0,0 +1,84 @@ +historyManager = $this->createMock(SubscriberHistoryManager::class); + $this->blacklistService = $this->createMock(SubscriberBlacklistService::class); + $this->handler = new BlacklistUserHandler( + subscriberHistoryManager: $this->historyManager, + blacklistService: $this->blacklistService + ); + } + + public function testSupportsOnlyBlacklistUser(): void + { + $this->assertTrue($this->handler->supports('blacklistuser')); + $this->assertFalse($this->handler->supports('unconfirmuser')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleBlacklistsAndAddsHistoryWhenSubscriberPresentAndNotBlacklisted(): void + { + $subscriber = $this->createMock(Subscriber::class); + + $this->blacklistService + ->expects($this->once()) + ->method('blacklist') + ->with( + $subscriber, + $this->stringContains('bounce rule 17') + ); + + $this->historyManager + ->expects($this->once()) + ->method('addHistory') + ->with( + $subscriber, + 'Auto Unsubscribed', + $this->stringContains('bounce rule 17') + ); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'blacklisted' => false, + 'ruleId' => 17, + ]); + } + + public function testHandleDoesNothingWhenAlreadyBlacklistedOrNoSubscriber(): void + { + $subscriber = $this->createMock(Subscriber::class); + $this->blacklistService->expects($this->never())->method('blacklist'); + $this->historyManager->expects($this->never())->method('addHistory'); + + // Already blacklisted + $this->handler->handle([ + 'subscriber' => $subscriber, + 'blacklisted' => true, + 'ruleId' => 5, + ]); + + // No subscriber provided + $this->handler->handle([ + 'blacklisted' => false, + 'ruleId' => 5, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php new file mode 100644 index 00000000..7d82336f --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php @@ -0,0 +1,103 @@ +historyManager = $this->createMock(SubscriberHistoryManager::class); + $this->subscriberManager = $this->createMock(SubscriberManager::class); + $this->bounceManager = $this->createMock(BounceManager::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->handler = new DecreaseCountConfirmUserAndDeleteBounceHandler( + subscriberHistoryManager: $this->historyManager, + subscriberManager: $this->subscriberManager, + bounceManager: $this->bounceManager, + subscriberRepository: $this->subscriberRepository, + ); + } + + public function testSupportsOnlyDecreaseCountConfirmUserAndDeleteBounce(): void + { + $this->assertTrue($this->handler->supports('decreasecountconfirmuseranddeletebounce')); + $this->assertFalse($this->handler->supports('deleteuser')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleDecrementsMarksConfirmedAddsHistoryAndDeletesWhenNotConfirmed(): void + { + $subscriber = $this->createMock(Subscriber::class); + $bounce = $this->createMock(Bounce::class); + + $this->subscriberManager->expects($this->once())->method('decrementBounceCount')->with($subscriber); + $this->subscriberRepository->expects($this->once())->method('markConfirmed')->with(11); + $this->historyManager->expects($this->once())->method('addHistory')->with( + $subscriber, + 'Auto confirmed', + $this->stringContains('bounce rule 77') + ); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'userId' => 11, + 'confirmed' => false, + 'ruleId' => 77, + 'bounce' => $bounce, + ]); + } + + public function testHandleOnlyDecrementsAndDeletesWhenAlreadyConfirmed(): void + { + $subscriber = $this->createMock(Subscriber::class); + $bounce = $this->createMock(Bounce::class); + + $this->subscriberManager->expects($this->once())->method('decrementBounceCount')->with($subscriber); + $this->subscriberRepository->expects($this->never())->method('markConfirmed'); + $this->historyManager->expects($this->never())->method('addHistory'); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'userId' => 11, + 'confirmed' => true, + 'ruleId' => 77, + 'bounce' => $bounce, + ]); + } + + public function testHandleDeletesBounceEvenWithoutSubscriber(): void + { + $bounce = $this->createMock(Bounce::class); + + $this->subscriberManager->expects($this->never())->method('decrementBounceCount'); + $this->subscriberRepository->expects($this->never())->method('markConfirmed'); + $this->historyManager->expects($this->never())->method('addHistory'); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'confirmed' => true, + 'ruleId' => 1, + 'bounce' => $bounce, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php new file mode 100644 index 00000000..25028345 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php @@ -0,0 +1,40 @@ +bounceManager = $this->createMock(BounceManager::class); + $this->handler = new DeleteBounceHandler($this->bounceManager); + } + + public function testSupportsOnlyDeleteBounce(): void + { + $this->assertTrue($this->handler->supports('deletebounce')); + $this->assertFalse($this->handler->supports('deleteuser')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleDeletesBounce(): void + { + $bounce = $this->createMock(Bounce::class); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'bounce' => $bounce, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php new file mode 100644 index 00000000..0d68b631 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php @@ -0,0 +1,63 @@ +bounceManager = $this->createMock(BounceManager::class); + $this->subscriberManager = $this->createMock(SubscriberManager::class); + $this->handler = new DeleteUserAndBounceHandler( + bounceManager: $this->bounceManager, + subscriberManager: $this->subscriberManager + ); + } + + public function testSupportsOnlyDeleteUserAndBounce(): void + { + $this->assertTrue($this->handler->supports('deleteuserandbounce')); + $this->assertFalse($this->handler->supports('deleteuser')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleDeletesUserWhenPresentAndAlwaysDeletesBounce(): void + { + $subscriber = $this->createMock(Subscriber::class); + $bounce = $this->createMock(Bounce::class); + + $this->subscriberManager->expects($this->once())->method('deleteSubscriber')->with($subscriber); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'bounce' => $bounce, + ]); + } + + public function testHandleSkipsUserDeletionWhenNoSubscriberButDeletesBounce(): void + { + $bounce = $this->createMock(Bounce::class); + + $this->subscriberManager->expects($this->never())->method('deleteSubscriber'); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'bounce' => $bounce, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php new file mode 100644 index 00000000..427f8146 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php @@ -0,0 +1,71 @@ +subscriberManager = $this->createMock(SubscriberManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->handler = new DeleteUserHandler(subscriberManager: $this->subscriberManager, logger: $this->logger); + } + + public function testSupportsOnlyDeleteUser(): void + { + $this->assertTrue($this->handler->supports('deleteuser')); + $this->assertFalse($this->handler->supports('deleteuserandbounce')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleLogsAndDeletesWhenSubscriberPresent(): void + { + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getEmail')->willReturn('user@example.com'); + + $this->logger + ->expects($this->once()) + ->method('info') + ->with( + 'User deleted by bounce rule', + $this->callback(function ($context) { + return isset($context['user'], $context['rule']) + && $context['user'] === 'user@example.com' + && $context['rule'] === 42; + }) + ); + + $this->subscriberManager + ->expects($this->once()) + ->method('deleteSubscriber') + ->with($subscriber); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'ruleId' => 42, + ]); + } + + public function testHandleDoesNothingWhenNoSubscriber(): void + { + $this->logger->expects($this->never())->method('info'); + $this->subscriberManager->expects($this->never())->method('deleteSubscriber'); + + $this->handler->handle([ + 'ruleId' => 1, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php new file mode 100644 index 00000000..7a4ac245 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php @@ -0,0 +1,90 @@ +historyManager = $this->createMock(SubscriberHistoryManager::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->bounceManager = $this->createMock(BounceManager::class); + $this->handler = new UnconfirmUserAndDeleteBounceHandler( + subscriberHistoryManager: $this->historyManager, + subscriberRepository: $this->subscriberRepository, + bounceManager: $this->bounceManager, + ); + } + + public function testSupportsOnlyUnconfirmUserAndDeleteBounce(): void + { + $this->assertTrue($this->handler->supports('unconfirmuseranddeletebounce')); + $this->assertFalse($this->handler->supports('unconfirmuser')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleUnconfirmsAndAddsHistoryAndDeletesBounce(): void + { + $subscriber = $this->createMock(Subscriber::class); + $bounce = $this->createMock(Bounce::class); + + $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(10); + $this->historyManager->expects($this->once())->method('addHistory')->with( + $subscriber, + 'Auto unconfirmed', + $this->stringContains('bounce rule 3') + ); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'userId' => 10, + 'confirmed' => true, + 'ruleId' => 3, + 'bounce' => $bounce, + ]); + } + + public function testHandleDeletesBounceAndSkipsUnconfirmWhenNotConfirmedOrNoSubscriber(): void + { + $subscriber = $this->createMock(Subscriber::class); + $bounce = $this->createMock(Bounce::class); + + $this->subscriberRepository->expects($this->never())->method('markUnconfirmed'); + $this->historyManager->expects($this->never())->method('addHistory'); + $this->bounceManager->expects($this->exactly(2))->method('delete')->with($bounce); + + // Not confirmed + $this->handler->handle([ + 'subscriber' => $subscriber, + 'userId' => 10, + 'confirmed' => false, + 'ruleId' => 3, + 'bounce' => $bounce, + ]); + + // No subscriber + $this->handler->handle([ + 'userId' => 10, + 'confirmed' => true, + 'ruleId' => 3, + 'bounce' => $bounce, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php new file mode 100644 index 00000000..a395e110 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php @@ -0,0 +1,77 @@ +subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->historyManager = $this->createMock(SubscriberHistoryManager::class); + $this->handler = new UnconfirmUserHandler( + subscriberRepository: $this->subscriberRepository, + subscriberHistoryManager: $this->historyManager + ); + } + + public function testSupportsOnlyUnconfirmUser(): void + { + $this->assertTrue($this->handler->supports('unconfirmuser')); + $this->assertFalse($this->handler->supports('blacklistuser')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleMarksUnconfirmedAndAddsHistoryWhenSubscriberPresentAndConfirmed(): void + { + $subscriber = $this->createMock(Subscriber::class); + + $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(123); + $this->historyManager->expects($this->once())->method('addHistory')->with( + $subscriber, + 'Auto Unconfirmed', + $this->stringContains('bounce rule 9') + ); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'userId' => 123, + 'confirmed' => true, + 'ruleId' => 9, + ]); + } + + public function testHandleDoesNothingWhenNotConfirmedOrNoSubscriber(): void + { + $subscriber = $this->createMock(Subscriber::class); + $this->subscriberRepository->expects($this->never())->method('markUnconfirmed'); + $this->historyManager->expects($this->never())->method('addHistory'); + + // Not confirmed + $this->handler->handle([ + 'subscriber' => $subscriber, + 'userId' => 44, + 'confirmed' => false, + 'ruleId' => 1, + ]); + + // No subscriber + $this->handler->handle([ + 'userId' => 44, + 'confirmed' => true, + 'ruleId' => 1, + ]); + } +} From 65253d5321154c30afcee89f53097b09e660caa9 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 1 Sep 2025 12:17:36 +0400 Subject: [PATCH 23/24] More tests --- .../Messaging/Service/LockServiceTest.php | 88 ++++++++ .../Service/Manager/BounceManagerTest.php | 114 ++++++++++- .../Service/Manager/BounceRuleManagerTest.php | 120 +++++++++++ .../Manager/SendProcessManagerTest.php | 86 ++++++++ .../Messaging/Service/MessageParserTest.php | 76 +++++++ .../AdvancedBounceRulesProcessorTest.php | 192 ++++++++++++++++++ .../Processor/BounceDataProcessorTest.php | 171 ++++++++++++++++ .../{ => Processor}/CampaignProcessorTest.php | 2 +- .../Processor/MboxBounceProcessorTest.php | 76 +++++++ .../Processor/PopBounceProcessorTest.php | 64 ++++++ .../UnidentifiedBounceReprocessorTest.php | 75 +++++++ .../Service/WebklexImapClientFactoryTest.php | 70 +++++++ 12 files changed, 1129 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/Domain/Messaging/Service/LockServiceTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/MessageParserTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php rename tests/Unit/Domain/Messaging/Service/{ => Processor}/CampaignProcessorTest.php (99%) create mode 100644 tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php diff --git a/tests/Unit/Domain/Messaging/Service/LockServiceTest.php b/tests/Unit/Domain/Messaging/Service/LockServiceTest.php new file mode 100644 index 00000000..8851d7de --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/LockServiceTest.php @@ -0,0 +1,88 @@ +repo = $this->createMock(SendProcessRepository::class); + $this->manager = $this->createMock(SendProcessManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + } + + public function testAcquirePageLockCreatesProcessWhenBelowMax(): void + { + $service = new LockService($this->repo, $this->manager, $this->logger, 600, 0, 0); + + $this->repo->method('countAliveByPage')->willReturn(0); + $this->manager->method('findNewestAliveWithAge')->willReturn(null); + + $sendProcess = $this->createConfiguredMock(SendProcess::class, ['getId' => 42]); + $this->manager->expects($this->once()) + ->method('create') + ->with('mypage', $this->callback(fn(string $id) => $id !== '')) + ->willReturn($sendProcess); + + $id = $service->acquirePageLock('my page'); + $this->assertSame(42, $id); + } + + public function testAcquirePageLockReturnsNullWhenAtMaxInCli(): void + { + $service = new LockService($this->repo, $this->manager, $this->logger, 600, 0, 0); + + $this->repo->method('countAliveByPage')->willReturn(1); + $this->manager->method('findNewestAliveWithAge')->willReturn(['age' => 1, 'id' => 10]); + + $this->logger->expects($this->atLeastOnce())->method('info'); + $id = $service->acquirePageLock('page', false, true, false, 1); + $this->assertNull($id); + } + + public function testAcquirePageLockStealsStale(): void + { + $service = new LockService($this->repo, $this->manager, $this->logger, 1, 0, 0); + + $this->repo->expects($this->exactly(2))->method('countAliveByPage')->willReturnOnConsecutiveCalls(1, 0); + $this->manager + ->expects($this->exactly(2)) + ->method('findNewestAliveWithAge') + ->willReturnOnConsecutiveCalls(['age' => 5, 'id' => 10], null); + $this->repo->expects($this->once())->method('markDeadById')->with(10); + + $sendProcess = $this->createConfiguredMock(SendProcess::class, ['getId' => 99]); + $this->manager->method('create')->willReturn($sendProcess); + + $id = $service->acquirePageLock('page', false, true); + $this->assertSame(99, $id); + } + + public function testKeepCheckReleaseDelegatesToRepo(): void + { + $service = new LockService($this->repo, $this->manager, $this->logger); + + $this->repo->expects($this->once())->method('incrementAlive')->with(5); + $service->keepLock(5); + + $this->repo->expects($this->once())->method('getAliveValue')->with(5)->willReturn(7); + $this->assertSame(7, $service->checkLock(5)); + + $this->repo->expects($this->once())->method('markDeadById')->with(5); + $service->release(5); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php index 8000b1c3..8b07f5f5 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php @@ -7,9 +7,11 @@ use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Messaging\Model\Bounce; +use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; use PhpList\Core\Domain\Messaging\Repository\BounceRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Domain\Subscription\Model\Subscriber; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -17,17 +19,22 @@ class BounceManagerTest extends TestCase { private BounceRepository&MockObject $repository; + private UserMessageBounceRepository&MockObject $userMessageBounceRepository; + private EntityManagerInterface&MockObject $entityManager; + private LoggerInterface&MockObject $logger; private BounceManager $manager; protected function setUp(): void { $this->repository = $this->createMock(BounceRepository::class); - $userMessageBounceRepository = $this->createMock(UserMessageBounceRepository::class); + $this->userMessageBounceRepository = $this->createMock(UserMessageBounceRepository::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); $this->manager = new BounceManager( bounceRepository: $this->repository, - userMessageBounceRepo: $userMessageBounceRepository, - entityManager: $this->createMock(EntityManagerInterface::class), - logger: $this->createMock(LoggerInterface::class), + userMessageBounceRepo: $this->userMessageBounceRepository, + entityManager: $this->entityManager, + logger: $this->logger, ); } @@ -102,4 +109,103 @@ public function testGetByIdReturnsNullWhenNotFound(): void $this->assertNull($this->manager->getById(999)); } + + public function testUpdateChangesFieldsAndSaves(): void + { + $bounce = new Bounce(); + $this->repository->expects($this->once()) + ->method('save') + ->with($bounce); + + $updated = $this->manager->update($bounce, 'processed', 'done'); + $this->assertSame($bounce, $updated); + $this->assertSame('processed', $bounce->getStatus()); + $this->assertSame('done', $bounce->getComment()); + } + + public function testLinkUserMessageBounceFlushesAndSetsFields(): void + { + $bounce = new Bounce(); + $this->setId($bounce, 77); + + $this->entityManager->expects($this->once())->method('flush'); + + $dt = new DateTimeImmutable('2024-05-01 12:34:56'); + $umb = $this->manager->linkUserMessageBounce($bounce, $dt, 123, 456); + + $this->assertSame(77, $umb->getBounceId()); + $this->assertSame(123, $umb->getUserId()); + $this->assertSame(456, $umb->getMessageId()); + } + + public function testExistsUserMessageBounceDelegatesToRepo(): void + { + $this->userMessageBounceRepository->expects($this->once()) + ->method('existsByMessageIdAndUserId') + ->with(456, 123) + ->willReturn(true); + + $this->assertTrue($this->manager->existsUserMessageBounce(123, 456)); + } + + public function testFindByStatusDelegatesToRepository(): void + { + $b1 = new Bounce(); + $b2 = new Bounce(); + $this->repository->expects($this->once()) + ->method('findByStatus') + ->with('new') + ->willReturn([$b1, $b2]); + + $this->assertSame([$b1, $b2], $this->manager->findByStatus('new')); + } + + public function testGetUserMessageBounceCount(): void + { + $this->userMessageBounceRepository->expects($this->once()) + ->method('count') + ->willReturn(5); + $this->assertSame(5, $this->manager->getUserMessageBounceCount()); + } + + public function testFetchUserMessageBounceBatchDelegates(): void + { + $expected = [['umb' => new UserMessageBounce(1, new \DateTime()), 'bounce' => new Bounce()]]; + $this->userMessageBounceRepository->expects($this->once()) + ->method('getPaginatedWithJoinNoRelation') + ->with(10, 50) + ->willReturn($expected); + $this->assertSame($expected, $this->manager->fetchUserMessageBounceBatch(10, 50)); + } + + public function testGetUserMessageHistoryWithBouncesDelegates(): void + { + $subscriber = new Subscriber(); + $expected = []; + $this->userMessageBounceRepository->expects($this->once()) + ->method('getUserMessageHistoryWithBounces') + ->with($subscriber) + ->willReturn($expected); + $this->assertSame($expected, $this->manager->getUserMessageHistoryWithBounces($subscriber)); + } + + public function testAnnounceDeletionModeLogsCorrectMessage(): void + { + $this->logger->expects($this->exactly(2)) + ->method('info') + ->withConsecutive([ + 'Running in test mode, not deleting messages from mailbox' + ], [ + 'Processed messages will be deleted from the mailbox' + ]); + + $this->manager->announceDeletionMode(true); + $this->manager->announceDeletionMode(false); + } + + private function setId(object $entity, int $id): void + { + $ref = new \ReflectionProperty($entity, 'id'); + $ref->setValue($entity, $id); + } } diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php new file mode 100644 index 00000000..b1073d07 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php @@ -0,0 +1,120 @@ +regexRepository = $this->createMock(BounceRegexRepository::class); + $this->relationRepository = $this->createMock(BounceRegexBounceRepository::class); + $this->manager = new BounceRuleManager($this->regexRepository, $this->relationRepository); + } + + public function testLoadActiveRulesMapsRowsAndSkipsInvalid(): void + { + $valid = new BounceRegex(regex: 'user unknown', regexHash: md5('user unknown'), action: 'delete'); + // invalids: no regex, no action, no id + $noRegex = new BounceRegex(regex: '', regexHash: md5(''), action: 'delete'); + $noAction = new BounceRegex(regex: 'pattern', regexHash: md5('pattern'), action: ''); + $noId = new BounceRegex(regex: 'has no id', regexHash: md5('has no id'), action: 'keep'); + + // Simulate id assignment for only some of them + $this->setId($valid, 1); + $this->setId($noRegex, 2); + $this->setId($noAction, 3); + // $noId intentionally left without id + + $this->regexRepository->expects($this->once()) + ->method('fetchActiveOrdered') + ->willReturn([$valid, $noRegex, $noAction, $noId]); + + $result = $this->manager->loadActiveRules(); + + $this->assertSame(['user unknown' => $valid], $result); + } + + public function testLoadAllRulesDelegatesToRepository(): void + { + $r1 = new BounceRegex(regex: 'a', regexHash: md5('a'), action: 'keep'); + $r2 = new BounceRegex(regex: 'b', regexHash: md5('b'), action: 'delete'); + $this->setId($r1, 10); + $this->setId($r2, 11); + + $this->regexRepository->expects($this->once()) + ->method('fetchAllOrdered') + ->willReturn([$r1, $r2]); + + $result = $this->manager->loadAllRules(); + $this->assertSame(['a' => $r1, 'b' => $r2], $result); + } + + public function testMatchBounceRulesMatchesQuotedAndRawAndHandlesInvalidPatterns(): void + { + $valid = new BounceRegex(regex: 'user unknown', regexHash: md5('user unknown'), action: 'delete'); + $this->setId($valid, 1); + // invalid regex pattern that would break preg_match if not handled (unbalanced bracket) + $invalid = new BounceRegex(regex: '([a-z', regexHash: md5('([a-z'), action: 'keep'); + $this->setId($invalid, 2); + + $rules = ['user unknown' => $valid, '([a-z' => $invalid]; + + $matched = $this->manager->matchBounceRules('Delivery failed: user unknown at example', $rules); + $this->assertSame($valid, $matched); + + // Ensure invalid pattern does not throw and simply not match + $matchedInvalid = $this->manager->matchBounceRules('something else', ['([a-z' => $invalid]); + $this->assertNull($matchedInvalid); + } + + public function testIncrementCountPersists(): void + { + $rule = new BounceRegex(regex: 'x', regexHash: md5('x'), action: 'keep', count: 0); + $this->setId($rule, 5); + + $this->regexRepository->expects($this->once()) + ->method('save') + ->with($rule); + + $this->manager->incrementCount($rule); + $this->assertSame(1, $rule->getCount()); + } + + public function testLinkRuleToBounceCreatesRelationAndSaves(): void + { + $rule = new BounceRegex(regex: 'y', regexHash: md5('y'), action: 'delete'); + $bounce = new Bounce(); + $this->setId($rule, 9); + $this->setId($bounce, 20); + + $this->relationRepository->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(BounceRegexBounce::class)); + + $relation = $this->manager->linkRuleToBounce($rule, $bounce); + + $this->assertInstanceOf(BounceRegexBounce::class, $relation); + $this->assertSame(9, $relation->getRegexId()); + } + + private function setId(object $entity, int $id): void + { + $ref = new \ReflectionProperty($entity, 'id'); + $ref->setValue($entity, $id); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php new file mode 100644 index 00000000..e56f11ca --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php @@ -0,0 +1,86 @@ +repository = $this->createMock(SendProcessRepository::class); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->manager = new SendProcessManager($this->repository, $this->em); + } + + public function testCreatePersistsEntityAndSetsFields(): void + { + $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(SendProcess::class)); + $this->em->expects($this->once())->method('flush'); + + $sp = $this->manager->create('pageA', 'proc-1'); + $this->assertInstanceOf(SendProcess::class, $sp); + $this->assertSame('pageA', $sp->getPage()); + $this->assertSame('proc-1', $sp->getIpaddress()); + $this->assertSame(1, $sp->getAlive()); + $this->assertInstanceOf(DateTime::class, $sp->getStartedDate()); + } + + public function testFindNewestAliveWithAgeReturnsNullWhenNotFound(): void + { + $this->repository->expects($this->once()) + ->method('findNewestAlive') + ->with('pageX') + ->willReturn(null); + + $this->assertNull($this->manager->findNewestAliveWithAge('pageX')); + } + + public function testFindNewestAliveWithAgeReturnsIdAndAge(): void + { + $model = new SendProcess(); + // set id + $this->setId($model, 42); + // set updatedAt to now - 5 seconds + $updated = new \DateTime('now'); + $updated->sub(new DateInterval('PT5S')); + $this->setUpdatedAt($model, $updated); + + $this->repository->expects($this->once()) + ->method('findNewestAlive') + ->with('pageY') + ->willReturn($model); + + $result = $this->manager->findNewestAliveWithAge('pageY'); + + $this->assertIsArray($result); + $this->assertSame(42, $result['id']); + $this->assertGreaterThanOrEqual(0, $result['age']); + $this->assertLessThan(60, $result['age']); + } + + private function setId(object $entity, int $id): void + { + $ref = new \ReflectionProperty($entity, 'id'); + $ref->setValue($entity, $id); + } + + private function setUpdatedAt(SendProcess $entity, \DateTime $dt): void + { + $ref = new \ReflectionProperty($entity, 'updatedAt'); + $ref->setValue($entity, $dt); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/MessageParserTest.php b/tests/Unit/Domain/Messaging/Service/MessageParserTest.php new file mode 100644 index 00000000..49b38615 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/MessageParserTest.php @@ -0,0 +1,76 @@ +repo = $this->createMock(SubscriberRepository::class); + } + + public function testDecodeBodyQuotedPrintable(): void + { + $parser = new MessageParser($this->repo); + $header = "Content-Transfer-Encoding: quoted-printable\r\n"; + $body = 'Hello=20World'; + $this->assertSame('Hello World', $parser->decodeBody($header, $body)); + } + + public function testDecodeBodyBase64(): void + { + $parser = new MessageParser($this->repo); + $header = "Content-Transfer-Encoding: base64\r\n"; + $body = base64_encode('hi there'); + $this->assertSame('hi there', $parser->decodeBody($header, $body)); + } + + public function testFindMessageId(): void + { + $parser = new MessageParser($this->repo); + $text = "X-MessageId: abc-123\r\nOther: x\r\n"; + $this->assertSame('abc-123', $parser->findMessageId($text)); + } + + public function testFindUserIdWithHeaderNumeric(): void + { + $parser = new MessageParser($this->repo); + $text = "X-User: 77\r\n"; + $this->assertSame(77, $parser->findUserId($text)); + } + + public function testFindUserIdWithHeaderEmailAndLookup(): void + { + $parser = new MessageParser($this->repo); + $subscriber = $this->createConfiguredMock(Subscriber::class, ['getId' => 55]); + $this->repo->method('findOneByEmail')->with('john@example.com')->willReturn($subscriber); + $text = "X-User: john@example.com\r\n"; + $this->assertSame(55, $parser->findUserId($text)); + } + + public function testFindUserIdByScanningEmails(): void + { + $parser = new MessageParser($this->repo); + $subscriber = $this->createConfiguredMock(Subscriber::class, ['getId' => 88]); + $this->repo->method('findOneByEmail')->with('user@acme.com')->willReturn($subscriber); + $text = 'Hello bounce for user@acme.com, thanks'; + $this->assertSame(88, $parser->findUserId($text)); + } + + public function testFindUserReturnsNullWhenNoMatches(): void + { + $parser = new MessageParser($this->repo); + $this->repo->method('findOneByEmail')->willReturn(null); + $this->assertNull($parser->findUserId('no users here')); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php new file mode 100644 index 00000000..e36162bc --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php @@ -0,0 +1,192 @@ +bounceManager = $this->createMock(BounceManager::class); + $this->ruleManager = $this->createMock(BounceRuleManager::class); + $this->actionResolver = $this->createMock(BounceActionResolver::class); + $this->subscriberManager = $this->createMock(SubscriberManager::class); + $this->io = $this->createMock(SymfonyStyle::class); + } + + public function testNoActiveRules(): void + { + $this->io->expects($this->once())->method('section')->with('Processing bounces based on active bounce rules'); + $this->ruleManager->method('loadActiveRules')->willReturn([]); + $this->io->expects($this->once())->method('writeln')->with('No active rules'); + + $processor = new AdvancedBounceRulesProcessor( + bounceManager: $this->bounceManager, + ruleManager: $this->ruleManager, + actionResolver: $this->actionResolver, + subscriberManager: $this->subscriberManager, + ); + + $processor->process($this->io, 100); + } + + public function testProcessingWithMatchesAndNonMatches(): void + { + $rule1 = new BounceRegex(action: 'blacklist', count: 0); + $rule2 = new BounceRegex(action: 'notify', count: 0); + // Manually set IDs via reflection since BounceRegex::id is private and generated by ORM in production + $setId = function (object $obj, int $id): void { + $ref = new \ReflectionClass($obj); + $prop = $ref->getProperty('id'); + $prop->setValue($obj, $id); + }; + $setId($rule1, 10); + $setId($rule2, 20); + $rules = [$rule1, $rule2]; + $this->ruleManager->method('loadActiveRules')->willReturn($rules); + + $this->bounceManager->method('getUserMessageBounceCount')->willReturn(3); + + $bounce1 = $this->createMock(Bounce::class); + $bounce1->method('getHeader')->willReturn('H1'); + $bounce1->method('getData')->willReturn('D1'); + + $bounce2 = $this->createMock(Bounce::class); + $bounce2->method('getHeader')->willReturn('H2'); + $bounce2->method('getData')->willReturn('D2'); + + $bounce3 = $this->createMock(Bounce::class); + $bounce3->method('getHeader')->willReturn('H3'); + $bounce3->method('getData')->willReturn('D3'); + + $umb1 = new class { + public function getId() + { + return 1; + } public function getUserId() + { + return 111; + } + }; + $umb2 = new class { + public function getId() + { + return 2; + } public function getUserId() + { + return 0; + } + }; + $umb3 = new class { + public function getId() + { + return 3; + } public function getUserId() + { + return 222; + } + }; + + $this->bounceManager->method('fetchUserMessageBounceBatch')->willReturnOnConsecutiveCalls( + [ ['umb' => $umb1, 'bounce' => $bounce1], ['umb' => $umb2, 'bounce' => $bounce2] ], + [ ['umb' => $umb3, 'bounce' => $bounce3] ] + ); + + // Rule matches for first and third, not for second + $this->ruleManager->expects($this->exactly(3)) + ->method('matchBounceRules') + ->willReturnCallback(function (string $text, array $r) use ($rules) { + $this->assertSame($rules, $r); + if ($text === 'H1' . "\n\n" . 'D1') { + return $rules[0]; + } + if ($text === 'H2' . "\n\n" . 'D2') { + return null; + } + if ($text === 'H3' . "\n\n" . 'D3') { + return $rules[1]; + } + $this->fail('Unexpected arguments to matchBounceRules: ' . $text); + }); + + $this->ruleManager->expects($this->exactly(2))->method('incrementCount'); + $this->ruleManager->expects($this->exactly(2))->method('linkRuleToBounce'); + + // subscriber lookups for umb1 and umb3 (111 and 222). umb2 has 0 user id so skip. + $subscriber111 = $this->createMock(Subscriber::class); + $subscriber111->method('getId')->willReturn(111); + $subscriber111->method('isConfirmed')->willReturn(true); + $subscriber111->method('isBlacklisted')->willReturn(false); + + $subscriber222 = $this->createMock(Subscriber::class); + $subscriber222->method('getId')->willReturn(222); + $subscriber222->method('isConfirmed')->willReturn(false); + $subscriber222->method('isBlacklisted')->willReturn(true); + + $this->subscriberManager->expects($this->exactly(2)) + ->method('getSubscriberById') + ->willReturnCallback(function (int $id) use ($subscriber111, $subscriber222) { + if ($id === 111) { + return $subscriber111; + } + if ($id === 222) { + return $subscriber222; + } + $this->fail('Unexpected subscriber id: ' . $id); + }); + + $this->actionResolver->expects($this->exactly(2)) + ->method('handle') + ->willReturnCallback(function (string $action, array $ctx) { + if ($action === 'blacklist') { + $this->assertSame(111, $ctx['userId']); + $this->assertTrue($ctx['confirmed']); + $this->assertFalse($ctx['blacklisted']); + $this->assertSame(10, $ctx['ruleId']); + $this->assertInstanceOf(Bounce::class, $ctx['bounce']); + } elseif ($action === 'notify') { + $this->assertSame(222, $ctx['userId']); + $this->assertFalse($ctx['confirmed']); + $this->assertTrue($ctx['blacklisted']); + $this->assertSame(20, $ctx['ruleId']); + } else { + $this->fail('Unexpected action: ' . $action); + } + return null; + }); + + $this->io + ->expects($this->once()) + ->method('section') + ->with('Processing bounces based on active bounce rules'); + $this->io->expects($this->exactly(4))->method('writeln'); + + $processor = new AdvancedBounceRulesProcessor( + bounceManager: $this->bounceManager, + ruleManager: $this->ruleManager, + actionResolver: $this->actionResolver, + subscriberManager: $this->subscriberManager, + ); + + $processor->process($this->io, 2); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php new file mode 100644 index 00000000..faf7c59e --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php @@ -0,0 +1,171 @@ +bounceManager = $this->createMock(BounceManager::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->messageRepository = $this->createMock(MessageRepository::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->subscriberManager = $this->createMock(SubscriberManager::class); + $this->historyManager = $this->createMock(SubscriberHistoryManager::class); + $this->bounce = $this->createMock(Bounce::class); + } + + private function makeProcessor(): BounceDataProcessor + { + return new BounceDataProcessor( + $this->bounceManager, + $this->subscriberRepository, + $this->messageRepository, + $this->logger, + $this->subscriberManager, + $this->historyManager, + ); + } + + public function testSystemMessageWithUserAddsHistory(): void + { + $processor = $this->makeProcessor(); + $date = new DateTimeImmutable('2020-01-01'); + + $this->bounce->method('getId')->willReturn(77); + + $this->bounceManager + ->expects($this->once()) + ->method('update') + ->with($this->bounce, 'bounced system message', '123 marked unconfirmed'); + $this->bounceManager + ->expects($this->once()) + ->method('linkUserMessageBounce') + ->with($this->bounce, $date, 123); + $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(123); + $this->logger + ->expects($this->once()) + ->method('info') + ->with('system message bounced, user marked unconfirmed', ['userId' => 123]); + + $subscriber = new class { + public function getId() + { + return 123; + } + }; + $this->subscriberManager->method('getSubscriberById')->with(123)->willReturn($subscriber); + $this->historyManager + ->expects($this->once()) + ->method('addHistory') + ->with($subscriber, 'Bounced system message', 'User marked unconfirmed. Bounce #77'); + + $res = $processor->process($this->bounce, 'systemmessage', 123, $date); + $this->assertTrue($res); + } + + public function testSystemMessageUnknownUser(): void + { + $processor = $this->makeProcessor(); + $this->bounceManager + ->expects($this->once()) + ->method('update') + ->with($this->bounce, 'bounced system message', 'unknown user'); + $this->logger->expects($this->once())->method('info')->with('system message bounced, but unknown user'); + $res = $processor->process($this->bounce, 'systemmessage', null, new DateTimeImmutable()); + $this->assertTrue($res); + } + + public function testKnownMessageAndUserNew(): void + { + $processor = $this->makeProcessor(); + $date = new DateTimeImmutable(); + $this->bounceManager->method('existsUserMessageBounce')->with(5, 10)->willReturn(false); + $this->bounceManager + ->expects($this->once()) + ->method('linkUserMessageBounce') + ->with($this->bounce, $date, 5, 10); + $this->bounceManager + ->expects($this->once()) + ->method('update') + ->with($this->bounce, 'bounced list message 10', '5 bouncecount increased'); + $this->messageRepository->expects($this->once())->method('incrementBounceCount')->with(10); + $this->subscriberRepository->expects($this->once())->method('incrementBounceCount')->with(5); + $res = $processor->process($this->bounce, '10', 5, $date); + $this->assertTrue($res); + } + + public function testKnownMessageAndUserDuplicate(): void + { + $processor = $this->makeProcessor(); + $date = new DateTimeImmutable(); + $this->bounceManager->method('existsUserMessageBounce')->with(5, 10)->willReturn(true); + $this->bounceManager + ->expects($this->once()) + ->method('linkUserMessageBounce') + ->with($this->bounce, $date, 5, 10); + $this->bounceManager + ->expects($this->once()) + ->method('update') + ->with($this->bounce, 'duplicate bounce for 5', 'duplicate bounce for subscriber 5 on message 10'); + $res = $processor->process($this->bounce, '10', 5, $date); + $this->assertTrue($res); + } + + public function testUserOnly(): void + { + $processor = $this->makeProcessor(); + $this->bounceManager + ->expects($this->once()) + ->method('update') + ->with($this->bounce, 'bounced unidentified message', '5 bouncecount increased'); + $this->subscriberRepository->expects($this->once())->method('incrementBounceCount')->with(5); + $res = $processor->process($this->bounce, null, 5, new DateTimeImmutable()); + $this->assertTrue($res); + } + + public function testMessageOnly(): void + { + $processor = $this->makeProcessor(); + $this->bounceManager + ->expects($this->once()) + ->method('update') + ->with($this->bounce, 'bounced list message 10', 'unknown user'); + $this->messageRepository->expects($this->once())->method('incrementBounceCount')->with(10); + $res = $processor->process($this->bounce, '10', null, new DateTimeImmutable()); + $this->assertTrue($res); + } + + public function testNeitherMessageNorUser(): void + { + $processor = $this->makeProcessor(); + $this->bounceManager + ->expects($this->once()) + ->method('update') + ->with($this->bounce, 'unidentified bounce', 'not processed'); + $res = $processor->process($this->bounce, null, null, new DateTimeImmutable()); + $this->assertFalse($res); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php similarity index 99% rename from tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php rename to tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php index 3d685ed4..b2c51c71 100644 --- a/tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Processor; use Doctrine\ORM\EntityManagerInterface; use Exception; diff --git a/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php new file mode 100644 index 00000000..210e000c --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php @@ -0,0 +1,76 @@ +service = $this->createMock(BounceProcessingServiceInterface::class); + $this->input = $this->createMock(InputInterface::class); + $this->io = $this->createMock(SymfonyStyle::class); + } + + public function testGetProtocol(): void + { + $processor = new MboxBounceProcessor($this->service); + $this->assertSame('mbox', $processor->getProtocol()); + } + + public function testProcessThrowsWhenMailboxMissing(): void + { + $processor = new MboxBounceProcessor($this->service); + + $this->input->method('getOption')->willReturnMap([ + ['test', false], + ['maximum', 0], + ['mailbox', ''], + ]); + + $this->io + ->expects($this->once()) + ->method('error') + ->with('mbox file path must be provided with --mailbox.'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Missing --mailbox for mbox protocol'); + + $processor->process($this->input, $this->io); + } + + public function testProcessSuccess(): void + { + $processor = new MboxBounceProcessor($this->service); + + $this->input->method('getOption')->willReturnMap([ + ['test', true], + ['maximum', 50], + ['mailbox', '/var/mail/bounce.mbox'], + ]); + + $this->io->expects($this->once())->method('section')->with('Opening mbox /var/mail/bounce.mbox'); + $this->io->expects($this->once())->method('writeln')->with('Please do not interrupt this process'); + + $this->service->expects($this->once()) + ->method('processMailbox') + ->with('/var/mail/bounce.mbox', 50, true) + ->willReturn('OK'); + + $result = $processor->process($this->input, $this->io); + $this->assertSame('OK', $result); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php new file mode 100644 index 00000000..fad4cfbe --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php @@ -0,0 +1,64 @@ +service = $this->createMock(BounceProcessingServiceInterface::class); + $this->input = $this->createMock(InputInterface::class); + $this->io = $this->createMock(SymfonyStyle::class); + } + + public function testGetProtocol(): void + { + $processor = new PopBounceProcessor($this->service, 'mail.example.com', 995, 'INBOX'); + $this->assertSame('pop', $processor->getProtocol()); + } + + public function testProcessWithMultipleMailboxesAndDefaults(): void + { + $processor = new PopBounceProcessor($this->service, 'pop.example.com', 110, 'INBOX, ,Custom'); + + $this->input->method('getOption')->willReturnMap([ + ['test', true], + ['maximum', 100], + ]); + + $this->io->expects($this->exactly(3))->method('section'); + $this->io->expects($this->exactly(3))->method('writeln'); + + $this->service->expects($this->exactly(3)) + ->method('processMailbox') + ->willReturnCallback(function (string $mailbox, int $max, bool $test) { + $expectedThird = '{pop.example.com:110}Custom'; + $expectedFirst = '{pop.example.com:110}INBOX'; + $this->assertSame(100, $max); + $this->assertTrue($test); + if ($mailbox === $expectedFirst) { + return 'A'; + } + if ($mailbox === $expectedThird) { + return 'C'; + } + $this->fail('Unexpected mailbox: ' . $mailbox); + }); + + $result = $processor->process($this->input, $this->io); + $this->assertSame('AAC', $result); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php new file mode 100644 index 00000000..a671e74c --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php @@ -0,0 +1,75 @@ +bounceManager = $this->createMock(BounceManager::class); + $this->messageParser = $this->createMock(MessageParser::class); + $this->dataProcessor = $this->createMock(BounceDataProcessor::class); + $this->io = $this->createMock(SymfonyStyle::class); + } + + public function testProcess(): void + { + $bounce1 = $this->createBounce('H1', 'D1'); + $bounce2 = $this->createBounce('H2', 'D2'); + $bounce3 = $this->createBounce('H3', 'D3'); + $this->bounceManager + ->method('findByStatus') + ->with('unidentified bounce') + ->willReturn([$bounce1, $bounce2, $bounce3]); + + $this->io->expects($this->once())->method('section')->with('Reprocessing unidentified bounces'); + $this->io->expects($this->exactly(3))->method('writeln'); + + // For b1: only userId found -> should process + $this->messageParser->expects($this->exactly(3))->method('decodeBody'); + $this->messageParser->method('findUserId')->willReturnOnConsecutiveCalls(111, null, 222); + $this->messageParser->method('findMessageId')->willReturnOnConsecutiveCalls(null, '555', '666'); + + // process called for b1 and b3 (two calls return true and true), + // and also for b2 since it has messageId -> should be called too -> total 3 calls + $this->dataProcessor->expects($this->exactly(3)) + ->method('process') + ->with( + $this->anything(), + $this->callback(fn($messageId) => $messageId === null || is_string($messageId)), + $this->callback(fn($messageId) => $messageId === null || is_int($messageId)), + $this->isInstanceOf(DateTimeImmutable::class) + ) + ->willReturnOnConsecutiveCalls(true, false, true); + + $processor = new UnidentifiedBounceReprocessor( + bounceManager: $this->bounceManager, + messageParser: $this->messageParser, + bounceDataProcessor: $this->dataProcessor + ); + $processor->process($this->io); + } + + private function createBounce(string $header, string $data): Bounce + { + // Bounce constructor: (DateTime|null, header, data, status, comment) + return new Bounce(null, $header, $data, null, null); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php b/tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php new file mode 100644 index 00000000..e75766f5 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php @@ -0,0 +1,70 @@ +manager = $this->createMock(ClientManager::class); + } + + public function testMakeForMailboxBuildsClientWithConfiguredParams(): void + { + $factory = new WebklexImapClientFactory( + clientManager: $this->manager, + mailbox: 'imap.example.com#BOUNCES', + host: 'imap.example.com', + username: 'user', + password: 'pass', + protocol: 'imap', + port: 993, + encryption: 'ssl' + ); + + $client = $this->createMock(Client::class); + + $this->manager + ->expects($this->once()) + ->method('make') + ->with($this->callback(function (array $cfg) { + $this->assertSame('imap.example.com', $cfg['host']); + $this->assertSame(993, $cfg['port']); + $this->assertSame('ssl', $cfg['encryption']); + $this->assertTrue($cfg['validate_cert']); + $this->assertSame('user', $cfg['username']); + $this->assertSame('pass', $cfg['password']); + $this->assertSame('imap', $cfg['protocol']); + return true; + })) + ->willReturn($client); + + $out = $factory->makeForMailbox(); + $this->assertSame($client, $out); + $this->assertSame('BOUNCES', $factory->getFolderName()); + } + + public function testGetFolderNameDefaultsToInbox(): void + { + $factory = new WebklexImapClientFactory( + clientManager: $this->manager, + mailbox: 'imap.example.com', + host: 'imap.example.com', + username: 'u', + password: 'p', + protocol: 'imap', + port: 993 + ); + $this->assertSame('INBOX', $factory->getFolderName()); + } +} From 8a1dd1f223ecc7fccdcc1cdf6955f0cd2c97bd39 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 2 Sep 2025 10:34:36 +0400 Subject: [PATCH 24/24] Fix tests --- .../Service/Manager/BounceManagerTest.php | 10 +-- .../Service/Manager/BounceRuleManagerTest.php | 71 ++++++++++++------- .../Manager/TemplateImageManagerTest.php | 4 +- .../AdvancedBounceRulesProcessorTest.php | 59 ++++++--------- .../Processor/BounceDataProcessorTest.php | 21 +++--- 5 files changed, 82 insertions(+), 83 deletions(-) diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php index 8b07f5f5..bd1a4a68 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php @@ -125,8 +125,8 @@ public function testUpdateChangesFieldsAndSaves(): void public function testLinkUserMessageBounceFlushesAndSetsFields(): void { - $bounce = new Bounce(); - $this->setId($bounce, 77); + $bounce = $this->createMock(Bounce::class); + $bounce->method('getId')->willReturn(77); $this->entityManager->expects($this->once())->method('flush'); @@ -202,10 +202,4 @@ public function testAnnounceDeletionModeLogsCorrectMessage(): void $this->manager->announceDeletionMode(true); $this->manager->announceDeletionMode(false); } - - private function setId(object $entity, int $id): void - { - $ref = new \ReflectionProperty($entity, 'id'); - $ref->setValue($entity, $id); - } } diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php index b1073d07..040f98a8 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php @@ -23,22 +23,32 @@ protected function setUp(): void { $this->regexRepository = $this->createMock(BounceRegexRepository::class); $this->relationRepository = $this->createMock(BounceRegexBounceRepository::class); - $this->manager = new BounceRuleManager($this->regexRepository, $this->relationRepository); + $this->manager = new BounceRuleManager( + repository: $this->regexRepository, + bounceRelationRepository: $this->relationRepository, + ); } public function testLoadActiveRulesMapsRowsAndSkipsInvalid(): void { - $valid = new BounceRegex(regex: 'user unknown', regexHash: md5('user unknown'), action: 'delete'); - // invalids: no regex, no action, no id - $noRegex = new BounceRegex(regex: '', regexHash: md5(''), action: 'delete'); - $noAction = new BounceRegex(regex: 'pattern', regexHash: md5('pattern'), action: ''); - $noId = new BounceRegex(regex: 'has no id', regexHash: md5('has no id'), action: 'keep'); - - // Simulate id assignment for only some of them - $this->setId($valid, 1); - $this->setId($noRegex, 2); - $this->setId($noAction, 3); - // $noId intentionally left without id + $valid = $this->createMock(BounceRegex::class); + $valid->method('getId')->willReturn(1); + $valid->method('getAction')->willReturn('delete'); + $valid->method('getRegex')->willReturn('user unknown'); + $valid->method('getRegexHash')->willReturn(md5('user unknown')); + + $noRegex = $this->createMock(BounceRegex::class); + $noRegex->method('getId')->willReturn(2); + + $noAction = $this->createMock(BounceRegex::class); + $noAction->method('getId')->willReturn(3); + $noAction->method('getRegex')->willReturn('pattern'); + $noAction->method('getRegexHash')->willReturn(md5('pattern')); + + $noId = $this->createMock(BounceRegex::class); + $noId->method('getRegex')->willReturn('has no id'); + $noId->method('getRegexHash')->willReturn(md5('has no id')); + $noId->method('getAction')->willReturn('keep'); $this->regexRepository->expects($this->once()) ->method('fetchActiveOrdered') @@ -51,33 +61,46 @@ public function testLoadActiveRulesMapsRowsAndSkipsInvalid(): void public function testLoadAllRulesDelegatesToRepository(): void { - $r1 = new BounceRegex(regex: 'a', regexHash: md5('a'), action: 'keep'); - $r2 = new BounceRegex(regex: 'b', regexHash: md5('b'), action: 'delete'); - $this->setId($r1, 10); - $this->setId($r2, 11); + $rule1 = $this->createMock(BounceRegex::class); + $rule1->method('getId')->willReturn(10); + $rule1->method('getAction')->willReturn('keep'); + $rule1->method('getRegex')->willReturn('a'); + $rule1->method('getRegexHash')->willReturn(md5('a')); + + $rule2 = $this->createMock(BounceRegex::class); + $rule2->method('getId')->willReturn(11); + $rule2->method('getAction')->willReturn('delete'); + $rule2->method('getRegex')->willReturn('b'); + $rule2->method('getRegexHash')->willReturn(md5('b')); $this->regexRepository->expects($this->once()) ->method('fetchAllOrdered') - ->willReturn([$r1, $r2]); + ->willReturn([$rule1, $rule2]); $result = $this->manager->loadAllRules(); - $this->assertSame(['a' => $r1, 'b' => $r2], $result); + $this->assertSame(['a' => $rule1, 'b' => $rule2], $result); } public function testMatchBounceRulesMatchesQuotedAndRawAndHandlesInvalidPatterns(): void { - $valid = new BounceRegex(regex: 'user unknown', regexHash: md5('user unknown'), action: 'delete'); - $this->setId($valid, 1); - // invalid regex pattern that would break preg_match if not handled (unbalanced bracket) - $invalid = new BounceRegex(regex: '([a-z', regexHash: md5('([a-z'), action: 'keep'); - $this->setId($invalid, 2); + $valid = $this->createMock(BounceRegex::class); + $valid->method('getId')->willReturn(1); + $valid->method('getAction')->willReturn('delete'); + $valid->method('getRegex')->willReturn('user unknown'); + $valid->method('getRegexHash')->willReturn(md5('user unknown')); + + $invalid = $this->createMock(BounceRegex::class); + $invalid->method('getId')->willReturn(2); + $invalid->method('getAction')->willReturn('keep'); + $invalid->method('getRegex')->willReturn('([a-z'); + $invalid->method('getRegexHash')->willReturn(md5('([a-z')); $rules = ['user unknown' => $valid, '([a-z' => $invalid]; $matched = $this->manager->matchBounceRules('Delivery failed: user unknown at example', $rules); $this->assertSame($valid, $matched); - // Ensure invalid pattern does not throw and simply not match + // Ensure an invalid pattern does not throw and simply not match $matchedInvalid = $this->manager->matchBounceRules('something else', ['([a-z' => $invalid]); $this->assertNull($matchedInvalid); } diff --git a/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php index 7eb6afe7..93907f02 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php @@ -24,8 +24,8 @@ protected function setUp(): void $this->entityManager = $this->createMock(EntityManagerInterface::class); $this->manager = new TemplateImageManager( - $this->templateImageRepository, - $this->entityManager + templateImageRepository: $this->templateImageRepository, + entityManager: $this->entityManager ); } diff --git a/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php index e36162bc..209fb583 100644 --- a/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php @@ -6,6 +6,7 @@ use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Model\BounceRegex; +use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; use PhpList\Core\Domain\Messaging\Service\BounceActionResolver; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager; @@ -51,16 +52,16 @@ public function testNoActiveRules(): void public function testProcessingWithMatchesAndNonMatches(): void { - $rule1 = new BounceRegex(action: 'blacklist', count: 0); - $rule2 = new BounceRegex(action: 'notify', count: 0); - // Manually set IDs via reflection since BounceRegex::id is private and generated by ORM in production - $setId = function (object $obj, int $id): void { - $ref = new \ReflectionClass($obj); - $prop = $ref->getProperty('id'); - $prop->setValue($obj, $id); - }; - $setId($rule1, 10); - $setId($rule2, 20); + $rule1 = $this->createMock(BounceRegex::class); + $rule1->method('getId')->willReturn(10); + $rule1->method('getAction')->willReturn('blacklist'); + $rule1->method('getCount')->willReturn(0); + + $rule2 = $this->createMock(BounceRegex::class); + $rule2->method('getId')->willReturn(20); + $rule2->method('getAction')->willReturn('notify'); + $rule2->method('getCount')->willReturn(0); + $rules = [$rule1, $rule2]; $this->ruleManager->method('loadActiveRules')->willReturn($rules); @@ -78,33 +79,17 @@ public function testProcessingWithMatchesAndNonMatches(): void $bounce3->method('getHeader')->willReturn('H3'); $bounce3->method('getData')->willReturn('D3'); - $umb1 = new class { - public function getId() - { - return 1; - } public function getUserId() - { - return 111; - } - }; - $umb2 = new class { - public function getId() - { - return 2; - } public function getUserId() - { - return 0; - } - }; - $umb3 = new class { - public function getId() - { - return 3; - } public function getUserId() - { - return 222; - } - }; + $umb1 = $this->createMock(UserMessageBounce::class); + $umb1->method('getId')->willReturn(1); + $umb1->method('getUserId')->willReturn(111); + + $umb2 = $this->createMock(UserMessageBounce::class); + $umb2->method('getId')->willReturn(2); + $umb2->method('getUserId')->willReturn(0); + + $umb3 = $this->createMock(UserMessageBounce::class); + $umb3->method('getId')->willReturn(3); + $umb3->method('getUserId')->willReturn(222); $this->bounceManager->method('fetchUserMessageBounceBatch')->willReturnOnConsecutiveCalls( [ ['umb' => $umb1, 'bounce' => $bounce1], ['umb' => $umb2, 'bounce' => $bounce2] ], diff --git a/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php index faf7c59e..b7009cd9 100644 --- a/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php @@ -8,6 +8,7 @@ use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; @@ -40,12 +41,12 @@ protected function setUp(): void private function makeProcessor(): BounceDataProcessor { return new BounceDataProcessor( - $this->bounceManager, - $this->subscriberRepository, - $this->messageRepository, - $this->logger, - $this->subscriberManager, - $this->historyManager, + bounceManager: $this->bounceManager, + subscriberRepository: $this->subscriberRepository, + messageRepository: $this->messageRepository, + logger: $this->logger, + subscriberManager: $this->subscriberManager, + subscriberHistoryManager: $this->historyManager, ); } @@ -70,12 +71,8 @@ public function testSystemMessageWithUserAddsHistory(): void ->method('info') ->with('system message bounced, user marked unconfirmed', ['userId' => 123]); - $subscriber = new class { - public function getId() - { - return 123; - } - }; + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getId')->willReturn(123); $this->subscriberManager->method('getSubscriberById')->with(123)->willReturn($subscriber); $this->historyManager ->expects($this->once())