From ab6c5fd88ea1884ea719c6f80598f71cbdb429c4 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Fri, 8 Aug 2025 22:23:15 +0400 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 92ae4923e3ee8d575adde79c570b7a03fce45d0b Mon Sep 17 00:00:00 2001 From: Tatevik Grigoryan Date: Tue, 16 Sep 2025 06:24:13 +0000 Subject: [PATCH 7/9] Added translation using Weblate (Russian) --- resources/translations/messages.ru.xlf | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 resources/translations/messages.ru.xlf diff --git a/resources/translations/messages.ru.xlf b/resources/translations/messages.ru.xlf new file mode 100644 index 00000000..05115569 --- /dev/null +++ b/resources/translations/messages.ru.xlf @@ -0,0 +1,31 @@ + + + + + + + Not authorized + + + Failed admin login attempt for '%login%' + + + Login attempt for disabled admin '%login%' + + + + Administrator not found + + + + Subscriber list not found. + + + Subscriber does not exists. + + + Subscription not found for this subscriber and list. + + + + From b29d2525b759dfda429cda73f4da20e080dbfe7e Mon Sep 17 00:00:00 2001 From: Tatevik Grigoryan Date: Tue, 16 Sep 2025 07:36:46 +0000 Subject: [PATCH 8/9] Added translation using Weblate (Armenian) --- resources/translations/messages.hy.xlf | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 resources/translations/messages.hy.xlf diff --git a/resources/translations/messages.hy.xlf b/resources/translations/messages.hy.xlf new file mode 100644 index 00000000..a7e43314 --- /dev/null +++ b/resources/translations/messages.hy.xlf @@ -0,0 +1,31 @@ + + + + + + + Not authorized + + + Failed admin login attempt for '%login%' + + + Login attempt for disabled admin '%login%' + + + + Administrator not found + + + + Subscriber list not found. + + + Subscriber does not exists. + + + Subscription not found for this subscriber and list. + + + + From e8052e0c25e4cde6a8eeee8111c11dfbd537c65c Mon Sep 17 00:00:00 2001 From: Tatevik Grigoryan Date: Tue, 16 Sep 2025 07:28:44 +0000 Subject: [PATCH 9/9] Translated using Weblate (Russian) Currently translated at 71.4% (5 of 7 strings) Translation: core/messages Translate-URL: http://translate.phplist.org/projects/core/messages/ru/ --- resources/translations/messages.ru.xlf | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/resources/translations/messages.ru.xlf b/resources/translations/messages.ru.xlf index 05115569..a4287561 100644 --- a/resources/translations/messages.ru.xlf +++ b/resources/translations/messages.ru.xlf @@ -6,22 +6,27 @@ Not authorized - + Failed admin login attempt for '%login%' + Неудачная попытка авторизоваться как администратор '%login% - + Login attempt for disabled admin '%login%' + Администратор «%login%» отключён, попытка входа не удалась - + Administrator not found + Администратор не найден - + Subscriber list not found. + Список подписчиков не найден. - + Subscriber does not exists. + Подписчик не существует. Subscription not found for this subscriber and list.