From ab6c5fd88ea1884ea719c6f80598f71cbdb429c4 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Fri, 8 Aug 2025 22:23:15 +0400 Subject: [PATCH 01/10] Blacklist (#352) * Blacklisted * user repository methods * fix configs * add test * fix: phpmd * fix: repo configs * return a created resource --------- Co-authored-by: Tatevik --- config/services/managers.yml | 4 + config/services/repositories.yml | 10 + .../UserBlacklistDataRepository.php | 11 - .../Repository/UserBlacklistRepository.php | 11 - .../Model/UserBlacklist.php | 12 +- .../Model/UserBlacklistData.php | 4 +- .../Repository/SubscriberRepository.php | 14 ++ .../UserBlacklistDataRepository.php | 16 ++ .../Repository/UserBlacklistRepository.php | 33 +++ .../Manager/SubscriberBlacklistManager.php | 86 ++++++++ .../SubscriberBlacklistManagerTest.php | 202 ++++++++++++++++++ 11 files changed, 377 insertions(+), 26 deletions(-) delete mode 100644 src/Domain/Identity/Repository/UserBlacklistDataRepository.php delete mode 100644 src/Domain/Identity/Repository/UserBlacklistRepository.php rename src/Domain/{Identity => Subscription}/Model/UserBlacklist.php (72%) rename src/Domain/{Identity => Subscription}/Model/UserBlacklistData.php (90%) create mode 100644 src/Domain/Subscription/Repository/UserBlacklistDataRepository.php create mode 100644 src/Domain/Subscription/Repository/UserBlacklistRepository.php create mode 100644 src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php create mode 100644 tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php diff --git a/config/services/managers.yml b/config/services/managers.yml index 4f57fc11..0e1b1d8a 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -67,3 +67,7 @@ services: PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager: autowire: true autoconfigure: true + + PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager: + autowire: true + autoconfigure: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml index eca3a31c..db3831dd 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -110,3 +110,13 @@ services: 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 diff --git a/src/Domain/Identity/Repository/UserBlacklistDataRepository.php b/src/Domain/Identity/Repository/UserBlacklistDataRepository.php deleted file mode 100644 index 0f06722b..00000000 --- a/src/Domain/Identity/Repository/UserBlacklistDataRepository.php +++ /dev/null @@ -1,11 +0,0 @@ -email; @@ -42,4 +45,9 @@ public function setAdded(?DateTime $added): self $this->added = $added; return $this; } + + public function getBlacklistData(): ?UserBlacklistData + { + return $this->blacklistData; + } } diff --git a/src/Domain/Identity/Model/UserBlacklistData.php b/src/Domain/Subscription/Model/UserBlacklistData.php similarity index 90% rename from src/Domain/Identity/Model/UserBlacklistData.php rename to src/Domain/Subscription/Model/UserBlacklistData.php index 09697616..f8d78c59 100644 --- a/src/Domain/Identity/Model/UserBlacklistData.php +++ b/src/Domain/Subscription/Model/UserBlacklistData.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Identity\Model; +namespace PhpList\Core\Domain\Subscription\Model; use Doctrine\ORM\Mapping as ORM; use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Identity\Repository\UserBlacklistDataRepository; +use PhpList\Core\Domain\Subscription\Repository\UserBlacklistDataRepository; #[ORM\Entity(repositoryClass: UserBlacklistDataRepository::class)] #[ORM\Table(name: 'phplist_user_blacklist_data')] diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index 762096a0..6ebaee70 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -127,4 +127,18 @@ public function findSubscriberWithSubscriptions(int $id): ?Subscriber ->getQuery() ->getOneOrNullResult(); } + + public function isEmailBlacklisted(string $email): bool + { + $queryBuilder = $this->getEntityManager()->createQueryBuilder(); + + $queryBuilder->select('u.email') + ->from(Subscriber::class, 'u') + ->where('u.email = :email') + ->andWhere('u.blacklisted = 1') + ->setParameter('email', $email) + ->setMaxResults(1); + + return !($queryBuilder->getQuery()->getOneOrNullResult() === null); + } } diff --git a/src/Domain/Subscription/Repository/UserBlacklistDataRepository.php b/src/Domain/Subscription/Repository/UserBlacklistDataRepository.php new file mode 100644 index 00000000..a64525b9 --- /dev/null +++ b/src/Domain/Subscription/Repository/UserBlacklistDataRepository.php @@ -0,0 +1,16 @@ +findOneBy(['email' => $email]); + } +} diff --git a/src/Domain/Subscription/Repository/UserBlacklistRepository.php b/src/Domain/Subscription/Repository/UserBlacklistRepository.php new file mode 100644 index 00000000..665deb64 --- /dev/null +++ b/src/Domain/Subscription/Repository/UserBlacklistRepository.php @@ -0,0 +1,33 @@ +getEntityManager()->createQueryBuilder(); + + $queryBuilder->select('ub.email, ub.added, ubd.data AS reason') + ->from(UserBlacklist::class, 'ub') + ->innerJoin(UserBlacklistData::class, 'ubd', 'WITH', 'ub.email = ubd.email') + ->where('ub.email = :email') + ->setParameter('email', $email) + ->setMaxResults(1); + + return $queryBuilder->getQuery()->getOneOrNullResult(); + } + + public function findOneByEmail(string $email): ?UserBlacklist + { + return $this->findOneBy([ + 'email' => $email, + ]); + } +} diff --git a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php new file mode 100644 index 00000000..d30bae2d --- /dev/null +++ b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php @@ -0,0 +1,86 @@ +subscriberRepository->isEmailBlacklisted($email); + } + + public function getBlacklistInfo(string $email): ?UserBlacklist + { + return $this->userBlacklistRepository->findBlacklistInfoByEmail($email); + } + + public function addEmailToBlacklist(string $email, ?string $reasonData = null): UserBlacklist + { + $existing = $this->subscriberRepository->isEmailBlacklisted($email); + if ($existing) { + return $this->getBlacklistInfo($email); + } + + $blacklistEntry = new UserBlacklist(); + $blacklistEntry->setEmail($email); + $blacklistEntry->setAdded(new DateTime()); + + $this->entityManager->persist($blacklistEntry); + + if ($reasonData !== null) { + $blacklistData = new UserBlacklistData(); + $blacklistData->setEmail($email); + $blacklistData->setName('reason'); + $blacklistData->setData($reasonData); + $this->entityManager->persist($blacklistData); + } + + $this->entityManager->flush(); + + return $blacklistEntry; + } + + public function removeEmailFromBlacklist(string $email): void + { + $blacklistEntry = $this->userBlacklistRepository->findOneByEmail($email); + if ($blacklistEntry) { + $this->entityManager->remove($blacklistEntry); + } + + $blacklistData = $this->blacklistDataRepository->findOneByEmail($email); + if ($blacklistData) { + $this->entityManager->remove($blacklistData); + } + + $subscriber = $this->subscriberRepository->findOneByEmail($email); + if ($subscriber) { + $subscriber->setBlacklisted(false); + } + + $this->entityManager->flush(); + } + + public function getBlacklistReason(string $email): ?string + { + $data = $this->blacklistDataRepository->findOneByEmail($email); + return $data ? $data->getData() : null; + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php new file mode 100644 index 00000000..25fdf5ca --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php @@ -0,0 +1,202 @@ +subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->userBlacklistRepository = $this->createMock(UserBlacklistRepository::class); + $this->userBlacklistDataRepository = $this->createMock(UserBlacklistDataRepository::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + + $this->manager = new SubscriberBlacklistManager( + subscriberRepository: $this->subscriberRepository, + userBlacklistRepository: $this->userBlacklistRepository, + blacklistDataRepository: $this->userBlacklistDataRepository, + entityManager: $this->entityManager, + ); + } + + public function testIsEmailBlacklistedReturnsValueFromRepository(): void + { + $this->subscriberRepository + ->expects($this->once()) + ->method('isEmailBlacklisted') + ->with('test@example.com') + ->willReturn(true); + + $result = $this->manager->isEmailBlacklisted('test@example.com'); + + $this->assertTrue($result); + } + + public function testGetBlacklistInfoReturnsResultFromRepository(): void + { + $userBlacklist = $this->createMock(UserBlacklist::class); + + $this->userBlacklistRepository + ->expects($this->once()) + ->method('findBlacklistInfoByEmail') + ->with('foo@bar.com') + ->willReturn($userBlacklist); + + $result = $this->manager->getBlacklistInfo('foo@bar.com'); + + $this->assertSame($userBlacklist, $result); + } + + public function testAddEmailToBlacklistDoesNotAddIfAlreadyBlacklisted(): void + { + $this->subscriberRepository + ->expects($this->once()) + ->method('isEmailBlacklisted') + ->with('already@blacklisted.com') + ->willReturn(true); + + $this->userBlacklistRepository + ->expects($this->once()) + ->method('findBlacklistInfoByEmail') + ->willReturn($this->createMock(UserBlacklist::class)); + + $this->entityManager + ->expects($this->never()) + ->method('persist'); + + $this->entityManager + ->expects($this->never()) + ->method('flush'); + + $this->manager->addEmailToBlacklist('already@blacklisted.com', 'reason'); + } + + public function testAddEmailToBlacklistAddsEntryAndReason(): void + { + $this->subscriberRepository + ->expects($this->once()) + ->method('isEmailBlacklisted') + ->with('new@blacklist.com') + ->willReturn(false); + + $this->entityManager + ->expects($this->exactly(2)) + ->method('persist') + ->withConsecutive( + [$this->isInstanceOf(UserBlacklist::class)], + [$this->isInstanceOf(UserBlacklistData::class)] + ); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $this->manager->addEmailToBlacklist('new@blacklist.com', 'test reason'); + } + + public function testAddEmailToBlacklistAddsEntryWithoutReason(): void + { + $this->subscriberRepository + ->expects($this->once()) + ->method('isEmailBlacklisted') + ->with('noreason@blacklist.com') + ->willReturn(false); + + $this->entityManager + ->expects($this->once()) + ->method('persist') + ->with($this->isInstanceOf(UserBlacklist::class)); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $this->manager->addEmailToBlacklist('noreason@blacklist.com'); + } + + public function testRemoveEmailFromBlacklistRemovesAllRelatedData(): void + { + $blacklist = $this->createMock(UserBlacklist::class); + $blacklistData = $this->createMock(UserBlacklistData::class); + $subscriber = $this->getMockBuilder(Subscriber::class) + ->onlyMethods(['setBlacklisted']) + ->getMock(); + + $this->userBlacklistRepository + ->expects($this->once()) + ->method('findOneByEmail') + ->with('remove@me.com') + ->willReturn($blacklist); + + $this->userBlacklistDataRepository + ->expects($this->once()) + ->method('findOneByEmail') + ->with('remove@me.com') + ->willReturn($blacklistData); + + $this->subscriberRepository + ->expects($this->once()) + ->method('findOneByEmail') + ->with('remove@me.com') + ->willReturn($subscriber); + + $this->entityManager + ->expects($this->exactly(2)) + ->method('remove') + ->withConsecutive([$blacklist], [$blacklistData]); + + $subscriber->expects($this->once())->method('setBlacklisted')->with(false); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $this->manager->removeEmailFromBlacklist('remove@me.com'); + } + + public function testGetBlacklistReasonReturnsReasonOrNull(): void + { + $blacklistData = $this->createMock(UserBlacklistData::class); + $blacklistData->expects($this->once())->method('getData')->willReturn('my reason'); + + $this->userBlacklistDataRepository + ->expects($this->once()) + ->method('findOneByEmail') + ->with('why@blacklist.com') + ->willReturn($blacklistData); + + $result = $this->manager->getBlacklistReason('why@blacklist.com'); + $this->assertSame('my reason', $result); + } + + public function testGetBlacklistReasonReturnsNullIfNoData(): void + { + $this->userBlacklistDataRepository + ->expects($this->once()) + ->method('findOneByEmail') + ->with('none@blacklist.com') + ->willReturn(null); + + $result = $this->manager->getBlacklistReason('none@blacklist.com'); + $this->assertNull($result); + } +} From f80edc8b42d233469ab31f33404b30a56eba646f Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Mon, 18 Aug 2025 09:10:01 +0400 Subject: [PATCH 02/10] Subscribepage (#353) * subscriber page manager * owner entity * test * ci fix * getByPage data --------- Co-authored-by: Tatevik --- config/services/managers.yml | 4 + config/services/repositories.yml | 10 + .../Subscription/Model/SubscribePage.php | 10 +- .../SubscriberPageDataRepository.php | 13 + .../Repository/SubscriberPageRepository.php | 16 ++ .../Service/Manager/SubscribePageManager.php | 105 ++++++++ .../Manager/SubscribePageManagerTest.php | 234 ++++++++++++++++++ 7 files changed, 388 insertions(+), 4 deletions(-) create mode 100644 src/Domain/Subscription/Service/Manager/SubscribePageManager.php create mode 100644 tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php diff --git a/config/services/managers.yml b/config/services/managers.yml index 0e1b1d8a..d253fc95 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -71,3 +71,7 @@ services: PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager: autowire: true autoconfigure: true + + PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager: + autowire: true + autoconfigure: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml index db3831dd..02c9e7d3 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -120,3 +120,13 @@ services: 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 diff --git a/src/Domain/Subscription/Model/SubscribePage.php b/src/Domain/Subscription/Model/SubscribePage.php index 7ec518b2..e4696380 100644 --- a/src/Domain/Subscription/Model/SubscribePage.php +++ b/src/Domain/Subscription/Model/SubscribePage.php @@ -7,6 +7,7 @@ use Doctrine\ORM\Mapping as ORM; use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository; #[ORM\Entity(repositoryClass: SubscriberPageRepository::class)] @@ -24,8 +25,9 @@ class SubscribePage implements DomainModel, Identity #[ORM\Column(name: 'active', type: 'boolean', options: ['default' => 0])] private bool $active = false; - #[ORM\Column(name: 'owner', type: 'integer', nullable: true)] - private ?int $owner = null; + #[ORM\ManyToOne(targetEntity: Administrator::class)] + #[ORM\JoinColumn(name: 'owner', referencedColumnName: 'id', nullable: true)] + private ?Administrator $owner = null; public function getId(): ?int { @@ -42,7 +44,7 @@ public function isActive(): bool return $this->active; } - public function getOwner(): ?int + public function getOwner(): ?Administrator { return $this->owner; } @@ -59,7 +61,7 @@ public function setActive(bool $active): self return $this; } - public function setOwner(?int $owner): self + public function setOwner(?Administrator $owner): self { $this->owner = $owner; return $this; diff --git a/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php b/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php index 565930d4..68d0d6bc 100644 --- a/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php @@ -7,8 +7,21 @@ 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\Subscription\Model\SubscribePage; +use PhpList\Core\Domain\Subscription\Model\SubscribePageData; class SubscriberPageDataRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + public function findByPageAndName(SubscribePage $page, string $name): ?SubscribePageData + { + return $this->findOneBy(['id' => $page->getId(), 'name' => $name]); + } + + /** @return SubscribePageData[] */ + public function getByPage(SubscribePage $page): array + { + return $this->findBy(['id' => $page->getId()]); + } } diff --git a/src/Domain/Subscription/Repository/SubscriberPageRepository.php b/src/Domain/Subscription/Repository/SubscriberPageRepository.php index 2a8383c0..136b589c 100644 --- a/src/Domain/Subscription/Repository/SubscriberPageRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberPageRepository.php @@ -7,8 +7,24 @@ 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\Subscription\Model\SubscribePage; +use PhpList\Core\Domain\Subscription\Model\SubscribePageData; class SubscriberPageRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + /** @return array{page: SubscribePage, data: SubscribePageData}[] */ + public function findPagesWithData(int $pageId): array + { + return $this->createQueryBuilder('p') + ->select('p AS page, d AS data') + ->from(SubscribePage::class, 'p') + ->from(SubscribePageData::class, 'd') + ->where('p.id = :id') + ->andWhere('d.id = p.id') + ->setParameter('id', $pageId) + ->getQuery() + ->getResult(); + } } diff --git a/src/Domain/Subscription/Service/Manager/SubscribePageManager.php b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php new file mode 100644 index 00000000..8e429dc4 --- /dev/null +++ b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php @@ -0,0 +1,105 @@ +setTitle($title) + ->setActive($active) + ->setOwner($owner); + + $this->pageRepository->save($page); + + return $page; + } + + public function getPage(int $id): SubscribePage + { + /** @var SubscribePage|null $page */ + $page = $this->pageRepository->find($id); + if (!$page) { + throw new NotFoundHttpException('Subscribe page not found'); + } + + return $page; + } + + public function updatePage( + SubscribePage $page, + ?string $title = null, + ?bool $active = null, + ?Administrator $owner = null + ): SubscribePage { + if ($title !== null) { + $page->setTitle($title); + } + if ($active !== null) { + $page->setActive($active); + } + if ($owner !== null) { + $page->setOwner($owner); + } + + $this->entityManager->flush(); + + return $page; + } + + public function setActive(SubscribePage $page, bool $active): void + { + $page->setActive($active); + $this->entityManager->flush(); + } + + public function deletePage(SubscribePage $page): void + { + $this->pageRepository->remove($page); + } + + /** @return SubscribePageData[] */ + public function getPageData(SubscribePage $page): array + { + return $this->pageDataRepository->getByPage($page,); + } + + public function setPageData(SubscribePage $page, string $name, ?string $value): SubscribePageData + { + /** @var SubscribePageData|null $data */ + $data = $this->pageDataRepository->findByPageAndName($page, $name); + + if (!$data) { + $data = (new SubscribePageData()) + ->setId((int)$page->getId()) + ->setName($name); + $this->entityManager->persist($data); + } + + $data->setData($value); + $this->entityManager->flush(); + + return $data; + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php new file mode 100644 index 00000000..422c78a7 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php @@ -0,0 +1,234 @@ +pageRepository = $this->createMock(SubscriberPageRepository::class); + $this->pageDataRepository = $this->createMock(SubscriberPageDataRepository::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + + $this->manager = new SubscribePageManager( + pageRepository: $this->pageRepository, + pageDataRepository: $this->pageDataRepository, + entityManager: $this->entityManager, + ); + } + + public function testCreatePageCreatesAndSaves(): void + { + $owner = new Administrator(); + $this->pageRepository + ->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(SubscribePage::class)); + + $page = $this->manager->createPage('My Page', true, $owner); + + $this->assertInstanceOf(SubscribePage::class, $page); + $this->assertSame('My Page', $page->getTitle()); + $this->assertTrue($page->isActive()); + $this->assertSame($owner, $page->getOwner()); + } + + public function testGetPageReturnsPage(): void + { + $page = new SubscribePage(); + $this->pageRepository + ->expects($this->once()) + ->method('find') + ->with(123) + ->willReturn($page); + + $result = $this->manager->getPage(123); + + $this->assertSame($page, $result); + } + + public function testGetPageThrowsWhenNotFound(): void + { + $this->pageRepository + ->expects($this->once()) + ->method('find') + ->with(999) + ->willReturn(null); + + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('Subscribe page not found'); + + $this->manager->getPage(999); + } + + public function testUpdatePageUpdatesProvidedFieldsAndFlushes(): void + { + $originalOwner = new Administrator(); + $newOwner = new Administrator(); + $page = (new SubscribePage()) + ->setTitle('Old Title') + ->setActive(false) + ->setOwner($originalOwner); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $updated = $this->manager->updatePage($page, title: 'New Title', active: true, owner: $newOwner); + + $this->assertSame($page, $updated); + $this->assertSame('New Title', $updated->getTitle()); + $this->assertTrue($updated->isActive()); + $this->assertSame($newOwner, $updated->getOwner()); + } + + public function testUpdatePageLeavesNullFieldsUntouched(): void + { + $owner = new Administrator(); + $page = (new SubscribePage()) + ->setTitle('Keep Title') + ->setActive(true) + ->setOwner($owner); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $updated = $this->manager->updatePage(page: $page, title: null, active: null, owner: null); + + $this->assertSame('Keep Title', $updated->getTitle()); + $this->assertTrue($updated->isActive()); + $this->assertSame($owner, $updated->getOwner()); + } + + public function testSetActiveSetsFlagAndFlushes(): void + { + $page = (new SubscribePage()) + ->setTitle('Any') + ->setActive(false); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $this->manager->setActive($page, true); + $this->assertTrue($page->isActive()); + } + + public function testDeletePageCallsRepositoryRemove(): void + { + $page = new SubscribePage(); + + $this->pageRepository + ->expects($this->once()) + ->method('remove') + ->with($page); + + $this->manager->deletePage($page); + } + + public function testGetPageDataReturnsStringWhenFound(): void + { + $page = new SubscribePage(); + $data = $this->createMock(SubscribePageData::class); + $data->expects($this->once())->method('getData')->willReturn('value'); + + $this->pageDataRepository + ->expects($this->once()) + ->method('getByPage') + ->with($page) + ->willReturn([$data]); + + $result = $this->manager->getPageData($page); + $this->assertIsArray($result); + $this->assertSame('value', $result[0]->getData()); + } + + public function testGetPageDataReturnsNullWhenNotFound(): void + { + $page = new SubscribePage(); + + $this->pageDataRepository + ->expects($this->once()) + ->method('getByPage') + ->with($page) + ->willReturn([]); + + $result = $this->manager->getPageData($page); + $this->assertEmpty($result); + } + + public function testSetPageDataUpdatesExistingDataAndFlushes(): void + { + $page = new SubscribePage(); + $existing = new SubscribePageData(); + $existing->setId(5)->setName('color')->setData('red'); + + $this->pageDataRepository + ->expects($this->once()) + ->method('findByPageAndName') + ->with($page, 'color') + ->willReturn($existing); + + $this->entityManager + ->expects($this->never()) + ->method('persist'); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $result = $this->manager->setPageData($page, 'color', 'blue'); + + $this->assertSame($existing, $result); + $this->assertSame('blue', $result->getData()); + } + + public function testSetPageDataCreatesNewWhenMissingAndPersistsAndFlushes(): void + { + $page = $this->getMockBuilder(SubscribePage::class) + ->onlyMethods(['getId']) + ->getMock(); + $page->method('getId')->willReturn(123); + + $this->pageDataRepository + ->expects($this->once()) + ->method('findByPageAndName') + ->with($page, 'greeting') + ->willReturn(null); + + $this->entityManager + ->expects($this->once()) + ->method('persist') + ->with($this->isInstanceOf(SubscribePageData::class)); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $result = $this->manager->setPageData($page, 'greeting', 'hello'); + + $this->assertInstanceOf(SubscribePageData::class, $result); + $this->assertSame(123, $result->getId()); + $this->assertSame('greeting', $result->getName()); + $this->assertSame('hello', $result->getData()); + } +} From 9d0c1dba32ecc04ce07b885ea5817d04583913b2 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Tue, 19 Aug 2025 10:03:12 +0400 Subject: [PATCH 03/10] Bounceregex manager (#354) * BounceRegexManager * Fix manager directory * Prop name update admin -> adminId --------- Co-authored-by: Tatevik --- config/services/managers.yml | 40 ++--- config/services/repositories.yml | 5 + src/Domain/Messaging/Model/BounceRegex.php | 16 +- .../Repository/BounceRegexRepository.php | 6 + .../Service/Manager/BounceRegexManager.php | 99 ++++++++++++ .../Service/{ => Manager}/MessageManager.php | 2 +- .../{ => Manager}/TemplateImageManager.php | 2 +- .../Service/{ => Manager}/TemplateManager.php | 2 +- .../Manager/BounceRegexManagerTest.php | 144 ++++++++++++++++++ .../{ => Manager}/ListMessageManagerTest.php | 2 +- .../{ => Manager}/MessageManagerTest.php | 4 +- .../TemplateImageManagerTest.php | 4 +- .../{ => Manager}/TemplateManagerTest.php | 6 +- 13 files changed, 295 insertions(+), 37 deletions(-) create mode 100644 src/Domain/Messaging/Service/Manager/BounceRegexManager.php rename src/Domain/Messaging/Service/{ => Manager}/MessageManager.php (96%) rename src/Domain/Messaging/Service/{ => Manager}/TemplateImageManager.php (98%) rename src/Domain/Messaging/Service/{ => Manager}/TemplateManager.php (98%) create mode 100644 tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php rename tests/Unit/Domain/Messaging/Service/{ => Manager}/ListMessageManagerTest.php (98%) rename tests/Unit/Domain/Messaging/Service/{ => Manager}/MessageManagerTest.php (97%) rename tests/Unit/Domain/Messaging/Service/{ => Manager}/TemplateImageManagerTest.php (95%) rename tests/Unit/Domain/Messaging/Service/{ => Manager}/TemplateManagerTest.php (93%) diff --git a/config/services/managers.yml b/config/services/managers.yml index d253fc95..0f6bb119 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -4,74 +4,78 @@ services: autoconfigure: true public: false - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager: + PhpList\Core\Domain\Identity\Service\SessionManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Identity\Service\SessionManager: + PhpList\Core\Domain\Identity\Service\AdministratorManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager: + PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager: + PhpList\Core\Domain\Identity\Service\AdminAttributeManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\MessageManager: + PhpList\Core\Domain\Identity\Service\PasswordManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\TemplateManager: + PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\TemplateImageManager: + PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Identity\Service\AdministratorManager: + PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager: + PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Identity\Service\AdminAttributeManager: + PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager: + PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager: + PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager: + PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: + PhpList\Core\Domain\Messaging\Service\Manager\MessageManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Identity\Service\PasswordManager: + PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager: + PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager: + PhpList\Core\Domain\Messaging\Service\Manager\BounceRegexManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager: + PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: autowire: true autoconfigure: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml index 02c9e7d3..69bdb6ce 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -130,3 +130,8 @@ services: 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 diff --git a/src/Domain/Messaging/Model/BounceRegex.php b/src/Domain/Messaging/Model/BounceRegex.php index 510aaad8..0401d26b 100644 --- a/src/Domain/Messaging/Model/BounceRegex.php +++ b/src/Domain/Messaging/Model/BounceRegex.php @@ -31,8 +31,8 @@ class BounceRegex implements DomainModel, Identity #[ORM\Column(name: 'listorder', type: 'integer', nullable: true, options: ['default' => 0])] private ?int $listOrder = 0; - #[ORM\Column(type: 'integer', nullable: true)] - private ?int $admin; + #[ORM\Column(name: 'admin', type: 'integer', nullable: true)] + private ?int $adminId; #[ORM\Column(type: 'text', nullable: true)] private ?string $comment; @@ -48,7 +48,7 @@ public function __construct( ?string $regexHash = null, ?string $action = null, ?int $listOrder = 0, - ?int $admin = null, + ?int $adminId = null, ?string $comment = null, ?string $status = null, ?int $count = 0 @@ -57,7 +57,7 @@ public function __construct( $this->regexHash = $regexHash; $this->action = $action; $this->listOrder = $listOrder; - $this->admin = $admin; + $this->adminId = $adminId; $this->comment = $comment; $this->status = $status; $this->count = $count; @@ -112,14 +112,14 @@ public function setListOrder(?int $listOrder): self return $this; } - public function getAdmin(): ?int + public function getAdminId(): ?int { - return $this->admin; + return $this->adminId; } - public function setAdmin(?int $admin): self + public function setAdminId(?int $adminId): self { - $this->admin = $admin; + $this->adminId = $adminId; return $this; } diff --git a/src/Domain/Messaging/Repository/BounceRegexRepository.php b/src/Domain/Messaging/Repository/BounceRegexRepository.php index a08f65c0..f5088376 100644 --- a/src/Domain/Messaging/Repository/BounceRegexRepository.php +++ b/src/Domain/Messaging/Repository/BounceRegexRepository.php @@ -7,8 +7,14 @@ 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\BounceRegex; class BounceRegexRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + public function findOneByRegexHash(string $regexHash): ?BounceRegex + { + return $this->findOneBy(['regexHash' => $regexHash]); + } } diff --git a/src/Domain/Messaging/Service/Manager/BounceRegexManager.php b/src/Domain/Messaging/Service/Manager/BounceRegexManager.php new file mode 100644 index 00000000..c9d60580 --- /dev/null +++ b/src/Domain/Messaging/Service/Manager/BounceRegexManager.php @@ -0,0 +1,99 @@ +bounceRegexRepository = $bounceRegexRepository; + $this->entityManager = $entityManager; + } + + /** + * Creates or updates (if exists) a BounceRegex from a raw regex pattern. + */ + public function createOrUpdateFromPattern( + string $regex, + ?string $action = null, + ?int $listOrder = 0, + ?int $adminId = null, + ?string $comment = null, + ?string $status = null + ): BounceRegex { + $regexHash = md5($regex); + + $existing = $this->bounceRegexRepository->findOneByRegexHash($regexHash); + + if ($existing !== null) { + $existing->setRegex($regex) + ->setAction($action ?? $existing->getAction()) + ->setListOrder($listOrder ?? $existing->getListOrder()) + ->setAdminId($adminId ?? $existing->getAdminId()) + ->setComment($comment ?? $existing->getComment()) + ->setStatus($status ?? $existing->getStatus()); + + $this->bounceRegexRepository->save($existing); + + return $existing; + } + + $bounceRegex = new BounceRegex( + regex: $regex, + regexHash: $regexHash, + action: $action, + listOrder: $listOrder, + adminId: $adminId, + comment: $comment, + status: $status, + count: 0 + ); + + $this->bounceRegexRepository->save($bounceRegex); + + return $bounceRegex; + } + + /** @return BounceRegex[] */ + public function getAll(): array + { + return $this->bounceRegexRepository->findAll(); + } + + public function getByHash(string $regexHash): ?BounceRegex + { + return $this->bounceRegexRepository->findOneByRegexHash($regexHash); + } + + public function delete(BounceRegex $bounceRegex): void + { + $this->bounceRegexRepository->remove($bounceRegex); + } + + /** + * Associates a bounce with the regex it matched and increments usage count. + */ + public function associateBounce(BounceRegex $regex, Bounce $bounce): BounceRegexBounce + { + $relation = new BounceRegexBounce($regex->getId() ?? 0, $bounce->getId() ?? 0); + $this->entityManager->persist($relation); + + $regex->setCount(($regex->getCount() ?? 0) + 1); + $this->entityManager->flush(); + + return $relation; + } +} diff --git a/src/Domain/Messaging/Service/MessageManager.php b/src/Domain/Messaging/Service/Manager/MessageManager.php similarity index 96% rename from src/Domain/Messaging/Service/MessageManager.php rename to src/Domain/Messaging/Service/Manager/MessageManager.php index 9af4df0b..7b263083 100644 --- a/src/Domain/Messaging/Service/MessageManager.php +++ b/src/Domain/Messaging/Service/Manager/MessageManager.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service; +namespace PhpList\Core\Domain\Messaging\Service\Manager; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Dto\MessageContext; diff --git a/src/Domain/Messaging/Service/TemplateImageManager.php b/src/Domain/Messaging/Service/Manager/TemplateImageManager.php similarity index 98% rename from src/Domain/Messaging/Service/TemplateImageManager.php rename to src/Domain/Messaging/Service/Manager/TemplateImageManager.php index c5ebd3f4..30705715 100644 --- a/src/Domain/Messaging/Service/TemplateImageManager.php +++ b/src/Domain/Messaging/Service/Manager/TemplateImageManager.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service; +namespace PhpList\Core\Domain\Messaging\Service\Manager; use Doctrine\ORM\EntityManagerInterface; use DOMDocument; diff --git a/src/Domain/Messaging/Service/TemplateManager.php b/src/Domain/Messaging/Service/Manager/TemplateManager.php similarity index 98% rename from src/Domain/Messaging/Service/TemplateManager.php rename to src/Domain/Messaging/Service/Manager/TemplateManager.php index 35678484..7de31843 100644 --- a/src/Domain/Messaging/Service/TemplateManager.php +++ b/src/Domain/Messaging/Service/Manager/TemplateManager.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service; +namespace PhpList\Core\Domain\Messaging\Service\Manager; use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Common\Model\ValidationContext; diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php new file mode 100644 index 00000000..1cd432bc --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php @@ -0,0 +1,144 @@ +regexRepository = $this->createMock(BounceRegexRepository::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + + $this->manager = new BounceRegexManager( + bounceRegexRepository: $this->regexRepository, + entityManager: $this->entityManager + ); + } + + public function testCreateNewRegex(): void + { + $pattern = 'user unknown'; + $expectedHash = md5($pattern); + + $this->regexRepository->expects($this->once()) + ->method('findOneByRegexHash') + ->with($expectedHash) + ->willReturn(null); + + $this->regexRepository->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(BounceRegex::class)); + + $regex = $this->manager->createOrUpdateFromPattern( + regex: $pattern, + action: 'delete', + listOrder: 5, + adminId: 1, + comment: 'test', + status: 'active' + ); + + $this->assertInstanceOf(BounceRegex::class, $regex); + $this->assertSame($pattern, $regex->getRegex()); + $this->assertSame($expectedHash, $regex->getRegexHash()); + $this->assertSame('delete', $regex->getAction()); + $this->assertSame(5, $regex->getListOrder()); + $this->assertSame(1, $regex->getAdminId()); + $this->assertSame('test', $regex->getComment()); + $this->assertSame('active', $regex->getStatus()); + } + + public function testUpdateExistingRegex(): void + { + $pattern = 'mailbox full'; + $hash = md5($pattern); + + $existing = new BounceRegex( + regex: $pattern, + regexHash: $hash, + action: 'keep', + listOrder: 0, + adminId: null, + comment: null, + status: 'inactive', + count: 3 + ); + + $this->regexRepository->expects($this->once()) + ->method('findOneByRegexHash') + ->with($hash) + ->willReturn($existing); + + $this->regexRepository->expects($this->once()) + ->method('save') + ->with($existing); + + $updated = $this->manager->createOrUpdateFromPattern( + regex: $pattern, + action: 'delete', + listOrder: 10, + adminId: 2, + comment: 'upd', + status: 'active' + ); + + $this->assertSame('delete', $updated->getAction()); + $this->assertSame(10, $updated->getListOrder()); + $this->assertSame(2, $updated->getAdminId()); + $this->assertSame('upd', $updated->getComment()); + $this->assertSame('active', $updated->getStatus()); + $this->assertSame($hash, $updated->getRegexHash()); + } + + public function testDeleteRegex(): void + { + $model = $this->createMock(BounceRegex::class); + + $this->regexRepository->expects($this->once()) + ->method('remove') + ->with($model); + + $this->manager->delete($model); + } + + public function testAssociateBounceIncrementsCountAndPersistsRelation(): void + { + $regex = new BounceRegex(regex: 'x', regexHash: md5('x')); + + $refRegex = new ReflectionProperty(BounceRegex::class, 'id'); + $refRegex->setValue($regex, 7); + + $bounce = $this->createMock(Bounce::class); + $bounce->method('getId')->willReturn(11); + + $this->entityManager->expects($this->once()) + ->method('persist') + ->with($this->callback(function ($entity) use ($regex) { + return $entity instanceof BounceRegexBounce + && $entity->getRegex() === $regex->getId(); + })); + + $this->entityManager->expects($this->once()) + ->method('flush'); + + $this->assertSame(0, $regex->getCount()); + $this->manager->associateBounce($regex, $bounce); + $this->assertSame(1, $regex->getCount()); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/ListMessageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.php similarity index 98% rename from tests/Unit/Domain/Messaging/Service/ListMessageManagerTest.php rename to tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.php index 2ec4180f..2f1af5fe 100644 --- a/tests/Unit/Domain/Messaging/Service/ListMessageManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.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\Manager; use DateTime; use Doctrine\ORM\EntityManagerInterface; diff --git a/tests/Unit/Domain/Messaging/Service/MessageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php similarity index 97% rename from tests/Unit/Domain/Messaging/Service/MessageManagerTest.php rename to tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php index 8ee85915..aa1a47e0 100644 --- a/tests/Unit/Domain/Messaging/Service/MessageManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.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\Manager; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Dto\CreateMessageDto; @@ -15,7 +15,7 @@ use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\Builder\MessageBuilder; -use PhpList\Core\Domain\Messaging\Service\MessageManager; +use PhpList\Core\Domain\Messaging\Service\Manager\MessageManager; use PHPUnit\Framework\TestCase; class MessageManagerTest extends TestCase diff --git a/tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php similarity index 95% rename from tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php rename to tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php index bde3569a..7eb6afe7 100644 --- a/tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager; use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Messaging\Model\Template; use PhpList\Core\Domain\Messaging\Model\TemplateImage; use PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository; -use PhpList\Core\Domain\Messaging\Service\TemplateImageManager; +use PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php similarity index 93% rename from tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php rename to tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php index fbbb4831..d3748244 100644 --- a/tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager; use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Messaging\Model\Dto\CreateTemplateDto; use PhpList\Core\Domain\Messaging\Model\Template; use PhpList\Core\Domain\Messaging\Repository\TemplateRepository; -use PhpList\Core\Domain\Messaging\Service\TemplateImageManager; -use PhpList\Core\Domain\Messaging\Service\TemplateManager; +use PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager; +use PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager; use PhpList\Core\Domain\Messaging\Validator\TemplateImageValidator; use PhpList\Core\Domain\Messaging\Validator\TemplateLinkValidator; use PHPUnit\Framework\MockObject\MockObject; From dc99df1bf9e445b7e3182384e831972a042c82d4 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Tue, 2 Sep 2025 11:21:21 +0400 Subject: [PATCH 04/10] Bounce processing command (#355) * BounceManager * Add bounce email * Move to the processor dir * SendProcess lock service * ClientIp + SystemInfo * ProcessBouncesCommand * ProcessBouncesCommand all methods * BounceProcessingService * AdvancedBounceRulesProcessor * UnidentifiedBounceReprocessor * ConsecutiveBounceHandler * Refactor * BounceDataProcessor * ClientFactory + refactor * BounceProcessorPass * Register services + phpstan fix * PhpMd * PhpMd CyclomaticComplexity * PhpCodeSniffer * Tests * Refactor * Add tests * More tests * Fix tests --------- Co-authored-by: Tatevik --- composer.json | 4 +- config/PHPMD/rules.xml | 2 +- config/PhpCodeSniffer/ruleset.xml | 9 - config/parameters.yml.dist | 26 ++ config/services.yml | 96 +++--- config/services/builders.yml | 2 +- config/services/commands.yml | 4 + config/services/managers.yml | 12 + config/services/processor.yml | 21 ++ config/services/providers.yml | 4 + config/services/repositories.yml | 287 +++++++++--------- config/services/services.yml | 143 ++++++--- src/Core/ApplicationKernel.php | 1 + src/Core/BounceProcessorPass.php | 28 ++ src/Domain/Common/ClientIpResolver.php | 28 ++ .../Common/Mail/NativeImapMailReader.php | 65 ++++ src/Domain/Common/SystemInfoCollector.php | 77 +++++ .../Command/ProcessBouncesCommand.php | 114 +++++++ .../Messaging/Command/ProcessQueueCommand.php | 8 +- .../Messaging/Model/BounceRegexBounce.php | 30 +- .../Messaging/Model/UserMessageBounce.php | 16 +- .../Repository/BounceRegexRepository.php | 12 + .../Messaging/Repository/BounceRepository.php | 7 + .../Repository/MessageRepository.php | 11 + .../Repository/SendProcessRepository.php | 67 ++++ .../UserMessageBounceRepository.php | 69 +++++ .../Service/BounceActionResolver.php | 65 ++++ .../BounceProcessingServiceInterface.php | 10 + .../Service/ConsecutiveBounceHandler.php | 141 +++++++++ src/Domain/Messaging/Service/EmailService.php | 17 +- .../BlacklistEmailAndDeleteBounceHandler.php | 47 +++ .../Service/Handler/BlacklistEmailHandler.php | 42 +++ .../BlacklistUserAndDeleteBounceHandler.php | 47 +++ .../Service/Handler/BlacklistUserHandler.php | 42 +++ .../Handler/BounceActionHandlerInterface.php | 11 + ...CountConfirmUserAndDeleteBounceHandler.php | 51 ++++ .../Service/Handler/DeleteBounceHandler.php | 27 ++ .../Handler/DeleteUserAndBounceHandler.php | 33 ++ .../Service/Handler/DeleteUserHandler.php | 36 +++ .../UnconfirmUserAndDeleteBounceHandler.php | 44 +++ .../Service/Handler/UnconfirmUserHandler.php | 39 +++ src/Domain/Messaging/Service/LockService.php | 172 +++++++++++ .../Service/Manager/BounceManager.php | 138 +++++++++ .../Service/Manager/BounceRuleManager.php | 110 +++++++ .../Service/Manager/SendProcessManager.php | 57 ++++ .../Messaging/Service/MessageParser.php | 102 +++++++ .../Service/NativeBounceProcessingService.php | 138 +++++++++ .../AdvancedBounceRulesProcessor.php | 120 ++++++++ .../Service/Processor/BounceDataProcessor.php | 155 ++++++++++ .../Processor/BounceProtocolProcessor.php | 24 ++ .../{ => Processor}/CampaignProcessor.php | 3 +- .../Service/Processor/MboxBounceProcessor.php | 46 +++ .../Service/Processor/PopBounceProcessor.php | 59 ++++ .../UnidentifiedBounceReprocessor.php | 70 +++++ .../WebklexBounceProcessingService.php | 268 ++++++++++++++++ .../Service/WebklexImapClientFactory.php | 79 +++++ .../Repository/SubscriberRepository.php | 47 +++ .../Manager/SubscriberBlacklistManager.php | 10 + .../Manager/SubscriberHistoryManager.php | 28 +- .../Service/Manager/SubscriberManager.php | 19 +- .../Service/SubscriberBlacklistService.php | 69 +++++ .../Service/SubscriberDeletionServiceTest.php | 3 +- .../Domain/Common/ClientIpResolverTest.php | 61 ++++ .../Domain/Common/SystemInfoCollectorTest.php | 95 ++++++ .../Command/ProcessBouncesCommandTest.php | 197 ++++++++++++ .../Command/ProcessQueueCommandTest.php | 2 +- .../Service/BounceActionResolverTest.php | 66 ++++ .../Service/ConsecutiveBounceHandlerTest.php | 212 +++++++++++++ .../Messaging/Service/EmailServiceTest.php | 8 +- ...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 +++++ .../Messaging/Service/LockServiceTest.php | 88 ++++++ .../Service/Manager/BounceManagerTest.php | 205 +++++++++++++ .../Manager/BounceRegexManagerTest.php | 2 +- .../Service/Manager/BounceRuleManagerTest.php | 143 +++++++++ .../Manager/SendProcessManagerTest.php | 86 ++++++ .../Manager/TemplateImageManagerTest.php | 4 +- .../Messaging/Service/MessageParserTest.php | 76 +++++ .../AdvancedBounceRulesProcessorTest.php | 177 +++++++++++ .../Processor/BounceDataProcessorTest.php | 168 ++++++++++ .../{ => Processor}/CampaignProcessorTest.php | 4 +- .../Processor/MboxBounceProcessorTest.php | 76 +++++ .../Processor/PopBounceProcessorTest.php | 64 ++++ .../UnidentifiedBounceReprocessorTest.php | 75 +++++ .../Service/WebklexImapClientFactoryTest.php | 70 +++++ .../Manager/SubscriberHistoryManagerTest.php | 6 +- .../Service/Manager/SubscriberManagerTest.php | 2 +- 95 files changed, 5884 insertions(+), 284 deletions(-) create mode 100644 config/services/processor.yml create mode 100644 src/Core/BounceProcessorPass.php create mode 100644 src/Domain/Common/ClientIpResolver.php create mode 100644 src/Domain/Common/Mail/NativeImapMailReader.php create mode 100644 src/Domain/Common/SystemInfoCollector.php create mode 100644 src/Domain/Messaging/Command/ProcessBouncesCommand.php create mode 100644 src/Domain/Messaging/Service/BounceActionResolver.php create mode 100644 src/Domain/Messaging/Service/BounceProcessingServiceInterface.php create mode 100644 src/Domain/Messaging/Service/ConsecutiveBounceHandler.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 create mode 100644 src/Domain/Messaging/Service/LockService.php create mode 100644 src/Domain/Messaging/Service/Manager/BounceManager.php create mode 100644 src/Domain/Messaging/Service/Manager/BounceRuleManager.php create mode 100644 src/Domain/Messaging/Service/Manager/SendProcessManager.php create mode 100644 src/Domain/Messaging/Service/MessageParser.php create mode 100644 src/Domain/Messaging/Service/NativeBounceProcessingService.php create mode 100644 src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php create mode 100644 src/Domain/Messaging/Service/Processor/BounceDataProcessor.php create mode 100644 src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php rename src/Domain/Messaging/Service/{ => Processor}/CampaignProcessor.php (95%) create mode 100644 src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php create mode 100644 src/Domain/Messaging/Service/Processor/PopBounceProcessor.php create mode 100644 src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php create mode 100644 src/Domain/Messaging/Service/WebklexBounceProcessingService.php create mode 100644 src/Domain/Messaging/Service/WebklexImapClientFactory.php create mode 100644 src/Domain/Subscription/Service/SubscriberBlacklistService.php 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 create mode 100644 tests/Unit/Domain/Messaging/Service/LockServiceTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.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 (98%) 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/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/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/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/config/parameters.yml.dist b/config/parameters.yml.dist index 621a8b81..54c649d8 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -32,6 +32,32 @@ 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' + 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)%%' env(MESSENGER_TRANSPORT_DSN): 'doctrine://default?auto_setup=true' 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/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/managers.yml b/config/services/managers.yml index 0f6bb119..5ef215b3 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 @@ -79,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 new file mode 100644 index 00000000..acbd11c0 --- /dev/null +++ b/config/services/processor.yml @@ -0,0 +1,21 @@ +services: + _defaults: + autowire: true + autoconfigure: true + 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: + tags: ['phplist.bounce_protocol_processor'] + + 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/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/repositories.yml b/config/services/repositories.yml index 69bdb6ce..82ae6a82 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -1,137 +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\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 7b9f921c..19caddd8 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -1,36 +1,109 @@ 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%' - - 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\CampaignProcessor: - autowire: true - autoconfigure: true - public: true + 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 + + _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' } 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..2ab5c9c5 --- /dev/null +++ b/src/Core/BounceProcessorPass.php @@ -0,0 +1,28 @@ +hasDefinition($native) || !$container->hasDefinition($webklex)) { + return; + } + + $aliasTo = extension_loaded('imap') ? $native : $webklex; + + $container->setAlias(BounceProcessingServiceInterface::class, $aliasTo)->setPublic(false); + } +} diff --git a/src/Domain/Common/ClientIpResolver.php b/src/Domain/Common/ClientIpResolver.php new file mode 100644 index 00000000..65cbbb6c --- /dev/null +++ b/src/Domain/Common/ClientIpResolver.php @@ -0,0 +1,28 @@ +requestStack = $requestStack; + } + + public function resolve(): string + { + $request = $this->requestStack->getCurrentRequest(); + + if ($request !== null) { + return $request->getClientIp() ?? ''; + } + + return (gethostname() ?: 'localhost') . ':' . getmypid(); + } +} diff --git a/src/Domain/Common/Mail/NativeImapMailReader.php b/src/Domain/Common/Mail/NativeImapMailReader.php new file mode 100644 index 00000000..472fea54 --- /dev/null +++ b/src/Domain/Common/Mail/NativeImapMailReader.php @@ -0,0 +1,65 @@ +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; + } + + public function numMessages(Connection $link): int + { + return imap_num_msg($link); + } + + public function fetchHeader(Connection $link, int $msgNo): string + { + return imap_fetchheader($link, $msgNo) ?: ''; + } + + public function headerDate(Connection $link, int $msgNo): DateTimeImmutable + { + $info = imap_headerinfo($link, $msgNo); + $date = $info->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/SystemInfoCollector.php b/src/Domain/Common/SystemInfoCollector.php new file mode 100644 index 00000000..e66d27b1 --- /dev/null +++ b/src/Domain/Common/SystemInfoCollector.php @@ -0,0 +1,77 @@ + 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). + * @SuppressWarnings(PHPMD.StaticAccess) + * @return array + */ + public function collect(): array + { + $request = $this->requestStack->getCurrentRequest() ?? Request::createFromGlobals(); + $data = []; + $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() ?? ''; + + $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); + } +} diff --git a/src/Domain/Messaging/Command/ProcessBouncesCommand.php b/src/Domain/Messaging/Command/ProcessBouncesCommand.php new file mode 100644 index 00000000..f1e3b403 --- /dev/null +++ b/src/Domain/Messaging/Command/ProcessBouncesCommand.php @@ -0,0 +1,114 @@ +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('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'); + } + + public function __construct( + private readonly LockService $lockService, + private readonly LoggerInterface $logger, + /** @var iterable */ + private readonly iterable $protocolProcessors, + private readonly AdvancedBounceRulesProcessor $advancedRulesProcessor, + private readonly UnidentifiedBounceReprocessor $unidentifiedReprocessor, + private readonly ConsecutiveBounceHandler $consecutiveBounceHandler, + ) { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $inputOutput = new SymfonyStyle($input, $output); + + if (!function_exists('imap_open')) { + $inputOutput->note(self::IMAP_NOT_AVAILABLE); + } + + $force = (bool)$input->getOption('force'); + $lock = $this->lockService->acquirePageLock('bounce_processor', $force); + + if (($lock ?? 0) === 0) { + $inputOutput->warning($force ? self::FORCE_LOCK_FAILED : self::ALREADY_LOCKED); + + return $force ? Command::FAILURE : Command::SUCCESS; + } + + try { + $inputOutput->title('Processing bounces'); + $protocol = (string)$input->getOption('protocol'); + + $downloadReport = ''; + + $processor = $this->findProcessorFor($protocol); + if ($processor === null) { + $inputOutput->error('Unsupported protocol: '.$protocol); + + return Command::FAILURE; + } + + $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]); + $inputOutput->success('Bounce processing completed.'); + + return Command::SUCCESS; + } catch (Exception $e) { + $this->logger->error('Bounce processing failed', ['exception' => $e]); + $inputOutput->error('Error: '.$e->getMessage()); + + return Command::FAILURE; + } finally { + $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/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/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..9aecde78 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/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/Repository/UserMessageBounceRepository.php b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php index 16f07f79..1b315f5e 100644 --- a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php +++ b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php @@ -7,6 +7,10 @@ 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\UserMessage; +use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; +use PhpList\Core\Domain\Subscription\Model\Subscriber; class UserMessageBounceRepository extends AbstractRepository implements PaginatableRepositoryInterface { @@ -21,4 +25,69 @@ public function getCountByMessageId(int $messageId): int ->getQuery() ->getSingleScalarResult(); } + + public function existsByMessageIdAndUserId(int $messageId, int $subscriberId): bool + { + return (bool) $this->createQueryBuilder('umb') + ->select('1') + ->where('umb.messageId = :messageId') + ->andWhere('umb.userId = :userId') + ->setParameter('messageId', $messageId) + ->setParameter('userId', $subscriberId) + ->setMaxResults(1) + ->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(); + } + + /** + * @return array + */ + public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array + { + return $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') + ->getQuery() + ->getResult(); + } } diff --git a/src/Domain/Messaging/Service/BounceActionResolver.php b/src/Domain/Messaging/Service/BounceActionResolver.php new file mode 100644 index 00000000..93d432dd --- /dev/null +++ b/src/Domain/Messaging/Service/BounceActionResolver.php @@ -0,0 +1,65 @@ + */ + 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)); + } + + $this->cache[$action] = $handler; + + return $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 new file mode 100644 index 00000000..9d16702f --- /dev/null +++ b/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php @@ -0,0 +1,10 @@ +bounceManager = $bounceManager; + $this->subscriberRepository = $subscriberRepository; + $this->subscriberHistoryManager = $subscriberHistoryManager; + $this->blacklistService = $blacklistService; + $this->unsubscribeThreshold = $unsubscribeThreshold; + $this->blacklistThreshold = $blacklistThreshold; + } + + 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; + } + + $processed = 0; + foreach ($users as $user) { + $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 (!$unsubscribed && $consecutive >= $this->unsubscribeThreshold) { + $unsubscribed = true; + } + } + } + + 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->subscriberRepository->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->blacklistService->blacklist( + subscriber: $user, + reason: sprintf('%d consecutive bounces, threshold reached', $consecutive) + ); + return true; + } + + return false; + } +} 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/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php new file mode 100644 index 00000000..d32cf68b --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php @@ -0,0 +1,47 @@ +subscriberHistoryManager = $subscriberHistoryManager; + $this->bounceManager = $bounceManager; + $this->blacklistService = $blacklistService; + } + + public function supports(string $action): bool + { + return $action === 'blacklistemailanddeletebounce'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber'])) { + $this->blacklistService->blacklist( + subscriber: $closureData['subscriber'], + reason: '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..9a92088c --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php @@ -0,0 +1,42 @@ +subscriberHistoryManager = $subscriberHistoryManager; + $this->blacklistService = $blacklistService; + } + + public function supports(string $action): bool + { + return $action === 'blacklistemail'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber'])) { + $this->blacklistService->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..b017fe9c --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php @@ -0,0 +1,47 @@ +subscriberHistoryManager = $subscriberHistoryManager; + $this->bounceManager = $bounceManager; + $this->blacklistService = $blacklistService; + } + + public function supports(string $action): bool + { + return $action === 'blacklistuseranddeletebounce'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber']) && !$closureData['blacklisted']) { + $this->blacklistService->blacklist( + subscriber: $closureData['subscriber'], + reason: 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId'] + ); + $this->subscriberHistoryManager->addHistory( + 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 new file mode 100644 index 00000000..75c8b810 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php @@ -0,0 +1,42 @@ +subscriberHistoryManager = $subscriberHistoryManager; + $this->blacklistService = $blacklistService; + } + + public function supports(string $action): bool + { + return $action === 'blacklistuser'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber']) && !$closureData['blacklisted']) { + $this->blacklistService->blacklist( + subscriber: $closureData['subscriber'], + reason: 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId'] + ); + $this->subscriberHistoryManager->addHistory( + subscriber: $closureData['subscriber'], + message: 'Auto Unsubscribed', + details: '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 @@ +subscriberHistoryManager = $subscriberHistoryManager; + $this->subscriberManager = $subscriberManager; + $this->bounceManager = $bounceManager; + $this->subscriberRepository = $subscriberRepository; + } + + public function supports(string $action): bool + { + return $action === 'decreasecountconfirmuseranddeletebounce'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber'])) { + $this->subscriberManager->decrementBounceCount($closureData['subscriber']); + if (!$closureData['confirmed']) { + $this->subscriberRepository->markConfirmed($closureData['userId']); + $this->subscriberHistoryManager->addHistory( + subscriber: $closureData['subscriber'], + message: 'Auto confirmed', + details: '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..80c881a1 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php @@ -0,0 +1,27 @@ +bounceManager = $bounceManager; + } + + public function supports(string $action): bool + { + return $action === 'deletebounce'; + } + + public function handle(array $closureData): void + { + $this->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..d8887545 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php @@ -0,0 +1,33 @@ +bounceManager = $bounceManager; + $this->subscriberManager = $subscriberManager; + } + + public function supports(string $action): bool + { + return $action === 'deleteuserandbounce'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber'])) { + $this->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..64b1a073 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php @@ -0,0 +1,36 @@ +subscriberManager = $subscriberManager; + $this->logger = $logger; + } + + public function supports(string $action): bool + { + return $action === 'deleteuser'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber'])) { + $this->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..7ca39be8 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php @@ -0,0 +1,44 @@ +subscriberHistoryManager = $subscriberHistoryManager; + $this->subscriberRepository = $subscriberRepository; + $this->bounceManager = $bounceManager; + } + + public function supports(string $action): bool + { + return $action === 'unconfirmuseranddeletebounce'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber']) && $closureData['confirmed']) { + $this->subscriberRepository->markUnconfirmed($closureData['userId']); + $this->subscriberHistoryManager->addHistory( + 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 new file mode 100644 index 00000000..a5bdd0fe --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php @@ -0,0 +1,39 @@ +subscriberRepository = $subscriberRepository; + $this->subscriberHistoryManager = $subscriberHistoryManager; + } + + public function supports(string $action): bool + { + return $action === 'unconfirmuser'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber']) && $closureData['confirmed']) { + $this->subscriberRepository->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 new file mode 100644 index 00000000..d2f1eb34 --- /dev/null +++ b/src/Domain/Messaging/Service/LockService.php @@ -0,0 +1,172 @@ +repo = $repo; + $this->manager = $manager; + $this->logger = $logger; + $this->staleAfterSeconds = $staleAfterSeconds; + $this->sleepSeconds = $sleepSeconds; + $this->maxWaitCycles = $maxWaitCycles; + } + + /** + * @SuppressWarnings("BooleanArgumentFlag") + */ + public function acquirePageLock( + string $page, + bool $force = false, + bool $isCli = false, + bool $multiSend = false, + int $maxSendProcesses = 1, + ?string $clientIp = null, + ): ?int { + $page = $this->sanitizePage($page); + $max = $this->resolveMax($isCli, $multiSend, $maxSendProcesses); + + 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) { + if ($this->tryStealIfStale($running)) { + continue; + } + + $this->logAliveAge($running); + + 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; + } + + continue; + } + + $processIdentifier = $this->buildProcessIdentifier($isCli, $clientIp); + $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 + { + $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 new file mode 100644 index 00000000..f13c46ff --- /dev/null +++ b/src/Domain/Messaging/Service/Manager/BounceManager.php @@ -0,0 +1,138 @@ +bounceRepository = $bounceRepository; + $this->userMessageBounceRepo = $userMessageBounceRepo; + $this->entityManager = $entityManager; + $this->logger = $logger; + } + + public function create( + ?DateTimeImmutable $date = null, + ?string $header = null, + ?string $data = null, + ?string $status = null, + ?string $comment = null + ): Bounce { + $bounce = new Bounce( + date: new DateTime($date->format('Y-m-d H:i:s')), + header: $header, + data: $data, + status: $status, + comment: $comment + ); + + $this->bounceRepository->save($bounce); + + return $bounce; + } + + 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 + { + $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; + } + + public function linkUserMessageBounce( + Bounce $bounce, + DateTimeImmutable $date, + int $subscriberId, + ?int $messageId = -1 + ): UserMessageBounce { + $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; + } + + public function existsUserMessageBounce(int $subscriberId, int $messageId): bool + { + return $this->userMessageBounceRepo->existsByMessageIdAndUserId($messageId, $subscriberId); + } + + /** @return Bounce[] */ + public function findByStatus(string $status): array + { + return $this->bounceRepository->findByStatus($status); + } + + public function getUserMessageBounceCount(): int + { + return $this->userMessageBounceRepo->count(); + } + + /** + * @return array + */ + public function fetchUserMessageBounceBatch(int $fromId, int $batchSize): array + { + return $this->userMessageBounceRepo->getPaginatedWithJoinNoRelation($fromId, $batchSize); + } + + /** + * @return array + */ + 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 new file mode 100644 index 00000000..70a750a9 --- /dev/null +++ b/src/Domain/Messaging/Service/Manager/BounceRuleManager.php @@ -0,0 +1,110 @@ + + */ + 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) { + $quoted = '/'.preg_quote(str_replace(' ', '\s+', $pattern)).'/iUm'; + if ($this->safePregMatch($quoted, $text)) { + return $rule; + } + $raw = '/'.str_replace(' ', '\s+', $pattern).'/iUm'; + if ($this->safePregMatch($raw, $text)) { + return $rule; + } + } + + 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); + + $this->repository->save($rule); + } + + public function linkRuleToBounce(BounceRegex $rule, Bounce $bounce): BounceregexBounce + { + $relation = new BounceRegexBounce($rule->getId(), $bounce->getId()); + $this->bounceRelationRepository->save($relation); + + return $relation; + } +} diff --git a/src/Domain/Messaging/Service/Manager/SendProcessManager.php b/src/Domain/Messaging/Service/Manager/SendProcessManager.php new file mode 100644 index 00000000..0100ed29 --- /dev/null +++ b/src/Domain/Messaging/Service/Manager/SendProcessManager.php @@ -0,0 +1,57 @@ +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, + ]; + } +} diff --git a/src/Domain/Messaging/Service/MessageParser.php b/src/Domain/Messaging/Service/MessageParser.php new file mode 100644 index 00000000..14b4f952 --- /dev/null +++ b/src/Domain/Messaging/Service/MessageParser.php @@ -0,0 +1,102 @@ +subscriberRepository = $subscriberRepository; + } + + 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 + { + if (preg_match('/(?:X-MessageId|X-Message): (.*)\r\n/iU', $text, $match)) { + return trim($match[1]); + } + + return null; + } + + public function findUserId(string $text): ?int + { + $candidate = $this->extractUserHeader($text); + if ($candidate) { + $id = $this->resolveUserIdentifier($candidate); + 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; + } + } + + return null; + } +} diff --git a/src/Domain/Messaging/Service/NativeBounceProcessingService.php b/src/Domain/Messaging/Service/NativeBounceProcessingService.php new file mode 100644 index 00000000..eee5bb98 --- /dev/null +++ b/src/Domain/Messaging/Service/NativeBounceProcessingService.php @@ -0,0 +1,138 @@ +bounceManager = $bounceManager; + $this->mailReader = $mailReader; + $this->messageParser = $messageParser; + $this->bounceDataProcessor = $bounceDataProcessor; + $this->logger = $logger; + $this->purgeProcessed = $purgeProcessed; + $this->purgeUnprocessed = $purgeUnprocessed; + } + + public function processMailbox( + string $mailbox, + int $max, + bool $testMode + ): string { + $link = $this->openOrFail($mailbox, $testMode); + + $num = $this->prepareAndCapCount($link, $max); + if ($num === 0) { + $this->mailReader->close($link, false); + + return ''; + } + + $this->bounceManager->announceDeletionMode($testMode); + + for ($messageNumber = 1; $messageNumber <= $num; $messageNumber++) { + $this->handleMessage($link, $messageNumber, $testMode); + } + + $this->finalize($link, $testMode); + + return ''; + } + + private function openOrFail(string $mailbox, bool $testMode): Connection + { + try { + return $this->mailReader->open($mailbox, $testMode ? 0 : CL_EXPUNGE); + } catch (Throwable $e) { + $this->logger->error('Cannot open mailbox file: '.$e->getMessage()); + throw new RuntimeException('Cannot open mbox file'); + } + } + + private function prepareAndCapCount(Connection $link, int $max): int + { + $num = $this->mailReader->numMessages($link); + $this->logger->info(sprintf('%d bounces to fetch from the mailbox', $num)); + if ($num === 0) { + return 0; + } + + $this->logger->info('Please do not interrupt this process'); + if ($num > $max) { + $this->logger->info(sprintf('Processing first %d bounces', $max)); + $num = $max; + } + + return $num; + } + + 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; + } + + if (!$processed && $this->purgeUnprocessed) { + $this->mailReader->delete($link, $messageNumber); + } + } + + private function finalize(Connection $link, bool $testMode): void + { + $this->logger->info('Closing mailbox, and purging messages'); + $this->mailReader->close($link, !$testMode); + } + + private function processImapBounce($link, int $num, string $header): bool + { + $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->messageParser->findMessageId($body); + $userId = $this->messageParser->findUserId($body); + + $bounce = $this->bounceManager->create($bounceDate, $header, $body); + + return $this->bounceDataProcessor->process($bounce, $msgId, $userId, $bounceDate); + } +} diff --git a/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php new file mode 100644 index 00000000..568bf874 --- /dev/null +++ b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php @@ -0,0 +1,120 @@ +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; + $processed = 0; + + while ($processed < $total) { + $batch = $this->bounceManager->fetchUserMessageBounceBatch($fromId, $batchSize); + if (!$batch) { + break; + } + + foreach ($batch as $row) { + $fromId = $row['umb']->getId(); + + $bounce = $row['bounce']; + $userId = (int) $row['umb']->getUserId(); + $text = $this->composeText($bounce); + $rule = $this->ruleManager->matchBounceRules($text, $rules); + + if ($rule) { + $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++; + } + + $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)); + } + + 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 new file mode 100644 index 00000000..6f502a8c --- /dev/null +++ b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php @@ -0,0 +1,155 @@ +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 + { + $user = $userId ? $this->subscriberManager->getSubscriberById($userId) : null; + + if ($msgId === 'systemmessage') { + return $userId ? $this->handleSystemMessageWithUser( + $bounce, + $bounceDate, + $userId, + $user + ) : $this->handleSystemMessageUnknownUser($bounce); + } + + if ($msgId && $userId) { + return $this->handleKnownMessageAndUser($bounce, $bounceDate, (int)$msgId, $userId); + } + + if ($userId) { + return $this->handleUserOnly($bounce, $userId); + } + + if ($msgId) { + return $this->handleMessageOnly($bounce, (int)$msgId); + } + + $this->bounceManager->update($bounce, 'unidentified bounce', 'not processed'); + + 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->subscriberRepository->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()) + ); + } + + 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->messageRepository->incrementBounceCount($msgId); + $this->subscriberRepository->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) + ); + } + + return true; + } + + private function handleUserOnly(Bounce $bounce, int $userId): bool + { + $this->bounceManager->update( + bounce: $bounce, + status: 'bounced unidentified message', + comment: sprintf('%d bouncecount increased', $userId) + ); + $this->subscriberRepository->incrementBounceCount($userId); + + return true; + } + + private function handleMessageOnly(Bounce $bounce, int $msgId): bool + { + $this->bounceManager->update( + bounce: $bounce, + status: sprintf('bounced list message %d', $msgId), + comment: 'unknown user' + ); + $this->messageRepository->incrementBounceCount($msgId); + + return true; + } +} diff --git a/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php b/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php new file mode 100644 index 00000000..a0e7d904 --- /dev/null +++ b/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php @@ -0,0 +1,24 @@ +processingService = $processingService; + } + + public function getProtocol(): string + { + return 'mbox'; + } + + 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) { + $inputOutput->error('mbox file path must be provided with --mailbox.'); + throw new RuntimeException('Missing --mailbox for mbox protocol'); + } + + $inputOutput->section('Opening mbox ' . $file); + $inputOutput->writeln('Please do not interrupt this process'); + + return $this->processingService->processMailbox( + mailbox: $file, + max: $max, + testMode: $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..b6f59f65 --- /dev/null +++ b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php @@ -0,0 +1,59 @@ +processingService = $processingService; + $this->host = $host; + $this->port = $port; + $this->mailboxNames = $mailboxNames; + } + + public function getProtocol(): string + { + return 'pop'; + } + + public function process(InputInterface $input, SymfonyStyle $inputOutput): string + { + $testMode = (bool)$input->getOption('test'); + $max = (int)$input->getOption('maximum'); + + $downloadReport = ''; + foreach (explode(',', $this->mailboxNames) as $mailboxName) { + $mailboxName = trim($mailboxName); + 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( + mailbox: $mailbox, + max: $max, + testMode: $testMode + ); + } + + return $downloadReport; + } +} diff --git a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php new file mode 100644 index 00000000..503fc459 --- /dev/null +++ b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php @@ -0,0 +1,70 @@ +bounceManager = $bounceManager; + $this->messageParser = $messageParser; + $this->bounceDataProcessor = $bounceDataProcessor; + } + + public function process(SymfonyStyle $inputOutput): void + { + $inputOutput->section('Reprocessing unidentified bounces'); + $bounces = $this->bounceManager->findByStatus('unidentified bounce'); + $total = count($bounces); + $inputOutput->writeln(sprintf('%d bounces to reprocess', $total)); + + $count = 0; + $reparsed = 0; + $reidentified = 0; + foreach ($bounces as $bounce) { + $count++; + if ($count % 25 === 0) { + $inputOutput->writeln(sprintf('%d out of %d processed', $count, $total)); + } + + $decodedBody = $this->messageParser->decodeBody($bounce->getHeader(), $bounce->getData()); + $userId = $this->messageParser->findUserId($decodedBody); + $messageId = $this->messageParser->findMessageId($decodedBody); + + if ($userId || $messageId) { + $reparsed++; + if ($this->bounceDataProcessor->process( + $bounce, + $messageId, + $userId, + new DateTimeImmutable() + ) + ) { + $reidentified++; + } + } + } + + $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 new file mode 100644 index 00000000..01a94aff --- /dev/null +++ b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php @@ -0,0 +1,268 @@ +bounceManager = $bounceManager; + $this->logger = $logger; + $this->messageParser = $messageParser; + $this->clientFactory = $clientFactory; + $this->bounceDataProcessor = $bounceDataProcessor; + $this->purgeProcessed = $purgeProcessed; + $this->purgeUnprocessed = $purgeUnprocessed; + } + + /** + * 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, + int $max, + bool $testMode + ): string { + $client = $this->clientFactory->makeForMailbox(); + + try { + $client->connect(); + } catch (Throwable $e) { + $this->logger->error('Cannot connect to mailbox: '.$e->getMessage()); + throw new RuntimeException('Cannot connect to IMAP server'); + } + + try { + $folder = $client->getFolder($this->clientFactory->getFolderName()); + $query = $folder->query()->unseen()->limit($max); + + $messages = $query->get(); + $num = $messages->count(); + + $this->logger->info(sprintf('%d bounces to fetch from the mailbox', $num)); + if ($num === 0) { + return ''; + } + + $this->bounceManager->announceDeletionMode($testMode); + + 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 && $this->purgeProcessed) { + $this->safeDelete($message); + } + continue; + } + + $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, $messageId, $userId, $bounceDate); + + $this->processDelete($testMode, $processed, $message); + } + + $this->logger->info('Closing mailbox, and purging messages'); + $this->processExpunge($testMode, $folder, $client); + + return ''; + } finally { + try { + $client->disconnect(); + } catch (Throwable $e) { + $this->logger->warning('Disconnect failed', ['error' => $e->getMessage()]); + } + } + } + + private function headerToStringSafe(mixed $message): string + { + $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); + + 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; + } + + 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() ?? ''); + if ($text !== '') { + return $text; + } + $html = ($message->getHTMLBody() ?? ''); + if ($html !== '') { + return trim(strip_tags($html)); + } + + return ''; + } + + private function extractDate(mixed $message): DateTimeImmutable + { + $date = $message->getDate(); + if ($date instanceof DateTimeInterface) { + return new DateTimeImmutable($date->format('Y-m-d H:i:s')); + } + + if (method_exists($message, 'getInternalDate')) { + $internalDate = (int) $message->getInternalDate(); + if ($internalDate > 0) { + return new DateTimeImmutable('@'.$internalDate); + } + } + + 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 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 { + 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()]); + } + } + + 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/Messaging/Service/WebklexImapClientFactory.php b/src/Domain/Messaging/Service/WebklexImapClientFactory.php new file mode 100644 index 00000000..10271e4c --- /dev/null +++ b/src/Domain/Messaging/Service/WebklexImapClientFactory.php @@ -0,0 +1,79 @@ +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 + { + 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']; + } +} diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index 6ebaee70..3c3583b4 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -141,4 +141,51 @@ 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(); + } + + 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 + { + return $this->createQueryBuilder('s') + ->select('s.id') + ->where('s.bounceCount > 0') + ->andWhere('s.confirmed = 1') + ->andWhere('s.blacklisted = 0') + ->getQuery() + ->getScalarResult(); + } } diff --git a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php index d30bae2d..d5828c2f 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php @@ -58,6 +58,16 @@ 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..73531fbb 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; @@ -26,7 +27,7 @@ public function __construct( SubscriberRepository $subscriberRepository, EntityManagerInterface $entityManager, MessageBusInterface $messageBus, - SubscriberDeletionService $subscriberDeletionService + SubscriberDeletionService $subscriberDeletionService, ) { $this->subscriberRepository = $subscriberRepository; $this->entityManager = $entityManager; @@ -64,15 +65,9 @@ private function sendConfirmationEmail(Subscriber $subscriber): void $this->messageBus->dispatch($message); } - public function getSubscriber(int $subscriberId): Subscriber + public function getSubscriberById(int $subscriberId): ?Subscriber { - $subscriber = $this->subscriberRepository->findSubscriberWithSubscriptions($subscriberId); - - if (!$subscriber) { - throw new NotFoundHttpException('Subscriber not found'); - } - - return $subscriber; + return $this->subscriberRepository->find($subscriberId); } public function updateSubscriber(UpdateSubscriberDto $subscriberDto): Subscriber @@ -140,4 +135,10 @@ public function updateFromImport(Subscriber $existingSubscriber, ImportSubscribe return $existingSubscriber; } + + 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..d9ca5ea6 --- /dev/null +++ b/src/Domain/Subscription/Service/SubscriberBlacklistService.php @@ -0,0 +1,69 @@ +entityManager = $entityManager; + $this->blacklistManager = $blacklistManager; + $this->historyManager = $historyManager; + $this->requestStack = $requestStack; + } + + /** + * @SuppressWarnings(PHPMD.Superglobals) + */ + public function blacklist(Subscriber $subscriber, string $reason): void + { + $subscriber->setBlacklisted(true); + $this->entityManager->flush(); + $this->blacklistManager->addEmailToBlacklist($subscriber->getEmail(), $reason); + + foreach (['REMOTE_ADDR','HTTP_X_FORWARDED_FOR'] as $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) + ); + } + } + + $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 $plugin) { + if (method_exists($plugin, 'blacklistEmail')) { + $plugin->blacklistEmail($subscriber->getEmail(), $reason); + } + } + } + } +} 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/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/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/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/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 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, + ]); + } +} 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 new file mode 100644 index 00000000..bd1a4a68 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php @@ -0,0 +1,205 @@ +repository = $this->createMock(BounceRepository::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: $this->userMessageBounceRepository, + entityManager: $this->entityManager, + logger: $this->logger, + ); + } + + public function testCreatePersistsAndReturnsBounce(): void + { + $date = new DateTimeImmutable('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->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 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)); + } + + 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 = $this->createMock(Bounce::class); + $bounce->method('getId')->willReturn(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); + } +} 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/Messaging/Service/Manager/BounceRuleManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php new file mode 100644 index 00000000..040f98a8 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php @@ -0,0 +1,143 @@ +regexRepository = $this->createMock(BounceRegexRepository::class); + $this->relationRepository = $this->createMock(BounceRegexBounceRepository::class); + $this->manager = new BounceRuleManager( + repository: $this->regexRepository, + bounceRelationRepository: $this->relationRepository, + ); + } + + public function testLoadActiveRulesMapsRowsAndSkipsInvalid(): void + { + $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') + ->willReturn([$valid, $noRegex, $noAction, $noId]); + + $result = $this->manager->loadActiveRules(); + + $this->assertSame(['user unknown' => $valid], $result); + } + + public function testLoadAllRulesDelegatesToRepository(): void + { + $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([$rule1, $rule2]); + + $result = $this->manager->loadAllRules(); + $this->assertSame(['a' => $rule1, 'b' => $rule2], $result); + } + + public function testMatchBounceRulesMatchesQuotedAndRawAndHandlesInvalidPatterns(): void + { + $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 an 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/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/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..209fb583 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php @@ -0,0 +1,177 @@ +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 = $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); + + $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 = $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] ], + [ ['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..b7009cd9 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php @@ -0,0 +1,168 @@ +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( + bounceManager: $this->bounceManager, + subscriberRepository: $this->subscriberRepository, + messageRepository: $this->messageRepository, + logger: $this->logger, + subscriberManager: $this->subscriberManager, + subscriberHistoryManager: $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 = $this->createMock(Subscriber::class); + $subscriber->method('getId')->willReturn(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 98% rename from tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php rename to tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php index f8bb28d3..b2c51c71 100644 --- a/tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php @@ -2,15 +2,15 @@ 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; 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; 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()); + } +} 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..b7a99366 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php @@ -34,7 +34,7 @@ protected function setUp(): void subscriberRepository: $this->subscriberRepository, entityManager: $this->entityManager, messageBus: $this->messageBus, - subscriberDeletionService: $subscriberDeletionService + subscriberDeletionService: $subscriberDeletionService, ); } From 793c260640717c095a747c813df89aedde20cfe8 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Thu, 4 Sep 2025 13:32:22 +0400 Subject: [PATCH 05/10] EventLog + translator (#356) * EventLogManager * Log failed logins + translate messages * weblate * test fix * Use translations * Fix pipeline * Weblate * Deprecate DB translation table --------- Co-authored-by: Tatevik --- .github/workflows/i18n-validate.yml | 69 ++++++++++++++ .weblate | 23 +++++ config/config.yml | 5 +- config/services/managers.yml | 12 ++- config/services/repositories.yml | 15 ++- config/services/services.yml | 7 ++ resources/translations/messages.en.xlf | 44 +++++++++ src/Domain/Common/I18n/Messages.php | 29 ++++++ .../Model/Filter/EventLogFilter.php | 33 +++++++ src/Domain/Configuration/Model/I18n.php | 5 + .../Repository/EventLogRepository.php | 37 ++++++++ .../Repository/I18nRepository.php | 1 + .../Service/Manager/EventLogManager.php | 54 +++++++++++ .../Identity/Service/PasswordManager.php | 10 +- .../Identity/Service/SessionManager.php | 21 ++++- .../Service/Manager/SubscriptionManager.php | 16 +++- .../Service/Manager/EventLogManagerTest.php | 94 +++++++++++++++++++ .../Identity/Service/PasswordManagerTest.php | 4 +- .../Identity/Service/SessionManagerTest.php | 28 +++++- .../Manager/SubscriptionManagerTest.php | 11 ++- 20 files changed, 492 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/i18n-validate.yml create mode 100644 .weblate create mode 100644 resources/translations/messages.en.xlf create mode 100644 src/Domain/Common/I18n/Messages.php create mode 100644 src/Domain/Configuration/Model/Filter/EventLogFilter.php create mode 100644 src/Domain/Configuration/Service/Manager/EventLogManager.php create mode 100644 tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php diff --git a/.github/workflows/i18n-validate.yml b/.github/workflows/i18n-validate.yml new file mode 100644 index 00000000..4e49efa8 --- /dev/null +++ b/.github/workflows/i18n-validate.yml @@ -0,0 +1,69 @@ +name: I18n Validate + +on: + pull_request: + paths: + - 'resources/translations/**/*.xlf' + - 'composer.lock' + - 'composer.json' + +jobs: + validate-xliff: + runs-on: ubuntu-22.04 + + strategy: + fail-fast: false + matrix: + php: ['8.1'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: imap, zip + tools: composer:v2 + coverage: none + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: | + ~/.composer/cache/files + key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer-${{ matrix.php }}- + + - name: Install dependencies (no dev autoloader scripts) + run: | + set -euo pipefail + composer install --no-interaction --no-progress --prefer-dist + + - name: Lint XLIFF with Symfony + run: | + set -euo pipefail + # Adjust the directory to match your repo layout + php bin/console lint:xliff resources/translations + + - name: Validate XLIFF XML with xmllint + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends libxml2-utils + # Adjust root dir; prune vendor; accept spaces/newlines safely + find resources/translations -type f -name '*.xlf' -not -path '*/vendor/*' -print0 \ + | xargs -0 -n1 xmllint --noout + + - name: Symfony translation sanity (extract dry-run) + run: | + set -euo pipefail + # Show what would be created/updated without writing files + php bin/console translation:extract en \ + --format=xlf \ + --domain=messages \ + --dump-messages \ + --no-interaction + # Note: omit --force to keep this a dry-run diff --git a/.weblate b/.weblate new file mode 100644 index 00000000..5917a8b8 --- /dev/null +++ b/.weblate @@ -0,0 +1,23 @@ +# .weblate +--- +projects: + - slug: phplist-core + name: phpList core + components: + - slug: messages + name: Messages + files: + # {language} is Weblate’s placeholder (e.g., fr, de, es) + - src: resources/translations/messages.en.xlf + template: true + # Where localized files live (mirrors Symfony layout) + target: resources/translations/messages.{language}.xlf + file_format: xliff + language_code_style: bcp + # Ensure placeholders like %name% are preserved + parse_file_headers: true + check_flags: + - xml-invalid + - placeholders + - urls + - accelerated diff --git a/config/config.yml b/config/config.yml index e235f999..7de6dca6 100644 --- a/config/config.yml +++ b/config/config.yml @@ -10,7 +10,10 @@ parameters: framework: #esi: ~ - #translator: { fallbacks: ['%locale%'] } + translator: + default_path: '%kernel.project_dir%/resources/translations' + fallbacks: ['%locale%'] + secret: '%secret%' router: resource: '%kernel.project_dir%/config/routing.yml' diff --git a/config/services/managers.yml b/config/services/managers.yml index 5ef215b3..22dbe066 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -4,6 +4,14 @@ services: autoconfigure: true public: false + PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager: + autowire: true + autoconfigure: true + PhpList\Core\Domain\Identity\Service\SessionManager: autowire: true autoconfigure: true @@ -80,10 +88,6 @@ services: autowire: true autoconfigure: true - PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: - autowire: true - autoconfigure: true - PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager: autowire: true autoconfigure: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml index 82ae6a82..1289bea7 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -1,4 +1,14 @@ services: + PhpList\Core\Domain\Configuration\Repository\ConfigRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Configuration\Model\Config + + PhpList\Core\Domain\Configuration\Repository\EventLogRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Configuration\Model\EventLog + PhpList\Core\Domain\Identity\Repository\AdministratorRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: @@ -66,11 +76,6 @@ services: 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: diff --git a/config/services/services.yml b/config/services/services.yml index 19caddd8..1f509787 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -107,3 +107,10 @@ services: PhpList\Core\Domain\Messaging\Service\BounceActionResolver: arguments: - !tagged_iterator { tag: 'phplist.bounce_action_handler' } + + # I18n + PhpList\Core\Domain\Common\I18n\SimpleTranslator: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Common\I18n\TranslatorInterface: '@PhpList\Core\Domain\Common\I18n\SimpleTranslator' diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf new file mode 100644 index 00000000..7e176e3e --- /dev/null +++ b/resources/translations/messages.en.xlf @@ -0,0 +1,44 @@ + + + + + + + + Not authorized + Not authorized + + + + Failed admin login attempt for '%login%' + Failed admin login attempt for '%login%' + + + + Login attempt for disabled admin '%login%' + Login attempt for disabled admin '%login%' + + + + + Administrator not found + Administrator not found + + + + + Subscriber list not found. + Subscriber list not found. + + + Subscriber does not exists. + Subscriber does not exists. + + + Subscription not found for this subscriber and list. + Subscription not found for this subscriber and list. + + + + + diff --git a/src/Domain/Common/I18n/Messages.php b/src/Domain/Common/I18n/Messages.php new file mode 100644 index 00000000..f9e8822f --- /dev/null +++ b/src/Domain/Common/I18n/Messages.php @@ -0,0 +1,29 @@ +page; + } + + public function getDateFrom(): ?DateTimeInterface + { + return $this->dateFrom; + } + + public function getDateTo(): ?DateTimeInterface + { + return $this->dateTo; + } +} diff --git a/src/Domain/Configuration/Model/I18n.php b/src/Domain/Configuration/Model/I18n.php index bffed897..b8eefd63 100644 --- a/src/Domain/Configuration/Model/I18n.php +++ b/src/Domain/Configuration/Model/I18n.php @@ -8,6 +8,11 @@ use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Configuration\Repository\I18nRepository; +/** + * @deprecated + * + * Symfony\Contracts\Translation will be used instead. + */ #[ORM\Entity(repositoryClass: I18nRepository::class)] #[ORM\Table(name: 'phplist_i18n')] #[ORM\UniqueConstraint(name: 'lanorigunq', columns: ['lan', 'original'])] diff --git a/src/Domain/Configuration/Repository/EventLogRepository.php b/src/Domain/Configuration/Repository/EventLogRepository.php index 7caf5462..47640007 100644 --- a/src/Domain/Configuration/Repository/EventLogRepository.php +++ b/src/Domain/Configuration/Repository/EventLogRepository.php @@ -4,11 +4,48 @@ namespace PhpList\Core\Domain\Configuration\Repository; +use InvalidArgumentException; +use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface; 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\Configuration\Model\Filter\EventLogFilter; +use PhpList\Core\Domain\Configuration\Model\EventLog; class EventLogRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + /** + * @return EventLog[] + * @throws InvalidArgumentException + */ + public function getFilteredAfterId(int $lastId, int $limit, ?FilterRequestInterface $filter = null): array + { + $queryBuilder = $this->createQueryBuilder('e') + ->andWhere('e.id > :lastId') + ->setParameter('lastId', $lastId) + ->orderBy('e.id', 'ASC') + ->setMaxResults($limit); + + if ($filter === null) { + return $queryBuilder->getQuery()->getResult(); + } + + if (!$filter instanceof EventLogFilter) { + throw new InvalidArgumentException('Expected EventLogFilter.'); + } + + if ($filter->getPage() !== null) { + $queryBuilder->andWhere('e.page = :page')->setParameter('page', $filter->getPage()); + } + if ($filter->getDateFrom() !== null) { + $queryBuilder->andWhere('e.entered >= :dateFrom')->setParameter('dateFrom', $filter->getDateFrom()); + } + if ($filter->getDateTo() !== null) { + $queryBuilder->andWhere('e.entered <= :dateTo')->setParameter('dateTo', $filter->getDateTo()); + } + + return $queryBuilder->getQuery()->getResult(); + } } diff --git a/src/Domain/Configuration/Repository/I18nRepository.php b/src/Domain/Configuration/Repository/I18nRepository.php index f4465103..33fa599a 100644 --- a/src/Domain/Configuration/Repository/I18nRepository.php +++ b/src/Domain/Configuration/Repository/I18nRepository.php @@ -6,6 +6,7 @@ use PhpList\Core\Domain\Common\Repository\AbstractRepository; +/** @deprecated */ class I18nRepository extends AbstractRepository { } diff --git a/src/Domain/Configuration/Service/Manager/EventLogManager.php b/src/Domain/Configuration/Service/Manager/EventLogManager.php new file mode 100644 index 00000000..374db7ed --- /dev/null +++ b/src/Domain/Configuration/Service/Manager/EventLogManager.php @@ -0,0 +1,54 @@ +repository = $repository; + } + + public function log(string $page, string $entry): EventLog + { + $log = (new EventLog()) + ->setEntered(new DateTimeImmutable()) + ->setPage($page) + ->setEntry($entry); + + $this->repository->save($log); + + return $log; + } + + /** + * Get event logs with optional filters (page and date range) and cursor pagination. + * + * @return EventLog[] + */ + public function get( + int $lastId = 0, + int $limit = 50, + ?string $page = null, + ?DateTimeInterface $dateFrom = null, + ?DateTimeInterface $dateTo = null + ): array { + $filter = new EventLogFilter($page, $dateFrom, $dateTo); + return $this->repository->getFilteredAfterId($lastId, $limit, $filter); + } + + public function delete(EventLog $log): void + { + $this->repository->remove($log); + } +} diff --git a/src/Domain/Identity/Service/PasswordManager.php b/src/Domain/Identity/Service/PasswordManager.php index f6ad2a9e..2c7ebe1e 100644 --- a/src/Domain/Identity/Service/PasswordManager.php +++ b/src/Domain/Identity/Service/PasswordManager.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Domain\Identity\Service; use DateTime; +use PhpList\Core\Domain\Common\I18n\Messages; use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; @@ -13,6 +14,7 @@ use PhpList\Core\Security\HashGenerator; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Contracts\Translation\TranslatorInterface; class PasswordManager { @@ -22,17 +24,20 @@ class PasswordManager private AdministratorRepository $administratorRepository; private HashGenerator $hashGenerator; private MessageBusInterface $messageBus; + private TranslatorInterface $translator; public function __construct( AdminPasswordRequestRepository $passwordRequestRepository, AdministratorRepository $administratorRepository, HashGenerator $hashGenerator, - MessageBusInterface $messageBus + MessageBusInterface $messageBus, + TranslatorInterface $translator ) { $this->passwordRequestRepository = $passwordRequestRepository; $this->administratorRepository = $administratorRepository; $this->hashGenerator = $hashGenerator; $this->messageBus = $messageBus; + $this->translator = $translator; } /** @@ -47,7 +52,8 @@ public function generatePasswordResetToken(string $email): string { $administrator = $this->administratorRepository->findOneBy(['email' => $email]); if ($administrator === null) { - throw new NotFoundHttpException('Administrator not found', null, 1500567100); + $message = $this->translator->trans(Messages::IDENTITY_ADMIN_NOT_FOUND); + throw new NotFoundHttpException($message, null, 1500567100); } $existingRequests = $this->passwordRequestRepository->findByAdmin($administrator); diff --git a/src/Domain/Identity/Service/SessionManager.php b/src/Domain/Identity/Service/SessionManager.php index 52daafa3..82f52af1 100644 --- a/src/Domain/Identity/Service/SessionManager.php +++ b/src/Domain/Identity/Service/SessionManager.php @@ -4,6 +4,9 @@ namespace PhpList\Core\Domain\Identity\Service; +use PhpList\Core\Domain\Common\I18n\Messages; +use Symfony\Contracts\Translation\TranslatorInterface; +use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; @@ -13,24 +16,36 @@ class SessionManager { private AdministratorTokenRepository $tokenRepository; private AdministratorRepository $administratorRepository; + private EventLogManager $eventLogManager; + private TranslatorInterface $translator; public function __construct( AdministratorTokenRepository $tokenRepository, - AdministratorRepository $administratorRepository + AdministratorRepository $administratorRepository, + EventLogManager $eventLogManager, + TranslatorInterface $translator ) { $this->tokenRepository = $tokenRepository; $this->administratorRepository = $administratorRepository; + $this->eventLogManager = $eventLogManager; + $this->translator = $translator; } public function createSession(string $loginName, string $password): AdministratorToken { $administrator = $this->administratorRepository->findOneByLoginCredentials($loginName, $password); if ($administrator === null) { - throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567098); + $entry = $this->translator->trans(Messages::AUTH_LOGIN_FAILED, ['login' => $loginName]); + $this->eventLogManager->log('login', $entry); + $message = $this->translator->trans(Messages::AUTH_NOT_AUTHORIZED); + throw new UnauthorizedHttpException('', $message, null, 1500567098); } if ($administrator->isDisabled()) { - throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567099); + $entry = $this->translator->trans(Messages::AUTH_LOGIN_DISABLED, ['login' => $loginName]); + $this->eventLogManager->log('login', $entry); + $message = $this->translator->trans(Messages::AUTH_NOT_AUTHORIZED); + throw new UnauthorizedHttpException('', $message, null, 1500567099); } $token = new AdministratorToken(); diff --git a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php index bb3a0e14..764106ec 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Domain\Subscription\Service\Manager; +use PhpList\Core\Domain\Common\I18n\Messages; use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberList; @@ -11,21 +12,25 @@ use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository; +use Symfony\Contracts\Translation\TranslatorInterface; class SubscriptionManager { private SubscriptionRepository $subscriptionRepository; private SubscriberRepository $subscriberRepository; private SubscriberListRepository $subscriberListRepository; + private TranslatorInterface $translator; public function __construct( SubscriptionRepository $subscriptionRepository, SubscriberRepository $subscriberRepository, - SubscriberListRepository $subscriberListRepository + SubscriberListRepository $subscriberListRepository, + TranslatorInterface $translator ) { $this->subscriptionRepository = $subscriptionRepository; $this->subscriberRepository = $subscriberRepository; $this->subscriberListRepository = $subscriberListRepository; + $this->translator = $translator; } public function addSubscriberToAList(Subscriber $subscriber, int $listId): Subscription @@ -37,7 +42,8 @@ public function addSubscriberToAList(Subscriber $subscriber, int $listId): Subsc } $subscriberList = $this->subscriberListRepository->find($listId); if (!$subscriberList) { - throw new SubscriptionCreationException('Subscriber list not found.', 404); + $message = $this->translator->trans(Messages::SUBSCRIPTION_LIST_NOT_FOUND); + throw new SubscriptionCreationException($message, 404); } $subscription = new Subscription(); @@ -64,7 +70,8 @@ private function createSubscription(SubscriberList $subscriberList, string $emai { $subscriber = $this->subscriberRepository->findOneBy(['email' => $email]); if (!$subscriber) { - throw new SubscriptionCreationException('Subscriber does not exists.', 404); + $message = $this->translator->trans(Messages::SUBSCRIPTION_SUBSCRIBER_NOT_FOUND); + throw new SubscriptionCreationException($message, 404); } $existingSubscription = $this->subscriptionRepository @@ -101,7 +108,8 @@ private function deleteSubscription(SubscriberList $subscriberList, string $emai ->findOneBySubscriberEmailAndListId($subscriberList->getId(), $email); if (!$subscription) { - throw new SubscriptionCreationException('Subscription not found for this subscriber and list.', 404); + $message = $this->translator->trans(Messages::SUBSCRIPTION_NOT_FOUND_FOR_LIST_AND_SUBSCRIBER); + throw new SubscriptionCreationException($message, 404); } $this->subscriptionRepository->remove($subscription); diff --git a/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php b/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php new file mode 100644 index 00000000..818b8de0 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php @@ -0,0 +1,94 @@ +repository = $this->createMock(EventLogRepository::class); + $this->manager = new EventLogManager($this->repository); + } + + public function testLogCreatesAndPersists(): void + { + $this->repository->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(EventLog::class)); + + $log = $this->manager->log('dashboard', 'Viewed dashboard'); + + $this->assertInstanceOf(EventLog::class, $log); + $this->assertSame('dashboard', $log->getPage()); + $this->assertSame('Viewed dashboard', $log->getEntry()); + $this->assertNotNull($log->getEntered()); + $this->assertInstanceOf(DateTimeImmutable::class, $log->getEntered()); + } + + public function testDelete(): void + { + $log = new EventLog(); + $this->repository->expects($this->once()) + ->method('remove') + ->with($log); + + $this->manager->delete($log); + } + + public function testGetWithFiltersDelegatesToRepository(): void + { + $expected = [new EventLog(), new EventLog()]; + + $this->repository->expects($this->once()) + ->method('getFilteredAfterId') + ->with( + 100, + 25, + $this->callback(function (EventLogFilter $filter) { + // Use getters to validate + return method_exists($filter, 'getPage') + && $filter->getPage() === 'settings' + && $filter->getDateFrom() instanceof DateTimeImmutable + && $filter->getDateTo() instanceof DateTimeImmutable + && $filter->getDateFrom() <= $filter->getDateTo(); + }) + ) + ->willReturn($expected); + + $from = new DateTimeImmutable('-2 days'); + $to = new DateTimeImmutable('now'); + $result = $this->manager->get(lastId: 100, limit: 25, page: 'settings', dateFrom: $from, dateTo: $to); + + $this->assertSame($expected, $result); + } + + public function testGetWithoutFiltersDefaults(): void + { + $expected = []; + + $this->repository->expects($this->once()) + ->method('getFilteredAfterId') + ->with( + 0, + 50, + $this->anything() + ) + ->willReturn($expected); + + $result = $this->manager->get(); + $this->assertSame($expected, $result); + } +} diff --git a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php index 85e02f81..59ace13d 100644 --- a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php @@ -17,6 +17,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Contracts\Translation\TranslatorInterface; class PasswordManagerTest extends TestCase { @@ -36,7 +37,8 @@ protected function setUp(): void passwordRequestRepository: $this->passwordRequestRepository, administratorRepository: $this->administratorRepository, hashGenerator: $this->hashGenerator, - messageBus: $this->messageBus + messageBus: $this->messageBus, + translator: $this->createMock(TranslatorInterface::class) ); } diff --git a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php index 44072452..14419b0e 100644 --- a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php @@ -4,16 +4,19 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Service; +use PhpList\Core\Domain\Common\I18n\Messages; +use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; use PhpList\Core\Domain\Identity\Service\SessionManager; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; +use Symfony\Contracts\Translation\TranslatorInterface; class SessionManagerTest extends TestCase { - public function testCreateSessionWithInvalidCredentialsThrowsException(): void + public function testCreateSessionWithInvalidCredentialsThrowsExceptionAndLogs(): void { $adminRepo = $this->createMock(AdministratorRepository::class); $adminRepo->expects(self::once()) @@ -24,7 +27,24 @@ public function testCreateSessionWithInvalidCredentialsThrowsException(): void $tokenRepo = $this->createMock(AdministratorTokenRepository::class); $tokenRepo->expects(self::never())->method('save'); - $manager = new SessionManager($tokenRepo, $adminRepo); + $eventLogManager = $this->createMock(EventLogManager::class); + $eventLogManager->expects(self::once()) + ->method('log') + ->with('login', $this->stringContains('admin')); + + $translator = $this->createMock(TranslatorInterface::class); + $translator->expects(self::exactly(2)) + ->method('trans') + ->withConsecutive( + [Messages::AUTH_LOGIN_FAILED, ['login' => 'admin']], + [Messages::AUTH_NOT_AUTHORIZED, []] + ) + ->willReturnOnConsecutiveCalls( + "Failed admin login attempt for 'admin'", + 'Not authorized' + ); + + $manager = new SessionManager($tokenRepo, $adminRepo, $eventLogManager, $translator); $this->expectException(UnauthorizedHttpException::class); $this->expectExceptionMessage('Not authorized'); @@ -42,8 +62,10 @@ public function testDeleteSessionCallsRemove(): void ->with($token); $adminRepo = $this->createMock(AdministratorRepository::class); + $eventLogManager = $this->createMock(EventLogManager::class); + $translator = $this->createMock(TranslatorInterface::class); - $manager = new SessionManager($tokenRepo, $adminRepo); + $manager = new SessionManager($tokenRepo, $adminRepo, $eventLogManager, $translator); $manager->deleteSession($token); } } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php index e535a7fe..f0c1d3af 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php @@ -14,11 +14,13 @@ use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Contracts\Translation\TranslatorInterface; class SubscriptionManagerTest extends TestCase { private SubscriptionRepository&MockObject $subscriptionRepository; private SubscriberRepository&MockObject $subscriberRepository; + private TranslatorInterface&MockObject $translator; private SubscriptionManager $manager; protected function setUp(): void @@ -26,10 +28,12 @@ protected function setUp(): void $this->subscriptionRepository = $this->createMock(SubscriptionRepository::class); $this->subscriberRepository = $this->createMock(SubscriberRepository::class); $subscriberListRepository = $this->createMock(SubscriberListRepository::class); + $this->translator = $this->createMock(TranslatorInterface::class); $this->manager = new SubscriptionManager( - $this->subscriptionRepository, - $this->subscriberRepository, - $subscriberListRepository + subscriptionRepository: $this->subscriptionRepository, + subscriberRepository: $this->subscriberRepository, + subscriberListRepository: $subscriberListRepository, + translator: $this->translator, ); } @@ -51,6 +55,7 @@ public function testCreateSubscriptionWhenSubscriberExists(): void public function testCreateSubscriptionThrowsWhenSubscriberMissing(): void { + $this->translator->method('trans')->willReturn('Subscriber does not exists.'); $this->expectException(SubscriptionCreationException::class); $this->expectExceptionMessage('Subscriber does not exists.'); From 936cabcdef5339b36e334cc1501b113c65ad8761 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Fri, 5 Sep 2025 12:15:52 +0400 Subject: [PATCH 06/10] Access level check (#358) * OwnableInterface * PermissionChecker * Check related * Register service + test * Style fix --------- Co-authored-by: Tatevik --- config/services/providers.yml | 4 - config/services/services.yml | 6 +- .../Model/Interfaces/OwnableInterface.php | 12 +++ src/Domain/Identity/Model/Administrator.php | 10 +++ .../Identity/Service/PermissionChecker.php | 89 +++++++++++++++++++ src/Domain/Messaging/Model/Message.php | 3 +- .../Subscription/Model/SubscribePage.php | 3 +- .../Subscription/Model/SubscriberList.php | 3 +- .../Service/PermissionCheckerTest.php | 35 ++++++++ 9 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 src/Domain/Common/Model/Interfaces/OwnableInterface.php create mode 100644 src/Domain/Identity/Service/PermissionChecker.php create mode 100644 tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php diff --git a/config/services/providers.yml b/config/services/providers.yml index cb784988..226c4e81 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -2,7 +2,3 @@ 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 1f509787..f1b68e74 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -108,9 +108,7 @@ services: arguments: - !tagged_iterator { tag: 'phplist.bounce_action_handler' } - # I18n - PhpList\Core\Domain\Common\I18n\SimpleTranslator: + PhpList\Core\Domain\Identity\Service\PermissionChecker: autowire: true autoconfigure: true - - PhpList\Core\Domain\Common\I18n\TranslatorInterface: '@PhpList\Core\Domain\Common\I18n\SimpleTranslator' + public: true diff --git a/src/Domain/Common/Model/Interfaces/OwnableInterface.php b/src/Domain/Common/Model/Interfaces/OwnableInterface.php new file mode 100644 index 00000000..16e54e40 --- /dev/null +++ b/src/Domain/Common/Model/Interfaces/OwnableInterface.php @@ -0,0 +1,12 @@ +modifiedBy; } + + public function owns(OwnableInterface $resource): bool + { + if ($this->getId() === null) { + return false; + } + + return $resource->getOwner()->getId() === $this->getId(); + } } diff --git a/src/Domain/Identity/Service/PermissionChecker.php b/src/Domain/Identity/Service/PermissionChecker.php new file mode 100644 index 00000000..8fc241b7 --- /dev/null +++ b/src/Domain/Identity/Service/PermissionChecker.php @@ -0,0 +1,89 @@ + PrivilegeFlag::Subscribers, + SubscriberList::class => PrivilegeFlag::Subscribers, + Message::class => PrivilegeFlag::Campaigns, + ]; + + private const OWNERSHIP_MAP = [ + Subscriber::class => SubscriberList::class, + Message::class => SubscriberList::class + ]; + + public function canManage(Administrator $actor, DomainModel $resource): bool + { + if ($actor->isSuperUser()) { + return true; + } + + $required = $this->resolveRequiredPrivilege($resource); + if ($required !== null && !$actor->getPrivileges()->has($required)) { + return false; + } + + if ($resource instanceof OwnableInterface) { + return $actor->owns($resource); + } + + $notRestricted = true; + foreach (self::OWNERSHIP_MAP as $resourceClass => $relatedClass) { + if ($resource instanceof $resourceClass) { + $related = $this->resolveRelatedEntity($resource, $relatedClass); + $notRestricted = $this->checkRelatedResources($related, $actor); + } + } + + return $notRestricted; + } + + private function resolveRequiredPrivilege(DomainModel $resource): ?PrivilegeFlag + { + foreach (self::REQUIRED_PRIVILEGE_MAP as $class => $flag) { + if ($resource instanceof $class) { + return $flag; + } + } + + return null; + } + + /** @return OwnableInterface[] */ + private function resolveRelatedEntity(DomainModel $resource, string $relatedClass): array + { + if ($resource instanceof Subscriber && $relatedClass === SubscriberList::class) { + return $resource->getSubscribedLists()->toArray(); + } + + if ($resource instanceof Message && $relatedClass === SubscriberList::class) { + return $resource->getListMessages()->map(fn($lm) => $lm->getSubscriberList())->toArray(); + } + + return []; + } + + private function checkRelatedResources(array $related, Administrator $actor): bool + { + foreach ($related as $relatedResource) { + if ($actor->owns($relatedResource)) { + return true; + } + } + + return false; + } +} diff --git a/src/Domain/Messaging/Model/Message.php b/src/Domain/Messaging/Model/Message.php index fbbfec8a..5064c4f1 100644 --- a/src/Domain/Messaging/Model/Message.php +++ b/src/Domain/Messaging/Model/Message.php @@ -11,6 +11,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Common\Model\Interfaces\Identity; use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat; @@ -23,7 +24,7 @@ #[ORM\Table(name: 'phplist_message')] #[ORM\Index(name: 'uuididx', columns: ['uuid'])] #[ORM\HasLifecycleCallbacks] -class Message implements DomainModel, Identity, ModificationDate +class Message implements DomainModel, Identity, ModificationDate, OwnableInterface { #[ORM\Id] #[ORM\Column(type: 'integer')] diff --git a/src/Domain/Subscription/Model/SubscribePage.php b/src/Domain/Subscription/Model/SubscribePage.php index e4696380..979b3c4c 100644 --- a/src/Domain/Subscription/Model/SubscribePage.php +++ b/src/Domain/Subscription/Model/SubscribePage.php @@ -7,12 +7,13 @@ use Doctrine\ORM\Mapping as ORM; use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository; #[ORM\Entity(repositoryClass: SubscriberPageRepository::class)] #[ORM\Table(name: 'phplist_subscribepage')] -class SubscribePage implements DomainModel, Identity +class SubscribePage implements DomainModel, Identity, OwnableInterface { #[ORM\Id] #[ORM\Column(type: 'integer')] diff --git a/src/Domain/Subscription/Model/SubscriberList.php b/src/Domain/Subscription/Model/SubscriberList.php index 947cbe26..32f85f5d 100644 --- a/src/Domain/Subscription/Model/SubscriberList.php +++ b/src/Domain/Subscription/Model/SubscriberList.php @@ -12,6 +12,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Common\Model\Interfaces\Identity; use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\ListMessage; use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; @@ -28,7 +29,7 @@ #[ORM\Index(name: 'nameidx', columns: ['name'])] #[ORM\Index(name: 'listorderidx', columns: ['listorder'])] #[ORM\HasLifecycleCallbacks] -class SubscriberList implements DomainModel, Identity, CreationDate, ModificationDate +class SubscriberList implements DomainModel, Identity, CreationDate, ModificationDate, OwnableInterface { #[ORM\Id] #[ORM\Column(type: 'integer')] diff --git a/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php b/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php new file mode 100644 index 00000000..50820026 --- /dev/null +++ b/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php @@ -0,0 +1,35 @@ +checker = self::getContainer()->get(PermissionChecker::class); + } + + public function testServiceIsRegisteredInContainer(): void + { + self::assertInstanceOf(PermissionChecker::class, $this->checker); + self::assertSame($this->checker, self::getContainer()->get(PermissionChecker::class)); + } + + public function testSuperUserCanManageAnyResource(): void + { + $admin = new Administrator(); + $admin->setSuperUser(true); + $resource = $this->createMock(SubscriberList::class); + $this->assertTrue($this->checker->canManage($admin, $resource)); + } +} From fa422581db081fbe93f20bffe8a17e8dc0526f5c Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Wed, 17 Sep 2025 12:19:14 +0400 Subject: [PATCH 07/10] Message processor (#359) * MessageStatusEnum * Status validate * Embargo check * IspRestrictions * IspRestrictions * SendRateLimiter * UserMessageStatus * Refactor * RateLimitedCampaignMailer * RateLimitedCampaignMailerTest * Rate limit initialized from history * Check maintenance mode * Max processing time limiter --------- Co-authored-by: Tatevik --- config/parameters.yml.dist | 13 ++ config/services.yml | 4 - config/services/providers.yml | 10 ++ config/services/services.yml | 19 +++ src/Domain/Common/IspRestrictionsProvider.php | 137 ++++++++++++++++ src/Domain/Common/Model/IspRestrictions.php | 21 +++ .../Service/Manager/ConfigManager.php | 6 + .../Messaging/Command/ProcessQueueCommand.php | 17 +- .../Model/Dto/Message/MessageMetadataDto.php | 4 +- .../Model/Message/MessageMetadata.php | 20 ++- .../Messaging/Model/Message/MessageStatus.php | 38 +++++ .../Model/Message/UserMessageStatus.php | 16 ++ src/Domain/Messaging/Model/UserMessage.php | 12 +- .../Repository/MessageRepository.php | 12 ++ .../Repository/UserMessageRepository.php | 24 +++ .../Service/Builder/MessageBuilder.php | 2 +- .../Service/Handler/RequeueHandler.php | 60 +++++++ .../Service/Manager/MessageManager.php | 8 + .../Service/MaxProcessTimeLimiter.php | 46 ++++++ .../Service/Processor/CampaignProcessor.php | 92 +++++++++-- .../Service/RateLimitedCampaignMailer.php | 50 ++++++ .../Messaging/Service/SendRateLimiter.php | 103 ++++++++++++ .../Service/Provider/SubscriberProvider.php | 2 +- .../Repository/MessageRepositoryTest.php | 6 +- .../Service/SubscriberDeletionServiceTest.php | 4 +- .../Command/ProcessQueueCommandTest.php | 20 ++- .../Service/Builder/MessageBuilderTest.php | 15 +- .../Builder/MessageContentBuilderTest.php | 2 +- .../Builder/MessageFormatBuilderTest.php | 2 +- .../Builder/MessageOptionsBuilderTest.php | 2 +- .../Builder/MessageScheduleBuilderTest.php | 2 +- .../Service/Handler/RequeueHandlerTest.php | 155 ++++++++++++++++++ .../Service/Manager/MessageManagerTest.php | 17 +- .../Service/MaxProcessTimeLimiterTest.php | 53 ++++++ .../Processor/CampaignProcessorTest.php | 105 ++++++------ .../Service/RateLimitedCampaignMailerTest.php | 134 +++++++++++++++ .../Messaging/Service/SendRateLimiterTest.php | 90 ++++++++++ .../Provider/SubscriberProviderTest.php | 14 +- 38 files changed, 1207 insertions(+), 130 deletions(-) create mode 100644 src/Domain/Common/IspRestrictionsProvider.php create mode 100644 src/Domain/Common/Model/IspRestrictions.php create mode 100644 src/Domain/Messaging/Model/Message/MessageStatus.php create mode 100644 src/Domain/Messaging/Model/Message/UserMessageStatus.php create mode 100644 src/Domain/Messaging/Service/Handler/RequeueHandler.php create mode 100644 src/Domain/Messaging/Service/MaxProcessTimeLimiter.php create mode 100644 src/Domain/Messaging/Service/RateLimitedCampaignMailer.php create mode 100644 src/Domain/Messaging/Service/SendRateLimiter.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index 54c649d8..e34a7d2b 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -68,3 +68,16 @@ parameters: graylog_host: 'graylog.example.com' graylog_port: 12201 + + app.phplist_isp_conf_path: '%%env(APP_PHPLIST_ISP_CONF_PATH)%%' + env(APP_PHPLIST_ISP_CONF_PATH): '/etc/phplist.conf' + + # Message sending + messaging.mail_queue_batch_size: '%%env(MAILQUEUE_BATCH_SIZE)%%' + env(MAILQUEUE_BATCH_SIZE): '5' + messaging.mail_queue_period: '%%env(MAILQUEUE_BATCH_PERIOD)%%' + env(MAILQUEUE_BATCH_PERIOD): '5' + messaging.mail_queue_throttle: '%%env(MAILQUEUE_THROTTLE)%%' + env(MAILQUEUE_THROTTLE): '5' + messaging.max_process_time: '%%env(MESSAGING_MAX_PROCESS_TIME)%%' + env(MESSAGING_MAX_PROCESS_TIME): '600' diff --git a/config/services.yml b/config/services.yml index 47be8241..b21dc5aa 100644 --- a/config/services.yml +++ b/config/services.yml @@ -7,10 +7,6 @@ services: autoconfigure: true public: false - PhpList\Core\Core\ConfigProvider: - arguments: - $config: '%app.config%' - PhpList\Core\Core\ApplicationStructure: public: true diff --git a/config/services/providers.yml b/config/services/providers.yml index 226c4e81..bb4524c3 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -2,3 +2,13 @@ services: PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider: autowire: true autoconfigure: true + + PhpList\Core\Core\ConfigProvider: + arguments: + $config: '%app.config%' + + PhpList\Core\Domain\Common\IspRestrictionsProvider: + autowire: true + autoconfigure: true + arguments: + $confPath: '%app.phplist_isp_conf_path%' diff --git a/config/services/services.yml b/config/services/services.yml index f1b68e74..1afd1fc5 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -36,6 +36,14 @@ services: autoconfigure: true public: true + PhpList\Core\Domain\Messaging\Service\SendRateLimiter: + autowire: true + autoconfigure: true + arguments: + $mailqueueBatchSize: '%messaging.mail_queue_batch_size%' + $mailqueueBatchPeriod: '%messaging.mail_queue_period%' + $mailqueueThrottle: '%messaging.mail_queue_throttle%' + PhpList\Core\Domain\Common\ClientIpResolver: autowire: true autoconfigure: true @@ -44,6 +52,10 @@ services: autowire: true autoconfigure: true + PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer: + autowire: true + autoconfigure: true + PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler: autowire: true autoconfigure: true @@ -108,6 +120,13 @@ services: arguments: - !tagged_iterator { tag: 'phplist.bounce_action_handler' } + PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter: + autowire: true + autoconfigure: true + arguments: + $maxSeconds: '%messaging.max_process_time%' + + PhpList\Core\Domain\Identity\Service\PermissionChecker: autowire: true autoconfigure: true diff --git a/src/Domain/Common/IspRestrictionsProvider.php b/src/Domain/Common/IspRestrictionsProvider.php new file mode 100644 index 00000000..4095f5ce --- /dev/null +++ b/src/Domain/Common/IspRestrictionsProvider.php @@ -0,0 +1,137 @@ +readConfigFile(); + if ($contents === null) { + return new IspRestrictions(null, null, null); + } + + [$raw, $maxBatch, $minBatchPeriod, $lockFile] = $this->parseContents($contents); + + $this->logIfDetected($maxBatch, $minBatchPeriod, $lockFile); + + return new IspRestrictions($maxBatch, $minBatchPeriod, $lockFile, $raw); + } + + private function readConfigFile(): ?string + { + if (!is_file($this->confPath) || !is_readable($this->confPath)) { + return null; + } + $contents = file_get_contents($this->confPath); + if ($contents === false) { + $this->logger->warning('Cannot read ISP restrictions file', ['path' => $this->confPath]); + return null; + } + return $contents; + } + + /** + * @return array{0: array, 1: ?int, 2: ?int, 3: ?string} + */ + private function parseContents(string $contents): array + { + $maxBatch = null; + $minBatchPeriod = null; + $lockFile = null; + $raw = []; + + foreach (preg_split('/\R/', $contents) as $line) { + [$key, $val] = $this->parseLine($line); + if ($key === null) { + continue; + } + $raw[$key] = $val; + [$maxBatch, $minBatchPeriod, $lockFile] = $this->applyKeyValue( + $key, + $val, + $maxBatch, + $minBatchPeriod, + $lockFile + ); + } + + return [$raw, $maxBatch, $minBatchPeriod, $lockFile]; + } + + /** + * @return array{0: ?string, 1: string} + */ + private function parseLine(string $line): array + { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#') || str_starts_with($line, ';')) { + return [null, '']; + } + $parts = explode('=', $line, 2); + if (\count($parts) !== 2) { + return [null, '']; + } + + return array_map('trim', $parts); + } + + /** + * @param string $key + * @param string $val + * @param ?int $maxBatch + * @param ?int $minBatchPeriod + * @param ?string $lockFile + * @return array{0: ?int, 1: ?int, 2: ?string} + */ + private function applyKeyValue( + string $key, + string $val, + ?int $maxBatch, + ?int $minBatchPeriod, + ?string $lockFile + ): array { + if ($key === 'maxbatch') { + if ($val !== '' && ctype_digit($val)) { + $maxBatch = (int) $val; + } + return [$maxBatch, $minBatchPeriod, $lockFile]; + } + if ($key === 'minbatchperiod') { + if ($val !== '' && ctype_digit($val)) { + $minBatchPeriod = (int) $val; + } + return [$maxBatch, $minBatchPeriod, $lockFile]; + } + if ($key === 'lockfile') { + if ($val !== '') { + $lockFile = $val; + } + return [$maxBatch, $minBatchPeriod, $lockFile]; + } + return [$maxBatch, $minBatchPeriod, $lockFile]; + } + + private function logIfDetected(?int $maxBatch, ?int $minBatchPeriod, ?string $lockFile): void + { + if ($maxBatch !== null || $minBatchPeriod !== null || $lockFile !== null) { + $this->logger->info('ISP restrictions detected', [ + 'path' => $this->confPath, + 'maxbatch' => $maxBatch, + 'minbatchperiod' => $minBatchPeriod, + 'lockfile' => $lockFile, + ]); + } + } +} diff --git a/src/Domain/Common/Model/IspRestrictions.php b/src/Domain/Common/Model/IspRestrictions.php new file mode 100644 index 00000000..c3fc56b4 --- /dev/null +++ b/src/Domain/Common/Model/IspRestrictions.php @@ -0,0 +1,21 @@ +maxBatch === null && $this->minBatchPeriod === null && $this->lockFile === null; + } +} diff --git a/src/Domain/Configuration/Service/Manager/ConfigManager.php b/src/Domain/Configuration/Service/Manager/ConfigManager.php index cae380be..1a9c356f 100644 --- a/src/Domain/Configuration/Service/Manager/ConfigManager.php +++ b/src/Domain/Configuration/Service/Manager/ConfigManager.php @@ -17,6 +17,12 @@ public function __construct(ConfigRepository $configRepository) $this->configRepository = $configRepository; } + public function inMaintenanceMode(): bool + { + $config = $this->getByItem('maintenancemode'); + return $config?->getValue() === '1'; + } + /** * Get a configuration item by its key */ diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php index 820d403d..d2c7cbfa 100644 --- a/src/Domain/Messaging/Command/ProcessQueueCommand.php +++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php @@ -4,6 +4,9 @@ namespace PhpList\Core\Domain\Messaging\Command; +use DateTimeImmutable; +use PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager; +use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor; @@ -24,18 +27,21 @@ class ProcessQueueCommand extends Command private LockFactory $lockFactory; private MessageProcessingPreparator $messagePreparator; private CampaignProcessor $campaignProcessor; + private ConfigManager $configManager; public function __construct( MessageRepository $messageRepository, LockFactory $lockFactory, MessageProcessingPreparator $messagePreparator, CampaignProcessor $campaignProcessor, + ConfigManager $configManager ) { parent::__construct(); $this->messageRepository = $messageRepository; $this->lockFactory = $lockFactory; $this->messagePreparator = $messagePreparator; $this->campaignProcessor = $campaignProcessor; + $this->configManager = $configManager; } /** @@ -50,11 +56,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } + if ($this->configManager->inMaintenanceMode()) { + $output->writeln('The system is in maintenance mode, stopping. Try again later.'); + + return Command::FAILURE; + } + try { $this->messagePreparator->ensureSubscribersHaveUuid($output); $this->messagePreparator->ensureCampaignsHaveUuid($output); - $campaigns = $this->messageRepository->findBy(['status' => 'submitted']); + $campaigns = $this->messageRepository->getByStatusAndEmbargo( + status: MessageStatus::Submitted, + embargo: new DateTimeImmutable() + ); foreach ($campaigns as $campaign) { $this->campaignProcessor->process($campaign, $output); diff --git a/src/Domain/Messaging/Model/Dto/Message/MessageMetadataDto.php b/src/Domain/Messaging/Model/Dto/Message/MessageMetadataDto.php index 0776c1d1..91802df2 100644 --- a/src/Domain/Messaging/Model/Dto/Message/MessageMetadataDto.php +++ b/src/Domain/Messaging/Model/Dto/Message/MessageMetadataDto.php @@ -4,10 +4,12 @@ namespace PhpList\Core\Domain\Messaging\Model\Dto\Message; +use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; + class MessageMetadataDto { public function __construct( - public readonly string $status, + public readonly MessageStatus $status, ) { } } diff --git a/src/Domain/Messaging/Model/Message/MessageMetadata.php b/src/Domain/Messaging/Model/Message/MessageMetadata.php index 123103ff..156539b2 100644 --- a/src/Domain/Messaging/Model/Message/MessageMetadata.php +++ b/src/Domain/Messaging/Model/Message/MessageMetadata.php @@ -6,6 +6,7 @@ use DateTime; use Doctrine\ORM\Mapping as ORM; +use InvalidArgumentException; use PhpList\Core\Domain\Common\Model\Interfaces\EmbeddableInterface; #[ORM\Embeddable] @@ -33,13 +34,13 @@ class MessageMetadata implements EmbeddableInterface private ?DateTime $sendStart; public function __construct( - ?string $status = null, + ?MessageStatus $status = null, int $bounceCount = 0, ?DateTime $entered = null, ?DateTime $sent = null, ?DateTime $sendStart = null, ) { - $this->status = $status; + $this->status = $status->value ?? null; $this->processed = false; $this->viewed = 0; $this->bounceCount = $bounceCount; @@ -48,14 +49,21 @@ public function __construct( $this->sendStart = $sendStart; } - public function getStatus(): ?string + /** + * @SuppressWarnings("PHPMD.StaticAccess") + */ + public function getStatus(): ?MessageStatus { - return $this->status; + return MessageStatus::from($this->status); } - public function setStatus(string $status): self + public function setStatus(MessageStatus $status): self { - $this->status = $status; + if (!$this->getStatus()->canTransitionTo($status)) { + throw new InvalidArgumentException('Invalid status transition'); + } + $this->status = $status->value; + return $this; } diff --git a/src/Domain/Messaging/Model/Message/MessageStatus.php b/src/Domain/Messaging/Model/Message/MessageStatus.php new file mode 100644 index 00000000..90b7f987 --- /dev/null +++ b/src/Domain/Messaging/Model/Message/MessageStatus.php @@ -0,0 +1,38 @@ + [self::Submitted], + self::Submitted => [self::Prepared, self::InProcess], + self::Prepared => [self::InProcess], + self::InProcess => [self::Sent, self::Suspended, self::Submitted], + self::Requeued => [self::InProcess, self::Suspended], + self::Sent => [], + }; + } + + public function canTransitionTo(self $next): bool + { + return in_array($next, $this->allowedTransitions(), true); + } +} diff --git a/src/Domain/Messaging/Model/Message/UserMessageStatus.php b/src/Domain/Messaging/Model/Message/UserMessageStatus.php new file mode 100644 index 00000000..1237cfe8 --- /dev/null +++ b/src/Domain/Messaging/Model/Message/UserMessageStatus.php @@ -0,0 +1,16 @@ +viewed; } - public function getStatus(): ?string + /** + * @SuppressWarnings("PHPMD.StaticAccess") + */ + public function getStatus(): ?UserMessageStatus { - return $this->status; + return UserMessageStatus::from($this->status); } public function setViewed(?DateTime $viewed): self @@ -76,9 +80,9 @@ public function setViewed(?DateTime $viewed): self return $this; } - public function setStatus(?string $status): self + public function setStatus(?UserMessageStatus $status): self { - $this->status = $status; + $this->status = $status->value; return $this; } } diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php index 3da7ebf3..0ae8bc18 100644 --- a/src/Domain/Messaging/Repository/MessageRepository.php +++ b/src/Domain/Messaging/Repository/MessageRepository.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Domain\Messaging\Repository; +use DateTimeImmutable; use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface; use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; @@ -74,4 +75,15 @@ public function incrementBounceCount(int $messageId): void ->getQuery() ->execute(); } + + public function getByStatusAndEmbargo(Message\MessageStatus $status, DateTimeImmutable $embargo): array + { + return $this->createQueryBuilder('m') + ->where('m.status = :status') + ->andWhere('m.embargo IS NULL OR m.embargo <= :embargo') + ->setParameter('status', $status->value) + ->setParameter('embargo', $embargo) + ->getQuery() + ->getResult(); + } } diff --git a/src/Domain/Messaging/Repository/UserMessageRepository.php b/src/Domain/Messaging/Repository/UserMessageRepository.php index a19c5823..e8268025 100644 --- a/src/Domain/Messaging/Repository/UserMessageRepository.php +++ b/src/Domain/Messaging/Repository/UserMessageRepository.php @@ -4,8 +4,32 @@ namespace PhpList\Core\Domain\Messaging\Repository; +use DateTimeInterface; use PhpList\Core\Domain\Common\Repository\AbstractRepository; +use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus; +use PhpList\Core\Domain\Messaging\Model\UserMessage; +use PhpList\Core\Domain\Subscription\Model\Subscriber; class UserMessageRepository extends AbstractRepository { + public function findOneByUserAndMessage(Subscriber $subscriber, Message $campaign): ?UserMessage + { + return $this->findOneBy(['user' => $subscriber, 'message' => $campaign]); + } + + /** + * Counts how many user messages have status "sent" since the given time. + */ + public function countSentSince(DateTimeInterface $since): int + { + $queryBuilder = $this->createQueryBuilder('um'); + $queryBuilder->select('COUNT(um)') + ->where('um.createdAt > :since') + ->andWhere('um.status = :status') + ->setParameter('since', $since) + ->setParameter('status', UserMessageStatus::Sent->value); + + return (int) $queryBuilder->getQuery()->getSingleScalarResult(); + } } diff --git a/src/Domain/Messaging/Service/Builder/MessageBuilder.php b/src/Domain/Messaging/Service/Builder/MessageBuilder.php index e8807170..85e74331 100644 --- a/src/Domain/Messaging/Service/Builder/MessageBuilder.php +++ b/src/Domain/Messaging/Service/Builder/MessageBuilder.php @@ -45,7 +45,7 @@ public function build(MessageDtoInterface $createMessageDto, object $context = n return $context->getExisting(); } - $metadata = new Message\MessageMetadata($createMessageDto->getMetadata()->status); + $metadata = new Message\MessageMetadata(Message\MessageStatus::Draft); return new Message($format, $schedule, $metadata, $content, $options, $context->getOwner(), $template); } diff --git a/src/Domain/Messaging/Service/Handler/RequeueHandler.php b/src/Domain/Messaging/Service/Handler/RequeueHandler.php new file mode 100644 index 00000000..6a1d9d95 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/RequeueHandler.php @@ -0,0 +1,60 @@ +getSchedule(); + $interval = $schedule->getRequeueInterval() ?? 0; + $until = $schedule->getRequeueUntil(); + + if ($interval <= 0) { + return false; + } + $now = new DateTime(); + if ($until instanceof DateTime && $now > $until) { + return false; + } + + $embargoIsInFuture = $schedule->getEmbargo() instanceof DateTime && $schedule->getEmbargo() > new DateTime(); + $base = $embargoIsInFuture ? clone $schedule->getEmbargo() : new DateTime(); + $next = (clone $base)->add(new DateInterval('PT' . max(1, $interval) . 'M')); + if ($until instanceof DateTime && $next > $until) { + return false; + } + + $schedule->setEmbargo($next); + $campaign->setSchedule($schedule); + $campaign->getMetadata()->setStatus(MessageStatus::Submitted); + $this->entityManager->flush(); + + $output?->writeln(sprintf( + 'Requeued campaign; next embargo at %s', + $next->format(DateTime::ATOM) + )); + $this->logger->info('Campaign requeued with new embargo', [ + 'campaign_id' => $campaign->getId(), + 'embargo' => $next->format(DateTime::ATOM), + ]); + + return true; + } +} diff --git a/src/Domain/Messaging/Service/Manager/MessageManager.php b/src/Domain/Messaging/Service/Manager/MessageManager.php index 7b263083..c73f31ca 100644 --- a/src/Domain/Messaging/Service/Manager/MessageManager.php +++ b/src/Domain/Messaging/Service/Manager/MessageManager.php @@ -43,6 +43,14 @@ public function updateMessage( return $message; } + public function updateStatus(Message $message, Message\MessageStatus $status): Message + { + $message->getMetadata()->setStatus($status); + $this->messageRepository->save($message); + + return $message; + } + public function delete(Message $message): void { $this->messageRepository->remove($message); diff --git a/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php b/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php new file mode 100644 index 00000000..c5269aaa --- /dev/null +++ b/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php @@ -0,0 +1,46 @@ +maxSeconds = $maxSeconds ?? 600; + } + + public function start(): void + { + $this->startedAt = microtime(true); + } + + public function shouldStop(?OutputInterface $output = null): bool + { + if ($this->maxSeconds <= 0) { + return false; + } + if ($this->startedAt <= 0.0) { + $this->start(); + } + $elapsed = microtime(true) - $this->startedAt; + if ($elapsed >= $this->maxSeconds) { + $this->logger->warning(sprintf('Reached max processing time of %d seconds', $this->maxSeconds)); + $output?->writeln('Reached max processing time; stopping cleanly.'); + + return true; + } + + return false; + } +} diff --git a/src/Domain/Messaging/Service/Processor/CampaignProcessor.php b/src/Domain/Messaging/Service/Processor/CampaignProcessor.php index 13a100a3..92313e28 100644 --- a/src/Domain/Messaging/Service/Processor/CampaignProcessor.php +++ b/src/Domain/Messaging/Service/Processor/CampaignProcessor.php @@ -6,70 +6,126 @@ use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Messaging\Model\UserMessage; +use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus; +use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; +use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; +use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; +use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer; +use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider; +use PhpList\Core\Domain\Subscription\Model\Subscriber; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Mailer\MailerInterface; -use Symfony\Component\Mime\Email; use Throwable; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class CampaignProcessor { - private MailerInterface $mailer; + private RateLimitedCampaignMailer $mailer; private EntityManagerInterface $entityManager; private SubscriberProvider $subscriberProvider; private MessageProcessingPreparator $messagePreparator; private LoggerInterface $logger; + private UserMessageRepository $userMessageRepository; + private MaxProcessTimeLimiter $timeLimiter; + private RequeueHandler $requeueHandler; public function __construct( - MailerInterface $mailer, + RateLimitedCampaignMailer $mailer, EntityManagerInterface $entityManager, SubscriberProvider $subscriberProvider, MessageProcessingPreparator $messagePreparator, LoggerInterface $logger, + UserMessageRepository $userMessageRepository, + MaxProcessTimeLimiter $timeLimiter, + RequeueHandler $requeueHandler ) { $this->mailer = $mailer; $this->entityManager = $entityManager; $this->subscriberProvider = $subscriberProvider; $this->messagePreparator = $messagePreparator; $this->logger = $logger; + $this->userMessageRepository = $userMessageRepository; + $this->timeLimiter = $timeLimiter; + $this->requeueHandler = $requeueHandler; } public function process(Message $campaign, ?OutputInterface $output = null): void { + $this->updateMessageStatus($campaign, MessageStatus::Prepared); $subscribers = $this->subscriberProvider->getSubscribersForMessage($campaign); - // phpcs:ignore Generic.Commenting.Todo - // @todo check $ISPrestrictions logic + + $this->updateMessageStatus($campaign, MessageStatus::InProcess); + + $this->timeLimiter->start(); + $stoppedEarly = false; + foreach ($subscribers as $subscriber) { + if ($this->timeLimiter->shouldStop($output)) { + $stoppedEarly = true; + break; + } + + $existing = $this->userMessageRepository->findOneByUserAndMessage($subscriber, $campaign); + if ($existing && $existing->getStatus() !== UserMessageStatus::Todo) { + continue; + } + + $userMessage = $existing ?? new UserMessage($subscriber, $campaign); + $userMessage->setStatus(UserMessageStatus::Active); + $this->userMessageRepository->save($userMessage); + if (!filter_var($subscriber->getEmail(), FILTER_VALIDATE_EMAIL)) { + $this->updateUserMessageStatus($userMessage, UserMessageStatus::InvalidEmailAddress); + $this->unconfirmSubscriber($subscriber); + $output?->writeln('Invalid email, marking unconfirmed: ' . $subscriber->getEmail()); continue; } - $this->messagePreparator->processMessageLinks($campaign, $subscriber->getId()); - $email = (new Email()) - ->from('news@example.com') - ->to($subscriber->getEmail()) - ->subject($campaign->getContent()->getSubject()) - ->text($campaign->getContent()->getTextMessage()) - ->html($campaign->getContent()->getText()); + + $processed = $this->messagePreparator->processMessageLinks($campaign, $subscriber->getId()); try { + $email = $this->mailer->composeEmail($processed, $subscriber); $this->mailer->send($email); - - // phpcs:ignore Generic.Commenting.Todo - // @todo log somewhere that this subscriber got email + $this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent); } catch (Throwable $e) { + $this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent); $this->logger->error($e->getMessage(), [ 'subscriber_id' => $subscriber->getId(), 'campaign_id' => $campaign->getId(), ]); $output?->writeln('Failed to send to: ' . $subscriber->getEmail()); } + } - usleep(100000); + if ($stoppedEarly && $this->requeueHandler->handle($campaign, $output)) { + return; } - $campaign->getMetadata()->setStatus('sent'); + $this->updateMessageStatus($campaign, MessageStatus::Sent); + } + + private function unconfirmSubscriber(Subscriber $subscriber): void + { + if ($subscriber->isConfirmed()) { + $subscriber->setConfirmed(false); + $this->entityManager->flush(); + } + } + + private function updateMessageStatus(Message $message, MessageStatus $status): void + { + $message->getMetadata()->setStatus($status); + $this->entityManager->flush(); + } + + private function updateUserMessageStatus(UserMessage $userMessage, UserMessageStatus $status): void + { + $userMessage->setStatus($status); $this->entityManager->flush(); } } diff --git a/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php b/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php new file mode 100644 index 00000000..7691f970 --- /dev/null +++ b/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php @@ -0,0 +1,50 @@ +mailer = $mailer; + $this->limiter = $limiter; + } + + public function composeEmail(Message $processed, Subscriber $subscriber): Email + { + $email = new Email(); + if ($processed->getOptions()->getFromField() !== '') { + $email->from($processed->getOptions()->getFromField()); + } + + if ($processed->getOptions()->getReplyTo() !== '') { + $email->replyTo($processed->getOptions()->getReplyTo()); + } + + return $email + ->to($subscriber->getEmail()) + ->subject($processed->getContent()->getSubject()) + ->text($processed->getContent()->getTextMessage()) + ->html($processed->getContent()->getText()); + } + + /** + * @throws TransportExceptionInterface + */ + public function send(Email $email): void + { + $this->limiter->awaitTurn(); + $this->mailer->send($email); + $this->limiter->afterSend(); + } +} diff --git a/src/Domain/Messaging/Service/SendRateLimiter.php b/src/Domain/Messaging/Service/SendRateLimiter.php new file mode 100644 index 00000000..378b80d5 --- /dev/null +++ b/src/Domain/Messaging/Service/SendRateLimiter.php @@ -0,0 +1,103 @@ +initializeLimits(); + } + + private function initializeLimits(): void + { + $isp = $this->ispRestrictionsProvider->load(); + + $cfgBatch = $this->mailqueueBatchSize ?? 0; + $ispMax = isset($isp->maxBatch) ? (int)$isp->maxBatch : null; + + $cfgPeriod = $this->mailqueueBatchPeriod ?? 0; + $ispMinPeriod = $isp->minBatchPeriod ?? 0; + + $cfgThrottle = $this->mailqueueThrottle ?? 0; + $ispMinThrottle = (int)($isp->minThrottle ?? 0); + + if ($cfgBatch <= 0) { + $this->batchSize = $ispMax !== null ? max(0, $ispMax) : 0; + } else { + $this->batchSize = $ispMax !== null ? min($cfgBatch, max(1, $ispMax)) : $cfgBatch; + } + $this->batchPeriod = max(0, $cfgPeriod, $ispMinPeriod); + $this->throttleSec = max(0, $cfgThrottle, $ispMinThrottle); + + $this->sentInBatch = 0; + $this->batchStart = microtime(true); + $this->initializedFromHistory = false; + } + + /** + * Call before attempting to send another message. It will sleep if needed to + * respect batch limits. Returns true when it's okay to proceed. + */ + public function awaitTurn(?OutputInterface $output = null): bool + { + if (!$this->initializedFromHistory && $this->batchSize > 0 && $this->batchPeriod > 0) { + $since = (new DateTimeImmutable())->sub(new DateInterval('PT' . $this->batchPeriod . 'S')); + $alreadySent = $this->userMessageRepository->countSentSince($since); + $this->sentInBatch = max($this->sentInBatch, $alreadySent); + $this->initializedFromHistory = true; + } + + if ($this->batchSize > 0 && $this->batchPeriod > 0 && $this->sentInBatch >= $this->batchSize) { + $elapsed = microtime(true) - $this->batchStart; + $remaining = (int)ceil($this->batchPeriod - $elapsed); + if ($remaining > 0) { + $output?->writeln(sprintf( + 'Batch limit reached, sleeping %ds to respect MAILQUEUE_BATCH_PERIOD', + $remaining + )); + sleep($remaining); + } + $this->batchStart = microtime(true); + $this->sentInBatch = 0; + $this->initializedFromHistory = false; + } + + return true; + } + + /** + * Call after a successful sending to update counters and apply per-message throttle. + */ + public function afterSend(): void + { + $this->sentInBatch++; + if ($this->throttleSec > 0) { + sleep($this->throttleSec); + } + } +} diff --git a/src/Domain/Subscription/Service/Provider/SubscriberProvider.php b/src/Domain/Subscription/Service/Provider/SubscriberProvider.php index 5ec6b177..72b473be 100644 --- a/src/Domain/Subscription/Service/Provider/SubscriberProvider.php +++ b/src/Domain/Subscription/Service/Provider/SubscriberProvider.php @@ -36,7 +36,7 @@ public function getSubscribersForMessage(Message $message): array foreach ($lists as $list) { $listSubscribers = $this->subscriberRepository->getSubscribersBySubscribedListId($list->getId()); foreach ($listSubscribers as $subscriber) { - $subscribers[$subscriber->getId()] = $subscriber; + $subscribers[$subscriber->getEmail()] = $subscriber; } } diff --git a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php index d7435815..1bd98735 100644 --- a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php +++ b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php @@ -48,7 +48,7 @@ public function testMessageIsPersistedAndFetchedCorrectly(): void $message = new Message( new MessageFormat(true, 'text'), new MessageSchedule(1, null, 3, null, null), - new MessageMetadata('done'), + new MessageMetadata(Message\MessageStatus::Sent), new MessageContent('Hello world!'), new MessageOptions(), $admin @@ -62,7 +62,7 @@ public function testMessageIsPersistedAndFetchedCorrectly(): void self::assertCount(1, $foundMessages); self::assertInstanceOf(Message::class, $foundMessages[0]); - self::assertSame('done', $foundMessages[0]->getMetadata()->getStatus()); + self::assertSame(Message\MessageStatus::Sent, $foundMessages[0]->getMetadata()->getStatus()); self::assertSame('Hello world!', $foundMessages[0]->getContent()->getSubject()); } @@ -77,7 +77,7 @@ public function testGetByOwnerIdReturnsOnlyOwnedMessages(): void $msg1 = new Message( new MessageFormat(true, MessageFormat::FORMAT_TEXT), new MessageSchedule(1, null, 3, null, null), - new MessageMetadata('done'), + new MessageMetadata(Message\MessageStatus::Sent), new MessageContent('Owned by Admin 1!'), new MessageOptions(), $admin1 diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php index b3bfda0c..9019fd30 100644 --- a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php +++ b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php @@ -58,7 +58,7 @@ public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError(): $msg = new Message( format: new MessageFormat(true, MessageFormat::FORMAT_TEXT), schedule: new MessageSchedule(1, null, 3, null, null), - metadata: new MessageMetadata('done'), + metadata: new MessageMetadata(Message\MessageStatus::Sent), content: new MessageContent('Owned by Admin 1!'), options: new MessageOptions(), owner: $admin @@ -92,7 +92,7 @@ public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError(): $this->entityManager->persist($linkTrackUmlClick); $userMessage = new UserMessage($subscriber, $msg); - $userMessage->setStatus('sent'); + $userMessage->setStatus(Message\UserMessageStatus::Sent); $this->entityManager->persist($userMessage); $userMessageBounce = new UserMessageBounce(1, new DateTime()); diff --git a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php index 79ece9bd..d76f63c0 100644 --- a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php +++ b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Command; use Exception; +use PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager; use PhpList\Core\Domain\Messaging\Command\ProcessQueueCommand; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; @@ -41,7 +42,8 @@ protected function setUp(): void $this->messageRepository, $lockFactory, $this->messageProcessingPreparator, - $this->campaignProcessor + $this->campaignProcessor, + $this->createMock(ConfigManager::class), ); $application = new Application(); @@ -82,8 +84,8 @@ public function testExecuteWithNoCampaigns(): void ->method('ensureCampaignsHaveUuid'); $this->messageRepository->expects($this->once()) - ->method('findBy') - ->with(['status' => 'submitted']) + ->method('getByStatusAndEmbargo') + ->with($this->anything(), $this->anything()) ->willReturn([]); $this->campaignProcessor->expects($this->never()) @@ -112,8 +114,8 @@ public function testExecuteWithCampaigns(): void $campaign = $this->createMock(Message::class); $this->messageRepository->expects($this->once()) - ->method('findBy') - ->with(['status' => 'submitted']) + ->method('getByStatusAndEmbargo') + ->with($this->anything(), $this->anything()) ->willReturn([$campaign]); $this->campaignProcessor->expects($this->once()) @@ -145,8 +147,8 @@ public function testExecuteWithMultipleCampaigns(): void $campaign2 = $this->createMock(Message::class); $this->messageRepository->expects($this->once()) - ->method('findBy') - ->with(['status' => 'submitted']) + ->method('getByStatusAndEmbargo') + ->with($this->anything(), $this->anything()) ->willReturn([$campaign1, $campaign2]); $this->campaignProcessor->expects($this->exactly(2)) @@ -179,8 +181,8 @@ public function testExecuteWithProcessorException(): void $campaign = $this->createMock(Message::class); $this->messageRepository->expects($this->once()) - ->method('findBy') - ->with(['status' => 'submitted']) + ->method('getByStatusAndEmbargo') + ->with($this->anything(), $this->anything()) ->willReturn([$campaign]); $this->campaignProcessor->expects($this->once()) diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php index 564bd34d..d99d041a 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php @@ -2,9 +2,8 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Service\Builder; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder; -use Error; use InvalidArgumentException; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Dto\CreateMessageDto; @@ -64,7 +63,7 @@ private function createRequest(): CreateMessageDto formatOptions: [] ), metadata: new MessageMetadataDto( - status: 'draft' + status: Message\MessageStatus::Draft ), options: new MessageOptionsDto( fromField: '', @@ -117,16 +116,6 @@ public function testBuildsNewMessage(): void $this->builder->build($request, $context); } - public function testThrowsExceptionOnInvalidRequest(): void - { - $this->expectException(Error::class); - - $this->builder->build( - $this->createMock(CreateMessageDto::class), - new MessageContext($this->createMock(Administrator::class)) - ); - } - public function testThrowsExceptionOnInvalidContext(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php index 2b1aa771..21f90692 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Service\Builder; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder; use InvalidArgumentException; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageContentDto; diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php index 8d9320a0..1bd576f5 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Service\Builder; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder; use InvalidArgumentException; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageFormatDto; diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php index c6795d29..754177a2 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Service\Builder; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder; use InvalidArgumentException; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageOptionsDto; diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php index 38f04338..25a89052 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Service\Builder; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder; use DateTime; use InvalidArgumentException; diff --git a/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php new file mode 100644 index 00000000..5bfb1114 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php @@ -0,0 +1,155 @@ +logger = $this->createMock(LoggerInterface::class); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->output = $this->createMock(OutputInterface::class); + } + + private function createMessage( + ?int $requeueInterval, + ?DateTime $requeueUntil, + ?DateTime $embargo + ): Message { + $format = new MessageFormat(htmlFormatted: false, sendFormat: null); + $schedule = new MessageSchedule( + repeatInterval: null, + repeatUntil: null, + requeueInterval: $requeueInterval, + requeueUntil: $requeueUntil, + embargo: $embargo + ); + $metadata = new MessageMetadata(MessageStatus::Draft); + $content = new MessageContent('(no subject)'); + $options = new MessageOptions(); + + return new Message($format, $schedule, $metadata, $content, $options, owner: null, template: null); + } + + public function testReturnsFalseWhenIntervalIsZeroOrNegative(): void + { + $handler = new RequeueHandler($this->logger, $this->em); + $message = $this->createMessage(0, null, null); + + $this->em->expects($this->never())->method('flush'); + $this->output->expects($this->never())->method('writeln'); + $this->logger->expects($this->never())->method('info'); + + $result = $handler->handle($message, $this->output); + + $this->assertFalse($result); + $this->assertSame(MessageStatus::Draft, $message->getMetadata()->getStatus()); + } + + public function testReturnsFalseWhenNowIsAfterRequeueUntil(): void + { + $handler = new RequeueHandler($this->logger, $this->em); + $past = (new DateTime())->sub(new DateInterval('PT5M')); + $message = $this->createMessage(5, $past, null); + + $this->em->expects($this->never())->method('flush'); + $this->logger->expects($this->never())->method('info'); + + $result = $handler->handle($message, $this->output); + + $this->assertFalse($result); + $this->assertSame(MessageStatus::Draft, $message->getMetadata()->getStatus()); + } + + public function testRequeuesFromFutureEmbargoAndSetsSubmittedStatus(): void + { + $handler = new RequeueHandler($this->logger, $this->em); + $embargo = (new DateTime())->add(new DateInterval('PT5M')); + $interval = 10; + $message = $this->createMessage($interval, null, $embargo); + + $this->em->expects($this->once())->method('flush'); + $this->output->expects($this->once())->method('writeln'); + $this->logger->expects($this->once())->method('info'); + + $result = $handler->handle($message, $this->output); + + $this->assertTrue($result); + $this->assertSame(MessageStatus::Submitted, $message->getMetadata()->getStatus()); + + $expectedNext = (clone $embargo)->add(new DateInterval('PT' . $interval . 'M')); + $actualNext = $message->getSchedule()->getEmbargo(); + $this->assertInstanceOf(DateTime::class, $actualNext); + $this->assertEquals($expectedNext->format(DateTime::ATOM), $actualNext->format(DateTime::ATOM)); + } + + public function testRequeuesFromNowWhenEmbargoIsNullOrPast(): void + { + $handler = new RequeueHandler($this->logger, $this->em); + $interval = 3; + $message = $this->createMessage($interval, null, null); + + $this->em->expects($this->once())->method('flush'); + $this->logger->expects($this->once())->method('info'); + + $before = new DateTime(); + $result = $handler->handle($message, $this->output); + $after = new DateTime(); + + $this->assertTrue($result); + $this->assertSame(MessageStatus::Submitted, $message->getMetadata()->getStatus()); + + $embargo = $message->getSchedule()->getEmbargo(); + $this->assertInstanceOf(DateTime::class, $embargo); + + $minExpected = (clone $before)->add(new DateInterval('PT' . $interval . 'M')); + $maxExpected = (clone $after)->add(new DateInterval('PT' . $interval . 'M')); + + $this->assertGreaterThanOrEqual($minExpected->getTimestamp(), $embargo->getTimestamp()); + $this->assertLessThanOrEqual($maxExpected->getTimestamp(), $embargo->getTimestamp()); + } + + public function testReturnsFalseWhenNextEmbargoExceedsUntil(): void + { + $handler = new RequeueHandler($this->logger, $this->em); + $embargo = (new DateTime())->add(new DateInterval('PT1M')); + $interval = 10; + // next would be +10, which exceeds until + $until = (clone $embargo)->add(new DateInterval('PT5M')); + $message = $this->createMessage($interval, $until, $embargo); + + $this->em->expects($this->never())->method('flush'); + $this->logger->expects($this->never())->method('info'); + + $result = $handler->handle($message, $this->output); + + $this->assertFalse($result); + $this->assertSame(MessageStatus::Draft, $message->getMetadata()->getStatus()); + $this->assertEquals( + $embargo->format(DateTime::ATOM), + $message->getSchedule()->getEmbargo()?->format(DateTime::ATOM) + ); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php index aa1a47e0..94064485 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php @@ -13,6 +13,7 @@ use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageScheduleDto; use PhpList\Core\Domain\Messaging\Model\Dto\UpdateMessageDto; use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\Builder\MessageBuilder; use PhpList\Core\Domain\Messaging\Service\Manager\MessageManager; @@ -34,7 +35,7 @@ public function testCreateMessageReturnsPersistedMessage(): void requeueInterval: 60 * 12, requeueUntil: '2025-04-20T00:00:00+00:00', ); - $metadata = new MessageMetadataDto('draft'); + $metadata = new MessageMetadataDto(Message\MessageStatus::Draft); $content = new MessageContentDto('Subject', 'Full text', 'Short text', 'Footer'); $options = new MessageOptionsDto('from@example.com', 'to@example.com', 'reply@example.com', 'all-users'); @@ -50,11 +51,11 @@ public function testCreateMessageReturnsPersistedMessage(): void $authUser = $this->createMock(Administrator::class); $expectedMessage = $this->createMock(Message::class); - $expectedContent = $this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class); + $expectedContent = $this->createMock(MessageContent::class); $expectedMetadata = $this->createMock(Message\MessageMetadata::class); $expectedContent->method('getSubject')->willReturn('Subject'); - $expectedMetadata->method('getStatus')->willReturn('draft'); + $expectedMetadata->method('getStatus')->willReturn(Message\MessageStatus::Draft); $expectedMessage->method('getContent')->willReturn($expectedContent); $expectedMessage->method('getMetadata')->willReturn($expectedMetadata); @@ -71,7 +72,7 @@ public function testCreateMessageReturnsPersistedMessage(): void $message = $manager->createMessage($request, $authUser); $this->assertSame('Subject', $message->getContent()->getSubject()); - $this->assertSame('draft', $message->getMetadata()->getStatus()); + $this->assertSame(Message\MessageStatus::Draft, $message->getMetadata()->getStatus()); } public function testUpdateMessageReturnsUpdatedMessage(): void @@ -88,7 +89,7 @@ public function testUpdateMessageReturnsUpdatedMessage(): void requeueInterval: 0, requeueUntil: '2025-04-20T00:00:00+00:00', ); - $metadata = new MessageMetadataDto('draft'); + $metadata = new MessageMetadataDto(Message\MessageStatus::Draft); $content = new MessageContentDto( 'Updated Subject', 'Updated Full text', @@ -115,11 +116,11 @@ public function testUpdateMessageReturnsUpdatedMessage(): void $authUser = $this->createMock(Administrator::class); $existingMessage = $this->createMock(Message::class); - $expectedContent = $this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class); + $expectedContent = $this->createMock(MessageContent::class); $expectedMetadata = $this->createMock(Message\MessageMetadata::class); $expectedContent->method('getSubject')->willReturn('Updated Subject'); - $expectedMetadata->method('getStatus')->willReturn('draft'); + $expectedMetadata->method('getStatus')->willReturn(Message\MessageStatus::Draft); $existingMessage->method('getContent')->willReturn($expectedContent); $existingMessage->method('getMetadata')->willReturn($expectedMetadata); @@ -136,6 +137,6 @@ public function testUpdateMessageReturnsUpdatedMessage(): void $message = $manager->updateMessage($updateRequest, $existingMessage, $authUser); $this->assertSame('Updated Subject', $message->getContent()->getSubject()); - $this->assertSame('draft', $message->getMetadata()->getStatus()); + $this->assertSame(Message\MessageStatus::Draft, $message->getMetadata()->getStatus()); } } diff --git a/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php b/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php new file mode 100644 index 00000000..5944ca3e --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php @@ -0,0 +1,53 @@ +logger = $this->createMock(LoggerInterface::class); + } + + public function testShouldNotStopWhenMaxSecondsIsZero(): void + { + $limiter = new MaxProcessTimeLimiter(logger: $this->logger, maxSeconds: 0); + + $output = $this->createMock(OutputInterface::class); + $output->expects($this->never())->method('writeln'); + $this->logger->expects($this->never())->method('warning'); + + $limiter->start(); + usleep(200_000); + $this->assertFalse($limiter->shouldStop($output)); + } + + public function testShouldStopAfterThresholdAndLogAndOutput(): void + { + $limiter = new MaxProcessTimeLimiter(logger: $this->logger, maxSeconds: 1); + + $output = $this->createMock(OutputInterface::class); + $output->expects($this->once()) + ->method('writeln') + ->with('Reached max processing time; stopping cleanly.'); + + $this->logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('Reached max processing time of 1 seconds')); + + $this->assertFalse($limiter->shouldStop($output)); + + usleep(1_200_000); + $this->assertTrue($limiter->shouldStop($output)); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php index b2c51c71..26aec09f 100644 --- a/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php @@ -9,42 +9,50 @@ 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\Repository\UserMessageRepository; +use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; +use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor; +use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; class CampaignProcessorTest extends TestCase { - private MailerInterface&MockObject $mailer; - private EntityManagerInterface&MockObject $entityManager; - private SubscriberProvider&MockObject $subscriberProvider; - private MessageProcessingPreparator&MockObject $messagePreparator; - private LoggerInterface&MockObject $logger; - private OutputInterface&MockObject $output; + private RateLimitedCampaignMailer|MockObject $mailer; + private EntityManagerInterface|MockObject $entityManager; + private SubscriberProvider|MockObject $subscriberProvider; + private MessageProcessingPreparator|MockObject $messagePreparator; + private LoggerInterface|MockObject $logger; + private OutputInterface|MockObject $output; private CampaignProcessor $campaignProcessor; + private UserMessageRepository|MockObject $userMessageRepository; protected function setUp(): void { - $this->mailer = $this->createMock(MailerInterface::class); + $this->mailer = $this->createMock(RateLimitedCampaignMailer::class); $this->entityManager = $this->createMock(EntityManagerInterface::class); $this->subscriberProvider = $this->createMock(SubscriberProvider::class); $this->messagePreparator = $this->createMock(MessageProcessingPreparator::class); $this->logger = $this->createMock(LoggerInterface::class); $this->output = $this->createMock(OutputInterface::class); + $this->userMessageRepository = $this->createMock(UserMessageRepository::class); $this->campaignProcessor = new CampaignProcessor( - $this->mailer, - $this->entityManager, - $this->subscriberProvider, - $this->messagePreparator, - $this->logger + mailer: $this->mailer, + entityManager: $this->entityManager, + subscriberProvider: $this->subscriberProvider, + messagePreparator: $this->messagePreparator, + logger: $this->logger, + userMessageRepository: $this->userMessageRepository, + timeLimiter: $this->createMock(MaxProcessTimeLimiter::class), + requeueHandler: $this->createMock(RequeueHandler::class), ); } @@ -59,11 +67,10 @@ public function testProcessWithNoSubscribers(): void ->with($campaign) ->willReturn([]); - $metadata->expects($this->once()) - ->method('setStatus') - ->with('sent'); + $metadata->expects($this->atLeastOnce()) + ->method('setStatus'); - $this->entityManager->expects($this->once()) + $this->entityManager->expects($this->atLeastOnce()) ->method('flush'); $this->mailer->expects($this->never()) @@ -87,11 +94,10 @@ public function testProcessWithInvalidSubscriberEmail(): void ->with($campaign) ->willReturn([$subscriber]); - $metadata->expects($this->once()) - ->method('setStatus') - ->with('sent'); + $metadata->expects($this->atLeastOnce()) + ->method('setStatus'); - $this->entityManager->expects($this->once()) + $this->entityManager->expects($this->atLeastOnce()) ->method('flush'); $this->messagePreparator->expects($this->never()) @@ -123,22 +129,28 @@ public function testProcessWithValidSubscriberEmail(): void ->with($campaign, 1) ->willReturn($campaign); + $this->mailer->expects($this->once()) + ->method('composeEmail') + ->with($campaign, $subscriber) + ->willReturnCallback(function ($processed, $sub) use ($campaign, $subscriber) { + $this->assertSame($campaign, $processed); + $this->assertSame($subscriber, $sub); + return (new Email()) + ->from('news@example.com') + ->to('test@example.com') + ->subject('Test Subject') + ->text('Test text message') + ->html('

Test HTML message

'); + }); + $this->mailer->expects($this->once()) ->method('send') - ->with($this->callback(function (Email $email) { - $this->assertEquals('test@example.com', $email->getTo()[0]->getAddress()); - $this->assertEquals('news@example.com', $email->getFrom()[0]->getAddress()); - $this->assertEquals('Test Subject', $email->getSubject()); - $this->assertEquals('Test text message', $email->getTextBody()); - $this->assertEquals('

Test HTML message

', $email->getHtmlBody()); - return true; - })); - - $metadata->expects($this->once()) - ->method('setStatus') - ->with('sent'); - - $this->entityManager->expects($this->once()) + ->with($this->isInstanceOf(Email::class)); + + $metadata->expects($this->atLeastOnce()) + ->method('setStatus'); + + $this->entityManager->expects($this->atLeastOnce()) ->method('flush'); $this->campaignProcessor->process($campaign, $this->output); @@ -181,11 +193,10 @@ public function testProcessWithMailerException(): void ->method('writeln') ->with('Failed to send to: test@example.com'); - $metadata->expects($this->once()) - ->method('setStatus') - ->with('sent'); + $metadata->expects($this->atLeastOnce()) + ->method('setStatus'); - $this->entityManager->expects($this->once()) + $this->entityManager->expects($this->atLeastOnce()) ->method('flush'); $this->campaignProcessor->process($campaign, $this->output); @@ -221,11 +232,10 @@ public function testProcessWithMultipleSubscribers(): void $this->mailer->expects($this->exactly(2)) ->method('send'); - $metadata->expects($this->once()) - ->method('setStatus') - ->with('sent'); + $metadata->expects($this->atLeastOnce()) + ->method('setStatus'); - $this->entityManager->expects($this->once()) + $this->entityManager->expects($this->atLeastOnce()) ->method('flush'); $this->campaignProcessor->process($campaign, $this->output); @@ -264,11 +274,10 @@ public function testProcessWithNullOutput(): void 'campaign_id' => 123, ]); - $metadata->expects($this->once()) - ->method('setStatus') - ->with('sent'); + $metadata->expects($this->atLeastOnce()) + ->method('setStatus'); - $this->entityManager->expects($this->once()) + $this->entityManager->expects($this->atLeastOnce()) ->method('flush'); $this->campaignProcessor->process($campaign, null); @@ -277,7 +286,7 @@ public function testProcessWithNullOutput(): void /** * Creates a mock for the Message class with content */ - private function createCampaignMock(): Message&MockObject + private function createCampaignMock(): Message|MockObject { $campaign = $this->createMock(Message::class); $content = $this->createMock(MessageContent::class); diff --git a/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php b/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php new file mode 100644 index 00000000..6e2011ff --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php @@ -0,0 +1,134 @@ +mailer = $this->createMock(MailerInterface::class); + $this->limiter = $this->createMock(SendRateLimiter::class); + $this->sut = new RateLimitedCampaignMailer($this->mailer, $this->limiter); + } + + public function testComposeEmailSetsHeadersAndBody(): void + { + $message = $this->buildMessage( + subject: 'Subject', + textBody: 'Plain text', + htmlBody: '

HTML

', + from: 'from@example.com', + replyTo: 'reply@example.com' + ); + + $subscriber = new Subscriber(); + $this->setSubscriberEmail($subscriber, 'user@example.com'); + + $email = $this->sut->composeEmail($message, $subscriber); + + $this->assertInstanceOf(Email::class, $email); + $this->assertSame('user@example.com', $email->getTo()[0]->getAddress()); + $this->assertSame('Subject', $email->getSubject()); + $this->assertSame('from@example.com', $email->getFrom()[0]->getAddress()); + $this->assertSame('reply@example.com', $email->getReplyTo()[0]->getAddress()); + $this->assertSame('Plain text', $email->getTextBody()); + $this->assertSame('

HTML

', $email->getHtmlBody()); + } + + public function testComposeEmailWithoutOptionalHeaders(): void + { + $message = $this->buildMessage( + subject: 'No headers', + textBody: 'text', + htmlBody: 'h', + from: '', + replyTo: '' + ); + + $subscriber = new Subscriber(); + $this->setSubscriberEmail($subscriber, 'user2@example.com'); + + $email = $this->sut->composeEmail($message, $subscriber); + + $this->assertSame('user2@example.com', $email->getTo()[0]->getAddress()); + $this->assertSame('No headers', $email->getSubject()); + $this->assertSame([], $email->getFrom()); + $this->assertSame([], $email->getReplyTo()); + } + + public function testSendUsesLimiterAroundMailer(): void + { + $email = (new Email())->to('someone@example.com'); + + $this->limiter->expects($this->once())->method('awaitTurn'); + $this->mailer + ->expects($this->once()) + ->method('send') + ->with($this->isInstanceOf(Email::class)); + $this->limiter->expects($this->once())->method('afterSend'); + + $this->sut->send($email); + } + + private function buildMessage( + string $subject, + string $textBody, + string $htmlBody, + string $from, + string $replyTo + ): Message { + $content = new MessageContent( + subject: $subject, + text: $htmlBody, + textMessage: $textBody, + footer: null, + ); + $format = new MessageFormat( + htmlFormatted: true, + sendFormat: MessageFormat::FORMAT_HTML, + formatOptions: [MessageFormat::FORMAT_HTML] + ); + $schedule = new MessageSchedule( + repeatInterval: 0, + repeatUntil: null, + requeueInterval: 0, + requeueUntil: null, + embargo: null + ); + $metadata = new MessageMetadata(); + $options = new MessageOptions(fromField: $from, toField: '', replyTo: $replyTo); + + return new Message($format, $schedule, $metadata, $content, $options, null, null); + } + + /** + * Subscriber has no public setter for email, so we use reflection. + */ + private function setSubscriberEmail(Subscriber $subscriber, string $email): void + { + $ref = new ReflectionProperty($subscriber, 'email'); + $ref->setValue($subscriber, $email); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php b/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php new file mode 100644 index 00000000..e9ba27c0 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php @@ -0,0 +1,90 @@ +ispProvider = $this->createMock(IspRestrictionsProvider::class); + } + + public function testInitializesLimitsFromConfigOnly(): void + { + $this->ispProvider->method('load')->willReturn(new IspRestrictions(null, null, null)); + $limiter = new SendRateLimiter( + ispRestrictionsProvider: $this->ispProvider, + userMessageRepository: $this->createMock(UserMessageRepository::class), + mailqueueBatchSize: 5, + mailqueueBatchPeriod: 10, + mailqueueThrottle: 2 + ); + + $output = $this->createMock(OutputInterface::class); + $output->expects($this->never())->method('writeln'); + + $this->assertTrue($limiter->awaitTurn($output)); + } + + public function testBatchLimitTriggersWaitMessageAndResetsCounters(): void + { + $this->ispProvider->method('load')->willReturn(new IspRestrictions(2, 1, null)); + $limiter = new SendRateLimiter( + ispRestrictionsProvider: $this->ispProvider, + userMessageRepository: $this->createMock(UserMessageRepository::class), + mailqueueBatchSize: 10, + mailqueueBatchPeriod: 1, + mailqueueThrottle: 0 + ); + + $limiter->afterSend(); + $limiter->afterSend(); + + $output = $this->createMock(OutputInterface::class); + // We cannot reliably assert the exact second, but we assert a message called at least once + $output->expects($this->atLeast(0))->method('writeln'); + + // Now awaitTurn should detect batch full and attempt to sleep and reset. + $this->assertTrue($limiter->awaitTurn($output)); + + // Next afterSend should increase the counter again without exception + $limiter->afterSend(); + // Reaching here means no fatal due to internal counter/reset logic + $this->assertTrue(true); + } + + public function testThrottleSleepsPerMessagePathIsCallable(): void + { + $this->ispProvider->method('load')->willReturn(new IspRestrictions(null, null, null)); + $limiter = new SendRateLimiter( + ispRestrictionsProvider: $this->ispProvider, + userMessageRepository: $this->createMock(UserMessageRepository::class), + mailqueueBatchSize: 0, + mailqueueBatchPeriod: 0, + mailqueueThrottle: 1 + ); + + // We cannot speed up sleep without extensions; just call method to ensure no exceptions + $start = microtime(true); + $limiter->afterSend(); + $elapsed = microtime(true) - $start; + + // Ensure it likely slept at least ~0.5s + if ($elapsed < 0.3) { + $this->markTestIncomplete('Environment too fast to detect sleep; logic path executed.'); + } + $this->assertTrue(true); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php index 6adbde10..9efdeac2 100644 --- a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php +++ b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php @@ -26,8 +26,8 @@ protected function setUp(): void $this->subscriberListRepository = $this->createMock(SubscriberListRepository::class); $this->subscriberProvider = new SubscriberProvider( - $this->subscriberRepository, - $this->subscriberListRepository, + subscriberRepository: $this->subscriberRepository, + subscriberListRepository: $this->subscriberListRepository, ); } @@ -82,9 +82,9 @@ public function testGetSubscribersForMessageWithOneListAndSubscribersReturnsSubs ->willReturn([$subscriberList]); $subscriber1 = $this->createMock(Subscriber::class); - $subscriber1->method('getId')->willReturn(1); + $subscriber1->method('getEmail')->willReturn('test1@example.am'); $subscriber2 = $this->createMock(Subscriber::class); - $subscriber2->method('getId')->willReturn(2); + $subscriber2->method('getEmail')->willReturn('test2@exsmple.am'); $this->subscriberRepository ->expects($this->once()) @@ -114,11 +114,11 @@ public function testGetSubscribersForMessageWithMultipleListsReturnsUniqueSubscr ->willReturn([$subscriberList1, $subscriberList2]); $subscriber1 = $this->createMock(Subscriber::class); - $subscriber1->method('getId')->willReturn(1); + $subscriber1->method('getEmail')->willReturn('test1@example.am'); $subscriber2 = $this->createMock(Subscriber::class); - $subscriber2->method('getId')->willReturn(2); + $subscriber2->method('getEmail')->willReturn('test2@example.am'); $subscriber3 = $this->createMock(Subscriber::class); - $subscriber3->method('getId')->willReturn(3); + $subscriber3->method('getEmail')->willReturn('test3@example.am'); $this->subscriberRepository ->expects($this->exactly(2)) From fd51731d9b39d452581fe55ac4e1cfc543676b5c Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Tue, 23 Sep 2025 10:57:25 +0400 Subject: [PATCH 08/10] Exceptions + translations (#361) * MissingMessageIdException * MailboxConnectionException * BadMethodCallException (style fix) * Translate user facing messages * Translate * Translate PasswordResetMessageHandler texts * Translate SubscriberConfirmationMessageHandler texts * MessageBuilder exceptions * BlacklistEmailAndDeleteBounceHandler * BlacklistEmailHandler - AdvancedBounceRulesProcessor * AdvancedBounceRulesProcessor * BounceStatus * CampaignProcessor * UnidentifiedBounceReprocessor * Style fix * Test fix * ConsecutiveBounceHandler --------- Co-authored-by: Tatevik --- resources/translations/messages.en.xlf | 488 ++++++++++++++++-- .../Exception/MissingMessageIdException.php | 15 + .../Analytics/Service/LinkTrackService.php | 6 +- .../Exception/MailboxConnectionException.php | 23 + src/Domain/Common/I18n/Messages.php | 29 -- src/Domain/Common/IspRestrictionsProvider.php | 6 + .../Common/Mail/NativeImapMailReader.php | 4 +- .../Repository/CursorPaginationTrait.php | 8 +- src/Domain/Common/SystemInfoCollector.php | 7 +- .../Service/Manager/EventLogManager.php | 1 + .../Command/CleanUpOldSessionTokens.php | 1 + .../AdminAttributeDefinitionManager.php | 11 +- .../Identity/Service/PasswordManager.php | 3 +- .../Identity/Service/SessionManager.php | 9 +- .../Command/ProcessBouncesCommand.php | 17 +- .../Messaging/Command/ProcessQueueCommand.php | 12 +- .../Command/SendTestEmailCommand.php | 30 +- .../Exception/ImapConnectionException.php | 16 + .../Exception/InvalidContextTypeException.php | 15 + .../Exception/InvalidDtoTypeException.php | 15 + .../Exception/OpenMboxFileException.php | 16 + .../PasswordResetMessageHandler.php | 42 +- .../SubscriberConfirmationMessageHandler.php | 34 +- src/Domain/Messaging/Model/BounceStatus.php | 19 + .../Service/Builder/MessageBuilder.php | 14 +- .../Service/Builder/MessageContentBuilder.php | 12 +- .../Service/Builder/MessageFormatBuilder.php | 10 +- .../Service/Builder/MessageOptionsBuilder.php | 14 +- .../Builder/MessageScheduleBuilder.php | 14 +- .../Service/ConsecutiveBounceHandler.php | 26 +- .../BlacklistEmailAndDeleteBounceHandler.php | 20 +- .../Service/Handler/BlacklistEmailHandler.php | 16 +- .../BlacklistUserAndDeleteBounceHandler.php | 14 +- .../Service/Handler/BlacklistUserHandler.php | 14 +- ...CountConfirmUserAndDeleteBounceHandler.php | 10 +- .../Service/Handler/RequeueHandler.php | 10 +- .../UnconfirmUserAndDeleteBounceHandler.php | 10 +- .../Service/Handler/UnconfirmUserHandler.php | 12 +- .../Service/Manager/BounceManager.php | 13 +- .../Service/MaxProcessTimeLimiter.php | 10 +- .../Service/MessageProcessingPreparator.php | 14 +- .../Service/NativeBounceProcessingService.php | 11 +- .../AdvancedBounceRulesProcessor.php | 24 +- .../Service/Processor/BounceDataProcessor.php | 62 ++- .../Service/Processor/CampaignProcessor.php | 14 +- .../Service/Processor/MboxBounceProcessor.php | 11 +- .../Service/Processor/PopBounceProcessor.php | 10 +- .../UnidentifiedBounceReprocessor.php | 39 +- .../Messaging/Service/SendRateLimiter.php | 8 +- .../WebklexBounceProcessingService.php | 13 +- .../Validator/TemplateImageValidator.php | 21 +- .../Validator/TemplateLinkValidator.php | 12 +- .../CouldNotReadUploadedFileException.php | 12 + .../SubscriberAttributeCreationException.php | 2 +- .../Subscription/Service/CsvImporter.php | 6 +- .../Manager/AttributeDefinitionManager.php | 16 +- .../Service/Manager/SubscribePageManager.php | 4 +- .../Manager/SubscriberAttributeManager.php | 6 +- .../Service/Manager/SubscriberManager.php | 6 +- .../Service/Manager/SubscriptionManager.php | 7 +- .../Service/SubscriberBlacklistService.php | 6 +- .../Service/SubscriberCsvImporter.php | 24 +- .../Validator/AttributeTypeValidator.php | 17 +- .../Service/LinkTrackServiceTest.php | 4 +- .../Repository/CursorPaginationTraitTest.php | 6 +- .../AdminAttributeDefinitionManagerTest.php | 14 +- .../Identity/Service/SessionManagerTest.php | 5 +- .../Command/ProcessBouncesCommandTest.php | 5 + .../Command/ProcessQueueCommandTest.php | 21 +- .../Command/SendTestEmailCommandTest.php | 11 +- .../PasswordResetMessageHandlerTest.php | 7 +- ...bscriberConfirmationMessageHandlerTest.php | 7 +- .../Service/Builder/MessageBuilderTest.php | 26 +- .../Builder/MessageContentBuilderTest.php | 4 +- .../Builder/MessageFormatBuilderTest.php | 4 +- .../Builder/MessageOptionsBuilderTest.php | 4 +- .../Builder/MessageScheduleBuilderTest.php | 4 +- .../Service/ConsecutiveBounceHandlerTest.php | 10 +- ...acklistEmailAndDeleteBounceHandlerTest.php | 2 + .../Handler/BlacklistEmailHandlerTest.php | 2 + ...lacklistUserAndDeleteBounceHandlerTest.php | 2 + .../Handler/BlacklistUserHandlerTest.php | 4 +- ...tConfirmUserAndDeleteBounceHandlerTest.php | 2 + .../Service/Handler/RequeueHandlerTest.php | 11 +- ...nconfirmUserAndDeleteBounceHandlerTest.php | 2 + .../Handler/UnconfirmUserHandlerTest.php | 6 +- .../Service/Manager/BounceManagerTest.php | 2 + .../Service/MaxProcessTimeLimiterTest.php | 5 +- .../MessageProcessingPreparatorTest.php | 15 +- .../AdvancedBounceRulesProcessorTest.php | 17 +- .../Processor/CampaignProcessorTest.php | 7 +- .../Processor/MboxBounceProcessorTest.php | 21 +- .../Processor/PopBounceProcessorTest.php | 6 +- .../UnidentifiedBounceReprocessorTest.php | 4 +- .../Messaging/Service/SendRateLimiterTest.php | 4 + .../Validator/TemplateImageValidatorTest.php | 3 +- .../Validator/TemplateLinkValidatorTest.php | 3 +- .../AttributeDefinitionManagerTest.php | 31 +- .../Manager/SubscribePageManagerTest.php | 2 + .../SubscriberAttributeManagerTest.php | 11 +- .../Service/Manager/SubscriberManagerTest.php | 2 + .../Service/SubscriberCsvImporterTest.php | 12 +- .../Validator/AttributeTypeValidatorTest.php | 3 +- 103 files changed, 1305 insertions(+), 397 deletions(-) create mode 100644 src/Domain/Analytics/Exception/MissingMessageIdException.php create mode 100644 src/Domain/Common/Exception/MailboxConnectionException.php delete mode 100644 src/Domain/Common/I18n/Messages.php create mode 100644 src/Domain/Messaging/Exception/ImapConnectionException.php create mode 100644 src/Domain/Messaging/Exception/InvalidContextTypeException.php create mode 100644 src/Domain/Messaging/Exception/InvalidDtoTypeException.php create mode 100644 src/Domain/Messaging/Exception/OpenMboxFileException.php create mode 100644 src/Domain/Messaging/Model/BounceStatus.php create mode 100644 src/Domain/Subscription/Exception/CouldNotReadUploadedFileException.php diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf index 7e176e3e..6f128be1 100644 --- a/resources/translations/messages.en.xlf +++ b/resources/translations/messages.en.xlf @@ -1,44 +1,450 @@ - - - - - - Not authorized - Not authorized - - - - Failed admin login attempt for '%login%' - Failed admin login attempt for '%login%' - - - - Login attempt for disabled admin '%login%' - Login attempt for disabled admin '%login%' - - - - - Administrator not found - Administrator not found - - - - - Subscriber list not found. - Subscriber list not found. - - - Subscriber does not exists. - Subscriber does not exists. - - - Subscription not found for this subscriber and list. - Subscription not found for this subscriber and list. - - - - + + + + + Not authorized + Not authorized + + + + Failed admin login attempt for '%login%' + Failed admin login attempt for '%login%' + + + + Login attempt for disabled admin '%login%' + Login attempt for disabled admin '%login%' + + + + + Administrator not found + Administrator not found + + + + Attribute definition already exists. + Attribute definition already exists. + + + + Password Reset Request + + + + Hello, + + A password reset has been requested for your account. + Please use the following token to reset your password: + + %token% + + If you did not request this password reset, please ignore this email. + + Thank you. + + + Hello, + + A password reset has been requested for your account. + Please use the following token to reset your password: + + %token% + + If you did not request this password reset, please ignore this email. + + Thank you. + + + + + Password Reset Request!

+

Hello! A password reset has been requested for your account.

+

Please use the following token to reset your password:

+

Reset Password

+

If you did not request this password reset, please ignore this email.

+

Thank you.

]]> + + Password Reset Request!

+

Hello! A password reset has been requested for your account.

+

Please use the following token to reset your password:

+

Reset Password

+

If you did not request this password reset, please ignore this email.

+

Thank you.

+ ]]> +
+
+ + + Please confirm your subscription + Please confirm your subscription + + + + Thank you for subscribing! + + Please confirm your subscription by clicking the link below: + + %confirmation_link% + + If you did not request this subscription, please ignore this email. + + Thank you for subscribing! + + Please confirm your subscription by clicking the link below: + + %confirmation_link% + + If you did not request this subscription, please ignore this email. + + + + + Thank you for subscribing!

+

Please confirm your subscription by clicking the link below:

+

Confirm Subscription

+

If you did not request this subscription, please ignore this email.

]]> + + Thank you for subscribing!

+

Please confirm your subscription by clicking the link below:

+

Confirm Subscription

+

If you did not request this subscription, please ignore this email.

]]> +
+
+ + + + PHP IMAP extension not available. Falling back to Webklex IMAP. + PHP IMAP extension not available. Falling back to Webklex IMAP. + + + + Could not apply force lock. Aborting. + Could not apply force lock. Aborting. + + + + Another bounce processing is already running. Aborting. + Another bounce processing is already running. Aborting. + + + + Queue is already being processed by another instance. + Queue is already being processed by another instance. + + + + The system is in maintenance mode, stopping. Try again later. + The system is in maintenance mode, stopping. Try again later. + + + + Bounce processing completed. + Bounce processing completed. + + + + Recipient email address not provided + Recipient email address not provided + + + + Invalid email address: %email% + Invalid email address: %email% + + + + Sending test email synchronously to %email% + Sending test email synchronously to %email% + + + + Queuing test email for %email% + Queuing test email for %email% + + + + Test email sent successfully! + Test email sent successfully! + + + + Test email queued successfully! It will be sent asynchronously. + Test email queued successfully! It will be sent asynchronously. + + + + Failed to send test email: %error% + Failed to send test email: %error% + + + + Email address auto blacklisted by bounce rule %rule_id% + Email address auto blacklisted by bounce rule %rule_id% + + + + Auto Unsubscribed + Auto Unsubscribed + + + + User auto unsubscribed for bounce rule %rule_id% + User auto unsubscribed for bounce rule %rule_id% + + + + email auto unsubscribed for bounce rule %rule_id% + email auto unsubscribed for bounce rule %rule_id% + + + + Subscriber auto blacklisted by bounce rule %rule_id% + Subscriber auto blacklisted by bounce rule %rule_id% + + + + User auto unsubscribed for bounce rule %%rule_id% + User auto unsubscribed for bounce rule %%rule_id% + + + + Auto confirmed + Auto confirmed + + + + Auto unconfirmed + Auto unconfirmed + + + + Subscriber auto confirmed for bounce rule %rule_id% + Subscriber auto confirmed for bounce rule %rule_id% + + + + Requeued campaign; next embargo at %time% + Requeued campaign; next embargo at %time% + + + + Subscriber auto unconfirmed for bounce rule %rule_id% + Subscriber auto unconfirmed for bounce rule %rule_id% + + + + Running in test mode, not deleting messages from mailbox + Running in test mode, not deleting messages from mailbox + + + + Processed messages will be deleted from the mailbox + Processed messages will be deleted from the mailbox + + + + Processing bounces based on active bounce rules + Processing bounces based on active bounce rules + + + + No active rules + No active rules + + + + Processed %processed% out of %total% bounces for advanced bounce rules + Processed %processed% out of %total% bounces for advanced bounce rules + + + + %processed% bounces processed by advanced processing + %processed% bounces processed by advanced processing + + + %not_processed% bounces were not matched by advanced processing rules + %not_processed% bounces were not matched by advanced processing rules + + + + Opening mbox %file% + Opening mbox %file% + + + Connecting to %mailbox% + Connecting to %mailbox% + + + Please do not interrupt this process + Please do not interrupt this process + + + mbox file path must be provided with --mailbox. + mbox file path must be provided with --mailbox. + + + + Invalid email, marking unconfirmed: %email% + Invalid email, marking unconfirmed: %email% + + + Failed to send to: %email% + Failed to send to: %email% + + + + Reprocessing unidentified bounces + Reprocessing unidentified bounces + + + %total% bounces to reprocess + %total% bounces to reprocess + + + %count% out of %total% processed + %count% out of %total% processed + + + %reparsed% bounces were re-processed and %reidentified% bounces were re-identified + %reparsed% bounces were re-processed and %reidentified% bounces were re-identified + + + + Identifying consecutive bounces + Identifying consecutive bounces + + + Nothing to do + Nothing to do + + + Processed %processed% out of %total% subscribers + Processed %processed% out of %total% subscribers + + + Total of %total% subscribers processed + Total of %total% subscribers processed + + + Subscriber auto unconfirmed for %count% consecutive bounces + Subscriber auto unconfirmed for %count% consecutive bounces + + + %count% consecutive bounces, threshold reached + %count% consecutive bounces, threshold reached + + + + Reached max processing time; stopping cleanly. + Reached max processing time; stopping cleanly. + + + + Giving a UUID to %count% subscribers, this may take a while + Giving a UUID to %count% subscribers, this may take a while + + + Giving a UUID to %count% campaigns + Giving a UUID to %count% campaigns + + + + Batch limit reached, sleeping %sleep%s to respect MAILQUEUE_BATCH_PERIOD + Batch limit reached, sleeping %sleep%s to respect MAILQUEUE_BATCH_PERIOD + + + + Value must be an array of image URLs. + Value must be an array of image URLs. + + + Image "%url%" is not a full URL. + Image "%url%" is not a full URL. + + + Image "%url%" does not exist (HTTP %code%) + Image "%url%" does not exist (HTTP %code%) + + + Image "%url%" could not be validated: %message% + Image "%url%" could not be validated: %message% + + + + Not full URLs: %urls% + Not full URLs: %urls% + + + + + + Subscriber list not found. + Subscriber list not found. + + + + Subscriber does not exists. + Subscriber does not exists. + + + + Subscription not found for this subscriber and list. + Subscription not found for this subscriber and list. + + + Attribute definition already exists + Attribute definition already exists + + + Another attribute with this name already exists. + Another attribute with this name already exists. + + + + Subscribe page not found + Subscribe page not found + + + Value is required + Value is required + + + Subscriber not found + Subscriber not found + + + Unexpected error: %error% + Unexpected error: %error% + + + Added to blacklist for reason %reason% + Added to blacklist for reason %reason% + + + Could not read the uploaded file. + Could not read the uploaded file. + + + Error processing %email%: %error% + Error processing %email%: %error% + + + General import error: %error% + General import error: %error% + + + Value must be a string. + Value must be a string. + + + Invalid attribute type: "%type%". Valid types are: %valid_types% + Invalid attribute type: "%type%". Valid types are: %valid_types% + + + +
diff --git a/src/Domain/Analytics/Exception/MissingMessageIdException.php b/src/Domain/Analytics/Exception/MissingMessageIdException.php new file mode 100644 index 00000000..71479ff0 --- /dev/null +++ b/src/Domain/Analytics/Exception/MissingMessageIdException.php @@ -0,0 +1,15 @@ +getId(); if ($messageId === null) { - throw new InvalidArgumentException('Message must have an ID'); + throw new MissingMessageIdException(); } $links = $this->extractLinksFromHtml($content->getText() ?? ''); diff --git a/src/Domain/Common/Exception/MailboxConnectionException.php b/src/Domain/Common/Exception/MailboxConnectionException.php new file mode 100644 index 00000000..20a927b5 --- /dev/null +++ b/src/Domain/Common/Exception/MailboxConnectionException.php @@ -0,0 +1,23 @@ +confPath); if ($contents === false) { $this->logger->warning('Cannot read ISP restrictions file', ['path' => $this->confPath]); + return null; } + return $contents; } @@ -106,20 +108,24 @@ private function applyKeyValue( if ($val !== '' && ctype_digit($val)) { $maxBatch = (int) $val; } + return [$maxBatch, $minBatchPeriod, $lockFile]; } if ($key === 'minbatchperiod') { if ($val !== '' && ctype_digit($val)) { $minBatchPeriod = (int) $val; } + return [$maxBatch, $minBatchPeriod, $lockFile]; } if ($key === 'lockfile') { if ($val !== '') { $lockFile = $val; } + return [$maxBatch, $minBatchPeriod, $lockFile]; } + return [$maxBatch, $minBatchPeriod, $lockFile]; } diff --git a/src/Domain/Common/Mail/NativeImapMailReader.php b/src/Domain/Common/Mail/NativeImapMailReader.php index 472fea54..ba90151d 100644 --- a/src/Domain/Common/Mail/NativeImapMailReader.php +++ b/src/Domain/Common/Mail/NativeImapMailReader.php @@ -6,7 +6,7 @@ use DateTimeImmutable; use IMAP\Connection; -use RuntimeException; +use PhpList\Core\Domain\Common\Exception\MailboxConnectionException; class NativeImapMailReader { @@ -24,7 +24,7 @@ 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')); + throw new MailboxConnectionException($mailbox); } return $link; diff --git a/src/Domain/Common/Repository/CursorPaginationTrait.php b/src/Domain/Common/Repository/CursorPaginationTrait.php index 8be64ee2..3cf67a72 100644 --- a/src/Domain/Common/Repository/CursorPaginationTrait.php +++ b/src/Domain/Common/Repository/CursorPaginationTrait.php @@ -4,9 +4,9 @@ namespace PhpList\Core\Domain\Common\Repository; +use BadMethodCallException; use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface; use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; -use RuntimeException; trait CursorPaginationTrait { @@ -30,14 +30,14 @@ public function getAfterId(int $lastId, int $limit): array * Get filtered + paginated messages for a given owner and status. * * @return DomainModel[] - * @throws RuntimeException - */ + * @throws BadMethodCallException + * */ public function getFilteredAfterId(int $lastId, int $limit, ?FilterRequestInterface $filter = null): array { if ($filter === null) { return $this->getAfterId($lastId, $limit); } - throw new RuntimeException('Filter method not implemented'); + throw new BadMethodCallException('getFilteredAfterId method not implemented'); } } diff --git a/src/Domain/Common/SystemInfoCollector.php b/src/Domain/Common/SystemInfoCollector.php index e66d27b1..56b30579 100644 --- a/src/Domain/Common/SystemInfoCollector.php +++ b/src/Domain/Common/SystemInfoCollector.php @@ -16,10 +16,8 @@ class SystemInfoCollector /** * @param string[] $configuredKeys keys to include (empty => use defaults) */ - public function __construct( - RequestStack $requestStack, - array $configuredKeys = [] - ) { + public function __construct(RequestStack $requestStack, array $configuredKeys = []) + { $this->requestStack = $requestStack; $this->configuredKeys = $configuredKeys; } @@ -72,6 +70,7 @@ public function collectAsString(): string foreach ($pairs as $k => $v) { $lines[] = sprintf('%s = %s', $k, $v); } + return "\n" . implode("\n", $lines); } } diff --git a/src/Domain/Configuration/Service/Manager/EventLogManager.php b/src/Domain/Configuration/Service/Manager/EventLogManager.php index 374db7ed..d896a8f1 100644 --- a/src/Domain/Configuration/Service/Manager/EventLogManager.php +++ b/src/Domain/Configuration/Service/Manager/EventLogManager.php @@ -44,6 +44,7 @@ public function get( ?DateTimeInterface $dateTo = null ): array { $filter = new EventLogFilter($page, $dateFrom, $dateTo); + return $this->repository->getFilteredAfterId($lastId, $limit, $filter); } diff --git a/src/Domain/Identity/Command/CleanUpOldSessionTokens.php b/src/Domain/Identity/Command/CleanUpOldSessionTokens.php index 348ea025..364d5ea9 100644 --- a/src/Domain/Identity/Command/CleanUpOldSessionTokens.php +++ b/src/Domain/Identity/Command/CleanUpOldSessionTokens.php @@ -35,6 +35,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(sprintf('Successfully removed %d expired session token(s).', $deletedCount)); } catch (Throwable $throwable) { $output->writeln(sprintf('Error removing expired session tokens: %s', $throwable->getMessage())); + return Command::FAILURE; } diff --git a/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php b/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php index f0a18e07..24a70de8 100644 --- a/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php +++ b/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php @@ -9,25 +9,32 @@ use PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository; use PhpList\Core\Domain\Identity\Exception\AttributeDefinitionCreationException; use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator; +use Symfony\Contracts\Translation\TranslatorInterface; class AdminAttributeDefinitionManager { private AdminAttributeDefinitionRepository $definitionRepository; private AttributeTypeValidator $attributeTypeValidator; + private TranslatorInterface $translator; public function __construct( AdminAttributeDefinitionRepository $definitionRepository, - AttributeTypeValidator $attributeTypeValidator + AttributeTypeValidator $attributeTypeValidator, + TranslatorInterface $translator ) { $this->definitionRepository = $definitionRepository; $this->attributeTypeValidator = $attributeTypeValidator; + $this->translator = $translator; } public function create(AdminAttributeDefinitionDto $attributeDefinitionDto): AdminAttributeDefinition { $existingAttribute = $this->definitionRepository->findOneByName($attributeDefinitionDto->name); if ($existingAttribute) { - throw new AttributeDefinitionCreationException('Attribute definition already exists', 409); + throw new AttributeDefinitionCreationException( + $this->translator->trans('Attribute definition already exists.'), + 409 + ); } $this->attributeTypeValidator->validate($attributeDefinitionDto->type); diff --git a/src/Domain/Identity/Service/PasswordManager.php b/src/Domain/Identity/Service/PasswordManager.php index 2c7ebe1e..36c88570 100644 --- a/src/Domain/Identity/Service/PasswordManager.php +++ b/src/Domain/Identity/Service/PasswordManager.php @@ -5,7 +5,6 @@ namespace PhpList\Core\Domain\Identity\Service; use DateTime; -use PhpList\Core\Domain\Common\I18n\Messages; use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; @@ -52,7 +51,7 @@ public function generatePasswordResetToken(string $email): string { $administrator = $this->administratorRepository->findOneBy(['email' => $email]); if ($administrator === null) { - $message = $this->translator->trans(Messages::IDENTITY_ADMIN_NOT_FOUND); + $message = $this->translator->trans('Administrator not found'); throw new NotFoundHttpException($message, null, 1500567100); } diff --git a/src/Domain/Identity/Service/SessionManager.php b/src/Domain/Identity/Service/SessionManager.php index 82f52af1..105d3645 100644 --- a/src/Domain/Identity/Service/SessionManager.php +++ b/src/Domain/Identity/Service/SessionManager.php @@ -4,7 +4,6 @@ namespace PhpList\Core\Domain\Identity\Service; -use PhpList\Core\Domain\Common\I18n\Messages; use Symfony\Contracts\Translation\TranslatorInterface; use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Identity\Model\AdministratorToken; @@ -35,16 +34,16 @@ public function createSession(string $loginName, string $password): Administrato { $administrator = $this->administratorRepository->findOneByLoginCredentials($loginName, $password); if ($administrator === null) { - $entry = $this->translator->trans(Messages::AUTH_LOGIN_FAILED, ['login' => $loginName]); + $entry = $this->translator->trans("Failed admin login attempt for '%login%'", ['login' => $loginName]); $this->eventLogManager->log('login', $entry); - $message = $this->translator->trans(Messages::AUTH_NOT_AUTHORIZED); + $message = $this->translator->trans('Not authorized'); throw new UnauthorizedHttpException('', $message, null, 1500567098); } if ($administrator->isDisabled()) { - $entry = $this->translator->trans(Messages::AUTH_LOGIN_DISABLED, ['login' => $loginName]); + $entry = $this->translator->trans("Login attempt for disabled admin '%login%'", ['login' => $loginName]); $this->eventLogManager->log('login', $entry); - $message = $this->translator->trans(Messages::AUTH_NOT_AUTHORIZED); + $message = $this->translator->trans('Not authorized'); throw new UnauthorizedHttpException('', $message, null, 1500567099); } diff --git a/src/Domain/Messaging/Command/ProcessBouncesCommand.php b/src/Domain/Messaging/Command/ProcessBouncesCommand.php index f1e3b403..0d51d4f1 100644 --- a/src/Domain/Messaging/Command/ProcessBouncesCommand.php +++ b/src/Domain/Messaging/Command/ProcessBouncesCommand.php @@ -17,14 +17,11 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Contracts\Translation\TranslatorInterface; #[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 @@ -48,6 +45,7 @@ public function __construct( private readonly AdvancedBounceRulesProcessor $advancedRulesProcessor, private readonly UnidentifiedBounceReprocessor $unidentifiedReprocessor, private readonly ConsecutiveBounceHandler $consecutiveBounceHandler, + private readonly TranslatorInterface $translator, ) { parent::__construct(); } @@ -57,14 +55,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int $inputOutput = new SymfonyStyle($input, $output); if (!function_exists('imap_open')) { - $inputOutput->note(self::IMAP_NOT_AVAILABLE); + $inputOutput->note($this->translator->trans( + 'PHP IMAP extension not available. Falling back to Webklex IMAP.' + )); } $force = (bool)$input->getOption('force'); $lock = $this->lockService->acquirePageLock('bounce_processor', $force); if (($lock ?? 0) === 0) { - $inputOutput->warning($force ? self::FORCE_LOCK_FAILED : self::ALREADY_LOCKED); + $forceLockFailed = $this->translator->trans('Could not apply force lock. Aborting.'); + $lockFailed = $this->translator->trans('Another bounce processing is already running. Aborting.'); + + $inputOutput->warning($force ? $forceLockFailed : $lockFailed); return $force ? Command::FAILURE : Command::SUCCESS; } @@ -88,7 +91,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->consecutiveBounceHandler->handle($inputOutput); $this->logger->info('Bounce processing completed', ['downloadReport' => $downloadReport]); - $inputOutput->success('Bounce processing completed.'); + $inputOutput->success($this->translator->trans('Bounce processing completed.')); return Command::SUCCESS; } catch (Exception $e) { diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php index d2c7cbfa..b9a9068a 100644 --- a/src/Domain/Messaging/Command/ProcessQueueCommand.php +++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php @@ -15,6 +15,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Lock\LockFactory; +use Symfony\Contracts\Translation\TranslatorInterface; use Throwable; #[AsCommand( @@ -28,13 +29,15 @@ class ProcessQueueCommand extends Command private MessageProcessingPreparator $messagePreparator; private CampaignProcessor $campaignProcessor; private ConfigManager $configManager; + private TranslatorInterface $translator; public function __construct( MessageRepository $messageRepository, LockFactory $lockFactory, MessageProcessingPreparator $messagePreparator, CampaignProcessor $campaignProcessor, - ConfigManager $configManager + ConfigManager $configManager, + TranslatorInterface $translator ) { parent::__construct(); $this->messageRepository = $messageRepository; @@ -42,6 +45,7 @@ public function __construct( $this->messagePreparator = $messagePreparator; $this->campaignProcessor = $campaignProcessor; $this->configManager = $configManager; + $this->translator = $translator; } /** @@ -51,13 +55,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $lock = $this->lockFactory->createLock('queue_processor'); if (!$lock->acquire()) { - $output->writeln('Queue is already being processed by another instance.'); + $output->writeln($this->translator->trans('Queue is already being processed by another instance.')); return Command::FAILURE; } if ($this->configManager->inMaintenanceMode()) { - $output->writeln('The system is in maintenance mode, stopping. Try again later.'); + $output->writeln( + $this->translator->trans('The system is in maintenance mode, stopping. Try again later.') + ); return Command::FAILURE; } diff --git a/src/Domain/Messaging/Command/SendTestEmailCommand.php b/src/Domain/Messaging/Command/SendTestEmailCommand.php index bb6ba06b..e9670239 100644 --- a/src/Domain/Messaging/Command/SendTestEmailCommand.php +++ b/src/Domain/Messaging/Command/SendTestEmailCommand.php @@ -12,6 +12,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Mime\Address; +use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Component\Mime\Email; #[AsCommand( @@ -21,11 +22,13 @@ class SendTestEmailCommand extends Command { private EmailService $emailService; + private TranslatorInterface $translator; - public function __construct(EmailService $emailService) + public function __construct(EmailService $emailService, TranslatorInterface $translator) { parent::__construct(); $this->emailService = $emailService; + $this->translator = $translator; } protected function configure(): void @@ -48,13 +51,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $recipient = $input->getArgument('recipient'); if (!$recipient) { - $output->writeln('Recipient email address not provided'); + $output->writeln($this->translator->trans('Recipient email address not provided')); return Command::FAILURE; } if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) { - $output->writeln('Invalid email address: ' . $recipient); + $output->writeln($this->translator->trans('Invalid email address: %email%', ['%email%' => $recipient])); return Command::FAILURE; } @@ -63,9 +66,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int $syncMode = $input->getOption('sync'); if ($syncMode) { - $output->writeln('Sending test email synchronously to ' . $recipient); + $output->writeln($this->translator->trans( + 'Sending test email synchronously to %email%', + ['%email%' => $recipient] + )); } else { - $output->writeln('Queuing test email for ' . $recipient); + $output->writeln($this->translator->trans( + 'Queuing test email for %email%', + ['%email%' => $recipient] + )); } $email = (new Email()) @@ -77,15 +86,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($syncMode) { $this->emailService->sendEmailSync($email); - $output->writeln('Test email sent successfully!'); + $output->writeln($this->translator->trans('Test email sent successfully!')); } else { $this->emailService->sendEmail($email); - $output->writeln('Test email queued successfully! It will be sent asynchronously.'); + $output->writeln($this->translator->trans( + 'Test email queued successfully! It will be sent asynchronously.' + )); } return Command::SUCCESS; } catch (Exception $e) { - $output->writeln('Failed to send test email: ' . $e->getMessage()); + $output->writeln($this->translator->trans( + 'Failed to send test email: %error%', + ['%error%' => $e->getMessage()] + )); return Command::FAILURE; } diff --git a/src/Domain/Messaging/Exception/ImapConnectionException.php b/src/Domain/Messaging/Exception/ImapConnectionException.php new file mode 100644 index 00000000..8e5295e2 --- /dev/null +++ b/src/Domain/Messaging/Exception/ImapConnectionException.php @@ -0,0 +1,16 @@ +emailService = $emailService; + $this->translator = $translator; $this->passwordResetUrl = $passwordResetUrl; } @@ -28,19 +31,30 @@ public function __invoke(PasswordResetMessage $message): void { $confirmationLink = $this->generateLink($message->getToken()); - $subject = 'Password Reset Request'; - $textContent = "Hello,\n\n" - . "A password reset has been requested for your account.\n" - . "Please use the following token to reset your password:\n\n" - . $message->getToken() - . "\n\nIf you did not request this password reset, please ignore this email.\n\nThank you."; - - $htmlContent = '

Password Reset Request!

' - . '

Hello! A password reset has been requested for your account.

' - . '

Please use the following token to reset your password:

' - . '

Reset Password

' - . '

If you did not request this password reset, please ignore this email.

' - . '

Thank you.

'; + $subject = $this->translator->trans('Password Reset Request'); + + $textContent = $this->translator->trans( + "Hello,\n\n" . + "A password reset has been requested for your account.\n" . + "Please use the following token to reset your password:\n\n" . + "%token%\n\n" . + "If you did not request this password reset, please ignore this email.\n\n" . + 'Thank you.', + ['%token%' => $message->getToken()] + ); + + $htmlContent = $this->translator->trans( + '

Password Reset Request!

' . + '

Hello! A password reset has been requested for your account.

' . + '

Please use the following token to reset your password:

' . + '

Reset Password

' . + '

If you did not request this password reset, please ignore this email.

' . + '

Thank you.

', + [ + '%confirmation_link%' => $confirmationLink, + ] + ); + $email = (new Email()) ->to($message->getEmail()) diff --git a/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php b/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php index 8c487849..69ec42cb 100644 --- a/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php @@ -8,6 +8,7 @@ use PhpList\Core\Domain\Messaging\Service\EmailService; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Mime\Email; +use Symfony\Contracts\Translation\TranslatorInterface; /** * Handler for processing asynchronous subscriber confirmation email messages @@ -16,11 +17,13 @@ class SubscriberConfirmationMessageHandler { private EmailService $emailService; + private TranslatorInterface $translator; private string $confirmationUrl; - public function __construct(EmailService $emailService, string $confirmationUrl) + public function __construct(EmailService $emailService, TranslatorInterface $translator, string $confirmationUrl) { $this->emailService = $emailService; + $this->translator = $translator; $this->confirmationUrl = $confirmationUrl; } @@ -31,18 +34,29 @@ public function __invoke(SubscriberConfirmationMessage $message): void { $confirmationLink = $this->generateConfirmationLink($message->getUniqueId()); - $subject = 'Please confirm your subscription'; - $textContent = "Thank you for subscribing!\n\n" - . "Please confirm your subscription by clicking the link below:\n" - . $confirmationLink . "\n\n" - . 'If you did not request this subscription, please ignore this email.'; + $subject = $this->translator->trans('Please confirm your subscription'); + + $textContent = $this->translator->trans( + "Thank you for subscribing!\n\n" . + "Please confirm your subscription by clicking the link below:\n\n" . + "%confirmation_link%\n\n" . + 'If you did not request this subscription, please ignore this email.', + [ + '%confirmation_link%' => $confirmationLink + ] + ); $htmlContent = ''; if ($message->hasHtmlEmail()) { - $htmlContent = '

Thank you for subscribing!

' - . '

Please confirm your subscription by clicking the link below:

' - . '

Confirm Subscription

' - . '

If you did not request this subscription, please ignore this email.

'; + $htmlContent = $this->translator->trans( + '

Thank you for subscribing!

' . + '

Please confirm your subscription by clicking the link below:

' . + '

Confirm Subscription

' . + '

If you did not request this subscription, please ignore this email.

', + [ + '%confirmation_link%' => $confirmationLink, + ] + ); } $email = (new Email()) diff --git a/src/Domain/Messaging/Model/BounceStatus.php b/src/Domain/Messaging/Model/BounceStatus.php new file mode 100644 index 00000000..be77473f --- /dev/null +++ b/src/Domain/Messaging/Model/BounceStatus.php @@ -0,0 +1,19 @@ +value, $userId); + } +} diff --git a/src/Domain/Messaging/Service/Builder/MessageBuilder.php b/src/Domain/Messaging/Service/Builder/MessageBuilder.php index 85e74331..bb7fd852 100644 --- a/src/Domain/Messaging/Service/Builder/MessageBuilder.php +++ b/src/Domain/Messaging/Service/Builder/MessageBuilder.php @@ -4,7 +4,7 @@ namespace PhpList\Core\Domain\Messaging\Service\Builder; -use InvalidArgumentException; +use PhpList\Core\Domain\Messaging\Exception\InvalidContextTypeException; use PhpList\Core\Domain\Messaging\Model\Dto\MessageContext; use PhpList\Core\Domain\Messaging\Model\Dto\MessageDtoInterface; use PhpList\Core\Domain\Messaging\Model\Message; @@ -24,7 +24,7 @@ public function __construct( public function build(MessageDtoInterface $createMessageDto, object $context = null): Message { if (!$context instanceof MessageContext) { - throw new InvalidArgumentException('Invalid context type'); + throw new InvalidContextTypeException(get_debug_type($context)); } $format = $this->messageFormatBuilder->build($createMessageDto->getFormat()); @@ -47,6 +47,14 @@ public function build(MessageDtoInterface $createMessageDto, object $context = n $metadata = new Message\MessageMetadata(Message\MessageStatus::Draft); - return new Message($format, $schedule, $metadata, $content, $options, $context->getOwner(), $template); + return new Message( + format: $format, + schedule: $schedule, + metadata: $metadata, + content: $content, + options: $options, + owner: $context->getOwner(), + template: $template + ); } } diff --git a/src/Domain/Messaging/Service/Builder/MessageContentBuilder.php b/src/Domain/Messaging/Service/Builder/MessageContentBuilder.php index 1e9e442d..806afe00 100644 --- a/src/Domain/Messaging/Service/Builder/MessageContentBuilder.php +++ b/src/Domain/Messaging/Service/Builder/MessageContentBuilder.php @@ -4,7 +4,7 @@ namespace PhpList\Core\Domain\Messaging\Service\Builder; -use InvalidArgumentException; +use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageContentDto; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; @@ -13,14 +13,14 @@ class MessageContentBuilder public function build(object $dto): MessageContent { if (!$dto instanceof MessageContentDto) { - throw new InvalidArgumentException('Invalid request dto type: ' . get_class($dto)); + throw new InvalidDtoTypeException(get_debug_type($dto)); } return new MessageContent( - $dto->subject, - $dto->text, - $dto->textMessage, - $dto->footer + subject: $dto->subject, + text: $dto->text, + textMessage: $dto->textMessage, + footer: $dto->footer ); } } diff --git a/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php b/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php index 7bf9be8b..c6b05fc2 100644 --- a/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php +++ b/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php @@ -4,7 +4,7 @@ namespace PhpList\Core\Domain\Messaging\Service\Builder; -use InvalidArgumentException; +use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageFormatDto; use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat; @@ -13,13 +13,13 @@ class MessageFormatBuilder public function build(object $dto): MessageFormat { if (!$dto instanceof MessageFormatDto) { - throw new InvalidArgumentException('Invalid request dto type: ' . get_class($dto)); + throw new InvalidDtoTypeException(get_debug_type($dto)); } return new MessageFormat( - $dto->htmlFormated, - $dto->sendFormat, - $dto->formatOptions + htmlFormatted: $dto->htmlFormated, + sendFormat: $dto->sendFormat, + formatOptions: $dto->formatOptions ); } } diff --git a/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php b/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php index 0a241f0f..91689d1e 100644 --- a/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php +++ b/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php @@ -4,7 +4,7 @@ namespace PhpList\Core\Domain\Messaging\Service\Builder; -use InvalidArgumentException; +use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageOptionsDto; use PhpList\Core\Domain\Messaging\Model\Message\MessageOptions; @@ -13,15 +13,15 @@ class MessageOptionsBuilder public function build(object $dto): MessageOptions { if (!$dto instanceof MessageOptionsDto) { - throw new InvalidArgumentException('Invalid request dto type: ' . get_class($dto)); + throw new InvalidDtoTypeException(get_debug_type($dto)); } return new MessageOptions( - $dto->fromField ?? '', - $dto->toField ?? '', - $dto->replyTo ?? '', - $dto->userSelection, - null, + fromField: $dto->fromField ?? '', + toField: $dto->toField ?? '', + replyTo: $dto->replyTo ?? '', + userSelection: $dto->userSelection, + rssTemplate: null, ); } } diff --git a/src/Domain/Messaging/Service/Builder/MessageScheduleBuilder.php b/src/Domain/Messaging/Service/Builder/MessageScheduleBuilder.php index df847eaf..dbe86731 100644 --- a/src/Domain/Messaging/Service/Builder/MessageScheduleBuilder.php +++ b/src/Domain/Messaging/Service/Builder/MessageScheduleBuilder.php @@ -5,7 +5,7 @@ namespace PhpList\Core\Domain\Messaging\Service\Builder; use DateTime; -use InvalidArgumentException; +use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageScheduleDto; use PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule; @@ -14,15 +14,15 @@ class MessageScheduleBuilder public function build(object $dto): MessageSchedule { if (!$dto instanceof MessageScheduleDto) { - throw new InvalidArgumentException('Invalid request dto type: ' . get_class($dto)); + throw new InvalidDtoTypeException(get_debug_type($dto)); } return new MessageSchedule( - $dto->repeatInterval, - new DateTime($dto->repeatUntil), - $dto->requeueInterval, - new DateTime($dto->requeueUntil), - new DateTime($dto->embargo) + repeatInterval: $dto->repeatInterval, + repeatUntil: new DateTime($dto->repeatUntil), + requeueInterval: $dto->requeueInterval, + requeueUntil: new DateTime($dto->requeueUntil), + embargo: new DateTime($dto->embargo) ); } } diff --git a/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php b/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php index 0805c156..91c4c041 100644 --- a/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php +++ b/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php @@ -13,6 +13,7 @@ use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Contracts\Translation\TranslatorInterface; class ConsecutiveBounceHandler { @@ -20,6 +21,7 @@ class ConsecutiveBounceHandler private SubscriberRepository $subscriberRepository; private SubscriberHistoryManager $subscriberHistoryManager; private SubscriberBlacklistService $blacklistService; + private TranslatorInterface $translator; private int $unsubscribeThreshold; private int $blacklistThreshold; @@ -28,6 +30,7 @@ public function __construct( SubscriberRepository $subscriberRepository, SubscriberHistoryManager $subscriberHistoryManager, SubscriberBlacklistService $blacklistService, + TranslatorInterface $translator, int $unsubscribeThreshold, int $blacklistThreshold, ) { @@ -35,19 +38,21 @@ public function __construct( $this->subscriberRepository = $subscriberRepository; $this->subscriberHistoryManager = $subscriberHistoryManager; $this->blacklistService = $blacklistService; + $this->translator = $translator; $this->unsubscribeThreshold = $unsubscribeThreshold; $this->blacklistThreshold = $blacklistThreshold; } public function handle(SymfonyStyle $io): void { - $io->section('Identifying consecutive bounces'); + $io->section($this->translator->trans('Identifying consecutive bounces')); $users = $this->subscriberRepository->distinctUsersWithBouncesConfirmedNotBlacklisted(); $total = count($users); if ($total === 0) { - $io->writeln('Nothing to do'); + $io->writeln($this->translator->trans('Nothing to do')); + return; } @@ -57,11 +62,14 @@ public function handle(SymfonyStyle $io): void $processed++; if ($processed % 5 === 0) { - $io->writeln(\sprintf('processed %d out of %d subscribers', $processed, $total)); + $io->writeln($this->translator->trans('Processed %processed% out of %total% subscribers', [ + '%processed%' => $processed, + '%total%' => $total, + ])); } } - $io->writeln(\sprintf('total of %d subscribers processed', $total)); + $io->writeln($this->translator->trans('Total of %total% subscribers processed', ['%total%' => $total])); } private function processUser(Subscriber $user): void @@ -123,15 +131,19 @@ private function applyThresholdActions($user, int $consecutive, bool $alreadyUns $this->subscriberRepository->markUnconfirmed($user->getId()); $this->subscriberHistoryManager->addHistory( subscriber: $user, - message: 'Auto Unconfirmed', - details: sprintf('Subscriber auto unconfirmed for %d consecutive bounces', $consecutive) + message: $this->translator->trans('Auto unconfirmed'), + details: $this->translator->trans('Subscriber auto unconfirmed for %count% consecutive bounces', [ + '%count%' => $consecutive + ]) ); } if ($this->blacklistThreshold > 0 && $consecutive >= $this->blacklistThreshold) { $this->blacklistService->blacklist( subscriber: $user, - reason: sprintf('%d consecutive bounces, threshold reached', $consecutive) + reason: $this->translator->trans('%count% consecutive bounces, threshold reached', [ + '%count%' => $consecutive + ]) ); return true; } diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php index d32cf68b..e3b743cb 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php @@ -7,21 +7,25 @@ use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; +use Symfony\Contracts\Translation\TranslatorInterface; class BlacklistEmailAndDeleteBounceHandler implements BounceActionHandlerInterface { private SubscriberHistoryManager $subscriberHistoryManager; private BounceManager $bounceManager; private SubscriberBlacklistService $blacklistService; + private TranslatorInterface $translator; public function __construct( SubscriberHistoryManager $subscriberHistoryManager, BounceManager $bounceManager, - SubscriberBlacklistService $blacklistService + SubscriberBlacklistService $blacklistService, + TranslatorInterface $translator, ) { $this->subscriberHistoryManager = $subscriberHistoryManager; $this->bounceManager = $bounceManager; $this->blacklistService = $blacklistService; + $this->translator = $translator; } public function supports(string $action): bool @@ -32,14 +36,20 @@ public function supports(string $action): bool public function handle(array $closureData): void { if (!empty($closureData['subscriber'])) { + $reason = $this->translator->trans('Email address auto blacklisted by bounce rule %rule_id%', [ + '%rule_id%' => $closureData['ruleId'] + ]); $this->blacklistService->blacklist( subscriber: $closureData['subscriber'], - reason: 'Email address auto blacklisted by bounce rule '.$closureData['ruleId'] + reason: $reason ); + $details = $this->translator->trans('User auto unsubscribed for bounce rule %rule_id%', [ + '%rule_id%' => $closureData['ruleId'] + ]); $this->subscriberHistoryManager->addHistory( - $closureData['subscriber'], - 'Auto Unsubscribed', - 'User auto unsubscribed for bounce rule '.$closureData['ruleId'] + subscriber: $closureData['subscriber'], + message: $this->translator->trans('Auto Unsubscribed'), + details: $details ); } $this->bounceManager->delete($closureData['bounce']); diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php index 9a92088c..eac3b7a9 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php @@ -6,18 +6,22 @@ use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; +use Symfony\Contracts\Translation\TranslatorInterface; class BlacklistEmailHandler implements BounceActionHandlerInterface { private SubscriberHistoryManager $subscriberHistoryManager; private SubscriberBlacklistService $blacklistService; + private TranslatorInterface $translator; public function __construct( SubscriberHistoryManager $subscriberHistoryManager, SubscriberBlacklistService $blacklistService, + TranslatorInterface $translator, ) { $this->subscriberHistoryManager = $subscriberHistoryManager; $this->blacklistService = $blacklistService; + $this->translator = $translator; } public function supports(string $action): bool @@ -29,13 +33,17 @@ public function handle(array $closureData): void { if (!empty($closureData['subscriber'])) { $this->blacklistService->blacklist( - $closureData['subscriber'], - 'Email address auto blacklisted by bounce rule '.$closureData['ruleId'] + subscriber: $closureData['subscriber'], + reason: $this->translator->trans('Email address auto blacklisted by bounce rule %rule_id%', [ + '%rule_id%' => $closureData['ruleId'] + ]), ); $this->subscriberHistoryManager->addHistory( $closureData['subscriber'], - 'Auto Unsubscribed', - 'email auto unsubscribed for bounce rule '.$closureData['ruleId'] + $this->translator->trans('Auto Unsubscribed'), + $this->translator->trans('email auto unsubscribed for bounce rule %rule_id%', [ + '%rule_id%' => $closureData['ruleId'] + ]) ); } } diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php index b017fe9c..3fda46c2 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php @@ -7,21 +7,25 @@ use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; +use Symfony\Contracts\Translation\TranslatorInterface; class BlacklistUserAndDeleteBounceHandler implements BounceActionHandlerInterface { private SubscriberHistoryManager $subscriberHistoryManager; private BounceManager $bounceManager; private SubscriberBlacklistService $blacklistService; + private TranslatorInterface $translator; public function __construct( SubscriberHistoryManager $subscriberHistoryManager, BounceManager $bounceManager, SubscriberBlacklistService $blacklistService, + TranslatorInterface $translator, ) { $this->subscriberHistoryManager = $subscriberHistoryManager; $this->bounceManager = $bounceManager; $this->blacklistService = $blacklistService; + $this->translator = $translator; } public function supports(string $action): bool @@ -34,12 +38,16 @@ public function handle(array $closureData): void if (!empty($closureData['subscriber']) && !$closureData['blacklisted']) { $this->blacklistService->blacklist( subscriber: $closureData['subscriber'], - reason: 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId'] + reason: $this->translator->trans('Subscriber auto blacklisted by bounce rule %rule_id%', [ + '%rule_id%' => $closureData['ruleId'] + ]) ); $this->subscriberHistoryManager->addHistory( subscriber: $closureData['subscriber'], - message: 'Auto Unsubscribed', - details: 'User auto unsubscribed for bounce rule '.$closureData['ruleId'] + message: $this->translator->trans('Auto Unsubscribed'), + details: $this->translator->trans('User auto unsubscribed for bounce rule %rule_id%', [ + '%rule_id%' => $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 75c8b810..555ad3bf 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php @@ -6,18 +6,22 @@ use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; +use Symfony\Contracts\Translation\TranslatorInterface; class BlacklistUserHandler implements BounceActionHandlerInterface { private SubscriberHistoryManager $subscriberHistoryManager; private SubscriberBlacklistService $blacklistService; + private TranslatorInterface $translator; public function __construct( SubscriberHistoryManager $subscriberHistoryManager, SubscriberBlacklistService $blacklistService, + TranslatorInterface $translator, ) { $this->subscriberHistoryManager = $subscriberHistoryManager; $this->blacklistService = $blacklistService; + $this->translator = $translator; } public function supports(string $action): bool @@ -30,12 +34,16 @@ public function handle(array $closureData): void if (!empty($closureData['subscriber']) && !$closureData['blacklisted']) { $this->blacklistService->blacklist( subscriber: $closureData['subscriber'], - reason: 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId'] + reason: $this->translator->trans('Subscriber auto blacklisted by bounce rule %rule_id%', [ + '%rule_id%' => $closureData['ruleId'] + ]) ); $this->subscriberHistoryManager->addHistory( subscriber: $closureData['subscriber'], - message: 'Auto Unsubscribed', - details: 'User auto unsubscribed for bounce rule '.$closureData['ruleId'] + message: $this->translator->trans('Auto Unsubscribed'), + details: $this->translator->trans('User auto unsubscribed for bounce rule %rule_id%', [ + '%rule_id%' => $closureData['ruleId'] + ]) ); } } diff --git a/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php index a8ecdfb5..4b7471eb 100644 --- a/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php @@ -8,6 +8,7 @@ use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; +use Symfony\Contracts\Translation\TranslatorInterface; class DecreaseCountConfirmUserAndDeleteBounceHandler implements BounceActionHandlerInterface { @@ -15,17 +16,20 @@ class DecreaseCountConfirmUserAndDeleteBounceHandler implements BounceActionHand private SubscriberManager $subscriberManager; private BounceManager $bounceManager; private SubscriberRepository $subscriberRepository; + private TranslatorInterface $translator; public function __construct( SubscriberHistoryManager $subscriberHistoryManager, SubscriberManager $subscriberManager, BounceManager $bounceManager, SubscriberRepository $subscriberRepository, + TranslatorInterface $translator, ) { $this->subscriberHistoryManager = $subscriberHistoryManager; $this->subscriberManager = $subscriberManager; $this->bounceManager = $bounceManager; $this->subscriberRepository = $subscriberRepository; + $this->translator = $translator; } public function supports(string $action): bool @@ -41,8 +45,10 @@ public function handle(array $closureData): void $this->subscriberRepository->markConfirmed($closureData['userId']); $this->subscriberHistoryManager->addHistory( subscriber: $closureData['subscriber'], - message: 'Auto confirmed', - details: 'Subscriber auto confirmed for bounce rule '.$closureData['ruleId'] + message: $this->translator->trans('Auto confirmed'), + details: $this->translator->trans('Subscriber auto confirmed for bounce rule %rule_id%', [ + '%rule_id%' => $closureData['ruleId'] + ]) ); } } diff --git a/src/Domain/Messaging/Service/Handler/RequeueHandler.php b/src/Domain/Messaging/Service/Handler/RequeueHandler.php index 6a1d9d95..ddd0035d 100644 --- a/src/Domain/Messaging/Service/Handler/RequeueHandler.php +++ b/src/Domain/Messaging/Service/Handler/RequeueHandler.php @@ -11,12 +11,14 @@ use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Contracts\Translation\TranslatorInterface; class RequeueHandler { public function __construct( private readonly LoggerInterface $logger, - private readonly EntityManagerInterface $entityManager + private readonly EntityManagerInterface $entityManager, + private readonly TranslatorInterface $translator, ) { } @@ -46,9 +48,9 @@ public function handle(Message $campaign, ?OutputInterface $output = null): bool $campaign->getMetadata()->setStatus(MessageStatus::Submitted); $this->entityManager->flush(); - $output?->writeln(sprintf( - 'Requeued campaign; next embargo at %s', - $next->format(DateTime::ATOM) + $output?->writeln($this->translator->trans( + 'Requeued campaign; next embargo at %time%', + ['%time%' => $next->format(DateTime::ATOM)], )); $this->logger->info('Campaign requeued with new embargo', [ 'campaign_id' => $campaign->getId(), diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php index 7ca39be8..0653900f 100644 --- a/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php @@ -7,21 +7,25 @@ use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; +use Symfony\Contracts\Translation\TranslatorInterface; class UnconfirmUserAndDeleteBounceHandler implements BounceActionHandlerInterface { private SubscriberHistoryManager $subscriberHistoryManager; private SubscriberRepository $subscriberRepository; private BounceManager $bounceManager; + private TranslatorInterface $translator; public function __construct( SubscriberHistoryManager $subscriberHistoryManager, SubscriberRepository $subscriberRepository, BounceManager $bounceManager, + TranslatorInterface $translator, ) { $this->subscriberHistoryManager = $subscriberHistoryManager; $this->subscriberRepository = $subscriberRepository; $this->bounceManager = $bounceManager; + $this->translator = $translator; } public function supports(string $action): bool @@ -35,8 +39,10 @@ public function handle(array $closureData): void $this->subscriberRepository->markUnconfirmed($closureData['userId']); $this->subscriberHistoryManager->addHistory( subscriber: $closureData['subscriber'], - message: 'Auto unconfirmed', - details: 'Subscriber auto unconfirmed for bounce rule '.$closureData['ruleId'] + message: $this->translator->trans('Auto unconfirmed'), + details: $this->translator->trans('Subscriber auto unconfirmed for bounce rule %rule_id%', [ + '%rule_id%' => $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 a5bdd0fe..971863f3 100644 --- a/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php +++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php @@ -6,18 +6,22 @@ use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; +use Symfony\Contracts\Translation\TranslatorInterface; class UnconfirmUserHandler implements BounceActionHandlerInterface { private SubscriberRepository $subscriberRepository; private SubscriberHistoryManager $subscriberHistoryManager; + private TranslatorInterface $translator; public function __construct( SubscriberRepository $subscriberRepository, SubscriberHistoryManager $subscriberHistoryManager, + TranslatorInterface $translator, ) { $this->subscriberRepository = $subscriberRepository; $this->subscriberHistoryManager = $subscriberHistoryManager; + $this->translator = $translator; } public function supports(string $action): bool @@ -30,9 +34,11 @@ public function handle(array $closureData): void if (!empty($closureData['subscriber']) && $closureData['confirmed']) { $this->subscriberRepository->markUnconfirmed($closureData['userId']); $this->subscriberHistoryManager->addHistory( - $closureData['subscriber'], - 'Auto Unconfirmed', - 'Subscriber auto unconfirmed for bounce rule '.$closureData['ruleId'] + subscriber: $closureData['subscriber'], + message: $this->translator->trans('Auto unconfirmed'), + details: $this->translator->trans('Subscriber auto unconfirmed for bounce rule %rule_id%', [ + '%rule_id%' => $closureData['ruleId'] + ]) ); } } diff --git a/src/Domain/Messaging/Service/Manager/BounceManager.php b/src/Domain/Messaging/Service/Manager/BounceManager.php index f13c46ff..4945b881 100644 --- a/src/Domain/Messaging/Service/Manager/BounceManager.php +++ b/src/Domain/Messaging/Service/Manager/BounceManager.php @@ -14,27 +14,28 @@ use PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository; use PhpList\Core\Domain\Subscription\Model\Subscriber; use Psr\Log\LoggerInterface; +use Symfony\Contracts\Translation\TranslatorInterface; 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 TranslatorInterface $translator; public function __construct( BounceRepository $bounceRepository, UserMessageBounceRepository $userMessageBounceRepo, EntityManagerInterface $entityManager, LoggerInterface $logger, + TranslatorInterface $translator, ) { $this->bounceRepository = $bounceRepository; $this->userMessageBounceRepo = $userMessageBounceRepo; $this->entityManager = $entityManager; $this->logger = $logger; + $this->translator = $translator; } public function create( @@ -132,7 +133,9 @@ public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array public function announceDeletionMode(bool $testMode): void { - $message = $testMode ? self::TEST_MODE_MESSAGE : self::LIVE_MODE_MESSAGE; - $this->logger->info($message); + $testModeMessage = $this->translator->trans('Running in test mode, not deleting messages from mailbox'); + $liveModeMessage = $this->translator->trans('Processed messages will be deleted from the mailbox'); + + $this->logger->info($testMode ? $testModeMessage : $liveModeMessage); } } diff --git a/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php b/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php index c5269aaa..b3de16f9 100644 --- a/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php +++ b/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php @@ -6,6 +6,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Contracts\Translation\TranslatorInterface; /** * Limits the total processing time of a long-running operation. @@ -15,8 +16,11 @@ class MaxProcessTimeLimiter private float $startedAt = 0.0; private int $maxSeconds; - public function __construct(private readonly LoggerInterface $logger, ?int $maxSeconds = null) - { + public function __construct( + private readonly LoggerInterface $logger, + private readonly TranslatorInterface $translator, + ?int $maxSeconds = null + ) { $this->maxSeconds = $maxSeconds ?? 600; } @@ -36,7 +40,7 @@ public function shouldStop(?OutputInterface $output = null): bool $elapsed = microtime(true) - $this->startedAt; if ($elapsed >= $this->maxSeconds) { $this->logger->warning(sprintf('Reached max processing time of %d seconds', $this->maxSeconds)); - $output?->writeln('Reached max processing time; stopping cleanly.'); + $output?->writeln($this->translator->trans('Reached max processing time; stopping cleanly.')); return true; } diff --git a/src/Domain/Messaging/Service/MessageProcessingPreparator.php b/src/Domain/Messaging/Service/MessageProcessingPreparator.php index c602f7d4..9faa72fb 100644 --- a/src/Domain/Messaging/Service/MessageProcessingPreparator.php +++ b/src/Domain/Messaging/Service/MessageProcessingPreparator.php @@ -10,6 +10,7 @@ use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Contracts\Translation\TranslatorInterface; class MessageProcessingPreparator { @@ -20,17 +21,20 @@ class MessageProcessingPreparator private SubscriberRepository $subscriberRepository; private MessageRepository $messageRepository; private LinkTrackService $linkTrackService; + private TranslatorInterface $translator; public function __construct( EntityManagerInterface $entityManager, SubscriberRepository $subscriberRepository, MessageRepository $messageRepository, - LinkTrackService $linkTrackService + LinkTrackService $linkTrackService, + TranslatorInterface $translator, ) { $this->entityManager = $entityManager; $this->subscriberRepository = $subscriberRepository; $this->messageRepository = $messageRepository; $this->linkTrackService = $linkTrackService; + $this->translator = $translator; } public function ensureSubscribersHaveUuid(OutputInterface $output): void @@ -39,7 +43,9 @@ public function ensureSubscribersHaveUuid(OutputInterface $output): void $numSubscribers = count($subscribersWithoutUuid); if ($numSubscribers > 0) { - $output->writeln(sprintf('Giving a UUID to %d subscribers, this may take a while', $numSubscribers)); + $output->writeln($this->translator->trans('Giving a UUID to %count% subscribers, this may take a while', [ + '%count%' => $numSubscribers + ])); foreach ($subscribersWithoutUuid as $subscriber) { $subscriber->setUniqueId(bin2hex(random_bytes(16))); } @@ -53,7 +59,9 @@ public function ensureCampaignsHaveUuid(OutputInterface $output): void $numCampaigns = count($campaignsWithoutUuid); if ($numCampaigns > 0) { - $output->writeln(sprintf('Giving a UUID to %d campaigns', $numCampaigns)); + $output->writeln($this->translator->trans('Giving a UUID to %count% campaigns', [ + '%count%' => $numCampaigns + ])); foreach ($campaignsWithoutUuid as $campaign) { $campaign->setUuid(bin2hex(random_bytes(18))); } diff --git a/src/Domain/Messaging/Service/NativeBounceProcessingService.php b/src/Domain/Messaging/Service/NativeBounceProcessingService.php index eee5bb98..0cdc7cb4 100644 --- a/src/Domain/Messaging/Service/NativeBounceProcessingService.php +++ b/src/Domain/Messaging/Service/NativeBounceProcessingService.php @@ -6,10 +6,10 @@ use IMAP\Connection; use PhpList\Core\Domain\Common\Mail\NativeImapMailReader; +use PhpList\Core\Domain\Messaging\Exception\OpenMboxFileException; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor; use Psr\Log\LoggerInterface; -use RuntimeException; use Throwable; class NativeBounceProcessingService implements BounceProcessingServiceInterface @@ -69,9 +69,12 @@ private function openOrFail(string $mailbox, bool $testMode): Connection { try { return $this->mailReader->open($mailbox, $testMode ? 0 : CL_EXPUNGE); - } catch (Throwable $e) { - $this->logger->error('Cannot open mailbox file: '.$e->getMessage()); - throw new RuntimeException('Cannot open mbox file'); + } catch (Throwable $throwable) { + $this->logger->error('Cannot open mailbox file', [ + 'mailbox' => $mailbox, + 'error' => $throwable->getMessage(), + ]); + throw new OpenMboxFileException($throwable); } } diff --git a/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php index 568bf874..0e1c3fe0 100644 --- a/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php +++ b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php @@ -11,6 +11,7 @@ use PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Contracts\Translation\TranslatorInterface; class AdvancedBounceRulesProcessor { @@ -19,16 +20,18 @@ public function __construct( private readonly BounceRuleManager $ruleManager, private readonly BounceActionResolver $actionResolver, private readonly SubscriberManager $subscriberManager, + private readonly TranslatorInterface $translator, ) { } public function process(SymfonyStyle $io, int $batchSize): void { - $io->section('Processing bounces based on active bounce rules'); + $io->section($this->translator->trans('Processing bounces based on active bounce rules')); $rules = $this->ruleManager->loadActiveRules(); if (!$rules) { - $io->writeln('No active rules'); + $io->writeln($this->translator->trans('No active rules')); + return; } @@ -69,15 +72,20 @@ public function process(SymfonyStyle $io, int $batchSize): void $processed++; } - $io->writeln(sprintf( - 'processed %d out of %d bounces for advanced bounce rules', - min($processed, $total), - $total + $io->writeln($this->translator->trans( + 'Processed %processed% out of %total% bounces for advanced bounce rules', + ['%processed%' => min($processed, $total), '%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($this->translator->trans( + '%processed% bounces processed by advanced processing', + ['%processed%' => $matched] + )); + $io->writeln($this->translator->trans( + '%not_processed% bounces were not matched by advanced processing rules', + ['%not_processed%' => $notMatched] + )); } private function composeText(Bounce $bounce): string diff --git a/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php index 6f502a8c..7a33a7e9 100644 --- a/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php +++ b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use PhpList\Core\Domain\Messaging\Model\Bounce; +use PhpList\Core\Domain\Messaging\Model\BounceStatus; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; @@ -44,26 +45,35 @@ public function process(Bounce $bounce, ?string $msgId, ?int $userId, DateTimeIm if ($msgId === 'systemmessage') { return $userId ? $this->handleSystemMessageWithUser( - $bounce, - $bounceDate, - $userId, - $user - ) : $this->handleSystemMessageUnknownUser($bounce); + bounce: $bounce, + date: $bounceDate, + userId: $userId, + userOrNull: $user + ) : $this->handleSystemMessageUnknownUser(bounce: $bounce); } if ($msgId && $userId) { - return $this->handleKnownMessageAndUser($bounce, $bounceDate, (int)$msgId, $userId); + return $this->handleKnownMessageAndUser( + bounce: $bounce, + date: $bounceDate, + msgId: (int)$msgId, + userId: $userId + ); } if ($userId) { - return $this->handleUserOnly($bounce, $userId); + return $this->handleUserOnly(bounce: $bounce, userId: $userId); } if ($msgId) { - return $this->handleMessageOnly($bounce, (int)$msgId); + return $this->handleMessageOnly(bounce: $bounce, msgId: (int)$msgId); } - $this->bounceManager->update($bounce, 'unidentified bounce', 'not processed'); + $this->bounceManager->update( + bounce: $bounce, + status: BounceStatus::UnidentifiedBounce->value, + comment: 'not processed' + ); return false; } @@ -76,10 +86,10 @@ private function handleSystemMessageWithUser( ): bool { $this->bounceManager->update( bounce: $bounce, - status: 'bounced system message', + status: BounceStatus::SystemMessage->value, comment: sprintf('%d marked unconfirmed', $userId) ); - $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId); + $this->bounceManager->linkUserMessageBounce(bounce: $bounce, date: $date, subscriberId: $userId); $this->subscriberRepository->markUnconfirmed($userId); $this->logger->info('system message bounced, user marked unconfirmed', ['userId' => $userId]); @@ -96,7 +106,11 @@ private function handleSystemMessageWithUser( private function handleSystemMessageUnknownUser(Bounce $bounce): bool { - $this->bounceManager->update($bounce, 'bounced system message', 'unknown user'); + $this->bounceManager->update( + bounce:$bounce, + status: BounceStatus::SystemMessage->value, + comment: 'unknown user' + ); $this->logger->info('system message bounced, but unknown user'); return true; @@ -108,20 +122,30 @@ private function handleKnownMessageAndUser( int $msgId, int $userId ): bool { - if (!$this->bounceManager->existsUserMessageBounce($userId, $msgId)) { - $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId, $msgId); + if (!$this->bounceManager->existsUserMessageBounce(subscriberId: $userId, messageId: $msgId)) { + $this->bounceManager->linkUserMessageBounce( + bounce: $bounce, + date: $date, + subscriberId: $userId, + messageId: $msgId + ); $this->bounceManager->update( bounce: $bounce, - status: sprintf('bounced list message %d', $msgId), + status: BounceStatus::BouncedList->format($msgId), comment: sprintf('%d bouncecount increased', $userId) ); $this->messageRepository->incrementBounceCount($msgId); $this->subscriberRepository->incrementBounceCount($userId); } else { - $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId, $msgId); + $this->bounceManager->linkUserMessageBounce( + bounce: $bounce, + date: $date, + subscriberId: $userId, + messageId: $msgId + ); $this->bounceManager->update( bounce: $bounce, - status: sprintf('duplicate bounce for %d', $userId), + status: BounceStatus::DuplicateBounce->format($userId), comment: sprintf('duplicate bounce for subscriber %d on message %d', $userId, $msgId) ); } @@ -133,7 +157,7 @@ private function handleUserOnly(Bounce $bounce, int $userId): bool { $this->bounceManager->update( bounce: $bounce, - status: 'bounced unidentified message', + status: BounceStatus::UnidentifiedMessage->value, comment: sprintf('%d bouncecount increased', $userId) ); $this->subscriberRepository->incrementBounceCount($userId); @@ -145,7 +169,7 @@ private function handleMessageOnly(Bounce $bounce, int $msgId): bool { $this->bounceManager->update( bounce: $bounce, - status: sprintf('bounced list message %d', $msgId), + status: BounceStatus::BouncedList->format($msgId), comment: 'unknown user' ); $this->messageRepository->incrementBounceCount($msgId); diff --git a/src/Domain/Messaging/Service/Processor/CampaignProcessor.php b/src/Domain/Messaging/Service/Processor/CampaignProcessor.php index 92313e28..a5deb074 100644 --- a/src/Domain/Messaging/Service/Processor/CampaignProcessor.php +++ b/src/Domain/Messaging/Service/Processor/CampaignProcessor.php @@ -18,6 +18,7 @@ use PhpList\Core\Domain\Subscription\Model\Subscriber; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Contracts\Translation\TranslatorInterface; use Throwable; /** @@ -33,6 +34,7 @@ class CampaignProcessor private UserMessageRepository $userMessageRepository; private MaxProcessTimeLimiter $timeLimiter; private RequeueHandler $requeueHandler; + private TranslatorInterface $translator; public function __construct( RateLimitedCampaignMailer $mailer, @@ -42,7 +44,8 @@ public function __construct( LoggerInterface $logger, UserMessageRepository $userMessageRepository, MaxProcessTimeLimiter $timeLimiter, - RequeueHandler $requeueHandler + RequeueHandler $requeueHandler, + TranslatorInterface $translator, ) { $this->mailer = $mailer; $this->entityManager = $entityManager; @@ -52,6 +55,7 @@ public function __construct( $this->userMessageRepository = $userMessageRepository; $this->timeLimiter = $timeLimiter; $this->requeueHandler = $requeueHandler; + $this->translator = $translator; } public function process(Message $campaign, ?OutputInterface $output = null): void @@ -82,7 +86,9 @@ public function process(Message $campaign, ?OutputInterface $output = null): voi if (!filter_var($subscriber->getEmail(), FILTER_VALIDATE_EMAIL)) { $this->updateUserMessageStatus($userMessage, UserMessageStatus::InvalidEmailAddress); $this->unconfirmSubscriber($subscriber); - $output?->writeln('Invalid email, marking unconfirmed: ' . $subscriber->getEmail()); + $output?->writeln($this->translator->trans('Invalid email, marking unconfirmed: %email%', [ + '%email%' => $subscriber->getEmail(), + ])); continue; } @@ -98,7 +104,9 @@ public function process(Message $campaign, ?OutputInterface $output = null): voi 'subscriber_id' => $subscriber->getId(), 'campaign_id' => $campaign->getId(), ]); - $output?->writeln('Failed to send to: ' . $subscriber->getEmail()); + $output?->writeln($this->translator->trans('Failed to send to: %email%', [ + '%email%' => $subscriber->getEmail(), + ])); } } diff --git a/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php b/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php index a52b6f2f..d61742d5 100644 --- a/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php +++ b/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php @@ -8,14 +8,17 @@ use RuntimeException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Contracts\Translation\TranslatorInterface; class MboxBounceProcessor implements BounceProtocolProcessor { private BounceProcessingServiceInterface $processingService; + private TranslatorInterface $translator; - public function __construct(BounceProcessingServiceInterface $processingService) + public function __construct(BounceProcessingServiceInterface $processingService, TranslatorInterface $translator) { $this->processingService = $processingService; + $this->translator = $translator; } public function getProtocol(): string @@ -30,12 +33,12 @@ public function process(InputInterface $input, SymfonyStyle $inputOutput): strin $file = (string)$input->getOption('mailbox'); if (!$file) { - $inputOutput->error('mbox file path must be provided with --mailbox.'); + $inputOutput->error($this->translator->trans('mbox file path must be provided with --mailbox.')); throw new RuntimeException('Missing --mailbox for mbox protocol'); } - $inputOutput->section('Opening mbox ' . $file); - $inputOutput->writeln('Please do not interrupt this process'); + $inputOutput->section($this->translator->trans('Opening mbox %file%', ['%file%' => $file])); + $inputOutput->writeln($this->translator->trans('Please do not interrupt this process')); return $this->processingService->processMailbox( mailbox: $file, diff --git a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php index b6f59f65..b0079774 100644 --- a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php +++ b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php @@ -7,6 +7,7 @@ use PhpList\Core\Domain\Messaging\Service\BounceProcessingServiceInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Contracts\Translation\TranslatorInterface; class PopBounceProcessor implements BounceProtocolProcessor { @@ -14,17 +15,20 @@ class PopBounceProcessor implements BounceProtocolProcessor private string $host; private int $port; private string $mailboxNames; + private TranslatorInterface $translator; public function __construct( BounceProcessingServiceInterface $processingService, string $host, int $port, - string $mailboxNames + string $mailboxNames, + TranslatorInterface $translator ) { $this->processingService = $processingService; $this->host = $host; $this->port = $port; $this->mailboxNames = $mailboxNames; + $this->translator = $translator; } public function getProtocol(): string @@ -44,8 +48,8 @@ public function process(InputInterface $input, SymfonyStyle $inputOutput): strin $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'); + $inputOutput->section($this->translator->trans('Connecting to %mailbox%', ['%mailbox%' => $mailbox])); + $inputOutput->writeln($this->translator->trans('Please do not interrupt this process')); $downloadReport .= $this->processingService->processMailbox( mailbox: $mailbox, diff --git a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php index 503fc459..2646ede6 100644 --- a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php +++ b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php @@ -5,33 +5,37 @@ namespace PhpList\Core\Domain\Messaging\Service\Processor; use DateTimeImmutable; +use PhpList\Core\Domain\Messaging\Model\BounceStatus; use PhpList\Core\Domain\Messaging\Service\MessageParser; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Contracts\Translation\TranslatorInterface; class UnidentifiedBounceReprocessor { private BounceManager $bounceManager; private MessageParser $messageParser; private BounceDataProcessor $bounceDataProcessor; - + private TranslatorInterface $translator; public function __construct( BounceManager $bounceManager, MessageParser $messageParser, BounceDataProcessor $bounceDataProcessor, + TranslatorInterface $translator, ) { $this->bounceManager = $bounceManager; $this->messageParser = $messageParser; $this->bounceDataProcessor = $bounceDataProcessor; + $this->translator = $translator; } public function process(SymfonyStyle $inputOutput): void { - $inputOutput->section('Reprocessing unidentified bounces'); - $bounces = $this->bounceManager->findByStatus('unidentified bounce'); + $inputOutput->section($this->translator->trans('Reprocessing unidentified bounces')); + $bounces = $this->bounceManager->findByStatus(BounceStatus::UnidentifiedBounce->value); $total = count($bounces); - $inputOutput->writeln(sprintf('%d bounces to reprocess', $total)); + $inputOutput->writeln($this->translator->trans('%total% bounces to reprocess', ['%total%' => $total])); $count = 0; $reparsed = 0; @@ -39,20 +43,23 @@ public function process(SymfonyStyle $inputOutput): void foreach ($bounces as $bounce) { $count++; if ($count % 25 === 0) { - $inputOutput->writeln(sprintf('%d out of %d processed', $count, $total)); + $inputOutput->writeln($this->translator->trans('%count% out of %total% processed', [ + '%count%' => $count, + '%total%' => $total + ])); } - $decodedBody = $this->messageParser->decodeBody($bounce->getHeader(), $bounce->getData()); + $decodedBody = $this->messageParser->decodeBody(header: $bounce->getHeader(), body: $bounce->getData()); $userId = $this->messageParser->findUserId($decodedBody); $messageId = $this->messageParser->findMessageId($decodedBody); if ($userId || $messageId) { $reparsed++; if ($this->bounceDataProcessor->process( - $bounce, - $messageId, - $userId, - new DateTimeImmutable() + bounce: $bounce, + msgId: $messageId, + userId: $userId, + bounceDate: new DateTimeImmutable() ) ) { $reidentified++; @@ -60,11 +67,13 @@ 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 + $inputOutput->writeln($this->translator->trans('%count% out of %total% processed', [ + '%count%' => $count, + '%total%' => $total + ])); + $inputOutput->writeln($this->translator->trans( + '%reparsed% bounces were re-processed and %reidentified% bounces were re-identified', + ['%reparsed%' => $reparsed, '%reidentified%' => $reidentified] )); } } diff --git a/src/Domain/Messaging/Service/SendRateLimiter.php b/src/Domain/Messaging/Service/SendRateLimiter.php index 378b80d5..2590e721 100644 --- a/src/Domain/Messaging/Service/SendRateLimiter.php +++ b/src/Domain/Messaging/Service/SendRateLimiter.php @@ -9,6 +9,7 @@ use PhpList\Core\Domain\Common\IspRestrictionsProvider; use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Contracts\Translation\TranslatorInterface; /** * Encapsulates batching and throttling logic for sending emails respecting @@ -26,6 +27,7 @@ class SendRateLimiter public function __construct( private readonly IspRestrictionsProvider $ispRestrictionsProvider, private readonly UserMessageRepository $userMessageRepository, + private readonly TranslatorInterface $translator, private readonly ?int $mailqueueBatchSize = null, private readonly ?int $mailqueueBatchPeriod = null, private readonly ?int $mailqueueThrottle = null, @@ -76,9 +78,9 @@ public function awaitTurn(?OutputInterface $output = null): bool $elapsed = microtime(true) - $this->batchStart; $remaining = (int)ceil($this->batchPeriod - $elapsed); if ($remaining > 0) { - $output?->writeln(sprintf( - 'Batch limit reached, sleeping %ds to respect MAILQUEUE_BATCH_PERIOD', - $remaining + $output?->writeln($this->translator->trans( + 'Batch limit reached, sleeping %sleep%s to respect MAILQUEUE_BATCH_PERIOD', + ['%sleep%' => $remaining] )); sleep($remaining); } diff --git a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php index 01a94aff..09a1c14a 100644 --- a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php +++ b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php @@ -6,10 +6,10 @@ use DateTimeImmutable; use DateTimeInterface; +use PhpList\Core\Domain\Messaging\Exception\ImapConnectionException; use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor; use Psr\Log\LoggerInterface; -use RuntimeException; use Throwable; use Webklex\PHPIMAP\Client; use Webklex\PHPIMAP\Folder; @@ -50,7 +50,7 @@ public function __construct( * * $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. + * @throws ImapConnectionException If connection to the IMAP server cannot be established. */ public function processMailbox( string $mailbox, @@ -61,9 +61,12 @@ public function processMailbox( try { $client->connect(); - } catch (Throwable $e) { - $this->logger->error('Cannot connect to mailbox: '.$e->getMessage()); - throw new RuntimeException('Cannot connect to IMAP server'); + } catch (Throwable $throwable) { + $this->logger->error('Cannot connect to mailbox', [ + 'mailbox' => $mailbox, + 'error' => $throwable->getMessage() + ]); + throw new ImapConnectionException($throwable); } try { diff --git a/src/Domain/Messaging/Validator/TemplateImageValidator.php b/src/Domain/Messaging/Validator/TemplateImageValidator.php index 11bcc329..5e50e075 100644 --- a/src/Domain/Messaging/Validator/TemplateImageValidator.php +++ b/src/Domain/Messaging/Validator/TemplateImageValidator.php @@ -9,18 +9,21 @@ use PhpList\Core\Domain\Common\Model\ValidationContext; use PhpList\Core\Domain\Common\Validator\ValidatorInterface; use Symfony\Component\Validator\Exception\ValidatorException; +use Symfony\Contracts\Translation\TranslatorInterface; use Throwable; class TemplateImageValidator implements ValidatorInterface { - public function __construct(private readonly ClientInterface $httpClient) - { + public function __construct( + private readonly ClientInterface $httpClient, + private readonly TranslatorInterface $translator, + ) { } public function validate(mixed $value, ValidationContext $context = null): void { if (!is_array($value)) { - throw new InvalidArgumentException('Value must be an array of image URLs.'); + throw new InvalidArgumentException($this->translator->trans('Value must be an array of image URLs.')); } $checkFull = $context?->get('checkImages', false); @@ -42,7 +45,7 @@ private function validateFullUrls(array $urls): array foreach ($urls as $url) { if (!preg_match('#^https?://#i', $url)) { - $errors[] = sprintf('Image "%s" is not a full URL.', $url); + $errors[] = $this->translator->trans('Image "%url%" is not a full URL.', ['%url%' => $url]); } } @@ -61,10 +64,16 @@ private function validateExistence(array $urls): array try { $response = $this->httpClient->request('HEAD', $url); if ($response->getStatusCode() !== 200) { - $errors[] = sprintf('Image "%s" does not exist (HTTP %s)', $url, $response->getStatusCode()); + $errors[] = $this->translator->trans('Image "%url%" does not exist (HTTP %code%)', [ + '%url%' => $url, + '%code%' => $response->getStatusCode() + ]); } } catch (Throwable $e) { - $errors[] = sprintf('Image "%s" could not be validated: %s', $url, $e->getMessage()); + $errors[] = $this->translator->trans('Image "%url%" could not be validated: %message%', [ + '%url%' => $url, + '%message%' => $e->getMessage() + ]); } } diff --git a/src/Domain/Messaging/Validator/TemplateLinkValidator.php b/src/Domain/Messaging/Validator/TemplateLinkValidator.php index 18c772df..621f35a7 100644 --- a/src/Domain/Messaging/Validator/TemplateLinkValidator.php +++ b/src/Domain/Messaging/Validator/TemplateLinkValidator.php @@ -8,9 +8,14 @@ use PhpList\Core\Domain\Common\Model\ValidationContext; use PhpList\Core\Domain\Common\Validator\ValidatorInterface; use Symfony\Component\Validator\Exception\ValidatorException; +use Symfony\Contracts\Translation\TranslatorInterface; class TemplateLinkValidator implements ValidatorInterface { + public function __construct(private readonly TranslatorInterface $translator) + { + } + private const PLACEHOLDERS = [ '[PREFERENCESURL]', '[UNSUBSCRIBEURL]', @@ -37,10 +42,9 @@ public function validate(mixed $value, ValidationContext $context = null): void } if (!empty($invalid)) { - throw new ValidatorException(sprintf( - 'Not full URLs: %s', - implode(', ', $invalid) - )); + throw new ValidatorException( + $this->translator->trans('Not full URLs: %urls%', ['%urls%' => implode(', ', $invalid)]), + ); } } diff --git a/src/Domain/Subscription/Exception/CouldNotReadUploadedFileException.php b/src/Domain/Subscription/Exception/CouldNotReadUploadedFileException.php new file mode 100644 index 00000000..9a9d9c8b --- /dev/null +++ b/src/Domain/Subscription/Exception/CouldNotReadUploadedFileException.php @@ -0,0 +1,12 @@ +statusCode = $statusCode; diff --git a/src/Domain/Subscription/Service/CsvImporter.php b/src/Domain/Subscription/Service/CsvImporter.php index 3b3729e3..01fb51ea 100644 --- a/src/Domain/Subscription/Service/CsvImporter.php +++ b/src/Domain/Subscription/Service/CsvImporter.php @@ -8,6 +8,7 @@ use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto; use Symfony\Component\Validator\Validator\ValidatorInterface; use League\Csv\Exception as CsvException; +use Symfony\Contracts\Translation\TranslatorInterface; use Throwable; class CsvImporter @@ -15,6 +16,7 @@ class CsvImporter public function __construct( private readonly CsvRowToDtoMapper $rowMapper, private readonly ValidatorInterface $validator, + private readonly TranslatorInterface $translator, ) { } @@ -46,7 +48,9 @@ public function import(string $csvFilePath): array $validDtos[] = $dto; } catch (Throwable $e) { - $errors[$index + 1][] = 'Unexpected error: ' . $e->getMessage(); + $errors[$index + 1][] = $this->translator->trans('Unexpected error: %error%', [ + '%error%' => $e->getMessage() + ]); } } diff --git a/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php b/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php index d8983e65..d91956c6 100644 --- a/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php +++ b/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php @@ -9,25 +9,32 @@ use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator; +use Symfony\Contracts\Translation\TranslatorInterface; class AttributeDefinitionManager { private SubscriberAttributeDefinitionRepository $definitionRepository; private AttributeTypeValidator $attributeTypeValidator; + private TranslatorInterface $translator; public function __construct( SubscriberAttributeDefinitionRepository $definitionRepository, - AttributeTypeValidator $attributeTypeValidator + AttributeTypeValidator $attributeTypeValidator, + TranslatorInterface $translator, ) { $this->definitionRepository = $definitionRepository; $this->attributeTypeValidator = $attributeTypeValidator; + $this->translator = $translator; } public function create(AttributeDefinitionDto $attributeDefinitionDto): SubscriberAttributeDefinition { $existingAttribute = $this->definitionRepository->findOneByName($attributeDefinitionDto->name); if ($existingAttribute) { - throw new AttributeDefinitionCreationException('Attribute definition already exists', 409); + throw new AttributeDefinitionCreationException( + message: $this->translator->trans('Attribute definition already exists'), + statusCode: 409 + ); } $this->attributeTypeValidator->validate($attributeDefinitionDto->type); @@ -50,7 +57,10 @@ public function update( ): SubscriberAttributeDefinition { $existingAttribute = $this->definitionRepository->findOneByName($attributeDefinitionDto->name); if ($existingAttribute && $existingAttribute->getId() !== $attributeDefinition->getId()) { - throw new AttributeDefinitionCreationException('Another attribute with this name already exists.', 409); + throw new AttributeDefinitionCreationException( + message: $this->translator->trans('Another attribute with this name already exists.'), + statusCode: 409 + ); } $this->attributeTypeValidator->validate($attributeDefinitionDto->type); diff --git a/src/Domain/Subscription/Service/Manager/SubscribePageManager.php b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php index 8e429dc4..b0017e6c 100644 --- a/src/Domain/Subscription/Service/Manager/SubscribePageManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php @@ -11,6 +11,7 @@ use PhpList\Core\Domain\Subscription\Repository\SubscriberPageDataRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Contracts\Translation\TranslatorInterface; class SubscribePageManager { @@ -18,6 +19,7 @@ public function __construct( private readonly SubscriberPageRepository $pageRepository, private readonly SubscriberPageDataRepository $pageDataRepository, private readonly EntityManagerInterface $entityManager, + private readonly TranslatorInterface $translator, ) { } @@ -41,7 +43,7 @@ public function getPage(int $id): SubscribePage /** @var SubscribePage|null $page */ $page = $this->pageRepository->find($id); if (!$page) { - throw new NotFoundHttpException('Subscribe page not found'); + throw new NotFoundHttpException($this->translator->trans('Subscribe page not found')); } return $page; diff --git a/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php b/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php index cf83ca75..4446e0bf 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php @@ -10,18 +10,22 @@ use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository; +use Symfony\Contracts\Translation\TranslatorInterface; class SubscriberAttributeManager { private SubscriberAttributeValueRepository $attributeRepository; private EntityManagerInterface $entityManager; + private TranslatorInterface $translator; public function __construct( SubscriberAttributeValueRepository $attributeRepository, EntityManagerInterface $entityManager, + TranslatorInterface $translator, ) { $this->attributeRepository = $attributeRepository; $this->entityManager = $entityManager; + $this->translator = $translator; } public function createOrUpdate( @@ -38,7 +42,7 @@ public function createOrUpdate( $value = $value ?? $definition->getDefaultValue(); if ($value === null) { - throw new SubscriberAttributeCreationException('Value is required', 400); + throw new SubscriberAttributeCreationException($this->translator->trans('Value is required')); } $subscriberAttribute->setValue($value); diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php index 73531fbb..25d7045a 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php @@ -15,6 +15,7 @@ use PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Contracts\Translation\TranslatorInterface; class SubscriberManager { @@ -22,17 +23,20 @@ class SubscriberManager private EntityManagerInterface $entityManager; private MessageBusInterface $messageBus; private SubscriberDeletionService $subscriberDeletionService; + private TranslatorInterface $translator; public function __construct( SubscriberRepository $subscriberRepository, EntityManagerInterface $entityManager, MessageBusInterface $messageBus, SubscriberDeletionService $subscriberDeletionService, + TranslatorInterface $translator ) { $this->subscriberRepository = $subscriberRepository; $this->entityManager = $entityManager; $this->messageBus = $messageBus; $this->subscriberDeletionService = $subscriberDeletionService; + $this->translator = $translator; } public function createSubscriber(CreateSubscriberDto $subscriberDto): Subscriber @@ -91,7 +95,7 @@ public function markAsConfirmedByUniqueId(string $uniqueId): Subscriber { $subscriber = $this->subscriberRepository->findOneByUniqueId($uniqueId); if (!$subscriber) { - throw new NotFoundHttpException('Subscriber not found'); + throw new NotFoundHttpException($this->translator->trans('Subscriber not found')); } $subscriber->setConfirmed(true); diff --git a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php index 764106ec..6bed4d5b 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php @@ -4,7 +4,6 @@ namespace PhpList\Core\Domain\Subscription\Service\Manager; -use PhpList\Core\Domain\Common\I18n\Messages; use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberList; @@ -42,7 +41,7 @@ public function addSubscriberToAList(Subscriber $subscriber, int $listId): Subsc } $subscriberList = $this->subscriberListRepository->find($listId); if (!$subscriberList) { - $message = $this->translator->trans(Messages::SUBSCRIPTION_LIST_NOT_FOUND); + $message = $this->translator->trans('Subscriber list not found.'); throw new SubscriptionCreationException($message, 404); } @@ -70,7 +69,7 @@ private function createSubscription(SubscriberList $subscriberList, string $emai { $subscriber = $this->subscriberRepository->findOneBy(['email' => $email]); if (!$subscriber) { - $message = $this->translator->trans(Messages::SUBSCRIPTION_SUBSCRIBER_NOT_FOUND); + $message = $this->translator->trans('Subscriber does not exists.'); throw new SubscriptionCreationException($message, 404); } @@ -108,7 +107,7 @@ private function deleteSubscription(SubscriberList $subscriberList, string $emai ->findOneBySubscriberEmailAndListId($subscriberList->getId(), $email); if (!$subscription) { - $message = $this->translator->trans(Messages::SUBSCRIPTION_NOT_FOUND_FOR_LIST_AND_SUBSCRIBER); + $message = $this->translator->trans('Subscription not found for this subscriber and list.'); throw new SubscriptionCreationException($message, 404); } diff --git a/src/Domain/Subscription/Service/SubscriberBlacklistService.php b/src/Domain/Subscription/Service/SubscriberBlacklistService.php index d9ca5ea6..3a40f042 100644 --- a/src/Domain/Subscription/Service/SubscriberBlacklistService.php +++ b/src/Domain/Subscription/Service/SubscriberBlacklistService.php @@ -9,6 +9,7 @@ use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Contracts\Translation\TranslatorInterface; class SubscriberBlacklistService { @@ -16,17 +17,20 @@ class SubscriberBlacklistService private SubscriberBlacklistManager $blacklistManager; private SubscriberHistoryManager $historyManager; private RequestStack $requestStack; + private TranslatorInterface $translator; public function __construct( EntityManagerInterface $entityManager, SubscriberBlacklistManager $blacklistManager, SubscriberHistoryManager $historyManager, RequestStack $requestStack, + TranslatorInterface $translator, ) { $this->entityManager = $entityManager; $this->blacklistManager = $blacklistManager; $this->historyManager = $historyManager; $this->requestStack = $requestStack; + $this->translator = $translator; } /** @@ -55,7 +59,7 @@ public function blacklist(Subscriber $subscriber, string $reason): void $this->historyManager->addHistory( subscriber: $subscriber, message: 'Added to blacklist', - details: sprintf('Added to blacklist for reason %s', $reason) + details: $this->translator->trans('Added to blacklist for reason %reason%', ['%reason%' => $reason]) ); if (isset($GLOBALS['plugins']) && is_array($GLOBALS['plugins'])) { diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php index 4c58f22c..c88b935e 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Domain\Subscription\Service; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Subscription\Exception\CouldNotReadUploadedFileException; use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions; use PhpList\Core\Domain\Subscription\Model\Subscriber; @@ -13,8 +14,8 @@ use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager; -use RuntimeException; use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Contracts\Translation\TranslatorInterface; use Throwable; /** @@ -30,6 +31,7 @@ class SubscriberCsvImporter private CsvImporter $csvImporter; private SubscriberAttributeDefinitionRepository $attrDefinitionRepository; private EntityManagerInterface $entityManager; + private TranslatorInterface $translator; public function __construct( SubscriberManager $subscriberManager, @@ -38,7 +40,8 @@ public function __construct( SubscriberRepository $subscriberRepository, CsvImporter $csvImporter, SubscriberAttributeDefinitionRepository $attrDefinitionRepository, - EntityManagerInterface $entityManager + EntityManagerInterface $entityManager, + TranslatorInterface $translator, ) { $this->subscriberManager = $subscriberManager; $this->attributeManager = $attributeManager; @@ -47,6 +50,7 @@ public function __construct( $this->csvImporter = $csvImporter; $this->attrDefinitionRepository = $attrDefinitionRepository; $this->entityManager = $entityManager; + $this->translator = $translator; } /** @@ -55,7 +59,7 @@ public function __construct( * @param UploadedFile $file The uploaded CSV file * @param SubscriberImportOptions $options * @return array Import statistics - * @throws RuntimeException When the uploaded file cannot be read or for any other errors during import + * @throws CouldNotReadUploadedFileException When the uploaded file cannot be read during import */ public function importFromCsv(UploadedFile $file, SubscriberImportOptions $options): array { @@ -69,7 +73,9 @@ public function importFromCsv(UploadedFile $file, SubscriberImportOptions $optio try { $path = $file->getRealPath(); if ($path === false) { - throw new RuntimeException('Could not read the uploaded file.'); + throw new CouldNotReadUploadedFileException( + $this->translator->trans('Could not read the uploaded file.') + ); } $result = $this->csvImporter->import($path); @@ -81,7 +87,10 @@ public function importFromCsv(UploadedFile $file, SubscriberImportOptions $optio $this->entityManager->flush(); } } catch (Throwable $e) { - $stats['errors'][] = 'Error processing ' . $dto->email . ': ' . $e->getMessage(); + $stats['errors'][] = $this->translator->trans( + 'Error processing %email%: %error%', + ['%email%' => $dto->email, '%error%' => $e->getMessage()] + ); $stats['skipped']++; } } @@ -91,7 +100,10 @@ public function importFromCsv(UploadedFile $file, SubscriberImportOptions $optio $stats['skipped']++; } } catch (Throwable $e) { - $stats['errors'][] = 'General import error: ' . $e->getMessage(); + $stats['errors'][] = $this->translator->trans( + 'General import error: %error%', + ['%error%' => $e->getMessage()] + ); } return $stats; diff --git a/src/Domain/Subscription/Validator/AttributeTypeValidator.php b/src/Domain/Subscription/Validator/AttributeTypeValidator.php index 3923cdfc..36bcd45d 100644 --- a/src/Domain/Subscription/Validator/AttributeTypeValidator.php +++ b/src/Domain/Subscription/Validator/AttributeTypeValidator.php @@ -8,9 +8,14 @@ use PhpList\Core\Domain\Common\Model\ValidationContext; use PhpList\Core\Domain\Common\Validator\ValidatorInterface; use Symfony\Component\Validator\Exception\ValidatorException; +use Symfony\Contracts\Translation\TranslatorInterface; class AttributeTypeValidator implements ValidatorInterface { + public function __construct(private readonly TranslatorInterface $translator) + { + } + private const VALID_TYPES = [ 'textline', 'checkbox', @@ -25,15 +30,17 @@ class AttributeTypeValidator implements ValidatorInterface public function validate(mixed $value, ValidationContext $context = null): void { if (!is_string($value)) { - throw new InvalidArgumentException('Value must be a string.'); + throw new InvalidArgumentException($this->translator->trans('Value must be a string.')); } $errors = []; if (!in_array($value, self::VALID_TYPES, true)) { - $errors[] = sprintf( - 'Invalid attribute type: "%s". Valid types are: %s', - $value, - implode(', ', self::VALID_TYPES) + $errors[] = $this->translator->trans( + 'Invalid attribute type: "%type%". Valid types are: %valid_types%', + [ + '%type%' => $value, + '%valid_types%' => implode(', ', self::VALID_TYPES), + ] ); } diff --git a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php index 613e2c1f..109fb634 100644 --- a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php @@ -4,8 +4,8 @@ namespace PhpList\Core\Tests\Unit\Domain\Analytics\Service; -use InvalidArgumentException; use PhpList\Core\Core\ConfigProvider; +use PhpList\Core\Domain\Analytics\Exception\MissingMessageIdException; use PhpList\Core\Domain\Analytics\Model\LinkTrack; use PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository; use PhpList\Core\Domain\Analytics\Service\LinkTrackService; @@ -172,7 +172,7 @@ public function testExtractAndSaveLinksWithMessageWithoutId(): void $message->method('getId')->willReturn(null); $message->method('getContent')->willReturn($messageContent); - $this->expectException(InvalidArgumentException::class); + $this->expectException(MissingMessageIdException::class); $this->expectExceptionMessage('Message must have an ID'); $this->subject->extractAndSaveLinks($message, $userId); diff --git a/tests/Unit/Domain/Common/Repository/CursorPaginationTraitTest.php b/tests/Unit/Domain/Common/Repository/CursorPaginationTraitTest.php index 137da779..02cd5c40 100644 --- a/tests/Unit/Domain/Common/Repository/CursorPaginationTraitTest.php +++ b/tests/Unit/Domain/Common/Repository/CursorPaginationTraitTest.php @@ -4,12 +4,12 @@ namespace PhpList\Core\Tests\Unit\Domain\Common\Repository; +use BadMethodCallException; use Doctrine\ORM\Query; use Doctrine\ORM\QueryBuilder; use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use RuntimeException; final class CursorPaginationTraitTest extends TestCase { @@ -59,8 +59,8 @@ public function testGetFilteredAfterIdWithFilterThrows(): void { $dummyFilter = $this->createMock(FilterRequestInterface::class); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Filter method not implemented'); + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('getFilteredAfterId method not implemented'); $this->repo->getFilteredAfterId(0, 10, $dummyFilter); } diff --git a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php index e42aba74..1a4deef4 100644 --- a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php @@ -12,17 +12,24 @@ use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Contracts\Translation\TranslatorInterface; class AdminAttributeDefinitionManagerTest extends TestCase { private AdminAttributeDefinitionRepository&MockObject $repository; private AdminAttributeDefinitionManager $subject; + private TranslatorInterface&MockObject $translator; protected function setUp(): void { $this->repository = $this->createMock(AdminAttributeDefinitionRepository::class); $attributeTypeValidator = $this->createMock(AttributeTypeValidator::class); - $this->subject = new AdminAttributeDefinitionManager($this->repository, $attributeTypeValidator); + $this->translator = $this->createMock(TranslatorInterface::class); + $this->subject = new AdminAttributeDefinitionManager( + definitionRepository: $this->repository, + attributeTypeValidator: $attributeTypeValidator, + translator: $this->translator, + ); } public function testCreateCreatesNewAttributeDefinition(): void @@ -76,6 +83,11 @@ public function testCreateThrowsExceptionIfAttributeAlreadyExists(): void ->with('test-attribute') ->willReturn($existingAttribute); + $this->translator->expects($this->once()) + ->method('trans') + ->with('Attribute definition already exists.') + ->willReturn('Attribute definition already exists.'); + $this->expectException(AttributeDefinitionCreationException::class); $this->expectExceptionMessage('Attribute definition already exists'); diff --git a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php index 14419b0e..da620f12 100644 --- a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php @@ -4,7 +4,6 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Service; -use PhpList\Core\Domain\Common\I18n\Messages; use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; @@ -36,8 +35,8 @@ public function testCreateSessionWithInvalidCredentialsThrowsExceptionAndLogs(): $translator->expects(self::exactly(2)) ->method('trans') ->withConsecutive( - [Messages::AUTH_LOGIN_FAILED, ['login' => 'admin']], - [Messages::AUTH_NOT_AUTHORIZED, []] + ["Failed admin login attempt for '%login%'", ['login' => 'admin']], + ['Not authorized', []] ) ->willReturnOnConsecutiveCalls( "Failed admin login attempt for 'admin'", diff --git a/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php index 50cce9fa..3e8e24b6 100644 --- a/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php +++ b/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php @@ -15,6 +15,8 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Translation\Translator; +use Symfony\Contracts\Translation\TranslatorInterface; class ProcessBouncesCommandTest extends TestCase { @@ -26,6 +28,7 @@ class ProcessBouncesCommandTest extends TestCase private ConsecutiveBounceHandler&MockObject $consecutiveBounceHandler; private CommandTester $commandTester; + private TranslatorInterface|MockObject $translator; protected function setUp(): void { @@ -35,6 +38,7 @@ protected function setUp(): void $this->advancedRulesProcessor = $this->createMock(AdvancedBounceRulesProcessor::class); $this->unidentifiedReprocessor = $this->createMock(UnidentifiedBounceReprocessor::class); $this->consecutiveBounceHandler = $this->createMock(ConsecutiveBounceHandler::class); + $this->translator = new Translator('en'); $command = new ProcessBouncesCommand( lockService: $this->lockService, @@ -43,6 +47,7 @@ protected function setUp(): void advancedRulesProcessor: $this->advancedRulesProcessor, unidentifiedReprocessor: $this->unidentifiedReprocessor, consecutiveBounceHandler: $this->consecutiveBounceHandler, + translator: $this->translator, ); $this->commandTester = new CommandTester($command); diff --git a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php index d76f63c0..d8e837ba 100644 --- a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php +++ b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php @@ -17,6 +17,7 @@ use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockInterface; +use Symfony\Component\Translation\Translator; class ProcessQueueCommandTest extends TestCase { @@ -25,6 +26,7 @@ class ProcessQueueCommandTest extends TestCase private CampaignProcessor&MockObject $campaignProcessor; private LockInterface&MockObject $lock; private CommandTester $commandTester; + private Translator&MockObject $translator; protected function setUp(): void { @@ -33,17 +35,19 @@ protected function setUp(): void $this->messageProcessingPreparator = $this->createMock(MessageProcessingPreparator::class); $this->campaignProcessor = $this->createMock(CampaignProcessor::class); $this->lock = $this->createMock(LockInterface::class); + $this->translator = $this->createMock(Translator::class); $lockFactory->method('createLock') ->with('queue_processor') ->willReturn($this->lock); $command = new ProcessQueueCommand( - $this->messageRepository, - $lockFactory, - $this->messageProcessingPreparator, - $this->campaignProcessor, - $this->createMock(ConfigManager::class), + messageRepository: $this->messageRepository, + lockFactory: $lockFactory, + messagePreparator: $this->messageProcessingPreparator, + campaignProcessor: $this->campaignProcessor, + configManager: $this->createMock(ConfigManager::class), + translator: $this->translator, ); $application = new Application(); @@ -61,10 +65,15 @@ public function testExecuteWithLockAlreadyAcquired(): void $this->messageProcessingPreparator->expects($this->never()) ->method('ensureSubscribersHaveUuid'); + $this->translator->expects($this->once()) + ->method('trans') + ->with('Queue is already being processed by another instance.') + ->willReturn('Queue is already being processed by another instance.'); + $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Queue is already being processed by another instance', $output); + $this->assertStringContainsString('Queue is already being processed by another instance.', $output); $this->assertEquals(1, $this->commandTester->getStatusCode()); } diff --git a/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php b/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php index c1b4a92c..4e8bae26 100644 --- a/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php +++ b/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Command; +use Exception; use PhpList\Core\Domain\Messaging\Command\SendTestEmailCommand; use PhpList\Core\Domain\Messaging\Service\EmailService; use PHPUnit\Framework\MockObject\MockObject; @@ -11,16 +12,20 @@ use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Mime\Email; +use Symfony\Component\Translation\Translator; +use Symfony\Contracts\Translation\TranslatorInterface; class SendTestEmailCommandTest extends TestCase { private EmailService&MockObject $emailService; private CommandTester $commandTester; + private TranslatorInterface $translator; protected function setUp(): void { $this->emailService = $this->createMock(EmailService::class); - $command = new SendTestEmailCommand($this->emailService); + $this->translator = new Translator('en'); + $command = new SendTestEmailCommand($this->emailService, $this->translator); $application = new Application(); $application->add($command); @@ -165,7 +170,7 @@ public function testExecuteWithEmailServiceException(): void { $this->emailService->expects($this->once()) ->method('sendEmail') - ->willThrowException(new \Exception('Test exception')); + ->willThrowException(new Exception('Test exception')); $this->commandTester->execute([ 'recipient' => 'test@example.com', @@ -182,7 +187,7 @@ public function testExecuteWithEmailServiceExceptionSync(): void { $this->emailService->expects($this->once()) ->method('sendEmailSync') - ->willThrowException(new \Exception('Test sync exception')); + ->willThrowException(new Exception('Test sync exception')); $this->commandTester->execute([ 'recipient' => 'test@example.com', diff --git a/tests/Unit/Domain/Messaging/MessageHandler/PasswordResetMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/PasswordResetMessageHandlerTest.php index ae7aa184..22f83bfc 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/PasswordResetMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/PasswordResetMessageHandlerTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Mime\Email; +use Symfony\Component\Translation\Translator; class PasswordResetMessageHandlerTest extends TestCase { @@ -20,7 +21,11 @@ class PasswordResetMessageHandlerTest extends TestCase protected function setUp(): void { $this->emailService = $this->createMock(EmailService::class); - $this->handler = new PasswordResetMessageHandler($this->emailService, $this->passwordResetUrl); + $this->handler = new PasswordResetMessageHandler( + $this->emailService, + new Translator('en'), + $this->passwordResetUrl + ); } public function testInvoke(): void diff --git a/tests/Unit/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandlerTest.php index 4bd89243..550a6160 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandlerTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Mime\Email; +use Symfony\Component\Translation\Translator; class SubscriberConfirmationMessageHandlerTest extends TestCase { @@ -20,7 +21,11 @@ class SubscriberConfirmationMessageHandlerTest extends TestCase protected function setUp(): void { $this->emailService = $this->createMock(EmailService::class); - $this->handler = new SubscriberConfirmationMessageHandler($this->emailService, $this->confirmationUrl); + $this->handler = new SubscriberConfirmationMessageHandler( + emailService: $this->emailService, + translator: new Translator('en'), + confirmationUrl: $this->confirmationUrl + ); } public function testInvokeWithTextEmail(): void diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php index d99d041a..d08ee9a1 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php @@ -4,8 +4,8 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder; -use InvalidArgumentException; use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Messaging\Exception\InvalidContextTypeException; use PhpList\Core\Domain\Messaging\Model\Dto\CreateMessageDto; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageContentDto; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageFormatDto; @@ -14,6 +14,8 @@ use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageScheduleDto; use PhpList\Core\Domain\Messaging\Model\Dto\MessageContext; use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; +use PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule; use PhpList\Core\Domain\Messaging\Repository\TemplateRepository; use PhpList\Core\Domain\Messaging\Service\Builder\MessageBuilder; use PhpList\Core\Domain\Messaging\Service\Builder\MessageContentBuilder; @@ -40,11 +42,11 @@ protected function setUp(): void $this->optionsBuilder = $this->createMock(MessageOptionsBuilder::class); $this->builder = new MessageBuilder( - $templateRepository, - $this->formatBuilder, - $this->scheduleBuilder, - $this->contentBuilder, - $this->optionsBuilder + templateRepository: $templateRepository, + messageFormatBuilder: $this->formatBuilder, + messageScheduleBuilder: $this->scheduleBuilder, + messageContentBuilder: $this->contentBuilder, + messageOptionsBuilder: $this->optionsBuilder ); } @@ -92,12 +94,12 @@ private function mockBuildCalls(CreateMessageDto $createMessageDto): void $this->scheduleBuilder->expects($this->once()) ->method('build') ->with($createMessageDto->schedule) - ->willReturn($this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule::class)); + ->willReturn($this->createMock(MessageSchedule::class)); $this->contentBuilder->expects($this->once()) ->method('build') ->with($createMessageDto->content) - ->willReturn($this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class)); + ->willReturn($this->createMock(MessageContent::class)); $this->optionsBuilder->expects($this->once()) ->method('build') @@ -113,12 +115,12 @@ public function testBuildsNewMessage(): void $this->mockBuildCalls($request); - $this->builder->build($request, $context); + $this->builder->build(createMessageDto: $request, context: $context); } public function testThrowsExceptionOnInvalidContext(): void { - $this->expectException(InvalidArgumentException::class); + $this->expectException(InvalidContextTypeException::class); $this->builder->build($this->createMock(CreateMessageDto::class), new \stdClass()); } @@ -139,11 +141,11 @@ public function testUpdatesExistingMessage(): void $existingMessage ->expects($this->once()) ->method('setSchedule') - ->with($this->isInstanceOf(\PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule::class)); + ->with($this->isInstanceOf(MessageSchedule::class)); $existingMessage ->expects($this->once()) ->method('setContent') - ->with($this->isInstanceOf(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class)); + ->with($this->isInstanceOf(MessageContent::class)); $existingMessage ->expects($this->once()) ->method('setOptions') diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php index 21f90692..62475884 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php @@ -4,7 +4,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder; -use InvalidArgumentException; +use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageContentDto; use PhpList\Core\Domain\Messaging\Service\Builder\MessageContentBuilder; use PHPUnit\Framework\TestCase; @@ -37,7 +37,7 @@ public function testBuildsMessageContentSuccessfully(): void public function testThrowsExceptionOnInvalidDto(): void { - $this->expectException(InvalidArgumentException::class); + $this->expectException(InvalidDtoTypeException::class); $invalidDto = new \stdClass(); $this->builder->build($invalidDto); diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php index 1bd576f5..17d93eae 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php @@ -4,7 +4,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder; -use InvalidArgumentException; +use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageFormatDto; use PhpList\Core\Domain\Messaging\Service\Builder\MessageFormatBuilder; use PHPUnit\Framework\TestCase; @@ -30,7 +30,7 @@ public function testBuildsMessageFormatSuccessfully(): void public function testThrowsExceptionOnInvalidDto(): void { - $this->expectException(InvalidArgumentException::class); + $this->expectException(InvalidDtoTypeException::class); $invalidDto = new \stdClass(); $this->builder->build($invalidDto); diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php index 754177a2..e2de8398 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php @@ -4,7 +4,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder; -use InvalidArgumentException; +use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageOptionsDto; use PhpList\Core\Domain\Messaging\Service\Builder\MessageOptionsBuilder; use PHPUnit\Framework\TestCase; @@ -37,7 +37,7 @@ public function testBuildsMessageOptionsSuccessfully(): void public function testThrowsExceptionOnInvalidDto(): void { - $this->expectException(InvalidArgumentException::class); + $this->expectException(InvalidDtoTypeException::class); $invalidDto = new \stdClass(); $this->builder->build($invalidDto); diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php index 25a89052..8e9e5fb8 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php @@ -5,7 +5,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder; use DateTime; -use InvalidArgumentException; +use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageScheduleDto; use PhpList\Core\Domain\Messaging\Service\Builder\MessageScheduleBuilder; use PHPUnit\Framework\TestCase; @@ -40,7 +40,7 @@ public function testBuildsMessageScheduleSuccessfully(): void public function testThrowsExceptionOnInvalidDto(): void { - $this->expectException(InvalidArgumentException::class); + $this->expectException(InvalidDtoTypeException::class); $invalidDto = new \stdClass(); $this->builder->build($invalidDto); diff --git a/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php index 1cb1b6d2..5fc375cd 100644 --- a/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Translation\Translator; class ConsecutiveBounceHandlerTest extends TestCase { @@ -43,6 +44,7 @@ protected function setUp(): void subscriberRepository: $this->subscriberRepository, subscriberHistoryManager: $this->subscriberHistoryManager, blacklistService: $this->blacklistService, + translator: new Translator('en'), unsubscribeThreshold: $unsubscribeThreshold, blacklistThreshold: $blacklistThreshold, ); @@ -89,14 +91,14 @@ public function testUnsubscribeAtThresholdAddsHistoryAndMarksUnconfirmedOnce(): ->method('addHistory') ->with( $user, - 'Auto Unconfirmed', + '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->io->expects($this->once())->method('writeln')->with('Total of 1 subscribers processed'); $this->handler->handle($this->io); } @@ -132,7 +134,7 @@ public function testBlacklistAtThresholdStopsProcessingAndAlsoUnsubscribesIfReac ->method('addHistory') ->with( $user, - 'Auto Unconfirmed', + 'Auto unconfirmed', $this->stringContains('consecutive bounces') ); @@ -164,7 +166,7 @@ public function testDuplicateBouncesAreIgnoredInCounting(): void $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(55); $this->subscriberHistoryManager->expects($this->once())->method('addHistory')->with( $user, - 'Auto Unconfirmed', + 'Auto unconfirmed', $this->stringContains('2 consecutive bounces') ); $this->blacklistService->expects($this->never())->method('blacklist'); diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php index 8f5cdb11..cc0ff38d 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php @@ -12,6 +12,7 @@ use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Translator; class BlacklistEmailAndDeleteBounceHandlerTest extends TestCase { @@ -29,6 +30,7 @@ protected function setUp(): void subscriberHistoryManager: $this->historyManager, bounceManager: $this->bounceManager, blacklistService: $this->blacklistService, + translator: new Translator('en') ); } diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php index 54f7362b..cb009022 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php @@ -10,6 +10,7 @@ use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Translator; class BlacklistEmailHandlerTest extends TestCase { @@ -24,6 +25,7 @@ protected function setUp(): void $this->handler = new BlacklistEmailHandler( subscriberHistoryManager: $this->historyManager, blacklistService: $this->blacklistService, + translator: new Translator('en'), ); } diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php index af1df32e..0368d695 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php @@ -12,6 +12,7 @@ use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Translator; class BlacklistUserAndDeleteBounceHandlerTest extends TestCase { @@ -29,6 +30,7 @@ protected function setUp(): void subscriberHistoryManager: $this->historyManager, bounceManager: $this->bounceManager, blacklistService: $this->blacklistService, + translator: new Translator('en') ); } diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php index 72fe4584..e25f54c8 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php @@ -10,6 +10,7 @@ use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Translator; class BlacklistUserHandlerTest extends TestCase { @@ -23,7 +24,8 @@ protected function setUp(): void $this->blacklistService = $this->createMock(SubscriberBlacklistService::class); $this->handler = new BlacklistUserHandler( subscriberHistoryManager: $this->historyManager, - blacklistService: $this->blacklistService + blacklistService: $this->blacklistService, + translator: new Translator('en') ); } diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php index 7d82336f..34d707e5 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php @@ -13,6 +13,7 @@ use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Translator; class DecreaseCountConfirmUserAndDeleteBounceHandlerTest extends TestCase { @@ -33,6 +34,7 @@ protected function setUp(): void subscriberManager: $this->subscriberManager, bounceManager: $this->bounceManager, subscriberRepository: $this->subscriberRepository, + translator: new Translator('en'), ); } diff --git a/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php index 5bfb1114..079d06a8 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php @@ -19,6 +19,7 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Translation\Translator; class RequeueHandlerTest extends TestCase { @@ -55,7 +56,7 @@ private function createMessage( public function testReturnsFalseWhenIntervalIsZeroOrNegative(): void { - $handler = new RequeueHandler($this->logger, $this->em); + $handler = new RequeueHandler($this->logger, $this->em, new Translator('en')); $message = $this->createMessage(0, null, null); $this->em->expects($this->never())->method('flush'); @@ -70,7 +71,7 @@ public function testReturnsFalseWhenIntervalIsZeroOrNegative(): void public function testReturnsFalseWhenNowIsAfterRequeueUntil(): void { - $handler = new RequeueHandler($this->logger, $this->em); + $handler = new RequeueHandler($this->logger, $this->em, new Translator('en')); $past = (new DateTime())->sub(new DateInterval('PT5M')); $message = $this->createMessage(5, $past, null); @@ -85,7 +86,7 @@ public function testReturnsFalseWhenNowIsAfterRequeueUntil(): void public function testRequeuesFromFutureEmbargoAndSetsSubmittedStatus(): void { - $handler = new RequeueHandler($this->logger, $this->em); + $handler = new RequeueHandler($this->logger, $this->em, new Translator('en')); $embargo = (new DateTime())->add(new DateInterval('PT5M')); $interval = 10; $message = $this->createMessage($interval, null, $embargo); @@ -107,7 +108,7 @@ public function testRequeuesFromFutureEmbargoAndSetsSubmittedStatus(): void public function testRequeuesFromNowWhenEmbargoIsNullOrPast(): void { - $handler = new RequeueHandler($this->logger, $this->em); + $handler = new RequeueHandler($this->logger, $this->em, new Translator('en')); $interval = 3; $message = $this->createMessage($interval, null, null); @@ -133,7 +134,7 @@ public function testRequeuesFromNowWhenEmbargoIsNullOrPast(): void public function testReturnsFalseWhenNextEmbargoExceedsUntil(): void { - $handler = new RequeueHandler($this->logger, $this->em); + $handler = new RequeueHandler($this->logger, $this->em, new Translator('en')); $embargo = (new DateTime())->add(new DateInterval('PT1M')); $interval = 10; // next would be +10, which exceeds until diff --git a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php index 7a4ac245..6ddc4e3d 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php @@ -12,6 +12,7 @@ use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Translator; class UnconfirmUserAndDeleteBounceHandlerTest extends TestCase { @@ -29,6 +30,7 @@ protected function setUp(): void subscriberHistoryManager: $this->historyManager, subscriberRepository: $this->subscriberRepository, bounceManager: $this->bounceManager, + translator: new Translator('en') ); } diff --git a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php index a395e110..fbbc265a 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php @@ -10,6 +10,7 @@ use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Translator; class UnconfirmUserHandlerTest extends TestCase { @@ -23,7 +24,8 @@ protected function setUp(): void $this->historyManager = $this->createMock(SubscriberHistoryManager::class); $this->handler = new UnconfirmUserHandler( subscriberRepository: $this->subscriberRepository, - subscriberHistoryManager: $this->historyManager + subscriberHistoryManager: $this->historyManager, + translator: new Translator('en') ); } @@ -41,7 +43,7 @@ public function testHandleMarksUnconfirmedAndAddsHistoryWhenSubscriberPresentAnd $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(123); $this->historyManager->expects($this->once())->method('addHistory')->with( $subscriber, - 'Auto Unconfirmed', + 'Auto unconfirmed', $this->stringContains('bounce rule 9') ); diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php index bd1a4a68..0dbde7ad 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php @@ -15,6 +15,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use Symfony\Component\Translation\Translator; class BounceManagerTest extends TestCase { @@ -35,6 +36,7 @@ protected function setUp(): void userMessageBounceRepo: $this->userMessageBounceRepository, entityManager: $this->entityManager, logger: $this->logger, + translator: new Translator('en') ); } diff --git a/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php b/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php index 5944ca3e..57b2f07f 100644 --- a/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php +++ b/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Translation\Translator; class MaxProcessTimeLimiterTest extends TestCase { @@ -21,7 +22,7 @@ protected function setUp(): void public function testShouldNotStopWhenMaxSecondsIsZero(): void { - $limiter = new MaxProcessTimeLimiter(logger: $this->logger, maxSeconds: 0); + $limiter = new MaxProcessTimeLimiter(logger: $this->logger, translator: new Translator('en'), maxSeconds: 0); $output = $this->createMock(OutputInterface::class); $output->expects($this->never())->method('writeln'); @@ -34,7 +35,7 @@ public function testShouldNotStopWhenMaxSecondsIsZero(): void public function testShouldStopAfterThresholdAndLogAndOutput(): void { - $limiter = new MaxProcessTimeLimiter(logger: $this->logger, maxSeconds: 1); + $limiter = new MaxProcessTimeLimiter(logger: $this->logger, translator: new Translator('en'), maxSeconds: 1); $output = $this->createMock(OutputInterface::class); $output->expects($this->once()) diff --git a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php index c2c0d0a5..85066691 100644 --- a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php +++ b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php @@ -16,6 +16,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Translation\Translator; class MessageProcessingPreparatorTest extends TestCase { @@ -35,10 +36,11 @@ protected function setUp(): void $this->output = $this->createMock(OutputInterface::class); $this->preparator = new MessageProcessingPreparator( - $this->entityManager, - $this->subscriberRepository, - $this->messageRepository, - $this->linkTrackService + entityManager: $this->entityManager, + subscriberRepository: $this->subscriberRepository, + messageRepository: $this->messageRepository, + linkTrackService: $this->linkTrackService, + translator: new Translator('en'), ); } @@ -189,7 +191,10 @@ public function testProcessMessageLinksWithLinksExtracted(): void $savedLinks = [$linkTrack1, $linkTrack2]; $this->linkTrackService->method('isExtractAndSaveLinksApplicable')->willReturn(true); - $this->linkTrackService->method('extractAndSaveLinks')->with($message, $userId)->willReturn($savedLinks); + $this->linkTrackService + ->method('extractAndSaveLinks') + ->with($message, $userId) + ->willReturn($savedLinks); $message->method('getContent')->willReturn($content); diff --git a/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php index 209fb583..a4590052 100644 --- a/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php @@ -16,6 +16,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Translation\Translator; class AdvancedBounceRulesProcessorTest extends TestCase { @@ -36,15 +37,23 @@ protected function setUp(): void public function testNoActiveRules(): void { - $this->io->expects($this->once())->method('section')->with('Processing bounces based on active bounce rules'); + $translator = new Translator('en'); + $this->io + ->expects($this->once()) + ->method('section') + ->with($translator->trans('Processing bounces based on active bounce rules')); $this->ruleManager->method('loadActiveRules')->willReturn([]); - $this->io->expects($this->once())->method('writeln')->with('No active rules'); + $this->io + ->expects($this->once()) + ->method('writeln') + ->with($translator->trans('No active rules')); $processor = new AdvancedBounceRulesProcessor( bounceManager: $this->bounceManager, ruleManager: $this->ruleManager, actionResolver: $this->actionResolver, subscriberManager: $this->subscriberManager, + translator: $translator, ); $processor->process($this->io, 100); @@ -159,10 +168,11 @@ public function testProcessingWithMatchesAndNonMatches(): void return null; }); + $translator = new Translator('en'); $this->io ->expects($this->once()) ->method('section') - ->with('Processing bounces based on active bounce rules'); + ->with($translator->trans('Processing bounces based on active bounce rules')); $this->io->expects($this->exactly(4))->method('writeln'); $processor = new AdvancedBounceRulesProcessor( @@ -170,6 +180,7 @@ public function testProcessingWithMatchesAndNonMatches(): void ruleManager: $this->ruleManager, actionResolver: $this->actionResolver, subscriberManager: $this->subscriberManager, + translator: $translator, ); $processor->process($this->io, 2); diff --git a/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php index 26aec09f..e1976202 100644 --- a/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php @@ -22,6 +22,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Mime\Email; +use Symfony\Component\Translation\Translator; class CampaignProcessorTest extends TestCase { @@ -32,7 +33,6 @@ class CampaignProcessorTest extends TestCase private LoggerInterface|MockObject $logger; private OutputInterface|MockObject $output; private CampaignProcessor $campaignProcessor; - private UserMessageRepository|MockObject $userMessageRepository; protected function setUp(): void { @@ -42,7 +42,7 @@ protected function setUp(): void $this->messagePreparator = $this->createMock(MessageProcessingPreparator::class); $this->logger = $this->createMock(LoggerInterface::class); $this->output = $this->createMock(OutputInterface::class); - $this->userMessageRepository = $this->createMock(UserMessageRepository::class); + $userMessageRepository = $this->createMock(UserMessageRepository::class); $this->campaignProcessor = new CampaignProcessor( mailer: $this->mailer, @@ -50,9 +50,10 @@ protected function setUp(): void subscriberProvider: $this->subscriberProvider, messagePreparator: $this->messagePreparator, logger: $this->logger, - userMessageRepository: $this->userMessageRepository, + userMessageRepository: $userMessageRepository, timeLimiter: $this->createMock(MaxProcessTimeLimiter::class), requeueHandler: $this->createMock(RequeueHandler::class), + translator: new Translator('en'), ); } diff --git a/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php index 210e000c..9bf1c92f 100644 --- a/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php @@ -11,6 +11,7 @@ use RuntimeException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Translation\Translator; class MboxBounceProcessorTest extends TestCase { @@ -27,13 +28,14 @@ protected function setUp(): void public function testGetProtocol(): void { - $processor = new MboxBounceProcessor($this->service); + $processor = new MboxBounceProcessor($this->service, new Translator('en')); $this->assertSame('mbox', $processor->getProtocol()); } public function testProcessThrowsWhenMailboxMissing(): void { - $processor = new MboxBounceProcessor($this->service); + $translator = new Translator('en'); + $processor = new MboxBounceProcessor($this->service, $translator); $this->input->method('getOption')->willReturnMap([ ['test', false], @@ -44,7 +46,7 @@ public function testProcessThrowsWhenMailboxMissing(): void $this->io ->expects($this->once()) ->method('error') - ->with('mbox file path must be provided with --mailbox.'); + ->with($translator->trans('mbox file path must be provided with --mailbox.')); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Missing --mailbox for mbox protocol'); @@ -54,7 +56,8 @@ public function testProcessThrowsWhenMailboxMissing(): void public function testProcessSuccess(): void { - $processor = new MboxBounceProcessor($this->service); + $translator = new Translator('en'); + $processor = new MboxBounceProcessor($this->service, $translator); $this->input->method('getOption')->willReturnMap([ ['test', true], @@ -62,8 +65,14 @@ public function testProcessSuccess(): void ['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->io + ->expects($this->once()) + ->method('section') + ->with($translator->trans('Opening mbox %file%', ['%file%' => '/var/mail/bounce.mbox'])); + $this->io + ->expects($this->once()) + ->method('writeln') + ->with($translator->trans('Please do not interrupt this process')); $this->service->expects($this->once()) ->method('processMailbox') diff --git a/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php index fad4cfbe..d0141386 100644 --- a/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Translation\Translator; class PopBounceProcessorTest extends TestCase { @@ -26,13 +27,14 @@ protected function setUp(): void public function testGetProtocol(): void { - $processor = new PopBounceProcessor($this->service, 'mail.example.com', 995, 'INBOX'); + $processor = new PopBounceProcessor($this->service, 'mail.example.com', 995, 'INBOX', new Translator('en')); $this->assertSame('pop', $processor->getProtocol()); } public function testProcessWithMultipleMailboxesAndDefaults(): void { - $processor = new PopBounceProcessor($this->service, 'pop.example.com', 110, 'INBOX, ,Custom'); + $translator = new Translator('en'); + $processor = new PopBounceProcessor($this->service, 'pop.example.com', 110, 'INBOX, ,Custom', $translator); $this->input->method('getOption')->willReturnMap([ ['test', true], diff --git a/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php index a671e74c..ac1c9173 100644 --- a/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Translation\Translator; class UnidentifiedBounceReprocessorTest extends TestCase { @@ -62,7 +63,8 @@ public function testProcess(): void $processor = new UnidentifiedBounceReprocessor( bounceManager: $this->bounceManager, messageParser: $this->messageParser, - bounceDataProcessor: $this->dataProcessor + bounceDataProcessor: $this->dataProcessor, + translator: new Translator('en'), ); $processor->process($this->io); } diff --git a/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php b/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php index e9ba27c0..e29f6929 100644 --- a/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php +++ b/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Translation\Translator; class SendRateLimiterTest extends TestCase { @@ -27,6 +28,7 @@ public function testInitializesLimitsFromConfigOnly(): void $limiter = new SendRateLimiter( ispRestrictionsProvider: $this->ispProvider, userMessageRepository: $this->createMock(UserMessageRepository::class), + translator: new Translator('en'), mailqueueBatchSize: 5, mailqueueBatchPeriod: 10, mailqueueThrottle: 2 @@ -44,6 +46,7 @@ public function testBatchLimitTriggersWaitMessageAndResetsCounters(): void $limiter = new SendRateLimiter( ispRestrictionsProvider: $this->ispProvider, userMessageRepository: $this->createMock(UserMessageRepository::class), + translator: new Translator('en'), mailqueueBatchSize: 10, mailqueueBatchPeriod: 1, mailqueueThrottle: 0 @@ -71,6 +74,7 @@ public function testThrottleSleepsPerMessagePathIsCallable(): void $limiter = new SendRateLimiter( ispRestrictionsProvider: $this->ispProvider, userMessageRepository: $this->createMock(UserMessageRepository::class), + translator: new Translator('en'), mailqueueBatchSize: 0, mailqueueBatchPeriod: 0, mailqueueThrottle: 1 diff --git a/tests/Unit/Domain/Messaging/Validator/TemplateImageValidatorTest.php b/tests/Unit/Domain/Messaging/Validator/TemplateImageValidatorTest.php index 88af2c8c..40e1064a 100644 --- a/tests/Unit/Domain/Messaging/Validator/TemplateImageValidatorTest.php +++ b/tests/Unit/Domain/Messaging/Validator/TemplateImageValidatorTest.php @@ -12,6 +12,7 @@ use PhpList\Core\Domain\Messaging\Validator\TemplateImageValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Translator; use Symfony\Component\Validator\Exception\ValidatorException; class TemplateImageValidatorTest extends TestCase @@ -22,7 +23,7 @@ class TemplateImageValidatorTest extends TestCase protected function setUp(): void { $this->httpClient = $this->createMock(ClientInterface::class); - $this->validator = new TemplateImageValidator($this->httpClient); + $this->validator = new TemplateImageValidator($this->httpClient, new Translator('en')); } public function testThrowsExceptionIfValueIsNotArray(): void diff --git a/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php b/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php index d0ab6566..5767f193 100644 --- a/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php +++ b/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php @@ -7,6 +7,7 @@ use PhpList\Core\Domain\Common\Model\ValidationContext; use PhpList\Core\Domain\Messaging\Validator\TemplateLinkValidator; use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Translator; use Symfony\Component\Validator\Exception\ValidatorException; class TemplateLinkValidatorTest extends TestCase @@ -15,7 +16,7 @@ class TemplateLinkValidatorTest extends TestCase protected function setUp(): void { - $this->validator = new TemplateLinkValidator(); + $this->validator = new TemplateLinkValidator(new Translator('en')); } public function testSkipsValidationIfNotString(): void diff --git a/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php index 279a6ff7..7e7bcfb7 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php @@ -11,6 +11,7 @@ use PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager; use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator; use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Translator; class AttributeDefinitionManagerTest extends TestCase { @@ -18,7 +19,11 @@ public function testCreateAttributeDefinition(): void { $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); $validator = $this->createMock(AttributeTypeValidator::class); - $manager = new AttributeDefinitionManager($repository, $validator); + $manager = new AttributeDefinitionManager( + definitionRepository: $repository, + attributeTypeValidator: $validator, + translator: new Translator('en') + ); $dto = new AttributeDefinitionDto( name: 'Country', @@ -51,7 +56,11 @@ public function testCreateThrowsWhenAttributeAlreadyExists(): void { $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); $validator = $this->createMock(AttributeTypeValidator::class); - $manager = new AttributeDefinitionManager($repository, $validator); + $manager = new AttributeDefinitionManager( + definitionRepository: $repository, + attributeTypeValidator: $validator, + translator: new Translator('en'), + ); $dto = new AttributeDefinitionDto( name: 'Country', @@ -78,7 +87,11 @@ public function testUpdateAttributeDefinition(): void { $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); $validator = $this->createMock(AttributeTypeValidator::class); - $manager = new AttributeDefinitionManager($repository, $validator); + $manager = new AttributeDefinitionManager( + definitionRepository: $repository, + attributeTypeValidator: $validator, + translator: new Translator('en'), + ); $attribute = new SubscriberAttributeDefinition(); $attribute->setName('Old'); @@ -113,7 +126,11 @@ public function testUpdateThrowsWhenAnotherAttributeWithSameNameExists(): void { $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); $validator = $this->createMock(AttributeTypeValidator::class); - $manager = new AttributeDefinitionManager($repository, $validator); + $manager = new AttributeDefinitionManager( + definitionRepository: $repository, + attributeTypeValidator: $validator, + translator: new Translator('en'), + ); $dto = new AttributeDefinitionDto( name: 'Existing', @@ -144,7 +161,11 @@ public function testDeleteAttributeDefinition(): void { $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); $validator = $this->createMock(AttributeTypeValidator::class); - $manager = new AttributeDefinitionManager($repository, $validator); + $manager = new AttributeDefinitionManager( + definitionRepository: $repository, + attributeTypeValidator: $validator, + translator: new Translator('en'), + ); $attribute = new SubscriberAttributeDefinition(); diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php index 422c78a7..6add5016 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Translation\Translator; class SubscribePageManagerTest extends TestCase { @@ -32,6 +33,7 @@ protected function setUp(): void pageRepository: $this->pageRepository, pageDataRepository: $this->pageDataRepository, entityManager: $this->entityManager, + translator: new Translator('en'), ); } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php index 355de90f..a827ab3f 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php @@ -12,6 +12,7 @@ use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Translator; class SubscriberAttributeManagerTest extends TestCase { @@ -34,7 +35,7 @@ public function testCreateNewSubscriberAttribute(): void return $attr->getValue() === 'US'; })); - $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager); + $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager, new Translator('en')); $attribute = $manager->createOrUpdate($subscriber, $definition, 'US'); self::assertInstanceOf(SubscriberAttributeValue::class, $attribute); @@ -60,7 +61,7 @@ public function testUpdateExistingSubscriberAttribute(): void ->method('persist') ->with($existing); - $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager); + $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager, new Translator('en')); $result = $manager->createOrUpdate($subscriber, $definition, 'Updated'); self::assertSame('Updated', $result->getValue()); @@ -76,7 +77,7 @@ public function testCreateFailsWhenValueAndDefaultAreNull(): void $subscriberAttrRepo->method('findOneBySubscriberAndAttribute')->willReturn(null); - $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager); + $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager, new Translator('en')); $this->expectException(SubscriberAttributeCreationException::class); $this->expectExceptionMessage('Value is required'); @@ -95,7 +96,7 @@ public function testGetSubscriberAttribute(): void ->with(5, 10) ->willReturn($expected); - $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager); + $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager, new Translator('en')); $result = $manager->getSubscriberAttribute(5, 10); self::assertSame($expected, $result); @@ -111,7 +112,7 @@ public function testDeleteSubscriberAttribute(): void ->method('remove') ->with($attribute); - $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager); + $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager, new Translator('en')); $manager->delete($attribute); self::assertTrue(true); diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php index b7a99366..f96f32e2 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php @@ -15,6 +15,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Translation\Translator; class SubscriberManagerTest extends TestCase { @@ -35,6 +36,7 @@ protected function setUp(): void entityManager: $this->entityManager, messageBus: $this->messageBus, subscriberDeletionService: $subscriberDeletionService, + translator: new Translator('en'), ); } diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php index 0bacd756..f825f704 100644 --- a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php +++ b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php @@ -19,36 +19,36 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\Translation\Translator; class SubscriberCsvImporterTest extends TestCase { private SubscriberManager&MockObject $subscriberManagerMock; private SubscriberAttributeManager&MockObject $attributeManagerMock; - private SubscriptionManager&MockObject $subscriptionManagerMock; private SubscriberRepository&MockObject $subscriberRepositoryMock; private CsvImporter&MockObject $csvImporterMock; private SubscriberAttributeDefinitionRepository&MockObject $attributeDefinitionRepositoryMock; - private EntityManagerInterface $entityManager; private SubscriberCsvImporter $subject; protected function setUp(): void { $this->subscriberManagerMock = $this->createMock(SubscriberManager::class); $this->attributeManagerMock = $this->createMock(SubscriberAttributeManager::class); - $this->subscriptionManagerMock = $this->createMock(SubscriptionManager::class); + $subscriptionManagerMock = $this->createMock(SubscriptionManager::class); $this->subscriberRepositoryMock = $this->createMock(SubscriberRepository::class); $this->csvImporterMock = $this->createMock(CsvImporter::class); $this->attributeDefinitionRepositoryMock = $this->createMock(SubscriberAttributeDefinitionRepository::class); - $this->entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager = $this->createMock(EntityManagerInterface::class); $this->subject = new SubscriberCsvImporter( subscriberManager: $this->subscriberManagerMock, attributeManager: $this->attributeManagerMock, - subscriptionManager: $this->subscriptionManagerMock, + subscriptionManager: $subscriptionManagerMock, subscriberRepository: $this->subscriberRepositoryMock, csvImporter: $this->csvImporterMock, attrDefinitionRepository: $this->attributeDefinitionRepositoryMock, - entityManager: $this->entityManager, + entityManager: $entityManager, + translator: new Translator('en'), ); } diff --git a/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php b/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php index c0ab3a5a..cf691324 100644 --- a/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php +++ b/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php @@ -7,6 +7,7 @@ use InvalidArgumentException; use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator; use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Translator; use Symfony\Component\Validator\Exception\ValidatorException; class AttributeTypeValidatorTest extends TestCase @@ -15,7 +16,7 @@ class AttributeTypeValidatorTest extends TestCase protected function setUp(): void { - $this->validator = new AttributeTypeValidator(); + $this->validator = new AttributeTypeValidator(new Translator('en')); } public function testValidatesValidType(): void From 6e65b28d63f6b908ddb80e8c90735f229084aa1f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 23 Sep 2025 11:50:14 +0400 Subject: [PATCH 09/10] Fix autowiring --- config/services/services.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/services/services.yml b/config/services/services.yml index 1afd1fc5..89bf99b9 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -114,6 +114,8 @@ services: - { name: 'phplist.bounce_action_handler' } PhpList\Core\Domain\Messaging\Service\Handler\: + autowire: true + autoconfigure: true resource: '../../src/Domain/Messaging/Service/Handler/*Handler.php' PhpList\Core\Domain\Messaging\Service\BounceActionResolver: From da72f1fb0f221c3bb53d61744aaa1fcc03054042 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 24 Sep 2025 12:11:54 +0400 Subject: [PATCH 10/10] Reset subscriber bounce count --- .../Messaging/Service/Handler/BlacklistEmailHandler.php | 6 +++--- .../Subscription/Service/Manager/SubscriberManager.php | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php index eac3b7a9..4f95c18b 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php @@ -39,9 +39,9 @@ public function handle(array $closureData): void ]), ); $this->subscriberHistoryManager->addHistory( - $closureData['subscriber'], - $this->translator->trans('Auto Unsubscribed'), - $this->translator->trans('email auto unsubscribed for bounce rule %rule_id%', [ + subscriber: $closureData['subscriber'], + message: $this->translator->trans('Auto Unsubscribed'), + details: $this->translator->trans('email auto unsubscribed for bounce rule %rule_id%', [ '%rule_id%' => $closureData['ruleId'] ]) ); diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php index 25d7045a..59cd2505 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php @@ -91,6 +91,14 @@ public function updateSubscriber(UpdateSubscriberDto $subscriberDto): Subscriber return $subscriber; } + public function resetBounceCount(Subscriber $subscriber): Subscriber + { + $subscriber->setBounceCount(0); + $this->entityManager->flush(); + + return $subscriber; + } + public function markAsConfirmedByUniqueId(string $uniqueId): Subscriber { $subscriber = $this->subscriberRepository->findOneByUniqueId($uniqueId);