From ab6c5fd88ea1884ea719c6f80598f71cbdb429c4 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Fri, 8 Aug 2025 22:23:15 +0400 Subject: [PATCH 01/20] Blacklist (#352) * Blacklisted * user repository methods * fix configs * add test * fix: phpmd * fix: repo configs * return a created resource --------- Co-authored-by: Tatevik --- config/services/managers.yml | 4 + config/services/repositories.yml | 10 + .../UserBlacklistDataRepository.php | 11 - .../Repository/UserBlacklistRepository.php | 11 - .../Model/UserBlacklist.php | 12 +- .../Model/UserBlacklistData.php | 4 +- .../Repository/SubscriberRepository.php | 14 ++ .../UserBlacklistDataRepository.php | 16 ++ .../Repository/UserBlacklistRepository.php | 33 +++ .../Manager/SubscriberBlacklistManager.php | 86 ++++++++ .../SubscriberBlacklistManagerTest.php | 202 ++++++++++++++++++ 11 files changed, 377 insertions(+), 26 deletions(-) delete mode 100644 src/Domain/Identity/Repository/UserBlacklistDataRepository.php delete mode 100644 src/Domain/Identity/Repository/UserBlacklistRepository.php rename src/Domain/{Identity => Subscription}/Model/UserBlacklist.php (72%) rename src/Domain/{Identity => Subscription}/Model/UserBlacklistData.php (90%) create mode 100644 src/Domain/Subscription/Repository/UserBlacklistDataRepository.php create mode 100644 src/Domain/Subscription/Repository/UserBlacklistRepository.php create mode 100644 src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php create mode 100644 tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php diff --git a/config/services/managers.yml b/config/services/managers.yml index 4f57fc11..0e1b1d8a 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -67,3 +67,7 @@ services: PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager: autowire: true autoconfigure: true + + PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager: + autowire: true + autoconfigure: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml index eca3a31c..db3831dd 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -110,3 +110,13 @@ services: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Messaging\Model\ListMessage + + PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\UserBlacklist + + PhpList\Core\Domain\Subscription\Repository\UserBlacklistDataRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\UserBlacklistData diff --git a/src/Domain/Identity/Repository/UserBlacklistDataRepository.php b/src/Domain/Identity/Repository/UserBlacklistDataRepository.php deleted file mode 100644 index 0f06722b..00000000 --- a/src/Domain/Identity/Repository/UserBlacklistDataRepository.php +++ /dev/null @@ -1,11 +0,0 @@ -email; @@ -42,4 +45,9 @@ public function setAdded(?DateTime $added): self $this->added = $added; return $this; } + + public function getBlacklistData(): ?UserBlacklistData + { + return $this->blacklistData; + } } diff --git a/src/Domain/Identity/Model/UserBlacklistData.php b/src/Domain/Subscription/Model/UserBlacklistData.php similarity index 90% rename from src/Domain/Identity/Model/UserBlacklistData.php rename to src/Domain/Subscription/Model/UserBlacklistData.php index 09697616..f8d78c59 100644 --- a/src/Domain/Identity/Model/UserBlacklistData.php +++ b/src/Domain/Subscription/Model/UserBlacklistData.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Identity\Model; +namespace PhpList\Core\Domain\Subscription\Model; use Doctrine\ORM\Mapping as ORM; use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; -use PhpList\Core\Domain\Identity\Repository\UserBlacklistDataRepository; +use PhpList\Core\Domain\Subscription\Repository\UserBlacklistDataRepository; #[ORM\Entity(repositoryClass: UserBlacklistDataRepository::class)] #[ORM\Table(name: 'phplist_user_blacklist_data')] diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index 762096a0..6ebaee70 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -127,4 +127,18 @@ public function findSubscriberWithSubscriptions(int $id): ?Subscriber ->getQuery() ->getOneOrNullResult(); } + + public function isEmailBlacklisted(string $email): bool + { + $queryBuilder = $this->getEntityManager()->createQueryBuilder(); + + $queryBuilder->select('u.email') + ->from(Subscriber::class, 'u') + ->where('u.email = :email') + ->andWhere('u.blacklisted = 1') + ->setParameter('email', $email) + ->setMaxResults(1); + + return !($queryBuilder->getQuery()->getOneOrNullResult() === null); + } } diff --git a/src/Domain/Subscription/Repository/UserBlacklistDataRepository.php b/src/Domain/Subscription/Repository/UserBlacklistDataRepository.php new file mode 100644 index 00000000..a64525b9 --- /dev/null +++ b/src/Domain/Subscription/Repository/UserBlacklistDataRepository.php @@ -0,0 +1,16 @@ +findOneBy(['email' => $email]); + } +} diff --git a/src/Domain/Subscription/Repository/UserBlacklistRepository.php b/src/Domain/Subscription/Repository/UserBlacklistRepository.php new file mode 100644 index 00000000..665deb64 --- /dev/null +++ b/src/Domain/Subscription/Repository/UserBlacklistRepository.php @@ -0,0 +1,33 @@ +getEntityManager()->createQueryBuilder(); + + $queryBuilder->select('ub.email, ub.added, ubd.data AS reason') + ->from(UserBlacklist::class, 'ub') + ->innerJoin(UserBlacklistData::class, 'ubd', 'WITH', 'ub.email = ubd.email') + ->where('ub.email = :email') + ->setParameter('email', $email) + ->setMaxResults(1); + + return $queryBuilder->getQuery()->getOneOrNullResult(); + } + + public function findOneByEmail(string $email): ?UserBlacklist + { + return $this->findOneBy([ + 'email' => $email, + ]); + } +} diff --git a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php new file mode 100644 index 00000000..d30bae2d --- /dev/null +++ b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php @@ -0,0 +1,86 @@ +subscriberRepository->isEmailBlacklisted($email); + } + + public function getBlacklistInfo(string $email): ?UserBlacklist + { + return $this->userBlacklistRepository->findBlacklistInfoByEmail($email); + } + + public function addEmailToBlacklist(string $email, ?string $reasonData = null): UserBlacklist + { + $existing = $this->subscriberRepository->isEmailBlacklisted($email); + if ($existing) { + return $this->getBlacklistInfo($email); + } + + $blacklistEntry = new UserBlacklist(); + $blacklistEntry->setEmail($email); + $blacklistEntry->setAdded(new DateTime()); + + $this->entityManager->persist($blacklistEntry); + + if ($reasonData !== null) { + $blacklistData = new UserBlacklistData(); + $blacklistData->setEmail($email); + $blacklistData->setName('reason'); + $blacklistData->setData($reasonData); + $this->entityManager->persist($blacklistData); + } + + $this->entityManager->flush(); + + return $blacklistEntry; + } + + public function removeEmailFromBlacklist(string $email): void + { + $blacklistEntry = $this->userBlacklistRepository->findOneByEmail($email); + if ($blacklistEntry) { + $this->entityManager->remove($blacklistEntry); + } + + $blacklistData = $this->blacklistDataRepository->findOneByEmail($email); + if ($blacklistData) { + $this->entityManager->remove($blacklistData); + } + + $subscriber = $this->subscriberRepository->findOneByEmail($email); + if ($subscriber) { + $subscriber->setBlacklisted(false); + } + + $this->entityManager->flush(); + } + + public function getBlacklistReason(string $email): ?string + { + $data = $this->blacklistDataRepository->findOneByEmail($email); + return $data ? $data->getData() : null; + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php new file mode 100644 index 00000000..25fdf5ca --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php @@ -0,0 +1,202 @@ +subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->userBlacklistRepository = $this->createMock(UserBlacklistRepository::class); + $this->userBlacklistDataRepository = $this->createMock(UserBlacklistDataRepository::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + + $this->manager = new SubscriberBlacklistManager( + subscriberRepository: $this->subscriberRepository, + userBlacklistRepository: $this->userBlacklistRepository, + blacklistDataRepository: $this->userBlacklistDataRepository, + entityManager: $this->entityManager, + ); + } + + public function testIsEmailBlacklistedReturnsValueFromRepository(): void + { + $this->subscriberRepository + ->expects($this->once()) + ->method('isEmailBlacklisted') + ->with('test@example.com') + ->willReturn(true); + + $result = $this->manager->isEmailBlacklisted('test@example.com'); + + $this->assertTrue($result); + } + + public function testGetBlacklistInfoReturnsResultFromRepository(): void + { + $userBlacklist = $this->createMock(UserBlacklist::class); + + $this->userBlacklistRepository + ->expects($this->once()) + ->method('findBlacklistInfoByEmail') + ->with('foo@bar.com') + ->willReturn($userBlacklist); + + $result = $this->manager->getBlacklistInfo('foo@bar.com'); + + $this->assertSame($userBlacklist, $result); + } + + public function testAddEmailToBlacklistDoesNotAddIfAlreadyBlacklisted(): void + { + $this->subscriberRepository + ->expects($this->once()) + ->method('isEmailBlacklisted') + ->with('already@blacklisted.com') + ->willReturn(true); + + $this->userBlacklistRepository + ->expects($this->once()) + ->method('findBlacklistInfoByEmail') + ->willReturn($this->createMock(UserBlacklist::class)); + + $this->entityManager + ->expects($this->never()) + ->method('persist'); + + $this->entityManager + ->expects($this->never()) + ->method('flush'); + + $this->manager->addEmailToBlacklist('already@blacklisted.com', 'reason'); + } + + public function testAddEmailToBlacklistAddsEntryAndReason(): void + { + $this->subscriberRepository + ->expects($this->once()) + ->method('isEmailBlacklisted') + ->with('new@blacklist.com') + ->willReturn(false); + + $this->entityManager + ->expects($this->exactly(2)) + ->method('persist') + ->withConsecutive( + [$this->isInstanceOf(UserBlacklist::class)], + [$this->isInstanceOf(UserBlacklistData::class)] + ); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $this->manager->addEmailToBlacklist('new@blacklist.com', 'test reason'); + } + + public function testAddEmailToBlacklistAddsEntryWithoutReason(): void + { + $this->subscriberRepository + ->expects($this->once()) + ->method('isEmailBlacklisted') + ->with('noreason@blacklist.com') + ->willReturn(false); + + $this->entityManager + ->expects($this->once()) + ->method('persist') + ->with($this->isInstanceOf(UserBlacklist::class)); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $this->manager->addEmailToBlacklist('noreason@blacklist.com'); + } + + public function testRemoveEmailFromBlacklistRemovesAllRelatedData(): void + { + $blacklist = $this->createMock(UserBlacklist::class); + $blacklistData = $this->createMock(UserBlacklistData::class); + $subscriber = $this->getMockBuilder(Subscriber::class) + ->onlyMethods(['setBlacklisted']) + ->getMock(); + + $this->userBlacklistRepository + ->expects($this->once()) + ->method('findOneByEmail') + ->with('remove@me.com') + ->willReturn($blacklist); + + $this->userBlacklistDataRepository + ->expects($this->once()) + ->method('findOneByEmail') + ->with('remove@me.com') + ->willReturn($blacklistData); + + $this->subscriberRepository + ->expects($this->once()) + ->method('findOneByEmail') + ->with('remove@me.com') + ->willReturn($subscriber); + + $this->entityManager + ->expects($this->exactly(2)) + ->method('remove') + ->withConsecutive([$blacklist], [$blacklistData]); + + $subscriber->expects($this->once())->method('setBlacklisted')->with(false); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $this->manager->removeEmailFromBlacklist('remove@me.com'); + } + + public function testGetBlacklistReasonReturnsReasonOrNull(): void + { + $blacklistData = $this->createMock(UserBlacklistData::class); + $blacklistData->expects($this->once())->method('getData')->willReturn('my reason'); + + $this->userBlacklistDataRepository + ->expects($this->once()) + ->method('findOneByEmail') + ->with('why@blacklist.com') + ->willReturn($blacklistData); + + $result = $this->manager->getBlacklistReason('why@blacklist.com'); + $this->assertSame('my reason', $result); + } + + public function testGetBlacklistReasonReturnsNullIfNoData(): void + { + $this->userBlacklistDataRepository + ->expects($this->once()) + ->method('findOneByEmail') + ->with('none@blacklist.com') + ->willReturn(null); + + $result = $this->manager->getBlacklistReason('none@blacklist.com'); + $this->assertNull($result); + } +} From f80edc8b42d233469ab31f33404b30a56eba646f Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Mon, 18 Aug 2025 09:10:01 +0400 Subject: [PATCH 02/20] Subscribepage (#353) * subscriber page manager * owner entity * test * ci fix * getByPage data --------- Co-authored-by: Tatevik --- config/services/managers.yml | 4 + config/services/repositories.yml | 10 + .../Subscription/Model/SubscribePage.php | 10 +- .../SubscriberPageDataRepository.php | 13 + .../Repository/SubscriberPageRepository.php | 16 ++ .../Service/Manager/SubscribePageManager.php | 105 ++++++++ .../Manager/SubscribePageManagerTest.php | 234 ++++++++++++++++++ 7 files changed, 388 insertions(+), 4 deletions(-) create mode 100644 src/Domain/Subscription/Service/Manager/SubscribePageManager.php create mode 100644 tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php diff --git a/config/services/managers.yml b/config/services/managers.yml index 0e1b1d8a..d253fc95 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -71,3 +71,7 @@ services: PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager: autowire: true autoconfigure: true + + PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager: + autowire: true + autoconfigure: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml index db3831dd..02c9e7d3 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -120,3 +120,13 @@ services: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Subscription\Model\UserBlacklistData + + PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscribePage + + PhpList\Core\Domain\Subscription\Repository\SubscriberPageDataRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscribePageData diff --git a/src/Domain/Subscription/Model/SubscribePage.php b/src/Domain/Subscription/Model/SubscribePage.php index 7ec518b2..e4696380 100644 --- a/src/Domain/Subscription/Model/SubscribePage.php +++ b/src/Domain/Subscription/Model/SubscribePage.php @@ -7,6 +7,7 @@ use Doctrine\ORM\Mapping as ORM; use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository; #[ORM\Entity(repositoryClass: SubscriberPageRepository::class)] @@ -24,8 +25,9 @@ class SubscribePage implements DomainModel, Identity #[ORM\Column(name: 'active', type: 'boolean', options: ['default' => 0])] private bool $active = false; - #[ORM\Column(name: 'owner', type: 'integer', nullable: true)] - private ?int $owner = null; + #[ORM\ManyToOne(targetEntity: Administrator::class)] + #[ORM\JoinColumn(name: 'owner', referencedColumnName: 'id', nullable: true)] + private ?Administrator $owner = null; public function getId(): ?int { @@ -42,7 +44,7 @@ public function isActive(): bool return $this->active; } - public function getOwner(): ?int + public function getOwner(): ?Administrator { return $this->owner; } @@ -59,7 +61,7 @@ public function setActive(bool $active): self return $this; } - public function setOwner(?int $owner): self + public function setOwner(?Administrator $owner): self { $this->owner = $owner; return $this; diff --git a/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php b/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php index 565930d4..68d0d6bc 100644 --- a/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php @@ -7,8 +7,21 @@ use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; +use PhpList\Core\Domain\Subscription\Model\SubscribePage; +use PhpList\Core\Domain\Subscription\Model\SubscribePageData; class SubscriberPageDataRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + public function findByPageAndName(SubscribePage $page, string $name): ?SubscribePageData + { + return $this->findOneBy(['id' => $page->getId(), 'name' => $name]); + } + + /** @return SubscribePageData[] */ + public function getByPage(SubscribePage $page): array + { + return $this->findBy(['id' => $page->getId()]); + } } diff --git a/src/Domain/Subscription/Repository/SubscriberPageRepository.php b/src/Domain/Subscription/Repository/SubscriberPageRepository.php index 2a8383c0..136b589c 100644 --- a/src/Domain/Subscription/Repository/SubscriberPageRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberPageRepository.php @@ -7,8 +7,24 @@ use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; +use PhpList\Core\Domain\Subscription\Model\SubscribePage; +use PhpList\Core\Domain\Subscription\Model\SubscribePageData; class SubscriberPageRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + /** @return array{page: SubscribePage, data: SubscribePageData}[] */ + public function findPagesWithData(int $pageId): array + { + return $this->createQueryBuilder('p') + ->select('p AS page, d AS data') + ->from(SubscribePage::class, 'p') + ->from(SubscribePageData::class, 'd') + ->where('p.id = :id') + ->andWhere('d.id = p.id') + ->setParameter('id', $pageId) + ->getQuery() + ->getResult(); + } } diff --git a/src/Domain/Subscription/Service/Manager/SubscribePageManager.php b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php new file mode 100644 index 00000000..8e429dc4 --- /dev/null +++ b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php @@ -0,0 +1,105 @@ +setTitle($title) + ->setActive($active) + ->setOwner($owner); + + $this->pageRepository->save($page); + + return $page; + } + + public function getPage(int $id): SubscribePage + { + /** @var SubscribePage|null $page */ + $page = $this->pageRepository->find($id); + if (!$page) { + throw new NotFoundHttpException('Subscribe page not found'); + } + + return $page; + } + + public function updatePage( + SubscribePage $page, + ?string $title = null, + ?bool $active = null, + ?Administrator $owner = null + ): SubscribePage { + if ($title !== null) { + $page->setTitle($title); + } + if ($active !== null) { + $page->setActive($active); + } + if ($owner !== null) { + $page->setOwner($owner); + } + + $this->entityManager->flush(); + + return $page; + } + + public function setActive(SubscribePage $page, bool $active): void + { + $page->setActive($active); + $this->entityManager->flush(); + } + + public function deletePage(SubscribePage $page): void + { + $this->pageRepository->remove($page); + } + + /** @return SubscribePageData[] */ + public function getPageData(SubscribePage $page): array + { + return $this->pageDataRepository->getByPage($page,); + } + + public function setPageData(SubscribePage $page, string $name, ?string $value): SubscribePageData + { + /** @var SubscribePageData|null $data */ + $data = $this->pageDataRepository->findByPageAndName($page, $name); + + if (!$data) { + $data = (new SubscribePageData()) + ->setId((int)$page->getId()) + ->setName($name); + $this->entityManager->persist($data); + } + + $data->setData($value); + $this->entityManager->flush(); + + return $data; + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php new file mode 100644 index 00000000..422c78a7 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php @@ -0,0 +1,234 @@ +pageRepository = $this->createMock(SubscriberPageRepository::class); + $this->pageDataRepository = $this->createMock(SubscriberPageDataRepository::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + + $this->manager = new SubscribePageManager( + pageRepository: $this->pageRepository, + pageDataRepository: $this->pageDataRepository, + entityManager: $this->entityManager, + ); + } + + public function testCreatePageCreatesAndSaves(): void + { + $owner = new Administrator(); + $this->pageRepository + ->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(SubscribePage::class)); + + $page = $this->manager->createPage('My Page', true, $owner); + + $this->assertInstanceOf(SubscribePage::class, $page); + $this->assertSame('My Page', $page->getTitle()); + $this->assertTrue($page->isActive()); + $this->assertSame($owner, $page->getOwner()); + } + + public function testGetPageReturnsPage(): void + { + $page = new SubscribePage(); + $this->pageRepository + ->expects($this->once()) + ->method('find') + ->with(123) + ->willReturn($page); + + $result = $this->manager->getPage(123); + + $this->assertSame($page, $result); + } + + public function testGetPageThrowsWhenNotFound(): void + { + $this->pageRepository + ->expects($this->once()) + ->method('find') + ->with(999) + ->willReturn(null); + + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage('Subscribe page not found'); + + $this->manager->getPage(999); + } + + public function testUpdatePageUpdatesProvidedFieldsAndFlushes(): void + { + $originalOwner = new Administrator(); + $newOwner = new Administrator(); + $page = (new SubscribePage()) + ->setTitle('Old Title') + ->setActive(false) + ->setOwner($originalOwner); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $updated = $this->manager->updatePage($page, title: 'New Title', active: true, owner: $newOwner); + + $this->assertSame($page, $updated); + $this->assertSame('New Title', $updated->getTitle()); + $this->assertTrue($updated->isActive()); + $this->assertSame($newOwner, $updated->getOwner()); + } + + public function testUpdatePageLeavesNullFieldsUntouched(): void + { + $owner = new Administrator(); + $page = (new SubscribePage()) + ->setTitle('Keep Title') + ->setActive(true) + ->setOwner($owner); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $updated = $this->manager->updatePage(page: $page, title: null, active: null, owner: null); + + $this->assertSame('Keep Title', $updated->getTitle()); + $this->assertTrue($updated->isActive()); + $this->assertSame($owner, $updated->getOwner()); + } + + public function testSetActiveSetsFlagAndFlushes(): void + { + $page = (new SubscribePage()) + ->setTitle('Any') + ->setActive(false); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $this->manager->setActive($page, true); + $this->assertTrue($page->isActive()); + } + + public function testDeletePageCallsRepositoryRemove(): void + { + $page = new SubscribePage(); + + $this->pageRepository + ->expects($this->once()) + ->method('remove') + ->with($page); + + $this->manager->deletePage($page); + } + + public function testGetPageDataReturnsStringWhenFound(): void + { + $page = new SubscribePage(); + $data = $this->createMock(SubscribePageData::class); + $data->expects($this->once())->method('getData')->willReturn('value'); + + $this->pageDataRepository + ->expects($this->once()) + ->method('getByPage') + ->with($page) + ->willReturn([$data]); + + $result = $this->manager->getPageData($page); + $this->assertIsArray($result); + $this->assertSame('value', $result[0]->getData()); + } + + public function testGetPageDataReturnsNullWhenNotFound(): void + { + $page = new SubscribePage(); + + $this->pageDataRepository + ->expects($this->once()) + ->method('getByPage') + ->with($page) + ->willReturn([]); + + $result = $this->manager->getPageData($page); + $this->assertEmpty($result); + } + + public function testSetPageDataUpdatesExistingDataAndFlushes(): void + { + $page = new SubscribePage(); + $existing = new SubscribePageData(); + $existing->setId(5)->setName('color')->setData('red'); + + $this->pageDataRepository + ->expects($this->once()) + ->method('findByPageAndName') + ->with($page, 'color') + ->willReturn($existing); + + $this->entityManager + ->expects($this->never()) + ->method('persist'); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $result = $this->manager->setPageData($page, 'color', 'blue'); + + $this->assertSame($existing, $result); + $this->assertSame('blue', $result->getData()); + } + + public function testSetPageDataCreatesNewWhenMissingAndPersistsAndFlushes(): void + { + $page = $this->getMockBuilder(SubscribePage::class) + ->onlyMethods(['getId']) + ->getMock(); + $page->method('getId')->willReturn(123); + + $this->pageDataRepository + ->expects($this->once()) + ->method('findByPageAndName') + ->with($page, 'greeting') + ->willReturn(null); + + $this->entityManager + ->expects($this->once()) + ->method('persist') + ->with($this->isInstanceOf(SubscribePageData::class)); + + $this->entityManager + ->expects($this->once()) + ->method('flush'); + + $result = $this->manager->setPageData($page, 'greeting', 'hello'); + + $this->assertInstanceOf(SubscribePageData::class, $result); + $this->assertSame(123, $result->getId()); + $this->assertSame('greeting', $result->getName()); + $this->assertSame('hello', $result->getData()); + } +} From 9d0c1dba32ecc04ce07b885ea5817d04583913b2 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Tue, 19 Aug 2025 10:03:12 +0400 Subject: [PATCH 03/20] Bounceregex manager (#354) * BounceRegexManager * Fix manager directory * Prop name update admin -> adminId --------- Co-authored-by: Tatevik --- config/services/managers.yml | 40 ++--- config/services/repositories.yml | 5 + src/Domain/Messaging/Model/BounceRegex.php | 16 +- .../Repository/BounceRegexRepository.php | 6 + .../Service/Manager/BounceRegexManager.php | 99 ++++++++++++ .../Service/{ => Manager}/MessageManager.php | 2 +- .../{ => Manager}/TemplateImageManager.php | 2 +- .../Service/{ => Manager}/TemplateManager.php | 2 +- .../Manager/BounceRegexManagerTest.php | 144 ++++++++++++++++++ .../{ => Manager}/ListMessageManagerTest.php | 2 +- .../{ => Manager}/MessageManagerTest.php | 4 +- .../TemplateImageManagerTest.php | 4 +- .../{ => Manager}/TemplateManagerTest.php | 6 +- 13 files changed, 295 insertions(+), 37 deletions(-) create mode 100644 src/Domain/Messaging/Service/Manager/BounceRegexManager.php rename src/Domain/Messaging/Service/{ => Manager}/MessageManager.php (96%) rename src/Domain/Messaging/Service/{ => Manager}/TemplateImageManager.php (98%) rename src/Domain/Messaging/Service/{ => Manager}/TemplateManager.php (98%) create mode 100644 tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php rename tests/Unit/Domain/Messaging/Service/{ => Manager}/ListMessageManagerTest.php (98%) rename tests/Unit/Domain/Messaging/Service/{ => Manager}/MessageManagerTest.php (97%) rename tests/Unit/Domain/Messaging/Service/{ => Manager}/TemplateImageManagerTest.php (95%) rename tests/Unit/Domain/Messaging/Service/{ => Manager}/TemplateManagerTest.php (93%) diff --git a/config/services/managers.yml b/config/services/managers.yml index d253fc95..0f6bb119 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -4,74 +4,78 @@ services: autoconfigure: true public: false - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager: + PhpList\Core\Domain\Identity\Service\SessionManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Identity\Service\SessionManager: + PhpList\Core\Domain\Identity\Service\AdministratorManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager: + PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager: + PhpList\Core\Domain\Identity\Service\AdminAttributeManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\MessageManager: + PhpList\Core\Domain\Identity\Service\PasswordManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\TemplateManager: + PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\TemplateImageManager: + PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Identity\Service\AdministratorManager: + PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager: + PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Identity\Service\AdminAttributeManager: + PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager: + PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager: + PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager: + PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: + PhpList\Core\Domain\Messaging\Service\Manager\MessageManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Identity\Service\PasswordManager: + PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager: + PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager: + PhpList\Core\Domain\Messaging\Service\Manager\BounceRegexManager: autowire: true autoconfigure: true - PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager: + PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: autowire: true autoconfigure: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml index 02c9e7d3..69bdb6ce 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -130,3 +130,8 @@ services: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Subscription\Model\SubscribePageData + + PhpList\Core\Domain\Messaging\Repository\BounceRegexRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\BounceRegex diff --git a/src/Domain/Messaging/Model/BounceRegex.php b/src/Domain/Messaging/Model/BounceRegex.php index 510aaad8..0401d26b 100644 --- a/src/Domain/Messaging/Model/BounceRegex.php +++ b/src/Domain/Messaging/Model/BounceRegex.php @@ -31,8 +31,8 @@ class BounceRegex implements DomainModel, Identity #[ORM\Column(name: 'listorder', type: 'integer', nullable: true, options: ['default' => 0])] private ?int $listOrder = 0; - #[ORM\Column(type: 'integer', nullable: true)] - private ?int $admin; + #[ORM\Column(name: 'admin', type: 'integer', nullable: true)] + private ?int $adminId; #[ORM\Column(type: 'text', nullable: true)] private ?string $comment; @@ -48,7 +48,7 @@ public function __construct( ?string $regexHash = null, ?string $action = null, ?int $listOrder = 0, - ?int $admin = null, + ?int $adminId = null, ?string $comment = null, ?string $status = null, ?int $count = 0 @@ -57,7 +57,7 @@ public function __construct( $this->regexHash = $regexHash; $this->action = $action; $this->listOrder = $listOrder; - $this->admin = $admin; + $this->adminId = $adminId; $this->comment = $comment; $this->status = $status; $this->count = $count; @@ -112,14 +112,14 @@ public function setListOrder(?int $listOrder): self return $this; } - public function getAdmin(): ?int + public function getAdminId(): ?int { - return $this->admin; + return $this->adminId; } - public function setAdmin(?int $admin): self + public function setAdminId(?int $adminId): self { - $this->admin = $admin; + $this->adminId = $adminId; return $this; } diff --git a/src/Domain/Messaging/Repository/BounceRegexRepository.php b/src/Domain/Messaging/Repository/BounceRegexRepository.php index a08f65c0..f5088376 100644 --- a/src/Domain/Messaging/Repository/BounceRegexRepository.php +++ b/src/Domain/Messaging/Repository/BounceRegexRepository.php @@ -7,8 +7,14 @@ use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; +use PhpList\Core\Domain\Messaging\Model\BounceRegex; class BounceRegexRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + public function findOneByRegexHash(string $regexHash): ?BounceRegex + { + return $this->findOneBy(['regexHash' => $regexHash]); + } } diff --git a/src/Domain/Messaging/Service/Manager/BounceRegexManager.php b/src/Domain/Messaging/Service/Manager/BounceRegexManager.php new file mode 100644 index 00000000..c9d60580 --- /dev/null +++ b/src/Domain/Messaging/Service/Manager/BounceRegexManager.php @@ -0,0 +1,99 @@ +bounceRegexRepository = $bounceRegexRepository; + $this->entityManager = $entityManager; + } + + /** + * Creates or updates (if exists) a BounceRegex from a raw regex pattern. + */ + public function createOrUpdateFromPattern( + string $regex, + ?string $action = null, + ?int $listOrder = 0, + ?int $adminId = null, + ?string $comment = null, + ?string $status = null + ): BounceRegex { + $regexHash = md5($regex); + + $existing = $this->bounceRegexRepository->findOneByRegexHash($regexHash); + + if ($existing !== null) { + $existing->setRegex($regex) + ->setAction($action ?? $existing->getAction()) + ->setListOrder($listOrder ?? $existing->getListOrder()) + ->setAdminId($adminId ?? $existing->getAdminId()) + ->setComment($comment ?? $existing->getComment()) + ->setStatus($status ?? $existing->getStatus()); + + $this->bounceRegexRepository->save($existing); + + return $existing; + } + + $bounceRegex = new BounceRegex( + regex: $regex, + regexHash: $regexHash, + action: $action, + listOrder: $listOrder, + adminId: $adminId, + comment: $comment, + status: $status, + count: 0 + ); + + $this->bounceRegexRepository->save($bounceRegex); + + return $bounceRegex; + } + + /** @return BounceRegex[] */ + public function getAll(): array + { + return $this->bounceRegexRepository->findAll(); + } + + public function getByHash(string $regexHash): ?BounceRegex + { + return $this->bounceRegexRepository->findOneByRegexHash($regexHash); + } + + public function delete(BounceRegex $bounceRegex): void + { + $this->bounceRegexRepository->remove($bounceRegex); + } + + /** + * Associates a bounce with the regex it matched and increments usage count. + */ + public function associateBounce(BounceRegex $regex, Bounce $bounce): BounceRegexBounce + { + $relation = new BounceRegexBounce($regex->getId() ?? 0, $bounce->getId() ?? 0); + $this->entityManager->persist($relation); + + $regex->setCount(($regex->getCount() ?? 0) + 1); + $this->entityManager->flush(); + + return $relation; + } +} diff --git a/src/Domain/Messaging/Service/MessageManager.php b/src/Domain/Messaging/Service/Manager/MessageManager.php similarity index 96% rename from src/Domain/Messaging/Service/MessageManager.php rename to src/Domain/Messaging/Service/Manager/MessageManager.php index 9af4df0b..7b263083 100644 --- a/src/Domain/Messaging/Service/MessageManager.php +++ b/src/Domain/Messaging/Service/Manager/MessageManager.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service; +namespace PhpList\Core\Domain\Messaging\Service\Manager; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Dto\MessageContext; diff --git a/src/Domain/Messaging/Service/TemplateImageManager.php b/src/Domain/Messaging/Service/Manager/TemplateImageManager.php similarity index 98% rename from src/Domain/Messaging/Service/TemplateImageManager.php rename to src/Domain/Messaging/Service/Manager/TemplateImageManager.php index c5ebd3f4..30705715 100644 --- a/src/Domain/Messaging/Service/TemplateImageManager.php +++ b/src/Domain/Messaging/Service/Manager/TemplateImageManager.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service; +namespace PhpList\Core\Domain\Messaging\Service\Manager; use Doctrine\ORM\EntityManagerInterface; use DOMDocument; diff --git a/src/Domain/Messaging/Service/TemplateManager.php b/src/Domain/Messaging/Service/Manager/TemplateManager.php similarity index 98% rename from src/Domain/Messaging/Service/TemplateManager.php rename to src/Domain/Messaging/Service/Manager/TemplateManager.php index 35678484..7de31843 100644 --- a/src/Domain/Messaging/Service/TemplateManager.php +++ b/src/Domain/Messaging/Service/Manager/TemplateManager.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service; +namespace PhpList\Core\Domain\Messaging\Service\Manager; use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Common\Model\ValidationContext; diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php new file mode 100644 index 00000000..1cd432bc --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php @@ -0,0 +1,144 @@ +regexRepository = $this->createMock(BounceRegexRepository::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + + $this->manager = new BounceRegexManager( + bounceRegexRepository: $this->regexRepository, + entityManager: $this->entityManager + ); + } + + public function testCreateNewRegex(): void + { + $pattern = 'user unknown'; + $expectedHash = md5($pattern); + + $this->regexRepository->expects($this->once()) + ->method('findOneByRegexHash') + ->with($expectedHash) + ->willReturn(null); + + $this->regexRepository->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(BounceRegex::class)); + + $regex = $this->manager->createOrUpdateFromPattern( + regex: $pattern, + action: 'delete', + listOrder: 5, + adminId: 1, + comment: 'test', + status: 'active' + ); + + $this->assertInstanceOf(BounceRegex::class, $regex); + $this->assertSame($pattern, $regex->getRegex()); + $this->assertSame($expectedHash, $regex->getRegexHash()); + $this->assertSame('delete', $regex->getAction()); + $this->assertSame(5, $regex->getListOrder()); + $this->assertSame(1, $regex->getAdminId()); + $this->assertSame('test', $regex->getComment()); + $this->assertSame('active', $regex->getStatus()); + } + + public function testUpdateExistingRegex(): void + { + $pattern = 'mailbox full'; + $hash = md5($pattern); + + $existing = new BounceRegex( + regex: $pattern, + regexHash: $hash, + action: 'keep', + listOrder: 0, + adminId: null, + comment: null, + status: 'inactive', + count: 3 + ); + + $this->regexRepository->expects($this->once()) + ->method('findOneByRegexHash') + ->with($hash) + ->willReturn($existing); + + $this->regexRepository->expects($this->once()) + ->method('save') + ->with($existing); + + $updated = $this->manager->createOrUpdateFromPattern( + regex: $pattern, + action: 'delete', + listOrder: 10, + adminId: 2, + comment: 'upd', + status: 'active' + ); + + $this->assertSame('delete', $updated->getAction()); + $this->assertSame(10, $updated->getListOrder()); + $this->assertSame(2, $updated->getAdminId()); + $this->assertSame('upd', $updated->getComment()); + $this->assertSame('active', $updated->getStatus()); + $this->assertSame($hash, $updated->getRegexHash()); + } + + public function testDeleteRegex(): void + { + $model = $this->createMock(BounceRegex::class); + + $this->regexRepository->expects($this->once()) + ->method('remove') + ->with($model); + + $this->manager->delete($model); + } + + public function testAssociateBounceIncrementsCountAndPersistsRelation(): void + { + $regex = new BounceRegex(regex: 'x', regexHash: md5('x')); + + $refRegex = new ReflectionProperty(BounceRegex::class, 'id'); + $refRegex->setValue($regex, 7); + + $bounce = $this->createMock(Bounce::class); + $bounce->method('getId')->willReturn(11); + + $this->entityManager->expects($this->once()) + ->method('persist') + ->with($this->callback(function ($entity) use ($regex) { + return $entity instanceof BounceRegexBounce + && $entity->getRegex() === $regex->getId(); + })); + + $this->entityManager->expects($this->once()) + ->method('flush'); + + $this->assertSame(0, $regex->getCount()); + $this->manager->associateBounce($regex, $bounce); + $this->assertSame(1, $regex->getCount()); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/ListMessageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.php similarity index 98% rename from tests/Unit/Domain/Messaging/Service/ListMessageManagerTest.php rename to tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.php index 2ec4180f..2f1af5fe 100644 --- a/tests/Unit/Domain/Messaging/Service/ListMessageManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager; use DateTime; use Doctrine\ORM\EntityManagerInterface; diff --git a/tests/Unit/Domain/Messaging/Service/MessageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php similarity index 97% rename from tests/Unit/Domain/Messaging/Service/MessageManagerTest.php rename to tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php index 8ee85915..aa1a47e0 100644 --- a/tests/Unit/Domain/Messaging/Service/MessageManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Dto\CreateMessageDto; @@ -15,7 +15,7 @@ use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\Builder\MessageBuilder; -use PhpList\Core\Domain\Messaging\Service\MessageManager; +use PhpList\Core\Domain\Messaging\Service\Manager\MessageManager; use PHPUnit\Framework\TestCase; class MessageManagerTest extends TestCase diff --git a/tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php similarity index 95% rename from tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php rename to tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php index bde3569a..7eb6afe7 100644 --- a/tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager; use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Messaging\Model\Template; use PhpList\Core\Domain\Messaging\Model\TemplateImage; use PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository; -use PhpList\Core\Domain\Messaging\Service\TemplateImageManager; +use PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php similarity index 93% rename from tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php rename to tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php index fbbb4831..d3748244 100644 --- a/tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager; use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Messaging\Model\Dto\CreateTemplateDto; use PhpList\Core\Domain\Messaging\Model\Template; use PhpList\Core\Domain\Messaging\Repository\TemplateRepository; -use PhpList\Core\Domain\Messaging\Service\TemplateImageManager; -use PhpList\Core\Domain\Messaging\Service\TemplateManager; +use PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager; +use PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager; use PhpList\Core\Domain\Messaging\Validator\TemplateImageValidator; use PhpList\Core\Domain\Messaging\Validator\TemplateLinkValidator; use PHPUnit\Framework\MockObject\MockObject; From dc99df1bf9e445b7e3182384e831972a042c82d4 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Tue, 2 Sep 2025 11:21:21 +0400 Subject: [PATCH 04/20] Bounce processing command (#355) * BounceManager * Add bounce email * Move to the processor dir * SendProcess lock service * ClientIp + SystemInfo * ProcessBouncesCommand * ProcessBouncesCommand all methods * BounceProcessingService * AdvancedBounceRulesProcessor * UnidentifiedBounceReprocessor * ConsecutiveBounceHandler * Refactor * BounceDataProcessor * ClientFactory + refactor * BounceProcessorPass * Register services + phpstan fix * PhpMd * PhpMd CyclomaticComplexity * PhpCodeSniffer * Tests * Refactor * Add tests * More tests * Fix tests --------- Co-authored-by: Tatevik --- composer.json | 4 +- config/PHPMD/rules.xml | 2 +- config/PhpCodeSniffer/ruleset.xml | 9 - config/parameters.yml.dist | 26 ++ config/services.yml | 96 +++--- config/services/builders.yml | 2 +- config/services/commands.yml | 4 + config/services/managers.yml | 12 + config/services/processor.yml | 21 ++ config/services/providers.yml | 4 + config/services/repositories.yml | 287 +++++++++--------- config/services/services.yml | 143 ++++++--- src/Core/ApplicationKernel.php | 1 + src/Core/BounceProcessorPass.php | 28 ++ src/Domain/Common/ClientIpResolver.php | 28 ++ .../Common/Mail/NativeImapMailReader.php | 65 ++++ src/Domain/Common/SystemInfoCollector.php | 77 +++++ .../Command/ProcessBouncesCommand.php | 114 +++++++ .../Messaging/Command/ProcessQueueCommand.php | 8 +- .../Messaging/Model/BounceRegexBounce.php | 30 +- .../Messaging/Model/UserMessageBounce.php | 16 +- .../Repository/BounceRegexRepository.php | 12 + .../Messaging/Repository/BounceRepository.php | 7 + .../Repository/MessageRepository.php | 11 + .../Repository/SendProcessRepository.php | 67 ++++ .../UserMessageBounceRepository.php | 69 +++++ .../Service/BounceActionResolver.php | 65 ++++ .../BounceProcessingServiceInterface.php | 10 + .../Service/ConsecutiveBounceHandler.php | 141 +++++++++ src/Domain/Messaging/Service/EmailService.php | 17 +- .../BlacklistEmailAndDeleteBounceHandler.php | 47 +++ .../Service/Handler/BlacklistEmailHandler.php | 42 +++ .../BlacklistUserAndDeleteBounceHandler.php | 47 +++ .../Service/Handler/BlacklistUserHandler.php | 42 +++ .../Handler/BounceActionHandlerInterface.php | 11 + ...CountConfirmUserAndDeleteBounceHandler.php | 51 ++++ .../Service/Handler/DeleteBounceHandler.php | 27 ++ .../Handler/DeleteUserAndBounceHandler.php | 33 ++ .../Service/Handler/DeleteUserHandler.php | 36 +++ .../UnconfirmUserAndDeleteBounceHandler.php | 44 +++ .../Service/Handler/UnconfirmUserHandler.php | 39 +++ src/Domain/Messaging/Service/LockService.php | 172 +++++++++++ .../Service/Manager/BounceManager.php | 138 +++++++++ .../Service/Manager/BounceRuleManager.php | 110 +++++++ .../Service/Manager/SendProcessManager.php | 57 ++++ .../Messaging/Service/MessageParser.php | 102 +++++++ .../Service/NativeBounceProcessingService.php | 138 +++++++++ .../AdvancedBounceRulesProcessor.php | 120 ++++++++ .../Service/Processor/BounceDataProcessor.php | 155 ++++++++++ .../Processor/BounceProtocolProcessor.php | 24 ++ .../{ => Processor}/CampaignProcessor.php | 3 +- .../Service/Processor/MboxBounceProcessor.php | 46 +++ .../Service/Processor/PopBounceProcessor.php | 59 ++++ .../UnidentifiedBounceReprocessor.php | 70 +++++ .../WebklexBounceProcessingService.php | 268 ++++++++++++++++ .../Service/WebklexImapClientFactory.php | 79 +++++ .../Repository/SubscriberRepository.php | 47 +++ .../Manager/SubscriberBlacklistManager.php | 10 + .../Manager/SubscriberHistoryManager.php | 28 +- .../Service/Manager/SubscriberManager.php | 19 +- .../Service/SubscriberBlacklistService.php | 69 +++++ .../Service/SubscriberDeletionServiceTest.php | 3 +- .../Domain/Common/ClientIpResolverTest.php | 61 ++++ .../Domain/Common/SystemInfoCollectorTest.php | 95 ++++++ .../Command/ProcessBouncesCommandTest.php | 197 ++++++++++++ .../Command/ProcessQueueCommandTest.php | 2 +- .../Service/BounceActionResolverTest.php | 66 ++++ .../Service/ConsecutiveBounceHandlerTest.php | 212 +++++++++++++ .../Messaging/Service/EmailServiceTest.php | 8 +- ...acklistEmailAndDeleteBounceHandlerTest.php | 78 +++++ .../Handler/BlacklistEmailHandlerTest.php | 73 +++++ ...lacklistUserAndDeleteBounceHandlerTest.php | 90 ++++++ .../Handler/BlacklistUserHandlerTest.php | 84 +++++ ...tConfirmUserAndDeleteBounceHandlerTest.php | 103 +++++++ .../Handler/DeleteBounceHandlerTest.php | 40 +++ .../DeleteUserAndBounceHandlerTest.php | 63 ++++ .../Service/Handler/DeleteUserHandlerTest.php | 71 +++++ ...nconfirmUserAndDeleteBounceHandlerTest.php | 90 ++++++ .../Handler/UnconfirmUserHandlerTest.php | 77 +++++ .../Messaging/Service/LockServiceTest.php | 88 ++++++ .../Service/Manager/BounceManagerTest.php | 205 +++++++++++++ .../Manager/BounceRegexManagerTest.php | 2 +- .../Service/Manager/BounceRuleManagerTest.php | 143 +++++++++ .../Manager/SendProcessManagerTest.php | 86 ++++++ .../Manager/TemplateImageManagerTest.php | 4 +- .../Messaging/Service/MessageParserTest.php | 76 +++++ .../AdvancedBounceRulesProcessorTest.php | 177 +++++++++++ .../Processor/BounceDataProcessorTest.php | 168 ++++++++++ .../{ => Processor}/CampaignProcessorTest.php | 4 +- .../Processor/MboxBounceProcessorTest.php | 76 +++++ .../Processor/PopBounceProcessorTest.php | 64 ++++ .../UnidentifiedBounceReprocessorTest.php | 75 +++++ .../Service/WebklexImapClientFactoryTest.php | 70 +++++ .../Manager/SubscriberHistoryManagerTest.php | 6 +- .../Service/Manager/SubscriberManagerTest.php | 2 +- 95 files changed, 5884 insertions(+), 284 deletions(-) create mode 100644 config/services/processor.yml create mode 100644 src/Core/BounceProcessorPass.php create mode 100644 src/Domain/Common/ClientIpResolver.php create mode 100644 src/Domain/Common/Mail/NativeImapMailReader.php create mode 100644 src/Domain/Common/SystemInfoCollector.php create mode 100644 src/Domain/Messaging/Command/ProcessBouncesCommand.php create mode 100644 src/Domain/Messaging/Service/BounceActionResolver.php create mode 100644 src/Domain/Messaging/Service/BounceProcessingServiceInterface.php create mode 100644 src/Domain/Messaging/Service/ConsecutiveBounceHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php create mode 100644 src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/DeleteUserHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php create mode 100644 src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php create mode 100644 src/Domain/Messaging/Service/LockService.php create mode 100644 src/Domain/Messaging/Service/Manager/BounceManager.php create mode 100644 src/Domain/Messaging/Service/Manager/BounceRuleManager.php create mode 100644 src/Domain/Messaging/Service/Manager/SendProcessManager.php create mode 100644 src/Domain/Messaging/Service/MessageParser.php create mode 100644 src/Domain/Messaging/Service/NativeBounceProcessingService.php create mode 100644 src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php create mode 100644 src/Domain/Messaging/Service/Processor/BounceDataProcessor.php create mode 100644 src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php rename src/Domain/Messaging/Service/{ => Processor}/CampaignProcessor.php (95%) create mode 100644 src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php create mode 100644 src/Domain/Messaging/Service/Processor/PopBounceProcessor.php create mode 100644 src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php create mode 100644 src/Domain/Messaging/Service/WebklexBounceProcessingService.php create mode 100644 src/Domain/Messaging/Service/WebklexImapClientFactory.php create mode 100644 src/Domain/Subscription/Service/SubscriberBlacklistService.php create mode 100644 tests/Unit/Domain/Common/ClientIpResolverTest.php create mode 100644 tests/Unit/Domain/Common/SystemInfoCollectorTest.php create mode 100644 tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/LockServiceTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/MessageParserTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php rename tests/Unit/Domain/Messaging/Service/{ => Processor}/CampaignProcessorTest.php (98%) create mode 100644 tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php diff --git a/composer.json b/composer.json index be974681..2b391014 100644 --- a/composer.json +++ b/composer.json @@ -68,7 +68,9 @@ "symfony/sendgrid-mailer": "^6.4", "symfony/twig-bundle": "^6.4", "symfony/messenger": "^6.4", - "symfony/lock": "^6.4" + "symfony/lock": "^6.4", + "webklex/php-imap": "^6.2", + "ext-imap": "*" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/config/PHPMD/rules.xml b/config/PHPMD/rules.xml index 2d88410b..a0fbf650 100644 --- a/config/PHPMD/rules.xml +++ b/config/PHPMD/rules.xml @@ -51,7 +51,7 @@ - + diff --git a/config/PhpCodeSniffer/ruleset.xml b/config/PhpCodeSniffer/ruleset.xml index d0258304..fdba2edf 100644 --- a/config/PhpCodeSniffer/ruleset.xml +++ b/config/PhpCodeSniffer/ruleset.xml @@ -15,7 +15,6 @@ - @@ -41,9 +40,6 @@ - - - @@ -54,7 +50,6 @@ - @@ -66,9 +61,6 @@ - - - @@ -110,6 +102,5 @@ - diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index 621a8b81..54c649d8 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -32,6 +32,32 @@ parameters: app.password_reset_url: '%%env(PASSWORD_RESET_URL)%%' env(PASSWORD_RESET_URL): 'https://example.com/reset/' + # bounce email settings + imap_bounce.email: '%%env(BOUNCE_EMAIL)%%' + env(BOUNCE_EMAIL): 'bounce@phplist.com' + imap_bounce.password: '%%env(BOUNCE_IMAP_PASS)%%' + env(BOUNCE_IMAP_PASS): 'bounce@phplist.com' + imap_bounce.host: '%%env(BOUNCE_IMAP_HOST)%%' + env(BOUNCE_IMAP_HOST): 'imap.phplist.com' + imap_bounce.port: '%%env(BOUNCE_IMAP_PORT)%%' + env(BOUNCE_IMAP_PORT): '993' + imap_bounce.encryption: '%%env(BOUNCE_IMAP_ENCRYPTION)%%' + env(BOUNCE_IMAP_ENCRYPTION): 'ssl' + imap_bounce.mailbox: '%%env(BOUNCE_IMAP_MAILBOX)%%' + env(BOUNCE_IMAP_MAILBOX): '/var/spool/mail/bounces' + imap_bounce.mailbox_name: '%%env(BOUNCE_IMAP_MAILBOX_NAME)%%' + env(BOUNCE_IMAP_MAILBOX_NAME): 'INBOX,ONE_MORE' + imap_bounce.protocol: '%%env(BOUNCE_IMAP_PROTOCOL)%%' + env(BOUNCE_IMAP_PROTOCOL): 'imap' + imap_bounce.unsubscribe_threshold: '%%env(BOUNCE_IMAP_UNSUBSCRIBE_THRESHOLD)%%' + env(BOUNCE_IMAP_UNSUBSCRIBE_THRESHOLD): '5' + imap_bounce.blacklist_threshold: '%%env(BOUNCE_IMAP_BLACKLIST_THRESHOLD)%%' + env(BOUNCE_IMAP_BLACKLIST_THRESHOLD): '3' + imap_bounce.purge: '%%env(BOUNCE_IMAP_PURGE)%%' + env(BOUNCE_IMAP_PURGE): '0' + imap_bounce.purge_unprocessed: '%%env(BOUNCE_IMAP_PURGE_UNPROCESSED)%%' + env(BOUNCE_IMAP_PURGE_UNPROCESSED): '0' + # Messenger configuration for asynchronous processing app.messenger_transport_dsn: '%%env(MESSENGER_TRANSPORT_DSN)%%' env(MESSENGER_TRANSPORT_DSN): 'doctrine://default?auto_setup=true' diff --git a/config/services.yml b/config/services.yml index b83adce3..47be8241 100644 --- a/config/services.yml +++ b/config/services.yml @@ -1,51 +1,51 @@ imports: - - { resource: 'services/*.yml' } + - { resource: 'services/*.yml' } services: - _defaults: - autowire: true - autoconfigure: true - public: false - - PhpList\Core\Core\ConfigProvider: - arguments: - $config: '%app.config%' - - PhpList\Core\Core\ApplicationStructure: - public: true - - PhpList\Core\Security\Authentication: - public: true - - PhpList\Core\Security\HashGenerator: - public: true - - PhpList\Core\Routing\ExtraLoader: - tags: [routing.loader] - - PhpList\Core\Domain\Common\Repository\AbstractRepository: - abstract: true - autowire: true - autoconfigure: false - public: true - factory: ['@doctrine.orm.entity_manager', getRepository] - - # controllers are imported separately to make sure they're public - # and have a tag that allows actions to type-hint services - PhpList\Core\EmptyStartPageBundle\Controller\: - resource: '../src/EmptyStartPageBundle/Controller' - public: true - tags: [controller.service_arguments] - - doctrine.orm.metadata.annotation_reader: - alias: doctrine.annotation_reader - - doctrine.annotation_reader: - class: Doctrine\Common\Annotations\AnnotationReader - autowire: true - - doctrine.orm.default_annotation_metadata_driver: - class: Doctrine\ORM\Mapping\Driver\AnnotationDriver - arguments: - - '@annotation_reader' - - '%kernel.project_dir%/src/Domain/Model/' + _defaults: + autowire: true + autoconfigure: true + public: false + + PhpList\Core\Core\ConfigProvider: + arguments: + $config: '%app.config%' + + PhpList\Core\Core\ApplicationStructure: + public: true + + PhpList\Core\Security\Authentication: + public: true + + PhpList\Core\Security\HashGenerator: + public: true + + PhpList\Core\Routing\ExtraLoader: + tags: [routing.loader] + + PhpList\Core\Domain\Common\Repository\AbstractRepository: + abstract: true + autowire: true + autoconfigure: false + public: true + factory: ['@doctrine.orm.entity_manager', getRepository] + + # controllers are imported separately to make sure they're public + # and have a tag that allows actions to type-hint services + PhpList\Core\EmptyStartPageBundle\Controller\: + resource: '../src/EmptyStartPageBundle/Controller' + public: true + tags: [controller.service_arguments] + + doctrine.orm.metadata.annotation_reader: + alias: doctrine.annotation_reader + + doctrine.annotation_reader: + class: Doctrine\Common\Annotations\AnnotationReader + autowire: true + + doctrine.orm.default_annotation_metadata_driver: + class: Doctrine\ORM\Mapping\Driver\AnnotationDriver + arguments: + - '@annotation_reader' + - '%kernel.project_dir%/src/Domain/Model/' diff --git a/config/services/builders.yml b/config/services/builders.yml index c18961d6..10a994a4 100644 --- a/config/services/builders.yml +++ b/config/services/builders.yml @@ -20,6 +20,6 @@ services: autowire: true autoconfigure: true - PhpListPhpList\Core\Domain\Messaging\Service\Builder\MessageOptionsBuilder: + PhpList\Core\Domain\Messaging\Service\Builder\MessageOptionsBuilder: autowire: true autoconfigure: true diff --git a/config/services/commands.yml b/config/services/commands.yml index 5cc1a241..d9305748 100644 --- a/config/services/commands.yml +++ b/config/services/commands.yml @@ -11,3 +11,7 @@ services: PhpList\Core\Domain\Identity\Command\: resource: '../../src/Domain/Identity/Command' tags: ['console.command'] + + PhpList\Core\Domain\Messaging\Command\ProcessBouncesCommand: + arguments: + $protocolProcessors: !tagged_iterator 'phplist.bounce_protocol_processor' diff --git a/config/services/managers.yml b/config/services/managers.yml index 0f6bb119..5ef215b3 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -72,6 +72,10 @@ services: autowire: true autoconfigure: true + PhpList\Core\Domain\Messaging\Service\Manager\BounceManager: + autowire: true + autoconfigure: true + PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager: autowire: true autoconfigure: true @@ -79,3 +83,11 @@ services: PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: autowire: true autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\Manager\SendProcessManager: + autowire: true + autoconfigure: true diff --git a/config/services/processor.yml b/config/services/processor.yml new file mode 100644 index 00000000..acbd11c0 --- /dev/null +++ b/config/services/processor.yml @@ -0,0 +1,21 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + PhpList\Core\Domain\Messaging\Service\Processor\PopBounceProcessor: + arguments: + $host: '%imap_bounce.host%' + $port: '%imap_bounce.port%' + $mailboxNames: '%imap_bounce.mailbox_name%' + tags: ['phplist.bounce_protocol_processor'] + + PhpList\Core\Domain\Messaging\Service\Processor\MboxBounceProcessor: + tags: ['phplist.bounce_protocol_processor'] + + PhpList\Core\Domain\Messaging\Service\Processor\AdvancedBounceRulesProcessor: ~ + + PhpList\Core\Domain\Messaging\Service\Processor\UnidentifiedBounceReprocessor: ~ + + PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor: ~ diff --git a/config/services/providers.yml b/config/services/providers.yml index 226c4e81..cb784988 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -2,3 +2,7 @@ services: PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider: autowire: true autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\Provider\BounceActionProvider: + autowire: true + autoconfigure: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml index 69bdb6ce..82ae6a82 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -1,137 +1,152 @@ services: - PhpList\Core\Domain\Identity\Repository\AdministratorRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Identity\Model\Administrator - - Doctrine\ORM\Mapping\ClassMetadata\ClassMetadata - - PhpList\Core\Security\HashGenerator - - PhpList\Core\Domain\Identity\Repository\AdminAttributeValueRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Identity\Model\AdminAttributeValue - - PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition - - PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Identity\Model\AdministratorToken - - PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Identity\Model\AdminPasswordRequest - - PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\SubscriberList - - PhpList\Core\Domain\Subscription\Repository\SubscriberRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\Subscriber - - PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue - - PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition - - PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\Subscription - - PhpList\Core\Domain\Messaging\Repository\MessageRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Messaging\Model\Message - - PhpList\Core\Domain\Messaging\Repository\TemplateRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Messaging\Model\Template - - PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Messaging\Model\TemplateImage - - PhpList\Core\Domain\Configuration\Repository\ConfigRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Configuration\Model\Config - - PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Messaging\Model\UserMessageBounce - - PhpList\Core\Domain\Messaging\Repository\UserMessageForwardRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Messaging\Model\UserMessageForward - - PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Analytics\Model\LinkTrack - - PhpList\Core\Domain\Analytics\Repository\UserMessageViewRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Analytics\Model\UserMessageView - - PhpList\Core\Domain\Analytics\Repository\LinkTrackUmlClickRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Analytics\Model\LinkTrackUmlClick - - PhpList\Core\Domain\Messaging\Repository\UserMessageRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Messaging\Model\UserMessage - - PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\SubscriberHistory - - PhpList\Core\Domain\Messaging\Repository\ListMessageRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Messaging\Model\ListMessage - - PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\UserBlacklist - - PhpList\Core\Domain\Subscription\Repository\UserBlacklistDataRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\UserBlacklistData - - PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\SubscribePage - - PhpList\Core\Domain\Subscription\Repository\SubscriberPageDataRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\SubscribePageData - - PhpList\Core\Domain\Messaging\Repository\BounceRegexRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Messaging\Model\BounceRegex + PhpList\Core\Domain\Identity\Repository\AdministratorRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Identity\Model\Administrator + - Doctrine\ORM\Mapping\ClassMetadata\ClassMetadata + - PhpList\Core\Security\HashGenerator + + PhpList\Core\Domain\Identity\Repository\AdminAttributeValueRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Identity\Model\AdminAttributeValue + + PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition + + PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Identity\Model\AdministratorToken + + PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Identity\Model\AdminPasswordRequest + + PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscriberList + + PhpList\Core\Domain\Subscription\Repository\SubscriberRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\Subscriber + + PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue + + PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition + + PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\Subscription + + PhpList\Core\Domain\Messaging\Repository\MessageRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\Message + + PhpList\Core\Domain\Messaging\Repository\TemplateRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\Template + + PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\TemplateImage + + PhpList\Core\Domain\Configuration\Repository\ConfigRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Configuration\Model\Config + + PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\UserMessageBounce + + PhpList\Core\Domain\Messaging\Repository\UserMessageForwardRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\UserMessageForward + + PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Analytics\Model\LinkTrack + + PhpList\Core\Domain\Analytics\Repository\UserMessageViewRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Analytics\Model\UserMessageView + + PhpList\Core\Domain\Analytics\Repository\LinkTrackUmlClickRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Analytics\Model\LinkTrackUmlClick + + PhpList\Core\Domain\Messaging\Repository\UserMessageRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\UserMessage + + PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscriberHistory + + PhpList\Core\Domain\Messaging\Repository\ListMessageRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\ListMessage + + PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\UserBlacklist + + PhpList\Core\Domain\Subscription\Repository\UserBlacklistDataRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\UserBlacklistData + + PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscribePage + + PhpList\Core\Domain\Subscription\Repository\SubscriberPageDataRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscribePageData + + PhpList\Core\Domain\Messaging\Repository\BounceRegexRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\BounceRegex + + PhpList\Core\Domain\Messaging\Repository\BounceRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\Bounce + + PhpList\Core\Domain\Messaging\Repository\BounceRegexBounceRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\BounceRegex + + PhpList\Core\Domain\Messaging\Repository\SendProcessRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\SendProcess diff --git a/config/services/services.yml b/config/services/services.yml index 7b9f921c..19caddd8 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -1,36 +1,109 @@ services: - PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter: - autowire: true - autoconfigure: true - public: true - - PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter: - autowire: true - autoconfigure: true - public: true - - PhpList\Core\Domain\Messaging\Service\EmailService: - autowire: true - autoconfigure: true - arguments: - $defaultFromEmail: '%app.mailer_from%' - - PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService: - autowire: true - autoconfigure: true - public: true - - PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator: - autowire: true - autoconfigure: true - public: true - - PhpList\Core\Domain\Analytics\Service\LinkTrackService: - autowire: true - autoconfigure: true - public: true - - PhpList\Core\Domain\Messaging\Service\CampaignProcessor: - autowire: true - autoconfigure: true - public: true + PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter: + autowire: true + autoconfigure: true + public: true + + PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter: + autowire: true + autoconfigure: true + public: true + + PhpList\Core\Domain\Messaging\Service\EmailService: + autowire: true + autoconfigure: true + arguments: + $defaultFromEmail: '%app.mailer_from%' + $bounceEmail: '%imap_bounce.email%' + + PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService: + autowire: true + autoconfigure: true + public: true + + PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator: + autowire: true + autoconfigure: true + public: true + + PhpList\Core\Domain\Analytics\Service\LinkTrackService: + autowire: true + autoconfigure: true + public: true + + PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor: + autowire: true + autoconfigure: true + public: true + + PhpList\Core\Domain\Common\ClientIpResolver: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Common\SystemInfoCollector: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler: + autowire: true + autoconfigure: true + arguments: + $unsubscribeThreshold: '%imap_bounce.unsubscribe_threshold%' + $blacklistThreshold: '%imap_bounce.blacklist_threshold%' + + Webklex\PHPIMAP\ClientManager: ~ + + PhpList\Core\Domain\Messaging\Service\WebklexImapClientFactory: + autowire: true + autoconfigure: true + arguments: + $mailbox: '%imap_bounce.mailbox%'# e.g. "{imap.example.com:993/imap/ssl}INBOX" or "/var/mail/user" + $host: '%imap_bounce.host%' + $port: '%imap_bounce.port%' + $encryption: '%imap_bounce.encryption%' + $username: '%imap_bounce.email%' + $password: '%imap_bounce.password%' + $protocol: '%imap_bounce.protocol%' + + PhpList\Core\Domain\Common\Mail\NativeImapMailReader: + arguments: + $username: '%imap_bounce.email%' + $password: '%imap_bounce.password%' + + PhpList\Core\Domain\Messaging\Service\NativeBounceProcessingService: + autowire: true + autoconfigure: true + arguments: + $purgeProcessed: '%imap_bounce.purge%' + $purgeUnprocessed: '%imap_bounce.purge_unprocessed%' + + PhpList\Core\Domain\Messaging\Service\WebklexBounceProcessingService: + autowire: true + autoconfigure: true + arguments: + $purgeProcessed: '%imap_bounce.purge%' + $purgeUnprocessed: '%imap_bounce.purge_unprocessed%' + + PhpList\Core\Domain\Messaging\Service\LockService: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\MessageParser: + autowire: true + autoconfigure: true + + _instanceof: + PhpList\Core\Domain\Messaging\Service\Handler\BounceActionHandlerInterface: + tags: + - { name: 'phplist.bounce_action_handler' } + + PhpList\Core\Domain\Messaging\Service\Handler\: + resource: '../../src/Domain/Messaging/Service/Handler/*Handler.php' + + PhpList\Core\Domain\Messaging\Service\BounceActionResolver: + arguments: + - !tagged_iterator { tag: 'phplist.bounce_action_handler' } diff --git a/src/Core/ApplicationKernel.php b/src/Core/ApplicationKernel.php index 97249b45..8f43e62b 100644 --- a/src/Core/ApplicationKernel.php +++ b/src/Core/ApplicationKernel.php @@ -106,6 +106,7 @@ protected function build(ContainerBuilder $container): void { $container->setParameter('kernel.application_dir', $this->getApplicationDir()); $container->addCompilerPass(new DoctrineMappingPass()); + $container->addCompilerPass(new BounceProcessorPass()); } /** diff --git a/src/Core/BounceProcessorPass.php b/src/Core/BounceProcessorPass.php new file mode 100644 index 00000000..2ab5c9c5 --- /dev/null +++ b/src/Core/BounceProcessorPass.php @@ -0,0 +1,28 @@ +hasDefinition($native) || !$container->hasDefinition($webklex)) { + return; + } + + $aliasTo = extension_loaded('imap') ? $native : $webklex; + + $container->setAlias(BounceProcessingServiceInterface::class, $aliasTo)->setPublic(false); + } +} diff --git a/src/Domain/Common/ClientIpResolver.php b/src/Domain/Common/ClientIpResolver.php new file mode 100644 index 00000000..65cbbb6c --- /dev/null +++ b/src/Domain/Common/ClientIpResolver.php @@ -0,0 +1,28 @@ +requestStack = $requestStack; + } + + public function resolve(): string + { + $request = $this->requestStack->getCurrentRequest(); + + if ($request !== null) { + return $request->getClientIp() ?? ''; + } + + return (gethostname() ?: 'localhost') . ':' . getmypid(); + } +} diff --git a/src/Domain/Common/Mail/NativeImapMailReader.php b/src/Domain/Common/Mail/NativeImapMailReader.php new file mode 100644 index 00000000..472fea54 --- /dev/null +++ b/src/Domain/Common/Mail/NativeImapMailReader.php @@ -0,0 +1,65 @@ +username = $username; + $this->password = $password; + } + + public function open(string $mailbox, int $options = 0): Connection + { + $link = imap_open($mailbox, $this->username, $this->password, $options); + + if ($link === false) { + throw new RuntimeException('Cannot open mailbox: '.(imap_last_error() ?: 'unknown error')); + } + + return $link; + } + + public function numMessages(Connection $link): int + { + return imap_num_msg($link); + } + + public function fetchHeader(Connection $link, int $msgNo): string + { + return imap_fetchheader($link, $msgNo) ?: ''; + } + + public function headerDate(Connection $link, int $msgNo): DateTimeImmutable + { + $info = imap_headerinfo($link, $msgNo); + $date = $info->date ?? null; + + return $date ? new DateTimeImmutable($date) : new DateTimeImmutable(); + } + + public function body(Connection $link, int $msgNo): string + { + return imap_body($link, $msgNo) ?: ''; + } + + public function delete(Connection $link, int $msgNo): void + { + imap_delete($link, (string)$msgNo); + } + + public function close(Connection $link, bool $expunge): void + { + $expunge ? imap_close($link, CL_EXPUNGE) : imap_close($link); + } +} diff --git a/src/Domain/Common/SystemInfoCollector.php b/src/Domain/Common/SystemInfoCollector.php new file mode 100644 index 00000000..e66d27b1 --- /dev/null +++ b/src/Domain/Common/SystemInfoCollector.php @@ -0,0 +1,77 @@ + use defaults) + */ + public function __construct( + RequestStack $requestStack, + array $configuredKeys = [] + ) { + $this->requestStack = $requestStack; + $this->configuredKeys = $configuredKeys; + } + + /** + * Return key=>value pairs (already sanitized for safe logging/HTML display). + * @SuppressWarnings(PHPMD.StaticAccess) + * @return array + */ + public function collect(): array + { + $request = $this->requestStack->getCurrentRequest() ?? Request::createFromGlobals(); + $data = []; + $headers = $request->headers; + + $data['HTTP_USER_AGENT'] = (string) $headers->get('User-Agent', ''); + $data['HTTP_REFERER'] = (string) $headers->get('Referer', ''); + $data['HTTP_X_FORWARDED_FOR'] = (string) $headers->get('X-Forwarded-For', ''); + $data['REQUEST_URI'] = $request->getRequestUri(); + $data['REMOTE_ADDR'] = $request->getClientIp() ?? ''; + + $keys = $this->configuredKeys ?: $this->defaultKeys; + + $out = []; + foreach ($keys as $key) { + if (!array_key_exists($key, $data)) { + continue; + } + $val = $data[$key]; + + $safeKey = strip_tags($key); + $safeVal = htmlspecialchars((string) $val, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + $out[$safeKey] = $safeVal; + } + + return $out; + } + + /** + * Convenience to match the legacy multi-line string format. + */ + public function collectAsString(): string + { + $pairs = $this->collect(); + if (!$pairs) { + return ''; + } + $lines = []; + foreach ($pairs as $k => $v) { + $lines[] = sprintf('%s = %s', $k, $v); + } + return "\n" . implode("\n", $lines); + } +} diff --git a/src/Domain/Messaging/Command/ProcessBouncesCommand.php b/src/Domain/Messaging/Command/ProcessBouncesCommand.php new file mode 100644 index 00000000..f1e3b403 --- /dev/null +++ b/src/Domain/Messaging/Command/ProcessBouncesCommand.php @@ -0,0 +1,114 @@ +addOption('protocol', null, InputOption::VALUE_REQUIRED, 'Mailbox protocol: pop or mbox', 'pop') + ->addOption( + 'purge-unprocessed', + null, + InputOption::VALUE_NONE, + 'Delete/remove unprocessed messages from mailbox' + ) + ->addOption('rules-batch-size', null, InputOption::VALUE_OPTIONAL, 'Advanced rules batch size', '1000') + ->addOption('test', 't', InputOption::VALUE_NONE, 'Test mode: do not delete from mailbox') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force run: kill other processes if locked'); + } + + public function __construct( + private readonly LockService $lockService, + private readonly LoggerInterface $logger, + /** @var iterable */ + private readonly iterable $protocolProcessors, + private readonly AdvancedBounceRulesProcessor $advancedRulesProcessor, + private readonly UnidentifiedBounceReprocessor $unidentifiedReprocessor, + private readonly ConsecutiveBounceHandler $consecutiveBounceHandler, + ) { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $inputOutput = new SymfonyStyle($input, $output); + + if (!function_exists('imap_open')) { + $inputOutput->note(self::IMAP_NOT_AVAILABLE); + } + + $force = (bool)$input->getOption('force'); + $lock = $this->lockService->acquirePageLock('bounce_processor', $force); + + if (($lock ?? 0) === 0) { + $inputOutput->warning($force ? self::FORCE_LOCK_FAILED : self::ALREADY_LOCKED); + + return $force ? Command::FAILURE : Command::SUCCESS; + } + + try { + $inputOutput->title('Processing bounces'); + $protocol = (string)$input->getOption('protocol'); + + $downloadReport = ''; + + $processor = $this->findProcessorFor($protocol); + if ($processor === null) { + $inputOutput->error('Unsupported protocol: '.$protocol); + + return Command::FAILURE; + } + + $downloadReport .= $processor->process($input, $inputOutput); + $this->unidentifiedReprocessor->process($inputOutput); + $this->advancedRulesProcessor->process($inputOutput, (int)$input->getOption('rules-batch-size')); + $this->consecutiveBounceHandler->handle($inputOutput); + + $this->logger->info('Bounce processing completed', ['downloadReport' => $downloadReport]); + $inputOutput->success('Bounce processing completed.'); + + return Command::SUCCESS; + } catch (Exception $e) { + $this->logger->error('Bounce processing failed', ['exception' => $e]); + $inputOutput->error('Error: '.$e->getMessage()); + + return Command::FAILURE; + } finally { + $this->lockService->release($lock); + } + } + + private function findProcessorFor(string $protocol): ?BounceProtocolProcessor + { + foreach ($this->protocolProcessors as $processor) { + if ($processor->getProtocol() === $protocol) { + return $processor; + } + } + + return null; + } +} diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php index 43937f91..820d403d 100644 --- a/src/Domain/Messaging/Command/ProcessQueueCommand.php +++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php @@ -4,14 +4,14 @@ namespace PhpList\Core\Domain\Messaging\Command; -use PhpList\Core\Domain\Messaging\Service\CampaignProcessor; +use PhpList\Core\Domain\Messaging\Repository\MessageRepository; +use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; +use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use PhpList\Core\Domain\Messaging\Repository\MessageRepository; -use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; use Symfony\Component\Lock\LockFactory; -use Symfony\Component\Console\Attribute\AsCommand; use Throwable; #[AsCommand( diff --git a/src/Domain/Messaging/Model/BounceRegexBounce.php b/src/Domain/Messaging/Model/BounceRegexBounce.php index 9dbd3168..e815cd1f 100644 --- a/src/Domain/Messaging/Model/BounceRegexBounce.php +++ b/src/Domain/Messaging/Model/BounceRegexBounce.php @@ -13,38 +13,38 @@ class BounceRegexBounce implements DomainModel { #[ORM\Id] - #[ORM\Column(type: 'integer')] - private int $regex; + #[ORM\Column(name: 'regex', type: 'integer')] + private int $regexId; #[ORM\Id] - #[ORM\Column(type: 'integer')] - private int $bounce; + #[ORM\Column(name: 'bounce', type: 'integer')] + private int $bounceId; - public function __construct(int $regex, int $bounce) + public function __construct(int $regexId, int $bounceId) { - $this->regex = $regex; - $this->bounce = $bounce; + $this->regexId = $regexId; + $this->bounceId = $bounceId; } - public function getRegex(): int + public function getRegexId(): int { - return $this->regex; + return $this->regexId; } - public function setRegex(int $regex): self + public function setRegexId(int $regexId): self { - $this->regex = $regex; + $this->regexId = $regexId; return $this; } - public function getBounce(): int + public function getBounceId(): int { - return $this->bounce; + return $this->bounceId; } - public function setBounce(int $bounce): self + public function setBounceId(int $bounceId): self { - $this->bounce = $bounce; + $this->bounceId = $bounceId; return $this; } } diff --git a/src/Domain/Messaging/Model/UserMessageBounce.php b/src/Domain/Messaging/Model/UserMessageBounce.php index ccb05597..5da0d139 100644 --- a/src/Domain/Messaging/Model/UserMessageBounce.php +++ b/src/Domain/Messaging/Model/UserMessageBounce.php @@ -31,15 +31,15 @@ class UserMessageBounce implements DomainModel, Identity private int $messageId; #[ORM\Column(name: 'bounce', type: 'integer')] - private int $bounce; + private int $bounceId; #[ORM\Column(name: 'time', type: 'datetime', options: ['default' => 'CURRENT_TIMESTAMP'])] private DateTime $createdAt; - public function __construct(int $bounce) + public function __construct(int $bounceId, DateTime $createdAt) { - $this->bounce = $bounce; - $this->createdAt = new DateTime(); + $this->bounceId = $bounceId; + $this->createdAt = $createdAt; } public function getId(): ?int @@ -57,9 +57,9 @@ public function getMessageId(): int return $this->messageId; } - public function getBounce(): int + public function getBounceId(): int { - return $this->bounce; + return $this->bounceId; } public function getCreatedAt(): DateTime @@ -79,9 +79,9 @@ public function setMessageId(int $messageId): self return $this; } - public function setBounce(int $bounce): self + public function setBounceId(int $bounceId): self { - $this->bounce = $bounce; + $this->bounceId = $bounceId; return $this; } } diff --git a/src/Domain/Messaging/Repository/BounceRegexRepository.php b/src/Domain/Messaging/Repository/BounceRegexRepository.php index f5088376..9aecde78 100644 --- a/src/Domain/Messaging/Repository/BounceRegexRepository.php +++ b/src/Domain/Messaging/Repository/BounceRegexRepository.php @@ -17,4 +17,16 @@ public function findOneByRegexHash(string $regexHash): ?BounceRegex { return $this->findOneBy(['regexHash' => $regexHash]); } + + /** @return BounceRegex[] */ + public function fetchAllOrdered(): array + { + return $this->findBy([], ['listOrder' => 'ASC']); + } + + /** @return BounceRegex[] */ + public function fetchActiveOrdered(): array + { + return $this->findBy(['active' => true], ['listOrder' => 'ASC']); + } } diff --git a/src/Domain/Messaging/Repository/BounceRepository.php b/src/Domain/Messaging/Repository/BounceRepository.php index fa691a28..410f5da1 100644 --- a/src/Domain/Messaging/Repository/BounceRepository.php +++ b/src/Domain/Messaging/Repository/BounceRepository.php @@ -7,8 +7,15 @@ use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; +use PhpList\Core\Domain\Messaging\Model\Bounce; class BounceRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + /** @return Bounce[] */ + public function findByStatus(string $status): array + { + return $this->findBy(['status' => $status]); + } } diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php index cf802300..3da7ebf3 100644 --- a/src/Domain/Messaging/Repository/MessageRepository.php +++ b/src/Domain/Messaging/Repository/MessageRepository.php @@ -63,4 +63,15 @@ public function getMessagesByList(SubscriberList $list): array ->getQuery() ->getResult(); } + + public function incrementBounceCount(int $messageId): void + { + $this->createQueryBuilder('m') + ->update() + ->set('m.bounceCount', 'm.bounceCount + 1') + ->where('m.id = :messageId') + ->setParameter('messageId', $messageId) + ->getQuery() + ->execute(); + } } diff --git a/src/Domain/Messaging/Repository/SendProcessRepository.php b/src/Domain/Messaging/Repository/SendProcessRepository.php index 496adf9b..2a234a5a 100644 --- a/src/Domain/Messaging/Repository/SendProcessRepository.php +++ b/src/Domain/Messaging/Repository/SendProcessRepository.php @@ -7,8 +7,75 @@ use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; +use PhpList\Core\Domain\Messaging\Model\SendProcess; class SendProcessRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + public function deleteByPage(string $page): void + { + $this->createQueryBuilder('sp') + ->delete() + ->where('sp.page = :page') + ->setParameter('page', $page) + ->getQuery() + ->execute(); + } + + public function countAliveByPage(string $page): int + { + return (int)$this->createQueryBuilder('sp') + ->select('COUNT(sp.id)') + ->where('sp.page = :page') + ->andWhere('sp.alive > 0') + ->setParameter('page', $page) + ->getQuery() + ->getSingleScalarResult(); + } + + public function findNewestAlive(string $page): ?SendProcess + { + return $this->createQueryBuilder('sp') + ->where('sp.page = :page') + ->andWhere('sp.alive > 0') + ->setParameter('page', $page) + ->orderBy('sp.started', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + + public function markDeadById(int $id): void + { + $this->createQueryBuilder('sp') + ->update() + ->set('sp.alive', ':zero') + ->where('sp.id = :id') + ->setParameter('zero', 0) + ->setParameter('id', $id) + ->getQuery() + ->execute(); + } + + public function incrementAlive(int $id): void + { + $this->createQueryBuilder('sp') + ->update() + ->set('sp.alive', 'sp.alive + 1') + ->where('sp.id = :id') + ->setParameter('id', $id) + ->getQuery() + ->execute(); + } + + public function getAliveValue(int $id): int + { + return (int)$this->createQueryBuilder('sp') + ->select('sp.alive') + ->where('sp.id = :id') + ->setParameter('id', $id) + ->getQuery() + ->getSingleScalarResult(); + } } diff --git a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php index 16f07f79..1b315f5e 100644 --- a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php +++ b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php @@ -7,6 +7,10 @@ use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; +use PhpList\Core\Domain\Messaging\Model\Bounce; +use PhpList\Core\Domain\Messaging\Model\UserMessage; +use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; +use PhpList\Core\Domain\Subscription\Model\Subscriber; class UserMessageBounceRepository extends AbstractRepository implements PaginatableRepositoryInterface { @@ -21,4 +25,69 @@ public function getCountByMessageId(int $messageId): int ->getQuery() ->getSingleScalarResult(); } + + public function existsByMessageIdAndUserId(int $messageId, int $subscriberId): bool + { + return (bool) $this->createQueryBuilder('umb') + ->select('1') + ->where('umb.messageId = :messageId') + ->andWhere('umb.userId = :userId') + ->setParameter('messageId', $messageId) + ->setParameter('userId', $subscriberId) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + + /** + * @return array + */ + public function getPaginatedWithJoinNoRelation(int $fromId, int $limit): array + { + return $this->getEntityManager() + ->createQueryBuilder() + ->select('umb', 'bounce') + ->from(UserMessageBounce::class, 'umb') + ->innerJoin(Bounce::class, 'bounce', 'WITH', 'bounce.id = umb.bounce') + ->where('umb.id > :id') + ->setParameter('id', $fromId) + ->orderBy('umb.id', 'ASC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } + + /** + * @return array + */ + public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array + { + return $this->getEntityManager() + ->createQueryBuilder() + ->select('um', 'umb', 'b') + ->from(UserMessage::class, 'um') + ->leftJoin( + join: UserMessageBounce::class, + alias: 'umb', + conditionType: 'WITH', + condition: 'umb.messageId = IDENTITY(um.message) AND umb.userId = IDENTITY(um.user)' + ) + ->leftJoin( + join: Bounce::class, + alias: 'b', + conditionType: 'WITH', + condition: 'b.id = umb.bounceId' + ) + ->where('um.user = :userId') + ->andWhere('um.status = :status') + ->setParameter('userId', $subscriber->getId()) + ->setParameter('status', 'sent') + ->orderBy('um.entered', 'DESC') + ->getQuery() + ->getResult(); + } } diff --git a/src/Domain/Messaging/Service/BounceActionResolver.php b/src/Domain/Messaging/Service/BounceActionResolver.php new file mode 100644 index 00000000..93d432dd --- /dev/null +++ b/src/Domain/Messaging/Service/BounceActionResolver.php @@ -0,0 +1,65 @@ + */ + private array $cache = []; + + /** + * @param iterable $handlers + */ + public function __construct(iterable $handlers) + { + foreach ($handlers as $handler) { + $this->handlers[] = $handler; + } + } + + public function has(string $action): bool + { + return isset($this->cache[$action]) || $this->find($action) !== null; + } + + public function resolve(string $action): BounceActionHandlerInterface + { + if (isset($this->cache[$action])) { + return $this->cache[$action]; + } + + $handler = $this->find($action); + if ($handler === null) { + throw new RuntimeException(sprintf('No handler found for action "%s".', $action)); + } + + $this->cache[$action] = $handler; + + return $handler; + } + + /** Convenience: resolve + execute */ + public function handle(string $action, array $context): void + { + $this->resolve($action)->handle($context); + } + + private function find(string $action): ?BounceActionHandlerInterface + { + foreach ($this->handlers as $handler) { + if ($handler->supports($action)) { + return $handler; + } + } + + return null; + } +} diff --git a/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php b/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php new file mode 100644 index 00000000..9d16702f --- /dev/null +++ b/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php @@ -0,0 +1,10 @@ +bounceManager = $bounceManager; + $this->subscriberRepository = $subscriberRepository; + $this->subscriberHistoryManager = $subscriberHistoryManager; + $this->blacklistService = $blacklistService; + $this->unsubscribeThreshold = $unsubscribeThreshold; + $this->blacklistThreshold = $blacklistThreshold; + } + + public function handle(SymfonyStyle $io): void + { + $io->section('Identifying consecutive bounces'); + + $users = $this->subscriberRepository->distinctUsersWithBouncesConfirmedNotBlacklisted(); + $total = count($users); + + if ($total === 0) { + $io->writeln('Nothing to do'); + return; + } + + $processed = 0; + foreach ($users as $user) { + $this->processUser($user); + $processed++; + + if ($processed % 5 === 0) { + $io->writeln(\sprintf('processed %d out of %d subscribers', $processed, $total)); + } + } + + $io->writeln(\sprintf('total of %d subscribers processed', $total)); + } + + private function processUser(Subscriber $user): void + { + $history = $this->bounceManager->getUserMessageHistoryWithBounces($user); + if (count($history) === 0) { + return; + } + + $consecutive = 0; + $unsubscribed = false; + + foreach ($history as $row) { + /** @var array{um: UserMessage, umb: UserMessageBounce|null, b: Bounce|null} $row */ + $bounce = $row['b'] ?? null; + + if ($this->isDuplicate($bounce)) { + continue; + } + + if (!$this->hasRealId($bounce)) { + break; + } + + $consecutive++; + + if ($this->applyThresholdActions($user, $consecutive, $unsubscribed)) { + break; + } + + if (!$unsubscribed && $consecutive >= $this->unsubscribeThreshold) { + $unsubscribed = true; + } + } + } + + private function isDuplicate(?Bounce $bounce): bool + { + if ($bounce === null) { + return false; + } + $status = strtolower($bounce->getStatus() ?? ''); + $comment = strtolower($bounce->getComment() ?? ''); + + return str_contains($status, 'duplicate') || str_contains($comment, 'duplicate'); + } + + private function hasRealId(?Bounce $bounce): bool + { + return $bounce !== null && (int) $bounce->getId() > 0; + } + + /** + * Returns true if processing should stop for this user (e.g., blacklisted). + */ + private function applyThresholdActions($user, int $consecutive, bool $alreadyUnsubscribed): bool + { + if ($consecutive >= $this->unsubscribeThreshold && !$alreadyUnsubscribed) { + $this->subscriberRepository->markUnconfirmed($user->getId()); + $this->subscriberHistoryManager->addHistory( + subscriber: $user, + message: 'Auto Unconfirmed', + details: sprintf('Subscriber auto unconfirmed for %d consecutive bounces', $consecutive) + ); + } + + if ($this->blacklistThreshold > 0 && $consecutive >= $this->blacklistThreshold) { + $this->blacklistService->blacklist( + subscriber: $user, + reason: sprintf('%d consecutive bounces, threshold reached', $consecutive) + ); + return true; + } + + return false; + } +} diff --git a/src/Domain/Messaging/Service/EmailService.php b/src/Domain/Messaging/Service/EmailService.php index 86b17ec5..2a45b0fd 100644 --- a/src/Domain/Messaging/Service/EmailService.php +++ b/src/Domain/Messaging/Service/EmailService.php @@ -6,6 +6,7 @@ use PhpList\Core\Domain\Messaging\Message\AsyncEmailMessage; use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Mailer\Envelope; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Address; @@ -13,17 +14,20 @@ class EmailService { private MailerInterface $mailer; - private string $defaultFromEmail; private MessageBusInterface $messageBus; + private string $defaultFromEmail; + private string $bounceEmail; public function __construct( MailerInterface $mailer, + MessageBusInterface $messageBus, string $defaultFromEmail, - MessageBusInterface $messageBus + string $bounceEmail, ) { $this->mailer = $mailer; - $this->defaultFromEmail = $defaultFromEmail; $this->messageBus = $messageBus; + $this->defaultFromEmail = $defaultFromEmail; + $this->bounceEmail = $bounceEmail; } public function sendEmail( @@ -68,7 +72,12 @@ public function sendEmailSync( $email->attachFromPath($attachment); } - $this->mailer->send($email); + $envelope = new Envelope( + sender: new Address($this->bounceEmail, 'PHPList Bounce'), + recipients: [new Address($email->getTo()[0]->getAddress())] + ); + + $this->mailer->send(message: $email, envelope: $envelope); } public function sendBulkEmail( diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php new file mode 100644 index 00000000..d32cf68b --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php @@ -0,0 +1,47 @@ +subscriberHistoryManager = $subscriberHistoryManager; + $this->bounceManager = $bounceManager; + $this->blacklistService = $blacklistService; + } + + public function supports(string $action): bool + { + return $action === 'blacklistemailanddeletebounce'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber'])) { + $this->blacklistService->blacklist( + subscriber: $closureData['subscriber'], + reason: 'Email address auto blacklisted by bounce rule '.$closureData['ruleId'] + ); + $this->subscriberHistoryManager->addHistory( + $closureData['subscriber'], + 'Auto Unsubscribed', + 'User auto unsubscribed for bounce rule '.$closureData['ruleId'] + ); + } + $this->bounceManager->delete($closureData['bounce']); + } +} diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php new file mode 100644 index 00000000..9a92088c --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php @@ -0,0 +1,42 @@ +subscriberHistoryManager = $subscriberHistoryManager; + $this->blacklistService = $blacklistService; + } + + public function supports(string $action): bool + { + return $action === 'blacklistemail'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber'])) { + $this->blacklistService->blacklist( + $closureData['subscriber'], + 'Email address auto blacklisted by bounce rule '.$closureData['ruleId'] + ); + $this->subscriberHistoryManager->addHistory( + $closureData['subscriber'], + 'Auto Unsubscribed', + 'email auto unsubscribed for bounce rule '.$closureData['ruleId'] + ); + } + } +} diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php new file mode 100644 index 00000000..b017fe9c --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php @@ -0,0 +1,47 @@ +subscriberHistoryManager = $subscriberHistoryManager; + $this->bounceManager = $bounceManager; + $this->blacklistService = $blacklistService; + } + + public function supports(string $action): bool + { + return $action === 'blacklistuseranddeletebounce'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber']) && !$closureData['blacklisted']) { + $this->blacklistService->blacklist( + subscriber: $closureData['subscriber'], + reason: 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId'] + ); + $this->subscriberHistoryManager->addHistory( + subscriber: $closureData['subscriber'], + message: 'Auto Unsubscribed', + details: 'User auto unsubscribed for bounce rule '.$closureData['ruleId'] + ); + } + $this->bounceManager->delete($closureData['bounce']); + } +} diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php new file mode 100644 index 00000000..75c8b810 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php @@ -0,0 +1,42 @@ +subscriberHistoryManager = $subscriberHistoryManager; + $this->blacklistService = $blacklistService; + } + + public function supports(string $action): bool + { + return $action === 'blacklistuser'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber']) && !$closureData['blacklisted']) { + $this->blacklistService->blacklist( + subscriber: $closureData['subscriber'], + reason: 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId'] + ); + $this->subscriberHistoryManager->addHistory( + subscriber: $closureData['subscriber'], + message: 'Auto Unsubscribed', + details: 'User auto unsubscribed for bounce rule '.$closureData['ruleId'] + ); + } + } +} diff --git a/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php b/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php new file mode 100644 index 00000000..6b90cb49 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php @@ -0,0 +1,11 @@ +subscriberHistoryManager = $subscriberHistoryManager; + $this->subscriberManager = $subscriberManager; + $this->bounceManager = $bounceManager; + $this->subscriberRepository = $subscriberRepository; + } + + public function supports(string $action): bool + { + return $action === 'decreasecountconfirmuseranddeletebounce'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber'])) { + $this->subscriberManager->decrementBounceCount($closureData['subscriber']); + if (!$closureData['confirmed']) { + $this->subscriberRepository->markConfirmed($closureData['userId']); + $this->subscriberHistoryManager->addHistory( + subscriber: $closureData['subscriber'], + message: 'Auto confirmed', + details: 'Subscriber auto confirmed for bounce rule '.$closureData['ruleId'] + ); + } + } + $this->bounceManager->delete($closureData['bounce']); + } +} diff --git a/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php new file mode 100644 index 00000000..80c881a1 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php @@ -0,0 +1,27 @@ +bounceManager = $bounceManager; + } + + public function supports(string $action): bool + { + return $action === 'deletebounce'; + } + + public function handle(array $closureData): void + { + $this->bounceManager->delete($closureData['bounce']); + } +} diff --git a/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php b/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php new file mode 100644 index 00000000..d8887545 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php @@ -0,0 +1,33 @@ +bounceManager = $bounceManager; + $this->subscriberManager = $subscriberManager; + } + + public function supports(string $action): bool + { + return $action === 'deleteuserandbounce'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber'])) { + $this->subscriberManager->deleteSubscriber($closureData['subscriber']); + } + $this->bounceManager->delete($closureData['bounce']); + } +} diff --git a/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php b/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php new file mode 100644 index 00000000..64b1a073 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php @@ -0,0 +1,36 @@ +subscriberManager = $subscriberManager; + $this->logger = $logger; + } + + public function supports(string $action): bool + { + return $action === 'deleteuser'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber'])) { + $this->logger->info('User deleted by bounce rule', [ + 'user' => $closureData['subscriber']->getEmail(), + 'rule' => $closureData['ruleId'], + ]); + $this->subscriberManager->deleteSubscriber($closureData['subscriber']); + } + } +} diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php new file mode 100644 index 00000000..7ca39be8 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php @@ -0,0 +1,44 @@ +subscriberHistoryManager = $subscriberHistoryManager; + $this->subscriberRepository = $subscriberRepository; + $this->bounceManager = $bounceManager; + } + + public function supports(string $action): bool + { + return $action === 'unconfirmuseranddeletebounce'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber']) && $closureData['confirmed']) { + $this->subscriberRepository->markUnconfirmed($closureData['userId']); + $this->subscriberHistoryManager->addHistory( + subscriber: $closureData['subscriber'], + message: 'Auto unconfirmed', + details: 'Subscriber auto unconfirmed for bounce rule '.$closureData['ruleId'] + ); + } + $this->bounceManager->delete($closureData['bounce']); + } +} diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php new file mode 100644 index 00000000..a5bdd0fe --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php @@ -0,0 +1,39 @@ +subscriberRepository = $subscriberRepository; + $this->subscriberHistoryManager = $subscriberHistoryManager; + } + + public function supports(string $action): bool + { + return $action === 'unconfirmuser'; + } + + public function handle(array $closureData): void + { + if (!empty($closureData['subscriber']) && $closureData['confirmed']) { + $this->subscriberRepository->markUnconfirmed($closureData['userId']); + $this->subscriberHistoryManager->addHistory( + $closureData['subscriber'], + 'Auto Unconfirmed', + 'Subscriber auto unconfirmed for bounce rule '.$closureData['ruleId'] + ); + } + } +} diff --git a/src/Domain/Messaging/Service/LockService.php b/src/Domain/Messaging/Service/LockService.php new file mode 100644 index 00000000..d2f1eb34 --- /dev/null +++ b/src/Domain/Messaging/Service/LockService.php @@ -0,0 +1,172 @@ +repo = $repo; + $this->manager = $manager; + $this->logger = $logger; + $this->staleAfterSeconds = $staleAfterSeconds; + $this->sleepSeconds = $sleepSeconds; + $this->maxWaitCycles = $maxWaitCycles; + } + + /** + * @SuppressWarnings("BooleanArgumentFlag") + */ + public function acquirePageLock( + string $page, + bool $force = false, + bool $isCli = false, + bool $multiSend = false, + int $maxSendProcesses = 1, + ?string $clientIp = null, + ): ?int { + $page = $this->sanitizePage($page); + $max = $this->resolveMax($isCli, $multiSend, $maxSendProcesses); + + if ($force) { + $this->logger->info('Force set, killing other send processes (deleting lock rows).'); + $this->repo->deleteByPage($page); + } + + $waited = 0; + + while (true) { + $count = $this->repo->countAliveByPage($page); + $running = $this->manager->findNewestAliveWithAge($page); + + if ($count >= $max) { + if ($this->tryStealIfStale($running)) { + continue; + } + + $this->logAliveAge($running); + + if ($isCli) { + $this->logger->info("Running commandline, quitting. We'll find out what to do in the next run."); + + return null; + } + + if (!$this->waitOrGiveUp($waited)) { + $this->logger->info('We have been waiting too long, I guess the other process is still going ok'); + + return null; + } + + continue; + } + + $processIdentifier = $this->buildProcessIdentifier($isCli, $clientIp); + $sendProcess = $this->manager->create($page, $processIdentifier); + + return $sendProcess->getId(); + } + } + + public function keepLock(int $processId): void + { + $this->repo->incrementAlive($processId); + } + + public function checkLock(int $processId): int + { + return $this->repo->getAliveValue($processId); + } + + public function release(int $processId): void + { + $this->repo->markDeadById($processId); + } + + private function sanitizePage(string $page): string + { + $unicodeString = new UnicodeString($page); + $clean = preg_replace('/\W/', '', (string) $unicodeString); + + return $clean === '' ? 'default' : $clean; + } + + private function resolveMax(bool $isCli, bool $multiSend, int $maxSendProcesses): int + { + if (!$isCli) { + return 1; + } + return $multiSend ? \max(1, $maxSendProcesses) : 1; + } + + /** + * Returns true if it detected a stale process and killed it (so caller should loop again). + * + * @param array{id?: int, age?: int}|null $running + */ + private function tryStealIfStale(?array $running): bool + { + $age = (int)($running['age'] ?? 0); + if ($age > $this->staleAfterSeconds && isset($running['id'])) { + $this->repo->markDeadById((int)$running['id']); + + return true; + } + + return false; + } + + /** + * @param array{id?: int, age?: int}|null $running + */ + private function logAliveAge(?array $running): void + { + $age = (int)($running['age'] ?? 0); + $this->logger->info( + \sprintf( + 'A process for this page is already running and it was still alive %d seconds ago', + $age + ) + ); + } + + /** + * Sleeps once and increments $waited. Returns false if we exceeded max wait cycles. + */ + private function waitOrGiveUp(int &$waited): bool + { + $this->logger->info(\sprintf('Sleeping for %d seconds, aborting will quit', $this->sleepSeconds)); + \sleep($this->sleepSeconds); + $waited++; + return $waited <= $this->maxWaitCycles; + } + + private function buildProcessIdentifier(bool $isCli, ?string $clientIp): string + { + if ($isCli) { + $host = \php_uname('n') ?: 'localhost'; + return $host . ':' . \getmypid(); + } + return $clientIp ?? '0.0.0.0'; + } +} diff --git a/src/Domain/Messaging/Service/Manager/BounceManager.php b/src/Domain/Messaging/Service/Manager/BounceManager.php new file mode 100644 index 00000000..f13c46ff --- /dev/null +++ b/src/Domain/Messaging/Service/Manager/BounceManager.php @@ -0,0 +1,138 @@ +bounceRepository = $bounceRepository; + $this->userMessageBounceRepo = $userMessageBounceRepo; + $this->entityManager = $entityManager; + $this->logger = $logger; + } + + public function create( + ?DateTimeImmutable $date = null, + ?string $header = null, + ?string $data = null, + ?string $status = null, + ?string $comment = null + ): Bounce { + $bounce = new Bounce( + date: new DateTime($date->format('Y-m-d H:i:s')), + header: $header, + data: $data, + status: $status, + comment: $comment + ); + + $this->bounceRepository->save($bounce); + + return $bounce; + } + + public function update(Bounce $bounce, ?string $status = null, ?string $comment = null): Bounce + { + $bounce->setStatus($status); + $bounce->setComment($comment); + $this->bounceRepository->save($bounce); + + return $bounce; + } + + public function delete(Bounce $bounce): void + { + $this->bounceRepository->remove($bounce); + } + + /** @return Bounce[] */ + public function getAll(): array + { + return $this->bounceRepository->findAll(); + } + + public function getById(int $id): ?Bounce + { + /** @var Bounce|null $found */ + $found = $this->bounceRepository->find($id); + return $found; + } + + public function linkUserMessageBounce( + Bounce $bounce, + DateTimeImmutable $date, + int $subscriberId, + ?int $messageId = -1 + ): UserMessageBounce { + $userMessageBounce = new UserMessageBounce($bounce->getId(), new DateTime($date->format('Y-m-d H:i:s'))); + $userMessageBounce->setUserId($subscriberId); + $userMessageBounce->setMessageId($messageId); + $this->entityManager->flush(); + + return $userMessageBounce; + } + + public function existsUserMessageBounce(int $subscriberId, int $messageId): bool + { + return $this->userMessageBounceRepo->existsByMessageIdAndUserId($messageId, $subscriberId); + } + + /** @return Bounce[] */ + public function findByStatus(string $status): array + { + return $this->bounceRepository->findByStatus($status); + } + + public function getUserMessageBounceCount(): int + { + return $this->userMessageBounceRepo->count(); + } + + /** + * @return array + */ + public function fetchUserMessageBounceBatch(int $fromId, int $batchSize): array + { + return $this->userMessageBounceRepo->getPaginatedWithJoinNoRelation($fromId, $batchSize); + } + + /** + * @return array + */ + public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array + { + return $this->userMessageBounceRepo->getUserMessageHistoryWithBounces($subscriber); + } + + public function announceDeletionMode(bool $testMode): void + { + $message = $testMode ? self::TEST_MODE_MESSAGE : self::LIVE_MODE_MESSAGE; + $this->logger->info($message); + } +} diff --git a/src/Domain/Messaging/Service/Manager/BounceRuleManager.php b/src/Domain/Messaging/Service/Manager/BounceRuleManager.php new file mode 100644 index 00000000..70a750a9 --- /dev/null +++ b/src/Domain/Messaging/Service/Manager/BounceRuleManager.php @@ -0,0 +1,110 @@ + + */ + public function loadActiveRules(): array + { + return $this->mapRows($this->repository->fetchActiveOrdered()); + } + + /** + * @return array + */ + public function loadAllRules(): array + { + return $this->mapRows($this->repository->fetchAllOrdered()); + } + + /** + * Internal helper to normalize repository rows into the legacy shape. + * + * @param BounceRegex[] $rows + * @return array + */ + private function mapRows(array $rows): array + { + $result = []; + + foreach ($rows as $row) { + $regex = $row->getRegex(); + $action = $row->getAction(); + $id = $row->getId(); + + if (!is_string($regex) + || $regex === '' + || !is_string($action) + || $action === '' + || !is_int($id) + ) { + continue; + } + + $result[$regex] = $row; + } + + return $result; + } + + + /** + * @param array $rules + */ + public function matchBounceRules(string $text, array $rules): ?BounceRegex + { + foreach ($rules as $pattern => $rule) { + $quoted = '/'.preg_quote(str_replace(' ', '\s+', $pattern)).'/iUm'; + if ($this->safePregMatch($quoted, $text)) { + return $rule; + } + $raw = '/'.str_replace(' ', '\s+', $pattern).'/iUm'; + if ($this->safePregMatch($raw, $text)) { + return $rule; + } + } + + return null; + } + + private function safePregMatch(string $pattern, string $subject): bool + { + set_error_handler(static fn() => true); + $result = preg_match($pattern, $subject) === 1; + restore_error_handler(); + + return $result; + } + + public function incrementCount(BounceRegex $rule): void + { + $rule->setCount($rule->getCount() + 1); + + $this->repository->save($rule); + } + + public function linkRuleToBounce(BounceRegex $rule, Bounce $bounce): BounceregexBounce + { + $relation = new BounceRegexBounce($rule->getId(), $bounce->getId()); + $this->bounceRelationRepository->save($relation); + + return $relation; + } +} diff --git a/src/Domain/Messaging/Service/Manager/SendProcessManager.php b/src/Domain/Messaging/Service/Manager/SendProcessManager.php new file mode 100644 index 00000000..0100ed29 --- /dev/null +++ b/src/Domain/Messaging/Service/Manager/SendProcessManager.php @@ -0,0 +1,57 @@ +repository = $repository; + $this->entityManager = $entityManager; + } + + public function create(string $page, string $processIdentifier): SendProcess + { + $sendProcess = new SendProcess(); + $sendProcess->setStartedDate(new DateTime('now')); + $sendProcess->setAlive(1); + $sendProcess->setIpaddress($processIdentifier); + $sendProcess->setPage($page); + + $this->entityManager->persist($sendProcess); + $this->entityManager->flush(); + + return $sendProcess; + } + + + /** + * @return array{id:int, age:int}|null + */ + public function findNewestAliveWithAge(string $page): ?array + { + $row = $this->repository->findNewestAlive($page); + + if (!$row instanceof SendProcess) { + return null; + } + + $modified = $row->getUpdatedAt(); + $age = $modified ? max(0, time() - (int)$modified->format('U')) : 0; + + return [ + 'id' => $row->getId(), + 'age' => $age, + ]; + } +} diff --git a/src/Domain/Messaging/Service/MessageParser.php b/src/Domain/Messaging/Service/MessageParser.php new file mode 100644 index 00000000..14b4f952 --- /dev/null +++ b/src/Domain/Messaging/Service/MessageParser.php @@ -0,0 +1,102 @@ +subscriberRepository = $subscriberRepository; + } + + public function decodeBody(string $header, string $body): string + { + $transferEncoding = ''; + if (preg_match('/Content-Transfer-Encoding: ([\w-]+)/i', $header, $regs)) { + $transferEncoding = strtolower($regs[1]); + } + + return match ($transferEncoding) { + 'quoted-printable' => quoted_printable_decode($body), + 'base64' => base64_decode($body) ?: '', + default => $body, + }; + } + + public function findMessageId(string $text): ?string + { + if (preg_match('/(?:X-MessageId|X-Message): (.*)\r\n/iU', $text, $match)) { + return trim($match[1]); + } + + return null; + } + + public function findUserId(string $text): ?int + { + $candidate = $this->extractUserHeader($text); + if ($candidate) { + $id = $this->resolveUserIdentifier($candidate); + if ($id) { + return $id; + } + } + + $emails = $this->extractEmails($text); + + return $this->findFirstSubscriberId($emails); + } + + private function extractUserHeader(string $text): ?string + { + if (preg_match('/^(?:X-ListMember|X-User):\s*(?P[^\r\n]+)/mi', $text, $matches)) { + $user = trim($matches['user']); + + return $user !== '' ? $user : null; + } + + return null; + } + + private function resolveUserIdentifier(string $user): ?int + { + if (filter_var($user, FILTER_VALIDATE_EMAIL)) { + return $this->subscriberRepository->findOneByEmail($user)?->getId(); + } + + if (ctype_digit($user)) { + return (int) $user; + } + + return $this->subscriberRepository->findOneByEmail($user)?->getId(); + } + + private function extractEmails(string $text): array + { + preg_match_all('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+/i', $text, $matches); + if (empty($matches[0])) { + return []; + } + $norm = array_map('strtolower', $matches[0]); + + return array_values(array_unique($norm)); + } + + private function findFirstSubscriberId(array $emails): ?int + { + foreach ($emails as $email) { + $id = $this->subscriberRepository->findOneByEmail($email)?->getId(); + if ($id !== null) { + return $id; + } + } + + return null; + } +} diff --git a/src/Domain/Messaging/Service/NativeBounceProcessingService.php b/src/Domain/Messaging/Service/NativeBounceProcessingService.php new file mode 100644 index 00000000..eee5bb98 --- /dev/null +++ b/src/Domain/Messaging/Service/NativeBounceProcessingService.php @@ -0,0 +1,138 @@ +bounceManager = $bounceManager; + $this->mailReader = $mailReader; + $this->messageParser = $messageParser; + $this->bounceDataProcessor = $bounceDataProcessor; + $this->logger = $logger; + $this->purgeProcessed = $purgeProcessed; + $this->purgeUnprocessed = $purgeUnprocessed; + } + + public function processMailbox( + string $mailbox, + int $max, + bool $testMode + ): string { + $link = $this->openOrFail($mailbox, $testMode); + + $num = $this->prepareAndCapCount($link, $max); + if ($num === 0) { + $this->mailReader->close($link, false); + + return ''; + } + + $this->bounceManager->announceDeletionMode($testMode); + + for ($messageNumber = 1; $messageNumber <= $num; $messageNumber++) { + $this->handleMessage($link, $messageNumber, $testMode); + } + + $this->finalize($link, $testMode); + + return ''; + } + + private function openOrFail(string $mailbox, bool $testMode): Connection + { + try { + return $this->mailReader->open($mailbox, $testMode ? 0 : CL_EXPUNGE); + } catch (Throwable $e) { + $this->logger->error('Cannot open mailbox file: '.$e->getMessage()); + throw new RuntimeException('Cannot open mbox file'); + } + } + + private function prepareAndCapCount(Connection $link, int $max): int + { + $num = $this->mailReader->numMessages($link); + $this->logger->info(sprintf('%d bounces to fetch from the mailbox', $num)); + if ($num === 0) { + return 0; + } + + $this->logger->info('Please do not interrupt this process'); + if ($num > $max) { + $this->logger->info(sprintf('Processing first %d bounces', $max)); + $num = $max; + } + + return $num; + } + + private function handleMessage(Connection $link, int $messageNumber, bool $testMode): void + { + $header = $this->mailReader->fetchHeader($link, $messageNumber); + $processed = $this->processImapBounce($link, $messageNumber, $header); + + if ($testMode) { + return; + } + + if ($processed && $this->purgeProcessed) { + $this->mailReader->delete($link, $messageNumber); + return; + } + + if (!$processed && $this->purgeUnprocessed) { + $this->mailReader->delete($link, $messageNumber); + } + } + + private function finalize(Connection $link, bool $testMode): void + { + $this->logger->info('Closing mailbox, and purging messages'); + $this->mailReader->close($link, !$testMode); + } + + private function processImapBounce($link, int $num, string $header): bool + { + $bounceDate = $this->mailReader->headerDate($link, $num); + $body = $this->mailReader->body($link, $num); + $body = $this->messageParser->decodeBody($header, $body); + + // Quick hack: ignore MsExchange delayed notices (as in original) + if (preg_match('/Action: delayed\s+Status: 4\.4\.7/im', $body)) { + return true; + } + + $msgId = $this->messageParser->findMessageId($body); + $userId = $this->messageParser->findUserId($body); + + $bounce = $this->bounceManager->create($bounceDate, $header, $body); + + return $this->bounceDataProcessor->process($bounce, $msgId, $userId, $bounceDate); + } +} diff --git a/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php new file mode 100644 index 00000000..568bf874 --- /dev/null +++ b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php @@ -0,0 +1,120 @@ +section('Processing bounces based on active bounce rules'); + + $rules = $this->ruleManager->loadActiveRules(); + if (!$rules) { + $io->writeln('No active rules'); + return; + } + + $total = $this->bounceManager->getUserMessageBounceCount(); + $fromId = 0; + $matched = 0; + $notMatched = 0; + $processed = 0; + + while ($processed < $total) { + $batch = $this->bounceManager->fetchUserMessageBounceBatch($fromId, $batchSize); + if (!$batch) { + break; + } + + foreach ($batch as $row) { + $fromId = $row['umb']->getId(); + + $bounce = $row['bounce']; + $userId = (int) $row['umb']->getUserId(); + $text = $this->composeText($bounce); + $rule = $this->ruleManager->matchBounceRules($text, $rules); + + if ($rule) { + $this->incrementRuleCounters($rule, $bounce); + + $subscriber = $userId ? $this->subscriberManager->getSubscriberById($userId) : null; + $ctx = $this->makeContext($subscriber, $bounce, (int)$rule->getId()); + + $action = (string) $rule->getAction(); + $this->actionResolver->handle($action, $ctx); + + $matched++; + } else { + $notMatched++; + } + + $processed++; + } + + $io->writeln(sprintf( + 'processed %d out of %d bounces for advanced bounce rules', + min($processed, $total), + $total + )); + } + + $io->writeln(sprintf('%d bounces processed by advanced processing', $matched)); + $io->writeln(sprintf('%d bounces were not matched by advanced processing rules', $notMatched)); + } + + private function composeText(Bounce $bounce): string + { + return $bounce->getHeader() . "\n\n" . $bounce->getData(); + } + + private function incrementRuleCounters($rule, Bounce $bounce): void + { + $this->ruleManager->incrementCount($rule); + $rule->setCount($rule->getCount() + 1); + $this->ruleManager->linkRuleToBounce($rule, $bounce); + } + + /** + * @return array{ + * subscriber: ?Subscriber, + * bounce: Bounce, + * userId: int, + * confirmed: bool, + * blacklisted: bool, + * ruleId: int + * } + */ + private function makeContext(?Subscriber $subscriber, Bounce $bounce, int $ruleId): array + { + $userId = $subscriber?->getId() ?? 0; + $confirmed = $subscriber?->isConfirmed() ?? false; + $blacklisted = $subscriber?->isBlacklisted() ?? false; + + return [ + 'subscriber' => $subscriber, + 'bounce' => $bounce, + 'userId' => $userId, + 'confirmed' => $confirmed, + 'blacklisted' => $blacklisted, + 'ruleId' => $ruleId, + ]; + } +} diff --git a/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php new file mode 100644 index 00000000..6f502a8c --- /dev/null +++ b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php @@ -0,0 +1,155 @@ +bounceManager = $bounceManager; + $this->subscriberRepository = $subscriberRepository; + $this->messageRepository = $messageRepository; + $this->logger = $logger; + $this->subscriberManager = $subscriberManager; + $this->subscriberHistoryManager = $subscriberHistoryManager; + } + + public function process(Bounce $bounce, ?string $msgId, ?int $userId, DateTimeImmutable $bounceDate): bool + { + $user = $userId ? $this->subscriberManager->getSubscriberById($userId) : null; + + if ($msgId === 'systemmessage') { + return $userId ? $this->handleSystemMessageWithUser( + $bounce, + $bounceDate, + $userId, + $user + ) : $this->handleSystemMessageUnknownUser($bounce); + } + + if ($msgId && $userId) { + return $this->handleKnownMessageAndUser($bounce, $bounceDate, (int)$msgId, $userId); + } + + if ($userId) { + return $this->handleUserOnly($bounce, $userId); + } + + if ($msgId) { + return $this->handleMessageOnly($bounce, (int)$msgId); + } + + $this->bounceManager->update($bounce, 'unidentified bounce', 'not processed'); + + return false; + } + + private function handleSystemMessageWithUser( + Bounce $bounce, + DateTimeImmutable $date, + int $userId, + $userOrNull + ): bool { + $this->bounceManager->update( + bounce: $bounce, + status: 'bounced system message', + comment: sprintf('%d marked unconfirmed', $userId) + ); + $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId); + $this->subscriberRepository->markUnconfirmed($userId); + $this->logger->info('system message bounced, user marked unconfirmed', ['userId' => $userId]); + + if ($userOrNull) { + $this->subscriberHistoryManager->addHistory( + subscriber: $userOrNull, + message: 'Bounced system message', + details: sprintf('User marked unconfirmed. Bounce #%d', $bounce->getId()) + ); + } + + return true; + } + + private function handleSystemMessageUnknownUser(Bounce $bounce): bool + { + $this->bounceManager->update($bounce, 'bounced system message', 'unknown user'); + $this->logger->info('system message bounced, but unknown user'); + + return true; + } + + private function handleKnownMessageAndUser( + Bounce $bounce, + DateTimeImmutable $date, + int $msgId, + int $userId + ): bool { + if (!$this->bounceManager->existsUserMessageBounce($userId, $msgId)) { + $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId, $msgId); + $this->bounceManager->update( + bounce: $bounce, + status: sprintf('bounced list message %d', $msgId), + comment: sprintf('%d bouncecount increased', $userId) + ); + $this->messageRepository->incrementBounceCount($msgId); + $this->subscriberRepository->incrementBounceCount($userId); + } else { + $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId, $msgId); + $this->bounceManager->update( + bounce: $bounce, + status: sprintf('duplicate bounce for %d', $userId), + comment: sprintf('duplicate bounce for subscriber %d on message %d', $userId, $msgId) + ); + } + + return true; + } + + private function handleUserOnly(Bounce $bounce, int $userId): bool + { + $this->bounceManager->update( + bounce: $bounce, + status: 'bounced unidentified message', + comment: sprintf('%d bouncecount increased', $userId) + ); + $this->subscriberRepository->incrementBounceCount($userId); + + return true; + } + + private function handleMessageOnly(Bounce $bounce, int $msgId): bool + { + $this->bounceManager->update( + bounce: $bounce, + status: sprintf('bounced list message %d', $msgId), + comment: 'unknown user' + ); + $this->messageRepository->incrementBounceCount($msgId); + + return true; + } +} diff --git a/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php b/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php new file mode 100644 index 00000000..a0e7d904 --- /dev/null +++ b/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php @@ -0,0 +1,24 @@ +processingService = $processingService; + } + + public function getProtocol(): string + { + return 'mbox'; + } + + public function process(InputInterface $input, SymfonyStyle $inputOutput): string + { + $testMode = (bool)$input->getOption('test'); + $max = (int)$input->getOption('maximum'); + + $file = (string)$input->getOption('mailbox'); + if (!$file) { + $inputOutput->error('mbox file path must be provided with --mailbox.'); + throw new RuntimeException('Missing --mailbox for mbox protocol'); + } + + $inputOutput->section('Opening mbox ' . $file); + $inputOutput->writeln('Please do not interrupt this process'); + + return $this->processingService->processMailbox( + mailbox: $file, + max: $max, + testMode: $testMode + ); + } +} diff --git a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php new file mode 100644 index 00000000..b6f59f65 --- /dev/null +++ b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php @@ -0,0 +1,59 @@ +processingService = $processingService; + $this->host = $host; + $this->port = $port; + $this->mailboxNames = $mailboxNames; + } + + public function getProtocol(): string + { + return 'pop'; + } + + public function process(InputInterface $input, SymfonyStyle $inputOutput): string + { + $testMode = (bool)$input->getOption('test'); + $max = (int)$input->getOption('maximum'); + + $downloadReport = ''; + foreach (explode(',', $this->mailboxNames) as $mailboxName) { + $mailboxName = trim($mailboxName); + if ($mailboxName === '') { + $mailboxName = 'INBOX'; + } + $mailbox = sprintf('{%s:%s}%s', $this->host, $this->port, $mailboxName); + $inputOutput->section('Connecting to ' . $mailbox); + $inputOutput->writeln('Please do not interrupt this process'); + + $downloadReport .= $this->processingService->processMailbox( + mailbox: $mailbox, + max: $max, + testMode: $testMode + ); + } + + return $downloadReport; + } +} diff --git a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php new file mode 100644 index 00000000..503fc459 --- /dev/null +++ b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php @@ -0,0 +1,70 @@ +bounceManager = $bounceManager; + $this->messageParser = $messageParser; + $this->bounceDataProcessor = $bounceDataProcessor; + } + + public function process(SymfonyStyle $inputOutput): void + { + $inputOutput->section('Reprocessing unidentified bounces'); + $bounces = $this->bounceManager->findByStatus('unidentified bounce'); + $total = count($bounces); + $inputOutput->writeln(sprintf('%d bounces to reprocess', $total)); + + $count = 0; + $reparsed = 0; + $reidentified = 0; + foreach ($bounces as $bounce) { + $count++; + if ($count % 25 === 0) { + $inputOutput->writeln(sprintf('%d out of %d processed', $count, $total)); + } + + $decodedBody = $this->messageParser->decodeBody($bounce->getHeader(), $bounce->getData()); + $userId = $this->messageParser->findUserId($decodedBody); + $messageId = $this->messageParser->findMessageId($decodedBody); + + if ($userId || $messageId) { + $reparsed++; + if ($this->bounceDataProcessor->process( + $bounce, + $messageId, + $userId, + new DateTimeImmutable() + ) + ) { + $reidentified++; + } + } + } + + $inputOutput->writeln(sprintf('%d out of %d processed', $count, $total)); + $inputOutput->writeln(sprintf( + '%d bounces were re-processed and %d bounces were re-identified', + $reparsed, + $reidentified + )); + } +} diff --git a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php new file mode 100644 index 00000000..01a94aff --- /dev/null +++ b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php @@ -0,0 +1,268 @@ +bounceManager = $bounceManager; + $this->logger = $logger; + $this->messageParser = $messageParser; + $this->clientFactory = $clientFactory; + $this->bounceDataProcessor = $bounceDataProcessor; + $this->purgeProcessed = $purgeProcessed; + $this->purgeUnprocessed = $purgeUnprocessed; + } + + /** + * Process unseen messages from the given mailbox using Webklex. + * + * $mailbox: IMAP host; if you pass "host#FOLDER", FOLDER will be used instead of INBOX. + * + * @throws RuntimeException If connection to the IMAP server cannot be established. + */ + public function processMailbox( + string $mailbox, + int $max, + bool $testMode + ): string { + $client = $this->clientFactory->makeForMailbox(); + + try { + $client->connect(); + } catch (Throwable $e) { + $this->logger->error('Cannot connect to mailbox: '.$e->getMessage()); + throw new RuntimeException('Cannot connect to IMAP server'); + } + + try { + $folder = $client->getFolder($this->clientFactory->getFolderName()); + $query = $folder->query()->unseen()->limit($max); + + $messages = $query->get(); + $num = $messages->count(); + + $this->logger->info(sprintf('%d bounces to fetch from the mailbox', $num)); + if ($num === 0) { + return ''; + } + + $this->bounceManager->announceDeletionMode($testMode); + + foreach ($messages as $message) { + $header = $this->headerToStringSafe($message); + $body = $this->bodyBestEffort($message); + $body = $this->messageParser->decodeBody($header, $body); + + if (\preg_match('/Action: delayed\s+Status: 4\.4\.7/im', $body)) { + if (!$testMode && $this->purgeProcessed) { + $this->safeDelete($message); + } + continue; + } + + $messageId = $this->messageParser->findMessageId($body."\r\n".$header); + $userId = $this->messageParser->findUserId($body."\r\n".$header); + + $bounceDate = $this->extractDate($message); + $bounce = $this->bounceManager->create($bounceDate, $header, $body); + + $processed = $this->bounceDataProcessor->process($bounce, $messageId, $userId, $bounceDate); + + $this->processDelete($testMode, $processed, $message); + } + + $this->logger->info('Closing mailbox, and purging messages'); + $this->processExpunge($testMode, $folder, $client); + + return ''; + } finally { + try { + $client->disconnect(); + } catch (Throwable $e) { + $this->logger->warning('Disconnect failed', ['error' => $e->getMessage()]); + } + } + } + + private function headerToStringSafe(mixed $message): string + { + $raw = $this->tryRawHeader($message); + if ($raw !== null) { + return $raw; + } + + $lines = []; + $subj = $message->getSubject() ?? ''; + $from = $this->addrFirstToString($message->getFrom()); + $messageTo = $this->addrManyToString($message->getTo()); + $date = $this->extractDate($message)->format(\DATE_RFC2822); + + if ($subj !== '') { + $lines[] = 'Subject: ' . $subj; + } + if ($from !== '') { + $lines[] = 'From: ' . $from; + } + if ($messageTo !== '') { + $lines[] = 'To: ' . $messageTo; + } + $lines[] = 'Date: ' . $date; + + $mid = $message->getMessageId() ?? ''; + if ($mid !== '') { + $lines[] = 'Message-ID: ' . $mid; + } + + return implode("\r\n", $lines) . "\r\n"; + } + + private function tryRawHeader(mixed $message): ?string + { + if (!method_exists($message, 'getHeader')) { + return null; + } + + try { + $headerObj = $message->getHeader(); + if ($headerObj && method_exists($headerObj, 'toString')) { + $raw = (string) $headerObj->toString(); + if ($raw !== '') { + return $raw; + } + } + } catch (Throwable $e) { + return null; + } + + return null; + } + + private function bodyBestEffort($message): string + { + $text = ($message->getTextBody() ?? ''); + if ($text !== '') { + return $text; + } + $html = ($message->getHTMLBody() ?? ''); + if ($html !== '') { + return trim(strip_tags($html)); + } + + return ''; + } + + private function extractDate(mixed $message): DateTimeImmutable + { + $date = $message->getDate(); + if ($date instanceof DateTimeInterface) { + return new DateTimeImmutable($date->format('Y-m-d H:i:s')); + } + + if (method_exists($message, 'getInternalDate')) { + $internalDate = (int) $message->getInternalDate(); + if ($internalDate > 0) { + return new DateTimeImmutable('@'.$internalDate); + } + } + + return new DateTimeImmutable(); + } + + private function addrFirstToString($addresses): string + { + $many = $this->addrManyToArray($addresses); + return $many[0] ?? ''; + } + + private function addrManyToString($addresses): string + { + $arr = $this->addrManyToArray($addresses); + return implode(', ', $arr); + } + + private function addrManyToArray($addresses): array + { + if ($addresses === null) { + return []; + } + $out = []; + foreach ($addresses as $addr) { + $email = ($addr->mail ?? $addr->getAddress() ?? ''); + $name = ($addr->personal ?? $addr->getName() ?? ''); + $out[] = $name !== '' ? sprintf('%s <%s>', $name, $email) : $email; + } + + return $out; + } + + private function processDelete(bool $testMode, bool $processed, mixed $message): void + { + if (!$testMode) { + if ($processed && $this->purgeProcessed) { + $this->safeDelete($message); + } elseif (!$processed && $this->purgeUnprocessed) { + $this->safeDelete($message); + } + } + } + + private function safeDelete($message): void + { + try { + if (method_exists($message, 'delete')) { + $message->delete(); + } elseif (method_exists($message, 'setFlag')) { + $message->setFlag('DELETED'); + } + } catch (Throwable $e) { + $this->logger->warning('Failed to delete message', ['error' => $e->getMessage()]); + } + } + + private function processExpunge(bool $testMode, ?Folder $folder, Client $client): void + { + if (!$testMode) { + try { + if (method_exists($folder, 'expunge')) { + $folder->expunge(); + } elseif (method_exists($client, 'expunge')) { + $client->expunge(); + } + } catch (Throwable $e) { + $this->logger->warning('EXPUNGE failed', ['error' => $e->getMessage()]); + } + } + } +} diff --git a/src/Domain/Messaging/Service/WebklexImapClientFactory.php b/src/Domain/Messaging/Service/WebklexImapClientFactory.php new file mode 100644 index 00000000..10271e4c --- /dev/null +++ b/src/Domain/Messaging/Service/WebklexImapClientFactory.php @@ -0,0 +1,79 @@ +clientManager = $clientManager; + $this->mailbox = $mailbox; + $this->host = $host; + $this->username = $username; + $this->password = $password; + $this->protocol = $protocol; + $this->port = $port; + $this->encryption = $encryption; + } + + /** + * @param array $config + * @throws MaskNotFoundException + */ + public function make(array $config): Client + { + return $this->clientManager->make($config); + } + + public function makeForMailbox(): Client + { + return $this->make([ + 'host' => $this->host, + 'port' => $this->port, + 'encryption' => $this->encryption, + 'validate_cert' => true, + 'username' => $this->username, + 'password' => $this->password, + 'protocol' => $this->protocol, + ]); + } + + public function getFolderName(): string + { + return $this->parseMailbox($this->mailbox)[1]; + } + + private function parseMailbox(string $mailbox): array + { + if (str_contains($mailbox, '#')) { + [$host, $folder] = explode('#', $mailbox, 2); + $host = trim($host); + $folder = trim($folder) ?: 'INBOX'; + return [$host, $folder]; + } + return [trim($mailbox), 'INBOX']; + } +} diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index 6ebaee70..3c3583b4 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -141,4 +141,51 @@ public function isEmailBlacklisted(string $email): bool return !($queryBuilder->getQuery()->getOneOrNullResult() === null); } + + public function incrementBounceCount(int $subscriberId): void + { + $this->createQueryBuilder('s') + ->update() + ->set('s.bounceCount', 's.bounceCount + 1') + ->where('s.id = :subscriberId') + ->setParameter('subscriberId', $subscriberId) + ->getQuery() + ->execute(); + } + + public function markUnconfirmed(int $subscriberId): void + { + $this->createQueryBuilder('s') + ->update() + ->set('s.confirmed', ':confirmed') + ->where('s.id = :id') + ->setParameter('confirmed', false) + ->setParameter('id', $subscriberId) + ->getQuery() + ->execute(); + } + + public function markConfirmed(int $subscriberId): void + { + $this->createQueryBuilder('s') + ->update() + ->set('s.confirmed', ':confirmed') + ->where('s.id = :id') + ->setParameter('confirmed', true) + ->setParameter('id', $subscriberId) + ->getQuery() + ->execute(); + } + + /** @return Subscriber[] */ + public function distinctUsersWithBouncesConfirmedNotBlacklisted(): array + { + return $this->createQueryBuilder('s') + ->select('s.id') + ->where('s.bounceCount > 0') + ->andWhere('s.confirmed = 1') + ->andWhere('s.blacklisted = 0') + ->getQuery() + ->getScalarResult(); + } } diff --git a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php index d30bae2d..d5828c2f 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php @@ -58,6 +58,16 @@ public function addEmailToBlacklist(string $email, ?string $reasonData = null): return $blacklistEntry; } + public function addBlacklistData(string $email, string $name, string $data): void + { + $blacklistData = new UserBlacklistData(); + $blacklistData->setEmail($email); + $blacklistData->setName($name); + $blacklistData->setData($data); + $this->entityManager->persist($blacklistData); + $this->entityManager->flush(); + } + public function removeEmailFromBlacklist(string $email): void { $blacklistEntry = $this->userBlacklistRepository->findOneByEmail($email); diff --git a/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php b/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php index 4760acd8..bac2ef8d 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php @@ -4,20 +4,44 @@ namespace PhpList\Core\Domain\Subscription\Service\Manager; +use PhpList\Core\Domain\Common\ClientIpResolver; +use PhpList\Core\Domain\Common\SystemInfoCollector; use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberHistoryFilter; +use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Model\SubscriberHistory; use PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository; class SubscriberHistoryManager { private SubscriberHistoryRepository $repository; + private ClientIpResolver $clientIpResolver; + private SystemInfoCollector $systemInfoCollector; - public function __construct(SubscriberHistoryRepository $repository) - { + public function __construct( + SubscriberHistoryRepository $repository, + ClientIpResolver $clientIpResolver, + SystemInfoCollector $systemInfoCollector, + ) { $this->repository = $repository; + $this->clientIpResolver = $clientIpResolver; + $this->systemInfoCollector = $systemInfoCollector; } public function getHistory(int $lastId, int $limit, SubscriberHistoryFilter $filter): array { return $this->repository->getFilteredAfterId($lastId, $limit, $filter); } + + public function addHistory(Subscriber $subscriber, string $message, ?string $details = null): SubscriberHistory + { + $subscriberHistory = new SubscriberHistory($subscriber); + $subscriberHistory->setSummary($message); + $subscriberHistory->setDetail($details ?? $message); + $subscriberHistory->setSystemInfo($this->systemInfoCollector->collectAsString()); + $subscriberHistory->setIp($this->clientIpResolver->resolve()); + + $this->repository->save($subscriberHistory); + + return $subscriberHistory; + } } diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php index e036f195..73531fbb 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php @@ -11,6 +11,7 @@ use PhpList\Core\Domain\Subscription\Model\Dto\UpdateSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; +use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Messenger\MessageBusInterface; @@ -26,7 +27,7 @@ public function __construct( SubscriberRepository $subscriberRepository, EntityManagerInterface $entityManager, MessageBusInterface $messageBus, - SubscriberDeletionService $subscriberDeletionService + SubscriberDeletionService $subscriberDeletionService, ) { $this->subscriberRepository = $subscriberRepository; $this->entityManager = $entityManager; @@ -64,15 +65,9 @@ private function sendConfirmationEmail(Subscriber $subscriber): void $this->messageBus->dispatch($message); } - public function getSubscriber(int $subscriberId): Subscriber + public function getSubscriberById(int $subscriberId): ?Subscriber { - $subscriber = $this->subscriberRepository->findSubscriberWithSubscriptions($subscriberId); - - if (!$subscriber) { - throw new NotFoundHttpException('Subscriber not found'); - } - - return $subscriber; + return $this->subscriberRepository->find($subscriberId); } public function updateSubscriber(UpdateSubscriberDto $subscriberDto): Subscriber @@ -140,4 +135,10 @@ public function updateFromImport(Subscriber $existingSubscriber, ImportSubscribe return $existingSubscriber; } + + public function decrementBounceCount(Subscriber $subscriber): void + { + $subscriber->addToBounceCount(-1); + $this->entityManager->flush(); + } } diff --git a/src/Domain/Subscription/Service/SubscriberBlacklistService.php b/src/Domain/Subscription/Service/SubscriberBlacklistService.php new file mode 100644 index 00000000..d9ca5ea6 --- /dev/null +++ b/src/Domain/Subscription/Service/SubscriberBlacklistService.php @@ -0,0 +1,69 @@ +entityManager = $entityManager; + $this->blacklistManager = $blacklistManager; + $this->historyManager = $historyManager; + $this->requestStack = $requestStack; + } + + /** + * @SuppressWarnings(PHPMD.Superglobals) + */ + public function blacklist(Subscriber $subscriber, string $reason): void + { + $subscriber->setBlacklisted(true); + $this->entityManager->flush(); + $this->blacklistManager->addEmailToBlacklist($subscriber->getEmail(), $reason); + + foreach (['REMOTE_ADDR','HTTP_X_FORWARDED_FOR'] as $item) { + $request = $this->requestStack->getCurrentRequest(); + if (!$request) { + return; + } + if ($request->server->get($item)) { + $this->blacklistManager->addBlacklistData( + email: $subscriber->getEmail(), + name: $item, + data: $request->server->get($item) + ); + } + } + + $this->historyManager->addHistory( + subscriber: $subscriber, + message: 'Added to blacklist', + details: sprintf('Added to blacklist for reason %s', $reason) + ); + + if (isset($GLOBALS['plugins']) && is_array($GLOBALS['plugins'])) { + foreach ($GLOBALS['plugins'] as $plugin) { + if (method_exists($plugin, 'blacklistEmail')) { + $plugin->blacklistEmail($subscriber->getEmail(), $reason); + } + } + } + } +} diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php index e6d42236..b3bfda0c 100644 --- a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php +++ b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Tests\Integration\Domain\Subscription\Service; +use DateTime; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Tools\SchemaTool; use Exception; @@ -94,7 +95,7 @@ public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError(): $userMessage->setStatus('sent'); $this->entityManager->persist($userMessage); - $userMessageBounce = new UserMessageBounce(1); + $userMessageBounce = new UserMessageBounce(1, new DateTime()); $userMessageBounce->setUserId($subscriberId); $userMessageBounce->setMessageId(1); $this->entityManager->persist($userMessageBounce); diff --git a/tests/Unit/Domain/Common/ClientIpResolverTest.php b/tests/Unit/Domain/Common/ClientIpResolverTest.php new file mode 100644 index 00000000..e69e9f89 --- /dev/null +++ b/tests/Unit/Domain/Common/ClientIpResolverTest.php @@ -0,0 +1,61 @@ +requestStack = $this->createMock(RequestStack::class); + } + + public function testResolveReturnsClientIpFromCurrentRequest(): void + { + $request = $this->createMock(Request::class); + $request->method('getClientIp')->willReturn('203.0.113.10'); + + $this->requestStack + ->method('getCurrentRequest') + ->willReturn($request); + + $resolver = new ClientIpResolver($this->requestStack); + $this->assertSame('203.0.113.10', $resolver->resolve()); + } + + public function testResolveReturnsEmptyStringWhenClientIpIsNull(): void + { + $request = $this->createMock(Request::class); + $request->method('getClientIp')->willReturn(null); + + $this->requestStack + ->method('getCurrentRequest') + ->willReturn($request); + + $resolver = new ClientIpResolver($this->requestStack); + $this->assertSame('', $resolver->resolve()); + } + + public function testResolveReturnsHostAndPidWhenNoRequestAvailable(): void + { + $this->requestStack + ->method('getCurrentRequest') + ->willReturn(null); + + $resolver = new ClientIpResolver($this->requestStack); + + $expectedHost = gethostname() ?: 'localhost'; + $expected = $expectedHost . ':' . getmypid(); + + $this->assertSame($expected, $resolver->resolve()); + } +} diff --git a/tests/Unit/Domain/Common/SystemInfoCollectorTest.php b/tests/Unit/Domain/Common/SystemInfoCollectorTest.php new file mode 100644 index 00000000..7bf964d7 --- /dev/null +++ b/tests/Unit/Domain/Common/SystemInfoCollectorTest.php @@ -0,0 +1,95 @@ +requestStack = $this->createMock(RequestStack::class); + } + + public function testCollectReturnsSanitizedPairsWithDefaults(): void + { + $server = [ + 'HTTP_USER_AGENT' => 'Agent X"', + 'HTTP_REFERER' => 'https://example.com/?q=', + 'HTTP_X_FORWARDED_FOR' => '198.51.100.5, 203.0.113.7', + 'REQUEST_URI' => '/path?x=1&y="z"', + 'REMOTE_ADDR' => '203.0.113.10', + ]; + $request = new Request(query: [], request: [], attributes: [], cookies: [], files: [], server: $server); + + $this->requestStack->method('getCurrentRequest')->willReturn($request); + + $collector = new SystemInfoCollector($this->requestStack); + $result = $collector->collect(); + + $expected = [ + 'HTTP_USER_AGENT' => 'Agent <b>X</b>"', + 'HTTP_REFERER' => 'https://example.com/?q=<script>alert(1)</script>', + 'REMOTE_ADDR' => '203.0.113.10', + 'REQUEST_URI' => '/path?x=1&y="z"<w>', + 'HTTP_X_FORWARDED_FOR' => '198.51.100.5, 203.0.113.7', + ]; + + $this->assertSame($expected, $result); + } + + public function testCollectUsesConfiguredKeysAndSkipsMissing(): void + { + $server = [ + 'HTTP_USER_AGENT' => 'UA', + 'REQUEST_URI' => '/only/uri', + 'REMOTE_ADDR' => '198.51.100.10', + ]; + $request = new Request(query: [], request: [], attributes: [], cookies: [], files: [], server: $server); + $this->requestStack->method('getCurrentRequest')->willReturn($request); + + $collector = new SystemInfoCollector($this->requestStack, ['REQUEST_URI', 'UNKNOWN', 'REMOTE_ADDR']); + $result = $collector->collect(); + + $expected = [ + 'REQUEST_URI' => '/only/uri', + 'REMOTE_ADDR' => '198.51.100.10', + ]; + + $this->assertSame($expected, $result); + } + + public function testCollectAsStringFormatsLinesWithLeadingNewline(): void + { + $server = [ + 'HTTP_USER_AGENT' => 'UA', + 'HTTP_REFERER' => 'https://ref.example', + 'REMOTE_ADDR' => '192.0.2.5', + 'REQUEST_URI' => '/abc', + 'HTTP_X_FORWARDED_FOR' => '1.1.1.1', + ]; + $request = new Request(query: [], request: [], attributes: [], cookies: [], files: [], server: $server); + $this->requestStack->method('getCurrentRequest')->willReturn($request); + + $collector = new SystemInfoCollector($this->requestStack); + $string = $collector->collectAsString(); + + $expected = "\n" . implode("\n", [ + 'HTTP_USER_AGENT = UA', + 'HTTP_REFERER = https://ref.example', + 'REMOTE_ADDR = 192.0.2.5', + 'REQUEST_URI = /abc', + 'HTTP_X_FORWARDED_FOR = 1.1.1.1', + ]); + + $this->assertSame($expected, $string); + } +} diff --git a/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php new file mode 100644 index 00000000..50cce9fa --- /dev/null +++ b/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php @@ -0,0 +1,197 @@ +lockService = $this->createMock(LockService::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->protocolProcessor = $this->createMock(BounceProtocolProcessor::class); + $this->advancedRulesProcessor = $this->createMock(AdvancedBounceRulesProcessor::class); + $this->unidentifiedReprocessor = $this->createMock(UnidentifiedBounceReprocessor::class); + $this->consecutiveBounceHandler = $this->createMock(ConsecutiveBounceHandler::class); + + $command = new ProcessBouncesCommand( + lockService: $this->lockService, + logger: $this->logger, + protocolProcessors: [$this->protocolProcessor], + advancedRulesProcessor: $this->advancedRulesProcessor, + unidentifiedReprocessor: $this->unidentifiedReprocessor, + consecutiveBounceHandler: $this->consecutiveBounceHandler, + ); + + $this->commandTester = new CommandTester($command); + } + + public function testExecuteWhenLockNotAcquired(): void + { + $this->lockService->expects($this->once()) + ->method('acquirePageLock') + ->with('bounce_processor', false) + ->willReturn(null); + + $this->protocolProcessor->expects($this->never())->method('getProtocol'); + $this->protocolProcessor->expects($this->never())->method('process'); + $this->unidentifiedReprocessor->expects($this->never())->method('process'); + $this->advancedRulesProcessor->expects($this->never())->method('process'); + $this->consecutiveBounceHandler->expects($this->never())->method('handle'); + + $this->commandTester->execute([]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Another bounce processing is already running. Aborting.', $output); + $this->assertSame(0, $this->commandTester->getStatusCode()); + } + + public function testExecuteWithUnsupportedProtocol(): void + { + $this->lockService + ->expects($this->once()) + ->method('acquirePageLock') + ->with('bounce_processor', false) + ->willReturn(123); + $this->lockService + ->expects($this->once()) + ->method('release') + ->with(123); + + $this->protocolProcessor->method('getProtocol')->willReturn('pop'); + $this->protocolProcessor->expects($this->never())->method('process'); + + $this->commandTester->execute([ + '--protocol' => 'mbox', + ]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Unsupported protocol: mbox', $output); + $this->assertSame(1, $this->commandTester->getStatusCode()); + } + + public function testSuccessfulProcessingFlow(): void + { + $this->lockService + ->expects($this->once()) + ->method('acquirePageLock') + ->with('bounce_processor', false) + ->willReturn(456); + $this->lockService + ->expects($this->once()) + ->method('release') + ->with(456); + + $this->protocolProcessor->method('getProtocol')->willReturn('pop'); + $this->protocolProcessor + ->expects($this->once()) + ->method('process') + ->with( + $this->callback(function ($input) { + return $input->getOption('protocol') === 'pop' + && $input->getOption('test') === false + && $input->getOption('purge-unprocessed') === false; + }), + $this->anything() + ) + ->willReturn('downloaded 10 messages'); + + $this->unidentifiedReprocessor + ->expects($this->once()) + ->method('process') + ->with($this->anything()); + + $this->advancedRulesProcessor + ->expects($this->once()) + ->method('process') + ->with($this->anything(), 1000); + + $this->consecutiveBounceHandler + ->expects($this->once()) + ->method('handle') + ->with($this->anything()); + + $this->logger + ->expects($this->once()) + ->method('info') + ->with('Bounce processing completed', $this->arrayHasKey('downloadReport')); + + $this->commandTester->execute([]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Bounce processing completed.', $output); + $this->assertSame(0, $this->commandTester->getStatusCode()); + } + + public function testProcessingFlowWhenProcessorThrowsException(): void + { + $this->lockService + ->expects($this->once()) + ->method('acquirePageLock') + ->with('bounce_processor', false) + ->willReturn(42); + $this->lockService + ->expects($this->once()) + ->method('release') + ->with(42); + + $this->protocolProcessor->method('getProtocol')->willReturn('pop'); + + $this->protocolProcessor + ->expects($this->once()) + ->method('process') + ->willThrowException(new Exception('boom')); + + $this->unidentifiedReprocessor->expects($this->never())->method('process'); + $this->advancedRulesProcessor->expects($this->never())->method('process'); + $this->consecutiveBounceHandler->expects($this->never())->method('handle'); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with('Bounce processing failed', $this->arrayHasKey('exception')); + + $this->commandTester->execute([]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Error: boom', $output); + $this->assertSame(1, $this->commandTester->getStatusCode()); + } + + public function testForceOptionIsPassedToLockService(): void + { + $this->lockService->expects($this->once()) + ->method('acquirePageLock') + ->with('bounce_processor', true) + ->willReturn(1); + $this->protocolProcessor->method('getProtocol')->willReturn('pop'); + + $this->commandTester->execute([ + '--force' => true, + ]); + + $this->assertSame(0, $this->commandTester->getStatusCode()); + } +} diff --git a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php index 489b5d60..79ece9bd 100644 --- a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php +++ b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php @@ -8,8 +8,8 @@ use PhpList\Core\Domain\Messaging\Command\ProcessQueueCommand; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; -use PhpList\Core\Domain\Messaging\Service\CampaignProcessor; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; +use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Application; diff --git a/tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php b/tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php new file mode 100644 index 00000000..49d4aadb --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php @@ -0,0 +1,66 @@ +fooHandler = $this->createMock(BounceActionHandlerInterface::class); + $this->barHandler = $this->createMock(BounceActionHandlerInterface::class); + $this->fooHandler->method('supports')->willReturnCallback(fn ($action) => $action === 'foo'); + $this->barHandler->method('supports')->willReturnCallback(fn ($action) => $action === 'bar'); + + $this->resolver = new BounceActionResolver( + [ + $this->fooHandler, + $this->barHandler, + ] + ); + } + + public function testHasReturnsTrueWhenHandlerSupportsAction(): void + { + $this->assertTrue($this->resolver->has('foo')); + $this->assertTrue($this->resolver->has('bar')); + $this->assertFalse($this->resolver->has('baz')); + } + + public function testResolveReturnsSameInstanceAndCaches(): void + { + $first = $this->resolver->resolve('foo'); + $second = $this->resolver->resolve('foo'); + + $this->assertSame($first, $second); + + $this->assertInstanceOf(BounceActionHandlerInterface::class, $first); + } + + public function testResolveThrowsWhenNoHandlerFound(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No handler found for action "baz".'); + + $this->resolver->resolve('baz'); + } + + public function testHandleDelegatesToResolvedHandler(): void + { + $context = ['key' => 'value', 'n' => 42]; + $this->fooHandler->expects($this->once())->method('handle'); + $this->barHandler->expects($this->never())->method('handle'); + $this->resolver->handle('foo', $context); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php new file mode 100644 index 00000000..1cb1b6d2 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php @@ -0,0 +1,212 @@ +bounceManager = $this->createMock(BounceManager::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->subscriberHistoryManager = $this->createMock(SubscriberHistoryManager::class); + $this->blacklistService = $this->createMock(SubscriberBlacklistService::class); + $this->io = $this->createMock(SymfonyStyle::class); + + $this->io->method('section'); + $this->io->method('writeln'); + + $unsubscribeThreshold = 2; + $blacklistThreshold = 3; + + $this->handler = new ConsecutiveBounceHandler( + bounceManager: $this->bounceManager, + subscriberRepository: $this->subscriberRepository, + subscriberHistoryManager: $this->subscriberHistoryManager, + blacklistService: $this->blacklistService, + unsubscribeThreshold: $unsubscribeThreshold, + blacklistThreshold: $blacklistThreshold, + ); + } + + public function testHandleWithNoUsers(): void + { + $this->subscriberRepository + ->expects($this->once()) + ->method('distinctUsersWithBouncesConfirmedNotBlacklisted') + ->willReturn([]); + + $this->io->expects($this->once())->method('section')->with('Identifying consecutive bounces'); + $this->io->expects($this->once())->method('writeln')->with('Nothing to do'); + + $this->handler->handle($this->io); + } + + public function testUnsubscribeAtThresholdAddsHistoryAndMarksUnconfirmedOnce(): void + { + $user = $this->makeSubscriber(123); + $this->subscriberRepository + ->method('distinctUsersWithBouncesConfirmedNotBlacklisted') + ->willReturn([$user]); + + $history = [ + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(1)], + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(2)], + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(0)], + ]; + $this->bounceManager + ->expects($this->once()) + ->method('getUserMessageHistoryWithBounces') + ->with($user) + ->willReturn($history); + + $this->subscriberRepository + ->expects($this->once()) + ->method('markUnconfirmed') + ->with(123); + + $this->subscriberHistoryManager + ->expects($this->once()) + ->method('addHistory') + ->with( + $user, + 'Auto Unconfirmed', + $this->stringContains('2 consecutive bounces') + ); + + $this->blacklistService->expects($this->never())->method('blacklist'); + + $this->io->expects($this->once())->method('section')->with('Identifying consecutive bounces'); + $this->io->expects($this->once())->method('writeln')->with('total of 1 subscribers processed'); + + $this->handler->handle($this->io); + } + + public function testBlacklistAtThresholdStopsProcessingAndAlsoUnsubscribesIfReached(): void + { + $user = $this->makeSubscriber(7); + $this->subscriberRepository + ->method('distinctUsersWithBouncesConfirmedNotBlacklisted') + ->willReturn([$user]); + + $history = [ + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(11)], + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(12)], + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(13)], + // Any further entries should be ignored after blacklist stop + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(14)], + ]; + $this->bounceManager + ->expects($this->once()) + ->method('getUserMessageHistoryWithBounces') + ->with($user) + ->willReturn($history); + + // Unsubscribe reached at 2 + $this->subscriberRepository + ->expects($this->once()) + ->method('markUnconfirmed') + ->with(7); + + $this->subscriberHistoryManager + ->expects($this->once()) + ->method('addHistory') + ->with( + $user, + 'Auto Unconfirmed', + $this->stringContains('consecutive bounces') + ); + + // Blacklist at 3 + $this->blacklistService + ->expects($this->once()) + ->method('blacklist') + ->with( + $user, + $this->stringContains('3 consecutive bounces') + ); + + $this->handler->handle($this->io); + } + + public function testDuplicateBouncesAreIgnoredInCounting(): void + { + $user = $this->makeSubscriber(55); + $this->subscriberRepository->method('distinctUsersWithBouncesConfirmedNotBlacklisted')->willReturn([$user]); + + // First is duplicate (by status), ignored; then two real => unsubscribe triggered once + $history = [ + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(101, status: 'DUPLICATE bounce')], + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(102, comment: 'ok')], + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(103)], + ]; + $this->bounceManager->method('getUserMessageHistoryWithBounces')->willReturn($history); + + $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(55); + $this->subscriberHistoryManager->expects($this->once())->method('addHistory')->with( + $user, + 'Auto Unconfirmed', + $this->stringContains('2 consecutive bounces') + ); + $this->blacklistService->expects($this->never())->method('blacklist'); + + $this->handler->handle($this->io); + } + + public function testBreaksOnBounceWithoutRealId(): void + { + $user = $this->makeSubscriber(77); + $this->subscriberRepository->method('distinctUsersWithBouncesConfirmedNotBlacklisted')->willReturn([$user]); + + // The first entry has null bounce (no real id) => processing for the user stops immediately; no actions + $history = [ + ['um' => null, 'umb' => null, 'b' => null], + // should not be reached + ['um' => null, 'umb' => null, 'b' => $this->makeBounce(1)], + ]; + $this->bounceManager->method('getUserMessageHistoryWithBounces')->willReturn($history); + + $this->subscriberRepository->expects($this->never())->method('markUnconfirmed'); + $this->subscriberHistoryManager->expects($this->never())->method('addHistory'); + $this->blacklistService->expects($this->never())->method('blacklist'); + + $this->handler->handle($this->io); + } + + private function makeSubscriber(int $id): Subscriber + { + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getId')->willReturn($id); + + return $subscriber; + } + + private function makeBounce(int $id, ?string $status = null, ?string $comment = null): Bounce + { + $bounce = $this->createMock(Bounce::class); + $bounce->method('getId')->willReturn($id); + $bounce->method('getStatus')->willReturn($status); + $bounce->method('getComment')->willReturn($comment); + + return $bounce; + } +} diff --git a/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php b/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php index 9409320b..950f1021 100644 --- a/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php @@ -19,12 +19,18 @@ class EmailServiceTest extends TestCase private MailerInterface&MockObject $mailer; private MessageBusInterface&MockObject $messageBus; private string $defaultFromEmail = 'default@example.com'; + private string $bounceEmail = 'bounce@example.com'; protected function setUp(): void { $this->mailer = $this->createMock(MailerInterface::class); $this->messageBus = $this->createMock(MessageBusInterface::class); - $this->emailService = new EmailService($this->mailer, $this->defaultFromEmail, $this->messageBus); + $this->emailService = new EmailService( + mailer: $this->mailer, + messageBus: $this->messageBus, + defaultFromEmail: $this->defaultFromEmail, + bounceEmail: $this->bounceEmail, + ); } public function testSendEmailWithDefaultFrom(): void diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php new file mode 100644 index 00000000..8f5cdb11 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php @@ -0,0 +1,78 @@ +historyManager = $this->createMock(SubscriberHistoryManager::class); + $this->bounceManager = $this->createMock(BounceManager::class); + $this->blacklistService = $this->createMock(SubscriberBlacklistService::class); + $this->handler = new BlacklistEmailAndDeleteBounceHandler( + subscriberHistoryManager: $this->historyManager, + bounceManager: $this->bounceManager, + blacklistService: $this->blacklistService, + ); + } + + public function testSupportsOnlyBlacklistEmailAndDeleteBounce(): void + { + $this->assertTrue($this->handler->supports('blacklistemailanddeletebounce')); + $this->assertFalse($this->handler->supports('blacklistemail')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleBlacklistsAddsHistoryAndDeletesBounceWhenSubscriberPresent(): void + { + $subscriber = $this->createMock(Subscriber::class); + $bounce = $this->createMock(Bounce::class); + + $this->blacklistService->expects($this->once())->method('blacklist')->with( + $subscriber, + $this->stringContains('Email address auto blacklisted by bounce rule 9') + ); + $this->historyManager->expects($this->once())->method('addHistory')->with( + $subscriber, + 'Auto Unsubscribed', + $this->stringContains('User auto unsubscribed for bounce rule 9') + ); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'ruleId' => 9, + 'bounce' => $bounce, + ]); + } + + public function testHandleSkipsBlacklistAndHistoryWhenNoSubscriberButDeletesBounce(): void + { + $bounce = $this->createMock(Bounce::class); + + $this->blacklistService->expects($this->never())->method('blacklist'); + $this->historyManager->expects($this->never())->method('addHistory'); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'ruleId' => 9, + 'bounce' => $bounce, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php new file mode 100644 index 00000000..54f7362b --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php @@ -0,0 +1,73 @@ +historyManager = $this->createMock(SubscriberHistoryManager::class); + $this->blacklistService = $this->createMock(SubscriberBlacklistService::class); + $this->handler = new BlacklistEmailHandler( + subscriberHistoryManager: $this->historyManager, + blacklistService: $this->blacklistService, + ); + } + + public function testSupportsOnlyBlacklistEmail(): void + { + $this->assertTrue($this->handler->supports('blacklistemail')); + $this->assertFalse($this->handler->supports('blacklistuser')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleBlacklistsAndAddsHistoryWhenSubscriberPresent(): void + { + $subscriber = $this->createMock(Subscriber::class); + + $this->blacklistService + ->expects($this->once()) + ->method('blacklist') + ->with( + $subscriber, + $this->stringContains('Email address auto blacklisted by bounce rule 42') + ); + + $this->historyManager + ->expects($this->once()) + ->method('addHistory') + ->with( + $subscriber, + 'Auto Unsubscribed', + $this->stringContains('email auto unsubscribed for bounce rule 42') + ); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'ruleId' => 42, + ]); + } + + public function testHandleDoesNothingWhenNoSubscriber(): void + { + $this->blacklistService->expects($this->never())->method('blacklist'); + $this->historyManager->expects($this->never())->method('addHistory'); + + $this->handler->handle([ + 'ruleId' => 1, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php new file mode 100644 index 00000000..af1df32e --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php @@ -0,0 +1,90 @@ +historyManager = $this->createMock(SubscriberHistoryManager::class); + $this->bounceManager = $this->createMock(BounceManager::class); + $this->blacklistService = $this->createMock(SubscriberBlacklistService::class); + $this->handler = new BlacklistUserAndDeleteBounceHandler( + subscriberHistoryManager: $this->historyManager, + bounceManager: $this->bounceManager, + blacklistService: $this->blacklistService, + ); + } + + public function testSupportsOnlyBlacklistUserAndDeleteBounce(): void + { + $this->assertTrue($this->handler->supports('blacklistuseranddeletebounce')); + $this->assertFalse($this->handler->supports('blacklistuser')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleBlacklistsAddsHistoryAndDeletesBounceWhenSubscriberPresentAndNotBlacklisted(): void + { + $subscriber = $this->createMock(Subscriber::class); + $bounce = $this->createMock(Bounce::class); + + $this->blacklistService->expects($this->once())->method('blacklist')->with( + $subscriber, + $this->stringContains('Subscriber auto blacklisted by bounce rule 13') + ); + $this->historyManager->expects($this->once())->method('addHistory')->with( + $subscriber, + 'Auto Unsubscribed', + $this->stringContains('User auto unsubscribed for bounce rule 13') + ); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'blacklisted' => false, + 'ruleId' => 13, + 'bounce' => $bounce, + ]); + } + + public function testHandleSkipsBlacklistAndHistoryWhenNoSubscriberOrAlreadyBlacklistedButDeletesBounce(): void + { + $subscriber = $this->createMock(Subscriber::class); + $bounce = $this->createMock(Bounce::class); + + $this->blacklistService->expects($this->never())->method('blacklist'); + $this->historyManager->expects($this->never())->method('addHistory'); + $this->bounceManager->expects($this->exactly(2))->method('delete')->with($bounce); + + // Already blacklisted + $this->handler->handle([ + 'subscriber' => $subscriber, + 'blacklisted' => true, + 'ruleId' => 13, + 'bounce' => $bounce, + ]); + + // No subscriber + $this->handler->handle([ + 'blacklisted' => false, + 'ruleId' => 13, + 'bounce' => $bounce, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php new file mode 100644 index 00000000..72fe4584 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php @@ -0,0 +1,84 @@ +historyManager = $this->createMock(SubscriberHistoryManager::class); + $this->blacklistService = $this->createMock(SubscriberBlacklistService::class); + $this->handler = new BlacklistUserHandler( + subscriberHistoryManager: $this->historyManager, + blacklistService: $this->blacklistService + ); + } + + public function testSupportsOnlyBlacklistUser(): void + { + $this->assertTrue($this->handler->supports('blacklistuser')); + $this->assertFalse($this->handler->supports('unconfirmuser')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleBlacklistsAndAddsHistoryWhenSubscriberPresentAndNotBlacklisted(): void + { + $subscriber = $this->createMock(Subscriber::class); + + $this->blacklistService + ->expects($this->once()) + ->method('blacklist') + ->with( + $subscriber, + $this->stringContains('bounce rule 17') + ); + + $this->historyManager + ->expects($this->once()) + ->method('addHistory') + ->with( + $subscriber, + 'Auto Unsubscribed', + $this->stringContains('bounce rule 17') + ); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'blacklisted' => false, + 'ruleId' => 17, + ]); + } + + public function testHandleDoesNothingWhenAlreadyBlacklistedOrNoSubscriber(): void + { + $subscriber = $this->createMock(Subscriber::class); + $this->blacklistService->expects($this->never())->method('blacklist'); + $this->historyManager->expects($this->never())->method('addHistory'); + + // Already blacklisted + $this->handler->handle([ + 'subscriber' => $subscriber, + 'blacklisted' => true, + 'ruleId' => 5, + ]); + + // No subscriber provided + $this->handler->handle([ + 'blacklisted' => false, + 'ruleId' => 5, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php new file mode 100644 index 00000000..7d82336f --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php @@ -0,0 +1,103 @@ +historyManager = $this->createMock(SubscriberHistoryManager::class); + $this->subscriberManager = $this->createMock(SubscriberManager::class); + $this->bounceManager = $this->createMock(BounceManager::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->handler = new DecreaseCountConfirmUserAndDeleteBounceHandler( + subscriberHistoryManager: $this->historyManager, + subscriberManager: $this->subscriberManager, + bounceManager: $this->bounceManager, + subscriberRepository: $this->subscriberRepository, + ); + } + + public function testSupportsOnlyDecreaseCountConfirmUserAndDeleteBounce(): void + { + $this->assertTrue($this->handler->supports('decreasecountconfirmuseranddeletebounce')); + $this->assertFalse($this->handler->supports('deleteuser')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleDecrementsMarksConfirmedAddsHistoryAndDeletesWhenNotConfirmed(): void + { + $subscriber = $this->createMock(Subscriber::class); + $bounce = $this->createMock(Bounce::class); + + $this->subscriberManager->expects($this->once())->method('decrementBounceCount')->with($subscriber); + $this->subscriberRepository->expects($this->once())->method('markConfirmed')->with(11); + $this->historyManager->expects($this->once())->method('addHistory')->with( + $subscriber, + 'Auto confirmed', + $this->stringContains('bounce rule 77') + ); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'userId' => 11, + 'confirmed' => false, + 'ruleId' => 77, + 'bounce' => $bounce, + ]); + } + + public function testHandleOnlyDecrementsAndDeletesWhenAlreadyConfirmed(): void + { + $subscriber = $this->createMock(Subscriber::class); + $bounce = $this->createMock(Bounce::class); + + $this->subscriberManager->expects($this->once())->method('decrementBounceCount')->with($subscriber); + $this->subscriberRepository->expects($this->never())->method('markConfirmed'); + $this->historyManager->expects($this->never())->method('addHistory'); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'userId' => 11, + 'confirmed' => true, + 'ruleId' => 77, + 'bounce' => $bounce, + ]); + } + + public function testHandleDeletesBounceEvenWithoutSubscriber(): void + { + $bounce = $this->createMock(Bounce::class); + + $this->subscriberManager->expects($this->never())->method('decrementBounceCount'); + $this->subscriberRepository->expects($this->never())->method('markConfirmed'); + $this->historyManager->expects($this->never())->method('addHistory'); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'confirmed' => true, + 'ruleId' => 1, + 'bounce' => $bounce, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php new file mode 100644 index 00000000..25028345 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php @@ -0,0 +1,40 @@ +bounceManager = $this->createMock(BounceManager::class); + $this->handler = new DeleteBounceHandler($this->bounceManager); + } + + public function testSupportsOnlyDeleteBounce(): void + { + $this->assertTrue($this->handler->supports('deletebounce')); + $this->assertFalse($this->handler->supports('deleteuser')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleDeletesBounce(): void + { + $bounce = $this->createMock(Bounce::class); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'bounce' => $bounce, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php new file mode 100644 index 00000000..0d68b631 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php @@ -0,0 +1,63 @@ +bounceManager = $this->createMock(BounceManager::class); + $this->subscriberManager = $this->createMock(SubscriberManager::class); + $this->handler = new DeleteUserAndBounceHandler( + bounceManager: $this->bounceManager, + subscriberManager: $this->subscriberManager + ); + } + + public function testSupportsOnlyDeleteUserAndBounce(): void + { + $this->assertTrue($this->handler->supports('deleteuserandbounce')); + $this->assertFalse($this->handler->supports('deleteuser')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleDeletesUserWhenPresentAndAlwaysDeletesBounce(): void + { + $subscriber = $this->createMock(Subscriber::class); + $bounce = $this->createMock(Bounce::class); + + $this->subscriberManager->expects($this->once())->method('deleteSubscriber')->with($subscriber); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'bounce' => $bounce, + ]); + } + + public function testHandleSkipsUserDeletionWhenNoSubscriberButDeletesBounce(): void + { + $bounce = $this->createMock(Bounce::class); + + $this->subscriberManager->expects($this->never())->method('deleteSubscriber'); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'bounce' => $bounce, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php new file mode 100644 index 00000000..427f8146 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php @@ -0,0 +1,71 @@ +subscriberManager = $this->createMock(SubscriberManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->handler = new DeleteUserHandler(subscriberManager: $this->subscriberManager, logger: $this->logger); + } + + public function testSupportsOnlyDeleteUser(): void + { + $this->assertTrue($this->handler->supports('deleteuser')); + $this->assertFalse($this->handler->supports('deleteuserandbounce')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleLogsAndDeletesWhenSubscriberPresent(): void + { + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getEmail')->willReturn('user@example.com'); + + $this->logger + ->expects($this->once()) + ->method('info') + ->with( + 'User deleted by bounce rule', + $this->callback(function ($context) { + return isset($context['user'], $context['rule']) + && $context['user'] === 'user@example.com' + && $context['rule'] === 42; + }) + ); + + $this->subscriberManager + ->expects($this->once()) + ->method('deleteSubscriber') + ->with($subscriber); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'ruleId' => 42, + ]); + } + + public function testHandleDoesNothingWhenNoSubscriber(): void + { + $this->logger->expects($this->never())->method('info'); + $this->subscriberManager->expects($this->never())->method('deleteSubscriber'); + + $this->handler->handle([ + 'ruleId' => 1, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php new file mode 100644 index 00000000..7a4ac245 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php @@ -0,0 +1,90 @@ +historyManager = $this->createMock(SubscriberHistoryManager::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->bounceManager = $this->createMock(BounceManager::class); + $this->handler = new UnconfirmUserAndDeleteBounceHandler( + subscriberHistoryManager: $this->historyManager, + subscriberRepository: $this->subscriberRepository, + bounceManager: $this->bounceManager, + ); + } + + public function testSupportsOnlyUnconfirmUserAndDeleteBounce(): void + { + $this->assertTrue($this->handler->supports('unconfirmuseranddeletebounce')); + $this->assertFalse($this->handler->supports('unconfirmuser')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleUnconfirmsAndAddsHistoryAndDeletesBounce(): void + { + $subscriber = $this->createMock(Subscriber::class); + $bounce = $this->createMock(Bounce::class); + + $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(10); + $this->historyManager->expects($this->once())->method('addHistory')->with( + $subscriber, + 'Auto unconfirmed', + $this->stringContains('bounce rule 3') + ); + $this->bounceManager->expects($this->once())->method('delete')->with($bounce); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'userId' => 10, + 'confirmed' => true, + 'ruleId' => 3, + 'bounce' => $bounce, + ]); + } + + public function testHandleDeletesBounceAndSkipsUnconfirmWhenNotConfirmedOrNoSubscriber(): void + { + $subscriber = $this->createMock(Subscriber::class); + $bounce = $this->createMock(Bounce::class); + + $this->subscriberRepository->expects($this->never())->method('markUnconfirmed'); + $this->historyManager->expects($this->never())->method('addHistory'); + $this->bounceManager->expects($this->exactly(2))->method('delete')->with($bounce); + + // Not confirmed + $this->handler->handle([ + 'subscriber' => $subscriber, + 'userId' => 10, + 'confirmed' => false, + 'ruleId' => 3, + 'bounce' => $bounce, + ]); + + // No subscriber + $this->handler->handle([ + 'userId' => 10, + 'confirmed' => true, + 'ruleId' => 3, + 'bounce' => $bounce, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php new file mode 100644 index 00000000..a395e110 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php @@ -0,0 +1,77 @@ +subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->historyManager = $this->createMock(SubscriberHistoryManager::class); + $this->handler = new UnconfirmUserHandler( + subscriberRepository: $this->subscriberRepository, + subscriberHistoryManager: $this->historyManager + ); + } + + public function testSupportsOnlyUnconfirmUser(): void + { + $this->assertTrue($this->handler->supports('unconfirmuser')); + $this->assertFalse($this->handler->supports('blacklistuser')); + $this->assertFalse($this->handler->supports('')); + } + + public function testHandleMarksUnconfirmedAndAddsHistoryWhenSubscriberPresentAndConfirmed(): void + { + $subscriber = $this->createMock(Subscriber::class); + + $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(123); + $this->historyManager->expects($this->once())->method('addHistory')->with( + $subscriber, + 'Auto Unconfirmed', + $this->stringContains('bounce rule 9') + ); + + $this->handler->handle([ + 'subscriber' => $subscriber, + 'userId' => 123, + 'confirmed' => true, + 'ruleId' => 9, + ]); + } + + public function testHandleDoesNothingWhenNotConfirmedOrNoSubscriber(): void + { + $subscriber = $this->createMock(Subscriber::class); + $this->subscriberRepository->expects($this->never())->method('markUnconfirmed'); + $this->historyManager->expects($this->never())->method('addHistory'); + + // Not confirmed + $this->handler->handle([ + 'subscriber' => $subscriber, + 'userId' => 44, + 'confirmed' => false, + 'ruleId' => 1, + ]); + + // No subscriber + $this->handler->handle([ + 'userId' => 44, + 'confirmed' => true, + 'ruleId' => 1, + ]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/LockServiceTest.php b/tests/Unit/Domain/Messaging/Service/LockServiceTest.php new file mode 100644 index 00000000..8851d7de --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/LockServiceTest.php @@ -0,0 +1,88 @@ +repo = $this->createMock(SendProcessRepository::class); + $this->manager = $this->createMock(SendProcessManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + } + + public function testAcquirePageLockCreatesProcessWhenBelowMax(): void + { + $service = new LockService($this->repo, $this->manager, $this->logger, 600, 0, 0); + + $this->repo->method('countAliveByPage')->willReturn(0); + $this->manager->method('findNewestAliveWithAge')->willReturn(null); + + $sendProcess = $this->createConfiguredMock(SendProcess::class, ['getId' => 42]); + $this->manager->expects($this->once()) + ->method('create') + ->with('mypage', $this->callback(fn(string $id) => $id !== '')) + ->willReturn($sendProcess); + + $id = $service->acquirePageLock('my page'); + $this->assertSame(42, $id); + } + + public function testAcquirePageLockReturnsNullWhenAtMaxInCli(): void + { + $service = new LockService($this->repo, $this->manager, $this->logger, 600, 0, 0); + + $this->repo->method('countAliveByPage')->willReturn(1); + $this->manager->method('findNewestAliveWithAge')->willReturn(['age' => 1, 'id' => 10]); + + $this->logger->expects($this->atLeastOnce())->method('info'); + $id = $service->acquirePageLock('page', false, true, false, 1); + $this->assertNull($id); + } + + public function testAcquirePageLockStealsStale(): void + { + $service = new LockService($this->repo, $this->manager, $this->logger, 1, 0, 0); + + $this->repo->expects($this->exactly(2))->method('countAliveByPage')->willReturnOnConsecutiveCalls(1, 0); + $this->manager + ->expects($this->exactly(2)) + ->method('findNewestAliveWithAge') + ->willReturnOnConsecutiveCalls(['age' => 5, 'id' => 10], null); + $this->repo->expects($this->once())->method('markDeadById')->with(10); + + $sendProcess = $this->createConfiguredMock(SendProcess::class, ['getId' => 99]); + $this->manager->method('create')->willReturn($sendProcess); + + $id = $service->acquirePageLock('page', false, true); + $this->assertSame(99, $id); + } + + public function testKeepCheckReleaseDelegatesToRepo(): void + { + $service = new LockService($this->repo, $this->manager, $this->logger); + + $this->repo->expects($this->once())->method('incrementAlive')->with(5); + $service->keepLock(5); + + $this->repo->expects($this->once())->method('getAliveValue')->with(5)->willReturn(7); + $this->assertSame(7, $service->checkLock(5)); + + $this->repo->expects($this->once())->method('markDeadById')->with(5); + $service->release(5); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php new file mode 100644 index 00000000..bd1a4a68 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php @@ -0,0 +1,205 @@ +repository = $this->createMock(BounceRepository::class); + $this->userMessageBounceRepository = $this->createMock(UserMessageBounceRepository::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->manager = new BounceManager( + bounceRepository: $this->repository, + userMessageBounceRepo: $this->userMessageBounceRepository, + entityManager: $this->entityManager, + logger: $this->logger, + ); + } + + public function testCreatePersistsAndReturnsBounce(): void + { + $date = new DateTimeImmutable('2020-01-01 00:00:00'); + $header = 'X-Test: Header'; + $data = 'raw bounce'; + $status = 'new'; + $comment = 'created by test'; + + $this->repository->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(Bounce::class)); + + $bounce = $this->manager->create( + date: $date, + header: $header, + data: $data, + status: $status, + comment: $comment + ); + + $this->assertInstanceOf(Bounce::class, $bounce); + $this->assertSame($date->format('Y-m-d h:m:s'), $bounce->getDate()->format('Y-m-d h:m:s')); + $this->assertSame($header, $bounce->getHeader()); + $this->assertSame($data, $bounce->getData()); + $this->assertSame($status, $bounce->getStatus()); + $this->assertSame($comment, $bounce->getComment()); + } + + public function testDeleteDelegatesToRepository(): void + { + $model = new Bounce(); + + $this->repository->expects($this->once()) + ->method('remove') + ->with($model); + + $this->manager->delete($model); + } + + public function testGetAllReturnsArray(): void + { + $expected = [new Bounce(), new Bounce()]; + + $this->repository->expects($this->once()) + ->method('findAll') + ->willReturn($expected); + + $this->assertSame($expected, $this->manager->getAll()); + } + + public function testGetByIdReturnsBounce(): void + { + $expected = new Bounce(); + + $this->repository->expects($this->once()) + ->method('find') + ->with(123) + ->willReturn($expected); + + $this->assertSame($expected, $this->manager->getById(123)); + } + + public function testGetByIdReturnsNullWhenNotFound(): void + { + $this->repository->expects($this->once()) + ->method('find') + ->with(999) + ->willReturn(null); + + $this->assertNull($this->manager->getById(999)); + } + + public function testUpdateChangesFieldsAndSaves(): void + { + $bounce = new Bounce(); + $this->repository->expects($this->once()) + ->method('save') + ->with($bounce); + + $updated = $this->manager->update($bounce, 'processed', 'done'); + $this->assertSame($bounce, $updated); + $this->assertSame('processed', $bounce->getStatus()); + $this->assertSame('done', $bounce->getComment()); + } + + public function testLinkUserMessageBounceFlushesAndSetsFields(): void + { + $bounce = $this->createMock(Bounce::class); + $bounce->method('getId')->willReturn(77); + + $this->entityManager->expects($this->once())->method('flush'); + + $dt = new DateTimeImmutable('2024-05-01 12:34:56'); + $umb = $this->manager->linkUserMessageBounce($bounce, $dt, 123, 456); + + $this->assertSame(77, $umb->getBounceId()); + $this->assertSame(123, $umb->getUserId()); + $this->assertSame(456, $umb->getMessageId()); + } + + public function testExistsUserMessageBounceDelegatesToRepo(): void + { + $this->userMessageBounceRepository->expects($this->once()) + ->method('existsByMessageIdAndUserId') + ->with(456, 123) + ->willReturn(true); + + $this->assertTrue($this->manager->existsUserMessageBounce(123, 456)); + } + + public function testFindByStatusDelegatesToRepository(): void + { + $b1 = new Bounce(); + $b2 = new Bounce(); + $this->repository->expects($this->once()) + ->method('findByStatus') + ->with('new') + ->willReturn([$b1, $b2]); + + $this->assertSame([$b1, $b2], $this->manager->findByStatus('new')); + } + + public function testGetUserMessageBounceCount(): void + { + $this->userMessageBounceRepository->expects($this->once()) + ->method('count') + ->willReturn(5); + $this->assertSame(5, $this->manager->getUserMessageBounceCount()); + } + + public function testFetchUserMessageBounceBatchDelegates(): void + { + $expected = [['umb' => new UserMessageBounce(1, new \DateTime()), 'bounce' => new Bounce()]]; + $this->userMessageBounceRepository->expects($this->once()) + ->method('getPaginatedWithJoinNoRelation') + ->with(10, 50) + ->willReturn($expected); + $this->assertSame($expected, $this->manager->fetchUserMessageBounceBatch(10, 50)); + } + + public function testGetUserMessageHistoryWithBouncesDelegates(): void + { + $subscriber = new Subscriber(); + $expected = []; + $this->userMessageBounceRepository->expects($this->once()) + ->method('getUserMessageHistoryWithBounces') + ->with($subscriber) + ->willReturn($expected); + $this->assertSame($expected, $this->manager->getUserMessageHistoryWithBounces($subscriber)); + } + + public function testAnnounceDeletionModeLogsCorrectMessage(): void + { + $this->logger->expects($this->exactly(2)) + ->method('info') + ->withConsecutive([ + 'Running in test mode, not deleting messages from mailbox' + ], [ + 'Processed messages will be deleted from the mailbox' + ]); + + $this->manager->announceDeletionMode(true); + $this->manager->announceDeletionMode(false); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php index 1cd432bc..fd526a64 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php @@ -131,7 +131,7 @@ public function testAssociateBounceIncrementsCountAndPersistsRelation(): void ->method('persist') ->with($this->callback(function ($entity) use ($regex) { return $entity instanceof BounceRegexBounce - && $entity->getRegex() === $regex->getId(); + && $entity->getRegexId() === $regex->getId(); })); $this->entityManager->expects($this->once()) diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php new file mode 100644 index 00000000..040f98a8 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php @@ -0,0 +1,143 @@ +regexRepository = $this->createMock(BounceRegexRepository::class); + $this->relationRepository = $this->createMock(BounceRegexBounceRepository::class); + $this->manager = new BounceRuleManager( + repository: $this->regexRepository, + bounceRelationRepository: $this->relationRepository, + ); + } + + public function testLoadActiveRulesMapsRowsAndSkipsInvalid(): void + { + $valid = $this->createMock(BounceRegex::class); + $valid->method('getId')->willReturn(1); + $valid->method('getAction')->willReturn('delete'); + $valid->method('getRegex')->willReturn('user unknown'); + $valid->method('getRegexHash')->willReturn(md5('user unknown')); + + $noRegex = $this->createMock(BounceRegex::class); + $noRegex->method('getId')->willReturn(2); + + $noAction = $this->createMock(BounceRegex::class); + $noAction->method('getId')->willReturn(3); + $noAction->method('getRegex')->willReturn('pattern'); + $noAction->method('getRegexHash')->willReturn(md5('pattern')); + + $noId = $this->createMock(BounceRegex::class); + $noId->method('getRegex')->willReturn('has no id'); + $noId->method('getRegexHash')->willReturn(md5('has no id')); + $noId->method('getAction')->willReturn('keep'); + + $this->regexRepository->expects($this->once()) + ->method('fetchActiveOrdered') + ->willReturn([$valid, $noRegex, $noAction, $noId]); + + $result = $this->manager->loadActiveRules(); + + $this->assertSame(['user unknown' => $valid], $result); + } + + public function testLoadAllRulesDelegatesToRepository(): void + { + $rule1 = $this->createMock(BounceRegex::class); + $rule1->method('getId')->willReturn(10); + $rule1->method('getAction')->willReturn('keep'); + $rule1->method('getRegex')->willReturn('a'); + $rule1->method('getRegexHash')->willReturn(md5('a')); + + $rule2 = $this->createMock(BounceRegex::class); + $rule2->method('getId')->willReturn(11); + $rule2->method('getAction')->willReturn('delete'); + $rule2->method('getRegex')->willReturn('b'); + $rule2->method('getRegexHash')->willReturn(md5('b')); + + $this->regexRepository->expects($this->once()) + ->method('fetchAllOrdered') + ->willReturn([$rule1, $rule2]); + + $result = $this->manager->loadAllRules(); + $this->assertSame(['a' => $rule1, 'b' => $rule2], $result); + } + + public function testMatchBounceRulesMatchesQuotedAndRawAndHandlesInvalidPatterns(): void + { + $valid = $this->createMock(BounceRegex::class); + $valid->method('getId')->willReturn(1); + $valid->method('getAction')->willReturn('delete'); + $valid->method('getRegex')->willReturn('user unknown'); + $valid->method('getRegexHash')->willReturn(md5('user unknown')); + + $invalid = $this->createMock(BounceRegex::class); + $invalid->method('getId')->willReturn(2); + $invalid->method('getAction')->willReturn('keep'); + $invalid->method('getRegex')->willReturn('([a-z'); + $invalid->method('getRegexHash')->willReturn(md5('([a-z')); + + $rules = ['user unknown' => $valid, '([a-z' => $invalid]; + + $matched = $this->manager->matchBounceRules('Delivery failed: user unknown at example', $rules); + $this->assertSame($valid, $matched); + + // Ensure an invalid pattern does not throw and simply not match + $matchedInvalid = $this->manager->matchBounceRules('something else', ['([a-z' => $invalid]); + $this->assertNull($matchedInvalid); + } + + public function testIncrementCountPersists(): void + { + $rule = new BounceRegex(regex: 'x', regexHash: md5('x'), action: 'keep', count: 0); + $this->setId($rule, 5); + + $this->regexRepository->expects($this->once()) + ->method('save') + ->with($rule); + + $this->manager->incrementCount($rule); + $this->assertSame(1, $rule->getCount()); + } + + public function testLinkRuleToBounceCreatesRelationAndSaves(): void + { + $rule = new BounceRegex(regex: 'y', regexHash: md5('y'), action: 'delete'); + $bounce = new Bounce(); + $this->setId($rule, 9); + $this->setId($bounce, 20); + + $this->relationRepository->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(BounceRegexBounce::class)); + + $relation = $this->manager->linkRuleToBounce($rule, $bounce); + + $this->assertInstanceOf(BounceRegexBounce::class, $relation); + $this->assertSame(9, $relation->getRegexId()); + } + + private function setId(object $entity, int $id): void + { + $ref = new \ReflectionProperty($entity, 'id'); + $ref->setValue($entity, $id); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php new file mode 100644 index 00000000..e56f11ca --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php @@ -0,0 +1,86 @@ +repository = $this->createMock(SendProcessRepository::class); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->manager = new SendProcessManager($this->repository, $this->em); + } + + public function testCreatePersistsEntityAndSetsFields(): void + { + $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(SendProcess::class)); + $this->em->expects($this->once())->method('flush'); + + $sp = $this->manager->create('pageA', 'proc-1'); + $this->assertInstanceOf(SendProcess::class, $sp); + $this->assertSame('pageA', $sp->getPage()); + $this->assertSame('proc-1', $sp->getIpaddress()); + $this->assertSame(1, $sp->getAlive()); + $this->assertInstanceOf(DateTime::class, $sp->getStartedDate()); + } + + public function testFindNewestAliveWithAgeReturnsNullWhenNotFound(): void + { + $this->repository->expects($this->once()) + ->method('findNewestAlive') + ->with('pageX') + ->willReturn(null); + + $this->assertNull($this->manager->findNewestAliveWithAge('pageX')); + } + + public function testFindNewestAliveWithAgeReturnsIdAndAge(): void + { + $model = new SendProcess(); + // set id + $this->setId($model, 42); + // set updatedAt to now - 5 seconds + $updated = new \DateTime('now'); + $updated->sub(new DateInterval('PT5S')); + $this->setUpdatedAt($model, $updated); + + $this->repository->expects($this->once()) + ->method('findNewestAlive') + ->with('pageY') + ->willReturn($model); + + $result = $this->manager->findNewestAliveWithAge('pageY'); + + $this->assertIsArray($result); + $this->assertSame(42, $result['id']); + $this->assertGreaterThanOrEqual(0, $result['age']); + $this->assertLessThan(60, $result['age']); + } + + private function setId(object $entity, int $id): void + { + $ref = new \ReflectionProperty($entity, 'id'); + $ref->setValue($entity, $id); + } + + private function setUpdatedAt(SendProcess $entity, \DateTime $dt): void + { + $ref = new \ReflectionProperty($entity, 'updatedAt'); + $ref->setValue($entity, $dt); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php index 7eb6afe7..93907f02 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php @@ -24,8 +24,8 @@ protected function setUp(): void $this->entityManager = $this->createMock(EntityManagerInterface::class); $this->manager = new TemplateImageManager( - $this->templateImageRepository, - $this->entityManager + templateImageRepository: $this->templateImageRepository, + entityManager: $this->entityManager ); } diff --git a/tests/Unit/Domain/Messaging/Service/MessageParserTest.php b/tests/Unit/Domain/Messaging/Service/MessageParserTest.php new file mode 100644 index 00000000..49b38615 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/MessageParserTest.php @@ -0,0 +1,76 @@ +repo = $this->createMock(SubscriberRepository::class); + } + + public function testDecodeBodyQuotedPrintable(): void + { + $parser = new MessageParser($this->repo); + $header = "Content-Transfer-Encoding: quoted-printable\r\n"; + $body = 'Hello=20World'; + $this->assertSame('Hello World', $parser->decodeBody($header, $body)); + } + + public function testDecodeBodyBase64(): void + { + $parser = new MessageParser($this->repo); + $header = "Content-Transfer-Encoding: base64\r\n"; + $body = base64_encode('hi there'); + $this->assertSame('hi there', $parser->decodeBody($header, $body)); + } + + public function testFindMessageId(): void + { + $parser = new MessageParser($this->repo); + $text = "X-MessageId: abc-123\r\nOther: x\r\n"; + $this->assertSame('abc-123', $parser->findMessageId($text)); + } + + public function testFindUserIdWithHeaderNumeric(): void + { + $parser = new MessageParser($this->repo); + $text = "X-User: 77\r\n"; + $this->assertSame(77, $parser->findUserId($text)); + } + + public function testFindUserIdWithHeaderEmailAndLookup(): void + { + $parser = new MessageParser($this->repo); + $subscriber = $this->createConfiguredMock(Subscriber::class, ['getId' => 55]); + $this->repo->method('findOneByEmail')->with('john@example.com')->willReturn($subscriber); + $text = "X-User: john@example.com\r\n"; + $this->assertSame(55, $parser->findUserId($text)); + } + + public function testFindUserIdByScanningEmails(): void + { + $parser = new MessageParser($this->repo); + $subscriber = $this->createConfiguredMock(Subscriber::class, ['getId' => 88]); + $this->repo->method('findOneByEmail')->with('user@acme.com')->willReturn($subscriber); + $text = 'Hello bounce for user@acme.com, thanks'; + $this->assertSame(88, $parser->findUserId($text)); + } + + public function testFindUserReturnsNullWhenNoMatches(): void + { + $parser = new MessageParser($this->repo); + $this->repo->method('findOneByEmail')->willReturn(null); + $this->assertNull($parser->findUserId('no users here')); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php new file mode 100644 index 00000000..209fb583 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php @@ -0,0 +1,177 @@ +bounceManager = $this->createMock(BounceManager::class); + $this->ruleManager = $this->createMock(BounceRuleManager::class); + $this->actionResolver = $this->createMock(BounceActionResolver::class); + $this->subscriberManager = $this->createMock(SubscriberManager::class); + $this->io = $this->createMock(SymfonyStyle::class); + } + + public function testNoActiveRules(): void + { + $this->io->expects($this->once())->method('section')->with('Processing bounces based on active bounce rules'); + $this->ruleManager->method('loadActiveRules')->willReturn([]); + $this->io->expects($this->once())->method('writeln')->with('No active rules'); + + $processor = new AdvancedBounceRulesProcessor( + bounceManager: $this->bounceManager, + ruleManager: $this->ruleManager, + actionResolver: $this->actionResolver, + subscriberManager: $this->subscriberManager, + ); + + $processor->process($this->io, 100); + } + + public function testProcessingWithMatchesAndNonMatches(): void + { + $rule1 = $this->createMock(BounceRegex::class); + $rule1->method('getId')->willReturn(10); + $rule1->method('getAction')->willReturn('blacklist'); + $rule1->method('getCount')->willReturn(0); + + $rule2 = $this->createMock(BounceRegex::class); + $rule2->method('getId')->willReturn(20); + $rule2->method('getAction')->willReturn('notify'); + $rule2->method('getCount')->willReturn(0); + + $rules = [$rule1, $rule2]; + $this->ruleManager->method('loadActiveRules')->willReturn($rules); + + $this->bounceManager->method('getUserMessageBounceCount')->willReturn(3); + + $bounce1 = $this->createMock(Bounce::class); + $bounce1->method('getHeader')->willReturn('H1'); + $bounce1->method('getData')->willReturn('D1'); + + $bounce2 = $this->createMock(Bounce::class); + $bounce2->method('getHeader')->willReturn('H2'); + $bounce2->method('getData')->willReturn('D2'); + + $bounce3 = $this->createMock(Bounce::class); + $bounce3->method('getHeader')->willReturn('H3'); + $bounce3->method('getData')->willReturn('D3'); + + $umb1 = $this->createMock(UserMessageBounce::class); + $umb1->method('getId')->willReturn(1); + $umb1->method('getUserId')->willReturn(111); + + $umb2 = $this->createMock(UserMessageBounce::class); + $umb2->method('getId')->willReturn(2); + $umb2->method('getUserId')->willReturn(0); + + $umb3 = $this->createMock(UserMessageBounce::class); + $umb3->method('getId')->willReturn(3); + $umb3->method('getUserId')->willReturn(222); + + $this->bounceManager->method('fetchUserMessageBounceBatch')->willReturnOnConsecutiveCalls( + [ ['umb' => $umb1, 'bounce' => $bounce1], ['umb' => $umb2, 'bounce' => $bounce2] ], + [ ['umb' => $umb3, 'bounce' => $bounce3] ] + ); + + // Rule matches for first and third, not for second + $this->ruleManager->expects($this->exactly(3)) + ->method('matchBounceRules') + ->willReturnCallback(function (string $text, array $r) use ($rules) { + $this->assertSame($rules, $r); + if ($text === 'H1' . "\n\n" . 'D1') { + return $rules[0]; + } + if ($text === 'H2' . "\n\n" . 'D2') { + return null; + } + if ($text === 'H3' . "\n\n" . 'D3') { + return $rules[1]; + } + $this->fail('Unexpected arguments to matchBounceRules: ' . $text); + }); + + $this->ruleManager->expects($this->exactly(2))->method('incrementCount'); + $this->ruleManager->expects($this->exactly(2))->method('linkRuleToBounce'); + + // subscriber lookups for umb1 and umb3 (111 and 222). umb2 has 0 user id so skip. + $subscriber111 = $this->createMock(Subscriber::class); + $subscriber111->method('getId')->willReturn(111); + $subscriber111->method('isConfirmed')->willReturn(true); + $subscriber111->method('isBlacklisted')->willReturn(false); + + $subscriber222 = $this->createMock(Subscriber::class); + $subscriber222->method('getId')->willReturn(222); + $subscriber222->method('isConfirmed')->willReturn(false); + $subscriber222->method('isBlacklisted')->willReturn(true); + + $this->subscriberManager->expects($this->exactly(2)) + ->method('getSubscriberById') + ->willReturnCallback(function (int $id) use ($subscriber111, $subscriber222) { + if ($id === 111) { + return $subscriber111; + } + if ($id === 222) { + return $subscriber222; + } + $this->fail('Unexpected subscriber id: ' . $id); + }); + + $this->actionResolver->expects($this->exactly(2)) + ->method('handle') + ->willReturnCallback(function (string $action, array $ctx) { + if ($action === 'blacklist') { + $this->assertSame(111, $ctx['userId']); + $this->assertTrue($ctx['confirmed']); + $this->assertFalse($ctx['blacklisted']); + $this->assertSame(10, $ctx['ruleId']); + $this->assertInstanceOf(Bounce::class, $ctx['bounce']); + } elseif ($action === 'notify') { + $this->assertSame(222, $ctx['userId']); + $this->assertFalse($ctx['confirmed']); + $this->assertTrue($ctx['blacklisted']); + $this->assertSame(20, $ctx['ruleId']); + } else { + $this->fail('Unexpected action: ' . $action); + } + return null; + }); + + $this->io + ->expects($this->once()) + ->method('section') + ->with('Processing bounces based on active bounce rules'); + $this->io->expects($this->exactly(4))->method('writeln'); + + $processor = new AdvancedBounceRulesProcessor( + bounceManager: $this->bounceManager, + ruleManager: $this->ruleManager, + actionResolver: $this->actionResolver, + subscriberManager: $this->subscriberManager, + ); + + $processor->process($this->io, 2); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php new file mode 100644 index 00000000..b7009cd9 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php @@ -0,0 +1,168 @@ +bounceManager = $this->createMock(BounceManager::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->messageRepository = $this->createMock(MessageRepository::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->subscriberManager = $this->createMock(SubscriberManager::class); + $this->historyManager = $this->createMock(SubscriberHistoryManager::class); + $this->bounce = $this->createMock(Bounce::class); + } + + private function makeProcessor(): BounceDataProcessor + { + return new BounceDataProcessor( + bounceManager: $this->bounceManager, + subscriberRepository: $this->subscriberRepository, + messageRepository: $this->messageRepository, + logger: $this->logger, + subscriberManager: $this->subscriberManager, + subscriberHistoryManager: $this->historyManager, + ); + } + + public function testSystemMessageWithUserAddsHistory(): void + { + $processor = $this->makeProcessor(); + $date = new DateTimeImmutable('2020-01-01'); + + $this->bounce->method('getId')->willReturn(77); + + $this->bounceManager + ->expects($this->once()) + ->method('update') + ->with($this->bounce, 'bounced system message', '123 marked unconfirmed'); + $this->bounceManager + ->expects($this->once()) + ->method('linkUserMessageBounce') + ->with($this->bounce, $date, 123); + $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(123); + $this->logger + ->expects($this->once()) + ->method('info') + ->with('system message bounced, user marked unconfirmed', ['userId' => 123]); + + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getId')->willReturn(123); + $this->subscriberManager->method('getSubscriberById')->with(123)->willReturn($subscriber); + $this->historyManager + ->expects($this->once()) + ->method('addHistory') + ->with($subscriber, 'Bounced system message', 'User marked unconfirmed. Bounce #77'); + + $res = $processor->process($this->bounce, 'systemmessage', 123, $date); + $this->assertTrue($res); + } + + public function testSystemMessageUnknownUser(): void + { + $processor = $this->makeProcessor(); + $this->bounceManager + ->expects($this->once()) + ->method('update') + ->with($this->bounce, 'bounced system message', 'unknown user'); + $this->logger->expects($this->once())->method('info')->with('system message bounced, but unknown user'); + $res = $processor->process($this->bounce, 'systemmessage', null, new DateTimeImmutable()); + $this->assertTrue($res); + } + + public function testKnownMessageAndUserNew(): void + { + $processor = $this->makeProcessor(); + $date = new DateTimeImmutable(); + $this->bounceManager->method('existsUserMessageBounce')->with(5, 10)->willReturn(false); + $this->bounceManager + ->expects($this->once()) + ->method('linkUserMessageBounce') + ->with($this->bounce, $date, 5, 10); + $this->bounceManager + ->expects($this->once()) + ->method('update') + ->with($this->bounce, 'bounced list message 10', '5 bouncecount increased'); + $this->messageRepository->expects($this->once())->method('incrementBounceCount')->with(10); + $this->subscriberRepository->expects($this->once())->method('incrementBounceCount')->with(5); + $res = $processor->process($this->bounce, '10', 5, $date); + $this->assertTrue($res); + } + + public function testKnownMessageAndUserDuplicate(): void + { + $processor = $this->makeProcessor(); + $date = new DateTimeImmutable(); + $this->bounceManager->method('existsUserMessageBounce')->with(5, 10)->willReturn(true); + $this->bounceManager + ->expects($this->once()) + ->method('linkUserMessageBounce') + ->with($this->bounce, $date, 5, 10); + $this->bounceManager + ->expects($this->once()) + ->method('update') + ->with($this->bounce, 'duplicate bounce for 5', 'duplicate bounce for subscriber 5 on message 10'); + $res = $processor->process($this->bounce, '10', 5, $date); + $this->assertTrue($res); + } + + public function testUserOnly(): void + { + $processor = $this->makeProcessor(); + $this->bounceManager + ->expects($this->once()) + ->method('update') + ->with($this->bounce, 'bounced unidentified message', '5 bouncecount increased'); + $this->subscriberRepository->expects($this->once())->method('incrementBounceCount')->with(5); + $res = $processor->process($this->bounce, null, 5, new DateTimeImmutable()); + $this->assertTrue($res); + } + + public function testMessageOnly(): void + { + $processor = $this->makeProcessor(); + $this->bounceManager + ->expects($this->once()) + ->method('update') + ->with($this->bounce, 'bounced list message 10', 'unknown user'); + $this->messageRepository->expects($this->once())->method('incrementBounceCount')->with(10); + $res = $processor->process($this->bounce, '10', null, new DateTimeImmutable()); + $this->assertTrue($res); + } + + public function testNeitherMessageNorUser(): void + { + $processor = $this->makeProcessor(); + $this->bounceManager + ->expects($this->once()) + ->method('update') + ->with($this->bounce, 'unidentified bounce', 'not processed'); + $res = $processor->process($this->bounce, null, null, new DateTimeImmutable()); + $this->assertFalse($res); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php similarity index 98% rename from tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php rename to tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php index f8bb28d3..b2c51c71 100644 --- a/tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Processor; use Doctrine\ORM\EntityManagerInterface; use Exception; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata; -use PhpList\Core\Domain\Messaging\Service\CampaignProcessor; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; +use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider; use PHPUnit\Framework\MockObject\MockObject; diff --git a/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php new file mode 100644 index 00000000..210e000c --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php @@ -0,0 +1,76 @@ +service = $this->createMock(BounceProcessingServiceInterface::class); + $this->input = $this->createMock(InputInterface::class); + $this->io = $this->createMock(SymfonyStyle::class); + } + + public function testGetProtocol(): void + { + $processor = new MboxBounceProcessor($this->service); + $this->assertSame('mbox', $processor->getProtocol()); + } + + public function testProcessThrowsWhenMailboxMissing(): void + { + $processor = new MboxBounceProcessor($this->service); + + $this->input->method('getOption')->willReturnMap([ + ['test', false], + ['maximum', 0], + ['mailbox', ''], + ]); + + $this->io + ->expects($this->once()) + ->method('error') + ->with('mbox file path must be provided with --mailbox.'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Missing --mailbox for mbox protocol'); + + $processor->process($this->input, $this->io); + } + + public function testProcessSuccess(): void + { + $processor = new MboxBounceProcessor($this->service); + + $this->input->method('getOption')->willReturnMap([ + ['test', true], + ['maximum', 50], + ['mailbox', '/var/mail/bounce.mbox'], + ]); + + $this->io->expects($this->once())->method('section')->with('Opening mbox /var/mail/bounce.mbox'); + $this->io->expects($this->once())->method('writeln')->with('Please do not interrupt this process'); + + $this->service->expects($this->once()) + ->method('processMailbox') + ->with('/var/mail/bounce.mbox', 50, true) + ->willReturn('OK'); + + $result = $processor->process($this->input, $this->io); + $this->assertSame('OK', $result); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php new file mode 100644 index 00000000..fad4cfbe --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php @@ -0,0 +1,64 @@ +service = $this->createMock(BounceProcessingServiceInterface::class); + $this->input = $this->createMock(InputInterface::class); + $this->io = $this->createMock(SymfonyStyle::class); + } + + public function testGetProtocol(): void + { + $processor = new PopBounceProcessor($this->service, 'mail.example.com', 995, 'INBOX'); + $this->assertSame('pop', $processor->getProtocol()); + } + + public function testProcessWithMultipleMailboxesAndDefaults(): void + { + $processor = new PopBounceProcessor($this->service, 'pop.example.com', 110, 'INBOX, ,Custom'); + + $this->input->method('getOption')->willReturnMap([ + ['test', true], + ['maximum', 100], + ]); + + $this->io->expects($this->exactly(3))->method('section'); + $this->io->expects($this->exactly(3))->method('writeln'); + + $this->service->expects($this->exactly(3)) + ->method('processMailbox') + ->willReturnCallback(function (string $mailbox, int $max, bool $test) { + $expectedThird = '{pop.example.com:110}Custom'; + $expectedFirst = '{pop.example.com:110}INBOX'; + $this->assertSame(100, $max); + $this->assertTrue($test); + if ($mailbox === $expectedFirst) { + return 'A'; + } + if ($mailbox === $expectedThird) { + return 'C'; + } + $this->fail('Unexpected mailbox: ' . $mailbox); + }); + + $result = $processor->process($this->input, $this->io); + $this->assertSame('AAC', $result); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php new file mode 100644 index 00000000..a671e74c --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php @@ -0,0 +1,75 @@ +bounceManager = $this->createMock(BounceManager::class); + $this->messageParser = $this->createMock(MessageParser::class); + $this->dataProcessor = $this->createMock(BounceDataProcessor::class); + $this->io = $this->createMock(SymfonyStyle::class); + } + + public function testProcess(): void + { + $bounce1 = $this->createBounce('H1', 'D1'); + $bounce2 = $this->createBounce('H2', 'D2'); + $bounce3 = $this->createBounce('H3', 'D3'); + $this->bounceManager + ->method('findByStatus') + ->with('unidentified bounce') + ->willReturn([$bounce1, $bounce2, $bounce3]); + + $this->io->expects($this->once())->method('section')->with('Reprocessing unidentified bounces'); + $this->io->expects($this->exactly(3))->method('writeln'); + + // For b1: only userId found -> should process + $this->messageParser->expects($this->exactly(3))->method('decodeBody'); + $this->messageParser->method('findUserId')->willReturnOnConsecutiveCalls(111, null, 222); + $this->messageParser->method('findMessageId')->willReturnOnConsecutiveCalls(null, '555', '666'); + + // process called for b1 and b3 (two calls return true and true), + // and also for b2 since it has messageId -> should be called too -> total 3 calls + $this->dataProcessor->expects($this->exactly(3)) + ->method('process') + ->with( + $this->anything(), + $this->callback(fn($messageId) => $messageId === null || is_string($messageId)), + $this->callback(fn($messageId) => $messageId === null || is_int($messageId)), + $this->isInstanceOf(DateTimeImmutable::class) + ) + ->willReturnOnConsecutiveCalls(true, false, true); + + $processor = new UnidentifiedBounceReprocessor( + bounceManager: $this->bounceManager, + messageParser: $this->messageParser, + bounceDataProcessor: $this->dataProcessor + ); + $processor->process($this->io); + } + + private function createBounce(string $header, string $data): Bounce + { + // Bounce constructor: (DateTime|null, header, data, status, comment) + return new Bounce(null, $header, $data, null, null); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php b/tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php new file mode 100644 index 00000000..e75766f5 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php @@ -0,0 +1,70 @@ +manager = $this->createMock(ClientManager::class); + } + + public function testMakeForMailboxBuildsClientWithConfiguredParams(): void + { + $factory = new WebklexImapClientFactory( + clientManager: $this->manager, + mailbox: 'imap.example.com#BOUNCES', + host: 'imap.example.com', + username: 'user', + password: 'pass', + protocol: 'imap', + port: 993, + encryption: 'ssl' + ); + + $client = $this->createMock(Client::class); + + $this->manager + ->expects($this->once()) + ->method('make') + ->with($this->callback(function (array $cfg) { + $this->assertSame('imap.example.com', $cfg['host']); + $this->assertSame(993, $cfg['port']); + $this->assertSame('ssl', $cfg['encryption']); + $this->assertTrue($cfg['validate_cert']); + $this->assertSame('user', $cfg['username']); + $this->assertSame('pass', $cfg['password']); + $this->assertSame('imap', $cfg['protocol']); + return true; + })) + ->willReturn($client); + + $out = $factory->makeForMailbox(); + $this->assertSame($client, $out); + $this->assertSame('BOUNCES', $factory->getFolderName()); + } + + public function testGetFolderNameDefaultsToInbox(): void + { + $factory = new WebklexImapClientFactory( + clientManager: $this->manager, + mailbox: 'imap.example.com', + host: 'imap.example.com', + username: 'u', + password: 'p', + protocol: 'imap', + port: 993 + ); + $this->assertSame('INBOX', $factory->getFolderName()); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php index 8df0f4d8..43ae2fcc 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php @@ -4,6 +4,8 @@ namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service\Manager; +use PhpList\Core\Domain\Common\ClientIpResolver; +use PhpList\Core\Domain\Common\SystemInfoCollector; use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberHistoryFilter; use PhpList\Core\Domain\Subscription\Model\SubscriberHistory; use PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository; @@ -20,7 +22,9 @@ protected function setUp(): void { $this->subscriberHistoryRepository = $this->createMock(SubscriberHistoryRepository::class); $this->subscriptionHistoryService = new SubscriberHistoryManager( - repository: $this->subscriberHistoryRepository + repository: $this->subscriberHistoryRepository, + clientIpResolver: $this->createMock(ClientIpResolver::class), + systemInfoCollector: $this->createMock(SystemInfoCollector::class), ); } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php index 9a177312..b7a99366 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php @@ -34,7 +34,7 @@ protected function setUp(): void subscriberRepository: $this->subscriberRepository, entityManager: $this->entityManager, messageBus: $this->messageBus, - subscriberDeletionService: $subscriberDeletionService + subscriberDeletionService: $subscriberDeletionService, ); } From 793c260640717c095a747c813df89aedde20cfe8 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Thu, 4 Sep 2025 13:32:22 +0400 Subject: [PATCH 05/20] EventLog + translator (#356) * EventLogManager * Log failed logins + translate messages * weblate * test fix * Use translations * Fix pipeline * Weblate * Deprecate DB translation table --------- Co-authored-by: Tatevik --- .github/workflows/i18n-validate.yml | 69 ++++++++++++++ .weblate | 23 +++++ config/config.yml | 5 +- config/services/managers.yml | 12 ++- config/services/repositories.yml | 15 ++- config/services/services.yml | 7 ++ resources/translations/messages.en.xlf | 44 +++++++++ src/Domain/Common/I18n/Messages.php | 29 ++++++ .../Model/Filter/EventLogFilter.php | 33 +++++++ src/Domain/Configuration/Model/I18n.php | 5 + .../Repository/EventLogRepository.php | 37 ++++++++ .../Repository/I18nRepository.php | 1 + .../Service/Manager/EventLogManager.php | 54 +++++++++++ .../Identity/Service/PasswordManager.php | 10 +- .../Identity/Service/SessionManager.php | 21 ++++- .../Service/Manager/SubscriptionManager.php | 16 +++- .../Service/Manager/EventLogManagerTest.php | 94 +++++++++++++++++++ .../Identity/Service/PasswordManagerTest.php | 4 +- .../Identity/Service/SessionManagerTest.php | 28 +++++- .../Manager/SubscriptionManagerTest.php | 11 ++- 20 files changed, 492 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/i18n-validate.yml create mode 100644 .weblate create mode 100644 resources/translations/messages.en.xlf create mode 100644 src/Domain/Common/I18n/Messages.php create mode 100644 src/Domain/Configuration/Model/Filter/EventLogFilter.php create mode 100644 src/Domain/Configuration/Service/Manager/EventLogManager.php create mode 100644 tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php diff --git a/.github/workflows/i18n-validate.yml b/.github/workflows/i18n-validate.yml new file mode 100644 index 00000000..4e49efa8 --- /dev/null +++ b/.github/workflows/i18n-validate.yml @@ -0,0 +1,69 @@ +name: I18n Validate + +on: + pull_request: + paths: + - 'resources/translations/**/*.xlf' + - 'composer.lock' + - 'composer.json' + +jobs: + validate-xliff: + runs-on: ubuntu-22.04 + + strategy: + fail-fast: false + matrix: + php: ['8.1'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: imap, zip + tools: composer:v2 + coverage: none + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: | + ~/.composer/cache/files + key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer-${{ matrix.php }}- + + - name: Install dependencies (no dev autoloader scripts) + run: | + set -euo pipefail + composer install --no-interaction --no-progress --prefer-dist + + - name: Lint XLIFF with Symfony + run: | + set -euo pipefail + # Adjust the directory to match your repo layout + php bin/console lint:xliff resources/translations + + - name: Validate XLIFF XML with xmllint + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends libxml2-utils + # Adjust root dir; prune vendor; accept spaces/newlines safely + find resources/translations -type f -name '*.xlf' -not -path '*/vendor/*' -print0 \ + | xargs -0 -n1 xmllint --noout + + - name: Symfony translation sanity (extract dry-run) + run: | + set -euo pipefail + # Show what would be created/updated without writing files + php bin/console translation:extract en \ + --format=xlf \ + --domain=messages \ + --dump-messages \ + --no-interaction + # Note: omit --force to keep this a dry-run diff --git a/.weblate b/.weblate new file mode 100644 index 00000000..5917a8b8 --- /dev/null +++ b/.weblate @@ -0,0 +1,23 @@ +# .weblate +--- +projects: + - slug: phplist-core + name: phpList core + components: + - slug: messages + name: Messages + files: + # {language} is Weblate’s placeholder (e.g., fr, de, es) + - src: resources/translations/messages.en.xlf + template: true + # Where localized files live (mirrors Symfony layout) + target: resources/translations/messages.{language}.xlf + file_format: xliff + language_code_style: bcp + # Ensure placeholders like %name% are preserved + parse_file_headers: true + check_flags: + - xml-invalid + - placeholders + - urls + - accelerated diff --git a/config/config.yml b/config/config.yml index e235f999..7de6dca6 100644 --- a/config/config.yml +++ b/config/config.yml @@ -10,7 +10,10 @@ parameters: framework: #esi: ~ - #translator: { fallbacks: ['%locale%'] } + translator: + default_path: '%kernel.project_dir%/resources/translations' + fallbacks: ['%locale%'] + secret: '%secret%' router: resource: '%kernel.project_dir%/config/routing.yml' diff --git a/config/services/managers.yml b/config/services/managers.yml index 5ef215b3..22dbe066 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -4,6 +4,14 @@ services: autoconfigure: true public: false + PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager: + autowire: true + autoconfigure: true + PhpList\Core\Domain\Identity\Service\SessionManager: autowire: true autoconfigure: true @@ -80,10 +88,6 @@ services: autowire: true autoconfigure: true - PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: - autowire: true - autoconfigure: true - PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager: autowire: true autoconfigure: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml index 82ae6a82..1289bea7 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -1,4 +1,14 @@ services: + PhpList\Core\Domain\Configuration\Repository\ConfigRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Configuration\Model\Config + + PhpList\Core\Domain\Configuration\Repository\EventLogRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Configuration\Model\EventLog + PhpList\Core\Domain\Identity\Repository\AdministratorRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: @@ -66,11 +76,6 @@ services: arguments: - PhpList\Core\Domain\Messaging\Model\TemplateImage - PhpList\Core\Domain\Configuration\Repository\ConfigRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Configuration\Model\Config - PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: diff --git a/config/services/services.yml b/config/services/services.yml index 19caddd8..1f509787 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -107,3 +107,10 @@ services: PhpList\Core\Domain\Messaging\Service\BounceActionResolver: arguments: - !tagged_iterator { tag: 'phplist.bounce_action_handler' } + + # I18n + PhpList\Core\Domain\Common\I18n\SimpleTranslator: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Common\I18n\TranslatorInterface: '@PhpList\Core\Domain\Common\I18n\SimpleTranslator' diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf new file mode 100644 index 00000000..7e176e3e --- /dev/null +++ b/resources/translations/messages.en.xlf @@ -0,0 +1,44 @@ + + + + + + + + Not authorized + Not authorized + + + + Failed admin login attempt for '%login%' + Failed admin login attempt for '%login%' + + + + Login attempt for disabled admin '%login%' + Login attempt for disabled admin '%login%' + + + + + Administrator not found + Administrator not found + + + + + Subscriber list not found. + Subscriber list not found. + + + Subscriber does not exists. + Subscriber does not exists. + + + Subscription not found for this subscriber and list. + Subscription not found for this subscriber and list. + + + + + diff --git a/src/Domain/Common/I18n/Messages.php b/src/Domain/Common/I18n/Messages.php new file mode 100644 index 00000000..f9e8822f --- /dev/null +++ b/src/Domain/Common/I18n/Messages.php @@ -0,0 +1,29 @@ +page; + } + + public function getDateFrom(): ?DateTimeInterface + { + return $this->dateFrom; + } + + public function getDateTo(): ?DateTimeInterface + { + return $this->dateTo; + } +} diff --git a/src/Domain/Configuration/Model/I18n.php b/src/Domain/Configuration/Model/I18n.php index bffed897..b8eefd63 100644 --- a/src/Domain/Configuration/Model/I18n.php +++ b/src/Domain/Configuration/Model/I18n.php @@ -8,6 +8,11 @@ use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Configuration\Repository\I18nRepository; +/** + * @deprecated + * + * Symfony\Contracts\Translation will be used instead. + */ #[ORM\Entity(repositoryClass: I18nRepository::class)] #[ORM\Table(name: 'phplist_i18n')] #[ORM\UniqueConstraint(name: 'lanorigunq', columns: ['lan', 'original'])] diff --git a/src/Domain/Configuration/Repository/EventLogRepository.php b/src/Domain/Configuration/Repository/EventLogRepository.php index 7caf5462..47640007 100644 --- a/src/Domain/Configuration/Repository/EventLogRepository.php +++ b/src/Domain/Configuration/Repository/EventLogRepository.php @@ -4,11 +4,48 @@ namespace PhpList\Core\Domain\Configuration\Repository; +use InvalidArgumentException; +use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface; use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; +use PhpList\Core\Domain\Configuration\Model\Filter\EventLogFilter; +use PhpList\Core\Domain\Configuration\Model\EventLog; class EventLogRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + /** + * @return EventLog[] + * @throws InvalidArgumentException + */ + public function getFilteredAfterId(int $lastId, int $limit, ?FilterRequestInterface $filter = null): array + { + $queryBuilder = $this->createQueryBuilder('e') + ->andWhere('e.id > :lastId') + ->setParameter('lastId', $lastId) + ->orderBy('e.id', 'ASC') + ->setMaxResults($limit); + + if ($filter === null) { + return $queryBuilder->getQuery()->getResult(); + } + + if (!$filter instanceof EventLogFilter) { + throw new InvalidArgumentException('Expected EventLogFilter.'); + } + + if ($filter->getPage() !== null) { + $queryBuilder->andWhere('e.page = :page')->setParameter('page', $filter->getPage()); + } + if ($filter->getDateFrom() !== null) { + $queryBuilder->andWhere('e.entered >= :dateFrom')->setParameter('dateFrom', $filter->getDateFrom()); + } + if ($filter->getDateTo() !== null) { + $queryBuilder->andWhere('e.entered <= :dateTo')->setParameter('dateTo', $filter->getDateTo()); + } + + return $queryBuilder->getQuery()->getResult(); + } } diff --git a/src/Domain/Configuration/Repository/I18nRepository.php b/src/Domain/Configuration/Repository/I18nRepository.php index f4465103..33fa599a 100644 --- a/src/Domain/Configuration/Repository/I18nRepository.php +++ b/src/Domain/Configuration/Repository/I18nRepository.php @@ -6,6 +6,7 @@ use PhpList\Core\Domain\Common\Repository\AbstractRepository; +/** @deprecated */ class I18nRepository extends AbstractRepository { } diff --git a/src/Domain/Configuration/Service/Manager/EventLogManager.php b/src/Domain/Configuration/Service/Manager/EventLogManager.php new file mode 100644 index 00000000..374db7ed --- /dev/null +++ b/src/Domain/Configuration/Service/Manager/EventLogManager.php @@ -0,0 +1,54 @@ +repository = $repository; + } + + public function log(string $page, string $entry): EventLog + { + $log = (new EventLog()) + ->setEntered(new DateTimeImmutable()) + ->setPage($page) + ->setEntry($entry); + + $this->repository->save($log); + + return $log; + } + + /** + * Get event logs with optional filters (page and date range) and cursor pagination. + * + * @return EventLog[] + */ + public function get( + int $lastId = 0, + int $limit = 50, + ?string $page = null, + ?DateTimeInterface $dateFrom = null, + ?DateTimeInterface $dateTo = null + ): array { + $filter = new EventLogFilter($page, $dateFrom, $dateTo); + return $this->repository->getFilteredAfterId($lastId, $limit, $filter); + } + + public function delete(EventLog $log): void + { + $this->repository->remove($log); + } +} diff --git a/src/Domain/Identity/Service/PasswordManager.php b/src/Domain/Identity/Service/PasswordManager.php index f6ad2a9e..2c7ebe1e 100644 --- a/src/Domain/Identity/Service/PasswordManager.php +++ b/src/Domain/Identity/Service/PasswordManager.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Domain\Identity\Service; use DateTime; +use PhpList\Core\Domain\Common\I18n\Messages; use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; @@ -13,6 +14,7 @@ use PhpList\Core\Security\HashGenerator; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Contracts\Translation\TranslatorInterface; class PasswordManager { @@ -22,17 +24,20 @@ class PasswordManager private AdministratorRepository $administratorRepository; private HashGenerator $hashGenerator; private MessageBusInterface $messageBus; + private TranslatorInterface $translator; public function __construct( AdminPasswordRequestRepository $passwordRequestRepository, AdministratorRepository $administratorRepository, HashGenerator $hashGenerator, - MessageBusInterface $messageBus + MessageBusInterface $messageBus, + TranslatorInterface $translator ) { $this->passwordRequestRepository = $passwordRequestRepository; $this->administratorRepository = $administratorRepository; $this->hashGenerator = $hashGenerator; $this->messageBus = $messageBus; + $this->translator = $translator; } /** @@ -47,7 +52,8 @@ public function generatePasswordResetToken(string $email): string { $administrator = $this->administratorRepository->findOneBy(['email' => $email]); if ($administrator === null) { - throw new NotFoundHttpException('Administrator not found', null, 1500567100); + $message = $this->translator->trans(Messages::IDENTITY_ADMIN_NOT_FOUND); + throw new NotFoundHttpException($message, null, 1500567100); } $existingRequests = $this->passwordRequestRepository->findByAdmin($administrator); diff --git a/src/Domain/Identity/Service/SessionManager.php b/src/Domain/Identity/Service/SessionManager.php index 52daafa3..82f52af1 100644 --- a/src/Domain/Identity/Service/SessionManager.php +++ b/src/Domain/Identity/Service/SessionManager.php @@ -4,6 +4,9 @@ namespace PhpList\Core\Domain\Identity\Service; +use PhpList\Core\Domain\Common\I18n\Messages; +use Symfony\Contracts\Translation\TranslatorInterface; +use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; @@ -13,24 +16,36 @@ class SessionManager { private AdministratorTokenRepository $tokenRepository; private AdministratorRepository $administratorRepository; + private EventLogManager $eventLogManager; + private TranslatorInterface $translator; public function __construct( AdministratorTokenRepository $tokenRepository, - AdministratorRepository $administratorRepository + AdministratorRepository $administratorRepository, + EventLogManager $eventLogManager, + TranslatorInterface $translator ) { $this->tokenRepository = $tokenRepository; $this->administratorRepository = $administratorRepository; + $this->eventLogManager = $eventLogManager; + $this->translator = $translator; } public function createSession(string $loginName, string $password): AdministratorToken { $administrator = $this->administratorRepository->findOneByLoginCredentials($loginName, $password); if ($administrator === null) { - throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567098); + $entry = $this->translator->trans(Messages::AUTH_LOGIN_FAILED, ['login' => $loginName]); + $this->eventLogManager->log('login', $entry); + $message = $this->translator->trans(Messages::AUTH_NOT_AUTHORIZED); + throw new UnauthorizedHttpException('', $message, null, 1500567098); } if ($administrator->isDisabled()) { - throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567099); + $entry = $this->translator->trans(Messages::AUTH_LOGIN_DISABLED, ['login' => $loginName]); + $this->eventLogManager->log('login', $entry); + $message = $this->translator->trans(Messages::AUTH_NOT_AUTHORIZED); + throw new UnauthorizedHttpException('', $message, null, 1500567099); } $token = new AdministratorToken(); diff --git a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php index bb3a0e14..764106ec 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Domain\Subscription\Service\Manager; +use PhpList\Core\Domain\Common\I18n\Messages; use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberList; @@ -11,21 +12,25 @@ use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository; +use Symfony\Contracts\Translation\TranslatorInterface; class SubscriptionManager { private SubscriptionRepository $subscriptionRepository; private SubscriberRepository $subscriberRepository; private SubscriberListRepository $subscriberListRepository; + private TranslatorInterface $translator; public function __construct( SubscriptionRepository $subscriptionRepository, SubscriberRepository $subscriberRepository, - SubscriberListRepository $subscriberListRepository + SubscriberListRepository $subscriberListRepository, + TranslatorInterface $translator ) { $this->subscriptionRepository = $subscriptionRepository; $this->subscriberRepository = $subscriberRepository; $this->subscriberListRepository = $subscriberListRepository; + $this->translator = $translator; } public function addSubscriberToAList(Subscriber $subscriber, int $listId): Subscription @@ -37,7 +42,8 @@ public function addSubscriberToAList(Subscriber $subscriber, int $listId): Subsc } $subscriberList = $this->subscriberListRepository->find($listId); if (!$subscriberList) { - throw new SubscriptionCreationException('Subscriber list not found.', 404); + $message = $this->translator->trans(Messages::SUBSCRIPTION_LIST_NOT_FOUND); + throw new SubscriptionCreationException($message, 404); } $subscription = new Subscription(); @@ -64,7 +70,8 @@ private function createSubscription(SubscriberList $subscriberList, string $emai { $subscriber = $this->subscriberRepository->findOneBy(['email' => $email]); if (!$subscriber) { - throw new SubscriptionCreationException('Subscriber does not exists.', 404); + $message = $this->translator->trans(Messages::SUBSCRIPTION_SUBSCRIBER_NOT_FOUND); + throw new SubscriptionCreationException($message, 404); } $existingSubscription = $this->subscriptionRepository @@ -101,7 +108,8 @@ private function deleteSubscription(SubscriberList $subscriberList, string $emai ->findOneBySubscriberEmailAndListId($subscriberList->getId(), $email); if (!$subscription) { - throw new SubscriptionCreationException('Subscription not found for this subscriber and list.', 404); + $message = $this->translator->trans(Messages::SUBSCRIPTION_NOT_FOUND_FOR_LIST_AND_SUBSCRIBER); + throw new SubscriptionCreationException($message, 404); } $this->subscriptionRepository->remove($subscription); diff --git a/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php b/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php new file mode 100644 index 00000000..818b8de0 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php @@ -0,0 +1,94 @@ +repository = $this->createMock(EventLogRepository::class); + $this->manager = new EventLogManager($this->repository); + } + + public function testLogCreatesAndPersists(): void + { + $this->repository->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(EventLog::class)); + + $log = $this->manager->log('dashboard', 'Viewed dashboard'); + + $this->assertInstanceOf(EventLog::class, $log); + $this->assertSame('dashboard', $log->getPage()); + $this->assertSame('Viewed dashboard', $log->getEntry()); + $this->assertNotNull($log->getEntered()); + $this->assertInstanceOf(DateTimeImmutable::class, $log->getEntered()); + } + + public function testDelete(): void + { + $log = new EventLog(); + $this->repository->expects($this->once()) + ->method('remove') + ->with($log); + + $this->manager->delete($log); + } + + public function testGetWithFiltersDelegatesToRepository(): void + { + $expected = [new EventLog(), new EventLog()]; + + $this->repository->expects($this->once()) + ->method('getFilteredAfterId') + ->with( + 100, + 25, + $this->callback(function (EventLogFilter $filter) { + // Use getters to validate + return method_exists($filter, 'getPage') + && $filter->getPage() === 'settings' + && $filter->getDateFrom() instanceof DateTimeImmutable + && $filter->getDateTo() instanceof DateTimeImmutable + && $filter->getDateFrom() <= $filter->getDateTo(); + }) + ) + ->willReturn($expected); + + $from = new DateTimeImmutable('-2 days'); + $to = new DateTimeImmutable('now'); + $result = $this->manager->get(lastId: 100, limit: 25, page: 'settings', dateFrom: $from, dateTo: $to); + + $this->assertSame($expected, $result); + } + + public function testGetWithoutFiltersDefaults(): void + { + $expected = []; + + $this->repository->expects($this->once()) + ->method('getFilteredAfterId') + ->with( + 0, + 50, + $this->anything() + ) + ->willReturn($expected); + + $result = $this->manager->get(); + $this->assertSame($expected, $result); + } +} diff --git a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php index 85e02f81..59ace13d 100644 --- a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php @@ -17,6 +17,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Contracts\Translation\TranslatorInterface; class PasswordManagerTest extends TestCase { @@ -36,7 +37,8 @@ protected function setUp(): void passwordRequestRepository: $this->passwordRequestRepository, administratorRepository: $this->administratorRepository, hashGenerator: $this->hashGenerator, - messageBus: $this->messageBus + messageBus: $this->messageBus, + translator: $this->createMock(TranslatorInterface::class) ); } diff --git a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php index 44072452..14419b0e 100644 --- a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php @@ -4,16 +4,19 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Service; +use PhpList\Core\Domain\Common\I18n\Messages; +use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; use PhpList\Core\Domain\Identity\Service\SessionManager; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; +use Symfony\Contracts\Translation\TranslatorInterface; class SessionManagerTest extends TestCase { - public function testCreateSessionWithInvalidCredentialsThrowsException(): void + public function testCreateSessionWithInvalidCredentialsThrowsExceptionAndLogs(): void { $adminRepo = $this->createMock(AdministratorRepository::class); $adminRepo->expects(self::once()) @@ -24,7 +27,24 @@ public function testCreateSessionWithInvalidCredentialsThrowsException(): void $tokenRepo = $this->createMock(AdministratorTokenRepository::class); $tokenRepo->expects(self::never())->method('save'); - $manager = new SessionManager($tokenRepo, $adminRepo); + $eventLogManager = $this->createMock(EventLogManager::class); + $eventLogManager->expects(self::once()) + ->method('log') + ->with('login', $this->stringContains('admin')); + + $translator = $this->createMock(TranslatorInterface::class); + $translator->expects(self::exactly(2)) + ->method('trans') + ->withConsecutive( + [Messages::AUTH_LOGIN_FAILED, ['login' => 'admin']], + [Messages::AUTH_NOT_AUTHORIZED, []] + ) + ->willReturnOnConsecutiveCalls( + "Failed admin login attempt for 'admin'", + 'Not authorized' + ); + + $manager = new SessionManager($tokenRepo, $adminRepo, $eventLogManager, $translator); $this->expectException(UnauthorizedHttpException::class); $this->expectExceptionMessage('Not authorized'); @@ -42,8 +62,10 @@ public function testDeleteSessionCallsRemove(): void ->with($token); $adminRepo = $this->createMock(AdministratorRepository::class); + $eventLogManager = $this->createMock(EventLogManager::class); + $translator = $this->createMock(TranslatorInterface::class); - $manager = new SessionManager($tokenRepo, $adminRepo); + $manager = new SessionManager($tokenRepo, $adminRepo, $eventLogManager, $translator); $manager->deleteSession($token); } } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php index e535a7fe..f0c1d3af 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php @@ -14,11 +14,13 @@ use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Contracts\Translation\TranslatorInterface; class SubscriptionManagerTest extends TestCase { private SubscriptionRepository&MockObject $subscriptionRepository; private SubscriberRepository&MockObject $subscriberRepository; + private TranslatorInterface&MockObject $translator; private SubscriptionManager $manager; protected function setUp(): void @@ -26,10 +28,12 @@ protected function setUp(): void $this->subscriptionRepository = $this->createMock(SubscriptionRepository::class); $this->subscriberRepository = $this->createMock(SubscriberRepository::class); $subscriberListRepository = $this->createMock(SubscriberListRepository::class); + $this->translator = $this->createMock(TranslatorInterface::class); $this->manager = new SubscriptionManager( - $this->subscriptionRepository, - $this->subscriberRepository, - $subscriberListRepository + subscriptionRepository: $this->subscriptionRepository, + subscriberRepository: $this->subscriberRepository, + subscriberListRepository: $subscriberListRepository, + translator: $this->translator, ); } @@ -51,6 +55,7 @@ public function testCreateSubscriptionWhenSubscriberExists(): void public function testCreateSubscriptionThrowsWhenSubscriberMissing(): void { + $this->translator->method('trans')->willReturn('Subscriber does not exists.'); $this->expectException(SubscriptionCreationException::class); $this->expectExceptionMessage('Subscriber does not exists.'); From 936cabcdef5339b36e334cc1501b113c65ad8761 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Fri, 5 Sep 2025 12:15:52 +0400 Subject: [PATCH 06/20] Access level check (#358) * OwnableInterface * PermissionChecker * Check related * Register service + test * Style fix --------- Co-authored-by: Tatevik --- config/services/providers.yml | 4 - config/services/services.yml | 6 +- .../Model/Interfaces/OwnableInterface.php | 12 +++ src/Domain/Identity/Model/Administrator.php | 10 +++ .../Identity/Service/PermissionChecker.php | 89 +++++++++++++++++++ src/Domain/Messaging/Model/Message.php | 3 +- .../Subscription/Model/SubscribePage.php | 3 +- .../Subscription/Model/SubscriberList.php | 3 +- .../Service/PermissionCheckerTest.php | 35 ++++++++ 9 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 src/Domain/Common/Model/Interfaces/OwnableInterface.php create mode 100644 src/Domain/Identity/Service/PermissionChecker.php create mode 100644 tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php diff --git a/config/services/providers.yml b/config/services/providers.yml index cb784988..226c4e81 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -2,7 +2,3 @@ services: PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider: autowire: true autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Provider\BounceActionProvider: - autowire: true - autoconfigure: true diff --git a/config/services/services.yml b/config/services/services.yml index 1f509787..f1b68e74 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -108,9 +108,7 @@ services: arguments: - !tagged_iterator { tag: 'phplist.bounce_action_handler' } - # I18n - PhpList\Core\Domain\Common\I18n\SimpleTranslator: + PhpList\Core\Domain\Identity\Service\PermissionChecker: autowire: true autoconfigure: true - - PhpList\Core\Domain\Common\I18n\TranslatorInterface: '@PhpList\Core\Domain\Common\I18n\SimpleTranslator' + public: true diff --git a/src/Domain/Common/Model/Interfaces/OwnableInterface.php b/src/Domain/Common/Model/Interfaces/OwnableInterface.php new file mode 100644 index 00000000..16e54e40 --- /dev/null +++ b/src/Domain/Common/Model/Interfaces/OwnableInterface.php @@ -0,0 +1,12 @@ +modifiedBy; } + + public function owns(OwnableInterface $resource): bool + { + if ($this->getId() === null) { + return false; + } + + return $resource->getOwner()->getId() === $this->getId(); + } } diff --git a/src/Domain/Identity/Service/PermissionChecker.php b/src/Domain/Identity/Service/PermissionChecker.php new file mode 100644 index 00000000..8fc241b7 --- /dev/null +++ b/src/Domain/Identity/Service/PermissionChecker.php @@ -0,0 +1,89 @@ + PrivilegeFlag::Subscribers, + SubscriberList::class => PrivilegeFlag::Subscribers, + Message::class => PrivilegeFlag::Campaigns, + ]; + + private const OWNERSHIP_MAP = [ + Subscriber::class => SubscriberList::class, + Message::class => SubscriberList::class + ]; + + public function canManage(Administrator $actor, DomainModel $resource): bool + { + if ($actor->isSuperUser()) { + return true; + } + + $required = $this->resolveRequiredPrivilege($resource); + if ($required !== null && !$actor->getPrivileges()->has($required)) { + return false; + } + + if ($resource instanceof OwnableInterface) { + return $actor->owns($resource); + } + + $notRestricted = true; + foreach (self::OWNERSHIP_MAP as $resourceClass => $relatedClass) { + if ($resource instanceof $resourceClass) { + $related = $this->resolveRelatedEntity($resource, $relatedClass); + $notRestricted = $this->checkRelatedResources($related, $actor); + } + } + + return $notRestricted; + } + + private function resolveRequiredPrivilege(DomainModel $resource): ?PrivilegeFlag + { + foreach (self::REQUIRED_PRIVILEGE_MAP as $class => $flag) { + if ($resource instanceof $class) { + return $flag; + } + } + + return null; + } + + /** @return OwnableInterface[] */ + private function resolveRelatedEntity(DomainModel $resource, string $relatedClass): array + { + if ($resource instanceof Subscriber && $relatedClass === SubscriberList::class) { + return $resource->getSubscribedLists()->toArray(); + } + + if ($resource instanceof Message && $relatedClass === SubscriberList::class) { + return $resource->getListMessages()->map(fn($lm) => $lm->getSubscriberList())->toArray(); + } + + return []; + } + + private function checkRelatedResources(array $related, Administrator $actor): bool + { + foreach ($related as $relatedResource) { + if ($actor->owns($relatedResource)) { + return true; + } + } + + return false; + } +} diff --git a/src/Domain/Messaging/Model/Message.php b/src/Domain/Messaging/Model/Message.php index fbbfec8a..5064c4f1 100644 --- a/src/Domain/Messaging/Model/Message.php +++ b/src/Domain/Messaging/Model/Message.php @@ -11,6 +11,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Common\Model\Interfaces\Identity; use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat; @@ -23,7 +24,7 @@ #[ORM\Table(name: 'phplist_message')] #[ORM\Index(name: 'uuididx', columns: ['uuid'])] #[ORM\HasLifecycleCallbacks] -class Message implements DomainModel, Identity, ModificationDate +class Message implements DomainModel, Identity, ModificationDate, OwnableInterface { #[ORM\Id] #[ORM\Column(type: 'integer')] diff --git a/src/Domain/Subscription/Model/SubscribePage.php b/src/Domain/Subscription/Model/SubscribePage.php index e4696380..979b3c4c 100644 --- a/src/Domain/Subscription/Model/SubscribePage.php +++ b/src/Domain/Subscription/Model/SubscribePage.php @@ -7,12 +7,13 @@ use Doctrine\ORM\Mapping as ORM; use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Common\Model\Interfaces\Identity; +use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository; #[ORM\Entity(repositoryClass: SubscriberPageRepository::class)] #[ORM\Table(name: 'phplist_subscribepage')] -class SubscribePage implements DomainModel, Identity +class SubscribePage implements DomainModel, Identity, OwnableInterface { #[ORM\Id] #[ORM\Column(type: 'integer')] diff --git a/src/Domain/Subscription/Model/SubscriberList.php b/src/Domain/Subscription/Model/SubscriberList.php index 947cbe26..32f85f5d 100644 --- a/src/Domain/Subscription/Model/SubscriberList.php +++ b/src/Domain/Subscription/Model/SubscriberList.php @@ -12,6 +12,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Common\Model\Interfaces\Identity; use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\ListMessage; use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; @@ -28,7 +29,7 @@ #[ORM\Index(name: 'nameidx', columns: ['name'])] #[ORM\Index(name: 'listorderidx', columns: ['listorder'])] #[ORM\HasLifecycleCallbacks] -class SubscriberList implements DomainModel, Identity, CreationDate, ModificationDate +class SubscriberList implements DomainModel, Identity, CreationDate, ModificationDate, OwnableInterface { #[ORM\Id] #[ORM\Column(type: 'integer')] diff --git a/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php b/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php new file mode 100644 index 00000000..50820026 --- /dev/null +++ b/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php @@ -0,0 +1,35 @@ +checker = self::getContainer()->get(PermissionChecker::class); + } + + public function testServiceIsRegisteredInContainer(): void + { + self::assertInstanceOf(PermissionChecker::class, $this->checker); + self::assertSame($this->checker, self::getContainer()->get(PermissionChecker::class)); + } + + public function testSuperUserCanManageAnyResource(): void + { + $admin = new Administrator(); + $admin->setSuperUser(true); + $resource = $this->createMock(SubscriberList::class); + $this->assertTrue($this->checker->canManage($admin, $resource)); + } +} From fa422581db081fbe93f20bffe8a17e8dc0526f5c Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Wed, 17 Sep 2025 12:19:14 +0400 Subject: [PATCH 07/20] Message processor (#359) * MessageStatusEnum * Status validate * Embargo check * IspRestrictions * IspRestrictions * SendRateLimiter * UserMessageStatus * Refactor * RateLimitedCampaignMailer * RateLimitedCampaignMailerTest * Rate limit initialized from history * Check maintenance mode * Max processing time limiter --------- Co-authored-by: Tatevik --- config/parameters.yml.dist | 13 ++ config/services.yml | 4 - config/services/providers.yml | 10 ++ config/services/services.yml | 19 +++ src/Domain/Common/IspRestrictionsProvider.php | 137 ++++++++++++++++ src/Domain/Common/Model/IspRestrictions.php | 21 +++ .../Service/Manager/ConfigManager.php | 6 + .../Messaging/Command/ProcessQueueCommand.php | 17 +- .../Model/Dto/Message/MessageMetadataDto.php | 4 +- .../Model/Message/MessageMetadata.php | 20 ++- .../Messaging/Model/Message/MessageStatus.php | 38 +++++ .../Model/Message/UserMessageStatus.php | 16 ++ src/Domain/Messaging/Model/UserMessage.php | 12 +- .../Repository/MessageRepository.php | 12 ++ .../Repository/UserMessageRepository.php | 24 +++ .../Service/Builder/MessageBuilder.php | 2 +- .../Service/Handler/RequeueHandler.php | 60 +++++++ .../Service/Manager/MessageManager.php | 8 + .../Service/MaxProcessTimeLimiter.php | 46 ++++++ .../Service/Processor/CampaignProcessor.php | 92 +++++++++-- .../Service/RateLimitedCampaignMailer.php | 50 ++++++ .../Messaging/Service/SendRateLimiter.php | 103 ++++++++++++ .../Service/Provider/SubscriberProvider.php | 2 +- .../Repository/MessageRepositoryTest.php | 6 +- .../Service/SubscriberDeletionServiceTest.php | 4 +- .../Command/ProcessQueueCommandTest.php | 20 ++- .../Service/Builder/MessageBuilderTest.php | 15 +- .../Builder/MessageContentBuilderTest.php | 2 +- .../Builder/MessageFormatBuilderTest.php | 2 +- .../Builder/MessageOptionsBuilderTest.php | 2 +- .../Builder/MessageScheduleBuilderTest.php | 2 +- .../Service/Handler/RequeueHandlerTest.php | 155 ++++++++++++++++++ .../Service/Manager/MessageManagerTest.php | 17 +- .../Service/MaxProcessTimeLimiterTest.php | 53 ++++++ .../Processor/CampaignProcessorTest.php | 105 ++++++------ .../Service/RateLimitedCampaignMailerTest.php | 134 +++++++++++++++ .../Messaging/Service/SendRateLimiterTest.php | 90 ++++++++++ .../Provider/SubscriberProviderTest.php | 14 +- 38 files changed, 1207 insertions(+), 130 deletions(-) create mode 100644 src/Domain/Common/IspRestrictionsProvider.php create mode 100644 src/Domain/Common/Model/IspRestrictions.php create mode 100644 src/Domain/Messaging/Model/Message/MessageStatus.php create mode 100644 src/Domain/Messaging/Model/Message/UserMessageStatus.php create mode 100644 src/Domain/Messaging/Service/Handler/RequeueHandler.php create mode 100644 src/Domain/Messaging/Service/MaxProcessTimeLimiter.php create mode 100644 src/Domain/Messaging/Service/RateLimitedCampaignMailer.php create mode 100644 src/Domain/Messaging/Service/SendRateLimiter.php create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index 54c649d8..e34a7d2b 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -68,3 +68,16 @@ parameters: graylog_host: 'graylog.example.com' graylog_port: 12201 + + app.phplist_isp_conf_path: '%%env(APP_PHPLIST_ISP_CONF_PATH)%%' + env(APP_PHPLIST_ISP_CONF_PATH): '/etc/phplist.conf' + + # Message sending + messaging.mail_queue_batch_size: '%%env(MAILQUEUE_BATCH_SIZE)%%' + env(MAILQUEUE_BATCH_SIZE): '5' + messaging.mail_queue_period: '%%env(MAILQUEUE_BATCH_PERIOD)%%' + env(MAILQUEUE_BATCH_PERIOD): '5' + messaging.mail_queue_throttle: '%%env(MAILQUEUE_THROTTLE)%%' + env(MAILQUEUE_THROTTLE): '5' + messaging.max_process_time: '%%env(MESSAGING_MAX_PROCESS_TIME)%%' + env(MESSAGING_MAX_PROCESS_TIME): '600' diff --git a/config/services.yml b/config/services.yml index 47be8241..b21dc5aa 100644 --- a/config/services.yml +++ b/config/services.yml @@ -7,10 +7,6 @@ services: autoconfigure: true public: false - PhpList\Core\Core\ConfigProvider: - arguments: - $config: '%app.config%' - PhpList\Core\Core\ApplicationStructure: public: true diff --git a/config/services/providers.yml b/config/services/providers.yml index 226c4e81..bb4524c3 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -2,3 +2,13 @@ services: PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider: autowire: true autoconfigure: true + + PhpList\Core\Core\ConfigProvider: + arguments: + $config: '%app.config%' + + PhpList\Core\Domain\Common\IspRestrictionsProvider: + autowire: true + autoconfigure: true + arguments: + $confPath: '%app.phplist_isp_conf_path%' diff --git a/config/services/services.yml b/config/services/services.yml index f1b68e74..1afd1fc5 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -36,6 +36,14 @@ services: autoconfigure: true public: true + PhpList\Core\Domain\Messaging\Service\SendRateLimiter: + autowire: true + autoconfigure: true + arguments: + $mailqueueBatchSize: '%messaging.mail_queue_batch_size%' + $mailqueueBatchPeriod: '%messaging.mail_queue_period%' + $mailqueueThrottle: '%messaging.mail_queue_throttle%' + PhpList\Core\Domain\Common\ClientIpResolver: autowire: true autoconfigure: true @@ -44,6 +52,10 @@ services: autowire: true autoconfigure: true + PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer: + autowire: true + autoconfigure: true + PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler: autowire: true autoconfigure: true @@ -108,6 +120,13 @@ services: arguments: - !tagged_iterator { tag: 'phplist.bounce_action_handler' } + PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter: + autowire: true + autoconfigure: true + arguments: + $maxSeconds: '%messaging.max_process_time%' + + PhpList\Core\Domain\Identity\Service\PermissionChecker: autowire: true autoconfigure: true diff --git a/src/Domain/Common/IspRestrictionsProvider.php b/src/Domain/Common/IspRestrictionsProvider.php new file mode 100644 index 00000000..4095f5ce --- /dev/null +++ b/src/Domain/Common/IspRestrictionsProvider.php @@ -0,0 +1,137 @@ +readConfigFile(); + if ($contents === null) { + return new IspRestrictions(null, null, null); + } + + [$raw, $maxBatch, $minBatchPeriod, $lockFile] = $this->parseContents($contents); + + $this->logIfDetected($maxBatch, $minBatchPeriod, $lockFile); + + return new IspRestrictions($maxBatch, $minBatchPeriod, $lockFile, $raw); + } + + private function readConfigFile(): ?string + { + if (!is_file($this->confPath) || !is_readable($this->confPath)) { + return null; + } + $contents = file_get_contents($this->confPath); + if ($contents === false) { + $this->logger->warning('Cannot read ISP restrictions file', ['path' => $this->confPath]); + return null; + } + return $contents; + } + + /** + * @return array{0: array, 1: ?int, 2: ?int, 3: ?string} + */ + private function parseContents(string $contents): array + { + $maxBatch = null; + $minBatchPeriod = null; + $lockFile = null; + $raw = []; + + foreach (preg_split('/\R/', $contents) as $line) { + [$key, $val] = $this->parseLine($line); + if ($key === null) { + continue; + } + $raw[$key] = $val; + [$maxBatch, $minBatchPeriod, $lockFile] = $this->applyKeyValue( + $key, + $val, + $maxBatch, + $minBatchPeriod, + $lockFile + ); + } + + return [$raw, $maxBatch, $minBatchPeriod, $lockFile]; + } + + /** + * @return array{0: ?string, 1: string} + */ + private function parseLine(string $line): array + { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#') || str_starts_with($line, ';')) { + return [null, '']; + } + $parts = explode('=', $line, 2); + if (\count($parts) !== 2) { + return [null, '']; + } + + return array_map('trim', $parts); + } + + /** + * @param string $key + * @param string $val + * @param ?int $maxBatch + * @param ?int $minBatchPeriod + * @param ?string $lockFile + * @return array{0: ?int, 1: ?int, 2: ?string} + */ + private function applyKeyValue( + string $key, + string $val, + ?int $maxBatch, + ?int $minBatchPeriod, + ?string $lockFile + ): array { + if ($key === 'maxbatch') { + if ($val !== '' && ctype_digit($val)) { + $maxBatch = (int) $val; + } + return [$maxBatch, $minBatchPeriod, $lockFile]; + } + if ($key === 'minbatchperiod') { + if ($val !== '' && ctype_digit($val)) { + $minBatchPeriod = (int) $val; + } + return [$maxBatch, $minBatchPeriod, $lockFile]; + } + if ($key === 'lockfile') { + if ($val !== '') { + $lockFile = $val; + } + return [$maxBatch, $minBatchPeriod, $lockFile]; + } + return [$maxBatch, $minBatchPeriod, $lockFile]; + } + + private function logIfDetected(?int $maxBatch, ?int $minBatchPeriod, ?string $lockFile): void + { + if ($maxBatch !== null || $minBatchPeriod !== null || $lockFile !== null) { + $this->logger->info('ISP restrictions detected', [ + 'path' => $this->confPath, + 'maxbatch' => $maxBatch, + 'minbatchperiod' => $minBatchPeriod, + 'lockfile' => $lockFile, + ]); + } + } +} diff --git a/src/Domain/Common/Model/IspRestrictions.php b/src/Domain/Common/Model/IspRestrictions.php new file mode 100644 index 00000000..c3fc56b4 --- /dev/null +++ b/src/Domain/Common/Model/IspRestrictions.php @@ -0,0 +1,21 @@ +maxBatch === null && $this->minBatchPeriod === null && $this->lockFile === null; + } +} diff --git a/src/Domain/Configuration/Service/Manager/ConfigManager.php b/src/Domain/Configuration/Service/Manager/ConfigManager.php index cae380be..1a9c356f 100644 --- a/src/Domain/Configuration/Service/Manager/ConfigManager.php +++ b/src/Domain/Configuration/Service/Manager/ConfigManager.php @@ -17,6 +17,12 @@ public function __construct(ConfigRepository $configRepository) $this->configRepository = $configRepository; } + public function inMaintenanceMode(): bool + { + $config = $this->getByItem('maintenancemode'); + return $config?->getValue() === '1'; + } + /** * Get a configuration item by its key */ diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php index 820d403d..d2c7cbfa 100644 --- a/src/Domain/Messaging/Command/ProcessQueueCommand.php +++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php @@ -4,6 +4,9 @@ namespace PhpList\Core\Domain\Messaging\Command; +use DateTimeImmutable; +use PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager; +use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor; @@ -24,18 +27,21 @@ class ProcessQueueCommand extends Command private LockFactory $lockFactory; private MessageProcessingPreparator $messagePreparator; private CampaignProcessor $campaignProcessor; + private ConfigManager $configManager; public function __construct( MessageRepository $messageRepository, LockFactory $lockFactory, MessageProcessingPreparator $messagePreparator, CampaignProcessor $campaignProcessor, + ConfigManager $configManager ) { parent::__construct(); $this->messageRepository = $messageRepository; $this->lockFactory = $lockFactory; $this->messagePreparator = $messagePreparator; $this->campaignProcessor = $campaignProcessor; + $this->configManager = $configManager; } /** @@ -50,11 +56,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } + if ($this->configManager->inMaintenanceMode()) { + $output->writeln('The system is in maintenance mode, stopping. Try again later.'); + + return Command::FAILURE; + } + try { $this->messagePreparator->ensureSubscribersHaveUuid($output); $this->messagePreparator->ensureCampaignsHaveUuid($output); - $campaigns = $this->messageRepository->findBy(['status' => 'submitted']); + $campaigns = $this->messageRepository->getByStatusAndEmbargo( + status: MessageStatus::Submitted, + embargo: new DateTimeImmutable() + ); foreach ($campaigns as $campaign) { $this->campaignProcessor->process($campaign, $output); diff --git a/src/Domain/Messaging/Model/Dto/Message/MessageMetadataDto.php b/src/Domain/Messaging/Model/Dto/Message/MessageMetadataDto.php index 0776c1d1..91802df2 100644 --- a/src/Domain/Messaging/Model/Dto/Message/MessageMetadataDto.php +++ b/src/Domain/Messaging/Model/Dto/Message/MessageMetadataDto.php @@ -4,10 +4,12 @@ namespace PhpList\Core\Domain\Messaging\Model\Dto\Message; +use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; + class MessageMetadataDto { public function __construct( - public readonly string $status, + public readonly MessageStatus $status, ) { } } diff --git a/src/Domain/Messaging/Model/Message/MessageMetadata.php b/src/Domain/Messaging/Model/Message/MessageMetadata.php index 123103ff..156539b2 100644 --- a/src/Domain/Messaging/Model/Message/MessageMetadata.php +++ b/src/Domain/Messaging/Model/Message/MessageMetadata.php @@ -6,6 +6,7 @@ use DateTime; use Doctrine\ORM\Mapping as ORM; +use InvalidArgumentException; use PhpList\Core\Domain\Common\Model\Interfaces\EmbeddableInterface; #[ORM\Embeddable] @@ -33,13 +34,13 @@ class MessageMetadata implements EmbeddableInterface private ?DateTime $sendStart; public function __construct( - ?string $status = null, + ?MessageStatus $status = null, int $bounceCount = 0, ?DateTime $entered = null, ?DateTime $sent = null, ?DateTime $sendStart = null, ) { - $this->status = $status; + $this->status = $status->value ?? null; $this->processed = false; $this->viewed = 0; $this->bounceCount = $bounceCount; @@ -48,14 +49,21 @@ public function __construct( $this->sendStart = $sendStart; } - public function getStatus(): ?string + /** + * @SuppressWarnings("PHPMD.StaticAccess") + */ + public function getStatus(): ?MessageStatus { - return $this->status; + return MessageStatus::from($this->status); } - public function setStatus(string $status): self + public function setStatus(MessageStatus $status): self { - $this->status = $status; + if (!$this->getStatus()->canTransitionTo($status)) { + throw new InvalidArgumentException('Invalid status transition'); + } + $this->status = $status->value; + return $this; } diff --git a/src/Domain/Messaging/Model/Message/MessageStatus.php b/src/Domain/Messaging/Model/Message/MessageStatus.php new file mode 100644 index 00000000..90b7f987 --- /dev/null +++ b/src/Domain/Messaging/Model/Message/MessageStatus.php @@ -0,0 +1,38 @@ + [self::Submitted], + self::Submitted => [self::Prepared, self::InProcess], + self::Prepared => [self::InProcess], + self::InProcess => [self::Sent, self::Suspended, self::Submitted], + self::Requeued => [self::InProcess, self::Suspended], + self::Sent => [], + }; + } + + public function canTransitionTo(self $next): bool + { + return in_array($next, $this->allowedTransitions(), true); + } +} diff --git a/src/Domain/Messaging/Model/Message/UserMessageStatus.php b/src/Domain/Messaging/Model/Message/UserMessageStatus.php new file mode 100644 index 00000000..1237cfe8 --- /dev/null +++ b/src/Domain/Messaging/Model/Message/UserMessageStatus.php @@ -0,0 +1,16 @@ +viewed; } - public function getStatus(): ?string + /** + * @SuppressWarnings("PHPMD.StaticAccess") + */ + public function getStatus(): ?UserMessageStatus { - return $this->status; + return UserMessageStatus::from($this->status); } public function setViewed(?DateTime $viewed): self @@ -76,9 +80,9 @@ public function setViewed(?DateTime $viewed): self return $this; } - public function setStatus(?string $status): self + public function setStatus(?UserMessageStatus $status): self { - $this->status = $status; + $this->status = $status->value; return $this; } } diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php index 3da7ebf3..0ae8bc18 100644 --- a/src/Domain/Messaging/Repository/MessageRepository.php +++ b/src/Domain/Messaging/Repository/MessageRepository.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Domain\Messaging\Repository; +use DateTimeImmutable; use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface; use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; @@ -74,4 +75,15 @@ public function incrementBounceCount(int $messageId): void ->getQuery() ->execute(); } + + public function getByStatusAndEmbargo(Message\MessageStatus $status, DateTimeImmutable $embargo): array + { + return $this->createQueryBuilder('m') + ->where('m.status = :status') + ->andWhere('m.embargo IS NULL OR m.embargo <= :embargo') + ->setParameter('status', $status->value) + ->setParameter('embargo', $embargo) + ->getQuery() + ->getResult(); + } } diff --git a/src/Domain/Messaging/Repository/UserMessageRepository.php b/src/Domain/Messaging/Repository/UserMessageRepository.php index a19c5823..e8268025 100644 --- a/src/Domain/Messaging/Repository/UserMessageRepository.php +++ b/src/Domain/Messaging/Repository/UserMessageRepository.php @@ -4,8 +4,32 @@ namespace PhpList\Core\Domain\Messaging\Repository; +use DateTimeInterface; use PhpList\Core\Domain\Common\Repository\AbstractRepository; +use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus; +use PhpList\Core\Domain\Messaging\Model\UserMessage; +use PhpList\Core\Domain\Subscription\Model\Subscriber; class UserMessageRepository extends AbstractRepository { + public function findOneByUserAndMessage(Subscriber $subscriber, Message $campaign): ?UserMessage + { + return $this->findOneBy(['user' => $subscriber, 'message' => $campaign]); + } + + /** + * Counts how many user messages have status "sent" since the given time. + */ + public function countSentSince(DateTimeInterface $since): int + { + $queryBuilder = $this->createQueryBuilder('um'); + $queryBuilder->select('COUNT(um)') + ->where('um.createdAt > :since') + ->andWhere('um.status = :status') + ->setParameter('since', $since) + ->setParameter('status', UserMessageStatus::Sent->value); + + return (int) $queryBuilder->getQuery()->getSingleScalarResult(); + } } diff --git a/src/Domain/Messaging/Service/Builder/MessageBuilder.php b/src/Domain/Messaging/Service/Builder/MessageBuilder.php index e8807170..85e74331 100644 --- a/src/Domain/Messaging/Service/Builder/MessageBuilder.php +++ b/src/Domain/Messaging/Service/Builder/MessageBuilder.php @@ -45,7 +45,7 @@ public function build(MessageDtoInterface $createMessageDto, object $context = n return $context->getExisting(); } - $metadata = new Message\MessageMetadata($createMessageDto->getMetadata()->status); + $metadata = new Message\MessageMetadata(Message\MessageStatus::Draft); return new Message($format, $schedule, $metadata, $content, $options, $context->getOwner(), $template); } diff --git a/src/Domain/Messaging/Service/Handler/RequeueHandler.php b/src/Domain/Messaging/Service/Handler/RequeueHandler.php new file mode 100644 index 00000000..6a1d9d95 --- /dev/null +++ b/src/Domain/Messaging/Service/Handler/RequeueHandler.php @@ -0,0 +1,60 @@ +getSchedule(); + $interval = $schedule->getRequeueInterval() ?? 0; + $until = $schedule->getRequeueUntil(); + + if ($interval <= 0) { + return false; + } + $now = new DateTime(); + if ($until instanceof DateTime && $now > $until) { + return false; + } + + $embargoIsInFuture = $schedule->getEmbargo() instanceof DateTime && $schedule->getEmbargo() > new DateTime(); + $base = $embargoIsInFuture ? clone $schedule->getEmbargo() : new DateTime(); + $next = (clone $base)->add(new DateInterval('PT' . max(1, $interval) . 'M')); + if ($until instanceof DateTime && $next > $until) { + return false; + } + + $schedule->setEmbargo($next); + $campaign->setSchedule($schedule); + $campaign->getMetadata()->setStatus(MessageStatus::Submitted); + $this->entityManager->flush(); + + $output?->writeln(sprintf( + 'Requeued campaign; next embargo at %s', + $next->format(DateTime::ATOM) + )); + $this->logger->info('Campaign requeued with new embargo', [ + 'campaign_id' => $campaign->getId(), + 'embargo' => $next->format(DateTime::ATOM), + ]); + + return true; + } +} diff --git a/src/Domain/Messaging/Service/Manager/MessageManager.php b/src/Domain/Messaging/Service/Manager/MessageManager.php index 7b263083..c73f31ca 100644 --- a/src/Domain/Messaging/Service/Manager/MessageManager.php +++ b/src/Domain/Messaging/Service/Manager/MessageManager.php @@ -43,6 +43,14 @@ public function updateMessage( return $message; } + public function updateStatus(Message $message, Message\MessageStatus $status): Message + { + $message->getMetadata()->setStatus($status); + $this->messageRepository->save($message); + + return $message; + } + public function delete(Message $message): void { $this->messageRepository->remove($message); diff --git a/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php b/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php new file mode 100644 index 00000000..c5269aaa --- /dev/null +++ b/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php @@ -0,0 +1,46 @@ +maxSeconds = $maxSeconds ?? 600; + } + + public function start(): void + { + $this->startedAt = microtime(true); + } + + public function shouldStop(?OutputInterface $output = null): bool + { + if ($this->maxSeconds <= 0) { + return false; + } + if ($this->startedAt <= 0.0) { + $this->start(); + } + $elapsed = microtime(true) - $this->startedAt; + if ($elapsed >= $this->maxSeconds) { + $this->logger->warning(sprintf('Reached max processing time of %d seconds', $this->maxSeconds)); + $output?->writeln('Reached max processing time; stopping cleanly.'); + + return true; + } + + return false; + } +} diff --git a/src/Domain/Messaging/Service/Processor/CampaignProcessor.php b/src/Domain/Messaging/Service/Processor/CampaignProcessor.php index 13a100a3..92313e28 100644 --- a/src/Domain/Messaging/Service/Processor/CampaignProcessor.php +++ b/src/Domain/Messaging/Service/Processor/CampaignProcessor.php @@ -6,70 +6,126 @@ use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Messaging\Model\UserMessage; +use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus; +use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; +use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; +use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; +use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer; +use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider; +use PhpList\Core\Domain\Subscription\Model\Subscriber; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Mailer\MailerInterface; -use Symfony\Component\Mime\Email; use Throwable; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class CampaignProcessor { - private MailerInterface $mailer; + private RateLimitedCampaignMailer $mailer; private EntityManagerInterface $entityManager; private SubscriberProvider $subscriberProvider; private MessageProcessingPreparator $messagePreparator; private LoggerInterface $logger; + private UserMessageRepository $userMessageRepository; + private MaxProcessTimeLimiter $timeLimiter; + private RequeueHandler $requeueHandler; public function __construct( - MailerInterface $mailer, + RateLimitedCampaignMailer $mailer, EntityManagerInterface $entityManager, SubscriberProvider $subscriberProvider, MessageProcessingPreparator $messagePreparator, LoggerInterface $logger, + UserMessageRepository $userMessageRepository, + MaxProcessTimeLimiter $timeLimiter, + RequeueHandler $requeueHandler ) { $this->mailer = $mailer; $this->entityManager = $entityManager; $this->subscriberProvider = $subscriberProvider; $this->messagePreparator = $messagePreparator; $this->logger = $logger; + $this->userMessageRepository = $userMessageRepository; + $this->timeLimiter = $timeLimiter; + $this->requeueHandler = $requeueHandler; } public function process(Message $campaign, ?OutputInterface $output = null): void { + $this->updateMessageStatus($campaign, MessageStatus::Prepared); $subscribers = $this->subscriberProvider->getSubscribersForMessage($campaign); - // phpcs:ignore Generic.Commenting.Todo - // @todo check $ISPrestrictions logic + + $this->updateMessageStatus($campaign, MessageStatus::InProcess); + + $this->timeLimiter->start(); + $stoppedEarly = false; + foreach ($subscribers as $subscriber) { + if ($this->timeLimiter->shouldStop($output)) { + $stoppedEarly = true; + break; + } + + $existing = $this->userMessageRepository->findOneByUserAndMessage($subscriber, $campaign); + if ($existing && $existing->getStatus() !== UserMessageStatus::Todo) { + continue; + } + + $userMessage = $existing ?? new UserMessage($subscriber, $campaign); + $userMessage->setStatus(UserMessageStatus::Active); + $this->userMessageRepository->save($userMessage); + if (!filter_var($subscriber->getEmail(), FILTER_VALIDATE_EMAIL)) { + $this->updateUserMessageStatus($userMessage, UserMessageStatus::InvalidEmailAddress); + $this->unconfirmSubscriber($subscriber); + $output?->writeln('Invalid email, marking unconfirmed: ' . $subscriber->getEmail()); continue; } - $this->messagePreparator->processMessageLinks($campaign, $subscriber->getId()); - $email = (new Email()) - ->from('news@example.com') - ->to($subscriber->getEmail()) - ->subject($campaign->getContent()->getSubject()) - ->text($campaign->getContent()->getTextMessage()) - ->html($campaign->getContent()->getText()); + + $processed = $this->messagePreparator->processMessageLinks($campaign, $subscriber->getId()); try { + $email = $this->mailer->composeEmail($processed, $subscriber); $this->mailer->send($email); - - // phpcs:ignore Generic.Commenting.Todo - // @todo log somewhere that this subscriber got email + $this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent); } catch (Throwable $e) { + $this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent); $this->logger->error($e->getMessage(), [ 'subscriber_id' => $subscriber->getId(), 'campaign_id' => $campaign->getId(), ]); $output?->writeln('Failed to send to: ' . $subscriber->getEmail()); } + } - usleep(100000); + if ($stoppedEarly && $this->requeueHandler->handle($campaign, $output)) { + return; } - $campaign->getMetadata()->setStatus('sent'); + $this->updateMessageStatus($campaign, MessageStatus::Sent); + } + + private function unconfirmSubscriber(Subscriber $subscriber): void + { + if ($subscriber->isConfirmed()) { + $subscriber->setConfirmed(false); + $this->entityManager->flush(); + } + } + + private function updateMessageStatus(Message $message, MessageStatus $status): void + { + $message->getMetadata()->setStatus($status); + $this->entityManager->flush(); + } + + private function updateUserMessageStatus(UserMessage $userMessage, UserMessageStatus $status): void + { + $userMessage->setStatus($status); $this->entityManager->flush(); } } diff --git a/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php b/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php new file mode 100644 index 00000000..7691f970 --- /dev/null +++ b/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php @@ -0,0 +1,50 @@ +mailer = $mailer; + $this->limiter = $limiter; + } + + public function composeEmail(Message $processed, Subscriber $subscriber): Email + { + $email = new Email(); + if ($processed->getOptions()->getFromField() !== '') { + $email->from($processed->getOptions()->getFromField()); + } + + if ($processed->getOptions()->getReplyTo() !== '') { + $email->replyTo($processed->getOptions()->getReplyTo()); + } + + return $email + ->to($subscriber->getEmail()) + ->subject($processed->getContent()->getSubject()) + ->text($processed->getContent()->getTextMessage()) + ->html($processed->getContent()->getText()); + } + + /** + * @throws TransportExceptionInterface + */ + public function send(Email $email): void + { + $this->limiter->awaitTurn(); + $this->mailer->send($email); + $this->limiter->afterSend(); + } +} diff --git a/src/Domain/Messaging/Service/SendRateLimiter.php b/src/Domain/Messaging/Service/SendRateLimiter.php new file mode 100644 index 00000000..378b80d5 --- /dev/null +++ b/src/Domain/Messaging/Service/SendRateLimiter.php @@ -0,0 +1,103 @@ +initializeLimits(); + } + + private function initializeLimits(): void + { + $isp = $this->ispRestrictionsProvider->load(); + + $cfgBatch = $this->mailqueueBatchSize ?? 0; + $ispMax = isset($isp->maxBatch) ? (int)$isp->maxBatch : null; + + $cfgPeriod = $this->mailqueueBatchPeriod ?? 0; + $ispMinPeriod = $isp->minBatchPeriod ?? 0; + + $cfgThrottle = $this->mailqueueThrottle ?? 0; + $ispMinThrottle = (int)($isp->minThrottle ?? 0); + + if ($cfgBatch <= 0) { + $this->batchSize = $ispMax !== null ? max(0, $ispMax) : 0; + } else { + $this->batchSize = $ispMax !== null ? min($cfgBatch, max(1, $ispMax)) : $cfgBatch; + } + $this->batchPeriod = max(0, $cfgPeriod, $ispMinPeriod); + $this->throttleSec = max(0, $cfgThrottle, $ispMinThrottle); + + $this->sentInBatch = 0; + $this->batchStart = microtime(true); + $this->initializedFromHistory = false; + } + + /** + * Call before attempting to send another message. It will sleep if needed to + * respect batch limits. Returns true when it's okay to proceed. + */ + public function awaitTurn(?OutputInterface $output = null): bool + { + if (!$this->initializedFromHistory && $this->batchSize > 0 && $this->batchPeriod > 0) { + $since = (new DateTimeImmutable())->sub(new DateInterval('PT' . $this->batchPeriod . 'S')); + $alreadySent = $this->userMessageRepository->countSentSince($since); + $this->sentInBatch = max($this->sentInBatch, $alreadySent); + $this->initializedFromHistory = true; + } + + if ($this->batchSize > 0 && $this->batchPeriod > 0 && $this->sentInBatch >= $this->batchSize) { + $elapsed = microtime(true) - $this->batchStart; + $remaining = (int)ceil($this->batchPeriod - $elapsed); + if ($remaining > 0) { + $output?->writeln(sprintf( + 'Batch limit reached, sleeping %ds to respect MAILQUEUE_BATCH_PERIOD', + $remaining + )); + sleep($remaining); + } + $this->batchStart = microtime(true); + $this->sentInBatch = 0; + $this->initializedFromHistory = false; + } + + return true; + } + + /** + * Call after a successful sending to update counters and apply per-message throttle. + */ + public function afterSend(): void + { + $this->sentInBatch++; + if ($this->throttleSec > 0) { + sleep($this->throttleSec); + } + } +} diff --git a/src/Domain/Subscription/Service/Provider/SubscriberProvider.php b/src/Domain/Subscription/Service/Provider/SubscriberProvider.php index 5ec6b177..72b473be 100644 --- a/src/Domain/Subscription/Service/Provider/SubscriberProvider.php +++ b/src/Domain/Subscription/Service/Provider/SubscriberProvider.php @@ -36,7 +36,7 @@ public function getSubscribersForMessage(Message $message): array foreach ($lists as $list) { $listSubscribers = $this->subscriberRepository->getSubscribersBySubscribedListId($list->getId()); foreach ($listSubscribers as $subscriber) { - $subscribers[$subscriber->getId()] = $subscriber; + $subscribers[$subscriber->getEmail()] = $subscriber; } } diff --git a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php index d7435815..1bd98735 100644 --- a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php +++ b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php @@ -48,7 +48,7 @@ public function testMessageIsPersistedAndFetchedCorrectly(): void $message = new Message( new MessageFormat(true, 'text'), new MessageSchedule(1, null, 3, null, null), - new MessageMetadata('done'), + new MessageMetadata(Message\MessageStatus::Sent), new MessageContent('Hello world!'), new MessageOptions(), $admin @@ -62,7 +62,7 @@ public function testMessageIsPersistedAndFetchedCorrectly(): void self::assertCount(1, $foundMessages); self::assertInstanceOf(Message::class, $foundMessages[0]); - self::assertSame('done', $foundMessages[0]->getMetadata()->getStatus()); + self::assertSame(Message\MessageStatus::Sent, $foundMessages[0]->getMetadata()->getStatus()); self::assertSame('Hello world!', $foundMessages[0]->getContent()->getSubject()); } @@ -77,7 +77,7 @@ public function testGetByOwnerIdReturnsOnlyOwnedMessages(): void $msg1 = new Message( new MessageFormat(true, MessageFormat::FORMAT_TEXT), new MessageSchedule(1, null, 3, null, null), - new MessageMetadata('done'), + new MessageMetadata(Message\MessageStatus::Sent), new MessageContent('Owned by Admin 1!'), new MessageOptions(), $admin1 diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php index b3bfda0c..9019fd30 100644 --- a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php +++ b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php @@ -58,7 +58,7 @@ public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError(): $msg = new Message( format: new MessageFormat(true, MessageFormat::FORMAT_TEXT), schedule: new MessageSchedule(1, null, 3, null, null), - metadata: new MessageMetadata('done'), + metadata: new MessageMetadata(Message\MessageStatus::Sent), content: new MessageContent('Owned by Admin 1!'), options: new MessageOptions(), owner: $admin @@ -92,7 +92,7 @@ public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError(): $this->entityManager->persist($linkTrackUmlClick); $userMessage = new UserMessage($subscriber, $msg); - $userMessage->setStatus('sent'); + $userMessage->setStatus(Message\UserMessageStatus::Sent); $this->entityManager->persist($userMessage); $userMessageBounce = new UserMessageBounce(1, new DateTime()); diff --git a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php index 79ece9bd..d76f63c0 100644 --- a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php +++ b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Command; use Exception; +use PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager; use PhpList\Core\Domain\Messaging\Command\ProcessQueueCommand; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; @@ -41,7 +42,8 @@ protected function setUp(): void $this->messageRepository, $lockFactory, $this->messageProcessingPreparator, - $this->campaignProcessor + $this->campaignProcessor, + $this->createMock(ConfigManager::class), ); $application = new Application(); @@ -82,8 +84,8 @@ public function testExecuteWithNoCampaigns(): void ->method('ensureCampaignsHaveUuid'); $this->messageRepository->expects($this->once()) - ->method('findBy') - ->with(['status' => 'submitted']) + ->method('getByStatusAndEmbargo') + ->with($this->anything(), $this->anything()) ->willReturn([]); $this->campaignProcessor->expects($this->never()) @@ -112,8 +114,8 @@ public function testExecuteWithCampaigns(): void $campaign = $this->createMock(Message::class); $this->messageRepository->expects($this->once()) - ->method('findBy') - ->with(['status' => 'submitted']) + ->method('getByStatusAndEmbargo') + ->with($this->anything(), $this->anything()) ->willReturn([$campaign]); $this->campaignProcessor->expects($this->once()) @@ -145,8 +147,8 @@ public function testExecuteWithMultipleCampaigns(): void $campaign2 = $this->createMock(Message::class); $this->messageRepository->expects($this->once()) - ->method('findBy') - ->with(['status' => 'submitted']) + ->method('getByStatusAndEmbargo') + ->with($this->anything(), $this->anything()) ->willReturn([$campaign1, $campaign2]); $this->campaignProcessor->expects($this->exactly(2)) @@ -179,8 +181,8 @@ public function testExecuteWithProcessorException(): void $campaign = $this->createMock(Message::class); $this->messageRepository->expects($this->once()) - ->method('findBy') - ->with(['status' => 'submitted']) + ->method('getByStatusAndEmbargo') + ->with($this->anything(), $this->anything()) ->willReturn([$campaign]); $this->campaignProcessor->expects($this->once()) diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php index 564bd34d..d99d041a 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php @@ -2,9 +2,8 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Service\Builder; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder; -use Error; use InvalidArgumentException; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Dto\CreateMessageDto; @@ -64,7 +63,7 @@ private function createRequest(): CreateMessageDto formatOptions: [] ), metadata: new MessageMetadataDto( - status: 'draft' + status: Message\MessageStatus::Draft ), options: new MessageOptionsDto( fromField: '', @@ -117,16 +116,6 @@ public function testBuildsNewMessage(): void $this->builder->build($request, $context); } - public function testThrowsExceptionOnInvalidRequest(): void - { - $this->expectException(Error::class); - - $this->builder->build( - $this->createMock(CreateMessageDto::class), - new MessageContext($this->createMock(Administrator::class)) - ); - } - public function testThrowsExceptionOnInvalidContext(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php index 2b1aa771..21f90692 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Service\Builder; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder; use InvalidArgumentException; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageContentDto; diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php index 8d9320a0..1bd576f5 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Service\Builder; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder; use InvalidArgumentException; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageFormatDto; diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php index c6795d29..754177a2 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Service\Builder; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder; use InvalidArgumentException; use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageOptionsDto; diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php index 38f04338..25a89052 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Service\Builder; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder; use DateTime; use InvalidArgumentException; diff --git a/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php new file mode 100644 index 00000000..5bfb1114 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php @@ -0,0 +1,155 @@ +logger = $this->createMock(LoggerInterface::class); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->output = $this->createMock(OutputInterface::class); + } + + private function createMessage( + ?int $requeueInterval, + ?DateTime $requeueUntil, + ?DateTime $embargo + ): Message { + $format = new MessageFormat(htmlFormatted: false, sendFormat: null); + $schedule = new MessageSchedule( + repeatInterval: null, + repeatUntil: null, + requeueInterval: $requeueInterval, + requeueUntil: $requeueUntil, + embargo: $embargo + ); + $metadata = new MessageMetadata(MessageStatus::Draft); + $content = new MessageContent('(no subject)'); + $options = new MessageOptions(); + + return new Message($format, $schedule, $metadata, $content, $options, owner: null, template: null); + } + + public function testReturnsFalseWhenIntervalIsZeroOrNegative(): void + { + $handler = new RequeueHandler($this->logger, $this->em); + $message = $this->createMessage(0, null, null); + + $this->em->expects($this->never())->method('flush'); + $this->output->expects($this->never())->method('writeln'); + $this->logger->expects($this->never())->method('info'); + + $result = $handler->handle($message, $this->output); + + $this->assertFalse($result); + $this->assertSame(MessageStatus::Draft, $message->getMetadata()->getStatus()); + } + + public function testReturnsFalseWhenNowIsAfterRequeueUntil(): void + { + $handler = new RequeueHandler($this->logger, $this->em); + $past = (new DateTime())->sub(new DateInterval('PT5M')); + $message = $this->createMessage(5, $past, null); + + $this->em->expects($this->never())->method('flush'); + $this->logger->expects($this->never())->method('info'); + + $result = $handler->handle($message, $this->output); + + $this->assertFalse($result); + $this->assertSame(MessageStatus::Draft, $message->getMetadata()->getStatus()); + } + + public function testRequeuesFromFutureEmbargoAndSetsSubmittedStatus(): void + { + $handler = new RequeueHandler($this->logger, $this->em); + $embargo = (new DateTime())->add(new DateInterval('PT5M')); + $interval = 10; + $message = $this->createMessage($interval, null, $embargo); + + $this->em->expects($this->once())->method('flush'); + $this->output->expects($this->once())->method('writeln'); + $this->logger->expects($this->once())->method('info'); + + $result = $handler->handle($message, $this->output); + + $this->assertTrue($result); + $this->assertSame(MessageStatus::Submitted, $message->getMetadata()->getStatus()); + + $expectedNext = (clone $embargo)->add(new DateInterval('PT' . $interval . 'M')); + $actualNext = $message->getSchedule()->getEmbargo(); + $this->assertInstanceOf(DateTime::class, $actualNext); + $this->assertEquals($expectedNext->format(DateTime::ATOM), $actualNext->format(DateTime::ATOM)); + } + + public function testRequeuesFromNowWhenEmbargoIsNullOrPast(): void + { + $handler = new RequeueHandler($this->logger, $this->em); + $interval = 3; + $message = $this->createMessage($interval, null, null); + + $this->em->expects($this->once())->method('flush'); + $this->logger->expects($this->once())->method('info'); + + $before = new DateTime(); + $result = $handler->handle($message, $this->output); + $after = new DateTime(); + + $this->assertTrue($result); + $this->assertSame(MessageStatus::Submitted, $message->getMetadata()->getStatus()); + + $embargo = $message->getSchedule()->getEmbargo(); + $this->assertInstanceOf(DateTime::class, $embargo); + + $minExpected = (clone $before)->add(new DateInterval('PT' . $interval . 'M')); + $maxExpected = (clone $after)->add(new DateInterval('PT' . $interval . 'M')); + + $this->assertGreaterThanOrEqual($minExpected->getTimestamp(), $embargo->getTimestamp()); + $this->assertLessThanOrEqual($maxExpected->getTimestamp(), $embargo->getTimestamp()); + } + + public function testReturnsFalseWhenNextEmbargoExceedsUntil(): void + { + $handler = new RequeueHandler($this->logger, $this->em); + $embargo = (new DateTime())->add(new DateInterval('PT1M')); + $interval = 10; + // next would be +10, which exceeds until + $until = (clone $embargo)->add(new DateInterval('PT5M')); + $message = $this->createMessage($interval, $until, $embargo); + + $this->em->expects($this->never())->method('flush'); + $this->logger->expects($this->never())->method('info'); + + $result = $handler->handle($message, $this->output); + + $this->assertFalse($result); + $this->assertSame(MessageStatus::Draft, $message->getMetadata()->getStatus()); + $this->assertEquals( + $embargo->format(DateTime::ATOM), + $message->getSchedule()->getEmbargo()?->format(DateTime::ATOM) + ); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php index aa1a47e0..94064485 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php @@ -13,6 +13,7 @@ use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageScheduleDto; use PhpList\Core\Domain\Messaging\Model\Dto\UpdateMessageDto; use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\Builder\MessageBuilder; use PhpList\Core\Domain\Messaging\Service\Manager\MessageManager; @@ -34,7 +35,7 @@ public function testCreateMessageReturnsPersistedMessage(): void requeueInterval: 60 * 12, requeueUntil: '2025-04-20T00:00:00+00:00', ); - $metadata = new MessageMetadataDto('draft'); + $metadata = new MessageMetadataDto(Message\MessageStatus::Draft); $content = new MessageContentDto('Subject', 'Full text', 'Short text', 'Footer'); $options = new MessageOptionsDto('from@example.com', 'to@example.com', 'reply@example.com', 'all-users'); @@ -50,11 +51,11 @@ public function testCreateMessageReturnsPersistedMessage(): void $authUser = $this->createMock(Administrator::class); $expectedMessage = $this->createMock(Message::class); - $expectedContent = $this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class); + $expectedContent = $this->createMock(MessageContent::class); $expectedMetadata = $this->createMock(Message\MessageMetadata::class); $expectedContent->method('getSubject')->willReturn('Subject'); - $expectedMetadata->method('getStatus')->willReturn('draft'); + $expectedMetadata->method('getStatus')->willReturn(Message\MessageStatus::Draft); $expectedMessage->method('getContent')->willReturn($expectedContent); $expectedMessage->method('getMetadata')->willReturn($expectedMetadata); @@ -71,7 +72,7 @@ public function testCreateMessageReturnsPersistedMessage(): void $message = $manager->createMessage($request, $authUser); $this->assertSame('Subject', $message->getContent()->getSubject()); - $this->assertSame('draft', $message->getMetadata()->getStatus()); + $this->assertSame(Message\MessageStatus::Draft, $message->getMetadata()->getStatus()); } public function testUpdateMessageReturnsUpdatedMessage(): void @@ -88,7 +89,7 @@ public function testUpdateMessageReturnsUpdatedMessage(): void requeueInterval: 0, requeueUntil: '2025-04-20T00:00:00+00:00', ); - $metadata = new MessageMetadataDto('draft'); + $metadata = new MessageMetadataDto(Message\MessageStatus::Draft); $content = new MessageContentDto( 'Updated Subject', 'Updated Full text', @@ -115,11 +116,11 @@ public function testUpdateMessageReturnsUpdatedMessage(): void $authUser = $this->createMock(Administrator::class); $existingMessage = $this->createMock(Message::class); - $expectedContent = $this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class); + $expectedContent = $this->createMock(MessageContent::class); $expectedMetadata = $this->createMock(Message\MessageMetadata::class); $expectedContent->method('getSubject')->willReturn('Updated Subject'); - $expectedMetadata->method('getStatus')->willReturn('draft'); + $expectedMetadata->method('getStatus')->willReturn(Message\MessageStatus::Draft); $existingMessage->method('getContent')->willReturn($expectedContent); $existingMessage->method('getMetadata')->willReturn($expectedMetadata); @@ -136,6 +137,6 @@ public function testUpdateMessageReturnsUpdatedMessage(): void $message = $manager->updateMessage($updateRequest, $existingMessage, $authUser); $this->assertSame('Updated Subject', $message->getContent()->getSubject()); - $this->assertSame('draft', $message->getMetadata()->getStatus()); + $this->assertSame(Message\MessageStatus::Draft, $message->getMetadata()->getStatus()); } } diff --git a/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php b/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php new file mode 100644 index 00000000..5944ca3e --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php @@ -0,0 +1,53 @@ +logger = $this->createMock(LoggerInterface::class); + } + + public function testShouldNotStopWhenMaxSecondsIsZero(): void + { + $limiter = new MaxProcessTimeLimiter(logger: $this->logger, maxSeconds: 0); + + $output = $this->createMock(OutputInterface::class); + $output->expects($this->never())->method('writeln'); + $this->logger->expects($this->never())->method('warning'); + + $limiter->start(); + usleep(200_000); + $this->assertFalse($limiter->shouldStop($output)); + } + + public function testShouldStopAfterThresholdAndLogAndOutput(): void + { + $limiter = new MaxProcessTimeLimiter(logger: $this->logger, maxSeconds: 1); + + $output = $this->createMock(OutputInterface::class); + $output->expects($this->once()) + ->method('writeln') + ->with('Reached max processing time; stopping cleanly.'); + + $this->logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('Reached max processing time of 1 seconds')); + + $this->assertFalse($limiter->shouldStop($output)); + + usleep(1_200_000); + $this->assertTrue($limiter->shouldStop($output)); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php index b2c51c71..26aec09f 100644 --- a/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php @@ -9,42 +9,50 @@ use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata; +use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; +use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; +use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor; +use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; class CampaignProcessorTest extends TestCase { - private MailerInterface&MockObject $mailer; - private EntityManagerInterface&MockObject $entityManager; - private SubscriberProvider&MockObject $subscriberProvider; - private MessageProcessingPreparator&MockObject $messagePreparator; - private LoggerInterface&MockObject $logger; - private OutputInterface&MockObject $output; + private RateLimitedCampaignMailer|MockObject $mailer; + private EntityManagerInterface|MockObject $entityManager; + private SubscriberProvider|MockObject $subscriberProvider; + private MessageProcessingPreparator|MockObject $messagePreparator; + private LoggerInterface|MockObject $logger; + private OutputInterface|MockObject $output; private CampaignProcessor $campaignProcessor; + private UserMessageRepository|MockObject $userMessageRepository; protected function setUp(): void { - $this->mailer = $this->createMock(MailerInterface::class); + $this->mailer = $this->createMock(RateLimitedCampaignMailer::class); $this->entityManager = $this->createMock(EntityManagerInterface::class); $this->subscriberProvider = $this->createMock(SubscriberProvider::class); $this->messagePreparator = $this->createMock(MessageProcessingPreparator::class); $this->logger = $this->createMock(LoggerInterface::class); $this->output = $this->createMock(OutputInterface::class); + $this->userMessageRepository = $this->createMock(UserMessageRepository::class); $this->campaignProcessor = new CampaignProcessor( - $this->mailer, - $this->entityManager, - $this->subscriberProvider, - $this->messagePreparator, - $this->logger + mailer: $this->mailer, + entityManager: $this->entityManager, + subscriberProvider: $this->subscriberProvider, + messagePreparator: $this->messagePreparator, + logger: $this->logger, + userMessageRepository: $this->userMessageRepository, + timeLimiter: $this->createMock(MaxProcessTimeLimiter::class), + requeueHandler: $this->createMock(RequeueHandler::class), ); } @@ -59,11 +67,10 @@ public function testProcessWithNoSubscribers(): void ->with($campaign) ->willReturn([]); - $metadata->expects($this->once()) - ->method('setStatus') - ->with('sent'); + $metadata->expects($this->atLeastOnce()) + ->method('setStatus'); - $this->entityManager->expects($this->once()) + $this->entityManager->expects($this->atLeastOnce()) ->method('flush'); $this->mailer->expects($this->never()) @@ -87,11 +94,10 @@ public function testProcessWithInvalidSubscriberEmail(): void ->with($campaign) ->willReturn([$subscriber]); - $metadata->expects($this->once()) - ->method('setStatus') - ->with('sent'); + $metadata->expects($this->atLeastOnce()) + ->method('setStatus'); - $this->entityManager->expects($this->once()) + $this->entityManager->expects($this->atLeastOnce()) ->method('flush'); $this->messagePreparator->expects($this->never()) @@ -123,22 +129,28 @@ public function testProcessWithValidSubscriberEmail(): void ->with($campaign, 1) ->willReturn($campaign); + $this->mailer->expects($this->once()) + ->method('composeEmail') + ->with($campaign, $subscriber) + ->willReturnCallback(function ($processed, $sub) use ($campaign, $subscriber) { + $this->assertSame($campaign, $processed); + $this->assertSame($subscriber, $sub); + return (new Email()) + ->from('news@example.com') + ->to('test@example.com') + ->subject('Test Subject') + ->text('Test text message') + ->html('

Test HTML message

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

Test HTML message

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

HTML

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

HTML

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

+

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

+

Please use the following token to reset your password:

+

Reset Password

+

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

+

Thank you.

]]> + + Password Reset Request!

+

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

+

Please use the following token to reset your password:

+

Reset Password

+

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

+

Thank you.

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

+

Please confirm your subscription by clicking the link below:

+

Confirm Subscription

+

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

]]> + + Thank you for subscribing!

+

Please confirm your subscription by clicking the link below:

+

Confirm Subscription

+

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

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

Password Reset Request!

' - . '

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

' - . '

Please use the following token to reset your password:

' - . '

Reset Password

' - . '

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

' - . '

Thank you.

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

Password Reset Request!

' . + '

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

' . + '

Please use the following token to reset your password:

' . + '

Reset Password

' . + '

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

' . + '

Thank you.

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

Thank you for subscribing!

' - . '

Please confirm your subscription by clicking the link below:

' - . '

Confirm Subscription

' - . '

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

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

Thank you for subscribing!

' . + '

Please confirm your subscription by clicking the link below:

' . + '

Confirm Subscription

' . + '

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

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

-

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

-

Please use the following token to reset your password:

-

Reset Password

-

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

-

Thank you.

]]> - - + + <p>Password Reset Request!</p> +<p>Hello! A password reset has been requested for your account.</p> +<p>Please use the following token to reset your password:</p> +<p><a href="%confirmation_link%">Reset Password</a></p> +<p>If you did not request this password reset, please ignore this email.</p> +<p>Thank you.</p> + Password Reset Request!

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

Please use the following token to reset your password:

Reset Password

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

Thank you.

- ]]> -
-
- - - Please confirm your subscription - Please confirm your subscription - - - - Thank you for subscribing! + + ]]>
+
+ + Request for confirmation + Request for confirmation + + + Thank you for subscribing! Please confirm your subscription by clicking the link below: @@ -92,7 +85,7 @@ If you did not request this subscription, please ignore this email. - Thank you for subscribing! + Thank you for subscribing! Please confirm your subscription by clicking the link below: @@ -100,351 +93,597 @@ If you did not request this subscription, please ignore this email. - - - - Thank you for subscribing!

-

Please confirm your subscription by clicking the link below:

-

Confirm Subscription

-

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

]]> +
+ + <p>Thank you for subscribing!</p> +<p>Please confirm your subscription by clicking the link below:</p> +<p><a href="%confirmation_link%">Confirm Subscription</a></p> +<p>If you did not request this subscription, please ignore this email.</p> - Thank you for subscribing!

+ Thank you for subscribing!

Please confirm your subscription by clicking the link below:

Confirm Subscription

-

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

]]> -
-
- - - - PHP IMAP extension not available. Falling back to Webklex IMAP. - PHP IMAP extension not available. Falling back to Webklex IMAP. - - - - Could not apply force lock. Aborting. - Could not apply force lock. Aborting. - - - - Another bounce processing is already running. Aborting. - Another bounce processing is already running. Aborting. - - - - Queue is already being processed by another instance. - Queue is already being processed by another instance. - - - - The system is in maintenance mode, stopping. Try again later. - The system is in maintenance mode, stopping. Try again later. - - - - Bounce processing completed. - Bounce processing completed. - - - - Recipient email address not provided - Recipient email address not provided - - - - Invalid email address: %email% - Invalid email address: %email% - - - - Sending test email synchronously to %email% - Sending test email synchronously to %email% - - - - Queuing test email for %email% - Queuing test email for %email% - - - - Test email sent successfully! - Test email sent successfully! - - - - Test email queued successfully! It will be sent asynchronously. - Test email queued successfully! It will be sent asynchronously. - - - - Failed to send test email: %error% - Failed to send test email: %error% - - - - Email address auto blacklisted by bounce rule %rule_id% - Email address auto blacklisted by bounce rule %rule_id% - - - - Auto Unsubscribed - Auto Unsubscribed - - - - User auto unsubscribed for bounce rule %rule_id% - User auto unsubscribed for bounce rule %rule_id% - - - - email auto unsubscribed for bounce rule %rule_id% - email auto unsubscribed for bounce rule %rule_id% - - - - Subscriber auto blacklisted by bounce rule %rule_id% - Subscriber auto blacklisted by bounce rule %rule_id% - - - - User auto unsubscribed for bounce rule %%rule_id% - User auto unsubscribed for bounce rule %%rule_id% - - - - Auto confirmed - Auto confirmed - - - - Auto unconfirmed - Auto unconfirmed - - - - Subscriber auto confirmed for bounce rule %rule_id% - Subscriber auto confirmed for bounce rule %rule_id% - - - - Requeued campaign; next embargo at %time% - Requeued campaign; next embargo at %time% - - - - Subscriber auto unconfirmed for bounce rule %rule_id% - Subscriber auto unconfirmed for bounce rule %rule_id% - - - - Running in test mode, not deleting messages from mailbox - Running in test mode, not deleting messages from mailbox - - - - Processed messages will be deleted from the mailbox - Processed messages will be deleted from the mailbox - - - - Processing bounces based on active bounce rules - Processing bounces based on active bounce rules - - - - No active rules - No active rules - - - - Processed %processed% out of %total% bounces for advanced bounce rules - Processed %processed% out of %total% bounces for advanced bounce rules - - - - %processed% bounces processed by advanced processing - %processed% bounces processed by advanced processing - - - %not_processed% bounces were not matched by advanced processing rules - %not_processed% bounces were not matched by advanced processing rules - - - - Opening mbox %file% - Opening mbox %file% - - - Connecting to %mailbox% - Connecting to %mailbox% - - - Please do not interrupt this process - Please do not interrupt this process - - - mbox file path must be provided with --mailbox. - mbox file path must be provided with --mailbox. - - - - Invalid email, marking unconfirmed: %email% - Invalid email, marking unconfirmed: %email% - - - Failed to send to: %email% - Failed to send to: %email% - - - - Reprocessing unidentified bounces - Reprocessing unidentified bounces - - - %total% bounces to reprocess - %total% bounces to reprocess - - - %count% out of %total% processed - %count% out of %total% processed - - - %reparsed% bounces were re-processed and %reidentified% bounces were re-identified - %reparsed% bounces were re-processed and %reidentified% bounces were re-identified - - - - Identifying consecutive bounces - Identifying consecutive bounces - - - Nothing to do - Nothing to do - - - Processed %processed% out of %total% subscribers - Processed %processed% out of %total% subscribers - - - Total of %total% subscribers processed - Total of %total% subscribers processed - - - Subscriber auto unconfirmed for %count% consecutive bounces - Subscriber auto unconfirmed for %count% consecutive bounces - - - %count% consecutive bounces, threshold reached - %count% consecutive bounces, threshold reached - - - - Reached max processing time; stopping cleanly. - Reached max processing time; stopping cleanly. - - - - Giving a UUID to %count% subscribers, this may take a while - Giving a UUID to %count% subscribers, this may take a while - - - Giving a UUID to %count% campaigns - Giving a UUID to %count% campaigns - - - - Batch limit reached, sleeping %sleep%s to respect MAILQUEUE_BATCH_PERIOD - Batch limit reached, sleeping %sleep%s to respect MAILQUEUE_BATCH_PERIOD - - - - Value must be an array of image URLs. - Value must be an array of image URLs. - - - Image "%url%" is not a full URL. - Image "%url%" is not a full URL. - - - Image "%url%" does not exist (HTTP %code%) - Image "%url%" does not exist (HTTP %code%) - - - Image "%url%" could not be validated: %message% - Image "%url%" could not be validated: %message% - - - - Not full URLs: %urls% - Not full URLs: %urls% - - - - - - Subscriber list not found. - Subscriber list not found. - - - - Subscriber does not exists. - Subscriber does not exists. - - - - Subscription not found for this subscriber and list. - Subscription not found for this subscriber and list. - - - Attribute definition already exists - Attribute definition already exists - - - Another attribute with this name already exists. - Another attribute with this name already exists. - - - - Subscribe page not found - Subscribe page not found - - - Value is required - Value is required - - - Subscriber not found - Subscriber not found - - - Unexpected error: %error% - Unexpected error: %error% - - - Added to blacklist for reason %reason% - Added to blacklist for reason %reason% - - - Could not read the uploaded file. - Could not read the uploaded file. - - - Error processing %email%: %error% - Error processing %email%: %error% - - - General import error: %error% - General import error: %error% - - - Value must be a string. - Value must be a string. - - - Invalid attribute type: "%type%". Valid types are: %valid_types% - Invalid attribute type: "%type%". Valid types are: %valid_types% - - - -
+

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

+ ]]> +
+ + PHP IMAP extension not available. Falling back to Webklex IMAP. + PHP IMAP extension not available. Falling back to Webklex IMAP. + + + Could not apply force lock. Aborting. + Could not apply force lock. Aborting. + + + Another bounce processing is already running. Aborting. + Another bounce processing is already running. Aborting. + + + Queue is already being processed by another instance. + Queue is already being processed by another instance. + + + The system is in maintenance mode, stopping. Try again later. + The system is in maintenance mode, stopping. Try again later. + + + Bounce processing completed. + Bounce processing completed. + + + Recipient email address not provided + Recipient email address not provided + + + Invalid email address: %email% + Invalid email address: %email% + + + Sending test email synchronously to %email% + Sending test email synchronously to %email% + + + Queuing test email for %email% + Queuing test email for %email% + + + Test email sent successfully! + Test email sent successfully! + + + Test email queued successfully! It will be sent asynchronously. + Test email queued successfully! It will be sent asynchronously. + + + Failed to send test email: %error% + Failed to send test email: %error% + + + Email address auto blacklisted by bounce rule %rule_id% + Email address auto blacklisted by bounce rule %rule_id% + + + Auto Unsubscribed + Auto Unsubscribed + + + User auto unsubscribed for bounce rule %rule_id% + User auto unsubscribed for bounce rule %rule_id% + + + email auto unsubscribed for bounce rule %rule_id% + email auto unsubscribed for bounce rule %rule_id% + + + Subscriber auto blacklisted by bounce rule %rule_id% + Subscriber auto blacklisted by bounce rule %rule_id% + + + User auto unsubscribed for bounce rule %%rule_id% + User auto unsubscribed for bounce rule %%rule_id% + + + Auto confirmed + Auto confirmed + + + Auto unconfirmed + Auto unconfirmed + + + Subscriber auto confirmed for bounce rule %rule_id% + Subscriber auto confirmed for bounce rule %rule_id% + + + Requeued campaign; next embargo at %time% + Requeued campaign; next embargo at %time% + + + Subscriber auto unconfirmed for bounce rule %rule_id% + Subscriber auto unconfirmed for bounce rule %rule_id% + + + Running in test mode, not deleting messages from mailbox + Running in test mode, not deleting messages from mailbox + + + Processed messages will be deleted from the mailbox + Processed messages will be deleted from the mailbox + + + Processing bounces based on active bounce rules + Processing bounces based on active bounce rules + + + No active rules + No active rules + + + Processed %processed% out of %total% bounces for advanced bounce rules + Processed %processed% out of %total% bounces for advanced bounce rules + + + %processed% bounces processed by advanced processing + %processed% bounces processed by advanced processing + + + %not_processed% bounces were not matched by advanced processing rules + %not_processed% bounces were not matched by advanced processing rules + + + Opening mbox %file% + Opening mbox %file% + + + Connecting to %mailbox% + Connecting to %mailbox% + + + Please do not interrupt this process + Please do not interrupt this process + + + mbox file path must be provided with --mailbox. + mbox file path must be provided with --mailbox. + + + Invalid email, marking unconfirmed: %email% + Invalid email, marking unconfirmed: %email% + + + Failed to send to: %email% + Failed to send to: %email% + + + Reprocessing unidentified bounces + Reprocessing unidentified bounces + + + %total% bounces to reprocess + %total% bounces to reprocess + + + %count% out of %total% processed + %count% out of %total% processed + + + %reparsed% bounces were re-processed and %reidentified% bounces were re-identified + %reparsed% bounces were re-processed and %reidentified% bounces were re-identified + + + Identifying consecutive bounces + Identifying consecutive bounces + + + Nothing to do + Nothing to do + + + Processed %processed% out of %total% subscribers + Processed %processed% out of %total% subscribers + + + Total of %total% subscribers processed + Total of %total% subscribers processed + + + Subscriber auto unconfirmed for %count% consecutive bounces + Subscriber auto unconfirmed for %count% consecutive bounces + + + %count% consecutive bounces, threshold reached + %count% consecutive bounces, threshold reached + + + Reached max processing time; stopping cleanly. + Reached max processing time; stopping cleanly. + + + Giving a UUID to %count% subscribers, this may take a while + Giving a UUID to %count% subscribers, this may take a while + + + Giving a UUID to %count% campaigns + Giving a UUID to %count% campaigns + + + Batch limit reached, sleeping %sleep%s to respect MAILQUEUE_BATCH_PERIOD + Batch limit reached, sleeping %sleep%s to respect MAILQUEUE_BATCH_PERIOD + + + Value must be an array of image URLs. + Value must be an array of image URLs. + + + Image "%url%" is not a full URL. + Image "%url%" is not a full URL. + + + Image "%url%" does not exist (HTTP %code%) + Image "%url%" does not exist (HTTP %code%) + + + Image "%url%" could not be validated: %message% + Image "%url%" could not be validated: %message% + + + Not full URLs: %urls% + Not full URLs: %urls% + + + Subscriber list not found. + Subscriber list not found. + + + Subscriber does not exists. + Subscriber does not exists. + + + Subscription not found for this subscriber and list. + Subscription not found for this subscriber and list. + + + Attribute definition already exists + Attribute definition already exists + + + Another attribute with this name already exists. + Another attribute with this name already exists. + + + Subscribe page not found + Subscribe page not found + + + Value is required + Value is required + + + Subscriber not found + Subscriber not found + + + Unexpected error: %error% + Unexpected error: %error% + + + Added to blacklist for reason %reason% + Added to blacklist for reason %reason% + + + Could not read the uploaded file. + Could not read the uploaded file. + + + Error processing %email%: %error% + Error processing %email%: %error% + + + General import error: %error% + General import error: %error% + + + Value must be a string. + Value must be a string. + + + Invalid attribute type: "%type%". Valid types are: %valid_types% + Invalid attribute type: "%type%". Valid types are: %valid_types% + + + Thank you for subscribing! + +Please confirm your subscription by clicking the link below: + +%confirmation_link% + +If you did not request this subscription, please ignore this email. + __Thank you for subscribing! + +Please confirm your subscription by clicking the link below: + +%confirmation_link% + +If you did not request this subscription, please ignore this email. + + + <p>Thank you for subscribing!</p><p>Please confirm your subscription by clicking the link below:</p><p><a href="%confirmation_link%">Confirm Subscription</a></p><p>If you did not request this subscription, please ignore this email.</p> + Thank you for subscribing!

Please confirm your subscription by clicking the link below:

Confirm Subscription

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

]]>
+
+ + Hello, + +A password reset has been requested for your account. +Please use the following token to reset your password: + +%token% + +If you did not request this password reset, please ignore this email. + +Thank you. + __Hello, + +A password reset has been requested for your account. +Please use the following token to reset your password: + +%token% + +If you did not request this password reset, please ignore this email. + +Thank you. + + + <p>Password Reset Request!</p><p>Hello! A password reset has been requested for your account.</p><p>Please use the following token to reset your password:</p><p><a href="%confirmation_link%">Reset Password</a></p><p>If you did not request this password reset, please ignore this email.</p><p>Thank you.</p> + Password Reset Request!

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

Please use the following token to reset your password:

Reset Password

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

Thank you.

]]>
+
+ + Person in charge of this system (one email address) + __Person in charge of this system (one email address) + + + Name of the organisation + __Name of the organisation + + + Logo of the organisation + __Logo of the organisation + + + Date format + __Date format + + + Show notification for Release Candidates + __Show notification for Release Candidates + + + Secret for remote processing + __Secret for remote processing + + + Notify admin on login from new location + __Notify admin on login from new location + + + List of email addresses to CC in system messages (separate by commas) + __List of email addresses to CC in system messages (separate by commas) + + + Default for 'From:' in a campaign + __Default for 'From:' in a campaign + + + Default for 'address to alert when sending starts' + __Default for 'address to alert when sending starts' + + + Default for 'address to alert when sending finishes' + __Default for 'address to alert when sending finishes' + + + Always add analytics tracking code to campaigns + __Always add analytics tracking code to campaigns + + + Analytics tracking code to add to campaign URLs + __Analytics tracking code to add to campaign URLs + + + Who gets the reports (email address, separate multiple emails with a comma) + __Who gets the reports (email address, separate multiple emails with a comma) + + + From email address for system messages + __From email address for system messages + + + Webmaster + __Webmaster + + + Name for system messages + __Name for system messages + + + Reply-to email address for system messages + __Reply-to email address for system messages + + + If there is only one visible list, should it be hidden in the page and automatically subscribe users who sign up + __If there is only one visible list, should it be hidden in the page and automatically subscribe users who sign up + + + Categories for lists. Separate with commas. + __Categories for lists. Separate with commas. + + + Display list categories on subscribe page + __Display list categories on subscribe page + + + Width of a textline field (numerical) + __Width of a textline field (numerical) + + + Dimensions of a textarea field (rows,columns) + __Dimensions of a textarea field (rows,columns) + + + Send notifications about subscribe, update and unsubscribe + __Send notifications about subscribe, update and unsubscribe + + + The default subscribe page when there are multiple + __The default subscribe page when there are multiple + + + The default HTML template to use when sending a message + __The default HTML template to use when sending a message + + + The HTML wrapper template for system messages + __The HTML wrapper template for system messages + + + URL where subscribers can sign up + __URL where subscribers can sign up + + + URL where subscribers can unsubscribe + __URL where subscribers can unsubscribe + + + URL where unknown users can unsubscribe (do-not-send-list) + __URL where unknown users can unsubscribe (do-not-send-list) + + + URL where subscribers have to confirm their subscription + __URL where subscribers have to confirm their subscription + + + URL where subscribers can update their details + __URL where subscribers can update their details + + + URL for forwarding messages + __URL for forwarding messages + + + URL for downloading vcf card + __URL for downloading vcf card + + + <h3>Thanks, you have been added to our newsletter</h3><p>You will receive an email to confirm your subscription. Please click the link in the email to confirm</p> + Thanks, you have been added to our newsletter

You will receive an email to confirm your subscription. Please click the link in the email to confirm

]]>
+
+ + Text to display when subscription with an AJAX request was successful + __Text to display when subscription with an AJAX request was successful + + + Subject of the message subscribers receive when they sign up + __Subject of the message subscribers receive when they sign up + + + Message subscribers receive when they sign up + __Message subscribers receive when they sign up + + + Goodbye from our Newsletter + __Goodbye from our Newsletter + + + Subject of the message subscribers receive when they unsubscribe + __Subject of the message subscribers receive when they unsubscribe + + + Message subscribers receive when they unsubscribe + __Message subscribers receive when they unsubscribe + + + Welcome to our Newsletter + __Welcome to our Newsletter + + + Subject of the message subscribers receive after confirming their email address + __Subject of the message subscribers receive after confirming their email address + + + Message subscribers receive after confirming their email address + __Message subscribers receive after confirming their email address + + + [notify] Change of List-Membership details + __[notify] Change of List-Membership details + + + Subject of the message subscribers receive when they have changed their details + __Subject of the message subscribers receive when they have changed their details + + + Message subscribers receive when they have changed their details + __Message subscribers receive when they have changed their details + + + Part of the message that is sent to their new email address when subscribers change their information, and the email address has changed + __Part of the message that is sent to their new email address when subscribers change their information, and the email address has changed + + + Part of the message that is sent to their old email address when subscribers change their information, and the email address has changed + __Part of the message that is sent to their old email address when subscribers change their information, and the email address has changed + + + Your personal location + __Your personal location + + + Subject of message when subscribers request their personal location + __Subject of message when subscribers request their personal location + + + Default footer for sending a campaign + __Default footer for sending a campaign + + + Footer used when a message has been forwarded + __Footer used when a message has been forwarded + + + Message to send when they request their personal location + __Message to send when they request their personal location + + + String to always append to remote URL when using send-a-webpage + __String to always append to remote URL when using send-a-webpage + + + Width for Wordwrap of Text messages + __Width for Wordwrap of Text messages + + + CSS for HTML messages without a template + __CSS for HTML messages without a template + + + Domains that only accept text emails, one per line + __Domains that only accept text emails, one per line + + + last time TLDs were fetched + __last time TLDs were fetched + + + Top level domains + __Top level domains + + + Header of public pages. + __Header of public pages. + + + Footer of public pages + __Footer of public pages + + +
diff --git a/resources/translations/security.en.xlf b/resources/translations/security.en.xlf new file mode 100644 index 00000000..d053cd60 --- /dev/null +++ b/resources/translations/security.en.xlf @@ -0,0 +1,86 @@ + + + +
+ +
+ + + An authentication exception occurred. + An authentication exception occurred. + + + Authentication credentials could not be found. + Authentication credentials could not be found. + + + Authentication request could not be processed due to a system problem. + Authentication request could not be processed due to a system problem. + + + Invalid credentials. + Invalid credentials. + + + Cookie has already been used by someone else. + Cookie has already been used by someone else. + + + Not privileged to request the resource. + Not privileged to request the resource. + + + Invalid CSRF token. + Invalid CSRF token. + + + No authentication provider found to support the authentication token. + No authentication provider found to support the authentication token. + + + No session available, it either timed out or cookies are not enabled. + No session available, it either timed out or cookies are not enabled. + + + No token could be found. + No token could be found. + + + Username could not be found. + Username could not be found. + + + Account has expired. + Account has expired. + + + Credentials have expired. + Credentials have expired. + + + Account is disabled. + Account is disabled. + + + Account is locked. + Account is locked. + + + Too many failed login attempts, please try again later. + Too many failed login attempts, please try again later. + + + Invalid or expired login link. + Invalid or expired login link. + + + Too many failed login attempts, please try again in %minutes% minute. + Too many failed login attempts, please try again in %minutes% minute. + + + Too many failed login attempts, please try again in %minutes% minutes. + Too many failed login attempts, please try again in %minutes% minutes. + + +
+
diff --git a/resources/translations/validators.en.xlf b/resources/translations/validators.en.xlf new file mode 100644 index 00000000..41617e3b --- /dev/null +++ b/resources/translations/validators.en.xlf @@ -0,0 +1,694 @@ + + + +
+ +
+ + + This value should be false. + This value should be false. + + + This value should be true. + This value should be true. + + + This value should be of type {{ type }}. + This value should be of type {{ type }}. + + + This value should be blank. + This value should be blank. + + + The value you selected is not a valid choice. + The value you selected is not a valid choice. + + + You must select at least {{ limit }} choice.|You must select at least {{ limit }} choices. + You must select at least {{ limit }} choice.|You must select at least {{ limit }} choices. + + + You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices. + You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices. + + + One or more of the given values is invalid. + One or more of the given values is invalid. + + + This field was not expected. + This field was not expected. + + + This field is missing. + This field is missing. + + + This value is not a valid date. + This value is not a valid date. + + + This value is not a valid datetime. + This value is not a valid datetime. + + + This value is not a valid email address. + This value is not a valid email address. + + + The file could not be found. + The file could not be found. + + + The file is not readable. + The file is not readable. + + + The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}. + The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}. + + + The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}. + The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}. + + + This value should be {{ limit }} or less. + This value should be {{ limit }} or less. + + + This value is too long. It should have {{ limit }} character or less.|This value is too long. It should have {{ limit }} characters or less. + This value is too long. It should have {{ limit }} character or less.|This value is too long. It should have {{ limit }} characters or less. + + + This value should be {{ limit }} or more. + This value should be {{ limit }} or more. + + + This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more. + This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more. + + + This value should not be blank. + This value should not be blank. + + + This value should not be null. + This value should not be null. + + + This value should be null. + This value should be null. + + + This value is not valid. + This value is not valid. + + + This value is not a valid time. + This value is not a valid time. + + + This value is not a valid URL. + This value is not a valid URL. + + + The two values should be equal. + The two values should be equal. + + + The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}. + The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}. + + + The file is too large. + The file is too large. + + + The file could not be uploaded. + The file could not be uploaded. + + + This value should be a valid number. + This value should be a valid number. + + + This file is not a valid image. + This file is not a valid image. + + + This is not a valid IP address. + This value is not a valid IP address. + + + This value is not a valid language. + This value is not a valid language. + + + This value is not a valid locale. + This value is not a valid locale. + + + This value is not a valid country. + This value is not a valid country. + + + This value is already used. + This value is already used. + + + The size of the image could not be detected. + The size of the image could not be detected. + + + The image width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. + The image width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. + + + The image width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. + The image width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. + + + The image height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. + The image height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. + + + The image height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. + The image height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. + + + This value should be the user's current password. + This value should be the user's current password. + + + This value should have exactly {{ limit }} character.|This value should have exactly {{ limit }} characters. + This value should have exactly {{ limit }} character.|This value should have exactly {{ limit }} characters. + + + The file was only partially uploaded. + The file was only partially uploaded. + + + No file was uploaded. + No file was uploaded. + + + No temporary folder was configured in php.ini. + No temporary folder was configured in php.ini, or the configured folder does not exist. + + + Cannot write temporary file to disk. + Cannot write temporary file to disk. + + + A PHP extension caused the upload to fail. + A PHP extension caused the upload to fail. + + + This collection should contain {{ limit }} element or more.|This collection should contain {{ limit }} elements or more. + This collection should contain {{ limit }} element or more.|This collection should contain {{ limit }} elements or more. + + + This collection should contain {{ limit }} element or less.|This collection should contain {{ limit }} elements or less. + This collection should contain {{ limit }} element or less.|This collection should contain {{ limit }} elements or less. + + + This collection should contain exactly {{ limit }} element.|This collection should contain exactly {{ limit }} elements. + This collection should contain exactly {{ limit }} element.|This collection should contain exactly {{ limit }} elements. + + + Invalid card number. + Invalid card number. + + + Unsupported card type or invalid card number. + Unsupported card type or invalid card number. + + + This is not a valid International Bank Account Number (IBAN). + This value is not a valid International Bank Account Number (IBAN). + + + This value is not a valid ISBN-10. + This value is not a valid ISBN-10. + + + This value is not a valid ISBN-13. + This value is not a valid ISBN-13. + + + This value is neither a valid ISBN-10 nor a valid ISBN-13. + This value is neither a valid ISBN-10 nor a valid ISBN-13. + + + This value is not a valid ISSN. + This value is not a valid ISSN. + + + This value is not a valid currency. + This value is not a valid currency. + + + This value should be equal to {{ compared_value }}. + This value should be equal to {{ compared_value }}. + + + This value should be greater than {{ compared_value }}. + This value should be greater than {{ compared_value }}. + + + This value should be greater than or equal to {{ compared_value }}. + This value should be greater than or equal to {{ compared_value }}. + + + This value should be identical to {{ compared_value_type }} {{ compared_value }}. + This value should be identical to {{ compared_value_type }} {{ compared_value }}. + + + This value should be less than {{ compared_value }}. + This value should be less than {{ compared_value }}. + + + This value should be less than or equal to {{ compared_value }}. + This value should be less than or equal to {{ compared_value }}. + + + This value should not be equal to {{ compared_value }}. + This value should not be equal to {{ compared_value }}. + + + This value should not be identical to {{ compared_value_type }} {{ compared_value }}. + This value should not be identical to {{ compared_value_type }} {{ compared_value }}. + + + The image ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. + The image ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. + + + The image ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. + The image ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. + + + The image is square ({{ width }}x{{ height }}px). Square images are not allowed. + The image is square ({{ width }}x{{ height }}px). Square images are not allowed. + + + The image is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented images are not allowed. + The image is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented images are not allowed. + + + The image is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented images are not allowed. + The image is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented images are not allowed. + + + An empty file is not allowed. + An empty file is not allowed. + + + The host could not be resolved. + The host could not be resolved. + + + This value does not match the expected {{ charset }} charset. + This value does not match the expected {{ charset }} charset. + + + This is not a valid Business Identifier Code (BIC). + This value is not a valid Business Identifier Code (BIC). + + + Error + Error + + + This is not a valid UUID. + This value is not a valid UUID. + + + This value should be a multiple of {{ compared_value }}. + This value should be a multiple of {{ compared_value }}. + + + This Business Identifier Code (BIC) is not associated with IBAN {{ iban }}. + This Business Identifier Code (BIC) is not associated with IBAN {{ iban }}. + + + This value should be valid JSON. + This value should be valid JSON. + + + This collection should contain only unique elements. + This collection should contain only unique elements. + + + This value should be positive. + This value should be positive. + + + This value should be either positive or zero. + This value should be either positive or zero. + + + This value should be negative. + This value should be negative. + + + This value should be either negative or zero. + This value should be either negative or zero. + + + This value is not a valid timezone. + This value is not a valid timezone. + + + This password has been leaked in a data breach, it must not be used. Please use another password. + This password has been leaked in a data breach, it must not be used. Please use another password. + + + This value should be between {{ min }} and {{ max }}. + This value should be between {{ min }} and {{ max }}. + + + This value is not a valid hostname. + This value is not a valid hostname. + + + The number of elements in this collection should be a multiple of {{ compared_value }}. + The number of elements in this collection should be a multiple of {{ compared_value }}. + + + This value should satisfy at least one of the following constraints: + This value should satisfy at least one of the following constraints: + + + Each element of this collection should satisfy its own set of constraints. + Each element of this collection should satisfy its own set of constraints. + + + This value is not a valid International Securities Identification Number (ISIN). + This value is not a valid International Securities Identification Number (ISIN). + + + This value should be a valid expression. + This value should be a valid expression. + + + This value is not a valid CSS color. + This value is not a valid CSS color. + + + This value is not a valid CIDR notation. + This value is not a valid CIDR notation. + + + The value of the netmask should be between {{ min }} and {{ max }}. + The value of the netmask should be between {{ min }} and {{ max }}. + + + The filename is too long. It should have {{ filename_max_length }} character or less.|The filename is too long. It should have {{ filename_max_length }} characters or less. + The filename is too long. It should have {{ filename_max_length }} character or less.|The filename is too long. It should have {{ filename_max_length }} characters or less. + + + The password strength is too low. Please use a stronger password. + The password strength is too low. Please use a stronger password. + + + This value contains characters that are not allowed by the current restriction-level. + This value contains characters that are not allowed by the current restriction-level. + + + Using invisible characters is not allowed. + Using invisible characters is not allowed. + + + Mixing numbers from different scripts is not allowed. + Mixing numbers from different scripts is not allowed. + + + Using hidden overlay characters is not allowed. + Using hidden overlay characters is not allowed. + + + The extension of the file is invalid ({{ extension }}). Allowed extensions are {{ extensions }}. + The extension of the file is invalid ({{ extension }}). Allowed extensions are {{ extensions }}. + + + The detected character encoding is invalid ({{ detected }}). Allowed encodings are {{ encodings }}. + The detected character encoding is invalid ({{ detected }}). Allowed encodings are {{ encodings }}. + + + This value is not a valid MAC address. + This value is not a valid MAC address. + + + This URL is missing a top-level domain. + This URL is missing a top-level domain. + + + This value is too short. It should contain at least one word.|This value is too short. It should contain at least {{ min }} words. + This value is too short. It should contain at least one word.|This value is too short. It should contain at least {{ min }} words. + + + This value is too long. It should contain one word.|This value is too long. It should contain {{ max }} words or less. + This value is too long. It should contain one word.|This value is too long. It should contain {{ max }} words or less. + + + This value does not represent a valid week in the ISO 8601 format. + This value does not represent a valid week in the ISO 8601 format. + + + This value is not a valid week. + This value is not a valid week. + + + This value should not be before week "{{ min }}". + This value should not be before week "{{ min }}". + + + This value should not be after week "{{ max }}". + This value should not be after week "{{ max }}". + + + This value is not a valid Twig template. + This value is not a valid Twig template. + + + This file is not a valid video. + This file is not a valid video. + + + The size of the video could not be detected. + The size of the video could not be detected. + + + The video width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. + The video width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. + + + The video width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. + The video width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. + + + The video height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. + The video height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. + + + The video height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. + The video height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. + + + The video has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. + The video has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. + + + The video has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. + The video has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. + + + The video ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. + The video ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. + + + The video ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. + The video ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. + + + The video is square ({{ width }}x{{ height }}px). Square videos are not allowed. + The video is square ({{ width }}x{{ height }}px). Square videos are not allowed. + + + The video is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented videos are not allowed. + The video is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented videos are not allowed. + + + The video is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented videos are not allowed. + The video is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented videos are not allowed. + + + The video file is corrupted. + The video file is corrupted. + + + The video contains multiple streams. Only one stream is allowed. + The video contains multiple streams. Only one stream is allowed. + + + Unsupported video codec "{{ codec }}". + Unsupported video codec "{{ codec }}". + + + Unsupported video container "{{ container }}". + Unsupported video container "{{ container }}". + + + The image file is corrupted. + The image file is corrupted. + + + The image has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. + The image has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. + + + The image has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. + The image has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. + + + This filename does not match the expected charset. + This filename does not match the expected charset. + + + This form should not contain extra fields. + This form should not contain extra fields. + + + The uploaded file was too large. Please try to upload a smaller file. + The uploaded file was too large. Please try to upload a smaller file. + + + The CSRF token is invalid. Please try to resubmit the form. + The CSRF token is invalid. Please try to resubmit the form. + + + This value is not a valid HTML5 color. + This value is not a valid HTML5 color. + + + Please enter a valid birthdate. + Please enter a valid birthdate. + + + The selected choice is invalid. + The selected choice is invalid. + + + The collection is invalid. + The collection is invalid. + + + Please select a valid color. + Please select a valid color. + + + Please select a valid country. + Please select a valid country. + + + Please select a valid currency. + Please select a valid currency. + + + Please choose a valid date interval. + Please choose a valid date interval. + + + Please enter a valid date and time. + Please enter a valid date and time. + + + Please enter a valid date. + Please enter a valid date. + + + Please select a valid file. + Please select a valid file. + + + The hidden field is invalid. + The hidden field is invalid. + + + Please enter an integer. + Please enter an integer. + + + Please select a valid language. + Please select a valid language. + + + Please select a valid locale. + Please select a valid locale. + + + Please enter a valid money amount. + Please enter a valid money amount. + + + Please enter a number. + Please enter a number. + + + The password is invalid. + The password is invalid. + + + Please enter a percentage value. + Please enter a percentage value. + + + The values do not match. + The values do not match. + + + Please enter a valid time. + Please enter a valid time. + + + Please select a valid timezone. + Please select a valid timezone. + + + Please enter a valid URL. + Please enter a valid URL. + + + Please enter a valid search term. + Please enter a valid search term. + + + Please provide a valid phone number. + Please provide a valid phone number. + + + The checkbox has an invalid value. + The checkbox has an invalid value. + + + Please enter a valid email address. + Please enter a valid email address. + + + Please select a valid option. + Please select a valid option. + + + Please select a valid range. + Please select a valid range. + + + Please enter a valid week. + Please enter a valid week. + + +
+
diff --git a/src/Core/ConfigProvider.php b/src/Core/ParameterProvider.php similarity index 93% rename from src/Core/ConfigProvider.php rename to src/Core/ParameterProvider.php index b78f365f..ac278984 100644 --- a/src/Core/ConfigProvider.php +++ b/src/Core/ParameterProvider.php @@ -4,7 +4,7 @@ namespace PhpList\Core\Core; -class ConfigProvider +class ParameterProvider { public function __construct(private readonly array $config) { diff --git a/src/Domain/Analytics/Service/LinkTrackService.php b/src/Domain/Analytics/Service/LinkTrackService.php index 2252a0f0..75242104 100644 --- a/src/Domain/Analytics/Service/LinkTrackService.php +++ b/src/Domain/Analytics/Service/LinkTrackService.php @@ -4,7 +4,7 @@ namespace PhpList\Core\Domain\Analytics\Service; -use PhpList\Core\Core\ConfigProvider; +use PhpList\Core\Core\ParameterProvider; use PhpList\Core\Domain\Analytics\Exception\MissingMessageIdException; use PhpList\Core\Domain\Analytics\Model\LinkTrack; use PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository; @@ -13,12 +13,12 @@ class LinkTrackService { private LinkTrackRepository $linkTrackRepository; - private ConfigProvider $configProvider; + private ParameterProvider $paramProvider; - public function __construct(LinkTrackRepository $linkTrackRepository, ConfigProvider $configProvider) + public function __construct(LinkTrackRepository $linkTrackRepository, ParameterProvider $paramProvider) { $this->linkTrackRepository = $linkTrackRepository; - $this->configProvider = $configProvider; + $this->paramProvider = $paramProvider; } public function getUrlById(int $id): ?string @@ -29,7 +29,7 @@ public function getUrlById(int $id): ?string public function isExtractAndSaveLinksApplicable(): bool { - return (bool)$this->configProvider->get('click_track', false); + return (bool)$this->paramProvider->get('click_track', false); } /** diff --git a/src/Domain/Configuration/Model/ConfigOption.php b/src/Domain/Configuration/Model/ConfigOption.php new file mode 100644 index 00000000..86b9286e --- /dev/null +++ b/src/Domain/Configuration/Model/ConfigOption.php @@ -0,0 +1,18 @@ +findOneBy(['key' => $name])?->getValue(); + } } diff --git a/src/Domain/Configuration/Service/LegacyUrlBuilder.php b/src/Domain/Configuration/Service/LegacyUrlBuilder.php new file mode 100644 index 00000000..4bc6366f --- /dev/null +++ b/src/Domain/Configuration/Service/LegacyUrlBuilder.php @@ -0,0 +1,29 @@ +configRepository = $configRepository; } - public function inMaintenanceMode(): bool - { - $config = $this->getByItem('maintenancemode'); - return $config?->getValue() === '1'; - } - /** * Get a configuration item by its key */ diff --git a/src/Domain/Configuration/Service/PlaceholderResolver.php b/src/Domain/Configuration/Service/PlaceholderResolver.php new file mode 100644 index 00000000..3a0a3464 --- /dev/null +++ b/src/Domain/Configuration/Service/PlaceholderResolver.php @@ -0,0 +1,33 @@ + */ + private array $providers = []; + + public function register(string $token, callable $provider): void + { + // tokens like [UNSUBSCRIBEURL] (case-insensitive) + $this->providers[strtoupper($token)] = $provider; + } + + public function resolve(?string $input): ?string + { + if ($input === null || $input === '') { + return $input; + } + + // Replace [TOKEN] (case-insensitive) + return preg_replace_callback('/\[(\w+)\]/i', function ($map) { + $key = strtoupper($map[1]); + if (!isset($this->providers[$key])) { + return $map[0]; + } + return (string) ($this->providers[$key])(); + }, $input); + } +} diff --git a/src/Domain/Configuration/Service/Provider/ConfigProvider.php b/src/Domain/Configuration/Service/Provider/ConfigProvider.php new file mode 100644 index 00000000..a1db70fc --- /dev/null +++ b/src/Domain/Configuration/Service/Provider/ConfigProvider.php @@ -0,0 +1,85 @@ +booleanValues)) { + throw new InvalidArgumentException('Invalid boolean value key'); + } + $config = $this->configRepository->findOneBy(['item' => $key->value]); + + if ($config !== null) { + return $config->getValue() === '1'; + } + + return $this->defaultConfigs->has($key->value) && $this->defaultConfigs->get($key->value)['value'] === '1'; + } + + /** + * Get configuration value by its key, from settings or default configs or default value (if provided) + * @SuppressWarnings(PHPMD.StaticAccess) + * @throws InvalidArgumentException + */ + public function getValue(ConfigOption $key): ?string + { + if (in_array($key, $this->booleanValues)) { + throw new InvalidArgumentException('Key is a boolean value, use isEnabled instead'); + } + $cacheKey = 'cfg:' . $key->value; + $value = $this->cache->get($cacheKey); + if ($value === null) { + $value = $this->configRepository->findValueByItem($key->value); + $this->cache->set($cacheKey, $value, $this->ttlSeconds); + } + + if ($value !== null) { + return $value; + } + + return $this->defaultConfigs->has($key->value) ? $this->defaultConfigs->get($key->value)['value'] : null; + } + + /** @SuppressWarnings(PHPMD.StaticAccess) */ + public function getValueWithNamespace(ConfigOption $key): ?string + { + $full = $this->getValue($key); + if ($full !== null && $full !== '') { + return $full; + } + + if (str_contains($key->value, ':')) { + [$parent] = explode(':', $key->value, 2); + $parentKey = ConfigOption::from($parent); + + return $this->getValue($parentKey); + } + + return null; + } +} diff --git a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php new file mode 100644 index 00000000..bbe14a46 --- /dev/null +++ b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php @@ -0,0 +1,587 @@ +defaults)) { + return; + } + + $publicSchema = 'http'; + $pageRoot = '/api/v2'; + + $this->defaults = [ + 'admin_address' => [ + 'value' => 'webmaster@[DOMAIN]', + 'description' => $this->translator->trans('Person in charge of this system (one email address)'), + 'type' => 'email', + 'allowempty' => false, + 'category' => 'general', + ], + 'organisation_name' => [ + 'value' => '', + 'description' => $this->translator->trans('Name of the organisation'), + 'type' => 'text', + 'allowempty' => true, + 'allowtags' => '

', + 'allowJS' => false, + 'category' => 'general', + ], + 'organisation_logo' => [ + 'value' => '', + 'description' => $this->translator->trans('Logo of the organisation'), + 'infoicon' => true, + 'type' => 'image', + 'allowempty' => true, + 'category' => 'general', + ], + 'date_format' => [ + 'value' => 'j F Y', + 'description' => $this->translator->trans('Date format'), + 'infoicon' => true, + 'type' => 'text', + 'allowempty' => false, + 'category' => 'general', + ], + 'rc_notification' => [ + 'value' => 0, + 'description' => $this->translator->trans('Show notification for Release Candidates'), + 'type' => 'boolean', + 'allowempty' => true, + 'category' => 'security', + ], + 'remote_processing_secret' => [ + 'value' => bin2hex(random_bytes(10)), + 'description' => $this->translator->trans('Secret for remote processing'), + 'type' => 'text', + 'category' => 'security', + ], + 'notify_admin_login' => [ + 'value' => 1, + 'description' => $this->translator->trans('Notify admin on login from new location'), + 'type' => 'boolean', + 'category' => 'security', + 'allowempty' => true, + ], + 'admin_addresses' => [ + 'value' => '', + 'description' => $this->translator->trans( + 'List of email addresses to CC in system messages (separate by commas)' + ), + 'type' => 'emaillist', + 'allowempty' => true, + 'category' => 'reporting', + ], + 'campaignfrom_default' => [ + 'value' => '', + 'description' => $this->translator->trans("Default for 'From:' in a campaign"), + 'type' => 'text', + 'allowempty' => true, + 'category' => 'campaign', + ], + 'notifystart_default' => [ + 'value' => '', + 'description' => $this->translator->trans("Default for 'address to alert when sending starts'"), + 'type' => 'email', + 'allowempty' => true, + 'category' => 'campaign', + ], + 'notifyend_default' => [ + 'value' => '', + 'description' => $this->translator->trans("Default for 'address to alert when sending finishes'"), + 'type' => 'email', + 'allowempty' => true, + 'category' => 'campaign', + ], + 'always_add_googletracking' => [ + 'value' => '0', + 'description' => $this->translator->trans('Always add analytics tracking code to campaigns'), + 'type' => 'boolean', + 'allowempty' => true, + 'category' => 'campaign', + ], + 'analytic_tracker' => [ + 'values' => ['google' => 'Google Analytics', 'matomo' => 'Matomo'], + 'value' => 'google', + 'description' => $this->translator->trans('Analytics tracking code to add to campaign URLs'), + 'type' => 'select', + 'allowempty' => false, + 'category' => 'campaign', + ], + 'report_address' => [ + 'value' => 'listreports@[DOMAIN]', + 'description' => $this->translator->trans( + 'Who gets the reports (email address, separate multiple emails with a comma)' + ), + 'type' => 'emaillist', + 'allowempty' => true, + 'category' => 'reporting', + ], + 'message_from_address' => [ + 'value' => 'noreply@[DOMAIN]', + 'description' => $this->translator->trans('From email address for system messages'), + 'type' => 'email', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'message_from_name' => [ + 'value' => $this->translator->trans('Webmaster'), + 'description' => $this->translator->trans('Name for system messages'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'message_replyto_address' => [ + 'value' => 'noreply@[DOMAIN]', + 'description' => $this->translator->trans('Reply-to email address for system messages'), + 'type' => 'email', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'hide_single_list' => [ + 'value' => '1', + 'description' => $this->translator->trans('If there is only one visible list, should it be hidden in the page and automatically subscribe users who sign up'), + 'type' => 'boolean', + 'allowempty' => true, + 'category' => 'subscription-ui', + ], + 'list_categories' => [ + 'value' => '', + 'description' => $this->translator->trans('Categories for lists. Separate with commas.'), + 'infoicon' => true, + 'type' => 'text', + 'allowempty' => true, + 'category' => 'list-organisation', + ], + 'displaycategories' => [ + 'value' => 0, + 'description' => $this->translator->trans('Display list categories on subscribe page'), + 'type' => 'boolean', + 'allowempty' => false, + 'category' => 'list-organisation', + ], + 'textline_width' => [ + 'value' => '40', + 'description' => $this->translator->trans('Width of a textline field (numerical)'), + 'type' => 'integer', + 'min' => 20, + 'max' => 150, + 'category' => 'subscription-ui', + ], + 'textarea_dimensions' => [ + 'value' => '10,40', + 'description' => $this->translator->trans('Dimensions of a textarea field (rows,columns)'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'subscription-ui', + ], + 'send_admin_copies' => [ + 'value' => '0', + 'description' => $this->translator->trans('Send notifications about subscribe, update and unsubscribe'), + 'type' => 'boolean', + 'allowempty' => true, + 'category' => 'reporting', + ], + 'defaultsubscribepage' => [ + 'value' => 1, + 'description' => $this->translator->trans('The default subscribe page when there are multiple'), + 'type' => 'integer', + 'min' => 1, + 'max' => 999, + 'allowempty' => true, + 'category' => 'subscription', + ], + 'defaultmessagetemplate' => [ + 'value' => 0, + 'description' => $this->translator->trans('The default HTML template to use when sending a message'), + 'type' => 'text', + 'allowempty' => true, + 'category' => 'campaign', + ], + 'systemmessagetemplate' => [ + 'value' => 0, + 'description' => $this->translator->trans('The HTML wrapper template for system messages'), + 'type' => 'integer', + 'min' => 0, + 'max' => 999, + 'allowempty' => true, + 'category' => 'transactional', + ], + 'subscribeurl' => [ + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=subscribe', + 'description' => $this->translator->trans('URL where subscribers can sign up'), + 'type' => 'url', + 'allowempty' => 0, + 'category' => 'subscription', + ], + 'unsubscribeurl' => [ + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=unsubscribe', + 'description' => $this->translator->trans('URL where subscribers can unsubscribe'), + 'type' => 'url', + 'allowempty' => 0, + 'category' => 'subscription', + ], + 'blacklisturl' => [ + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=donotsend', + 'description' => $this->translator->trans('URL where unknown users can unsubscribe (do-not-send-list)'), + 'type' => 'url', + 'allowempty' => 0, + 'category' => 'subscription', + ], + 'confirmationurl' => [ + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=confirm', + 'description' => $this->translator->trans('URL where subscribers have to confirm their subscription'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'subscription', + ], + 'preferencesurl' => [ + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=preferences', + 'description' => $this->translator->trans('URL where subscribers can update their details'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'subscription', + ], + 'forwardurl' => [ + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=forward', + 'description' => $this->translator->trans('URL for forwarding messages'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'subscription', + ], + 'vcardurl' => [ + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=vcard', + 'description' => $this->translator->trans('URL for downloading vcf card'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'subscription', + ], + 'ajax_subscribeconfirmation' => [ + 'value' => $this->translator->trans('

Thanks, you have been added to our newsletter

You will receive an email to confirm your subscription. Please click the link in the email to confirm

'), + 'description' => $this->translator->trans('Text to display when subscription with an AJAX request was successful'), + 'type' => 'textarea', + 'allowempty' => true, + 'category' => 'subscription', + ], + 'subscribesubject' => [ + 'value' => $this->translator->trans('Request for confirmation'), + 'description' => $this->translator->trans( + 'Subject of the message subscribers receive when they sign up' + ), + 'infoicon' => true, + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'subscribemessage' => [ + 'value' => ' You have been subscribed to the following newsletters: + +[LISTS] + + +Please click the following link to confirm it\'s really you: + +[CONFIRMATIONURL] + + +In order to provide you with this service we\'ll need to + +Transfer your contact information to [DOMAIN] +Store your contact information in your [DOMAIN] account +Send you emails from [DOMAIN] +Track your interactions with these emails for marketing purposes + +If this is not correct, or you do not agree, simply take no action and delete this message.' + , + 'description' => $this->translator->trans('Message subscribers receive when they sign up'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'unsubscribesubject' => [ + 'value' => $this->translator->trans('Goodbye from our Newsletter'), + 'description' => $this->translator->trans( + 'Subject of the message subscribers receive when they unsubscribe' + ), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'unsubscribemessage' => [ + 'value' => 'Goodbye from our Newsletter, sorry to see you go. + +You have been unsubscribed from our newsletters. + +This is the last email you will receive from us. Our newsletter system, phpList, +will refuse to send you any further messages, without manual intervention by our administrator. + +If there is an error in this information, you can re-subscribe: +please go to [SUBSCRIBEURL] and follow the steps. + +Thank you' + , + 'description' => $this->translator->trans('Message subscribers receive when they unsubscribe'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'confirmationsubject' => [ + 'value' => $this->translator->trans('Welcome to our Newsletter'), + 'description' => $this->translator->trans( + 'Subject of the message subscribers receive after confirming their email address' + ), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'confirmationmessage' => [ + 'value' => 'Welcome to our Newsletter + +Please keep this message for later reference. + +Your email address has been added to the following newsletter(s): +[LISTS] + +To update your details and preferences please go to [PREFERENCESURL]. +If you do not want to receive any more messages, please go to [UNSUBSCRIBEURL]. + +Thank you' + , + 'description' => $this->translator->trans( + 'Message subscribers receive after confirming their email address' + ), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'updatesubject' => [ + 'value' => $this->translator->trans('[notify] Change of List-Membership details'), + 'description' => $this->translator->trans( + 'Subject of the message subscribers receive when they have changed their details' + ), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'transactional', + ], + // the message that is sent when a user updates their information. + // just to make sure they approve of it. + // confirmationinfo is replaced by one of the options below + // userdata is replaced by the information in the database + 'updatemessage' => [ + 'value' => 'This message is to inform you of a change of your details on our newsletter database + +You are currently member of the following newsletters: + +[LISTS] + +[CONFIRMATIONINFO] + +The information on our system for you is as follows: + +[USERDATA] + +If this is not correct, please update your information at the following location: + +[PREFERENCESURL] + +Thank you' + , + 'description' => $this->translator->trans( + 'Message subscribers receive when they have changed their details' + ), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ], + // this is the text that is placed in the [!-- confirmation --] location of the above + // message, in case the email is sent to their new email address and they have changed + // their email address + 'emailchanged_text' => [ + 'value' => ' + When updating your details, your email address has changed. + Please confirm your new email address by visiting this webpage: + + [CONFIRMATIONURL] + + ', + 'description' => $this->translator->trans('Part of the message that is sent to their new email address when subscribers change their information, and the email address has changed'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ], + // this is the text that is placed in the [!-- confirmation --] location of the above + // message, in case the email is sent to their old email address and they have changed + // their email address + 'emailchanged_text_oldaddress' => [ + 'value' => 'Please Note: when updating your details, your email address has changed. + +A message has been sent to your new email address with a URL +to confirm this change. Please visit this website to activate +your membership.' + , + 'description' => $this->translator->trans('Part of the message that is sent to their old email address when subscribers change their information, and the email address has changed'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'personallocation_subject' => [ + 'value' => $this->translator->trans('Your personal location'), + 'description' => $this->translator->trans( + 'Subject of message when subscribers request their personal location' + ), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'messagefooter' => [ + 'value' => '-- + + + + ', + 'description' => $this->translator->trans('Default footer for sending a campaign'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'campaign', + ], + 'forwardfooter' => [ + 'value' => ' + + ', + 'description' => $this->translator->trans('Footer used when a message has been forwarded'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'campaign', + ], + 'personallocation_message' => [ + 'value' => 'You have requested your personal location to update your details from our website. +The location is below. Please make sure that you use the full line as mentioned below. +Sometimes email programmes can wrap the line into multiple lines. + +Your personal location is: +[PREFERENCESURL] + +Thank you.' + , + 'description' => $this->translator->trans('Message to send when they request their personal location'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'remoteurl_append' => [ + 'value' => '', + 'description' => $this->translator->trans( + 'String to always append to remote URL when using send-a-webpage' + ), + 'type' => 'text', + 'allowempty' => true, + 'category' => 'campaign', + ], + 'wordwrap' => [ + 'value' => '75', + 'description' => $this->translator->trans('Width for Wordwrap of Text messages'), + 'type' => 'text', + 'allowempty' => true, + 'category' => 'campaign', + ], + 'html_email_style' => [ + 'value' => '', + 'description' => $this->translator->trans('CSS for HTML messages without a template'), + 'type' => 'textarea', + 'allowempty' => true, + 'category' => 'campaign', + ], + 'alwayssendtextto' => [ + 'value' => '', + 'description' => $this->translator->trans('Domains that only accept text emails, one per line'), + 'type' => 'textarea', + 'allowempty' => true, + 'category' => 'campaign', + ], + 'tld_last_sync' => [ + 'value' => '0', + 'description' => $this->translator->trans('last time TLDs were fetched'), + 'type' => 'text', + 'allowempty' => true, + 'category' => 'system', + 'hidden' => true, + ], + 'internet_tlds' => [ + 'value' => '', + 'description' => $this->translator->trans('Top level domains'), + 'type' => 'textarea', + 'allowempty' => true, + 'category' => 'system', + 'hidden' => true, + ], + 'pageheader' => [ + 'value' => '

Welcome

', + 'description' => $this->translator->trans('Header of public pages.'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'subscription-ui', + ], + 'pagefooter' => [ + 'value' => '

Footer text

', + 'description' => $this->translator->trans('Footer of public pages'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'subscription-ui', + ], + ]; + } + + /** + * Get a single default config item by key + * + * @param string $key + * @param mixed|null $default + * @return mixed + */ + public function get(string $key, mixed $default = null): mixed + { + $this->init(); + + return $this->defaults[$key] ?? $default; + } + + /** + * Check if a config key exists + */ + public function has(string $key): bool + { + $this->init(); + + return isset($this->defaults[$key]); + } +} diff --git a/src/Domain/Configuration/Service/UserPersonalizer.php b/src/Domain/Configuration/Service/UserPersonalizer.php new file mode 100644 index 00000000..7aedf1d8 --- /dev/null +++ b/src/Domain/Configuration/Service/UserPersonalizer.php @@ -0,0 +1,69 @@ +subscriberRepository->findOneByEmail($email); + if (!$user) { + return $value; + } + + $resolver = new PlaceholderResolver(); + $resolver->register('EMAIL', fn() => $user->getEmail()); + + $resolver->register('UNSUBSCRIBEURL', function () use ($user) { + $base = $this->config->getValue(ConfigOption::UnsubscribeUrl) ?? ''; + return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE; + }); + + $resolver->register('CONFIRMATIONURL', function () use ($user) { + $base = $this->config->getValue(ConfigOption::ConfirmationUrl) ?? ''; + return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE; + }); + $resolver->register('PREFERENCESURL', function () use ($user) { + $base = $this->config->getValue(ConfigOption::PreferencesUrl) ?? ''; + return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE; + }); + + $resolver->register( + 'SUBSCRIBEURL', + fn() => ($this->config->getValue(ConfigOption::SubscribeUrl) ?? '') . self::PHP_SPACE + ); + $resolver->register('DOMAIN', fn() => $this->config->getValue(ConfigOption::Domain) ?? ''); + $resolver->register('WEBSITE', fn() => $this->config->getValue(ConfigOption::Website) ?? ''); + + $userAttributes = $this->attributesRepository->getForSubscriber($user); + foreach ($userAttributes as $userAttribute) { + $resolver->register( + strtoupper($userAttribute->getAttributeDefinition()->getName()), + fn() => $this->attributeValueResolver->resolve($userAttribute) + ); + } + + $out = $resolver->resolve($value); + + return (string) $out; + } +} diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php index b9a9068a..7ed9c0b5 100644 --- a/src/Domain/Messaging/Command/ProcessQueueCommand.php +++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php @@ -5,7 +5,8 @@ namespace PhpList\Core\Domain\Messaging\Command; use DateTimeImmutable; -use PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager; +use PhpList\Core\Domain\Configuration\Model\ConfigOption; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; @@ -28,7 +29,7 @@ class ProcessQueueCommand extends Command private LockFactory $lockFactory; private MessageProcessingPreparator $messagePreparator; private CampaignProcessor $campaignProcessor; - private ConfigManager $configManager; + private ConfigProvider $configProvider; private TranslatorInterface $translator; public function __construct( @@ -36,7 +37,7 @@ public function __construct( LockFactory $lockFactory, MessageProcessingPreparator $messagePreparator, CampaignProcessor $campaignProcessor, - ConfigManager $configManager, + ConfigProvider $configProvider, TranslatorInterface $translator ) { parent::__construct(); @@ -44,7 +45,7 @@ public function __construct( $this->lockFactory = $lockFactory; $this->messagePreparator = $messagePreparator; $this->campaignProcessor = $campaignProcessor; - $this->configManager = $configManager; + $this->configProvider = $configProvider; $this->translator = $translator; } @@ -60,7 +61,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - if ($this->configManager->inMaintenanceMode()) { + if ($this->configProvider->isEnabled(ConfigOption::MaintenanceMode)) { $output->writeln( $this->translator->trans('The system is in maintenance mode, stopping. Try again later.') ); diff --git a/src/Domain/Messaging/Message/SubscriptionConfirmationMessage.php b/src/Domain/Messaging/Message/SubscriptionConfirmationMessage.php new file mode 100644 index 00000000..22515145 --- /dev/null +++ b/src/Domain/Messaging/Message/SubscriptionConfirmationMessage.php @@ -0,0 +1,51 @@ +email = $email; + $this->uniqueId = $uniqueId; + $this->listIds = $listIds; + $this->htmlEmail = $htmlEmail; + } + + public function getEmail(): string + { + return $this->email; + } + + public function getUniqueId(): string + { + return $this->uniqueId; + } + + public function getListIds(): array + { + return $this->listIds; + } + + public function hasHtmlEmail(): bool + { + return $this->htmlEmail; + } +} diff --git a/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php b/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php new file mode 100644 index 00000000..6ecb965b --- /dev/null +++ b/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php @@ -0,0 +1,76 @@ +emailService = $emailService; + $this->configProvider = $configProvider; + $this->logger = $logger; + $this->userPersonalizer = $userPersonalizer; + $this->subscriberListRepository = $subscriberListRepository; + } + + /** + * Process a subscription confirmation message by sending the confirmation email + */ + public function __invoke(SubscriptionConfirmationMessage $message): void + { + $subject = $this->configProvider->getValue(ConfigOption::SubscribeEmailSubject); + $textContent = $this->configProvider->getValue(ConfigOption::SubscribeMessage); + $personalizedTextContent = $this->userPersonalizer->personalize($textContent, $message->getUniqueId()); + $listOfLists = $this->getListNames($message->getListIds()); + $replacedTextContent = str_replace('[LISTS]', $listOfLists, $personalizedTextContent); + + $email = (new Email()) + ->to($message->getEmail()) + ->subject($subject) + ->text($replacedTextContent); + + $this->emailService->sendEmail($email); + + $this->logger->info('Subscription confirmation email sent to {email}', ['email' => $message->getEmail()]); + } + + private function getListNames(array $listIds): string + { + $listNames = []; + foreach ($listIds as $id) { + $list = $this->subscriberListRepository->find($id); + if ($list) { + $listNames[] = $list->getName(); + } + } + + return implode(', ', $listNames); + } +} diff --git a/src/Domain/Subscription/Repository/DynamicListAttrRepository.php b/src/Domain/Subscription/Repository/DynamicListAttrRepository.php new file mode 100644 index 00000000..104938b0 --- /dev/null +++ b/src/Domain/Subscription/Repository/DynamicListAttrRepository.php @@ -0,0 +1,62 @@ + + * @throws InvalidArgumentException + */ + public function fetchOptionNames(string $listTable, array $ids): array + { + if (empty($ids)) { + return []; + } + + if (!preg_match('/^[A-Za-z0-9_]+$/', $listTable)) { + throw new InvalidArgumentException('Invalid list table'); + } + + $table = $this->prefix . 'listattr_' . $listTable; + + $queryBuilder = $this->connection->createQueryBuilder(); + $queryBuilder->select('name') + ->from($table) + ->where('id IN (:ids)') + ->setParameter('ids', array_map('intval', $ids), ArrayParameterType::INTEGER); + + return $queryBuilder->executeQuery()->fetchFirstColumn(); + } + + public function fetchSingleOptionName(string $listTable, int $id): ?string + { + if (!preg_match('/^[A-Za-z0-9_]+$/', $listTable)) { + throw new InvalidArgumentException('Invalid list table'); + } + + $table = $this->prefix . 'listattr_' . $listTable; + + $queryBuilder = $this->connection->createQueryBuilder(); + $queryBuilder->select('name') + ->from($table) + ->where('id = :id') + ->setParameter('id', $id); + + $val = $queryBuilder->executeQuery()->fetchOne(); + + return $val === false ? null : (string) $val; + } +} diff --git a/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php b/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php index d29d56ff..6da037fd 100644 --- a/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php @@ -64,4 +64,16 @@ public function getFilteredAfterId(int $lastId, int $limit, ?FilterRequestInterf ->getQuery() ->getResult(); } + + /** @return SubscriberAttributeValue[] */ + public function getForSubscriber(Subscriber $subscriber): array + { + return $this->createQueryBuilder('sa') + ->join('sa.subscriber', 's') + ->join('sa.attributeDefinition', 'ad') + ->where('s = :subscriber') + ->setParameter('subscriber', $subscriber) + ->getQuery() + ->getResult(); + } } diff --git a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php index 6bed4d5b..c9921bab 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php @@ -32,12 +32,12 @@ public function __construct( $this->translator = $translator; } - public function addSubscriberToAList(Subscriber $subscriber, int $listId): Subscription + public function addSubscriberToAList(Subscriber $subscriber, int $listId): ?Subscription { $existingSubscription = $this->subscriptionRepository ->findOneBySubscriberEmailAndListId($listId, $subscriber->getEmail()); if ($existingSubscription) { - return $existingSubscription; + return null; } $subscriberList = $this->subscriberListRepository->find($listId); if (!$subscriberList) { diff --git a/src/Domain/Subscription/Service/Provider/AttributeValueProvider.php b/src/Domain/Subscription/Service/Provider/AttributeValueProvider.php new file mode 100644 index 00000000..e4f9f31f --- /dev/null +++ b/src/Domain/Subscription/Service/Provider/AttributeValueProvider.php @@ -0,0 +1,16 @@ +getType() === 'checkboxgroup'; + } + + public function getValue(SubscriberAttributeDefinition $attribute, SubscriberAttributeValue $userValue): string + { + $csv = $userValue->getValue() ?? ''; + if ($csv === '') { + return ''; + } + + $ids = array_values(array_filter(array_map(function ($value) { + $index = (int) trim($value); + return $index > 0 ? $index : null; + }, explode(',', $csv)))); + + if (empty($ids) || !$attribute->getTableName()) { + return ''; + } + + $names = $this->repo->fetchOptionNames($attribute->getTableName(), $ids); + + return implode('; ', $names); + } +} diff --git a/src/Domain/Subscription/Service/Provider/ScalarValueProvider.php b/src/Domain/Subscription/Service/Provider/ScalarValueProvider.php new file mode 100644 index 00000000..427fe1db --- /dev/null +++ b/src/Domain/Subscription/Service/Provider/ScalarValueProvider.php @@ -0,0 +1,21 @@ +getType() === null; + } + + public function getValue(SubscriberAttributeDefinition $attribute, SubscriberAttributeValue $userValue): string + { + return $userValue->getValue() ?? ''; + } +} diff --git a/src/Domain/Subscription/Service/Provider/SelectOrRadioValueProvider.php b/src/Domain/Subscription/Service/Provider/SelectOrRadioValueProvider.php new file mode 100644 index 00000000..22b3ab4e --- /dev/null +++ b/src/Domain/Subscription/Service/Provider/SelectOrRadioValueProvider.php @@ -0,0 +1,35 @@ +getType(), ['select','radio'], true); + } + + public function getValue(SubscriberAttributeDefinition $attribute, SubscriberAttributeValue $userValue): string + { + if (!$attribute->getTableName()) { + return ''; + } + + $id = (int)($userValue->getValue() ?? 0); + if ($id <= 0) { + return ''; + } + + return $this->repo->fetchSingleOptionName($attribute->getTableName(), $id) ?? ''; + } +} diff --git a/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php b/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php new file mode 100644 index 00000000..c63b7f8c --- /dev/null +++ b/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php @@ -0,0 +1,26 @@ + $providers */ + public function __construct(private readonly iterable $providers) + { + } + + public function resolve(SubscriberAttributeValue $userAttr): string + { + foreach ($this->providers as $provider) { + if ($provider->supports($userAttr->getAttributeDefinition())) { + return $provider->getValue($userAttr->getAttributeDefinition(), $userAttr); + } + } + return ''; + } +} diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php index c88b935e..f5d9e535 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Domain\Subscription\Service; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Messaging\Message\SubscriptionConfirmationMessage; use PhpList\Core\Domain\Subscription\Exception\CouldNotReadUploadedFileException; use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions; @@ -15,6 +16,7 @@ use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager; use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Contracts\Translation\TranslatorInterface; use Throwable; @@ -32,6 +34,7 @@ class SubscriberCsvImporter private SubscriberAttributeDefinitionRepository $attrDefinitionRepository; private EntityManagerInterface $entityManager; private TranslatorInterface $translator; + private MessageBusInterface $messageBus; public function __construct( SubscriberManager $subscriberManager, @@ -42,6 +45,7 @@ public function __construct( SubscriberAttributeDefinitionRepository $attrDefinitionRepository, EntityManagerInterface $entityManager, TranslatorInterface $translator, + MessageBusInterface $messageBus, ) { $this->subscriberManager = $subscriberManager; $this->attributeManager = $attributeManager; @@ -51,6 +55,7 @@ public function __construct( $this->attrDefinitionRepository = $attrDefinitionRepository; $this->entityManager = $entityManager; $this->translator = $translator; + $this->messageBus = $messageBus; } /** @@ -83,9 +88,6 @@ public function importFromCsv(UploadedFile $file, SubscriberImportOptions $optio foreach ($result['valid'] as $dto) { try { $this->processRow($dto, $options, $stats); - if (!$options->dryRun) { - $this->entityManager->flush(); - } } catch (Throwable $e) { $stats['errors'][] = $this->translator->trans( 'Error processing %email%: %error%', @@ -149,21 +151,17 @@ private function processRow( SubscriberImportOptions $options, array &$stats, ): void { - if (!filter_var($dto->email, FILTER_VALIDATE_EMAIL)) { - if ($options->skipInvalidEmail) { - $stats['skipped']++; - return; - } else { - $dto->email = 'invalid_' . $dto->email; - $dto->sendConfirmation = false; - } + if ($this->handleInvalidEmail($dto, $options, $stats)) { + return; } - $subscriber = $this->subscriberRepository->findOneByEmail($dto->email); + $subscriber = $this->subscriberRepository->findOneByEmail($dto->email); if ($subscriber && !$options->updateExisting) { $stats['skipped']++; + return; } + if ($subscriber) { $this->subscriberManager->updateFromImport($subscriber, $dto); $stats['updated']++; @@ -174,13 +172,65 @@ private function processRow( $this->processAttributes($subscriber, $dto); - if (count($options->listIds) > 0) { + $addedNewSubscriberToList = false; + if (!$subscriber->isBlacklisted() && count($options->listIds) > 0) { foreach ($options->listIds as $listId) { - $this->subscriptionManager->addSubscriberToAList($subscriber, $listId); + $created = $this->subscriptionManager->addSubscriberToAList($subscriber, $listId); + if ($created) { + $addedNewSubscriberToList = true; + } + } + } + + $this->handleFlushAndEmail($subscriber, $options, $dto, $addedNewSubscriberToList); + } + + private function handleInvalidEmail( + ImportSubscriberDto $dto, + SubscriberImportOptions $options, + array &$stats + ): bool { + if (!filter_var($dto->email, FILTER_VALIDATE_EMAIL)) { + if ($options->skipInvalidEmail) { + $stats['skipped']++; + + return true; + } + // phpcs:ignore Generic.Commenting.Todo + // @todo: check + $dto->email = 'invalid_' . $dto->email; + $dto->sendConfirmation = false; + } + + return false; + } + + private function handleFlushAndEmail( + Subscriber $subscriber, + SubscriberImportOptions $options, + ImportSubscriberDto $dto, + bool $addedNewSubscriberToList + ): void { + if (!$options->dryRun) { + $this->entityManager->flush(); + if ($dto->sendConfirmation && $addedNewSubscriberToList) { + $this->sendSubscribeEmail($subscriber, $options->listIds); } } } + private function sendSubscribeEmail(Subscriber $subscriber, array $listIds): void + { + $message = new SubscriptionConfirmationMessage( + email: $subscriber->getEmail(), + uniqueId: $subscriber->getUniqueId(), + listIds: $listIds, + htmlEmail: $subscriber->hasHtmlEmail(), + ); + + $this->messageBus->dispatch($message); + } + /** * Process subscriber attributes. * @@ -190,6 +240,12 @@ private function processRow( private function processAttributes(Subscriber $subscriber, ImportSubscriberDto $dto): void { foreach ($dto->extraAttributes as $key => $value) { + $lowerKey = strtolower((string)$key); + // Do not import or update sensitive/system fields from CSV + if (in_array($lowerKey, ['password', 'modified'], true)) { + continue; + } + $attributeDefinition = $this->attrDefinitionRepository->findOneByName($key); if ($attributeDefinition !== null) { $this->attributeManager->createOrUpdate( diff --git a/tests/Integration/Core/ConfigProviderTest.php b/tests/Integration/Core/ConfigProviderTest.php index d2fdf896..5776bd17 100644 --- a/tests/Integration/Core/ConfigProviderTest.php +++ b/tests/Integration/Core/ConfigProviderTest.php @@ -4,14 +4,14 @@ namespace PhpList\Core\Tests\Integration\Core; -use PhpList\Core\Core\ConfigProvider; +use PhpList\Core\Core\ParameterProvider; use PHPUnit\Framework\TestCase; class ConfigProviderTest extends TestCase { public function testReturnsConfigValueIfExists(): void { - $provider = new ConfigProvider([ + $provider = new ParameterProvider([ 'site_name' => 'phpList', 'debug' => true, ]); @@ -22,7 +22,7 @@ public function testReturnsConfigValueIfExists(): void public function testReturnsDefaultIfKeyMissing(): void { - $provider = new ConfigProvider([ + $provider = new ParameterProvider([ 'site_name' => 'phpList', ]); @@ -33,7 +33,7 @@ public function testReturnsDefaultIfKeyMissing(): void public function testReturnsAllConfig(): void { $data = ['a' => 1, 'b' => 2]; - $provider = new ConfigProvider($data); + $provider = new ParameterProvider($data); $this->assertSame($data, $provider->all()); } diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php index 0e84fdec..c9d60ae3 100644 --- a/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php +++ b/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Tests\Integration\Domain\Subscription\Service; +use Doctrine\ORM\Tools\SchemaTool; use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; @@ -28,6 +29,10 @@ class SubscriberCsvImportManagerTest extends KernelTestCase protected function setUp(): void { parent::setUp(); + $this->setUpDatabaseTest(); + $schemaTool = new SchemaTool($this->entityManager); + $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); + $schemaTool->dropSchema($metadata); $this->loadSchema(); $this->subscriberCsvImportManager = self::getContainer()->get(SubscriberCsvImporter::class); @@ -49,16 +54,16 @@ public function testImportFromCsvCreatesNewSubscribers(): void file_put_contents($tempFile, $csvContent); $uploadedFile = new UploadedFile( - $tempFile, - 'subscribers.csv', - 'text/csv', - null, - true + path: $tempFile, + originalName: 'subscribers.csv', + mimeType: 'text/csv', + error: null, + test: true ); $subscriberCountBefore = count($this->subscriberRepository->findAll()); - $options = new SubscriberImportOptions(); + $options = new SubscriberImportOptions(true); $result = $this->subscriberCsvImportManager->importFromCsv($uploadedFile, $options); $subscriberCountAfter = count($this->subscriberRepository->findAll()); diff --git a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php index 109fb634..c136ec51 100644 --- a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php @@ -4,7 +4,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Analytics\Service; -use PhpList\Core\Core\ConfigProvider; +use PhpList\Core\Core\ParameterProvider; use PhpList\Core\Domain\Analytics\Exception\MissingMessageIdException; use PhpList\Core\Domain\Analytics\Model\LinkTrack; use PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository; @@ -22,13 +22,13 @@ class LinkTrackServiceTest extends TestCase protected function setUp(): void { $this->linkTrackRepository = $this->createMock(LinkTrackRepository::class); - $configProvider = $this->createMock(ConfigProvider::class); + $paramProvider = $this->createMock(ParameterProvider::class); - $configProvider->method('get') + $paramProvider->method('get') ->with('click_track', false) ->willReturn(true); - $this->subject = new LinkTrackService($this->linkTrackRepository, $configProvider); + $this->subject = new LinkTrackService($this->linkTrackRepository, $paramProvider); } public function testExtractAndSaveLinksWithNoLinks(): void @@ -185,12 +185,12 @@ public function testIsExtractAndSaveLinksApplicableWhenClickTrackIsTrue(): void public function testIsExtractAndSaveLinksApplicableWhenClickTrackIsFalse(): void { - $configProvider = $this->createMock(ConfigProvider::class); - $configProvider->method('get') + $paramProvider = $this->createMock(ParameterProvider::class); + $paramProvider->method('get') ->with('click_track', false) ->willReturn(false); - $subject = new LinkTrackService($this->linkTrackRepository, $configProvider); + $subject = new LinkTrackService($this->linkTrackRepository, $paramProvider); self::assertFalse($subject->isExtractAndSaveLinksApplicable()); } diff --git a/tests/Unit/Domain/Configuration/Service/LegacyUrlBuilderTest.php b/tests/Unit/Domain/Configuration/Service/LegacyUrlBuilderTest.php new file mode 100644 index 00000000..9f5cfe96 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/LegacyUrlBuilderTest.php @@ -0,0 +1,79 @@ +withUid($baseUrl, $uid); + + $this->assertSame($expected, $actual); + } + + public static function provideWithUidCases(): array + { + return [ + 'no query -> add uid' => [ + 'https://example.com/page', + 'ABC123', + 'https://example.com/page?uid=ABC123', + ], + 'existing query -> append uid' => [ + 'https://example.com/page?foo=bar', + 'ABC123', + 'https://example.com/page?foo=bar&uid=ABC123', + ], + 'existing uid -> override (uid replaced)' => [ + 'https://example.com/page?uid=OLD&x=1', + 'ABC123', + 'https://example.com/page?uid=ABC123&x=1', + ], + 'port and fragment preserved' => [ + 'http://example.com:8080/path?x=1#frag', + 'ABC123', + 'http://example.com:8080/path?x=1&uid=ABC123#frag', + ], + 'relative url -> defaults to https with empty host' => [ + '/relative/path', + 'ABC123', + // scheme defaults to https; empty host -> "https:///" + path + 'https:///relative/path?uid=ABC123', + ], + 'no query/fragment/port/host only' => [ + 'http://example.com', + 'ZZZ', + 'http://example.com?uid=ZZZ', + ], + ]; + } + + public function testQueryEncodingIsUrlEncoded(): void + { + $builder = new LegacyUrlBuilder(); + + $url = 'https://example.com/path?name=John+Doe&city=New+York'; + $result = $builder->withUid($url, 'üñíčødé space'); + + // Ensure it is a valid URL and uid is url-encoded inside query + $parts = parse_url($result); + parse_str($parts['query'] ?? '', $query); + + $this->assertSame('John Doe', $query['name']); + $this->assertSame('New York', $query['city']); + $this->assertSame('üñíčødé space', $query['uid']); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php b/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php new file mode 100644 index 00000000..e2a1d719 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php @@ -0,0 +1,92 @@ +assertNull($resolver->resolve(null)); + $this->assertSame('', $resolver->resolve('')); + } + + public function testUnregisteredTokensRemainUnchanged(): void + { + $resolver = new PlaceholderResolver(); + + $input = 'Hello [NAME], click [UNSUBSCRIBEURL] to opt out.'; + $this->assertSame($input, $resolver->resolve($input)); + } + + public function testCaseInsensitiveTokenResolution(): void + { + $resolver = new PlaceholderResolver(); + $resolver->register('unsubscribeurl', fn () => 'https://u.example/u/123'); + + $input = 'Click [UnSubscribeUrl]'; + $expect = 'Click https://u.example/u/123'; + + $this->assertSame($expect, $resolver->resolve($input)); + } + + public function testMultipleDifferentTokensAreResolved(): void + { + $resolver = new PlaceholderResolver(); + $resolver->register('NAME', fn () => 'Ada'); + $resolver->register('EMAIL', fn () => 'ada@example.com'); + + $input = 'Hi [NAME] <[email]>'; + $expect = 'Hi Ada '; + + $this->assertSame($expect, $resolver->resolve($input)); + } + + public function testAdjacentAndRepeatedTokens(): void + { + $resolver = new PlaceholderResolver(); + + $count = 0; + $resolver->register('X', function () use (&$count) { + $count++; + return 'V'; + }); + + $input = 'Start [x][X]-[x] End'; + $expect = 'Start VV-V End'; + + $this->assertSame($expect, $resolver->resolve($input)); + $this->assertSame(3, $count); + } + + public function testDigitsAndUnderscoresInToken(): void + { + $resolver = new PlaceholderResolver(); + $resolver->register('USER_2', fn () => 'Bob#2'); + + $input = 'Hello [user_2]!'; + $expect = 'Hello Bob#2!'; + + $this->assertSame($expect, $resolver->resolve($input)); + } + + public function testUnknownTokensArePreservedVerbatim(): void + { + $resolver = new PlaceholderResolver(); + $resolver->register('KNOWN', fn () => 'K'); + + $input = 'A[UNKNOWN]B[KNOWN]C'; + $expect = 'A[UNKNOWN]BKC'; + + $this->assertSame($expect, $resolver->resolve($input)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php b/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php new file mode 100644 index 00000000..12e36ed9 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php @@ -0,0 +1,279 @@ +repo = $this->createMock(ConfigRepository::class); + $this->cache = $this->createMock(CacheInterface::class); + $this->defaults = $this->createMock(DefaultConfigProvider::class); + + $this->provider = new ConfigProvider( + configRepository: $this->repo, + cache: $this->cache, + defaultConfigs: $this->defaults, + ttlSeconds: 300 + ); + } + + /** + * Utility: pick a non-boolean enum case (i.e., anything except MaintenanceMode). + */ + private function pickNonBooleanCase(): ConfigOption + { + foreach (ConfigOption::cases() as $case) { + if ($case !== ConfigOption::MaintenanceMode) { + return $case; + } + } + $this->markTestSkipped('No non-boolean ConfigOption cases available to test.'); + } + + /** + * Utility: pick a namespaced case "parent:child" where parent exists as its own case. + */ + private function pickNamespacedCasePair(): array + { + $byValue = []; + foreach (ConfigOption::cases() as $c) { + $byValue[$c->value] = $c; + } + + foreach (ConfigOption::cases() as $c) { + if (!str_contains($c->value, ':')) { + continue; + } + [$parent] = explode(':', $c->value, 2); + if (isset($byValue[$parent])) { + return [$c, $byValue[$parent]]; + } + } + + $this->markTestSkipped('No namespaced ConfigOption (parent:child) pair found.'); + } + + public function testIsEnabledRejectsNonBooleanKeys(): void + { + $nonBoolean = $this->pickNonBooleanCase(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid boolean value key'); + + $this->provider->isEnabled($nonBoolean); + } + + public function testIsEnabledUsesRepositoryValueWhenPresent(): void + { + $key = ConfigOption::MaintenanceMode; + + $configEntity = $this->createMock(Config::class); + $configEntity->method('getValue')->willReturn('1'); + + $this->repo + ->expects($this->once()) + ->method('findOneBy') + ->with(['item' => $key->value]) + ->willReturn($configEntity); + + // Defaults should not be consulted if repo has value + $this->defaults->expects($this->never())->method('has'); + $this->defaults->expects($this->never())->method('get'); + + $enabled = $this->provider->isEnabled($key); + + $this->assertTrue($enabled, 'When repo has value "1", isEnabled() should return true.'); + } + + public function testIsEnabledFallsBackToDefaultsWhenRepoMissing(): void + { + $key = ConfigOption::MaintenanceMode; + + $this->repo + ->expects($this->once()) + ->method('findOneBy') + ->with(['item' => $key->value]) + ->willReturn(null); + + $this->defaults + ->expects($this->once()) + ->method('has') + ->with($key->value) + ->willReturn(true); + + $this->defaults + ->expects($this->once()) + ->method('get') + ->with($key->value) + ->willReturn(['value' => '1']); + + $this->assertTrue($this->provider->isEnabled($key)); + } + + public function testGetValueRejectsBooleanKeys(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Key is a boolean value, use isEnabled instead'); + + $this->provider->getValue(ConfigOption::MaintenanceMode); + } + + public function testGetValueReturnsFromCacheWhenPresent(): void + { + $key = $this->pickNonBooleanCase(); + $cacheKey = 'cfg:' . $key->value; + + $this->cache + ->expects($this->once()) + ->method('get') + ->with($cacheKey) + ->willReturn('CACHED'); + + $this->repo->expects($this->never())->method('findValueByItem'); + $this->defaults->expects($this->never())->method('has'); + $this->defaults->expects($this->never())->method('get'); + + $this->assertSame('CACHED', $this->provider->getValue($key)); + } + + public function testGetValueLoadsFromRepoAndCachesWhenCacheMiss(): void + { + $key = $this->pickNonBooleanCase(); + $cacheKey = 'cfg:' . $key->value; + + $this->cache + ->expects($this->once()) + ->method('get') + ->with($cacheKey) + ->willReturn(null); + + $this->repo + ->expects($this->once()) + ->method('findValueByItem') + ->with($key->value) + ->willReturn('DBVAL'); + + $this->cache + ->expects($this->once()) + ->method('set') + ->with($cacheKey, 'DBVAL', 300); + + $this->defaults->expects($this->never())->method('has'); + $this->defaults->expects($this->never())->method('get'); + + $this->assertSame('DBVAL', $this->provider->getValue($key)); + } + + public function testGetValueFallsBackToDefaultConfigsWhenNoCacheAndNoRepo(): void + { + $key = $this->pickNonBooleanCase(); + $cacheKey = 'cfg:' . $key->value; + + $this->cache + ->expects($this->once()) + ->method('get') + ->with($cacheKey) + ->willReturn(null); + + $this->repo + ->expects($this->once()) + ->method('findValueByItem') + ->with($key->value) + ->willReturn(null); + + $this->cache + ->expects($this->once()) + ->method('set') + ->with($cacheKey, null, 300); + + $this->defaults + ->expects($this->once()) + ->method('has') + ->with($key->value) + ->willReturn(true); + + $this->defaults + ->expects($this->once()) + ->method('get') + ->with($key->value) + ->willReturn(['value' => 'DEF']); + + $this->assertSame('DEF', $this->provider->getValue($key)); + } + + public function testGetValueReturnsNullWhenNoCacheNoRepoNoDefault(): void + { + $key = $this->pickNonBooleanCase(); + $cacheKey = 'cfg:' . $key->value; + + $this->cache->expects($this->once())->method('get')->with($cacheKey)->willReturn(null); + $this->repo->expects($this->once())->method('findValueByItem')->with($key->value)->willReturn(null); + $this->cache->expects($this->once())->method('set')->with($cacheKey, null, 300); + + $this->defaults->expects($this->once())->method('has')->with($key->value)->willReturn(false); + $this->defaults->expects($this->never())->method('get'); + + $this->assertNull($this->provider->getValue($key)); + } + + public function testGetValueWithNamespacePrefersFullValue(): void + { + $key = $this->pickNonBooleanCase(); + + // Force getValue($key) to return a non-empty string + $this->cache->method('get')->willReturn('FULL'); + $this->repo->expects($this->never())->method('findValueByItem'); + + $this->assertSame('FULL', $this->provider->getValueWithNamespace($key)); + } + + public function testGetValueWithNamespaceFallsBackToParentWhenFullEmpty(): void + { + [$child, $parent] = $this->pickNamespacedCasePair(); + + // Simulate: child is empty (null or ''), parent has value "PARENTVAL" + $this->cache + ->method('get') + ->willReturnMap([ + ['cfg:' . $child->value, null], + ['cfg:' . $parent->value, 'PARENTVAL'], + ]); + + // child -> repo null; parent -> not consulted because cache returns value + $this->repo + ->method('findValueByItem') + ->willReturnMap([ + [$child->value, null], + ]); + + // child miss is cached as null, parent value is not rewritten here (already cached) + $this->cache + ->expects($this->atLeastOnce()) + ->method('set'); + + $this->defaults->method('has')->willReturn(false); + + $this->assertSame('PARENTVAL', $this->provider->getValueWithNamespace($child)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php b/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php new file mode 100644 index 00000000..ae5b96cb --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php @@ -0,0 +1,127 @@ +translator = $this->createMock(TranslatorInterface::class); + $this->provider = new DefaultConfigProvider($this->translator); + } + + public function testHasReturnsTrueForKnownKey(): void + { + $this->assertTrue($this->provider->has('admin_address')); + } + + public function testGetReturnsArrayShapeForKnownKey(): void + { + $item = $this->provider->get('admin_address'); + + $this->assertIsArray($item); + $this->assertArrayHasKey('value', $item); + $this->assertArrayHasKey('description', $item); + $this->assertArrayHasKey('type', $item); + $this->assertArrayHasKey('category', $item); + + // basic sanity check + $this->assertSame('email', $item['type']); + $this->assertSame('general', $item['category']); + $this->assertStringContainsString('[DOMAIN]', (string) $item['value']); + } + + public function testGetReturnsProvidedDefaultWhenUnknownKey(): void + { + $fallback = ['value' => 'X', 'type' => 'text']; + $this->assertSame($fallback, $this->provider->get('does_not_exist', $fallback)); + } + + public function testRemoteProcessingSecretIsRandomHexOfExpectedLength(): void + { + $item = $this->provider->get('remote_processing_secret'); + $this->assertIsArray($item); + $this->assertArrayHasKey('value', $item); + + $val = (string) $item['value']; + // bin2hex(random_bytes(10)) => 20 hex chars + $this->assertMatchesRegularExpression('/^[0-9a-f]{20}$/i', $val); + } + + public function testSubscribeUrlDefaultsToHttpAndApiV2Path(): void + { + $item = $this->provider->get('subscribeurl'); + $this->assertIsArray($item); + $url = (string) $item['value']; + + $this->assertStringStartsWith('http://', $url); + $this->assertStringContainsString('[WEBSITE]', $url); + $this->assertStringContainsString('/api/v2/?p=subscribe', $url); + } + + public function testUnsubscribeUrlDefaults(): void + { + $item = $this->provider->get('unsubscribeurl'); + $url = (string) $item['value']; + + $this->assertStringStartsWith('http://', $url); + $this->assertStringContainsString('/api/v2/?p=unsubscribe', $url); + } + + public function testTranslatorIsUsedOnlyOnFirstInit(): void + { + $this->translator + ->expects($this->atLeastOnce()) + ->method('trans') + ->willReturnArgument(0); + $this->provider->get('admin_address'); + + // Subsequent calls should not trigger init again + $translator = $this->createMock(TranslatorInterface::class); + $translator + ->expects($this->never()) + ->method('trans'); + + $reflection = new ReflectionClass($this->provider); + $prop = $reflection->getProperty('translator'); + + $prop->setValue($this->provider, $translator); + $this->provider->get('unsubscribeurl'); + $this->provider->has('pageheader'); + } + + public function testKnownKeysHaveReasonableTypes(): void + { + $keys = [ + 'admin_address' => 'email', + 'organisation_name' => 'text', + 'organisation_logo' => 'image', + 'date_format' => 'text', + 'rc_notification' => 'boolean', + 'notify_admin_login' => 'boolean', + 'message_from_address' => 'email', + 'message_from_name' => 'text', + 'message_replyto_address' => 'email', + ]; + + foreach ($keys as $key => $type) { + $item = $this->provider->get($key); + $this->assertIsArray($item, 'Item should be an array. Key: ' . $key); + $this->assertSame($type, $item['type'] ?? null, $key .': should have type ' . $type); + } + } +} diff --git a/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php b/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php new file mode 100644 index 00000000..0c7f7dfd --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php @@ -0,0 +1,218 @@ +config = $this->createMock(ConfigProvider::class); + $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); + $this->subRepo = $this->createMock(SubscriberRepository::class); + $this->attrRepo = $this->createMock(SubscriberAttributeValueRepository::class); + $this->attrResolver = $this->createMock(AttributeValueResolver::class); + + $this->personalizer = new UserPersonalizer( + $this->config, + $this->urlBuilder, + $this->subRepo, + $this->attrRepo, + $this->attrResolver + ); + } + + public function testReturnsOriginalWhenSubscriberNotFound(): void + { + $this->subRepo + ->expects($this->once()) + ->method('findOneByEmail') + ->with('nobody@example.com') + ->willReturn(null); + + $result = $this->personalizer->personalize('Hello [EMAIL]', 'nobody@example.com'); + + $this->assertSame('Hello [EMAIL]', $result); + } + + public function testBuiltInPlaceholdersAreResolved(): void + { + $email = 'ada@example.com'; + $uid = 'U123'; + + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getEmail')->willReturn($email); + $subscriber->method('getUniqueId')->willReturn($uid); + + $this->subRepo + ->expects($this->once()) + ->method('findOneByEmail') + ->with($email) + ->willReturn($subscriber); + + // Config values for URLs + domain/website + subscribe url + $this->config->method('getValue')->willReturnCallback(function ($opt) { + return match ($opt) { + ConfigOption::UnsubscribeUrl => 'https://u.example/unsub', + ConfigOption::ConfirmationUrl => 'https://u.example/confirm', + ConfigOption::PreferencesUrl => 'https://u.example/prefs', + ConfigOption::SubscribeUrl => 'https://u.example/subscribe', + ConfigOption::Domain => 'example.org', + ConfigOption::Website => 'site.example.org', + default => null, + }; + }); + + // LegacyUrlBuilder glue behavior + $this->urlBuilder + ->method('withUid') + ->willReturnCallback(fn(string $base, string $u) => $base . '?uid=' . $u); + + $this->attrRepo + ->expects($this->once()) + ->method('getForSubscriber') + ->with($subscriber) + ->willReturn([]); + + $input = 'Email: [EMAIL] + Unsub: [UNSUBSCRIBEURL] + Conf: [confirmationurl] + Prefs: [PREFERENCESURL] + Sub: [SUBSCRIBEURL] + Domain: [DOMAIN] + Website: [WEBSITE]'; + + + $result = $this->personalizer->personalize($input, $email); + + $this->assertStringContainsString('Email: ada@example.com', $result); + // trailing space is expected after URL placeholders + $this->assertStringContainsString('Unsub: https://u.example/unsub?uid=U123 ', $result); + $this->assertStringContainsString('Conf: https://u.example/confirm?uid=U123 ', $result); + $this->assertStringContainsString('Prefs: https://u.example/prefs?uid=U123 ', $result); + $this->assertStringContainsString('Sub: https://u.example/subscribe ', $result); + $this->assertStringContainsString('Domain: example.org', $result); + $this->assertStringContainsString('Website: site.example.org', $result); + } + + public function testDynamicUserAttributesAreResolvedCaseInsensitive(): void + { + $email = 'bob@example.com'; + $uid = 'U999'; + + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getEmail')->willReturn($email); + $subscriber->method('getUniqueId')->willReturn($uid); + + $this->subRepo + ->expects($this->once()) + ->method('findOneByEmail') + ->with($email) + ->willReturn($subscriber); + + // Only needed so registration for URL placeholders doesn't blow up; values don't matter in this test + $this->config->method('getValue')->willReturnMap([ + [ConfigOption::UnsubscribeUrl, ''], + [ConfigOption::ConfirmationUrl, ''], + [ConfigOption::PreferencesUrl, ''], + [ConfigOption::SubscribeUrl, ''], + [ConfigOption::Domain, 'example.org'], + [ConfigOption::Website, 'site.example.org'], + ]); + + $this->urlBuilder->method('withUid')->willReturnCallback(fn(string $b, string $u) => $b . '?uid=' . $u); + + // Build a fake attribute value entity with definition NAME => "Full Name" + $attrDefinition = $this->createMock(SubscriberAttributeDefinition::class); + $attrDefinition->method('getName')->willReturn('Full_Name2'); + $attrValue = $this->createMock(SubscriberAttributeValue::class); + $attrValue->method('getAttributeDefinition')->willReturn($attrDefinition); + + $this->attrRepo + ->expects($this->once()) + ->method('getForSubscriber') + ->with($subscriber) + ->willReturn([$attrValue]); + + // When resolver is called with our attr value, return computed string + $this->attrResolver + ->expects($this->once()) + ->method('resolve') + ->with($attrValue) + ->willReturn('Bob #2'); + + $input = 'Hello [full_name2], your email is [email].'; + $result = $this->personalizer->personalize($input, $email); + + $this->assertSame('Hello Bob #2, your email is bob@example.com.', $result); + } + + public function testMultipleOccurrencesAndAdjacency(): void + { + $email = 'eve@example.com'; + $uid = 'UID42'; + + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getEmail')->willReturn($email); + $subscriber->method('getUniqueId')->willReturn($uid); + + $this->subRepo->method('findOneByEmail')->willReturn($subscriber); + + $this->config->method('getValue')->willReturnMap([ + [ConfigOption::UnsubscribeUrl, 'https://x/unsub'], + [ConfigOption::ConfirmationUrl, 'https://x/conf'], + [ConfigOption::PreferencesUrl, 'https://x/prefs'], + [ConfigOption::SubscribeUrl, 'https://x/sub'], + [ConfigOption::Domain, 'x.tld'], + [ConfigOption::Website, 'w.x.tld'], + ]); + + $this->urlBuilder->method('withUid')->willReturnCallback(fn(string $b, string $u) => $b . '?uid=' . $u); + + // Two attributes: FOO & BAR + $defFoo = $this->createMock(SubscriberAttributeDefinition::class); + $defFoo->method('getName')->willReturn('FOO'); + $valFoo = $this->createMock(SubscriberAttributeValue::class); + $valFoo->method('getAttributeDefinition')->willReturn($defFoo); + + $defBar = $this->createMock(SubscriberAttributeDefinition::class); + $defBar->method('getName')->willReturn('bar'); + $valBar = $this->createMock(SubscriberAttributeValue::class); + $valBar->method('getAttributeDefinition')->willReturn($defBar); + + $this->attrRepo->method('getForSubscriber')->willReturn([$valFoo, $valBar]); + + $this->attrResolver + ->method('resolve') + ->willReturnMap([ + [$valFoo, 'FVAL'], + [$valBar, 'BVAL'], + ]); + + $input = '[foo][BAR]-[email]-[UNSUBSCRIBEURL]'; + $out = $this->personalizer->personalize($input, $email); + + $this->assertSame('FVALBVAL-eve@example.com-https://x/unsub?uid=UID42 ', $out); + } +} diff --git a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php index d8e837ba..5cd84c5e 100644 --- a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php +++ b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php @@ -5,7 +5,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Command; use Exception; -use PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Messaging\Command\ProcessQueueCommand; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; @@ -46,7 +46,7 @@ protected function setUp(): void lockFactory: $lockFactory, messagePreparator: $this->messageProcessingPreparator, campaignProcessor: $this->campaignProcessor, - configManager: $this->createMock(ConfigManager::class), + configProvider: $this->createMock(ConfigProvider::class), translator: $this->translator, ); diff --git a/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php new file mode 100644 index 00000000..6288c5f4 --- /dev/null +++ b/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php @@ -0,0 +1,139 @@ +createMock(EmailService::class); + $configProvider = $this->createMock(ConfigProvider::class); + $logger = $this->createMock(LoggerInterface::class); + $personalizer = $this->createMock(UserPersonalizer::class); + $listRepo = $this->createMock(SubscriberListRepository::class); + + $handler = new SubscriptionConfirmationMessageHandler( + emailService: $emailService, + configProvider: $configProvider, + logger: $logger, + userPersonalizer: $personalizer, + subscriberListRepository: $listRepo + ); + $configProvider + ->expects($this->exactly(2)) + ->method('getValue') + ->willReturnMap([ + [ConfigOption::SubscribeEmailSubject, 'Please confirm your subscription'], + [ConfigOption::SubscribeMessage, 'Hi {{name}}, you subscribed to: [LISTS]'], + ]); + + $message = new SubscriptionConfirmationMessage('alice@example.com', 'user-123', [10, 11]); + + $personalizer->expects($this->once()) + ->method('personalize') + ->with('Hi {{name}}, you subscribed to: [LISTS]', 'user-123') + ->willReturn('Hi Alice, you subscribed to: [LISTS]'); + + $listA = $this->createMock(SubscriberList::class); + $listA->method('getName')->willReturn('Releases'); + $listB = $this->createMock(SubscriberList::class); + $listB->method('getName')->willReturn('Security Advisories'); + + $listRepo->method('find') + ->willReturnCallback(function (int $id) use ($listA, $listB) { + return match ($id) { + 10 => $listA, + 11 => $listB, + default => null + }; + }); + + // Capture the Email object passed to EmailService + $emailService->expects($this->once()) + ->method('sendEmail') + ->with($this->callback(function (Email $email): bool { + $addresses = $email->getTo(); + if (count($addresses) !== 1 || $addresses[0]->getAddress() !== 'alice@example.com') { + return false; + } + if ($email->getSubject() !== 'Please confirm your subscription') { + return false; + } + $body = $email->getTextBody(); + return $body === 'Hi Alice, you subscribed to: Releases, Security Advisories'; + })); + + $logger->expects($this->once()) + ->method('info') + ->with( + 'Subscription confirmation email sent to {email}', + ['email' => 'alice@example.com'] + ); + + $handler($message); + } + + public function testHandlesMissingListsGracefullyAndEmptyJoin(): void + { + $emailService = $this->createMock(EmailService::class); + $configProvider = $this->createMock(ConfigProvider::class); + $logger = $this->createMock(LoggerInterface::class); + $personalizer = $this->createMock(UserPersonalizer::class); + $listRepo = $this->createMock(SubscriberListRepository::class); + + $handler = new SubscriptionConfirmationMessageHandler( + emailService: $emailService, + configProvider: $configProvider, + logger: $logger, + userPersonalizer: $personalizer, + subscriberListRepository: $listRepo + ); + + $configProvider->method('getValue') + ->willReturnMap([ + [ConfigOption::SubscribeEmailSubject, 'Please confirm your subscription'], + [ConfigOption::SubscribeMessage, 'Lists: [LISTS]'], + ]); + + $message = $this->createMock(SubscriptionConfirmationMessage::class); + $message->method('getEmail')->willReturn('bob@example.com'); + $message->method('getUniqueId')->willReturn('user-456'); + $message->method('getListIds')->willReturn([42]); + + $personalizer->method('personalize') + ->with('Lists: [LISTS]', 'user-456') + ->willReturn('Lists: [LISTS]'); + + $listRepo->method('find')->with(42)->willReturn(null); + + $emailService->expects($this->once()) + ->method('sendEmail') + ->with($this->callback(function (Email $email): bool { + // Intended empty replacement when no lists found -> empty string + return $email->getTextBody() === 'Lists: '; + })); + + $logger->expects($this->once()) + ->method('info') + ->with('Subscription confirmation email sent to {email}', ['email' => 'bob@example.com']); + + $handler($message); + } +} diff --git a/tests/Unit/Domain/Subscription/Repository/DynamicListAttrRepositoryTest.php b/tests/Unit/Domain/Subscription/Repository/DynamicListAttrRepositoryTest.php new file mode 100644 index 00000000..948c8347 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Repository/DynamicListAttrRepositoryTest.php @@ -0,0 +1,147 @@ +createMock(Connection::class); + $repo = new DynamicListAttrRepository($conn, 'phplist_'); + + $this->assertSame([], $repo->fetchOptionNames('valid_table', [])); + $this->assertSame([], $repo->fetchOptionNames('valid_table', [])); + } + + public function testFetchOptionNamesThrowsOnInvalidTable(): void + { + $conn = $this->createMock(Connection::class); + $repo = new DynamicListAttrRepository($conn, 'phplist_'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid list table'); + + $repo->fetchOptionNames('invalid-table;', [1, 2]); + } + + public function testFetchOptionNamesReturnsNames(): void + { + $conn = $this->createMock(Connection::class); + + $qb = $this->getMockBuilder(QueryBuilder::class) + ->disableOriginalConstructor() + ->onlyMethods(['select', 'from', 'where', 'setParameter', 'executeQuery']) + ->getMock(); + + $qb->expects($this->once()) + ->method('select') + ->with('name') + ->willReturnSelf(); + + $qb->expects($this->once()) + ->method('from') + ->with('phplist_listattr_users') + ->willReturnSelf(); + + $qb->expects($this->once()) + ->method('where') + ->with('id IN (:ids)') + ->willReturnSelf(); + + // Expect integer coercion of IDs and correct array parameter type + $qb->expects($this->once()) + ->method('setParameter') + ->with( + 'ids', + [1, 2, 3], + ArrayParameterType::INTEGER + ) + ->willReturnSelf(); + + // Mock Result + $result = $this->createMock(Result::class); + $result->expects($this->once()) + ->method('fetchFirstColumn') + ->willReturn(['alpha', 'beta', 'gamma']); + + $qb->expects($this->once()) + ->method('executeQuery') + ->willReturn($result); + + $conn->method('createQueryBuilder')->willReturn($qb); + + $repo = new DynamicListAttrRepository($conn, 'phplist_'); + $names = $repo->fetchOptionNames('users', [1, '2', 3]); + + $this->assertSame(['alpha', 'beta', 'gamma'], $names); + } + + public function testFetchSingleOptionNameThrowsOnInvalidTable(): void + { + $conn = $this->createMock(Connection::class); + $repo = new DynamicListAttrRepository($conn, 'phplist_'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid list table'); + + $repo->fetchSingleOptionName('bad name!', 10); + } + + public function testFetchSingleOptionNameReturnsString(): void + { + $conn = $this->createMock(Connection::class); + + $qb = $this->getMockBuilder(QueryBuilder::class) + ->disableOriginalConstructor() + ->onlyMethods(['select', 'from', 'where', 'setParameter', 'executeQuery']) + ->getMock(); + + $qb->expects($this->once())->method('select')->with('name')->willReturnSelf(); + $qb->expects($this->once())->method('from')->with('phplist_listattr_ukcountries')->willReturnSelf(); + $qb->expects($this->once())->method('where')->with('id = :id')->willReturnSelf(); + $qb->expects($this->once())->method('setParameter')->with('id', 42)->willReturnSelf(); + + $result = $this->createMock(Result::class); + $result->expects($this->once())->method('fetchOne')->willReturn('Bradford'); + + $qb->expects($this->once())->method('executeQuery')->willReturn($result); + $conn->method('createQueryBuilder')->willReturn($qb); + + $repo = new DynamicListAttrRepository($conn, 'phplist_'); + $this->assertSame('Bradford', $repo->fetchSingleOptionName('ukcountries', 42)); + } + + public function testFetchSingleOptionNameReturnsNullWhenNotFound(): void + { + $conn = $this->createMock(Connection::class); + + $qb = $this->getMockBuilder(QueryBuilder::class) + ->disableOriginalConstructor() + ->onlyMethods(['select', 'from', 'where', 'setParameter', 'executeQuery']) + ->getMock(); + + $qb->method('select')->with('name')->willReturnSelf(); + $qb->method('from')->with('phplist_listattr_termsofservices')->willReturnSelf(); + $qb->method('where')->with('id = :id')->willReturnSelf(); + $qb->method('setParameter')->with('id', 999)->willReturnSelf(); + + $result = $this->createMock(Result::class); + $result->expects($this->once())->method('fetchOne')->willReturn(false); + + $qb->method('executeQuery')->willReturn($result); + $conn->method('createQueryBuilder')->willReturn($qb); + + $repo = new DynamicListAttrRepository($conn, 'phplist_'); + $this->assertNull($repo->fetchSingleOptionName('termsofservices', 999)); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/AttributeValueResolverTest.php b/tests/Unit/Domain/Subscription/Service/AttributeValueResolverTest.php new file mode 100644 index 00000000..515557c8 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/AttributeValueResolverTest.php @@ -0,0 +1,83 @@ +createMock(SubscriberAttributeDefinition::class); + $userAttr = $this->createMock(SubscriberAttributeValue::class); + $userAttr->method('getAttributeDefinition')->willReturn($def); + + $p1 = $this->createMock(AttributeValueProvider::class); + $p1->expects($this->once())->method('supports')->with($def)->willReturn(false); + $p1->expects($this->never())->method('getValue'); + + $p2 = $this->createMock(AttributeValueProvider::class); + $p2->expects($this->once())->method('supports')->with($def)->willReturn(false); + $p2->expects($this->never())->method('getValue'); + + $resolver = new AttributeValueResolver([$p1, $p2]); + + self::assertSame('', $resolver->resolve($userAttr)); + } + + public function testResolveReturnsValueFromFirstSupportingProvider(): void + { + $def = $this->createMock(SubscriberAttributeDefinition::class); + $userAttr = $this->createMock(SubscriberAttributeValue::class); + $userAttr->method('getAttributeDefinition')->willReturn($def); + + $nonSupporting = $this->createMock(AttributeValueProvider::class); + $nonSupporting->expects($this->once())->method('supports')->with($def)->willReturn(false); + $nonSupporting->expects($this->never())->method('getValue'); + + $supporting = $this->createMock(AttributeValueProvider::class); + $supporting->expects($this->once())->method('supports')->with($def)->willReturn(true); + $supporting->expects($this->once()) + ->method('getValue') + ->with($def, $userAttr) + ->willReturn('Resolved Value'); + + // This provider should never be interrogated because resolver exits early. + $afterFirstMatch = $this->createMock(AttributeValueProvider::class); + $afterFirstMatch->expects($this->never())->method('supports'); + $afterFirstMatch->expects($this->never())->method('getValue'); + + $resolver = new AttributeValueResolver([$nonSupporting, $supporting, $afterFirstMatch]); + + self::assertSame('Resolved Value', $resolver->resolve($userAttr)); + } + + public function testResolveHonorsProviderOrderFirstMatchWins(): void + { + $def = $this->createMock(SubscriberAttributeDefinition::class); + $userAttr = $this->createMock(SubscriberAttributeValue::class); + $userAttr->method('getAttributeDefinition')->willReturn($def); + + $firstSupporting = $this->createMock(AttributeValueProvider::class); + $firstSupporting->expects($this->once())->method('supports')->with($def)->willReturn(true); + $firstSupporting->expects($this->once()) + ->method('getValue') + ->with($def, $userAttr) + ->willReturn('first'); + + $secondSupporting = $this->createMock(AttributeValueProvider::class); + // Must not be called because the first already matched + $secondSupporting->expects($this->never())->method('supports'); + $secondSupporting->expects($this->never())->method('getValue'); + + $resolver = new AttributeValueResolver([$firstSupporting, $secondSupporting]); + + self::assertSame('first', $resolver->resolve($userAttr)); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Provider/CheckboxGroupValueProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/CheckboxGroupValueProviderTest.php new file mode 100644 index 00000000..b5be5650 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/Provider/CheckboxGroupValueProviderTest.php @@ -0,0 +1,118 @@ +repo = $this->createMock(DynamicListAttrRepository::class); + $this->subject = new CheckboxGroupValueProvider($this->repo); + } + + private function createAttribute( + string $type = 'checkboxgroup', + ?string $tableName = 'colors' + ): SubscriberAttributeDefinition { + $attr = new SubscriberAttributeDefinition(); + $attr->setName('prefs')->setType($type)->setTableName($tableName); + + return $attr; + } + + private function createUserAttr(SubscriberAttributeDefinition $def, ?string $value): SubscriberAttributeValue + { + $subscriber = new Subscriber(); + $userAttr = new SubscriberAttributeValue($def, $subscriber); + $userAttr->setValue($value); + + return $userAttr; + } + + public function testSupportsReturnsTrueForCheckboxgroup(): void + { + $attr = $this->createAttribute('checkboxgroup'); + self::assertTrue($this->subject->supports($attr)); + } + + public function testSupportsReturnsFalseForOtherTypes(): void + { + $attr = $this->createAttribute('textline'); + self::assertFalse($this->subject->supports($attr)); + } + + public function testGetValueReturnsEmptyStringForNullOrEmptyValue(): void + { + $attr = $this->createAttribute(); + + $uaNull = $this->createUserAttr($attr, null); + self::assertSame('', $this->subject->getValue($attr, $uaNull)); + + $uaEmpty = $this->createUserAttr($attr, ''); + self::assertSame('', $this->subject->getValue($attr, $uaEmpty)); + } + + public function testGetValueReturnsEmptyStringWhenNoParsedIds(): void + { + $attr = $this->createAttribute(); + $ua = $this->createUserAttr($attr, '0, -1, foo, bar'); + + // Repository should not be called in this case + $this->repo->expects(self::never())->method('fetchOptionNames'); + + self::assertSame('', $this->subject->getValue($attr, $ua)); + } + + public function testGetValueReturnsEmptyStringWhenNoTableName(): void + { + $attr = $this->createAttribute('checkboxgroup', null); + $ua = $this->createUserAttr($attr, '1,2'); + + $this->repo->expects(self::never())->method('fetchOptionNames'); + + self::assertSame('', $this->subject->getValue($attr, $ua)); + } + + public function testGetValueFetchesNamesAndJoinsWithSemicolon(): void + { + $attr = $this->createAttribute('checkboxgroup', 'colors'); + $ua = $this->createUserAttr($attr, '1, 2,3'); + + $this->repo + ->expects(self::once()) + ->method('fetchOptionNames') + ->with('colors', [1, 2, 3]) + ->willReturn(['Red', 'Green', 'Blue']); + + self::assertSame('Red; Green; Blue', $this->subject->getValue($attr, $ua)); + } + + public function testGetValueParsesAndPreservesOrderAndFiltersInvalids(): void + { + $attr = $this->createAttribute('checkboxgroup', 'sizes'); + $ua = $this->createUserAttr($attr, '3, 0, -2, two, 1, 2 , 2'); + // After parsing: [3,1,2,2] -> duplicates are allowed and passed through to repository + $this->repo + ->expects(self::once()) + ->method('fetchOptionNames') + ->with('sizes', [3, 1, 2, 2]) + ->willReturn(['Large', 'Small', 'Medium', 'Medium']); + + self::assertSame('Large; Small; Medium; Medium', $this->subject->getValue($attr, $ua)); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Provider/ScalarValueProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/ScalarValueProviderTest.php new file mode 100644 index 00000000..2b28c295 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/Provider/ScalarValueProviderTest.php @@ -0,0 +1,57 @@ +createMock(SubscriberAttributeDefinition::class); + $attr->method('getType')->willReturn(null); + + self::assertTrue($provider->supports($attr)); + } + + public function testSupportsReturnsFalseWhenTypeIsNotNull(): void + { + $provider = new ScalarValueProvider(); + + $attr = $this->createMock(SubscriberAttributeDefinition::class); + $attr->method('getType')->willReturn('checkbox'); + + self::assertFalse($provider->supports($attr)); + } + + public function testGetValueReturnsUnderlyingString(): void + { + $provider = new ScalarValueProvider(); + + $attr = $this->createMock(SubscriberAttributeDefinition::class); + + $value = $this->createMock(SubscriberAttributeValue::class); + $value->method('getValue')->willReturn('hello'); + + self::assertSame('hello', $provider->getValue($attr, $value)); + } + + public function testGetValueReturnsEmptyStringWhenNull(): void + { + $provider = new ScalarValueProvider(); + + $attr = $this->createMock(SubscriberAttributeDefinition::class); + + $value = $this->createMock(SubscriberAttributeValue::class); + $value->method('getValue')->willReturn(null); + + self::assertSame('', $provider->getValue($attr, $value)); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Provider/SelectOrRadioValueProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/SelectOrRadioValueProviderTest.php new file mode 100644 index 00000000..38849fd7 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/Provider/SelectOrRadioValueProviderTest.php @@ -0,0 +1,115 @@ +createMock(DynamicListAttrRepository::class); + $provider = new SelectOrRadioValueProvider($repo); + + $attrSelect = $this->createMock(SubscriberAttributeDefinition::class); + $attrSelect->method('getType')->willReturn('select'); + self::assertTrue($provider->supports($attrSelect)); + + $attrRadio = $this->createMock(SubscriberAttributeDefinition::class); + $attrRadio->method('getType')->willReturn('radio'); + self::assertTrue($provider->supports($attrRadio)); + } + + public function testSupportsReturnsFalseForOtherTypes(): void + { + $repo = $this->createMock(DynamicListAttrRepository::class); + $provider = new SelectOrRadioValueProvider($repo); + + $attr = $this->createMock(SubscriberAttributeDefinition::class); + $attr->method('getType')->willReturn('checkboxgroup'); + + self::assertFalse($provider->supports($attr)); + } + + public function testGetValueReturnsEmptyWhenNoTableName(): void + { + $repo = $this->createMock(DynamicListAttrRepository::class); + $provider = new SelectOrRadioValueProvider($repo); + + $attr = $this->createMock(SubscriberAttributeDefinition::class); + $attr->method('getTableName')->willReturn(null); + + $val = $this->createMock(SubscriberAttributeValue::class); + $val->method('getValue')->willReturn('10'); + + $repo->expects($this->never())->method('fetchSingleOptionName'); + + self::assertSame('', $provider->getValue($attr, $val)); + } + + public function testGetValueReturnsEmptyWhenValueNullOrNonPositive(): void + { + $repo = $this->createMock(DynamicListAttrRepository::class); + $provider = new SelectOrRadioValueProvider($repo); + + $attr = $this->createMock(SubscriberAttributeDefinition::class); + $attr->method('getTableName')->willReturn('products'); + + $valNull = $this->createMock(SubscriberAttributeValue::class); + $valNull->method('getValue')->willReturn(null); + $repo->expects($this->never())->method('fetchSingleOptionName'); + self::assertSame('', $provider->getValue($attr, $valNull)); + + $valZero = $this->createMock(SubscriberAttributeValue::class); + $valZero->method('getValue')->willReturn('0'); + self::assertSame('', $provider->getValue($attr, $valZero)); + + $valNegative = $this->createMock(SubscriberAttributeValue::class); + $valNegative->method('getValue')->willReturn('-5'); + self::assertSame('', $provider->getValue($attr, $valNegative)); + } + + public function testGetValueReturnsEmptyWhenRepoReturnsNull(): void + { + $repo = $this->createMock(DynamicListAttrRepository::class); + $provider = new SelectOrRadioValueProvider($repo); + + $attr = $this->createMock(SubscriberAttributeDefinition::class); + $attr->method('getTableName')->willReturn('users'); + + $val = $this->createMock(SubscriberAttributeValue::class); + $val->method('getValue')->willReturn('7'); + + $repo->expects($this->once()) + ->method('fetchSingleOptionName') + ->with('users', 7) + ->willReturn(null); + + self::assertSame('', $provider->getValue($attr, $val)); + } + + public function testGetValueHappyPathReturnsNameFromRepo(): void + { + $repo = $this->createMock(DynamicListAttrRepository::class); + $provider = new SelectOrRadioValueProvider($repo); + + $attr = $this->createMock(SubscriberAttributeDefinition::class); + $attr->method('getTableName')->willReturn('countries'); + + $val = $this->createMock(SubscriberAttributeValue::class); + $val->method('getValue')->willReturn(' 42 '); + + $repo->expects($this->once()) + ->method('fetchSingleOptionName') + ->with('countries', 42) + ->willReturn('Armenia'); + + self::assertSame('Armenia', $provider->getValue($attr, $val)); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php index f825f704..1453cfa2 100644 --- a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php +++ b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php @@ -19,6 +19,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Translation\Translator; class SubscriberCsvImporterTest extends TestCase @@ -49,6 +50,7 @@ protected function setUp(): void attrDefinitionRepository: $this->attributeDefinitionRepositoryMock, entityManager: $entityManager, translator: new Translator('en'), + messageBus: $this->createMock(MessageBusInterface::class), ); } From 9adb0ecd2691c058657faa1608e118a8cdd42f08 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Mon, 13 Oct 2025 11:22:13 +0400 Subject: [PATCH 13/20] Refactor import add subscriber history (#363) * Add blacklisted stat to import result * Add history * Add translations * addHistory for unconfirmed * Refactor * Add changeSetDto * Do not send email on creating without any list * Flush in controller * Add test --------- Co-authored-by: Tatevik --- config/services/providers.yml | 3 + resources/translations/messages.en.xlf | 42 +++++ .../Service/Processor/CampaignProcessor.php | 14 ++ .../Subscription/Model/Dto/ChangeSetDto.php | 81 ++++++++ .../Manager/SubscriberAttributeManager.php | 26 +++ .../Manager/SubscriberHistoryManager.php | 98 +++++++++- .../Service/Manager/SubscriberManager.php | 54 +++--- .../Service/Manager/SubscriptionManager.php | 12 +- .../SubscriberAttributeChangeSetProvider.php | 81 ++++++++ .../Service/SubscriberCsvImporter.php | 169 +++++++++-------- .../Processor/CampaignProcessorTest.php | 2 + .../SubscriberAttributeManagerTest.php | 36 +++- .../Manager/SubscriberHistoryManagerTest.php | 4 + .../Service/Manager/SubscriberManagerTest.php | 38 +--- .../Manager/SubscriptionManagerTest.php | 8 +- ...bscriberAttributeChangeSetProviderTest.php | 175 ++++++++++++++++++ .../Service/SubscriberCsvImporterTest.php | 22 ++- 17 files changed, 709 insertions(+), 156 deletions(-) create mode 100644 src/Domain/Subscription/Model/Dto/ChangeSetDto.php create mode 100644 src/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProvider.php create mode 100644 tests/Unit/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProviderTest.php diff --git a/config/services/providers.yml b/config/services/providers.yml index f4f06010..b7b66be8 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -27,3 +27,6 @@ services: autowire: true arguments: $cache: '@Psr\SimpleCache\CacheInterface' + + PhpList\Core\Domain\Subscription\Service\Provider\SubscriberAttributeChangeSetProvider: + autowire: true diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf index 5bfb12fd..9a9fae29 100644 --- a/resources/translations/messages.en.xlf +++ b/resources/translations/messages.en.xlf @@ -684,6 +684,48 @@ Thank you. Footer of public pages __Footer of public pages + + Please confirm your subscription + __Please confirm your subscription + + + No user details changed + __No user details changed + + + %field% = %new% *changed* from %old% + __%field% = %new% *changed* from %old% + + + Subscribed to %list% + __Subscribed to %list% + + + Subscriber marked unconfirmed for invalid email address + __Subscriber marked unconfirmed for invalid email address + + + Marked unconfirmed while sending campaign %message_id% + __Marked unconfirmed while sending campaign %message_id% + + + Update by %admin% + __Update by %admin% + + + (no data) + __(no data) + + + %attribute% = %new_value% + changed from %old_value% + __%attribute% = %new_value% + changed from %old_value% + + + No data changed + __No data changed + diff --git a/src/Domain/Messaging/Service/Processor/CampaignProcessor.php b/src/Domain/Messaging/Service/Processor/CampaignProcessor.php index a5deb074..e16d4246 100644 --- a/src/Domain/Messaging/Service/Processor/CampaignProcessor.php +++ b/src/Domain/Messaging/Service/Processor/CampaignProcessor.php @@ -14,6 +14,7 @@ use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer; use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; +use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider; use PhpList\Core\Domain\Subscription\Model\Subscriber; use Psr\Log\LoggerInterface; @@ -23,6 +24,8 @@ /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.StaticAccess) + * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ class CampaignProcessor { @@ -35,6 +38,7 @@ class CampaignProcessor private MaxProcessTimeLimiter $timeLimiter; private RequeueHandler $requeueHandler; private TranslatorInterface $translator; + private SubscriberHistoryManager $subscriberHistoryManager; public function __construct( RateLimitedCampaignMailer $mailer, @@ -46,6 +50,7 @@ public function __construct( MaxProcessTimeLimiter $timeLimiter, RequeueHandler $requeueHandler, TranslatorInterface $translator, + SubscriberHistoryManager $subscriberHistoryManager, ) { $this->mailer = $mailer; $this->entityManager = $entityManager; @@ -56,6 +61,7 @@ public function __construct( $this->timeLimiter = $timeLimiter; $this->requeueHandler = $requeueHandler; $this->translator = $translator; + $this->subscriberHistoryManager = $subscriberHistoryManager; } public function process(Message $campaign, ?OutputInterface $output = null): void @@ -89,6 +95,14 @@ public function process(Message $campaign, ?OutputInterface $output = null): voi $output?->writeln($this->translator->trans('Invalid email, marking unconfirmed: %email%', [ '%email%' => $subscriber->getEmail(), ])); + $this->subscriberHistoryManager->addHistory( + subscriber: $subscriber, + message: $this->translator->trans('Subscriber marked unconfirmed for invalid email address'), + details: $this->translator->trans( + 'Marked unconfirmed while sending campaign %message_id%', + ['%message_id%' => $campaign->getId()] + ) + ); continue; } diff --git a/src/Domain/Subscription/Model/Dto/ChangeSetDto.php b/src/Domain/Subscription/Model/Dto/ChangeSetDto.php new file mode 100644 index 00000000..ef3cc2da --- /dev/null +++ b/src/Domain/Subscription/Model/Dto/ChangeSetDto.php @@ -0,0 +1,81 @@ + + * + * Example: + * [ + * 'email' => [null, 'newemail@example.com'], + * 'isActive' => [true, false] + * ] + */ + private array $changes = []; + + /** + * @param array $changes + */ + public function __construct(array $changes = []) + { + $this->changes = $changes; + } + + /** + * @return array + */ + public function getChanges(): array + { + return $this->changes; + } + + public function hasChanges(): bool + { + return !empty($this->changes); + } + + public function hasField(string $field): bool + { + return array_key_exists($field, $this->changes); + } + + /** + * @return array{0: mixed, 1: mixed}|null + */ + public function getFieldChange(string $field): ?array + { + return $this->changes[$field] ?? null; + } + + /** + * @return mixed|null + */ + public function getOldValue(string $field): mixed + { + return $this->changes[$field][0] ?? null; + } + + /** + * @return mixed|null + */ + public function getNewValue(string $field): mixed + { + return $this->changes[$field][1] ?? null; + } + + public function toArray(): array + { + return $this->changes; + } + + public static function fromDoctrineChangeSet(array $changeSet): self + { + return new self($changeSet); + } +} diff --git a/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php b/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php index 4446e0bf..75b9e3e2 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php @@ -6,24 +6,29 @@ use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Subscription\Exception\SubscriberAttributeCreationException; +use PhpList\Core\Domain\Subscription\Model\Dto\ChangeSetDto; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue; +use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository; use Symfony\Contracts\Translation\TranslatorInterface; class SubscriberAttributeManager { private SubscriberAttributeValueRepository $attributeRepository; + private SubscriberAttributeDefinitionRepository $attrDefinitionRepository; private EntityManagerInterface $entityManager; private TranslatorInterface $translator; public function __construct( SubscriberAttributeValueRepository $attributeRepository, + SubscriberAttributeDefinitionRepository $attrDefinitionRepository, EntityManagerInterface $entityManager, TranslatorInterface $translator, ) { $this->attributeRepository = $attributeRepository; + $this->attrDefinitionRepository = $attrDefinitionRepository; $this->entityManager = $entityManager; $this->translator = $translator; } @@ -33,6 +38,8 @@ public function createOrUpdate( SubscriberAttributeDefinition $definition, ?string $value = null ): SubscriberAttributeValue { + // phpcs:ignore Generic.Commenting.Todo + // todo: clarify which attributes can be created/updated $subscriberAttribute = $this->attributeRepository ->findOneBySubscriberAndAttribute($subscriber, $definition); @@ -60,4 +67,23 @@ public function delete(SubscriberAttributeValue $attribute): void { $this->attributeRepository->remove($attribute); } + + public function processAttributes(Subscriber $subscriber, array $attributeData): void + { + foreach ($attributeData as $key => $value) { + $lowerKey = strtolower((string)$key); + if (in_array($lowerKey, ChangeSetDto::IGNORED_ATTRIBUTES, true)) { + continue; + } + + $attributeDefinition = $this->attrDefinitionRepository->findOneByName($key); + if ($attributeDefinition !== null) { + $this->createOrUpdate( + subscriber: $subscriber, + definition: $attributeDefinition, + value: $value + ); + } + } + } } diff --git a/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php b/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php index bac2ef8d..f8d35c0a 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php @@ -4,27 +4,37 @@ namespace PhpList\Core\Domain\Subscription\Service\Manager; +use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Common\ClientIpResolver; use PhpList\Core\Domain\Common\SystemInfoCollector; +use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Subscription\Model\Dto\ChangeSetDto; 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; +use Symfony\Contracts\Translation\TranslatorInterface; class SubscriberHistoryManager { private SubscriberHistoryRepository $repository; private ClientIpResolver $clientIpResolver; private SystemInfoCollector $systemInfoCollector; + private TranslatorInterface $translator; + private EntityManagerInterface $entityManager; public function __construct( SubscriberHistoryRepository $repository, ClientIpResolver $clientIpResolver, SystemInfoCollector $systemInfoCollector, + TranslatorInterface $translator, + EntityManagerInterface $entityManager, ) { $this->repository = $repository; $this->clientIpResolver = $clientIpResolver; $this->systemInfoCollector = $systemInfoCollector; + $this->translator = $translator; + $this->entityManager = $entityManager; } public function getHistory(int $lastId, int $limit, SubscriberHistoryFilter $filter): array @@ -40,8 +50,94 @@ public function addHistory(Subscriber $subscriber, string $message, ?string $det $subscriberHistory->setSystemInfo($this->systemInfoCollector->collectAsString()); $subscriberHistory->setIp($this->clientIpResolver->resolve()); - $this->repository->save($subscriberHistory); + $this->entityManager->persist($subscriberHistory); return $subscriberHistory; } + + public function addHistoryFromChangeSet( + Subscriber $subscriber, + string $message, + ChangeSetDto $changeSet, + ): SubscriberHistory { + $details = ''; + foreach ($changeSet->getChanges() as $attribute => [$old, $new]) { + if (in_array($attribute, ChangeSetDto::IGNORED_ATTRIBUTES, true) || $new === null) { + continue; + } + $details .= $this->translator->trans( + "%attribute% = %new_value% \n changed from %old_value%", + [ + '%attribute%' => $attribute, + '%new_value%' => $new, + '%old_value%' => $old ?? $this->translator->trans('(no data)'), + ] + ) . PHP_EOL; + } + + if ($details === '') { + $details .= $this->translator->trans('No data changed') . PHP_EOL; + } + + return $this->addHistory($subscriber, $message, $details); + } + + public function addHistoryFromImport( + Subscriber $subscriber, + array $listLines, + ChangeSetDto $changeSetDto, + ?Administrator $admin = null, + ): void { + $headerLine = sprintf('API-v2-import - %s: %s%s', $admin ? 'Admin' : 'CLI', $admin?->getId(), "\n\n"); + + $lines = $this->getHistoryLines($changeSetDto, $listLines); + + $this->addHistory( + subscriber: $subscriber, + message: 'Import by ' . $admin?->getLoginName(), + details: $headerLine . implode(PHP_EOL, $lines) . PHP_EOL + ); + } + + public function addHistoryFromApi( + Subscriber $subscriber, + array $listLines, + ChangeSetDto $updatedData, + ?Administrator $admin = null, + ): void { + $lines = $this->getHistoryLines($updatedData, $listLines); + + $this->addHistory( + subscriber: $subscriber, + message: $this->translator->trans('Update by %admin%', ['%admin%' => $admin->getLoginName()]), + details: implode(PHP_EOL, $lines) . PHP_EOL + ); + } + + private function getHistoryLines(ChangeSetDto $updatedData, array $listLines): array + { + $lines = []; + if (!$updatedData->hasChanges() && empty($listLines)) { + $lines[] = $this->translator->trans('No user details changed'); + } else { + foreach ($updatedData->getChanges() as $field => [$old, $new]) { + if (in_array($field, ChangeSetDto::IGNORED_ATTRIBUTES, true)) { + continue; + } + $lines[] = $this->translator->trans( + '%field% = %new% *changed* from %old%', + [ + '%field' => $field, + '%new%' => json_encode($new), + '%old%' => json_encode($old) + ], + ); + } + foreach ($listLines as $line) { + $lines[] = $line; + } + } + + return $lines; + } } diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php index 59cd2505..40bfcc20 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php @@ -5,38 +5,40 @@ namespace PhpList\Core\Domain\Subscription\Service\Manager; use Doctrine\ORM\EntityManagerInterface; -use PhpList\Core\Domain\Messaging\Message\SubscriberConfirmationMessage; +use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Subscription\Model\Dto\ChangeSetDto; use PhpList\Core\Domain\Subscription\Model\Dto\CreateSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto; 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; use Symfony\Contracts\Translation\TranslatorInterface; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class SubscriberManager { private SubscriberRepository $subscriberRepository; private EntityManagerInterface $entityManager; - private MessageBusInterface $messageBus; private SubscriberDeletionService $subscriberDeletionService; private TranslatorInterface $translator; + private SubscriberHistoryManager $subscriberHistoryManager; public function __construct( SubscriberRepository $subscriberRepository, EntityManagerInterface $entityManager, - MessageBusInterface $messageBus, SubscriberDeletionService $subscriberDeletionService, - TranslatorInterface $translator + TranslatorInterface $translator, + SubscriberHistoryManager $subscriberHistoryManager, ) { $this->subscriberRepository = $subscriberRepository; $this->entityManager = $entityManager; - $this->messageBus = $messageBus; $this->subscriberDeletionService = $subscriberDeletionService; $this->translator = $translator; + $this->subscriberHistoryManager = $subscriberHistoryManager; } public function createSubscriber(CreateSubscriberDto $subscriberDto): Subscriber @@ -51,30 +53,16 @@ public function createSubscriber(CreateSubscriberDto $subscriberDto): Subscriber $this->subscriberRepository->save($subscriber); - if ($subscriberDto->requestConfirmation) { - $this->sendConfirmationEmail($subscriber); - } - return $subscriber; } - private function sendConfirmationEmail(Subscriber $subscriber): void - { - $message = new SubscriberConfirmationMessage( - email: $subscriber->getEmail(), - uniqueId:$subscriber->getUniqueId(), - htmlEmail: $subscriber->hasHtmlEmail() - ); - - $this->messageBus->dispatch($message); - } - public function getSubscriberById(int $subscriberId): ?Subscriber { return $this->subscriberRepository->find($subscriberId); } - public function updateSubscriber(UpdateSubscriberDto $subscriberDto): Subscriber + /** @SuppressWarnings(PHPMD.StaticAccess) */ + public function updateSubscriber(UpdateSubscriberDto $subscriberDto, Administrator $admin): Subscriber { /** @var Subscriber $subscriber */ $subscriber = $this->subscriberRepository->find($subscriberDto->subscriberId); @@ -86,7 +74,12 @@ public function updateSubscriber(UpdateSubscriberDto $subscriberDto): Subscriber $subscriber->setDisabled($subscriberDto->disabled); $subscriber->setExtraData($subscriberDto->additionalData); - $this->entityManager->flush(); + $uow = $this->entityManager->getUnitOfWork(); + $meta = $this->entityManager->getClassMetadata(Subscriber::class); + $uow->computeChangeSet($meta, $subscriber); + $changeSet = ChangeSetDto::fromDoctrineChangeSet($uow->getEntityChangeSet($subscriber)); + + $this->subscriberHistoryManager->addHistoryFromApi($subscriber, [], $changeSet, $admin); return $subscriber; } @@ -129,14 +122,11 @@ public function createFromImport(ImportSubscriberDto $subscriberDto): Subscriber $this->entityManager->persist($subscriber); - if ($subscriberDto->sendConfirmation) { - $this->sendConfirmationEmail($subscriber); - } - return $subscriber; } - public function updateFromImport(Subscriber $existingSubscriber, ImportSubscriberDto $subscriberDto): Subscriber + /** @SuppressWarnings(PHPMD.StaticAccess) */ + public function updateFromImport(Subscriber $existingSubscriber, ImportSubscriberDto $subscriberDto): ChangeSetDto { $existingSubscriber->setEmail($subscriberDto->email); $existingSubscriber->setConfirmed($subscriberDto->confirmed); @@ -145,7 +135,11 @@ public function updateFromImport(Subscriber $existingSubscriber, ImportSubscribe $existingSubscriber->setDisabled($subscriberDto->disabled); $existingSubscriber->setExtraData($subscriberDto->extraData); - return $existingSubscriber; + $uow = $this->entityManager->getUnitOfWork(); + $meta = $this->entityManager->getClassMetadata(Subscriber::class); + $uow->computeChangeSet($meta, $existingSubscriber); + + return ChangeSetDto::fromDoctrineChangeSet($uow->getEntityChangeSet($existingSubscriber)); } public function decrementBounceCount(Subscriber $subscriber): void diff --git a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php index c9921bab..b3631d91 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 Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberList; @@ -19,17 +20,20 @@ class SubscriptionManager private SubscriberRepository $subscriberRepository; private SubscriberListRepository $subscriberListRepository; private TranslatorInterface $translator; + private EntityManagerInterface $entityManager; public function __construct( SubscriptionRepository $subscriptionRepository, SubscriberRepository $subscriberRepository, SubscriberListRepository $subscriberListRepository, - TranslatorInterface $translator + TranslatorInterface $translator, + EntityManagerInterface $entityManager ) { $this->subscriptionRepository = $subscriptionRepository; $this->subscriberRepository = $subscriberRepository; $this->subscriberListRepository = $subscriberListRepository; $this->translator = $translator; + $this->entityManager = $entityManager; } public function addSubscriberToAList(Subscriber $subscriber, int $listId): ?Subscription @@ -49,7 +53,7 @@ public function addSubscriberToAList(Subscriber $subscriber, int $listId): ?Subs $subscription->setSubscriber($subscriber); $subscription->setSubscriberList($subscriberList); - $this->subscriptionRepository->save($subscription); + $this->entityManager->persist($subscription); return $subscription; } @@ -83,7 +87,7 @@ private function createSubscription(SubscriberList $subscriberList, string $emai $subscription->setSubscriber($subscriber); $subscription->setSubscriberList($subscriberList); - $this->subscriptionRepository->save($subscription); + $this->entityManager->persist($subscription); return $subscription; } @@ -111,7 +115,7 @@ private function deleteSubscription(SubscriberList $subscriberList, string $emai throw new SubscriptionCreationException($message, 404); } - $this->subscriptionRepository->remove($subscription); + $this->entityManager->remove($subscription); } /** @return Subscriber[] */ diff --git a/src/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProvider.php b/src/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProvider.php new file mode 100644 index 00000000..290c6de8 --- /dev/null +++ b/src/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProvider.php @@ -0,0 +1,81 @@ + $attributeData + * @return ChangeSetDto + */ + public function getAttributeChangeSet(Subscriber $subscriber, array $attributeData): ChangeSetDto + { + $oldMap = $this->getMappedValues($subscriber); + + $canon = static function (array $attributes): array { + $out = []; + foreach ($attributes as $key => $value) { + $out[mb_strtolower((string)$key)] = $value; + } + return $out; + }; + + $oldC = $canon($oldMap); + $newC = $canon($attributeData); + + foreach (ChangeSetDto::IGNORED_ATTRIBUTES as $ignoredAttribute) { + $lowerCaseIgnoredAttribute = mb_strtolower($ignoredAttribute); + unset($oldC[$lowerCaseIgnoredAttribute], $newC[$lowerCaseIgnoredAttribute]); + } + + $keys = array_values(array_unique(array_merge(array_keys($oldC), array_keys($newC)))); + + $changeSet = []; + foreach ($keys as $key) { + $hasOld = array_key_exists($key, $oldC); + $hasNew = array_key_exists($key, $newC); + + if ($hasOld && !$hasNew) { + $changeSet[$key] = [$oldC[$key], null]; + continue; + } + + if (!$hasOld && $hasNew) { + $changeSet[$key] = [null, $newC[$key]]; + continue; + } + + if ($oldC[$key] !== $newC[$key]) { + $changeSet[$key] = [$oldC[$key], $newC[$key]]; + } + } + + return new ChangeSetDto($changeSet); + } + + private function getMappedValues(Subscriber $subscriber): array + { + $userAttributes = $this->attributesRepository->getForSubscriber($subscriber); + foreach ($userAttributes as $userAttribute) { + $data[$userAttribute->getAttributeDefinition()->getName()] = $this->resolver->resolve($userAttribute); + } + + return $data ?? []; + } +} diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php index f5d9e535..e80db165 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php @@ -5,14 +5,16 @@ namespace PhpList\Core\Domain\Subscription\Service; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Message\SubscriptionConfirmationMessage; use PhpList\Core\Domain\Subscription\Exception\CouldNotReadUploadedFileException; +use PhpList\Core\Domain\Subscription\Model\Dto\ChangeSetDto; use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions; use PhpList\Core\Domain\Subscription\Model\Subscriber; -use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; +use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -21,8 +23,11 @@ use Throwable; /** + * phpcs:ignore Generic.Commenting.Todo + * @todo: check if dryRun will work (some function flush) * Service for importing subscribers from a CSV file. * @SuppressWarnings("CouplingBetweenObjects") + * @SuppressWarnings("ExcessiveParameterList") */ class SubscriberCsvImporter { @@ -31,10 +36,10 @@ class SubscriberCsvImporter private SubscriptionManager $subscriptionManager; private SubscriberRepository $subscriberRepository; private CsvImporter $csvImporter; - private SubscriberAttributeDefinitionRepository $attrDefinitionRepository; private EntityManagerInterface $entityManager; private TranslatorInterface $translator; private MessageBusInterface $messageBus; + private SubscriberHistoryManager $subscriberHistoryManager; public function __construct( SubscriberManager $subscriberManager, @@ -42,36 +47,37 @@ public function __construct( SubscriptionManager $subscriptionManager, SubscriberRepository $subscriberRepository, CsvImporter $csvImporter, - SubscriberAttributeDefinitionRepository $attrDefinitionRepository, EntityManagerInterface $entityManager, TranslatorInterface $translator, MessageBusInterface $messageBus, + SubscriberHistoryManager $subscriberHistoryManager, ) { $this->subscriberManager = $subscriberManager; $this->attributeManager = $attributeManager; $this->subscriptionManager = $subscriptionManager; $this->subscriberRepository = $subscriberRepository; $this->csvImporter = $csvImporter; - $this->attrDefinitionRepository = $attrDefinitionRepository; $this->entityManager = $entityManager; $this->translator = $translator; $this->messageBus = $messageBus; + $this->subscriberHistoryManager = $subscriberHistoryManager; } /** * Import subscribers from a CSV file. - * - * @param UploadedFile $file The uploaded CSV file - * @param SubscriberImportOptions $options * @return array Import statistics - * @throws CouldNotReadUploadedFileException When the uploaded file cannot be read during import + * @throws CouldNotReadUploadedFileException */ - public function importFromCsv(UploadedFile $file, SubscriberImportOptions $options): array - { + public function importFromCsv( + UploadedFile $file, + SubscriberImportOptions $options, + ?Administrator $admin = null + ): array { $stats = [ 'created' => 0, 'updated' => 0, 'skipped' => 0, + 'blacklisted' => 0, 'errors' => [], ]; @@ -87,8 +93,22 @@ public function importFromCsv(UploadedFile $file, SubscriberImportOptions $optio foreach ($result['valid'] as $dto) { try { - $this->processRow($dto, $options, $stats); + $this->entityManager->beginTransaction(); + + $message = $this->processRow($dto, $options, $stats, $admin); + + if ($options->dryRun) { + $this->entityManager->rollback(); + } else { + $this->entityManager->flush(); + $this->entityManager->commit(); + if ($message !== null) { + $this->messageBus->dispatch($message); + } + } } catch (Throwable $e) { + $this->entityManager->rollback(); + $stats['errors'][] = $this->translator->trans( 'Error processing %email%: %error%', ['%email%' => $dto->email, '%error%' => $e->getMessage()] @@ -117,11 +137,16 @@ public function importFromCsv(UploadedFile $file, SubscriberImportOptions $optio * @param UploadedFile $file The uploaded CSV file * @return array Import statistics */ - public function importAndUpdateFromCsv(UploadedFile $file, ?array $listIds = [], bool $dryRun = false): array - { + public function importAndUpdateFromCsv( + UploadedFile $file, + Administrator $admin, + ?array $listIds = [], + bool $dryRun = false + ): array { return $this->importFromCsv( file: $file, - options: new SubscriberImportOptions(updateExisting: true, listIds: $listIds, dryRun: $dryRun) + options: new SubscriberImportOptions(updateExisting: true, listIds: $listIds, dryRun: $dryRun), + admin: $admin, ); } @@ -131,58 +156,74 @@ public function importAndUpdateFromCsv(UploadedFile $file, ?array $listIds = [], * @param UploadedFile $file The uploaded CSV file * @return array Import statistics */ - public function importNewFromCsv(UploadedFile $file, ?array $listIds = [], bool $dryRun = false): array - { + public function importNewFromCsv( + UploadedFile $file, + Administrator $admin, + ?array $listIds = [], + bool $dryRun = false + ): array { return $this->importFromCsv( file: $file, - options: new SubscriberImportOptions(listIds: $listIds, dryRun: $dryRun) + options: new SubscriberImportOptions(listIds: $listIds, dryRun: $dryRun), + admin: $admin, ); } /** * Process a single row from the CSV file. - * - * @param ImportSubscriberDto $dto - * @param SubscriberImportOptions $options - * @param array $stats Statistics to update */ private function processRow( ImportSubscriberDto $dto, SubscriberImportOptions $options, array &$stats, - ): void { + ?Administrator $admin = null + ): ?SubscriptionConfirmationMessage { if ($this->handleInvalidEmail($dto, $options, $stats)) { - return; + return null; } $subscriber = $this->subscriberRepository->findOneByEmail($dto->email); - if ($subscriber && !$options->updateExisting) { - $stats['skipped']++; - - return; + if ($this->handleSkipCase($subscriber, $options, $stats)) { + return null; } if ($subscriber) { - $this->subscriberManager->updateFromImport($subscriber, $dto); + $changeSet = $this->subscriberManager->updateFromImport($subscriber, $dto); $stats['updated']++; } else { $subscriber = $this->subscriberManager->createFromImport($dto); $stats['created']++; } - $this->processAttributes($subscriber, $dto); + $this->attributeManager->processAttributes($subscriber, $dto->extraAttributes); $addedNewSubscriberToList = false; + $listLines = []; if (!$subscriber->isBlacklisted() && count($options->listIds) > 0) { foreach ($options->listIds as $listId) { $created = $this->subscriptionManager->addSubscriberToAList($subscriber, $listId); if ($created) { $addedNewSubscriberToList = true; + $listLines[] = $this->translator->trans( + 'Subscribed to %list%', + ['%list%' => $created->getSubscriberList()->getName()] + ); } } } - $this->handleFlushAndEmail($subscriber, $options, $dto, $addedNewSubscriberToList); + if ($subscriber->isBlacklisted()) { + $stats['blacklisted']++; + } + + $this->subscriberHistoryManager->addHistoryFromImport( + subscriber: $subscriber, + listLines: $listLines, + changeSetDto: $changeSet ?? new ChangeSetDto(), + admin: $admin + ); + + return $this->prepareConfirmationMessage($subscriber, $options, $dto, $addedNewSubscriberToList); } private function handleInvalidEmail( @@ -205,55 +246,35 @@ private function handleInvalidEmail( return false; } - private function handleFlushAndEmail( - Subscriber $subscriber, + private function handleSkipCase( + ?Subscriber $existingSubscriber, SubscriberImportOptions $options, - ImportSubscriberDto $dto, - bool $addedNewSubscriberToList - ): void { - if (!$options->dryRun) { - $this->entityManager->flush(); - if ($dto->sendConfirmation && $addedNewSubscriberToList) { - $this->sendSubscribeEmail($subscriber, $options->listIds); - } - } - } + array &$stats + ): bool { + if ($existingSubscriber && !$options->updateExisting) { + $stats['skipped']++; - private function sendSubscribeEmail(Subscriber $subscriber, array $listIds): void - { - $message = new SubscriptionConfirmationMessage( - email: $subscriber->getEmail(), - uniqueId: $subscriber->getUniqueId(), - listIds: $listIds, - htmlEmail: $subscriber->hasHtmlEmail(), - ); + return true; + } - $this->messageBus->dispatch($message); + return false; } - /** - * Process subscriber attributes. - * - * @param Subscriber $subscriber The subscriber - * @param ImportSubscriberDto $dto - */ - private function processAttributes(Subscriber $subscriber, ImportSubscriberDto $dto): void - { - foreach ($dto->extraAttributes as $key => $value) { - $lowerKey = strtolower((string)$key); - // Do not import or update sensitive/system fields from CSV - if (in_array($lowerKey, ['password', 'modified'], true)) { - continue; - } - - $attributeDefinition = $this->attrDefinitionRepository->findOneByName($key); - if ($attributeDefinition !== null) { - $this->attributeManager->createOrUpdate( - subscriber: $subscriber, - definition: $attributeDefinition, - value: $value - ); - } + private function prepareConfirmationMessage( + Subscriber $subscriber, + SubscriberImportOptions $options, + ImportSubscriberDto $dto, + bool $addedNewSubscriberToList + ): ?SubscriptionConfirmationMessage { + if ($dto->sendConfirmation && $addedNewSubscriberToList) { + return new SubscriptionConfirmationMessage( + email: $subscriber->getEmail(), + uniqueId: $subscriber->getUniqueId(), + listIds: $options->listIds, + htmlEmail: $subscriber->hasHtmlEmail(), + ); } + + return null; } } diff --git a/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php index e1976202..f2f82752 100644 --- a/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php @@ -16,6 +16,7 @@ use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor; use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer; use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -54,6 +55,7 @@ protected function setUp(): void timeLimiter: $this->createMock(MaxProcessTimeLimiter::class), requeueHandler: $this->createMock(RequeueHandler::class), translator: new Translator('en'), + subscriberHistoryManager: $this->createMock(SubscriberHistoryManager::class), ); } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php index a827ab3f..332b3a7c 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php @@ -9,6 +9,7 @@ use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue; +use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; use PHPUnit\Framework\TestCase; @@ -35,7 +36,12 @@ public function testCreateNewSubscriberAttribute(): void return $attr->getValue() === 'US'; })); - $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager, new Translator('en')); + $manager = new SubscriberAttributeManager( + attributeRepository: $subscriberAttrRepo, + attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class), + entityManager: $entityManager, + translator: new Translator('en') + ); $attribute = $manager->createOrUpdate($subscriber, $definition, 'US'); self::assertInstanceOf(SubscriberAttributeValue::class, $attribute); @@ -61,7 +67,12 @@ public function testUpdateExistingSubscriberAttribute(): void ->method('persist') ->with($existing); - $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager, new Translator('en')); + $manager = new SubscriberAttributeManager( + attributeRepository: $subscriberAttrRepo, + attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class), + entityManager: $entityManager, + translator: new Translator('en') + ); $result = $manager->createOrUpdate($subscriber, $definition, 'Updated'); self::assertSame('Updated', $result->getValue()); @@ -77,7 +88,12 @@ public function testCreateFailsWhenValueAndDefaultAreNull(): void $subscriberAttrRepo->method('findOneBySubscriberAndAttribute')->willReturn(null); - $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager, new Translator('en')); + $manager = new SubscriberAttributeManager( + attributeRepository: $subscriberAttrRepo, + attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class), + entityManager: $entityManager, + translator: new Translator('en') + ); $this->expectException(SubscriberAttributeCreationException::class); $this->expectExceptionMessage('Value is required'); @@ -96,7 +112,12 @@ public function testGetSubscriberAttribute(): void ->with(5, 10) ->willReturn($expected); - $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager, new Translator('en')); + $manager = new SubscriberAttributeManager( + attributeRepository: $subscriberAttrRepo, + attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class), + entityManager: $entityManager, + translator: new Translator('en') + ); $result = $manager->getSubscriberAttribute(5, 10); self::assertSame($expected, $result); @@ -112,7 +133,12 @@ public function testDeleteSubscriberAttribute(): void ->method('remove') ->with($attribute); - $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager, new Translator('en')); + $manager = new SubscriberAttributeManager( + attributeRepository: $subscriberAttrRepo, + attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class), + entityManager: $entityManager, + translator: new Translator('en') + ); $manager->delete($attribute); self::assertTrue(true); diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php index 43ae2fcc..85e99730 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service\Manager; +use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Common\ClientIpResolver; use PhpList\Core\Domain\Common\SystemInfoCollector; use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberHistoryFilter; @@ -12,6 +13,7 @@ use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Contracts\Translation\TranslatorInterface; class SubscriberHistoryManagerTest extends TestCase { @@ -25,6 +27,8 @@ protected function setUp(): void repository: $this->subscriberHistoryRepository, clientIpResolver: $this->createMock(ClientIpResolver::class), systemInfoCollector: $this->createMock(SystemInfoCollector::class), + translator: $this->createMock(TranslatorInterface::class), + entityManager: $this->createMock(EntityManagerInterface::class), ); } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php index f96f32e2..4f11c393 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php @@ -5,38 +5,34 @@ namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service\Manager; use Doctrine\ORM\EntityManagerInterface; -use PhpList\Core\Domain\Messaging\Message\SubscriberConfirmationMessage; use PhpList\Core\Domain\Subscription\Model\Dto\CreateSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; +use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Symfony\Component\Messenger\Envelope; -use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Translation\Translator; class SubscriberManagerTest extends TestCase { private SubscriberRepository|MockObject $subscriberRepository; private EntityManagerInterface|MockObject $entityManager; - private MessageBusInterface|MockObject $messageBus; private SubscriberManager $subscriberManager; protected function setUp(): void { $this->subscriberRepository = $this->createMock(SubscriberRepository::class); $this->entityManager = $this->createMock(EntityManagerInterface::class); - $this->messageBus = $this->createMock(MessageBusInterface::class); $subscriberDeletionService = $this->createMock(SubscriberDeletionService::class); $this->subscriberManager = new SubscriberManager( subscriberRepository: $this->subscriberRepository, entityManager: $this->entityManager, - messageBus: $this->messageBus, subscriberDeletionService: $subscriberDeletionService, translator: new Translator('en'), + subscriberHistoryManager: $this->createMock(SubscriberHistoryManager::class) ); } @@ -65,7 +61,7 @@ public function testCreateSubscriberPersistsAndReturnsProperlyInitializedEntity( $this->assertFalse($result->isDisabled()); } - public function testCreateSubscriberPersistsAndSendsEmail(): void + public function testCreateSubscriberPersists(): void { $this->subscriberRepository ->expects($this->once()) @@ -79,13 +75,6 @@ public function testCreateSubscriberPersistsAndSendsEmail(): void && $sub->isDisabled() === false; })); - $this->messageBus - ->expects($this->once()) - ->method('dispatch') - ->willReturnCallback(function ($message) { - return new Envelope($message); - }); - $dto = new CreateSubscriberDto(email: 'foo@bar.com', requestConfirmation: true, htmlEmail: true); $result = $this->subscriberManager->createSubscriber($dto); @@ -97,7 +86,7 @@ public function testCreateSubscriberPersistsAndSendsEmail(): void $this->assertFalse($result->isDisabled()); } - public function testCreateSubscriberWithConfirmationSendsConfirmationEmail(): void + public function testCreateSubscriberWithConfirmation(): void { $capturedSubscriber = null; $this->subscriberRepository @@ -109,19 +98,6 @@ public function testCreateSubscriberWithConfirmationSendsConfirmationEmail(): vo return true; })); - $this->messageBus - ->expects($this->once()) - ->method('dispatch') - ->with($this->callback(function (SubscriberConfirmationMessage $message) { - $this->assertEquals('test@example.com', $message->getEmail()); - $this->assertEquals('test-unique-id-123', $message->getUniqueId()); - $this->assertTrue($message->hasHtmlEmail()); - return true; - })) - ->willReturnCallback(function ($message) { - return new Envelope($message); - }); - $dto = new CreateSubscriberDto(email: 'test@example.com', requestConfirmation: true, htmlEmail: true); $this->subscriberManager->createSubscriber($dto); @@ -131,16 +107,12 @@ public function testCreateSubscriberWithConfirmationSendsConfirmationEmail(): vo $this->assertFalse($capturedSubscriber->isConfirmed()); } - public function testCreateSubscriberWithoutConfirmationDoesNotSendConfirmationEmail(): void + public function testCreateSubscriberWithoutConfirmation(): void { $this->subscriberRepository ->expects($this->once()) ->method('save'); - $this->messageBus - ->expects($this->never()) - ->method('dispatch'); - $dto = new CreateSubscriberDto(email: 'test@example.com', requestConfirmation: false, htmlEmail: true); $this->subscriberManager->createSubscriber($dto); } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php index f0c1d3af..edbe1c07 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service\Manager; +use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberList; @@ -21,6 +22,7 @@ class SubscriptionManagerTest extends TestCase private SubscriptionRepository&MockObject $subscriptionRepository; private SubscriberRepository&MockObject $subscriberRepository; private TranslatorInterface&MockObject $translator; + private EntityManagerInterface&MockObject $entityManager; private SubscriptionManager $manager; protected function setUp(): void @@ -29,11 +31,13 @@ protected function setUp(): void $this->subscriberRepository = $this->createMock(SubscriberRepository::class); $subscriberListRepository = $this->createMock(SubscriberListRepository::class); $this->translator = $this->createMock(TranslatorInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); $this->manager = new SubscriptionManager( subscriptionRepository: $this->subscriptionRepository, subscriberRepository: $this->subscriberRepository, subscriberListRepository: $subscriberListRepository, translator: $this->translator, + entityManager: $this->entityManager, ); } @@ -45,7 +49,7 @@ public function testCreateSubscriptionWhenSubscriberExists(): void $this->subscriberRepository->method('findOneBy')->with(['email' => $email])->willReturn($subscriber); $this->subscriptionRepository->method('findOneBySubscriberListAndSubscriber')->willReturn(null); - $this->subscriptionRepository->expects($this->once())->method('save'); + $this->entityManager->expects($this->once())->method('persist'); $subscriptions = $this->manager->createSubscriptions($list, [$email]); @@ -78,7 +82,7 @@ public function testDeleteSubscriptionSuccessfully(): void ->with($subscriberList->getId(), $email) ->willReturn($subscription); - $this->subscriptionRepository->expects($this->once())->method('remove')->with($subscription); + $this->entityManager->expects($this->once())->method('remove')->with($subscription); $this->manager->deleteSubscriptions($subscriberList, [$email]); } diff --git a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProviderTest.php new file mode 100644 index 00000000..a38baabf --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProviderTest.php @@ -0,0 +1,175 @@ +resolver = $this->createMock(AttributeValueResolver::class); + $this->resolver + ->method('resolve') + ->willReturnCallback(function (SubscriberAttributeValue $attr) { + return $attr->getValue(); + }); + + $this->repository = $this->createMock(SubscriberAttributeValueRepository::class); + + $this->provider = new SubscriberAttributeChangeSetProvider( + resolver: $this->resolver, + attributesRepository: $this->repository, + ); + } + + public function testNoChangesWhenNewAndExistingAreIdenticalCaseInsensitive(): void + { + $subscriber = new Subscriber(); + $existing = [ + $this->attr('Name', 'John', $subscriber), + $this->attr('Age', '30', $subscriber), + ]; + + $this->repository->expects(self::once()) + ->method('getForSubscriber') + ->with($subscriber) + ->willReturn($existing); + + $newData = [ + 'name' => 'John', + 'AGE' => '30', + ]; + + $changeSet = $this->provider->getAttributeChangeSet($subscriber, $newData); + + self::assertInstanceOf(ChangeSetDto::class, $changeSet); + self::assertFalse($changeSet->hasChanges()); + self::assertSame([], $changeSet->getChanges()); + } + + public function testAddedAttributeAppearsWithNullOldValue(): void + { + $subscriber = new Subscriber(); + $existing = [ + $this->attr('Name', 'John', $subscriber), + ]; + + $this->repository->method('getForSubscriber')->willReturn($existing); + + $newData = [ + 'name' => 'John', + 'city' => 'NY', + ]; + + $changeSet = $this->provider->getAttributeChangeSet($subscriber, $newData); + + self::assertTrue($changeSet->hasField('city')); + self::assertSame([null, 'NY'], $changeSet->getFieldChange('city')); + + self::assertSame(['city' => [null, 'NY']], $changeSet->getChanges()); + } + + public function testRemovedAttributeAppearsWithNullNewValue(): void + { + $subscriber = new Subscriber(); + $existing = [ + $this->attr('Country', 'US', $subscriber), + ]; + + $this->repository->method('getForSubscriber')->willReturn($existing); + + $changeSet = $this->provider->getAttributeChangeSet($subscriber, []); + + self::assertTrue($changeSet->hasField('country')); + self::assertSame(['US', null], $changeSet->getFieldChange('country')); + self::assertSame(['country' => ['US', null]], $changeSet->getChanges()); + } + + public function testChangedAttributeShowsOldAndNewValues(): void + { + $subscriber = new Subscriber(); + $existing = [ + $this->attr('Phone', '123', $subscriber), + ]; + + $this->repository->method('getForSubscriber')->willReturn($existing); + + $newData = [ + 'phone' => '456', + ]; + + $changeSet = $this->provider->getAttributeChangeSet($subscriber, $newData); + + self::assertSame(['123', '456'], $changeSet->getFieldChange('phone')); + self::assertSame(['phone' => ['123', '456']], $changeSet->getChanges()); + } + + public function testIgnoredAttributesAreExcluded(): void + { + $subscriber = new Subscriber(); + $existing = [ + $this->attr('Password', 'old', $subscriber), + $this->attr('Modified', 'yesterday', $subscriber), + $this->attr('Nickname', 'Bob', $subscriber), + ]; + + $this->repository->method('getForSubscriber')->willReturn($existing); + + $newData = [ + 'password' => 'new', + 'MODIFIED' => null, + 'nickname' => 'Bobby', + ]; + + $changeSet = $this->provider->getAttributeChangeSet($subscriber, $newData); + + self::assertFalse($changeSet->hasField('password')); + self::assertFalse($changeSet->hasField('modified')); + self::assertTrue($changeSet->hasField('nickname')); + self::assertSame(['Bob', 'Bobby'], $changeSet->getFieldChange('nickname')); + self::assertSame(['nickname' => ['Bob', 'Bobby']], $changeSet->getChanges()); + } + + public function testCaseInsensitiveKeyComparisonAndResultLowercasing(): void + { + $subscriber = new Subscriber(); + $existing = [ + $this->attr('FirstName', 'Ann', $subscriber), + ]; + + $this->repository->method('getForSubscriber')->willReturn($existing); + + $newData = [ + 'firstname' => 'Anna', + ]; + + $changeSet = $this->provider->getAttributeChangeSet($subscriber, $newData); + + self::assertTrue($changeSet->hasField('firstname')); + self::assertSame(['Ann', 'Anna'], $changeSet->getFieldChange('firstname')); + self::assertSame(['firstname' => ['Ann', 'Anna']], $changeSet->getChanges()); + } + + private function attr(string $name, ?string $value, Subscriber $subscriber): SubscriberAttributeValue + { + $def = (new SubscriberAttributeDefinition())->setName($name); + $attr = new SubscriberAttributeValue($def, $subscriber); + $attr->setValue($value); + return $attr; + } +} diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php index 1453cfa2..424a7e0d 100644 --- a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php +++ b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Subscription\Model\Dto\ChangeSetDto; use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions; use PhpList\Core\Domain\Subscription\Model\Subscriber; @@ -13,6 +14,7 @@ use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\CsvImporter; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; +use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager; use PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter; @@ -47,10 +49,10 @@ protected function setUp(): void subscriptionManager: $subscriptionManagerMock, subscriberRepository: $this->subscriberRepositoryMock, csvImporter: $this->csvImporterMock, - attrDefinitionRepository: $this->attributeDefinitionRepositoryMock, entityManager: $entityManager, translator: new Translator('en'), messageBus: $this->createMock(MessageBusInterface::class), + subscriberHistoryManager: $this->createMock(SubscriberHistoryManager::class), ); } @@ -120,10 +122,10 @@ public function testImportFromCsvCreatesNewSubscribers(): void $this->attributeManagerMock ->expects($this->exactly(2)) - ->method('createOrUpdate') + ->method('processAttributes') ->withConsecutive( - [$subscriber1, $attributeDefinition, 'John'], - [$subscriber2, $attributeDefinition, 'Jane'] + [$subscriber1], + [$subscriber2] ); $options = new SubscriberImportOptions(); @@ -156,8 +158,6 @@ public function testImportFromCsvUpdatesExistingSubscribers(): void ->with('existing@example.com') ->willReturn($existingSubscriber); - $updatedSubscriber = $this->createMock(Subscriber::class); - $importDto = new ImportSubscriberDto( email: 'existing@example.com', confirmed: true, @@ -180,7 +180,15 @@ public function testImportFromCsvUpdatesExistingSubscribers(): void ->expects($this->once()) ->method('updateFromImport') ->with($existingSubscriber, $importDto) - ->willReturn($updatedSubscriber); + ->willReturn(new ChangeSetDto( + [ + 'extra_data' => [null, 'Updated data'], + 'confirmed' => [false, true], + 'html_email' => [false, true], + 'blacklisted' => [true, false], + 'disabled' => [true, false], + ] + )); $options = new SubscriberImportOptions(updateExisting: true); $result = $this->subject->importFromCsv($uploadedFile, $options); From 69321065c919b583b2e9859d7cc635c63a8e55d7 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Wed, 22 Oct 2025 13:44:05 +0400 Subject: [PATCH 14/20] Em flush (#364) * createSubscriberList * ProcessQueueCommand * Dispatch CampaignProcessorMessage for processing * Fix tests * Fix style * CleanUpOldSessionTokens * Move bounce processing into the Bounce folder * delete/remove * Remove hardcoded TatevikGrRssBundle mapping * Fix configs * Add sync * Fix: DQL in MessageRepository.php * PhpMD * SubscriberBlacklistService, SubscriberBlacklistManager * AdministratorManager * BounceManager * SendProcessManager * TemplateManager * SubscribePageManager * Fix: tests * BounceManager * rest of flushes * save * fix test * CouplingBetweenObjects 15 --------- Co-authored-by: Tatevik --- config/PHPMD/rules.xml | 6 +- config/doctrine.yml | 6 - config/packages/messenger.yaml | 6 + config/services/commands.yml | 2 +- config/services/managers.yml | 2 +- config/services/messenger.yml | 7 +- config/services/processor.yml | 10 +- config/services/resolvers.yml | 2 +- config/services/services.yml | 21 +-- resources/translations/messages.en.xlf | 4 + .../Command/ProcessBouncesCommand.php | 15 +- .../Exception/ImapConnectionException.php | 2 +- .../Exception/OpenMboxFileException.php | 2 +- .../Service/BounceActionResolver.php | 4 +- .../BounceProcessingServiceInterface.php | 2 +- .../Service/ConsecutiveBounceHandler.php | 5 +- .../BlacklistEmailAndDeleteBounceHandler.php | 6 +- .../Service/Handler/BlacklistEmailHandler.php | 4 +- .../BlacklistUserAndDeleteBounceHandler.php | 6 +- .../Service/Handler/BlacklistUserHandler.php | 4 +- .../Handler/BounceActionHandlerInterface.php | 2 +- ...CountConfirmUserAndDeleteBounceHandler.php | 10 +- .../Service/Handler/DeleteBounceHandler.php | 4 +- .../Handler/DeleteUserAndBounceHandler.php | 4 +- .../Service/Handler/DeleteUserHandler.php | 2 +- .../UnconfirmUserAndDeleteBounceHandler.php | 4 +- .../Service/Handler/UnconfirmUserHandler.php | 2 +- .../Service/LockService.php | 2 +- .../Service/Manager/BounceManager.php | 9 +- .../Service/MessageParser.php | 2 +- .../Service/NativeBounceProcessingService.php | 14 +- .../AdvancedBounceRulesProcessor.php | 8 +- .../Service/Processor/BounceDataProcessor.php | 11 +- .../Processor/BounceProtocolProcessor.php | 2 +- .../Service/Processor/MboxBounceProcessor.php | 4 +- .../Service/Processor/PopBounceProcessor.php | 4 +- .../UnidentifiedBounceReprocessor.php | 6 +- .../Service/SubscriberBlacklistService.php | 4 +- .../WebklexBounceProcessingService.php | 8 +- .../Service/WebklexImapClientFactory.php | 2 +- src/Core/BounceProcessorPass.php | 6 +- .../Analytics/Service/LinkTrackService.php | 2 +- .../Common/Repository/AbstractRepository.php | 12 +- .../Service/Manager/ConfigManager.php | 4 +- .../Command/CleanUpOldSessionTokens.php | 17 +- .../AdministratorTokenRepository.php | 23 +-- .../AdminAttributeDefinitionManager.php | 4 +- .../Service/AdminAttributeManager.php | 2 +- .../Identity/Service/AdministratorManager.php | 4 - .../Identity/Service/PasswordManager.php | 4 +- .../Identity/Service/SessionManager.php | 2 +- .../Messaging/Command/ProcessQueueCommand.php | 37 +++-- .../Message/CampaignProcessorMessage.php | 20 +++ .../Message/SyncCampaignProcessorMessage.php | 20 +++ .../CampaignProcessorMessageHandler.php} | 107 ++++++++----- .../Repository/MessageRepository.php | 17 +- .../Service/Handler/RequeueHandler.php | 3 - .../Service/Manager/BounceRegexManager.php | 5 +- .../Service/Manager/ListMessageManager.php | 1 - .../Service/Manager/MessageManager.php | 8 +- .../Service/Manager/SendProcessManager.php | 1 - .../Service/Manager/TemplateImageManager.php | 2 - .../Service/Manager/TemplateManager.php | 8 +- .../Service/MessageProcessingPreparator.php | 6 - .../Repository/SubscriberRepository.php | 11 ++ .../Manager/AttributeDefinitionManager.php | 4 +- .../Service/Manager/SubscribePageManager.php | 6 +- .../Manager/SubscriberBlacklistManager.php | 5 - .../Service/Manager/SubscriberListManager.php | 2 +- .../Service/Manager/SubscriberManager.php | 13 +- .../AdministratorRepositoryTest.php | 2 +- .../AdministratorTokenRepositoryTest.php | 52 +----- .../SubscriberListRepositoryTest.php | 2 +- .../Repository/SubscriberRepositoryTest.php | 4 +- .../Repository/SubscriptionRepositoryTest.php | 2 +- .../Command/ProcessBouncesCommandTest.php | 16 +- .../Service/BounceActionResolverTest.php | 6 +- .../Service/ConsecutiveBounceHandlerTest.php | 8 +- ...acklistEmailAndDeleteBounceHandlerTest.php | 8 +- .../Handler/BlacklistEmailHandlerTest.php | 6 +- ...lacklistUserAndDeleteBounceHandlerTest.php | 8 +- .../Handler/BlacklistUserHandlerTest.php | 6 +- ...tConfirmUserAndDeleteBounceHandlerTest.php | 16 +- .../Handler/DeleteBounceHandlerTest.php | 6 +- .../DeleteUserAndBounceHandlerTest.php | 6 +- .../Service/Handler/DeleteUserHandlerTest.php | 4 +- ...nconfirmUserAndDeleteBounceHandlerTest.php | 6 +- .../Handler/UnconfirmUserHandlerTest.php | 4 +- .../Service/LockServiceTest.php | 4 +- .../Service/MessageParserTest.php | 4 +- .../Service/WebklexImapClientFactoryTest.php | 4 +- .../Service/LinkTrackServiceTest.php | 12 +- .../Service/Manager/ConfigManagerTest.php | 25 +-- .../Command/CleanUpOldSessionTokensTest.php | 84 +++++----- .../AdminAttributeDefinitionManagerTest.php | 6 +- .../Service/AdminAttributeManagerTest.php | 7 +- .../Service/AdministratorManagerTest.php | 4 - .../Identity/Service/PasswordManagerTest.php | 4 +- .../Command/ProcessQueueCommandTest.php | 78 ++++++--- .../CampaignProcessorMessageHandlerTest.php} | 148 ++++++++++-------- .../Service/Handler/RequeueHandlerTest.php | 18 +-- .../Service/Manager/BounceManagerTest.php | 11 +- .../Manager/BounceRegexManagerTest.php | 9 +- .../Manager/ListMessageManagerTest.php | 6 - .../Service/Manager/MessageManagerTest.php | 6 +- .../Manager/SendProcessManagerTest.php | 1 - .../Manager/TemplateImageManagerTest.php | 3 - .../Service/Manager/TemplateManagerTest.php | 5 +- .../MessageProcessingPreparatorTest.php | 16 -- .../AttributeDefinitionManagerTest.php | 4 +- .../Manager/SubscribePageManagerTest.php | 18 +-- .../SubscriberBlacklistManagerTest.php | 12 -- .../Manager/SubscriberListManagerTest.php | 2 +- .../Service/Manager/SubscriberManagerTest.php | 12 +- .../AdvancedBounceRulesProcessorTest.php | 8 +- .../Processor/BounceDataProcessorTest.php | 8 +- .../Processor/MboxBounceProcessorTest.php | 6 +- .../Processor/PopBounceProcessorTest.php | 6 +- .../UnidentifiedBounceReprocessorTest.php | 10 +- 119 files changed, 622 insertions(+), 657 deletions(-) rename src/{Domain/Messaging => Bounce}/Command/ProcessBouncesCommand.php (89%) rename src/{Domain/Messaging => Bounce}/Exception/ImapConnectionException.php (84%) rename src/{Domain/Messaging => Bounce}/Exception/OpenMboxFileException.php (84%) rename src/{Domain/Messaging => Bounce}/Service/BounceActionResolver.php (92%) rename src/{Domain/Messaging => Bounce}/Service/BounceProcessingServiceInterface.php (77%) rename src/{Domain/Messaging => Bounce}/Service/ConsecutiveBounceHandler.php (96%) rename src/{Domain/Messaging => Bounce}/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php (90%) rename src/{Domain/Messaging => Bounce}/Service/Handler/BlacklistEmailHandler.php (92%) rename src/{Domain/Messaging => Bounce}/Service/Handler/BlacklistUserAndDeleteBounceHandler.php (90%) rename src/{Domain/Messaging => Bounce}/Service/Handler/BlacklistUserHandler.php (92%) rename src/{Domain/Messaging => Bounce}/Service/Handler/BounceActionHandlerInterface.php (76%) rename src/{Domain/Messaging => Bounce}/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php (81%) rename src/{Domain/Messaging => Bounce}/Service/Handler/DeleteBounceHandler.php (80%) rename src/{Domain/Messaging => Bounce}/Service/Handler/DeleteUserAndBounceHandler.php (87%) rename src/{Domain/Messaging => Bounce}/Service/Handler/DeleteUserHandler.php (94%) rename src/{Domain/Messaging => Bounce}/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php (93%) rename src/{Domain/Messaging => Bounce}/Service/Handler/UnconfirmUserHandler.php (96%) rename src/{Domain/Messaging => Bounce}/Service/LockService.php (99%) rename src/{Domain/Messaging => Bounce}/Service/Manager/BounceManager.php (96%) rename src/{Domain/Messaging => Bounce}/Service/MessageParser.php (98%) rename src/{Domain/Messaging => Bounce}/Service/NativeBounceProcessingService.php (90%) rename src/{Domain/Messaging => Bounce}/Service/Processor/AdvancedBounceRulesProcessor.php (95%) rename src/{Domain/Messaging => Bounce}/Service/Processor/BounceDataProcessor.php (93%) rename src/{Domain/Messaging => Bounce}/Service/Processor/BounceProtocolProcessor.php (91%) rename src/{Domain/Messaging => Bounce}/Service/Processor/MboxBounceProcessor.php (91%) rename src/{Domain/Messaging => Bounce}/Service/Processor/PopBounceProcessor.php (93%) rename src/{Domain/Messaging => Bounce}/Service/Processor/UnidentifiedBounceReprocessor.php (93%) rename src/{Domain/Subscription => Bounce}/Service/SubscriberBlacklistService.php (95%) rename src/{Domain/Messaging => Bounce}/Service/WebklexBounceProcessingService.php (97%) rename src/{Domain/Messaging => Bounce}/Service/WebklexImapClientFactory.php (97%) create mode 100644 src/Domain/Messaging/Message/CampaignProcessorMessage.php create mode 100644 src/Domain/Messaging/Message/SyncCampaignProcessorMessage.php rename src/Domain/Messaging/{Service/Processor/CampaignProcessor.php => MessageHandler/CampaignProcessorMessageHandler.php} (59%) rename tests/Unit/{Domain/Messaging => Bounce}/Command/ProcessBouncesCommandTest.php (93%) rename tests/Unit/{Domain/Messaging => Bounce}/Service/BounceActionResolverTest.php (91%) rename tests/Unit/{Domain/Messaging => Bounce}/Service/ConsecutiveBounceHandlerTest.php (96%) rename tests/Unit/{Domain/Messaging => Bounce}/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php (90%) rename tests/Unit/{Domain/Messaging => Bounce}/Service/Handler/BlacklistEmailHandlerTest.php (91%) rename tests/Unit/{Domain/Messaging => Bounce}/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php (91%) rename tests/Unit/{Domain/Messaging => Bounce}/Service/Handler/BlacklistUserHandlerTest.php (92%) rename tests/Unit/{Domain/Messaging => Bounce}/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php (81%) rename tests/Unit/{Domain/Messaging => Bounce}/Service/Handler/DeleteBounceHandlerTest.php (83%) rename tests/Unit/{Domain/Messaging => Bounce}/Service/Handler/DeleteUserAndBounceHandlerTest.php (91%) rename tests/Unit/{Domain/Messaging => Bounce}/Service/Handler/DeleteUserHandlerTest.php (94%) rename tests/Unit/{Domain/Messaging => Bounce}/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php (93%) rename tests/Unit/{Domain/Messaging => Bounce}/Service/Handler/UnconfirmUserHandlerTest.php (94%) rename tests/Unit/{Domain/Messaging => Bounce}/Service/LockServiceTest.php (96%) rename tests/Unit/{Domain/Messaging => Bounce}/Service/MessageParserTest.php (95%) rename tests/Unit/{Domain/Messaging => Bounce}/Service/WebklexImapClientFactoryTest.php (94%) rename tests/Unit/Domain/Messaging/{Service/Processor/CampaignProcessorTest.php => MessageHandler/CampaignProcessorMessageHandlerTest.php} (70%) rename tests/Unit/{Domain/Messaging/Service => }/Processor/AdvancedBounceRulesProcessorTest.php (96%) rename tests/Unit/{Domain/Messaging/Service => }/Processor/BounceDataProcessorTest.php (96%) rename tests/Unit/{Domain/Messaging/Service => }/Processor/MboxBounceProcessorTest.php (92%) rename tests/Unit/{Domain/Messaging/Service => }/Processor/PopBounceProcessorTest.php (91%) rename tests/Unit/{Domain/Messaging/Service => }/Processor/UnidentifiedBounceReprocessorTest.php (89%) diff --git a/config/PHPMD/rules.xml b/config/PHPMD/rules.xml index a0fbf650..081ed9f3 100644 --- a/config/PHPMD/rules.xml +++ b/config/PHPMD/rules.xml @@ -45,7 +45,11 @@ - + + + + + diff --git a/config/doctrine.yml b/config/doctrine.yml index 7b53b7ff..327cf305 100644 --- a/config/doctrine.yml +++ b/config/doctrine.yml @@ -17,12 +17,6 @@ doctrine: naming_strategy: doctrine.orm.naming_strategy.underscore auto_mapping: false mappings: - TatevikGrRssBundle: - is_bundle: false - type: attribute - dir: '%kernel.project_dir%/vendor/tatevikgr/rss-feed/src/Entity' - prefix: 'TatevikGr\RssFeedBundle\Entity' - alias: 'RssBundle' Identity: is_bundle: false type: attribute diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 6841eed5..38a75a1b 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -6,6 +6,7 @@ framework: transports: # https://symfony.com/doc/current/messenger.html#transport-configuration + sync: 'sync://' async_email: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' options: @@ -25,3 +26,8 @@ framework: # Route your messages to the transports 'PhpList\Core\Domain\Messaging\Message\AsyncEmailMessage': async_email 'PhpList\Core\Domain\Messaging\Message\SubscriberConfirmationMessage': async_email + 'PhpList\Core\Domain\Messaging\Message\SubscriptionConfirmationMessage': async_email + 'PhpList\Core\Domain\Messaging\Message\PasswordResetMessage': async_email + 'PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage': async_email + 'PhpList\Core\Domain\Messaging\Message\SyncCampaignProcessorMessage': sync + diff --git a/config/services/commands.yml b/config/services/commands.yml index 4f30b06b..7e16ac87 100644 --- a/config/services/commands.yml +++ b/config/services/commands.yml @@ -12,7 +12,7 @@ services: resource: '../../src/Domain/Identity/Command' tags: ['console.command'] - PhpList\Core\Domain\Messaging\Command\ProcessBouncesCommand: + PhpList\Core\Bounce\Command\ProcessBouncesCommand: arguments: $protocolProcessors: !tagged_iterator 'phplist.bounce_protocol_processor' diff --git a/config/services/managers.yml b/config/services/managers.yml index 22dbe066..2f72bd7e 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -80,7 +80,7 @@ services: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\Manager\BounceManager: + PhpList\Core\Bounce\Service\Manager\BounceManager: autowire: true autoconfigure: true diff --git a/config/services/messenger.yml b/config/services/messenger.yml index 80f893f4..214a4c86 100644 --- a/config/services/messenger.yml +++ b/config/services/messenger.yml @@ -1,6 +1,7 @@ services: # Register message handlers for Symfony Messenger PhpList\Core\Domain\Messaging\MessageHandler\: + autowire: true resource: '../../src/Domain/Messaging/MessageHandler' tags: [ 'messenger.message_handler' ] @@ -24,7 +25,7 @@ services: $passwordResetUrl: '%app.password_reset_url%' PhpList\Core\Domain\Messaging\MessageHandler\SubscriptionConfirmationMessageHandler: - autowire: true - autoconfigure: true - tags: [ 'messenger.message_handler' ] + autowire: true + autoconfigure: true + tags: [ 'messenger.message_handler' ] diff --git a/config/services/processor.yml b/config/services/processor.yml index acbd11c0..8ef01135 100644 --- a/config/services/processor.yml +++ b/config/services/processor.yml @@ -4,18 +4,18 @@ services: autoconfigure: true public: false - PhpList\Core\Domain\Messaging\Service\Processor\PopBounceProcessor: + PhpList\Core\Bounce\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: + PhpList\Core\Bounce\Service\Processor\MboxBounceProcessor: tags: ['phplist.bounce_protocol_processor'] - PhpList\Core\Domain\Messaging\Service\Processor\AdvancedBounceRulesProcessor: ~ + PhpList\Core\Bounce\Service\Processor\AdvancedBounceRulesProcessor: ~ - PhpList\Core\Domain\Messaging\Service\Processor\UnidentifiedBounceReprocessor: ~ + PhpList\Core\Bounce\Service\Processor\UnidentifiedBounceReprocessor: ~ - PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor: ~ + PhpList\Core\Bounce\Service\Processor\BounceDataProcessor: ~ diff --git a/config/services/resolvers.yml b/config/services/resolvers.yml index bf8f9fc7..99c08356 100644 --- a/config/services/resolvers.yml +++ b/config/services/resolvers.yml @@ -10,6 +10,6 @@ services: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\BounceActionResolver: + PhpList\Core\Bounce\Service\BounceActionResolver: arguments: - !tagged_iterator { tag: 'phplist.bounce_action_handler' } diff --git a/config/services/services.yml b/config/services/services.yml index bc236399..65ede6b7 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -31,11 +31,6 @@ services: autoconfigure: true public: true - PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor: - autowire: true - autoconfigure: true - public: true - PhpList\Core\Domain\Messaging\Service\SendRateLimiter: autowire: true autoconfigure: true @@ -52,7 +47,7 @@ services: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler: + PhpList\Core\Bounce\Service\ConsecutiveBounceHandler: autowire: true autoconfigure: true arguments: @@ -61,7 +56,7 @@ services: Webklex\PHPIMAP\ClientManager: ~ - PhpList\Core\Domain\Messaging\Service\WebklexImapClientFactory: + PhpList\Core\Bounce\Service\WebklexImapClientFactory: autowire: true autoconfigure: true arguments: @@ -78,34 +73,34 @@ services: $username: '%imap_bounce.email%' $password: '%imap_bounce.password%' - PhpList\Core\Domain\Messaging\Service\NativeBounceProcessingService: + PhpList\Core\Bounce\Service\NativeBounceProcessingService: autowire: true autoconfigure: true arguments: $purgeProcessed: '%imap_bounce.purge%' $purgeUnprocessed: '%imap_bounce.purge_unprocessed%' - PhpList\Core\Domain\Messaging\Service\WebklexBounceProcessingService: + PhpList\Core\Bounce\Service\WebklexBounceProcessingService: autowire: true autoconfigure: true arguments: $purgeProcessed: '%imap_bounce.purge%' $purgeUnprocessed: '%imap_bounce.purge_unprocessed%' - PhpList\Core\Domain\Messaging\Service\LockService: + PhpList\Core\Bounce\Service\LockService: autowire: true autoconfigure: true - PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService: + PhpList\Core\Bounce\Service\SubscriberBlacklistService: autowire: true autoconfigure: true - PhpList\Core\Domain\Messaging\Service\MessageParser: + PhpList\Core\Bounce\Service\MessageParser: autowire: true autoconfigure: true _instanceof: - PhpList\Core\Domain\Messaging\Service\Handler\BounceActionHandlerInterface: + PhpList\Core\Bounce\Service\Handler\BounceActionHandlerInterface: tags: - { name: 'phplist.bounce_action_handler' } diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf index 9a9fae29..b3204742 100644 --- a/resources/translations/messages.en.xlf +++ b/resources/translations/messages.en.xlf @@ -726,6 +726,10 @@ Thank you. No data changed __No data changed + + Campaign not found or not in submitted status + __Campaign not found or not in submitted status + diff --git a/src/Domain/Messaging/Command/ProcessBouncesCommand.php b/src/Bounce/Command/ProcessBouncesCommand.php similarity index 89% rename from src/Domain/Messaging/Command/ProcessBouncesCommand.php rename to src/Bounce/Command/ProcessBouncesCommand.php index 0d51d4f1..fcb37ba2 100644 --- a/src/Domain/Messaging/Command/ProcessBouncesCommand.php +++ b/src/Bounce/Command/ProcessBouncesCommand.php @@ -2,14 +2,15 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Command; +namespace PhpList\Core\Bounce\Command; +use Doctrine\ORM\EntityManagerInterface; use Exception; -use PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler; -use PhpList\Core\Domain\Messaging\Service\Processor\BounceProtocolProcessor; -use PhpList\Core\Domain\Messaging\Service\Processor\AdvancedBounceRulesProcessor; -use PhpList\Core\Domain\Messaging\Service\Processor\UnidentifiedBounceReprocessor; -use PhpList\Core\Domain\Messaging\Service\LockService; +use PhpList\Core\Bounce\Service\ConsecutiveBounceHandler; +use PhpList\Core\Bounce\Service\LockService; +use PhpList\Core\Bounce\Service\Processor\AdvancedBounceRulesProcessor; +use PhpList\Core\Bounce\Service\Processor\BounceProtocolProcessor; +use PhpList\Core\Bounce\Service\Processor\UnidentifiedBounceReprocessor; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -46,6 +47,7 @@ public function __construct( private readonly UnidentifiedBounceReprocessor $unidentifiedReprocessor, private readonly ConsecutiveBounceHandler $consecutiveBounceHandler, private readonly TranslatorInterface $translator, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct(); } @@ -62,6 +64,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $force = (bool)$input->getOption('force'); $lock = $this->lockService->acquirePageLock('bounce_processor', $force); + $this->entityManager->flush(); if (($lock ?? 0) === 0) { $forceLockFailed = $this->translator->trans('Could not apply force lock. Aborting.'); diff --git a/src/Domain/Messaging/Exception/ImapConnectionException.php b/src/Bounce/Exception/ImapConnectionException.php similarity index 84% rename from src/Domain/Messaging/Exception/ImapConnectionException.php rename to src/Bounce/Exception/ImapConnectionException.php index 8e5295e2..58d3495d 100644 --- a/src/Domain/Messaging/Exception/ImapConnectionException.php +++ b/src/Bounce/Exception/ImapConnectionException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Exception; +namespace PhpList\Core\Bounce\Exception; use RuntimeException; use Throwable; diff --git a/src/Domain/Messaging/Exception/OpenMboxFileException.php b/src/Bounce/Exception/OpenMboxFileException.php similarity index 84% rename from src/Domain/Messaging/Exception/OpenMboxFileException.php rename to src/Bounce/Exception/OpenMboxFileException.php index 2fc7c458..c5dc775f 100644 --- a/src/Domain/Messaging/Exception/OpenMboxFileException.php +++ b/src/Bounce/Exception/OpenMboxFileException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Exception; +namespace PhpList\Core\Bounce\Exception; use RuntimeException; use Throwable; diff --git a/src/Domain/Messaging/Service/BounceActionResolver.php b/src/Bounce/Service/BounceActionResolver.php similarity index 92% rename from src/Domain/Messaging/Service/BounceActionResolver.php rename to src/Bounce/Service/BounceActionResolver.php index 93d432dd..0359866c 100644 --- a/src/Domain/Messaging/Service/BounceActionResolver.php +++ b/src/Bounce/Service/BounceActionResolver.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service; +namespace PhpList\Core\Bounce\Service; -use PhpList\Core\Domain\Messaging\Service\Handler\BounceActionHandlerInterface; +use PhpList\Core\Bounce\Service\Handler\BounceActionHandlerInterface; use RuntimeException; class BounceActionResolver diff --git a/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php b/src/Bounce/Service/BounceProcessingServiceInterface.php similarity index 77% rename from src/Domain/Messaging/Service/BounceProcessingServiceInterface.php rename to src/Bounce/Service/BounceProcessingServiceInterface.php index 9d16702f..8050a400 100644 --- a/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php +++ b/src/Bounce/Service/BounceProcessingServiceInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service; +namespace PhpList\Core\Bounce\Service; interface BounceProcessingServiceInterface { diff --git a/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php b/src/Bounce/Service/ConsecutiveBounceHandler.php similarity index 96% rename from src/Domain/Messaging/Service/ConsecutiveBounceHandler.php rename to src/Bounce/Service/ConsecutiveBounceHandler.php index 91c4c041..6a2687f2 100644 --- a/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php +++ b/src/Bounce/Service/ConsecutiveBounceHandler.php @@ -2,16 +2,15 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service; +namespace PhpList\Core\Bounce\Service; +use PhpList\Core\Bounce\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Model\UserMessage; use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php b/src/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php similarity index 90% rename from src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php rename to src/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php index e3b743cb..9f224007 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php +++ b/src/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Bounce\Service\Handler; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Bounce\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use Symfony\Contracts\Translation\TranslatorInterface; class BlacklistEmailAndDeleteBounceHandler implements BounceActionHandlerInterface diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php b/src/Bounce/Service/Handler/BlacklistEmailHandler.php similarity index 92% rename from src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php rename to src/Bounce/Service/Handler/BlacklistEmailHandler.php index 4f95c18b..f2bd12b9 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php +++ b/src/Bounce/Service/Handler/BlacklistEmailHandler.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Bounce\Service\Handler; +use PhpList\Core\Bounce\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use Symfony\Contracts\Translation\TranslatorInterface; class BlacklistEmailHandler implements BounceActionHandlerInterface diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php b/src/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandler.php similarity index 90% rename from src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php rename to src/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandler.php index 3fda46c2..7c997982 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php +++ b/src/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandler.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Bounce\Service\Handler; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Bounce\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use Symfony\Contracts\Translation\TranslatorInterface; class BlacklistUserAndDeleteBounceHandler implements BounceActionHandlerInterface diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php b/src/Bounce/Service/Handler/BlacklistUserHandler.php similarity index 92% rename from src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php rename to src/Bounce/Service/Handler/BlacklistUserHandler.php index 555ad3bf..c5dd2fd5 100644 --- a/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php +++ b/src/Bounce/Service/Handler/BlacklistUserHandler.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Bounce\Service\Handler; +use PhpList\Core\Bounce\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use Symfony\Contracts\Translation\TranslatorInterface; class BlacklistUserHandler implements BounceActionHandlerInterface diff --git a/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php b/src/Bounce/Service/Handler/BounceActionHandlerInterface.php similarity index 76% rename from src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php rename to src/Bounce/Service/Handler/BounceActionHandlerInterface.php index 6b90cb49..ce43f7c7 100644 --- a/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php +++ b/src/Bounce/Service/Handler/BounceActionHandlerInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Bounce\Service\Handler; interface BounceActionHandlerInterface { diff --git a/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php b/src/Bounce/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php similarity index 81% rename from src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php rename to src/Bounce/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php index 4b7471eb..fa09f4a0 100644 --- a/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php +++ b/src/Bounce/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php @@ -2,31 +2,27 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Bounce\Service\Handler; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Bounce\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use Symfony\Contracts\Translation\TranslatorInterface; class DecreaseCountConfirmUserAndDeleteBounceHandler implements BounceActionHandlerInterface { private SubscriberHistoryManager $subscriberHistoryManager; - private SubscriberManager $subscriberManager; private BounceManager $bounceManager; private SubscriberRepository $subscriberRepository; private TranslatorInterface $translator; public function __construct( SubscriberHistoryManager $subscriberHistoryManager, - SubscriberManager $subscriberManager, BounceManager $bounceManager, SubscriberRepository $subscriberRepository, TranslatorInterface $translator, ) { $this->subscriberHistoryManager = $subscriberHistoryManager; - $this->subscriberManager = $subscriberManager; $this->bounceManager = $bounceManager; $this->subscriberRepository = $subscriberRepository; $this->translator = $translator; @@ -40,7 +36,7 @@ public function supports(string $action): bool public function handle(array $closureData): void { if (!empty($closureData['subscriber'])) { - $this->subscriberManager->decrementBounceCount($closureData['subscriber']); + $this->subscriberRepository->decrementBounceCount($closureData['subscriber']); if (!$closureData['confirmed']) { $this->subscriberRepository->markConfirmed($closureData['userId']); $this->subscriberHistoryManager->addHistory( diff --git a/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php b/src/Bounce/Service/Handler/DeleteBounceHandler.php similarity index 80% rename from src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php rename to src/Bounce/Service/Handler/DeleteBounceHandler.php index 80c881a1..9bf0d93e 100644 --- a/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php +++ b/src/Bounce/Service/Handler/DeleteBounceHandler.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Bounce\Service\Handler; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Bounce\Service\Manager\BounceManager; class DeleteBounceHandler implements BounceActionHandlerInterface { diff --git a/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php b/src/Bounce/Service/Handler/DeleteUserAndBounceHandler.php similarity index 87% rename from src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php rename to src/Bounce/Service/Handler/DeleteUserAndBounceHandler.php index d8887545..526f6968 100644 --- a/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php +++ b/src/Bounce/Service/Handler/DeleteUserAndBounceHandler.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Bounce\Service\Handler; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Bounce\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; class DeleteUserAndBounceHandler implements BounceActionHandlerInterface diff --git a/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php b/src/Bounce/Service/Handler/DeleteUserHandler.php similarity index 94% rename from src/Domain/Messaging/Service/Handler/DeleteUserHandler.php rename to src/Bounce/Service/Handler/DeleteUserHandler.php index 64b1a073..88f9da8f 100644 --- a/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php +++ b/src/Bounce/Service/Handler/DeleteUserHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Bounce\Service\Handler; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use Psr\Log\LoggerInterface; diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php b/src/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php similarity index 93% rename from src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php rename to src/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php index 0653900f..e13afb7a 100644 --- a/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php +++ b/src/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Bounce\Service\Handler; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Bounce\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php b/src/Bounce/Service/Handler/UnconfirmUserHandler.php similarity index 96% rename from src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php rename to src/Bounce/Service/Handler/UnconfirmUserHandler.php index 971863f3..bef028a6 100644 --- a/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php +++ b/src/Bounce/Service/Handler/UnconfirmUserHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Bounce\Service\Handler; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; diff --git a/src/Domain/Messaging/Service/LockService.php b/src/Bounce/Service/LockService.php similarity index 99% rename from src/Domain/Messaging/Service/LockService.php rename to src/Bounce/Service/LockService.php index d2f1eb34..c3948c1f 100644 --- a/src/Domain/Messaging/Service/LockService.php +++ b/src/Bounce/Service/LockService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service; +namespace PhpList\Core\Bounce\Service; use PhpList\Core\Domain\Messaging\Repository\SendProcessRepository; use PhpList\Core\Domain\Messaging\Service\Manager\SendProcessManager; diff --git a/src/Domain/Messaging/Service/Manager/BounceManager.php b/src/Bounce/Service/Manager/BounceManager.php similarity index 96% rename from src/Domain/Messaging/Service/Manager/BounceManager.php rename to src/Bounce/Service/Manager/BounceManager.php index 4945b881..230ad279 100644 --- a/src/Domain/Messaging/Service/Manager/BounceManager.php +++ b/src/Bounce/Service/Manager/BounceManager.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Manager; +namespace PhpList\Core\Bounce\Service\Manager; use DateTime; use DateTimeImmutable; @@ -53,7 +53,8 @@ public function create( comment: $comment ); - $this->bounceRepository->save($bounce); + $this->bounceRepository->persist($bounce); + $this->entityManager->flush(); return $bounce; } @@ -62,7 +63,7 @@ public function update(Bounce $bounce, ?string $status = null, ?string $comment { $bounce->setStatus($status); $bounce->setComment($comment); - $this->bounceRepository->save($bounce); + $this->entityManager->flush(); return $bounce; } @@ -70,6 +71,7 @@ public function update(Bounce $bounce, ?string $status = null, ?string $comment public function delete(Bounce $bounce): void { $this->bounceRepository->remove($bounce); + $this->entityManager->flush(); } /** @return Bounce[] */ @@ -94,7 +96,6 @@ public function linkUserMessageBounce( $userMessageBounce = new UserMessageBounce($bounce->getId(), new DateTime($date->format('Y-m-d H:i:s'))); $userMessageBounce->setUserId($subscriberId); $userMessageBounce->setMessageId($messageId); - $this->entityManager->flush(); return $userMessageBounce; } diff --git a/src/Domain/Messaging/Service/MessageParser.php b/src/Bounce/Service/MessageParser.php similarity index 98% rename from src/Domain/Messaging/Service/MessageParser.php rename to src/Bounce/Service/MessageParser.php index 14b4f952..336cbe02 100644 --- a/src/Domain/Messaging/Service/MessageParser.php +++ b/src/Bounce/Service/MessageParser.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service; +namespace PhpList\Core\Bounce\Service; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; diff --git a/src/Domain/Messaging/Service/NativeBounceProcessingService.php b/src/Bounce/Service/NativeBounceProcessingService.php similarity index 90% rename from src/Domain/Messaging/Service/NativeBounceProcessingService.php rename to src/Bounce/Service/NativeBounceProcessingService.php index 0cdc7cb4..887aa94d 100644 --- a/src/Domain/Messaging/Service/NativeBounceProcessingService.php +++ b/src/Bounce/Service/NativeBounceProcessingService.php @@ -2,13 +2,14 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service; +namespace PhpList\Core\Bounce\Service; +use Doctrine\ORM\EntityManagerInterface; use IMAP\Connection; +use PhpList\Core\Bounce\Exception\OpenMboxFileException; +use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Bounce\Service\Processor\BounceDataProcessor; use PhpList\Core\Domain\Common\Mail\NativeImapMailReader; -use PhpList\Core\Domain\Messaging\Exception\OpenMboxFileException; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; -use PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor; use Psr\Log\LoggerInterface; use Throwable; @@ -21,6 +22,7 @@ class NativeBounceProcessingService implements BounceProcessingServiceInterface private LoggerInterface $logger; private bool $purgeProcessed; private bool $purgeUnprocessed; + private EntityManagerInterface $entityManager; public function __construct( BounceManager $bounceManager, @@ -28,6 +30,7 @@ public function __construct( MessageParser $messageParser, BounceDataProcessor $bounceDataProcessor, LoggerInterface $logger, + EntityManagerInterface $entityManager, bool $purgeProcessed, bool $purgeUnprocessed ) { @@ -36,6 +39,7 @@ public function __construct( $this->messageParser = $messageParser; $this->bounceDataProcessor = $bounceDataProcessor; $this->logger = $logger; + $this->entityManager = $entityManager; $this->purgeProcessed = $purgeProcessed; $this->purgeUnprocessed = $purgeUnprocessed; } @@ -135,7 +139,7 @@ private function processImapBounce($link, int $num, string $header): bool $userId = $this->messageParser->findUserId($body); $bounce = $this->bounceManager->create($bounceDate, $header, $body); - + $this->entityManager->flush(); return $this->bounceDataProcessor->process($bounce, $msgId, $userId, $bounceDate); } } diff --git a/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php b/src/Bounce/Service/Processor/AdvancedBounceRulesProcessor.php similarity index 95% rename from src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php rename to src/Bounce/Service/Processor/AdvancedBounceRulesProcessor.php index 0e1c3fe0..3d6fb116 100644 --- a/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php +++ b/src/Bounce/Service/Processor/AdvancedBounceRulesProcessor.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Processor; +namespace PhpList\Core\Bounce\Service\Processor; +use PhpList\Core\Bounce\Service\BounceActionResolver; +use PhpList\Core\Bounce\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\Bounce; -use PhpList\Core\Domain\Messaging\Service\BounceActionResolver; -use PhpList\Core\Domain\Subscription\Model\Subscriber; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager; +use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php b/src/Bounce/Service/Processor/BounceDataProcessor.php similarity index 93% rename from src/Domain/Messaging/Service/Processor/BounceDataProcessor.php rename to src/Bounce/Service/Processor/BounceDataProcessor.php index 7a33a7e9..d40707b3 100644 --- a/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php +++ b/src/Bounce/Service/Processor/BounceDataProcessor.php @@ -2,13 +2,14 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Processor; +namespace PhpList\Core\Bounce\Service\Processor; use DateTimeImmutable; +use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Bounce\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Model\BounceStatus; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; @@ -22,6 +23,7 @@ class BounceDataProcessor private readonly LoggerInterface $logger; private readonly SubscriberManager $subscriberManager; private readonly SubscriberHistoryManager $subscriberHistoryManager; + private EntityManagerInterface $entityManager; public function __construct( BounceManager $bounceManager, @@ -30,6 +32,7 @@ public function __construct( LoggerInterface $logger, SubscriberManager $subscriberManager, SubscriberHistoryManager $subscriberHistoryManager, + EntityManagerInterface $entityManager, ) { $this->bounceManager = $bounceManager; $this->subscriberRepository = $subscriberRepository; @@ -37,6 +40,7 @@ public function __construct( $this->logger = $logger; $this->subscriberManager = $subscriberManager; $this->subscriberHistoryManager = $subscriberHistoryManager; + $this->entityManager = $entityManager; } public function process(Bounce $bounce, ?string $msgId, ?int $userId, DateTimeImmutable $bounceDate): bool @@ -90,6 +94,7 @@ private function handleSystemMessageWithUser( comment: sprintf('%d marked unconfirmed', $userId) ); $this->bounceManager->linkUserMessageBounce(bounce: $bounce, date: $date, subscriberId: $userId); + $this->entityManager->flush(); $this->subscriberRepository->markUnconfirmed($userId); $this->logger->info('system message bounced, user marked unconfirmed', ['userId' => $userId]); @@ -129,6 +134,7 @@ private function handleKnownMessageAndUser( subscriberId: $userId, messageId: $msgId ); + $this->entityManager->flush(); $this->bounceManager->update( bounce: $bounce, status: BounceStatus::BouncedList->format($msgId), @@ -143,6 +149,7 @@ private function handleKnownMessageAndUser( subscriberId: $userId, messageId: $msgId ); + $this->entityManager->flush(); $this->bounceManager->update( bounce: $bounce, status: BounceStatus::DuplicateBounce->format($userId), diff --git a/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php b/src/Bounce/Service/Processor/BounceProtocolProcessor.php similarity index 91% rename from src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php rename to src/Bounce/Service/Processor/BounceProtocolProcessor.php index a0e7d904..6bb77a49 100644 --- a/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php +++ b/src/Bounce/Service/Processor/BounceProtocolProcessor.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Processor; +namespace PhpList\Core\Bounce\Service\Processor; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; diff --git a/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php b/src/Bounce/Service/Processor/MboxBounceProcessor.php similarity index 91% rename from src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php rename to src/Bounce/Service/Processor/MboxBounceProcessor.php index d61742d5..b3f8c79b 100644 --- a/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php +++ b/src/Bounce/Service/Processor/MboxBounceProcessor.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Processor; +namespace PhpList\Core\Bounce\Service\Processor; -use PhpList\Core\Domain\Messaging\Service\BounceProcessingServiceInterface; +use PhpList\Core\Bounce\Service\BounceProcessingServiceInterface; use RuntimeException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; diff --git a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php b/src/Bounce/Service/Processor/PopBounceProcessor.php similarity index 93% rename from src/Domain/Messaging/Service/Processor/PopBounceProcessor.php rename to src/Bounce/Service/Processor/PopBounceProcessor.php index b0079774..9ebb26c4 100644 --- a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php +++ b/src/Bounce/Service/Processor/PopBounceProcessor.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Processor; +namespace PhpList\Core\Bounce\Service\Processor; -use PhpList\Core\Domain\Messaging\Service\BounceProcessingServiceInterface; +use PhpList\Core\Bounce\Service\BounceProcessingServiceInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php b/src/Bounce/Service/Processor/UnidentifiedBounceReprocessor.php similarity index 93% rename from src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php rename to src/Bounce/Service/Processor/UnidentifiedBounceReprocessor.php index 2646ede6..416684b2 100644 --- a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php +++ b/src/Bounce/Service/Processor/UnidentifiedBounceReprocessor.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Processor; +namespace PhpList\Core\Bounce\Service\Processor; use DateTimeImmutable; +use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Bounce\Service\MessageParser; use PhpList\Core\Domain\Messaging\Model\BounceStatus; -use PhpList\Core\Domain\Messaging\Service\MessageParser; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/src/Domain/Subscription/Service/SubscriberBlacklistService.php b/src/Bounce/Service/SubscriberBlacklistService.php similarity index 95% rename from src/Domain/Subscription/Service/SubscriberBlacklistService.php rename to src/Bounce/Service/SubscriberBlacklistService.php index 3a40f042..eee70215 100644 --- a/src/Domain/Subscription/Service/SubscriberBlacklistService.php +++ b/src/Bounce/Service/SubscriberBlacklistService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Subscription\Service; +namespace PhpList\Core\Bounce\Service; use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Subscription\Model\Subscriber; @@ -41,6 +41,7 @@ public function blacklist(Subscriber $subscriber, string $reason): void $subscriber->setBlacklisted(true); $this->entityManager->flush(); $this->blacklistManager->addEmailToBlacklist($subscriber->getEmail(), $reason); + $this->entityManager->flush(); foreach (['REMOTE_ADDR','HTTP_X_FORWARDED_FOR'] as $item) { $request = $this->requestStack->getCurrentRequest(); @@ -53,6 +54,7 @@ public function blacklist(Subscriber $subscriber, string $reason): void name: $item, data: $request->server->get($item) ); + $this->entityManager->flush(); } } diff --git a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php b/src/Bounce/Service/WebklexBounceProcessingService.php similarity index 97% rename from src/Domain/Messaging/Service/WebklexBounceProcessingService.php rename to src/Bounce/Service/WebklexBounceProcessingService.php index 09a1c14a..4ca20461 100644 --- a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php +++ b/src/Bounce/Service/WebklexBounceProcessingService.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service; +namespace PhpList\Core\Bounce\Service; use DateTimeImmutable; use DateTimeInterface; -use PhpList\Core\Domain\Messaging\Exception\ImapConnectionException; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; -use PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor; +use PhpList\Core\Bounce\Exception\ImapConnectionException; +use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Bounce\Service\Processor\BounceDataProcessor; use Psr\Log\LoggerInterface; use Throwable; use Webklex\PHPIMAP\Client; diff --git a/src/Domain/Messaging/Service/WebklexImapClientFactory.php b/src/Bounce/Service/WebklexImapClientFactory.php similarity index 97% rename from src/Domain/Messaging/Service/WebklexImapClientFactory.php rename to src/Bounce/Service/WebklexImapClientFactory.php index 10271e4c..48fc26bc 100644 --- a/src/Domain/Messaging/Service/WebklexImapClientFactory.php +++ b/src/Bounce/Service/WebklexImapClientFactory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service; +namespace PhpList\Core\Bounce\Service; use Webklex\PHPIMAP\Client; use Webklex\PHPIMAP\ClientManager; diff --git a/src/Core/BounceProcessorPass.php b/src/Core/BounceProcessorPass.php index 2ab5c9c5..6ec27dae 100644 --- a/src/Core/BounceProcessorPass.php +++ b/src/Core/BounceProcessorPass.php @@ -4,9 +4,9 @@ namespace PhpList\Core\Core; -use PhpList\Core\Domain\Messaging\Service\BounceProcessingServiceInterface; -use PhpList\Core\Domain\Messaging\Service\NativeBounceProcessingService; -use PhpList\Core\Domain\Messaging\Service\WebklexBounceProcessingService; +use PhpList\Core\Bounce\Service\BounceProcessingServiceInterface; +use PhpList\Core\Bounce\Service\NativeBounceProcessingService; +use PhpList\Core\Bounce\Service\WebklexBounceProcessingService; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; diff --git a/src/Domain/Analytics/Service/LinkTrackService.php b/src/Domain/Analytics/Service/LinkTrackService.php index 75242104..ad2df206 100644 --- a/src/Domain/Analytics/Service/LinkTrackService.php +++ b/src/Domain/Analytics/Service/LinkTrackService.php @@ -72,7 +72,7 @@ public function extractAndSaveLinks(Message $message, int $userId): array $linkTrack->setUserId($userId); $linkTrack->setUrl($url); - $this->linkTrackRepository->save($linkTrack); + $this->linkTrackRepository->persist($linkTrack); $savedLinks[] = $linkTrack; } diff --git a/src/Domain/Common/Repository/AbstractRepository.php b/src/Domain/Common/Repository/AbstractRepository.php index 284ef544..bfefd054 100644 --- a/src/Domain/Common/Repository/AbstractRepository.php +++ b/src/Domain/Common/Repository/AbstractRepository.php @@ -31,6 +31,11 @@ public function save(DomainModel $model): void $this->getEntityManager()->flush(); } + public function persist(DomainModel $model): void + { + $this->getEntityManager()->persist($model); + } + /** * Removes $model and flushes the entity manager change list. * @@ -41,9 +46,14 @@ public function save(DomainModel $model): void * * @return void */ - public function remove(DomainModel $model): void + public function delete(DomainModel $model): void { $this->getEntityManager()->remove($model); $this->getEntityManager()->flush(); } + + public function remove(DomainModel $model): void + { + $this->getEntityManager()->remove($model); + } } diff --git a/src/Domain/Configuration/Service/Manager/ConfigManager.php b/src/Domain/Configuration/Service/Manager/ConfigManager.php index cae380be..a61aa7d2 100644 --- a/src/Domain/Configuration/Service/Manager/ConfigManager.php +++ b/src/Domain/Configuration/Service/Manager/ConfigManager.php @@ -45,8 +45,6 @@ public function update(Config $config, string $value): void throw new ConfigNotEditableException($config->getKey()); } $config->setValue($value); - - $this->configRepository->save($config); } public function create(string $key, string $value, bool $editable, ?string $type = null): void @@ -57,7 +55,7 @@ public function create(string $key, string $value, bool $editable, ?string $type ->setEditable($editable) ->setType($type); - $this->configRepository->save($config); + $this->configRepository->persist($config); } public function delete(Config $config): void diff --git a/src/Domain/Identity/Command/CleanUpOldSessionTokens.php b/src/Domain/Identity/Command/CleanUpOldSessionTokens.php index 364d5ea9..820db648 100644 --- a/src/Domain/Identity/Command/CleanUpOldSessionTokens.php +++ b/src/Domain/Identity/Command/CleanUpOldSessionTokens.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Domain\Identity\Command; +use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -18,11 +19,13 @@ class CleanUpOldSessionTokens extends Command { private AdministratorTokenRepository $tokenRepository; + private EntityManagerInterface $entityManager; - public function __construct(AdministratorTokenRepository $tokenRepository) + public function __construct(AdministratorTokenRepository $tokenRepository, EntityManagerInterface $entityManager) { parent::__construct(); $this->tokenRepository = $tokenRepository; + $this->entityManager = $entityManager; } /** @@ -31,7 +34,17 @@ public function __construct(AdministratorTokenRepository $tokenRepository) protected function execute(InputInterface $input, OutputInterface $output): int { try { - $deletedCount = $this->tokenRepository->removeExpired(); + $expiredTokens = $this->tokenRepository->getExpired(); + + $deletedCount = 0; + + foreach ($expiredTokens as $token) { + $this->entityManager->remove($token); + $deletedCount++; + } + + $this->entityManager->flush(); + $output->writeln(sprintf('Successfully removed %d expired session token(s).', $deletedCount)); } catch (Throwable $throwable) { $output->writeln(sprintf('Error removing expired session tokens: %s', $throwable->getMessage())); diff --git a/src/Domain/Identity/Repository/AdministratorTokenRepository.php b/src/Domain/Identity/Repository/AdministratorTokenRepository.php index fef697cd..811f3bd1 100644 --- a/src/Domain/Identity/Repository/AdministratorTokenRepository.php +++ b/src/Domain/Identity/Repository/AdministratorTokenRepository.php @@ -8,6 +8,7 @@ use DateTimeImmutable; use DateTimeZone; use Doctrine\Common\Collections\Criteria; +use Exception; use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; @@ -45,31 +46,19 @@ public function findOneUnexpiredByKey(string $key): ?AdministratorToken } /** - * Removes all expired tokens. + * Get all expired tokens. * - * This method should be called regularly to clean up the tokens. - * - * @return int the number of removed tokens + * @return AdministratorToken[] + * @throws Exception */ - public function removeExpired(): int + public function getExpired(): array { $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); - $expiredTokens = $this->createQueryBuilder('at') + return $this->createQueryBuilder('at') ->where('at.expiry <= :date') ->setParameter('date', $now) ->getQuery() ->getResult(); - - $deletedCount = 0; - - foreach ($expiredTokens as $token) { - $this->getEntityManager()->remove($token); - $deletedCount++; - } - - $this->getEntityManager()->flush(); - - return $deletedCount; } } diff --git a/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php b/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php index 24a70de8..e868cbc4 100644 --- a/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php +++ b/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php @@ -45,7 +45,7 @@ public function create(AdminAttributeDefinitionDto $attributeDefinitionDto): Adm ->setDefaultValue($attributeDefinitionDto->defaultValue) ->setTableName($attributeDefinitionDto->tableName); - $this->definitionRepository->save($attributeDefinition); + $this->definitionRepository->persist($attributeDefinition); return $attributeDefinition; } @@ -68,8 +68,6 @@ public function update( ->setDefaultValue($attributeDefinitionDto->defaultValue) ->setTableName($attributeDefinitionDto->tableName); - $this->definitionRepository->save($attributeDefinition); - return $attributeDefinition; } diff --git a/src/Domain/Identity/Service/AdminAttributeManager.php b/src/Domain/Identity/Service/AdminAttributeManager.php index b5cb13f5..53fa330e 100644 --- a/src/Domain/Identity/Service/AdminAttributeManager.php +++ b/src/Domain/Identity/Service/AdminAttributeManager.php @@ -39,7 +39,7 @@ public function createOrUpdate( } $adminAttribute->setValue($value); - $this->attributeRepository->save($adminAttribute); + $this->attributeRepository->persist($adminAttribute); return $adminAttribute; } diff --git a/src/Domain/Identity/Service/AdministratorManager.php b/src/Domain/Identity/Service/AdministratorManager.php index 82d3d36f..814557a1 100644 --- a/src/Domain/Identity/Service/AdministratorManager.php +++ b/src/Domain/Identity/Service/AdministratorManager.php @@ -32,7 +32,6 @@ public function createAdministrator(CreateAdministratorDto $dto): Administrator $administrator->setPrivilegesFromArray($dto->privileges); $this->entityManager->persist($administrator); - $this->entityManager->flush(); return $administrator; } @@ -53,13 +52,10 @@ public function updateAdministrator(Administrator $administrator, UpdateAdminist $administrator->setPasswordHash($hashedPassword); } $administrator->setPrivilegesFromArray($dto->privileges); - - $this->entityManager->flush(); } public function deleteAdministrator(Administrator $administrator): void { $this->entityManager->remove($administrator); - $this->entityManager->flush(); } } diff --git a/src/Domain/Identity/Service/PasswordManager.php b/src/Domain/Identity/Service/PasswordManager.php index 36c88570..35d5d1ff 100644 --- a/src/Domain/Identity/Service/PasswordManager.php +++ b/src/Domain/Identity/Service/PasswordManager.php @@ -65,7 +65,7 @@ public function generatePasswordResetToken(string $email): string $expiryDate = new DateTime(self::TOKEN_EXPIRY); $passwordRequest = new AdminPasswordRequest(date: $expiryDate, admin: $administrator, keyValue: $token); - $this->passwordRequestRepository->save($passwordRequest); + $this->passwordRequestRepository->persist($passwordRequest); $message = new PasswordResetMessage(email: $email, token: $token); $this->messageBus->dispatch($message); @@ -113,7 +113,7 @@ public function updatePasswordWithToken(string $token, string $newPassword): boo $passwordHash = $this->hashGenerator->createPasswordHash($newPassword); $administrator->setPasswordHash($passwordHash); - $this->administratorRepository->save($administrator); + $this->administratorRepository->persist($administrator); $passwordRequest = $this->passwordRequestRepository->findOneByToken($token); $this->passwordRequestRepository->remove($passwordRequest); diff --git a/src/Domain/Identity/Service/SessionManager.php b/src/Domain/Identity/Service/SessionManager.php index 105d3645..658799f7 100644 --- a/src/Domain/Identity/Service/SessionManager.php +++ b/src/Domain/Identity/Service/SessionManager.php @@ -51,7 +51,7 @@ public function createSession(string $loginName, string $password): Administrato $token->setAdministrator($administrator); $token->generateExpiry(); $token->generateKey(); - $this->tokenRepository->save($token); + $this->tokenRepository->persist($token); return $token; } diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php index 7ed9c0b5..600246cb 100644 --- a/src/Domain/Messaging/Command/ProcessQueueCommand.php +++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php @@ -5,20 +5,25 @@ namespace PhpList\Core\Domain\Messaging\Command; use DateTimeImmutable; +use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; +use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage; use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; -use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor; 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 Symfony\Component\Lock\LockFactory; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Contracts\Translation\TranslatorInterface; use Throwable; +/** + * @SuppressWarnings("PHPMD.CouplingBetweenObjects") + */ #[AsCommand( name: 'phplist:process-queue', description: 'Processes the email campaign queue.' @@ -28,25 +33,28 @@ class ProcessQueueCommand extends Command private MessageRepository $messageRepository; private LockFactory $lockFactory; private MessageProcessingPreparator $messagePreparator; - private CampaignProcessor $campaignProcessor; + private MessageBusInterface $messageBus; private ConfigProvider $configProvider; private TranslatorInterface $translator; + private EntityManagerInterface $entityManager; public function __construct( MessageRepository $messageRepository, LockFactory $lockFactory, MessageProcessingPreparator $messagePreparator, - CampaignProcessor $campaignProcessor, + MessageBusInterface $messageBus, ConfigProvider $configProvider, - TranslatorInterface $translator + TranslatorInterface $translator, + EntityManagerInterface $entityManager, ) { parent::__construct(); $this->messageRepository = $messageRepository; $this->lockFactory = $lockFactory; $this->messagePreparator = $messagePreparator; - $this->campaignProcessor = $campaignProcessor; + $this->messageBus = $messageBus; $this->configProvider = $configProvider; $this->translator = $translator; + $this->entityManager = $entityManager; } /** @@ -73,13 +81,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->messagePreparator->ensureSubscribersHaveUuid($output); $this->messagePreparator->ensureCampaignsHaveUuid($output); - $campaigns = $this->messageRepository->getByStatusAndEmbargo( - status: MessageStatus::Submitted, - embargo: new DateTimeImmutable() - ); + $this->entityManager->flush(); + } catch (Throwable $throwable) { + $output->writeln($throwable->getMessage()); + $lock->release(); + return Command::FAILURE; + } + + $campaigns = $this->messageRepository->getByStatusAndEmbargo( + status: MessageStatus::Submitted, + embargo: new DateTimeImmutable() + ); + + try { foreach ($campaigns as $campaign) { - $this->campaignProcessor->process($campaign, $output); + $this->messageBus->dispatch(new CampaignProcessorMessage(messageId: $campaign->getId())); } } catch (Throwable $throwable) { $output->writeln($throwable->getMessage()); diff --git a/src/Domain/Messaging/Message/CampaignProcessorMessage.php b/src/Domain/Messaging/Message/CampaignProcessorMessage.php new file mode 100644 index 00000000..e7680a44 --- /dev/null +++ b/src/Domain/Messaging/Message/CampaignProcessorMessage.php @@ -0,0 +1,20 @@ +messageId = $messageId; + } + + public function getMessageId(): int + { + return $this->messageId; + } +} diff --git a/src/Domain/Messaging/Message/SyncCampaignProcessorMessage.php b/src/Domain/Messaging/Message/SyncCampaignProcessorMessage.php new file mode 100644 index 00000000..05be675f --- /dev/null +++ b/src/Domain/Messaging/Message/SyncCampaignProcessorMessage.php @@ -0,0 +1,20 @@ +messageId = $messageId; + } + + public function getMessageId(): int + { + return $this->messageId; + } +} diff --git a/src/Domain/Messaging/Service/Processor/CampaignProcessor.php b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php similarity index 59% rename from src/Domain/Messaging/Service/Processor/CampaignProcessor.php rename to src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php index e16d4246..82e1e664 100644 --- a/src/Domain/Messaging/Service/Processor/CampaignProcessor.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php @@ -2,23 +2,26 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service\Processor; +namespace PhpList\Core\Domain\Messaging\MessageHandler; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage; +use PhpList\Core\Domain\Messaging\Message\SyncCampaignProcessorMessage; use PhpList\Core\Domain\Messaging\Model\Message; -use PhpList\Core\Domain\Messaging\Model\UserMessage; -use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus; use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; +use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus; +use PhpList\Core\Domain\Messaging\Model\UserMessage; +use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; -use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer; use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; +use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer; +use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider; -use PhpList\Core\Domain\Subscription\Model\Subscriber; use Psr\Log\LoggerInterface; -use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Contracts\Translation\TranslatorInterface; use Throwable; @@ -27,7 +30,8 @@ * @SuppressWarnings(PHPMD.StaticAccess) * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ -class CampaignProcessor +#[AsMessageHandler] +class CampaignProcessorMessageHandler { private RateLimitedCampaignMailer $mailer; private EntityManagerInterface $entityManager; @@ -39,6 +43,7 @@ class CampaignProcessor private RequeueHandler $requeueHandler; private TranslatorInterface $translator; private SubscriberHistoryManager $subscriberHistoryManager; + private MessageRepository $messageRepository; public function __construct( RateLimitedCampaignMailer $mailer, @@ -51,6 +56,7 @@ public function __construct( RequeueHandler $requeueHandler, TranslatorInterface $translator, SubscriberHistoryManager $subscriberHistoryManager, + MessageRepository $messageRepository, ) { $this->mailer = $mailer; $this->entityManager = $entityManager; @@ -62,10 +68,21 @@ public function __construct( $this->requeueHandler = $requeueHandler; $this->translator = $translator; $this->subscriberHistoryManager = $subscriberHistoryManager; + $this->messageRepository = $messageRepository; } - public function process(Message $campaign, ?OutputInterface $output = null): void + public function __invoke(CampaignProcessorMessage|SyncCampaignProcessorMessage $message): void { + $campaign = $this->messageRepository->findByIdAndStatus($message->getMessageId(), MessageStatus::Submitted); + if (!$campaign) { + $this->logger->warning( + $this->translator->trans('Campaign not found or not in submitted status'), + ['campaign_id' => $message->getMessageId()] + ); + + return; + } + $this->updateMessageStatus($campaign, MessageStatus::Prepared); $subscribers = $this->subscriberProvider->getSubscribersForMessage($campaign); @@ -75,7 +92,7 @@ public function process(Message $campaign, ?OutputInterface $output = null): voi $stoppedEarly = false; foreach ($subscribers as $subscriber) { - if ($this->timeLimiter->shouldStop($output)) { + if ($this->timeLimiter->shouldStop()) { $stoppedEarly = true; break; } @@ -90,41 +107,16 @@ public function process(Message $campaign, ?OutputInterface $output = null): voi $this->userMessageRepository->save($userMessage); if (!filter_var($subscriber->getEmail(), FILTER_VALIDATE_EMAIL)) { - $this->updateUserMessageStatus($userMessage, UserMessageStatus::InvalidEmailAddress); - $this->unconfirmSubscriber($subscriber); - $output?->writeln($this->translator->trans('Invalid email, marking unconfirmed: %email%', [ - '%email%' => $subscriber->getEmail(), - ])); - $this->subscriberHistoryManager->addHistory( - subscriber: $subscriber, - message: $this->translator->trans('Subscriber marked unconfirmed for invalid email address'), - details: $this->translator->trans( - 'Marked unconfirmed while sending campaign %message_id%', - ['%message_id%' => $campaign->getId()] - ) - ); + $this->handleInvalidEmail($userMessage, $subscriber, $campaign); + $this->entityManager->flush(); continue; } - $processed = $this->messagePreparator->processMessageLinks($campaign, $subscriber->getId()); - - try { - $email = $this->mailer->composeEmail($processed, $subscriber); - $this->mailer->send($email); - $this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent); - } catch (Throwable $e) { - $this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent); - $this->logger->error($e->getMessage(), [ - 'subscriber_id' => $subscriber->getId(), - 'campaign_id' => $campaign->getId(), - ]); - $output?->writeln($this->translator->trans('Failed to send to: %email%', [ - '%email%' => $subscriber->getEmail(), - ])); - } + $this->handleEmailSending($campaign, $subscriber, $userMessage); } - if ($stoppedEarly && $this->requeueHandler->handle($campaign, $output)) { + if ($stoppedEarly && $this->requeueHandler->handle($campaign)) { + $this->entityManager->flush(); return; } @@ -150,4 +142,41 @@ private function updateUserMessageStatus(UserMessage $userMessage, UserMessageSt $userMessage->setStatus($status); $this->entityManager->flush(); } + + private function handleInvalidEmail(UserMessage $userMessage, Subscriber $subscriber, mixed $campaign): void + { + $this->updateUserMessageStatus($userMessage, UserMessageStatus::InvalidEmailAddress); + $this->unconfirmSubscriber($subscriber); + $this->logger->warning($this->translator->trans('Invalid email, marking unconfirmed: %email%', [ + '%email%' => $subscriber->getEmail(), + ])); + $this->subscriberHistoryManager->addHistory( + subscriber: $subscriber, + message: $this->translator->trans('Subscriber marked unconfirmed for invalid email address'), + details: $this->translator->trans( + 'Marked unconfirmed while sending campaign %message_id%', + ['%message_id%' => $campaign->getId()] + ) + ); + } + + private function handleEmailSending(mixed $campaign, Subscriber $subscriber, UserMessage $userMessage): void + { + $processed = $this->messagePreparator->processMessageLinks($campaign, $subscriber->getId()); + + try { + $email = $this->mailer->composeEmail($processed, $subscriber); + $this->mailer->send($email); + $this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent); + } catch (Throwable $e) { + $this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent); + $this->logger->error($e->getMessage(), [ + 'subscriber_id' => $subscriber->getId(), + 'campaign_id' => $campaign->getId(), + ]); + $this->logger->warning($this->translator->trans('Failed to send to: %email%', [ + '%email%' => $subscriber->getEmail(), + ])); + } + } } diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php index 0ae8bc18..30d5f1fd 100644 --- a/src/Domain/Messaging/Repository/MessageRepository.php +++ b/src/Domain/Messaging/Repository/MessageRepository.php @@ -69,7 +69,7 @@ public function incrementBounceCount(int $messageId): void { $this->createQueryBuilder('m') ->update() - ->set('m.bounceCount', 'm.bounceCount + 1') + ->set('m.metadata.bounceCount', 'm.bounceCount + 1') ->where('m.id = :messageId') ->setParameter('messageId', $messageId) ->getQuery() @@ -79,11 +79,22 @@ public function incrementBounceCount(int $messageId): void public function getByStatusAndEmbargo(Message\MessageStatus $status, DateTimeImmutable $embargo): array { return $this->createQueryBuilder('m') - ->where('m.status = :status') - ->andWhere('m.embargo IS NULL OR m.embargo <= :embargo') + ->where('m.metadata.status = :status') + ->andWhere('m.schedule.embargo IS NULL OR m.embargo <= :embargo') ->setParameter('status', $status->value) ->setParameter('embargo', $embargo) ->getQuery() ->getResult(); } + + public function findByIdAndStatus(int $id, Message\MessageStatus $status) + { + return $this->createQueryBuilder('m') + ->where('m.id = :id') + ->andWhere('m.metadata.status = :status') + ->setParameter('id', $id) + ->setParameter('status', $status->value) + ->getQuery() + ->getOneOrNullResult(); + } } diff --git a/src/Domain/Messaging/Service/Handler/RequeueHandler.php b/src/Domain/Messaging/Service/Handler/RequeueHandler.php index ddd0035d..3fbca634 100644 --- a/src/Domain/Messaging/Service/Handler/RequeueHandler.php +++ b/src/Domain/Messaging/Service/Handler/RequeueHandler.php @@ -6,7 +6,6 @@ use DateInterval; use DateTime; -use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; use Psr\Log\LoggerInterface; @@ -17,7 +16,6 @@ class RequeueHandler { public function __construct( private readonly LoggerInterface $logger, - private readonly EntityManagerInterface $entityManager, private readonly TranslatorInterface $translator, ) { } @@ -46,7 +44,6 @@ public function handle(Message $campaign, ?OutputInterface $output = null): bool $schedule->setEmbargo($next); $campaign->setSchedule($schedule); $campaign->getMetadata()->setStatus(MessageStatus::Submitted); - $this->entityManager->flush(); $output?->writeln($this->translator->trans( 'Requeued campaign; next embargo at %time%', diff --git a/src/Domain/Messaging/Service/Manager/BounceRegexManager.php b/src/Domain/Messaging/Service/Manager/BounceRegexManager.php index c9d60580..3ebcc539 100644 --- a/src/Domain/Messaging/Service/Manager/BounceRegexManager.php +++ b/src/Domain/Messaging/Service/Manager/BounceRegexManager.php @@ -46,8 +46,6 @@ public function createOrUpdateFromPattern( ->setComment($comment ?? $existing->getComment()) ->setStatus($status ?? $existing->getStatus()); - $this->bounceRegexRepository->save($existing); - return $existing; } @@ -62,7 +60,7 @@ public function createOrUpdateFromPattern( count: 0 ); - $this->bounceRegexRepository->save($bounceRegex); + $this->bounceRegexRepository->persist($bounceRegex); return $bounceRegex; } @@ -92,7 +90,6 @@ public function associateBounce(BounceRegex $regex, Bounce $bounce): BounceRegex $this->entityManager->persist($relation); $regex->setCount(($regex->getCount() ?? 0) + 1); - $this->entityManager->flush(); return $relation; } diff --git a/src/Domain/Messaging/Service/Manager/ListMessageManager.php b/src/Domain/Messaging/Service/Manager/ListMessageManager.php index 22176113..c481e5f8 100644 --- a/src/Domain/Messaging/Service/Manager/ListMessageManager.php +++ b/src/Domain/Messaging/Service/Manager/ListMessageManager.php @@ -43,7 +43,6 @@ public function associateMessageWithList(Message $message, SubscriberList $subsc $listMessage->setEntered(new DateTime()); $this->entityManager->persist($listMessage); - $this->entityManager->flush(); return $listMessage; } diff --git a/src/Domain/Messaging/Service/Manager/MessageManager.php b/src/Domain/Messaging/Service/Manager/MessageManager.php index c73f31ca..c11d34d9 100644 --- a/src/Domain/Messaging/Service/Manager/MessageManager.php +++ b/src/Domain/Messaging/Service/Manager/MessageManager.php @@ -26,7 +26,7 @@ public function createMessage(MessageDtoInterface $createMessageDto, Administrat { $context = new MessageContext($authUser); $message = $this->messageBuilder->build($createMessageDto, $context); - $this->messageRepository->save($message); + $this->messageRepository->persist($message); return $message; } @@ -37,16 +37,12 @@ public function updateMessage( Administrator $authUser ): Message { $context = new MessageContext($authUser, $message); - $message = $this->messageBuilder->build($updateMessageDto, $context); - $this->messageRepository->save($message); - - return $message; + return $this->messageBuilder->build($updateMessageDto, $context); } public function updateStatus(Message $message, Message\MessageStatus $status): Message { $message->getMetadata()->setStatus($status); - $this->messageRepository->save($message); return $message; } diff --git a/src/Domain/Messaging/Service/Manager/SendProcessManager.php b/src/Domain/Messaging/Service/Manager/SendProcessManager.php index 0100ed29..23d16761 100644 --- a/src/Domain/Messaging/Service/Manager/SendProcessManager.php +++ b/src/Domain/Messaging/Service/Manager/SendProcessManager.php @@ -29,7 +29,6 @@ public function create(string $page, string $processIdentifier): SendProcess $sendProcess->setPage($page); $this->entityManager->persist($sendProcess); - $this->entityManager->flush(); return $sendProcess; } diff --git a/src/Domain/Messaging/Service/Manager/TemplateImageManager.php b/src/Domain/Messaging/Service/Manager/TemplateImageManager.php index 30705715..68b04f40 100644 --- a/src/Domain/Messaging/Service/Manager/TemplateImageManager.php +++ b/src/Domain/Messaging/Service/Manager/TemplateImageManager.php @@ -50,8 +50,6 @@ public function createImagesFromImagePaths(array $imagePaths, Template $template $templateImages[] = $image; } - $this->entityManager->flush(); - return $templateImages; } diff --git a/src/Domain/Messaging/Service/Manager/TemplateManager.php b/src/Domain/Messaging/Service/Manager/TemplateManager.php index 7de31843..80447d8b 100644 --- a/src/Domain/Messaging/Service/Manager/TemplateManager.php +++ b/src/Domain/Messaging/Service/Manager/TemplateManager.php @@ -4,7 +4,6 @@ namespace PhpList\Core\Domain\Messaging\Service\Manager; -use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Common\Model\ValidationContext; use PhpList\Core\Domain\Messaging\Model\Dto\CreateTemplateDto; use PhpList\Core\Domain\Messaging\Model\Template; @@ -17,20 +16,17 @@ class TemplateManager { private TemplateRepository $templateRepository; - private EntityManagerInterface $entityManager; private TemplateImageManager $templateImageManager; private TemplateLinkValidator $templateLinkValidator; private TemplateImageValidator $templateImageValidator; public function __construct( TemplateRepository $templateRepository, - EntityManagerInterface $entityManager, TemplateImageManager $templateImageManager, TemplateLinkValidator $templateLinkValidator, TemplateImageValidator $templateImageValidator ) { $this->templateRepository = $templateRepository; - $this->entityManager = $entityManager; $this->templateImageManager = $templateImageManager; $this->templateLinkValidator = $templateLinkValidator; $this->templateImageValidator = $templateImageValidator; @@ -56,7 +52,7 @@ public function create(CreateTemplateDto $createTemplateDto): Template $imageUrls = $this->templateImageManager->extractAllImages($template->getContent() ?? ''); $this->templateImageValidator->validate($imageUrls, $context); - $this->templateRepository->save($template); + $this->templateRepository->persist($template); $this->templateImageManager->createImagesFromImagePaths($imageUrls, $template); @@ -75,8 +71,6 @@ public function update(UpdateSubscriberDto $updateSubscriberDto): Subscriber $subscriber->setDisabled($updateSubscriberDto->disabled); $subscriber->setExtraData($updateSubscriberDto->additionalData); - $this->entityManager->flush(); - return $subscriber; } diff --git a/src/Domain/Messaging/Service/MessageProcessingPreparator.php b/src/Domain/Messaging/Service/MessageProcessingPreparator.php index 9faa72fb..5255914d 100644 --- a/src/Domain/Messaging/Service/MessageProcessingPreparator.php +++ b/src/Domain/Messaging/Service/MessageProcessingPreparator.php @@ -4,7 +4,6 @@ namespace PhpList\Core\Domain\Messaging\Service; -use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Analytics\Service\LinkTrackService; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; @@ -17,20 +16,17 @@ class MessageProcessingPreparator // phpcs:ignore Generic.Commenting.Todo // @todo: create functionality to track public const LINT_TRACK_ENDPOINT = '/api/v2/link-track'; - private EntityManagerInterface $entityManager; private SubscriberRepository $subscriberRepository; private MessageRepository $messageRepository; private LinkTrackService $linkTrackService; private TranslatorInterface $translator; public function __construct( - EntityManagerInterface $entityManager, SubscriberRepository $subscriberRepository, MessageRepository $messageRepository, LinkTrackService $linkTrackService, TranslatorInterface $translator, ) { - $this->entityManager = $entityManager; $this->subscriberRepository = $subscriberRepository; $this->messageRepository = $messageRepository; $this->linkTrackService = $linkTrackService; @@ -49,7 +45,6 @@ public function ensureSubscribersHaveUuid(OutputInterface $output): void foreach ($subscribersWithoutUuid as $subscriber) { $subscriber->setUniqueId(bin2hex(random_bytes(16))); } - $this->entityManager->flush(); } } @@ -65,7 +60,6 @@ public function ensureCampaignsHaveUuid(OutputInterface $output): void foreach ($campaignsWithoutUuid as $campaign) { $campaign->setUuid(bin2hex(random_bytes(18))); } - $this->entityManager->flush(); } } diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index 3c3583b4..2ea02474 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -188,4 +188,15 @@ public function distinctUsersWithBouncesConfirmedNotBlacklisted(): array ->getQuery() ->getScalarResult(); } + + public function decrementBounceCount(Subscriber $subscriber): void + { + $this->createQueryBuilder('s') + ->update() + ->set('s.bounceCount', 's.bounceCount - 1') + ->where('s.id = :subscriberId') + ->setParameter('subscriberId', $subscriber->getId()) + ->getQuery() + ->execute(); + } } diff --git a/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php b/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php index d91956c6..5f95640d 100644 --- a/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php +++ b/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php @@ -46,7 +46,7 @@ public function create(AttributeDefinitionDto $attributeDefinitionDto): Subscrib ->setDefaultValue($attributeDefinitionDto->defaultValue) ->setTableName($attributeDefinitionDto->tableName); - $this->definitionRepository->save($attributeDefinition); + $this->definitionRepository->persist($attributeDefinition); return $attributeDefinition; } @@ -72,8 +72,6 @@ public function update( ->setDefaultValue($attributeDefinitionDto->defaultValue) ->setTableName($attributeDefinitionDto->tableName); - $this->definitionRepository->save($attributeDefinition); - return $attributeDefinition; } diff --git a/src/Domain/Subscription/Service/Manager/SubscribePageManager.php b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php index b0017e6c..485041a5 100644 --- a/src/Domain/Subscription/Service/Manager/SubscribePageManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php @@ -33,7 +33,7 @@ public function createPage(string $title, bool $active = false, ?Administrator $ ->setActive($active) ->setOwner($owner); - $this->pageRepository->save($page); + $this->pageRepository->persist($page); return $page; } @@ -65,15 +65,12 @@ public function updatePage( $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 @@ -100,7 +97,6 @@ public function setPageData(SubscribePage $page, string $name, ?string $value): } $data->setData($value); - $this->entityManager->flush(); return $data; } diff --git a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php index d5828c2f..3d124a31 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php @@ -53,8 +53,6 @@ public function addEmailToBlacklist(string $email, ?string $reasonData = null): $this->entityManager->persist($blacklistData); } - $this->entityManager->flush(); - return $blacklistEntry; } @@ -65,7 +63,6 @@ public function addBlacklistData(string $email, string $name, string $data): voi $blacklistData->setName($name); $blacklistData->setData($data); $this->entityManager->persist($blacklistData); - $this->entityManager->flush(); } public function removeEmailFromBlacklist(string $email): void @@ -84,8 +81,6 @@ public function removeEmailFromBlacklist(string $email): void if ($subscriber) { $subscriber->setBlacklisted(false); } - - $this->entityManager->flush(); } public function getBlacklistReason(string $email): ?string diff --git a/src/Domain/Subscription/Service/Manager/SubscriberListManager.php b/src/Domain/Subscription/Service/Manager/SubscriberListManager.php index a56b48eb..9f51f566 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberListManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberListManager.php @@ -29,7 +29,7 @@ public function createSubscriberList( ->setListPosition($subscriberListDto->listPosition) ->setPublic($subscriberListDto->isPublic); - $this->subscriberListRepository->save($subscriberList); + $this->subscriberListRepository->persist($subscriberList); return $subscriberList; } diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php index 40bfcc20..1993cd9b 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php @@ -16,9 +16,6 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Contracts\Translation\TranslatorInterface; -/** - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - */ class SubscriberManager { private SubscriberRepository $subscriberRepository; @@ -51,7 +48,7 @@ public function createSubscriber(CreateSubscriberDto $subscriberDto): Subscriber $subscriber->setHtmlEmail((bool)$subscriberDto->htmlEmail); $subscriber->setDisabled(false); - $this->subscriberRepository->save($subscriber); + $this->subscriberRepository->persist($subscriber); return $subscriber; } @@ -87,7 +84,6 @@ public function updateSubscriber(UpdateSubscriberDto $subscriberDto, Administrat public function resetBounceCount(Subscriber $subscriber): Subscriber { $subscriber->setBounceCount(0); - $this->entityManager->flush(); return $subscriber; } @@ -100,7 +96,6 @@ public function markAsConfirmedByUniqueId(string $uniqueId): Subscriber } $subscriber->setConfirmed(true); - $this->entityManager->flush(); return $subscriber; } @@ -141,10 +136,4 @@ public function updateFromImport(Subscriber $existingSubscriber, ImportSubscribe return ChangeSetDto::fromDoctrineChangeSet($uow->getEntityChangeSet($existingSubscriber)); } - - public function decrementBounceCount(Subscriber $subscriber): void - { - $subscriber->addToBounceCount(-1); - $this->entityManager->flush(); - } } diff --git a/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php b/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php index 25ecf0f8..a5215789 100644 --- a/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php +++ b/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php @@ -177,7 +177,7 @@ public function testRemoveRemovesModel(): void $this->assertNotEmpty($allModels); $model = $allModels[0]; - $this->repository->remove($model); + $this->repository->delete($model); $remainingModels = $this->repository->findAll(); $this->assertCount(count($allModels) - 1, $remainingModels); diff --git a/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php b/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php index 52feeb8c..ff750a63 100644 --- a/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php +++ b/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php @@ -122,56 +122,6 @@ public function testFindOneUnexpiredByKeyNotFindsUnexpiredTokenWithNonMatchingKe self::assertNull($model); } - public function testRemoveExpiredRemovesExpiredToken() - { - $this->loadFixtures([DetachedAdministratorTokenFixture::class]); - - $idOfExpiredToken = 1; - $this->repository->removeExpired(); - - $token = $this->repository->find($idOfExpiredToken); - self::assertNull($token); - } - - public function testRemoveExpiredKeepsUnexpiredToken() - { - $this->assertNotYear2037Yet(); - - $this->loadFixtures([DetachedAdministratorTokenFixture::class]); - - $idOfUnexpiredToken = 2; - $this->repository->removeExpired(); - - $token = $this->repository->find($idOfUnexpiredToken); - self::assertNotNull($token); - } - - /** - * Asserts that it's not year 2037 yet (which is the year the "not expired" token in the fixture - * data set expires). - */ - private function assertNotYear2037Yet(): void - { - $currentYear = (int)date('Y'); - if ($currentYear >= 2037) { - self::markTestIncomplete('The tests token has an expiry in the year 2037. Please update this test.'); - } - } - - public function testRemoveExpiredForNoExpiredTokensReturnsZero() - { - self::assertSame(0, $this->repository->removeExpired()); - } - - public function testRemoveExpiredForOneExpiredTokenReturnsOne() - { - $this->assertNotYear2037Yet(); - - $this->loadFixtures([DetachedAdministratorTokenFixture::class]); - - self::assertSame(1, $this->repository->removeExpired()); - } - public function testSavePersistsAndFlushesModel() { $this->loadFixtures([AdministratorFixture::class]); @@ -196,7 +146,7 @@ public function testRemoveRemovesModel() $numberOfModelsBeforeRemove = count($allModels); $firstModel = $allModels[0]; - $this->repository->remove($firstModel); + $this->repository->delete($firstModel); $numberOfModelsAfterRemove = count($this->repository->findAll()); self::assertSame(1, $numberOfModelsBeforeRemove - $numberOfModelsAfterRemove); diff --git a/tests/Integration/Domain/Messaging/Repository/SubscriberListRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/SubscriberListRepositoryTest.php index 0f122582..93e99a81 100644 --- a/tests/Integration/Domain/Messaging/Repository/SubscriberListRepositoryTest.php +++ b/tests/Integration/Domain/Messaging/Repository/SubscriberListRepositoryTest.php @@ -206,7 +206,7 @@ public function testRemoveRemovesModel() $numberOfModelsBeforeRemove = count($allModels); $firstModel = $allModels[0]; - $this->subscriberListRepository->remove($firstModel); + $this->subscriberListRepository->delete($firstModel); $numberOfModelsAfterRemove = count($this->subscriberListRepository->findAll()); self::assertSame(1, $numberOfModelsBeforeRemove - $numberOfModelsAfterRemove); diff --git a/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php b/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php index 91d871d5..9a2ead68 100644 --- a/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php +++ b/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php @@ -227,7 +227,7 @@ public function testRemoveAlsoRemovesAssociatedSubscriptions() $numberOfAssociatedSubscriptions = count($model->getSubscriptions()); self::assertGreaterThan(0, $numberOfAssociatedSubscriptions); - $this->subscriberRepository->remove($model); + $this->subscriberRepository->delete($model); $newNumberOfSubscriptions = count($this->subscriptionRepository->findAll()); $numberOfRemovedSubscriptions = $initialNumberOfSubscriptions - $newNumberOfSubscriptions; @@ -246,7 +246,7 @@ public function testRemoveRemovesModel() $numberOfModelsBeforeRemove = count($allModels); $firstModel = $allModels[0]; - $this->subscriberRepository->remove($firstModel); + $this->subscriberRepository->delete($firstModel); $numberOfModelsAfterRemove = count($this->subscriberRepository->findAll()); self::assertSame(1, $numberOfModelsBeforeRemove - $numberOfModelsAfterRemove); diff --git a/tests/Integration/Domain/Subscription/Repository/SubscriptionRepositoryTest.php b/tests/Integration/Domain/Subscription/Repository/SubscriptionRepositoryTest.php index de18894c..3086b178 100644 --- a/tests/Integration/Domain/Subscription/Repository/SubscriptionRepositoryTest.php +++ b/tests/Integration/Domain/Subscription/Repository/SubscriptionRepositoryTest.php @@ -161,7 +161,7 @@ public function testRemoveRemovesModel() $numberOfModelsBeforeRemove = count($allModels); $firstModel = $allModels[0]; - $this->subscriptionRepository->remove($firstModel); + $this->subscriptionRepository->delete($firstModel); $numberOfModelsAfterRemove = count($this->subscriptionRepository->findAll()); self::assertSame(1, $numberOfModelsBeforeRemove - $numberOfModelsAfterRemove); diff --git a/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php b/tests/Unit/Bounce/Command/ProcessBouncesCommandTest.php similarity index 93% rename from tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php rename to tests/Unit/Bounce/Command/ProcessBouncesCommandTest.php index 3e8e24b6..b3840dfb 100644 --- a/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php +++ b/tests/Unit/Bounce/Command/ProcessBouncesCommandTest.php @@ -2,15 +2,16 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Command; +namespace PhpList\Core\Tests\Unit\Bounce\Command; +use Doctrine\ORM\EntityManagerInterface; use Exception; -use PhpList\Core\Domain\Messaging\Command\ProcessBouncesCommand; -use PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler; -use PhpList\Core\Domain\Messaging\Service\LockService; -use PhpList\Core\Domain\Messaging\Service\Processor\AdvancedBounceRulesProcessor; -use PhpList\Core\Domain\Messaging\Service\Processor\BounceProtocolProcessor; -use PhpList\Core\Domain\Messaging\Service\Processor\UnidentifiedBounceReprocessor; +use PhpList\Core\Bounce\Command\ProcessBouncesCommand; +use PhpList\Core\Bounce\Service\ConsecutiveBounceHandler; +use PhpList\Core\Bounce\Service\LockService; +use PhpList\Core\Bounce\Service\Processor\AdvancedBounceRulesProcessor; +use PhpList\Core\Bounce\Service\Processor\BounceProtocolProcessor; +use PhpList\Core\Bounce\Service\Processor\UnidentifiedBounceReprocessor; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -48,6 +49,7 @@ protected function setUp(): void unidentifiedReprocessor: $this->unidentifiedReprocessor, consecutiveBounceHandler: $this->consecutiveBounceHandler, translator: $this->translator, + entityManager: $this->createMock(EntityManagerInterface::class), ); $this->commandTester = new CommandTester($command); diff --git a/tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php b/tests/Unit/Bounce/Service/BounceActionResolverTest.php similarity index 91% rename from tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php rename to tests/Unit/Bounce/Service/BounceActionResolverTest.php index 49d4aadb..92b1054d 100644 --- a/tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php +++ b/tests/Unit/Bounce/Service/BounceActionResolverTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; +namespace PhpList\Core\Tests\Unit\Bounce\Service; -use PhpList\Core\Domain\Messaging\Service\BounceActionResolver; -use PhpList\Core\Domain\Messaging\Service\Handler\BounceActionHandlerInterface; +use PhpList\Core\Bounce\Service\BounceActionResolver; +use PhpList\Core\Bounce\Service\Handler\BounceActionHandlerInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use RuntimeException; diff --git a/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php b/tests/Unit/Bounce/Service/ConsecutiveBounceHandlerTest.php similarity index 96% rename from tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php rename to tests/Unit/Bounce/Service/ConsecutiveBounceHandlerTest.php index 5fc375cd..fbfdfa8a 100644 --- a/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php +++ b/tests/Unit/Bounce/Service/ConsecutiveBounceHandlerTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; +namespace PhpList\Core\Tests\Unit\Bounce\Service; +use PhpList\Core\Bounce\Service\ConsecutiveBounceHandler; +use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Bounce\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Messaging\Model\Bounce; -use PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Style\SymfonyStyle; diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php b/tests/Unit/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php similarity index 90% rename from tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php rename to tests/Unit/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php index cc0ff38d..c7c2260d 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php +++ b/tests/Unit/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; +use PhpList\Core\Bounce\Service\Handler\BlacklistEmailAndDeleteBounceHandler; +use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Bounce\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Messaging\Model\Bounce; -use PhpList\Core\Domain\Messaging\Service\Handler\BlacklistEmailAndDeleteBounceHandler; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Translation\Translator; diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php b/tests/Unit/Bounce/Service/Handler/BlacklistEmailHandlerTest.php similarity index 91% rename from tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php rename to tests/Unit/Bounce/Service/Handler/BlacklistEmailHandlerTest.php index cb009022..b5b06e59 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php +++ b/tests/Unit/Bounce/Service/Handler/BlacklistEmailHandlerTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; -use PhpList\Core\Domain\Messaging\Service\Handler\BlacklistEmailHandler; +use PhpList\Core\Bounce\Service\Handler\BlacklistEmailHandler; +use PhpList\Core\Bounce\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Translation\Translator; diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php b/tests/Unit/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php similarity index 91% rename from tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php rename to tests/Unit/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php index 0368d695..e2975d37 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php +++ b/tests/Unit/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; +use PhpList\Core\Bounce\Service\Handler\BlacklistUserAndDeleteBounceHandler; +use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Bounce\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Messaging\Model\Bounce; -use PhpList\Core\Domain\Messaging\Service\Handler\BlacklistUserAndDeleteBounceHandler; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Translation\Translator; diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php b/tests/Unit/Bounce/Service/Handler/BlacklistUserHandlerTest.php similarity index 92% rename from tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php rename to tests/Unit/Bounce/Service/Handler/BlacklistUserHandlerTest.php index e25f54c8..153faa1c 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php +++ b/tests/Unit/Bounce/Service/Handler/BlacklistUserHandlerTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; -use PhpList\Core\Domain\Messaging\Service\Handler\BlacklistUserHandler; +use PhpList\Core\Bounce\Service\Handler\BlacklistUserHandler; +use PhpList\Core\Bounce\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Translation\Translator; diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Bounce/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php similarity index 81% rename from tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php rename to tests/Unit/Bounce/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php index 34d707e5..9625d348 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php +++ b/tests/Unit/Bounce/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php @@ -2,15 +2,14 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; +use PhpList\Core\Bounce\Service\Handler\DecreaseCountConfirmUserAndDeleteBounceHandler; +use PhpList\Core\Bounce\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\Bounce; -use PhpList\Core\Domain\Messaging\Service\Handler\DecreaseCountConfirmUserAndDeleteBounceHandler; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; -use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Translation\Translator; @@ -18,7 +17,6 @@ class DecreaseCountConfirmUserAndDeleteBounceHandlerTest extends TestCase { private SubscriberHistoryManager&MockObject $historyManager; - private SubscriberManager&MockObject $subscriberManager; private BounceManager&MockObject $bounceManager; private SubscriberRepository&MockObject $subscriberRepository; private DecreaseCountConfirmUserAndDeleteBounceHandler $handler; @@ -26,12 +24,10 @@ class DecreaseCountConfirmUserAndDeleteBounceHandlerTest extends TestCase protected function setUp(): void { $this->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, translator: new Translator('en'), @@ -50,7 +46,7 @@ public function testHandleDecrementsMarksConfirmedAddsHistoryAndDeletesWhenNotCo $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('decrementBounceCount')->with($subscriber); $this->subscriberRepository->expects($this->once())->method('markConfirmed')->with(11); $this->historyManager->expects($this->once())->method('addHistory')->with( $subscriber, @@ -73,7 +69,7 @@ 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->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); @@ -91,7 +87,7 @@ public function testHandleDeletesBounceEvenWithoutSubscriber(): void { $bounce = $this->createMock(Bounce::class); - $this->subscriberManager->expects($this->never())->method('decrementBounceCount'); + $this->subscriberRepository->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); diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php b/tests/Unit/Bounce/Service/Handler/DeleteBounceHandlerTest.php similarity index 83% rename from tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php rename to tests/Unit/Bounce/Service/Handler/DeleteBounceHandlerTest.php index 25028345..1455ab83 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php +++ b/tests/Unit/Bounce/Service/Handler/DeleteBounceHandlerTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; +use PhpList\Core\Bounce\Service\Handler\DeleteBounceHandler; +use PhpList\Core\Bounce\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\Bounce; -use PhpList\Core\Domain\Messaging\Service\Handler\DeleteBounceHandler; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php b/tests/Unit/Bounce/Service/Handler/DeleteUserAndBounceHandlerTest.php similarity index 91% rename from tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php rename to tests/Unit/Bounce/Service/Handler/DeleteUserAndBounceHandlerTest.php index 0d68b631..768efd0c 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php +++ b/tests/Unit/Bounce/Service/Handler/DeleteUserAndBounceHandlerTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; +use PhpList\Core\Bounce\Service\Handler\DeleteUserAndBounceHandler; +use PhpList\Core\Bounce\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\Bounce; -use PhpList\Core\Domain\Messaging\Service\Handler\DeleteUserAndBounceHandler; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PHPUnit\Framework\MockObject\MockObject; diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php b/tests/Unit/Bounce/Service/Handler/DeleteUserHandlerTest.php similarity index 94% rename from tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php rename to tests/Unit/Bounce/Service/Handler/DeleteUserHandlerTest.php index 427f8146..af61b8d5 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php +++ b/tests/Unit/Bounce/Service/Handler/DeleteUserHandlerTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; -use PhpList\Core\Domain\Messaging\Service\Handler\DeleteUserHandler; +use PhpList\Core\Bounce\Service\Handler\DeleteUserHandler; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PHPUnit\Framework\MockObject\MockObject; diff --git a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php similarity index 93% rename from tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php rename to tests/Unit/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php index 6ddc4e3d..92b146fb 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php +++ b/tests/Unit/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; +use PhpList\Core\Bounce\Service\Handler\UnconfirmUserAndDeleteBounceHandler; +use PhpList\Core\Bounce\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\Bounce; -use PhpList\Core\Domain\Messaging\Service\Handler\UnconfirmUserAndDeleteBounceHandler; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; diff --git a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php b/tests/Unit/Bounce/Service/Handler/UnconfirmUserHandlerTest.php similarity index 94% rename from tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php rename to tests/Unit/Bounce/Service/Handler/UnconfirmUserHandlerTest.php index fbbc265a..dcc0c0d8 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php +++ b/tests/Unit/Bounce/Service/Handler/UnconfirmUserHandlerTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; +namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; -use PhpList\Core\Domain\Messaging\Service\Handler\UnconfirmUserHandler; +use PhpList\Core\Bounce\Service\Handler\UnconfirmUserHandler; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; diff --git a/tests/Unit/Domain/Messaging/Service/LockServiceTest.php b/tests/Unit/Bounce/Service/LockServiceTest.php similarity index 96% rename from tests/Unit/Domain/Messaging/Service/LockServiceTest.php rename to tests/Unit/Bounce/Service/LockServiceTest.php index 8851d7de..8577ef5f 100644 --- a/tests/Unit/Domain/Messaging/Service/LockServiceTest.php +++ b/tests/Unit/Bounce/Service/LockServiceTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; +namespace PhpList\Core\Tests\Unit\Bounce\Service; +use PhpList\Core\Bounce\Service\LockService; use PhpList\Core\Domain\Messaging\Model\SendProcess; use PhpList\Core\Domain\Messaging\Repository\SendProcessRepository; -use PhpList\Core\Domain\Messaging\Service\LockService; use PhpList\Core\Domain\Messaging\Service\Manager\SendProcessManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Messaging/Service/MessageParserTest.php b/tests/Unit/Bounce/Service/MessageParserTest.php similarity index 95% rename from tests/Unit/Domain/Messaging/Service/MessageParserTest.php rename to tests/Unit/Bounce/Service/MessageParserTest.php index 49b38615..35e60706 100644 --- a/tests/Unit/Domain/Messaging/Service/MessageParserTest.php +++ b/tests/Unit/Bounce/Service/MessageParserTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; +namespace PhpList\Core\Tests\Unit\Bounce\Service; -use PhpList\Core\Domain\Messaging\Service\MessageParser; +use PhpList\Core\Bounce\Service\MessageParser; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PHPUnit\Framework\MockObject\MockObject; diff --git a/tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php b/tests/Unit/Bounce/Service/WebklexImapClientFactoryTest.php similarity index 94% rename from tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php rename to tests/Unit/Bounce/Service/WebklexImapClientFactoryTest.php index e75766f5..25ad57dc 100644 --- a/tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php +++ b/tests/Unit/Bounce/Service/WebklexImapClientFactoryTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; +namespace PhpList\Core\Tests\Unit\Bounce\Service; -use PhpList\Core\Domain\Messaging\Service\WebklexImapClientFactory; +use PhpList\Core\Bounce\Service\WebklexImapClientFactory; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Webklex\PHPIMAP\Client; diff --git a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php index c136ec51..c768b056 100644 --- a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php @@ -42,7 +42,7 @@ public function testExtractAndSaveLinksWithNoLinks(): void $message->method('getId')->willReturn($messageId); $message->method('getContent')->willReturn($messageContent); - $this->linkTrackRepository->expects(self::never())->method('save'); + $this->linkTrackRepository->expects(self::never())->method('persist'); $result = $this->subject->extractAndSaveLinks($message, $userId); @@ -63,7 +63,7 @@ public function testExtractAndSaveLinksWithLinks(): void $message->method('getContent')->willReturn($messageContent); $this->linkTrackRepository->expects(self::exactly(2)) - ->method('save') + ->method('persist') ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) { self::assertSame($messageId, $linkTrack->getMessageId()); self::assertSame($userId, $linkTrack->getUserId()); @@ -92,7 +92,7 @@ public function testExtractAndSaveLinksWithFooter(): void $message->method('getContent')->willReturn($messageContent); $this->linkTrackRepository->expects(self::exactly(2)) - ->method('save') + ->method('persist') ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) { self::assertSame($messageId, $linkTrack->getMessageId()); self::assertSame($userId, $linkTrack->getUserId()); @@ -120,7 +120,7 @@ public function testExtractAndSaveLinksWithDuplicateLinks(): void $message->method('getContent')->willReturn($messageContent); $this->linkTrackRepository->expects(self::once()) - ->method('save') + ->method('persist') ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) { self::assertSame($messageId, $linkTrack->getMessageId()); self::assertSame($userId, $linkTrack->getUserId()); @@ -147,7 +147,7 @@ public function testExtractAndSaveLinksWithNullText(): void $message->method('getContent')->willReturn($messageContent); $this->linkTrackRepository->expects(self::once()) - ->method('save') + ->method('persist') ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) { self::assertSame($messageId, $linkTrack->getMessageId()); self::assertSame($userId, $linkTrack->getUserId()); @@ -219,7 +219,7 @@ public function testExtractAndSaveLinksWithExistingLink(): void ->willReturn($existingLinkTrack); $this->linkTrackRepository->expects(self::never()) - ->method('save'); + ->method('persist'); $result = $this->subject->extractAndSaveLinks($message, $userId); diff --git a/tests/Unit/Domain/Configuration/Service/Manager/ConfigManagerTest.php b/tests/Unit/Domain/Configuration/Service/Manager/ConfigManagerTest.php index 3b14122b..fbe5276e 100644 --- a/tests/Unit/Domain/Configuration/Service/Manager/ConfigManagerTest.php +++ b/tests/Unit/Domain/Configuration/Service/Manager/ConfigManagerTest.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Configuration\Service\Manager; +use PhpList\Core\Domain\Configuration\Exception\ConfigNotEditableException; use PhpList\Core\Domain\Configuration\Model\Config; use PhpList\Core\Domain\Configuration\Repository\ConfigRepository; use PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager; @@ -61,30 +62,13 @@ public function testGetAllReturnsAllConfigsFromRepository(): void $this->assertSame('value2', $result[1]->getValue()); } - public function testUpdateSavesConfigToRepository(): void - { - $configRepository = $this->createMock(ConfigRepository::class); - $manager = new ConfigManager($configRepository); - - $config = new Config(); - $config->setKey('test_item'); - $config->setValue('test_value'); - $config->setEditable(true); - - $configRepository->expects($this->once()) - ->method('save') - ->with($config); - - $manager->update($config, 'new_value'); - } - public function testCreateSavesNewConfigToRepository(): void { $configRepository = $this->createMock(ConfigRepository::class); $manager = new ConfigManager($configRepository); $configRepository->expects($this->once()) - ->method('save') + ->method('persist') ->with($this->callback(function (Config $config) { return $config->getKey() === 'test_key' && $config->getValue() === 'test_value' && @@ -119,10 +103,7 @@ public function testUpdateThrowsExceptionWhenConfigIsNotEditable(): void $config->setValue('test_value'); $config->setEditable(false); - $configRepository->expects($this->never()) - ->method('save'); - - $this->expectException(\PhpList\Core\Domain\Configuration\Exception\ConfigNotEditableException::class); + $this->expectException(ConfigNotEditableException::class); $this->expectExceptionMessage('Configuration item "test_item" is not editable.'); $manager->update($config, 'new_value'); diff --git a/tests/Unit/Domain/Identity/Command/CleanUpOldSessionTokensTest.php b/tests/Unit/Domain/Identity/Command/CleanUpOldSessionTokensTest.php index a358c25c..51a440d5 100644 --- a/tests/Unit/Domain/Identity/Command/CleanUpOldSessionTokensTest.php +++ b/tests/Unit/Domain/Identity/Command/CleanUpOldSessionTokensTest.php @@ -4,67 +4,69 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Command; -use Exception; +use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Identity\Command\CleanUpOldSessionTokens; use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Symfony\Component\Console\Application; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; -class CleanUpOldSessionTokensTest extends TestCase +final class CleanUpOldSessionTokensTest extends TestCase { - private AdministratorTokenRepository&MockObject $tokenRepository; - private CommandTester $commandTester; - - protected function setUp(): void + public function testItRemovesAllExpiredTokensAndOutputsSuccess(): void { - $this->tokenRepository = $this->createMock(AdministratorTokenRepository::class); + $repo = $this->createMock(AdministratorTokenRepository::class); + $em = $this->createMock(EntityManagerInterface::class); - $command = new CleanUpOldSessionTokens($this->tokenRepository); + $token1 = (object) ['id' => 1]; + $token2 = (object) ['id' => 2]; + $expired = [$token1, $token2]; - $application = new Application(); - $application->add($command); + $repo->expects($this->once()) + ->method('getExpired') + ->willReturn($expired); - $this->commandTester = new CommandTester($command); - } + $removed = []; + $em->expects($this->exactly(\count($expired))) + ->method('remove') + ->willReturnCallback(function (object $o) use (&$removed) { + $removed[] = $o; + }); - public function testExecuteSuccessfully(): void - { - $this->tokenRepository->expects($this->once()) - ->method('removeExpired') - ->willReturn(5); + $em->expects($this->once()) + ->method('flush'); - $this->commandTester->execute([]); + $command = new CleanUpOldSessionTokens($repo, $em); + $tester = new CommandTester($command); - $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Successfully removed 5 expired session token(s)', $output); - $this->assertEquals(0, $this->commandTester->getStatusCode()); - } + $exitCode = $tester->execute([]); - public function testExecuteWithNoExpiredTokens(): void - { - $this->tokenRepository->expects($this->once()) - ->method('removeExpired') - ->willReturn(0); + self::assertSame(Command::SUCCESS, $exitCode); - $this->commandTester->execute([]); + $display = $tester->getDisplay(); + self::assertStringContainsString('Successfully removed 2 expired session token(s).', $display); - $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Successfully removed 0 expired session token(s)', $output); - $this->assertEquals(0, $this->commandTester->getStatusCode()); + self::assertEqualsCanonicalizing($expired, $removed); } - public function testExecuteWithException(): void + public function testItHandlesExceptionsAndOutputsFailure(): void { - $this->tokenRepository->expects($this->once()) - ->method('removeExpired') - ->willThrowException(new Exception('Test exception')); + $repo = $this->createMock(AdministratorTokenRepository::class); + $em = $this->createMock(EntityManagerInterface::class); + + $repo->expects($this->once()) + ->method('getExpired') + ->willThrowException(new \RuntimeException('boom')); + + $em->expects($this->never())->method('remove'); + $em->expects($this->never())->method('flush'); + + $command = new CleanUpOldSessionTokens($repo, $em); + $tester = new CommandTester($command); - $this->commandTester->execute([]); + $exitCode = $tester->execute([]); - $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Error removing expired session tokens: Test exception', $output); - $this->assertEquals(1, $this->commandTester->getStatusCode()); + self::assertSame(Command::FAILURE, $exitCode); + self::assertStringContainsString('Error removing expired session tokens: boom', $tester->getDisplay()); } } diff --git a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php index 1a4deef4..3c3356d7 100644 --- a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php @@ -49,7 +49,7 @@ public function testCreateCreatesNewAttributeDefinition(): void ->willReturn(null); $this->repository->expects($this->once()) - ->method('save') + ->method('persist') ->with($this->callback(function (AdminAttributeDefinition $definition) use ($dto) { return $definition->getName() === $dto->name && $definition->getType() === $dto->type @@ -143,10 +143,6 @@ public function testUpdateUpdatesAttributeDefinition(): void ->with('new_table') ->willReturnSelf(); - $this->repository->expects($this->once()) - ->method('save') - ->with($attributeDefinition); - $result = $this->subject->update($attributeDefinition, $dto); $this->assertSame($attributeDefinition, $result); diff --git a/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php b/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php index 9c5cac8f..d0ea805c 100644 --- a/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php @@ -41,7 +41,7 @@ public function testCreateOrUpdateCreatesNewAttributeIfNotExists(): void ->willReturn(null); $this->repository->expects($this->once()) - ->method('save') + ->method('persist') ->with($this->callback(function (AdminAttributeValue $attribute) use ($value) { return $attribute->getAdministrator()->getId() === 1 && $attribute->getAttributeDefinition()->getId() === 2 @@ -72,7 +72,7 @@ public function testCreateOrUpdateUpdatesExistingAttribute(): void ->with(1, 2) ->willReturn($existingAttribute); - $this->repository->expects($this->once()) + $this->repository->expects($this->never()) ->method('save') ->with($this->callback(function (AdminAttributeValue $attribute) use ($newValue) { return $attribute->getValue() === $newValue; @@ -98,8 +98,7 @@ public function testCreateOrUpdateUsesDefaultValueIfValueIsNull(): void ->method('findOneByAdminIdAndAttributeId') ->willReturn(null); - $this->repository->expects($this->once()) - ->method('save'); + // will not throw AdminAttributeCreationException $result = $this->subject->createOrUpdate($admin, $definition); diff --git a/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php b/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php index 11c3f378..0b91fb8e 100644 --- a/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php @@ -32,7 +32,6 @@ public function testCreateAdministrator(): void ->willReturn('hashed_pass'); $entityManager->expects($this->once())->method('persist'); - $entityManager->expects($this->once())->method('flush'); $manager = new AdministratorManager($entityManager, $hashGenerator); $admin = $manager->createAdministrator($dto); @@ -67,8 +66,6 @@ public function testUpdateAdministrator(): void ->with('newpass') ->willReturn('new_hash'); - $entityManager->expects($this->once())->method('flush'); - $manager = new AdministratorManager($entityManager, $hashGenerator); $manager->updateAdministrator($admin, $dto); @@ -86,7 +83,6 @@ public function testDeleteAdministrator(): void $admin = $this->createMock(Administrator::class); $entityManager->expects($this->once())->method('remove')->with($admin); - $entityManager->expects($this->once())->method('flush'); $manager = new AdministratorManager($entityManager, $hashGenerator); $manager->deleteAdministrator($admin); diff --git a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php index 59ace13d..b9b53039 100644 --- a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php @@ -83,7 +83,7 @@ public function testGeneratePasswordResetTokenCleansUpExistingRequests(): void ->with($existingRequest); $this->passwordRequestRepository->expects($this->once()) - ->method('save') + ->method('persist') ->with($this->isInstanceOf(AdminPasswordRequest::class)); $this->messageBus->expects($this->once()) @@ -187,7 +187,7 @@ public function testUpdatePasswordWithTokenUpdatesPasswordAndRemovesToken(): voi ->willReturn($newPasswordHash); $this->administratorRepository->expects($this->once()) - ->method('save') + ->method('persist') ->with($administrator); $this->passwordRequestRepository->expects($this->once()) diff --git a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php index 5cd84c5e..e4dc836e 100644 --- a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php +++ b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php @@ -4,26 +4,29 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Command; +use Doctrine\ORM\EntityManagerInterface; use Exception; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Messaging\Command\ProcessQueueCommand; +use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; 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; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockInterface; +use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Translation\Translator; class ProcessQueueCommandTest extends TestCase { private MessageRepository&MockObject $messageRepository; private MessageProcessingPreparator&MockObject $messageProcessingPreparator; - private CampaignProcessor&MockObject $campaignProcessor; + private MessageBusInterface&MockObject $messageBus; private LockInterface&MockObject $lock; private CommandTester $commandTester; private Translator&MockObject $translator; @@ -33,7 +36,7 @@ protected function setUp(): void $this->messageRepository = $this->createMock(MessageRepository::class); $lockFactory = $this->createMock(LockFactory::class); $this->messageProcessingPreparator = $this->createMock(MessageProcessingPreparator::class); - $this->campaignProcessor = $this->createMock(CampaignProcessor::class); + $this->messageBus = $this->createMock(MessageBusInterface::class); $this->lock = $this->createMock(LockInterface::class); $this->translator = $this->createMock(Translator::class); @@ -45,9 +48,10 @@ protected function setUp(): void messageRepository: $this->messageRepository, lockFactory: $lockFactory, messagePreparator: $this->messageProcessingPreparator, - campaignProcessor: $this->campaignProcessor, + messageBus: $this->messageBus, configProvider: $this->createMock(ConfigProvider::class), translator: $this->translator, + entityManager: $this->createMock(EntityManagerInterface::class), ); $application = new Application(); @@ -97,8 +101,8 @@ public function testExecuteWithNoCampaigns(): void ->with($this->anything(), $this->anything()) ->willReturn([]); - $this->campaignProcessor->expects($this->never()) - ->method('process'); + $this->messageBus->expects($this->never()) + ->method('dispatch'); $this->commandTester->execute([]); @@ -121,22 +125,29 @@ public function testExecuteWithCampaigns(): void ->method('ensureCampaignsHaveUuid'); $campaign = $this->createMock(Message::class); + $campaign->method('getId')->willReturn(1); $this->messageRepository->expects($this->once()) ->method('getByStatusAndEmbargo') ->with($this->anything(), $this->anything()) ->willReturn([$campaign]); - $this->campaignProcessor->expects($this->once()) - ->method('process') - ->with($campaign, $this->anything()); + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with( + $this->callback(function (CampaignProcessorMessage $message) use ($campaign) { + $this->assertEquals($campaign->getId(), $message->getMessageId()); + return true; + }), + $this->equalTo([]) + ) + ->willReturn(new Envelope(new CampaignProcessorMessage($campaign->getId()))); $this->commandTester->execute([]); $this->assertEquals(0, $this->commandTester->getStatusCode()); } - public function testExecuteWithMultipleCampaigns(): void { $this->lock->expects($this->once()) @@ -152,27 +163,37 @@ public function testExecuteWithMultipleCampaigns(): void $this->messageProcessingPreparator->expects($this->once()) ->method('ensureCampaignsHaveUuid'); - $campaign1 = $this->createMock(Message::class); - $campaign2 = $this->createMock(Message::class); + $cmp1 = $this->createMock(Message::class); + $cmp1->method('getId')->willReturn(1); + $cmp2 = $this->createMock(Message::class); + $cmp2->method('getId')->willReturn(2); $this->messageRepository->expects($this->once()) ->method('getByStatusAndEmbargo') ->with($this->anything(), $this->anything()) - ->willReturn([$campaign1, $campaign2]); - - $this->campaignProcessor->expects($this->exactly(2)) - ->method('process') - ->withConsecutive( - [$campaign1, $this->anything()], - [$campaign2, $this->anything()] - ); + ->willReturn([$cmp1, $cmp2]); + + $this->messageBus->expects($this->exactly(2)) + ->method('dispatch') + ->willReturnCallback(function (CampaignProcessorMessage $message, array $stamps) use ($cmp1, $cmp2) { + static $call = 0; + $call++; + if ($call === 1) { + $this->assertEquals($cmp1->getId(), $message->getMessageId()); + } else { + $this->assertEquals($cmp2->getId(), $message->getMessageId()); + } + $this->assertSame([], $stamps); + + return new Envelope(new CampaignProcessorMessage($message->getMessageId())); + }); $this->commandTester->execute([]); $this->assertEquals(0, $this->commandTester->getStatusCode()); } - public function testExecuteWithProcessorException(): void + public function testExecuteWithDispatcherException(): void { $this->lock->expects($this->once()) ->method('acquire') @@ -188,16 +209,23 @@ public function testExecuteWithProcessorException(): void ->method('ensureCampaignsHaveUuid'); $campaign = $this->createMock(Message::class); + $campaign->method('getId')->willReturn(1); $this->messageRepository->expects($this->once()) ->method('getByStatusAndEmbargo') ->with($this->anything(), $this->anything()) ->willReturn([$campaign]); - $this->campaignProcessor->expects($this->once()) - ->method('process') - ->with($campaign, $this->anything()) - ->willThrowException(new Exception('Test exception')); + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with( + $this->callback(function (CampaignProcessorMessage $message) use ($campaign) { + $this->assertEquals($campaign->getId(), $message->getMessageId()); + return true; + }), + $this->equalTo([]) + ) + ->willThrowException(new Exception()); $this->commandTester->execute([]); diff --git a/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php similarity index 70% rename from tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php rename to tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php index f2f82752..e50d89fa 100644 --- a/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php @@ -2,18 +2,21 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Processor; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\MessageHandler; use Doctrine\ORM\EntityManagerInterface; use Exception; +use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage; +use PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata; +use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; +use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; -use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor; use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; @@ -21,19 +24,23 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Mime\Email; use Symfony\Component\Translation\Translator; +use Symfony\Contracts\Translation\TranslatorInterface; -class CampaignProcessorTest extends TestCase +class CampaignProcessorMessageHandlerTest extends TestCase { private RateLimitedCampaignMailer|MockObject $mailer; private EntityManagerInterface|MockObject $entityManager; private SubscriberProvider|MockObject $subscriberProvider; private MessageProcessingPreparator|MockObject $messagePreparator; private LoggerInterface|MockObject $logger; - private OutputInterface|MockObject $output; - private CampaignProcessor $campaignProcessor; + private CampaignProcessorMessageHandler $handler; + private MessageRepository|MockObject $messageRepository; + private UserMessageRepository|MockObject $userMessageRepository; + private MaxProcessTimeLimiter|MockObject $timeLimiter; + private RequeueHandler|MockObject $requeueHandler; + private TranslatorInterface|MockObject $translator; protected function setUp(): void { @@ -42,28 +49,58 @@ protected function setUp(): void $this->subscriberProvider = $this->createMock(SubscriberProvider::class); $this->messagePreparator = $this->createMock(MessageProcessingPreparator::class); $this->logger = $this->createMock(LoggerInterface::class); - $this->output = $this->createMock(OutputInterface::class); - $userMessageRepository = $this->createMock(UserMessageRepository::class); + $this->messageRepository = $this->createMock(MessageRepository::class); + $this->userMessageRepository = $this->createMock(UserMessageRepository::class); + $this->timeLimiter = $this->createMock(MaxProcessTimeLimiter::class); + $this->requeueHandler = $this->createMock(RequeueHandler::class); + $this->translator = $this->createMock(Translator::class); - $this->campaignProcessor = new CampaignProcessor( + $this->timeLimiter->method('start'); + $this->timeLimiter->method('shouldStop')->willReturn(false); + + $this->handler = new CampaignProcessorMessageHandler( mailer: $this->mailer, entityManager: $this->entityManager, subscriberProvider: $this->subscriberProvider, messagePreparator: $this->messagePreparator, logger: $this->logger, - userMessageRepository: $userMessageRepository, - timeLimiter: $this->createMock(MaxProcessTimeLimiter::class), - requeueHandler: $this->createMock(RequeueHandler::class), - translator: new Translator('en'), + userMessageRepository: $this->userMessageRepository, + timeLimiter: $this->timeLimiter, + requeueHandler: $this->requeueHandler, + translator: $this->translator, subscriberHistoryManager: $this->createMock(SubscriberHistoryManager::class), + messageRepository: $this->messageRepository, ); } - public function testProcessWithNoSubscribers(): void + public function testInvokeWhenCampaignNotFound(): void + { + $message = new CampaignProcessorMessage(999); + + $this->messageRepository->expects($this->once()) + ->method('findByIdAndStatus') + ->with(999, MessageStatus::Submitted) + ->willReturn(null); + + $this->translator->method('trans')->willReturnCallback(fn(string $msg) => $msg); + + $this->logger->expects($this->once()) + ->method('warning') + ->with('Campaign not found or not in submitted status', ['campaign_id' => 999]); + + ($this->handler)($message); + } + + public function testInvokeWithNoSubscribers(): void { $campaign = $this->createCampaignMock(); $metadata = $this->createMock(MessageMetadata::class); $campaign->method('getMetadata')->willReturn($metadata); + $campaign->method('getId')->willReturn(1); + + $this->messageRepository->method('findByIdAndStatus') + ->with(1, MessageStatus::Submitted) + ->willReturn($campaign); $this->subscriberProvider->expects($this->once()) ->method('getSubscribersForMessage') @@ -79,14 +116,19 @@ public function testProcessWithNoSubscribers(): void $this->mailer->expects($this->never()) ->method('send'); - $this->campaignProcessor->process($campaign, $this->output); + ($this->handler)(new CampaignProcessorMessage(1)); } - public function testProcessWithInvalidSubscriberEmail(): void + public function testInvokeWithInvalidSubscriberEmail(): void { $campaign = $this->createCampaignMock(); $metadata = $this->createMock(MessageMetadata::class); $campaign->method('getMetadata')->willReturn($metadata); + $campaign->method('getId')->willReturn(1); + + $this->messageRepository->method('findByIdAndStatus') + ->with(1, MessageStatus::Submitted) + ->willReturn($campaign); $subscriber = $this->createMock(Subscriber::class); $subscriber->method('getEmail')->willReturn('invalid-email'); @@ -109,14 +151,19 @@ public function testProcessWithInvalidSubscriberEmail(): void $this->mailer->expects($this->never()) ->method('send'); - $this->campaignProcessor->process($campaign, $this->output); + ($this->handler)(new CampaignProcessorMessage(1)); } - public function testProcessWithValidSubscriberEmail(): void + public function testInvokeWithValidSubscriberEmail(): void { $campaign = $this->createCampaignMock(); $metadata = $this->createMock(MessageMetadata::class); $campaign->method('getMetadata')->willReturn($metadata); + $campaign->method('getId')->willReturn(1); + + $this->messageRepository->method('findByIdAndStatus') + ->with(1, MessageStatus::Submitted) + ->willReturn($campaign); $subscriber = $this->createMock(Subscriber::class); $subscriber->method('getEmail')->willReturn('test@example.com'); @@ -156,16 +203,20 @@ public function testProcessWithValidSubscriberEmail(): void $this->entityManager->expects($this->atLeastOnce()) ->method('flush'); - $this->campaignProcessor->process($campaign, $this->output); + ($this->handler)(new CampaignProcessorMessage(1)); } - public function testProcessWithMailerException(): void + public function testInvokeWithMailerException(): void { $campaign = $this->createCampaignMock(); $metadata = $this->createMock(MessageMetadata::class); $campaign->method('getMetadata')->willReturn($metadata); $campaign->method('getId')->willReturn(123); + $this->messageRepository->method('findByIdAndStatus') + ->with(123, MessageStatus::Submitted) + ->willReturn($campaign); + $subscriber = $this->createMock(Subscriber::class); $subscriber->method('getEmail')->willReturn('test@example.com'); $subscriber->method('getId')->willReturn(1); @@ -192,24 +243,25 @@ public function testProcessWithMailerException(): void 'campaign_id' => 123, ]); - $this->output->expects($this->once()) - ->method('writeln') - ->with('Failed to send to: test@example.com'); - $metadata->expects($this->atLeastOnce()) ->method('setStatus'); $this->entityManager->expects($this->atLeastOnce()) ->method('flush'); - $this->campaignProcessor->process($campaign, $this->output); + ($this->handler)(new CampaignProcessorMessage(123)); } - public function testProcessWithMultipleSubscribers(): void + public function testInvokeWithMultipleSubscribers(): void { $campaign = $this->createCampaignMock(); $metadata = $this->createMock(MessageMetadata::class); $campaign->method('getMetadata')->willReturn($metadata); + $campaign->method('getId')->willReturn(1); + + $this->messageRepository->method('findByIdAndStatus') + ->with(1, MessageStatus::Submitted) + ->willReturn($campaign); $subscriber1 = $this->createMock(Subscriber::class); $subscriber1->method('getEmail')->willReturn('test1@example.com'); @@ -241,49 +293,7 @@ public function testProcessWithMultipleSubscribers(): void $this->entityManager->expects($this->atLeastOnce()) ->method('flush'); - $this->campaignProcessor->process($campaign, $this->output); - } - - public function testProcessWithNullOutput(): void - { - $campaign = $this->createCampaignMock(); - $metadata = $this->createMock(MessageMetadata::class); - $campaign->method('getMetadata')->willReturn($metadata); - $campaign->method('getId')->willReturn(123); - - $subscriber = $this->createMock(Subscriber::class); - $subscriber->method('getEmail')->willReturn('test@example.com'); - $subscriber->method('getId')->willReturn(1); - - $this->subscriberProvider->expects($this->once()) - ->method('getSubscribersForMessage') - ->with($campaign) - ->willReturn([$subscriber]); - - $this->messagePreparator->expects($this->once()) - ->method('processMessageLinks') - ->with($campaign, 1) - ->willReturn($campaign); - - $exception = new Exception('Test exception'); - $this->mailer->expects($this->once()) - ->method('send') - ->willThrowException($exception); - - $this->logger->expects($this->once()) - ->method('error') - ->with('Test exception', [ - 'subscriber_id' => 1, - 'campaign_id' => 123, - ]); - - $metadata->expects($this->atLeastOnce()) - ->method('setStatus'); - - $this->entityManager->expects($this->atLeastOnce()) - ->method('flush'); - - $this->campaignProcessor->process($campaign, null); + ($this->handler)(new CampaignProcessorMessage(1)); } /** diff --git a/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php index 079d06a8..495f496e 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php @@ -6,7 +6,6 @@ use DateInterval; use DateTime; -use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat; @@ -24,13 +23,11 @@ class RequeueHandlerTest extends TestCase { private LoggerInterface&MockObject $logger; - private EntityManagerInterface&MockObject $em; private OutputInterface&MockObject $output; protected function setUp(): void { $this->logger = $this->createMock(LoggerInterface::class); - $this->em = $this->createMock(EntityManagerInterface::class); $this->output = $this->createMock(OutputInterface::class); } @@ -56,10 +53,9 @@ private function createMessage( public function testReturnsFalseWhenIntervalIsZeroOrNegative(): void { - $handler = new RequeueHandler($this->logger, $this->em, new Translator('en')); + $handler = new RequeueHandler($this->logger, new Translator('en')); $message = $this->createMessage(0, null, null); - $this->em->expects($this->never())->method('flush'); $this->output->expects($this->never())->method('writeln'); $this->logger->expects($this->never())->method('info'); @@ -71,11 +67,10 @@ public function testReturnsFalseWhenIntervalIsZeroOrNegative(): void public function testReturnsFalseWhenNowIsAfterRequeueUntil(): void { - $handler = new RequeueHandler($this->logger, $this->em, new Translator('en')); + $handler = new RequeueHandler($this->logger, new Translator('en')); $past = (new DateTime())->sub(new DateInterval('PT5M')); $message = $this->createMessage(5, $past, null); - $this->em->expects($this->never())->method('flush'); $this->logger->expects($this->never())->method('info'); $result = $handler->handle($message, $this->output); @@ -86,12 +81,11 @@ public function testReturnsFalseWhenNowIsAfterRequeueUntil(): void public function testRequeuesFromFutureEmbargoAndSetsSubmittedStatus(): void { - $handler = new RequeueHandler($this->logger, $this->em, new Translator('en')); + $handler = new RequeueHandler($this->logger, new Translator('en')); $embargo = (new DateTime())->add(new DateInterval('PT5M')); $interval = 10; $message = $this->createMessage($interval, null, $embargo); - $this->em->expects($this->once())->method('flush'); $this->output->expects($this->once())->method('writeln'); $this->logger->expects($this->once())->method('info'); @@ -108,11 +102,10 @@ public function testRequeuesFromFutureEmbargoAndSetsSubmittedStatus(): void public function testRequeuesFromNowWhenEmbargoIsNullOrPast(): void { - $handler = new RequeueHandler($this->logger, $this->em, new Translator('en')); + $handler = new RequeueHandler($this->logger, new Translator('en')); $interval = 3; $message = $this->createMessage($interval, null, null); - $this->em->expects($this->once())->method('flush'); $this->logger->expects($this->once())->method('info'); $before = new DateTime(); @@ -134,14 +127,13 @@ public function testRequeuesFromNowWhenEmbargoIsNullOrPast(): void public function testReturnsFalseWhenNextEmbargoExceedsUntil(): void { - $handler = new RequeueHandler($this->logger, $this->em, new Translator('en')); + $handler = new RequeueHandler($this->logger, new Translator('en')); $embargo = (new DateTime())->add(new DateInterval('PT1M')); $interval = 10; // next would be +10, which exceeds until $until = (clone $embargo)->add(new DateInterval('PT5M')); $message = $this->createMessage($interval, $until, $embargo); - $this->em->expects($this->never())->method('flush'); $this->logger->expects($this->never())->method('info'); $result = $handler->handle($message, $this->output); diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php index 0dbde7ad..9de0df4d 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php @@ -6,11 +6,11 @@ use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Bounce\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; use PhpList\Core\Domain\Messaging\Repository\BounceRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -49,7 +49,7 @@ public function testCreatePersistsAndReturnsBounce(): void $comment = 'created by test'; $this->repository->expects($this->once()) - ->method('save') + ->method('persist') ->with($this->isInstanceOf(Bounce::class)); $bounce = $this->manager->create( @@ -115,9 +115,8 @@ public function testGetByIdReturnsNullWhenNotFound(): void public function testUpdateChangesFieldsAndSaves(): void { $bounce = new Bounce(); - $this->repository->expects($this->once()) - ->method('save') - ->with($bounce); + $this->entityManager->expects($this->once()) + ->method('flush'); $updated = $this->manager->update($bounce, 'processed', 'done'); $this->assertSame($bounce, $updated); @@ -130,8 +129,6 @@ 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); diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php index fd526a64..4ade0040 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php @@ -42,7 +42,7 @@ public function testCreateNewRegex(): void ->willReturn(null); $this->regexRepository->expects($this->once()) - ->method('save') + ->method('persist') ->with($this->isInstanceOf(BounceRegex::class)); $regex = $this->manager->createOrUpdateFromPattern( @@ -85,10 +85,6 @@ public function testUpdateExistingRegex(): void ->with($hash) ->willReturn($existing); - $this->regexRepository->expects($this->once()) - ->method('save') - ->with($existing); - $updated = $this->manager->createOrUpdateFromPattern( regex: $pattern, action: 'delete', @@ -134,9 +130,6 @@ public function testAssociateBounceIncrementsCountAndPersistsRelation(): void && $entity->getRegexId() === $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/Manager/ListMessageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.php index 2f1af5fe..26b6fee3 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.php @@ -51,9 +51,6 @@ public function testAssociateMessageWithList(): void && $listMessage->getEntered() instanceof DateTime; })); - $this->entityManager->expects($this->once()) - ->method('flush'); - $result = $this->manager->associateMessageWithList($message, $subscriberList); $this->assertInstanceOf(ListMessage::class, $result); @@ -120,9 +117,6 @@ public function testAssociateMessageWithLists(): void && $listMessage->getEntered() instanceof DateTime; })); - $this->entityManager->expects($this->exactly(2)) - ->method('flush'); - $this->manager->associateMessageWithLists($message, [$subscriberList1, $subscriberList2]); } diff --git a/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php index 94064485..ac41566f 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php @@ -66,7 +66,7 @@ public function testCreateMessageReturnsPersistedMessage(): void ->willReturn($expectedMessage); $messageRepository->expects($this->once()) - ->method('save') + ->method('persist') ->with($expectedMessage); $message = $manager->createMessage($request, $authUser); @@ -130,10 +130,6 @@ public function testUpdateMessageReturnsUpdatedMessage(): void ->with($updateRequest, $this->anything()) ->willReturn($existingMessage); - $messageRepository->expects($this->once()) - ->method('save') - ->with($existingMessage); - $message = $manager->updateMessage($updateRequest, $existingMessage, $authUser); $this->assertSame('Updated Subject', $message->getContent()->getSubject()); diff --git a/tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php index e56f11ca..1f8c9276 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php @@ -29,7 +29,6 @@ protected function setUp(): void 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); diff --git a/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php index 93907f02..932e0d8a 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php @@ -37,9 +37,6 @@ public function testCreateImagesFromImagePaths(): void ->method('persist') ->with($this->isInstanceOf(TemplateImage::class)); - $this->entityManager->expects($this->once()) - ->method('flush'); - $images = $this->manager->createImagesFromImagePaths(['image1.jpg', 'image2.png'], $template); $this->assertCount(2, $images); diff --git a/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php index d3748244..4f6544c2 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php @@ -4,7 +4,6 @@ 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; @@ -26,14 +25,12 @@ class TemplateManagerTest extends TestCase protected function setUp(): void { $this->templateRepository = $this->createMock(TemplateRepository::class); - $entityManager = $this->createMock(EntityManagerInterface::class); $this->templateImageManager = $this->createMock(TemplateImageManager::class); $this->templateLinkValidator = $this->createMock(TemplateLinkValidator::class); $this->templateImageValidator = $this->createMock(TemplateImageValidator::class); $this->manager = new TemplateManager( $this->templateRepository, - $entityManager, $this->templateImageManager, $this->templateLinkValidator, $this->templateImageValidator @@ -66,7 +63,7 @@ public function testCreateTemplateSuccessfully(): void ->with([], $this->anything()); $this->templateRepository->expects($this->once()) - ->method('save') + ->method('persist') ->with($this->isInstanceOf(Template::class)); $this->templateImageManager->expects($this->once()) diff --git a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php index 85066691..f2a29d49 100644 --- a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php +++ b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php @@ -4,7 +4,6 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; -use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Analytics\Model\LinkTrack; use PhpList\Core\Domain\Analytics\Service\LinkTrackService; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; @@ -20,7 +19,6 @@ class MessageProcessingPreparatorTest extends TestCase { - private EntityManagerInterface&MockObject $entityManager; private SubscriberRepository&MockObject $subscriberRepository; private MessageRepository&MockObject $messageRepository; private LinkTrackService&MockObject $linkTrackService; @@ -29,14 +27,12 @@ class MessageProcessingPreparatorTest extends TestCase protected function setUp(): void { - $this->entityManager = $this->createMock(EntityManagerInterface::class); $this->subscriberRepository = $this->createMock(SubscriberRepository::class); $this->messageRepository = $this->createMock(MessageRepository::class); $this->linkTrackService = $this->createMock(LinkTrackService::class); $this->output = $this->createMock(OutputInterface::class); $this->preparator = new MessageProcessingPreparator( - entityManager: $this->entityManager, subscriberRepository: $this->subscriberRepository, messageRepository: $this->messageRepository, linkTrackService: $this->linkTrackService, @@ -53,9 +49,6 @@ public function testEnsureSubscribersHaveUuidWithNoSubscribers(): void $this->output->expects($this->never()) ->method('writeln'); - $this->entityManager->expects($this->never()) - ->method('flush'); - $this->preparator->ensureSubscribersHaveUuid($this->output); } @@ -82,9 +75,6 @@ public function testEnsureSubscribersHaveUuidWithSubscribers(): void ->method('setUniqueId') ->with($this->isType('string')); - $this->entityManager->expects($this->once()) - ->method('flush'); - $this->preparator->ensureSubscribersHaveUuid($this->output); } @@ -97,9 +87,6 @@ public function testEnsureCampaignsHaveUuidWithNoCampaigns(): void $this->output->expects($this->never()) ->method('writeln'); - $this->entityManager->expects($this->never()) - ->method('flush'); - $this->preparator->ensureCampaignsHaveUuid($this->output); } @@ -126,9 +113,6 @@ public function testEnsureCampaignsHaveUuidWithCampaigns(): void ->method('setUuid') ->with($this->isType('string')); - $this->entityManager->expects($this->once()) - ->method('flush'); - $this->preparator->ensureCampaignsHaveUuid($this->output); } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php index 7e7bcfb7..6a047051 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php @@ -39,7 +39,7 @@ public function testCreateAttributeDefinition(): void ->with('Country') ->willReturn(null); - $repository->expects($this->once())->method('save'); + $repository->expects($this->once())->method('persist'); $attribute = $manager->create($dto); @@ -110,8 +110,6 @@ public function testUpdateAttributeDefinition(): void ->with('New') ->willReturn(null); - $repository->expects($this->once())->method('save')->with($attribute); - $updated = $manager->update($attribute, $dto); $this->assertSame('New', $updated->getName()); diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php index 6add5016..0c29242a 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php @@ -42,7 +42,7 @@ public function testCreatePageCreatesAndSaves(): void $owner = new Administrator(); $this->pageRepository ->expects($this->once()) - ->method('save') + ->method('persist') ->with($this->isInstanceOf(SubscribePage::class)); $page = $this->manager->createPage('My Page', true, $owner); @@ -91,7 +91,7 @@ public function testUpdatePageUpdatesProvidedFieldsAndFlushes(): void ->setOwner($originalOwner); $this->entityManager - ->expects($this->once()) + ->expects($this->never()) ->method('flush'); $updated = $this->manager->updatePage($page, title: 'New Title', active: true, owner: $newOwner); @@ -111,7 +111,7 @@ public function testUpdatePageLeavesNullFieldsUntouched(): void ->setOwner($owner); $this->entityManager - ->expects($this->once()) + ->expects($this->never()) ->method('flush'); $updated = $this->manager->updatePage(page: $page, title: null, active: null, owner: null); @@ -121,14 +121,14 @@ public function testUpdatePageLeavesNullFieldsUntouched(): void $this->assertSame($owner, $updated->getOwner()); } - public function testSetActiveSetsFlagAndFlushes(): void + public function testSetActiveSetsFlagButNDoesotFlush(): void { $page = (new SubscribePage()) ->setTitle('Any') ->setActive(false); $this->entityManager - ->expects($this->once()) + ->expects($this->never()) ->method('flush'); $this->manager->setActive($page, true); @@ -194,10 +194,6 @@ public function testSetPageDataUpdatesExistingDataAndFlushes(): void ->expects($this->never()) ->method('persist'); - $this->entityManager - ->expects($this->once()) - ->method('flush'); - $result = $this->manager->setPageData($page, 'color', 'blue'); $this->assertSame($existing, $result); @@ -222,10 +218,6 @@ public function testSetPageDataCreatesNewWhenMissingAndPersistsAndFlushes(): voi ->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); diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php index 25fdf5ca..16ca73bd 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php @@ -106,10 +106,6 @@ public function testAddEmailToBlacklistAddsEntryAndReason(): void [$this->isInstanceOf(UserBlacklistData::class)] ); - $this->entityManager - ->expects($this->once()) - ->method('flush'); - $this->manager->addEmailToBlacklist('new@blacklist.com', 'test reason'); } @@ -126,10 +122,6 @@ public function testAddEmailToBlacklistAddsEntryWithoutReason(): void ->method('persist') ->with($this->isInstanceOf(UserBlacklist::class)); - $this->entityManager - ->expects($this->once()) - ->method('flush'); - $this->manager->addEmailToBlacklist('noreason@blacklist.com'); } @@ -166,10 +158,6 @@ public function testRemoveEmailFromBlacklistRemovesAllRelatedData(): void $subscriber->expects($this->once())->method('setBlacklisted')->with(false); - $this->entityManager - ->expects($this->once()) - ->method('flush'); - $this->manager->removeEmailFromBlacklist('remove@me.com'); } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberListManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberListManagerTest.php index c913518f..cf4dbf9c 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberListManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberListManagerTest.php @@ -36,7 +36,7 @@ public function testCreateSubscriberList(): void $this->subscriberListRepository ->expects($this->once()) - ->method('save') + ->method('persist') ->with($this->isInstanceOf(SubscriberList::class)); $result = $this->manager->createSubscriberList($request, $admin); diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php index 4f11c393..aaff7298 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php @@ -40,7 +40,7 @@ public function testCreateSubscriberPersistsAndReturnsProperlyInitializedEntity( { $this->subscriberRepository ->expects($this->once()) - ->method('save') + ->method('persist') ->with($this->callback(function (Subscriber $sub): bool { return $sub->getEmail() === 'foo@bar.com' && $sub->isConfirmed() === true @@ -65,7 +65,7 @@ public function testCreateSubscriberPersists(): void { $this->subscriberRepository ->expects($this->once()) - ->method('save') + ->method('persist') ->with($this->callback(function (Subscriber $sub): bool { $sub->setUniqueId('test-unique-id-456'); return $sub->getEmail() === 'foo@bar.com' @@ -91,7 +91,7 @@ public function testCreateSubscriberWithConfirmation(): void $capturedSubscriber = null; $this->subscriberRepository ->expects($this->once()) - ->method('save') + ->method('persist') ->with($this->callback(function (Subscriber $subscriber) use (&$capturedSubscriber) { $capturedSubscriber = $subscriber; $subscriber->setUniqueId('test-unique-id-123'); @@ -111,7 +111,7 @@ public function testCreateSubscriberWithoutConfirmation(): void { $this->subscriberRepository ->expects($this->once()) - ->method('save'); + ->method('persist'); $dto = new CreateSubscriberDto(email: 'test@example.com', requestConfirmation: false, htmlEmail: true); $this->subscriberManager->createSubscriber($dto); @@ -133,10 +133,6 @@ public function testMarkAsConfirmedByUniqueIdConfirmsSubscriber(): void ->method('setConfirmed') ->with(true); - $this->entityManager - ->expects($this->once()) - ->method('flush'); - $result = $this->subscriberManager->markAsConfirmedByUniqueId($uniqueId); $this->assertSame($subscriber, $result); diff --git a/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php b/tests/Unit/Processor/AdvancedBounceRulesProcessorTest.php similarity index 96% rename from tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php rename to tests/Unit/Processor/AdvancedBounceRulesProcessorTest.php index a4590052..7a57980d 100644 --- a/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php +++ b/tests/Unit/Processor/AdvancedBounceRulesProcessorTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Processor; +namespace PhpList\Core\Tests\Unit\Processor; +use PhpList\Core\Bounce\Service\BounceActionResolver; +use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Bounce\Service\Processor\AdvancedBounceRulesProcessor; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Model\BounceRegex; use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; -use PhpList\Core\Domain\Messaging\Service\BounceActionResolver; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager; -use PhpList\Core\Domain\Messaging\Service\Processor\AdvancedBounceRulesProcessor; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PHPUnit\Framework\MockObject\MockObject; diff --git a/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php b/tests/Unit/Processor/BounceDataProcessorTest.php similarity index 96% rename from tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php rename to tests/Unit/Processor/BounceDataProcessorTest.php index b7009cd9..d5d901c0 100644 --- a/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php +++ b/tests/Unit/Processor/BounceDataProcessorTest.php @@ -2,17 +2,18 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Processor; +namespace PhpList\Core\Tests\Unit\Processor; use DateTimeImmutable; +use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Bounce\Service\Processor\BounceDataProcessor; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; -use PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -47,6 +48,7 @@ private function makeProcessor(): BounceDataProcessor logger: $this->logger, subscriberManager: $this->subscriberManager, subscriberHistoryManager: $this->historyManager, + entityManager: $this->createMock(EntityManagerInterface::class), ); } diff --git a/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php b/tests/Unit/Processor/MboxBounceProcessorTest.php similarity index 92% rename from tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php rename to tests/Unit/Processor/MboxBounceProcessorTest.php index 9bf1c92f..a67235dd 100644 --- a/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php +++ b/tests/Unit/Processor/MboxBounceProcessorTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Processor; +namespace PhpList\Core\Tests\Unit\Processor; -use PhpList\Core\Domain\Messaging\Service\BounceProcessingServiceInterface; -use PhpList\Core\Domain\Messaging\Service\Processor\MboxBounceProcessor; +use PhpList\Core\Bounce\Service\BounceProcessingServiceInterface; +use PhpList\Core\Bounce\Service\Processor\MboxBounceProcessor; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use RuntimeException; diff --git a/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php b/tests/Unit/Processor/PopBounceProcessorTest.php similarity index 91% rename from tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php rename to tests/Unit/Processor/PopBounceProcessorTest.php index d0141386..d218edd0 100644 --- a/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php +++ b/tests/Unit/Processor/PopBounceProcessorTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Processor; +namespace PhpList\Core\Tests\Unit\Processor; -use PhpList\Core\Domain\Messaging\Service\BounceProcessingServiceInterface; -use PhpList\Core\Domain\Messaging\Service\Processor\PopBounceProcessor; +use PhpList\Core\Bounce\Service\BounceProcessingServiceInterface; +use PhpList\Core\Bounce\Service\Processor\PopBounceProcessor; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Input\InputInterface; diff --git a/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php b/tests/Unit/Processor/UnidentifiedBounceReprocessorTest.php similarity index 89% rename from tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php rename to tests/Unit/Processor/UnidentifiedBounceReprocessorTest.php index ac1c9173..0e2d2254 100644 --- a/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php +++ b/tests/Unit/Processor/UnidentifiedBounceReprocessorTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Processor; +namespace PhpList\Core\Tests\Unit\Processor; use DateTimeImmutable; +use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Bounce\Service\MessageParser; +use PhpList\Core\Bounce\Service\Processor\BounceDataProcessor; +use PhpList\Core\Bounce\Service\Processor\UnidentifiedBounceReprocessor; use PhpList\Core\Domain\Messaging\Model\Bounce; -use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; -use PhpList\Core\Domain\Messaging\Service\MessageParser; -use PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor; -use PhpList\Core\Domain\Messaging\Service\Processor\UnidentifiedBounceReprocessor; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Style\SymfonyStyle; From 93547aa56e0557c2a603a843ec5a3a1b1d65b0ba Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 23 Oct 2025 10:31:33 +0400 Subject: [PATCH 15/20] Add test --- .../Command/ProcessBouncesCommandTest.php | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Bounce/Command/ProcessBouncesCommandTest.php b/tests/Unit/Bounce/Command/ProcessBouncesCommandTest.php index b3840dfb..4ab6d556 100644 --- a/tests/Unit/Bounce/Command/ProcessBouncesCommandTest.php +++ b/tests/Unit/Bounce/Command/ProcessBouncesCommandTest.php @@ -30,6 +30,7 @@ class ProcessBouncesCommandTest extends TestCase private CommandTester $commandTester; private TranslatorInterface|MockObject $translator; + private EntityManagerInterface|MockObject $entityManager; protected function setUp(): void { @@ -40,6 +41,7 @@ protected function setUp(): void $this->unidentifiedReprocessor = $this->createMock(UnidentifiedBounceReprocessor::class); $this->consecutiveBounceHandler = $this->createMock(ConsecutiveBounceHandler::class); $this->translator = new Translator('en'); + $this->entityManager = $this->createMock(EntityManagerInterface::class); $command = new ProcessBouncesCommand( lockService: $this->lockService, @@ -49,7 +51,7 @@ protected function setUp(): void unidentifiedReprocessor: $this->unidentifiedReprocessor, consecutiveBounceHandler: $this->consecutiveBounceHandler, translator: $this->translator, - entityManager: $this->createMock(EntityManagerInterface::class), + entityManager: $this->entityManager, ); $this->commandTester = new CommandTester($command); @@ -199,6 +201,67 @@ public function testForceOptionIsPassedToLockService(): void '--force' => true, ]); + $this->assertSame(0, $this->commandTester->getStatusCode()); + } + public function testForceLockFailureReturnsFailureAndMessage(): void + { + $this->lockService->expects($this->once()) + ->method('acquirePageLock') + ->with('bounce_processor', true) + ->willReturn(0); + + $this->protocolProcessor->expects($this->never())->method('process'); + $this->advancedRulesProcessor->expects($this->never())->method('process'); + $this->consecutiveBounceHandler->expects($this->never())->method('handle'); + + $this->commandTester->execute([ + '--force' => true, + ]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Could not apply force lock. Aborting.', $output); + $this->assertSame(1, $this->commandTester->getStatusCode()); + } + + public function testRulesBatchSizeOptionIsRespected(): void + { + $this->lockService + ->expects($this->once()) + ->method('acquirePageLock') + ->with('bounce_processor', false) + ->willReturn(10); + $this->lockService + ->expects($this->once()) + ->method('release') + ->with(10); + + $this->protocolProcessor->method('getProtocol')->willReturn('pop'); + $this->protocolProcessor->method('process')->willReturn(''); + + $this->advancedRulesProcessor + ->expects($this->once()) + ->method('process') + ->with($this->anything(), 50); + + $this->commandTester->execute([ + '--rules-batch-size' => 50, + ]); + + $this->assertSame(0, $this->commandTester->getStatusCode()); + } + + public function testEntityManagerIsFlushedAfterLockAcquireAttempt(): void + { + $this->lockService->expects($this->once()) + ->method('acquirePageLock') + ->with('bounce_processor', false) + ->willReturn(null); + + $this->entityManager->expects($this->once()) + ->method('flush'); + + $this->commandTester->execute([]); + $this->assertSame(0, $this->commandTester->getStatusCode()); } } From 987873e5b8c147a9b69fef6320f3bcf5ba5ceb83 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 27 Oct 2025 10:55:04 +0400 Subject: [PATCH 16/20] Update: docs --- docs/ClassStructure.md | 61 +++++++++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/docs/ClassStructure.md b/docs/ClassStructure.md index e7cf6801..8b3d9516 100644 --- a/docs/ClassStructure.md +++ b/docs/ClassStructure.md @@ -1,32 +1,49 @@ # Class structure -All production classes are located in `src/`, and all unit and integration -tests are located in `tests/`. - +All production classes live under `src/`, and all unit/integration tests live under `tests/`. ## Core/ +Core runtime and DI wiring. +- Bootstrap: entry point that bootstraps the phpList core system and configures the application. +- ApplicationKernel, ApplicationStructure: Symfony kernel and structure configuration. +- Compiler passes (e.g., BounceProcessorPass, DoctrineMappingPass), environment helpers, parameter providers. -### Bootstrap - -This class bootstraps the phpList core system. +## Bounce/ +Bounce processing feature. (This module continuously updates the database throughout the bounce-processing workflow; therefore, it is separated into its own feature block.) +- Command/: Console commands related to processing bounces. +- Service/: Services that parse, classify and handle bounces. +- Exception/: Bounce‑related exceptions. +## Composer/ +Integration with Composer. +- ScriptHandler, ModuleFinder, PackageRepository: helpers invoked by Composer scripts and for module discovery. ## Domain/ - -### Model/ - -These classes are the domain models, which map to some of the database tables, -and where the model-related business logic can be found. There must be no -database access code in these classes. - -### Repository/ - -These classes are responsible for reading domain models from the database, -for writing them there, and for other database queries. - - -## Security - -These classes deal with security-related concerns, e.g., password hashing. +Domain logic organized by sub‑domains (e.g., Analytics, Common, Configuration, Identity, Messaging, Subscription). +Each sub‑domain follows similar conventions: +- Model/: Domain entities/value objects. Contains business logic; no direct DB access. +- Repository/: Reading/writing models and other DB queries. +- Service/: Domain services and orchestration. +- Exception/: Domain‑specific exceptions. + +## EmptyStartPageBundle/ +A minimal Symfony bundle providing an empty start page. +- Controller/: Controllers for the bundle. + +## Migrations/ +Holds database migration files (Doctrine Migrations). May be empty until migrations are generated. + +## Routing/ +Routing extensions and loaders. +- ExtraLoader: additional/dynamic route loading. + +## Security/ +Security‑related concerns. +- Authentication: authentication helpers/integration. +- HashGenerator: password hashing utilities. + +## TestingSupport/ +Utilities to support tests. +- Traits/: Reusable traits and helpers used in the test suite. From 938b090a94c29520af951084439a5a6c95257fbd Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Fri, 31 Oct 2025 12:55:01 +0400 Subject: [PATCH 17/20] Migrations (mysql psql) (#366) * OnlyOrmTablesFilter * Current * Admin * Init migration * In progress * Fix mapping * Fix tests * Migrate * Separate MySql * Use psql * Rename indexes * PostgreSqlPlatform * MySqlSqlPlatform rename indexes * Fix: cs * Fix: test configs * Add migration template * PR agent (#365) * .coderabbit.yaml This reverts commit 2246e490b4fb1385d09a855ac608fc0cd989a2c1. --------- Co-authored-by: Tatevik * rename template * After review 0 * After review 1 * Fix MySql migrations * After review 2 --------- Co-authored-by: Tatevik --- .coderabbit.yaml | 9 + .github/workflows/ci.yml | 2 +- config/PHPMD/rules.xml | 3 +- config/doctrine.yml | 1 + config/doctrine_migrations.yml | 1 + config/services.yml | 5 + resources/Database/Schema.sql | 896 ++++++++++++------ .../Service/SubscriberBlacklistService.php | 4 +- src/Core/Doctrine/OnlyOrmTablesFilter.php | 84 ++ src/Domain/Analytics/Model/LinkTrack.php | 18 +- .../Analytics/Model/LinkTrackForward.php | 6 +- src/Domain/Analytics/Model/LinkTrackMl.php | 4 +- .../Analytics/Model/LinkTrackUmlClick.php | 8 +- .../Analytics/Model/LinkTrackUserClick.php | 10 +- .../Analytics/Model/UserMessageView.php | 6 +- src/Domain/Analytics/Model/UserStats.php | 16 +- .../Model/Interfaces/ModificationDate.php | 10 - src/Domain/Configuration/Model/EventLog.php | 13 +- src/Domain/Configuration/Model/I18n.php | 4 +- src/Domain/Configuration/Model/UrlCache.php | 10 +- .../Command/CleanUpOldSessionTokens.php | 3 - src/Domain/Identity/Model/AdminLogin.php | 2 +- .../Identity/Model/AdminPasswordRequest.php | 1 - src/Domain/Identity/Model/Administrator.php | 131 ++- .../Identity/Model/AdministratorToken.php | 17 +- .../Identity/Service/SessionManager.php | 5 +- .../Messaging/Command/ProcessQueueCommand.php | 3 - src/Domain/Messaging/Model/Bounce.php | 4 +- src/Domain/Messaging/Model/BounceRegex.php | 2 +- src/Domain/Messaging/Model/ListMessage.php | 21 +- src/Domain/Messaging/Model/Message.php | 18 +- .../Messaging/Model/Message/MessageFormat.php | 12 +- .../Model/Message/MessageMetadata.php | 2 +- .../Messaging/Model/MessageAttachment.php | 4 +- src/Domain/Messaging/Model/SendProcess.php | 4 +- src/Domain/Messaging/Model/Template.php | 2 +- src/Domain/Messaging/Model/TemplateImage.php | 2 +- src/Domain/Messaging/Model/UserMessage.php | 10 +- .../Messaging/Model/UserMessageBounce.php | 9 +- .../Messaging/Model/UserMessageForward.php | 6 +- .../Service/Manager/ListMessageManager.php | 1 - src/Domain/Subscription/Model/Subscriber.php | 122 ++- .../Model/SubscriberAttributeDefinition.php | 4 +- .../Model/SubscriberAttributeValue.php | 6 +- .../Subscription/Model/SubscriberHistory.php | 4 +- .../Subscription/Model/SubscriberList.php | 27 +- .../Subscription/Model/Subscription.php | 12 +- .../Subscription/Model/UserBlacklist.php | 25 +- .../Subscription/Model/UserBlacklistData.php | 28 +- .../Manager/SubscriberBlacklistManager.php | 11 +- src/Migrations/.gitkeep | 0 .../Version20251028092901MySqlInit.php | 34 + .../Version20251028092902MySqlUpdate.php | 312 ++++++ .../Version20251031072945PostGreInit.php | 311 ++++++ src/Migrations/_template_migration.php.tpl | 47 + src/Migrations/initial_schema.sql | 879 +++++++++++++++++ src/Routing/ExtraLoader.php | 4 - ...nistratorTokenWithAdministratorFixture.php | 5 +- .../DetachedAdministratorTokenFixture.php | 6 +- .../AdministratorRepositoryTest.php | 6 +- .../AdministratorTokenRepositoryTest.php | 3 +- .../Repository/MessageRepositoryTest.php | 6 +- .../Fixtures/SubscriberListFixture.php | 3 +- .../Service/SubscriberDeletionServiceTest.php | 2 +- .../Identity/Model/AdministratorTest.php | 12 +- .../Identity/Model/AdministratorTokenTest.php | 21 +- .../Subscription/Model/SubscriberTest.php | 4 +- tests/Unit/Security/AuthenticationTest.php | 5 +- 68 files changed, 2683 insertions(+), 585 deletions(-) create mode 100644 .coderabbit.yaml create mode 100644 src/Core/Doctrine/OnlyOrmTablesFilter.php delete mode 100644 src/Migrations/.gitkeep create mode 100644 src/Migrations/Version20251028092901MySqlInit.php create mode 100644 src/Migrations/Version20251028092902MySqlUpdate.php create mode 100644 src/Migrations/Version20251031072945PostGreInit.php create mode 100644 src/Migrations/_template_migration.php.tpl create mode 100644 src/Migrations/initial_schema.sql diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..3cc5119b --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,9 @@ +language: "en-US" +reviews: + profile: "chill" + high_level_summary: true + auto_review: + enabled: true + base_branches: + - ".*" + drafts: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43dea8d7..8e0d5fbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,4 +75,4 @@ jobs: - name: Running PHPMD run: vendor/bin/phpmd src/ text config/PHPMD/rules.xml; - name: Running PHP_CodeSniffer - run: vendor/bin/phpcs --standard=config/PhpCodeSniffer/ bin/ src/ tests/ public/; + run: vendor/bin/phpcs --standard=config/PhpCodeSniffer/ --ignore=*/Migrations/* bin/ src/ tests/ public/; diff --git a/config/PHPMD/rules.xml b/config/PHPMD/rules.xml index 081ed9f3..b8b0c3df 100644 --- a/config/PHPMD/rules.xml +++ b/config/PHPMD/rules.xml @@ -4,6 +4,8 @@ PHPMD rules for phpList + */Migrations/* + @@ -71,5 +73,4 @@ - diff --git a/config/doctrine.yml b/config/doctrine.yml index 327cf305..caaaec71 100644 --- a/config/doctrine.yml +++ b/config/doctrine.yml @@ -11,6 +11,7 @@ doctrine: user: '%database_user%' password: '%database_password%' charset: UTF8 + use_savepoints: true orm: auto_generate_proxy_classes: '%kernel.debug%' diff --git a/config/doctrine_migrations.yml b/config/doctrine_migrations.yml index cd7f9e12..97e3bd6f 100644 --- a/config/doctrine_migrations.yml +++ b/config/doctrine_migrations.yml @@ -4,6 +4,7 @@ doctrine_migrations: # 'TatevikGr\RssBundle\RssFeedBundle\Migrations': '%kernel.project_dir%/vendor/tatevikgr/rss-bundle/src/RssFeedBundle/Migrations' all_or_nothing: true organize_migrations: false + custom_template: '%kernel.project_dir%/src/Migrations/_template_migration.php.tpl' storage: table_storage: table_name: 'doctrine_migration_versions' diff --git a/config/services.yml b/config/services.yml index b21dc5aa..59f32e7a 100644 --- a/config/services.yml +++ b/config/services.yml @@ -45,3 +45,8 @@ services: arguments: - '@annotation_reader' - '%kernel.project_dir%/src/Domain/Model/' + + PhpList\Core\Core\Doctrine\OnlyOrmTablesFilter: + lazy: true + tags: + - { name: 'doctrine.dbal.schema_filter', connection: 'default' } diff --git a/resources/Database/Schema.sql b/resources/Database/Schema.sql index d14d73b3..fa9e6966 100644 --- a/resources/Database/Schema.sql +++ b/resources/Database/Schema.sql @@ -1,207 +1,358 @@ +-- MySQL dump 10.13 Distrib 8.0.43, for Linux (x86_64) +-- +-- Host: localhost Database: phplistdb +-- ------------------------------------------------------ +-- Server version 8.0.43-0ubuntu0.20.04.1+esm1 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `phplist_admin` +-- + DROP TABLE IF EXISTS `phplist_admin`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_admin` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `loginname` varchar(25) NOT NULL, - `namelc` varchar(255) DEFAULT NULL, - `email` varchar(255) NOT NULL, - `created` datetime DEFAULT NULL, - `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `modifiedby` varchar(25) DEFAULT NULL, - `password` varchar(255) DEFAULT NULL, - `passwordchanged` date DEFAULT NULL, - `superuser` tinyint(4) DEFAULT '0', - `disabled` tinyint(4) DEFAULT '0', - `privileges` text, - PRIMARY KEY (`id`), - UNIQUE KEY `loginnameidx` (`loginname`) -) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8; + `id` int NOT NULL AUTO_INCREMENT, + `loginname` varchar(66) NOT NULL, + `namelc` varchar(255) DEFAULT NULL, + `email` varchar(255) NOT NULL, + `created` datetime DEFAULT NULL, + `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `modifiedby` varchar(66) DEFAULT NULL, + `password` varchar(255) DEFAULT NULL, + `passwordchanged` date DEFAULT NULL, + `superuser` tinyint DEFAULT '0', + `disabled` tinyint DEFAULT '0', + `privileges` text, + PRIMARY KEY (`id`), + UNIQUE KEY `loginnameidx` (`loginname`) +) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_admin_attribute` +-- DROP TABLE IF EXISTS `phplist_admin_attribute`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_admin_attribute` ( - `adminattributeid` int(11) NOT NULL, - `adminid` int(11) NOT NULL, - `value` varchar(255) DEFAULT NULL, - PRIMARY KEY (`adminattributeid`,`adminid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; + `adminattributeid` int NOT NULL, + `adminid` int NOT NULL, + `value` varchar(255) DEFAULT NULL, + PRIMARY KEY (`adminattributeid`,`adminid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_admin_login` +-- + +DROP TABLE IF EXISTS `phplist_admin_login`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_admin_login` ( + `id` int NOT NULL AUTO_INCREMENT, + `moment` bigint NOT NULL, + `adminid` int NOT NULL, + `remote_ip4` varchar(32) NOT NULL, + `remote_ip6` varchar(50) NOT NULL, + `sessionid` varchar(50) NOT NULL, + `active` tinyint NOT NULL DEFAULT '0', + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=414 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_admin_password_request` +-- DROP TABLE IF EXISTS `phplist_admin_password_request`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_admin_password_request` ( - `id_key` int(11) NOT NULL AUTO_INCREMENT, - `date` datetime DEFAULT NULL, - `admin` int(11) DEFAULT NULL, - `key_value` varchar(32) NOT NULL, - PRIMARY KEY (`id_key`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; + `id_key` int NOT NULL AUTO_INCREMENT, + `date` datetime DEFAULT NULL, + `admin` int DEFAULT NULL, + `key_value` varchar(32) NOT NULL, + PRIMARY KEY (`id_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_adminattribute` +-- DROP TABLE IF EXISTS `phplist_adminattribute`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_adminattribute` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `name` varchar(255) NOT NULL, - `type` varchar(30) DEFAULT NULL, - `listorder` int(11) DEFAULT NULL, - `default_value` varchar(255) DEFAULT NULL, - `required` tinyint(4) DEFAULT NULL, - `tablename` varchar(255) DEFAULT NULL, - PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `type` varchar(30) DEFAULT NULL, + `listorder` int DEFAULT NULL, + `default_value` varchar(255) DEFAULT NULL, + `required` tinyint DEFAULT NULL, + `tablename` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_admintoken` +-- DROP TABLE IF EXISTS `phplist_admintoken`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_admintoken` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `adminid` int(11) NOT NULL, + `id` int NOT NULL AUTO_INCREMENT, + `adminid` int NOT NULL, `value` varchar(255) DEFAULT NULL, - `entered` int(11) NOT NULL, + `entered` int NOT NULL, `expires` datetime NOT NULL, PRIMARY KEY (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=51 DEFAULT CHARSET=utf8; +) ENGINE=InnoDB AUTO_INCREMENT=3670 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_attachment` +-- DROP TABLE IF EXISTS `phplist_attachment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_attachment` ( - `id` int(11) NOT NULL AUTO_INCREMENT, + `id` int NOT NULL AUTO_INCREMENT, `filename` varchar(255) DEFAULT NULL, `remotefile` varchar(255) DEFAULT NULL, `mimetype` varchar(255) DEFAULT NULL, `description` text, - `size` int(11) DEFAULT NULL, + `size` int DEFAULT NULL, PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_bounce` +-- DROP TABLE IF EXISTS `phplist_bounce`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_bounce` ( - `id` int(11) NOT NULL AUTO_INCREMENT, + `id` int NOT NULL AUTO_INCREMENT, `date` datetime DEFAULT NULL, `header` text, `data` mediumblob, `status` varchar(255) DEFAULT NULL, `comment` text, PRIMARY KEY (`id`), - KEY `dateindex` (`date`) -) ENGINE=InnoDB AUTO_INCREMENT=2168 DEFAULT CHARSET=utf8; - -DROP TABLE IF EXISTS phplist_bounceregex; -CREATE TABLE phplist_bounceregex ( - id int(11) NOT NULL AUTO_INCREMENT, - regex varchar(2083) DEFAULT NULL, - regexhash char(32) DEFAULT NULL, - action varchar(255) DEFAULT NULL, - listorder int(11) DEFAULT '0', - admin int(11) DEFAULT NULL, - comment text, - status varchar(255) DEFAULT NULL, - count int(11) DEFAULT '0', - PRIMARY KEY (id), - UNIQUE KEY regex (regexhash) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; + KEY `dateindex` (`date`), + KEY `statusidx` (`status`(20)) +) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_bounceregex` +-- + +DROP TABLE IF EXISTS `phplist_bounceregex`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_bounceregex` ( + `id` int NOT NULL AUTO_INCREMENT, + `regex` varchar(2083) DEFAULT NULL, + `regexhash` char(32) DEFAULT NULL, + `action` varchar(255) DEFAULT NULL, + `listorder` int DEFAULT '0', + `admin` int DEFAULT NULL, + `comment` text, + `status` varchar(255) DEFAULT NULL, + `count` int DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `regex` (`regexhash`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_bounceregex_bounce` +-- DROP TABLE IF EXISTS `phplist_bounceregex_bounce`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_bounceregex_bounce` ( - `regex` int(11) NOT NULL, - `bounce` int(11) NOT NULL, + `regex` int NOT NULL, + `bounce` int NOT NULL, PRIMARY KEY (`regex`,`bounce`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_config` +-- DROP TABLE IF EXISTS `phplist_config`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_config` ( `item` varchar(35) NOT NULL, `value` longtext, - `editable` tinyint(4) DEFAULT '1', + `editable` tinyint DEFAULT '1', `type` varchar(25) DEFAULT NULL, PRIMARY KEY (`item`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_eventlog` +-- DROP TABLE IF EXISTS `phplist_eventlog`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_eventlog` ( - `id` int(11) NOT NULL AUTO_INCREMENT, + `id` int NOT NULL AUTO_INCREMENT, `entered` datetime DEFAULT NULL, `page` varchar(100) DEFAULT NULL, `entry` text, PRIMARY KEY (`id`), KEY `enteredidx` (`entered`), KEY `pageidx` (`page`) -) ENGINE=InnoDB AUTO_INCREMENT=204119 DEFAULT CHARSET=utf8; +) ENGINE=InnoDB AUTO_INCREMENT=343 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_i18n` +-- DROP TABLE IF EXISTS `phplist_i18n`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_i18n` ( - `lan` varchar(255) NOT NULL, + `lan` varchar(10) NOT NULL, `original` text NOT NULL, `translation` text NOT NULL, - KEY `lanorigidx` (`lan`(50),`original`(200)) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; + UNIQUE KEY `lanorigunq` (`lan`,`original`(200)), + KEY `lanorigidx` (`lan`,`original`(200)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; -DROP TABLE IF EXISTS `phplist_language`; -CREATE TABLE `phplist_language` ( - `iso` varchar(10) DEFAULT NULL, - `name` varchar(255) DEFAULT NULL, - `charset` varchar(100) DEFAULT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +-- +-- Table structure for table `phplist_linktrack` +-- DROP TABLE IF EXISTS `phplist_linktrack`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_linktrack` ( - `linkid` int(11) NOT NULL AUTO_INCREMENT, - `messageid` int(11) NOT NULL, - `userid` int(11) NOT NULL, + `linkid` int NOT NULL AUTO_INCREMENT, + `messageid` int NOT NULL, + `userid` int NOT NULL, `url` varchar(255) DEFAULT NULL, - `forward` text, + `forward` varchar(255) DEFAULT NULL, `firstclick` datetime DEFAULT NULL, - `latestclick` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `clicked` int(11) DEFAULT '0', + `latestclick` timestamp NULL DEFAULT NULL, + `clicked` int DEFAULT '0', PRIMARY KEY (`linkid`), UNIQUE KEY `miduidurlindex` (`messageid`,`userid`,`url`), KEY `midindex` (`messageid`), KEY `uidindex` (`userid`), KEY `urlindex` (`url`), KEY `miduidindex` (`messageid`,`userid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - -DROP TABLE IF EXISTS phplist_linktrack_forward; -CREATE TABLE phplist_linktrack_forward ( - id int(11) NOT NULL AUTO_INCREMENT, - url varchar(2083) DEFAULT NULL, - urlhash char(32) DEFAULT NULL, - personalise tinyint(4) DEFAULT '0', - PRIMARY KEY (id), - UNIQUE KEY urlunique (urlhash), - KEY urlindex (url(255)) -) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_linktrack_forward` +-- + +DROP TABLE IF EXISTS `phplist_linktrack_forward`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_linktrack_forward` ( + `id` int NOT NULL AUTO_INCREMENT, + `url` varchar(2083) DEFAULT NULL, + `urlhash` char(32) DEFAULT NULL, + `uuid` varchar(36) DEFAULT '', + `personalise` tinyint DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `urlunique` (`urlhash`), + KEY `urlindex` (`url`(255)), + KEY `uuididx` (`uuid`) +) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_linktrack_ml` +-- DROP TABLE IF EXISTS `phplist_linktrack_ml`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_linktrack_ml` ( - `messageid` int(11) NOT NULL, - `forwardid` int(11) NOT NULL, + `messageid` int NOT NULL, + `forwardid` int NOT NULL, `firstclick` datetime DEFAULT NULL, `latestclick` datetime DEFAULT NULL, - `total` int(11) DEFAULT '0', - `clicked` int(11) DEFAULT '0', - `htmlclicked` int(11) DEFAULT '0', - `textclicked` int(11) DEFAULT '0', + `total` int DEFAULT '0', + `clicked` int DEFAULT '0', + `htmlclicked` int DEFAULT '0', + `textclicked` int DEFAULT '0', PRIMARY KEY (`messageid`,`forwardid`), KEY `midindex` (`messageid`), KEY `fwdindex` (`forwardid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_linktrack_uml_click` +-- DROP TABLE IF EXISTS `phplist_linktrack_uml_click`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_linktrack_uml_click` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `messageid` int(11) NOT NULL, - `userid` int(11) NOT NULL, - `forwardid` int(11) DEFAULT NULL, + `id` int NOT NULL AUTO_INCREMENT, + `messageid` int NOT NULL, + `userid` int NOT NULL, + `forwardid` int DEFAULT NULL, `firstclick` datetime DEFAULT NULL, `latestclick` datetime DEFAULT NULL, - `clicked` int(11) DEFAULT '0', - `htmlclicked` int(11) DEFAULT '0', - `textclicked` int(11) DEFAULT '0', + `clicked` int DEFAULT '0', + `htmlclicked` int DEFAULT '0', + `textclicked` int DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `miduidfwdid` (`messageid`,`userid`,`forwardid`), KEY `midindex` (`messageid`), KEY `uidindex` (`userid`), KEY `miduidindex` (`messageid`,`userid`) -) ENGINE=InnoDB AUTO_INCREMENT=58434 DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_linktrack_userclick` +-- DROP TABLE IF EXISTS `phplist_linktrack_userclick`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_linktrack_userclick` ( - `linkid` int(11) NOT NULL, - `userid` int(11) NOT NULL, - `messageid` int(11) NOT NULL, + `linkid` int NOT NULL, + `userid` int NOT NULL, + `messageid` int NOT NULL, `name` varchar(255) DEFAULT NULL, `data` text, `date` datetime DEFAULT NULL, @@ -210,52 +361,127 @@ CREATE TABLE `phplist_linktrack_userclick` ( KEY `midindex` (`messageid`), KEY `linkuserindex` (`linkid`,`userid`), KEY `linkusermessageindex` (`linkid`,`userid`,`messageid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_list` +-- DROP TABLE IF EXISTS `phplist_list`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_list` ( - `id` int(11) NOT NULL AUTO_INCREMENT, + `id` int NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, `description` text, `entered` datetime DEFAULT NULL, - `listorder` int(11) DEFAULT NULL, + `listorder` int DEFAULT NULL, `prefix` varchar(10) DEFAULT NULL, `rssfeed` varchar(255) DEFAULT NULL, `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `active` tinyint(4) DEFAULT NULL, - `owner` int(11) DEFAULT NULL, + `active` tinyint DEFAULT NULL, + `owner` int DEFAULT NULL, `category` varchar(255) DEFAULT '', PRIMARY KEY (`id`), KEY `nameidx` (`name`), KEY `listorderidx` (`listorder`) -) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8; +) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_listattr_becities` +-- + +DROP TABLE IF EXISTS `phplist_listattr_becities`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_listattr_becities` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(255) DEFAULT NULL, + `listorder` int DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`(150)) +) ENGINE=InnoDB AUTO_INCREMENT=2680 DEFAULT CHARSET=utf8mb4; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_listattr_termsofservice` +-- + +DROP TABLE IF EXISTS `phplist_listattr_termsofservice`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_listattr_termsofservice` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(255) DEFAULT NULL, + `listorder` int DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`(150)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_listattr_ukcounties` +-- + +DROP TABLE IF EXISTS `phplist_listattr_ukcounties`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_listattr_ukcounties` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(255) DEFAULT NULL, + `listorder` int DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`(150)) +) ENGINE=InnoDB AUTO_INCREMENT=184 DEFAULT CHARSET=utf8mb4; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_listattr_ukcounties1` +-- + +DROP TABLE IF EXISTS `phplist_listattr_ukcounties1`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_listattr_ukcounties1` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(255) DEFAULT NULL, + `listorder` int DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`(150)) +) ENGINE=InnoDB AUTO_INCREMENT=184 DEFAULT CHARSET=utf8mb4; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_listmessage` +-- DROP TABLE IF EXISTS `phplist_listmessage`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_listmessage` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `messageid` int(11) NOT NULL, - `listid` int(11) NOT NULL, + `id` int NOT NULL AUTO_INCREMENT, + `messageid` int NOT NULL, + `listid` int NOT NULL, `entered` datetime DEFAULT NULL, `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `messageid` (`messageid`,`listid`), KEY `listmessageidx` (`listid`,`messageid`) -) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8; +) ENGINE=InnoDB AUTO_INCREMENT=36 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; -DROP TABLE IF EXISTS `phplist_listrss`; -CREATE TABLE `phplist_listrss` ( - `listid` int(11) NOT NULL, - `type` varchar(255) NOT NULL, - `entered` datetime NOT NULL, - `info` text, - KEY `listididx` (`listid`), - KEY `enteredidx` (`entered`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +-- +-- Table structure for table `phplist_listuser` +-- DROP TABLE IF EXISTS `phplist_listuser`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_listuser` ( - `userid` int(11) NOT NULL, - `listid` int(11) NOT NULL, + `userid` int NOT NULL, + `listid` int NOT NULL, `entered` datetime DEFAULT NULL, `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`userid`,`listid`), @@ -263,192 +489,235 @@ CREATE TABLE `phplist_listuser` ( KEY `userlistenteredidx` (`userid`,`listid`,`entered`), KEY `useridx` (`userid`), KEY `listidx` (`listid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_message` +-- DROP TABLE IF EXISTS `phplist_message`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_message` ( - `id` int(11) NOT NULL AUTO_INCREMENT, + `id` int NOT NULL AUTO_INCREMENT, + `uuid` varchar(36) DEFAULT '', `subject` varchar(255) NOT NULL DEFAULT '(no subject)', `fromfield` varchar(255) NOT NULL DEFAULT '', `tofield` varchar(255) NOT NULL DEFAULT '', `replyto` varchar(255) NOT NULL DEFAULT '', - `message` mediumtext, - `textmessage` mediumtext, + `message` longtext, + `textmessage` longtext, `footer` text, `entered` datetime DEFAULT NULL, `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `embargo` datetime DEFAULT NULL, - `repeatinterval` int(11) DEFAULT '0', + `repeatinterval` int DEFAULT '0', `repeatuntil` datetime DEFAULT NULL, - `requeueinterval` int(11) DEFAULT '0', + `requeueinterval` int DEFAULT '0', `requeueuntil` datetime DEFAULT NULL, `status` varchar(255) DEFAULT NULL, `userselection` text, `sent` datetime DEFAULT NULL, - `htmlformatted` tinyint(4) DEFAULT '0', + `htmlformatted` tinyint DEFAULT '0', `sendformat` varchar(20) DEFAULT NULL, - `template` int(11) DEFAULT NULL, - `processed` mediumint(8) unsigned DEFAULT '0', - `astext` int(11) DEFAULT '0', - `ashtml` int(11) DEFAULT '0', - `astextandhtml` int(11) DEFAULT '0', - `aspdf` int(11) DEFAULT '0', - `astextandpdf` int(11) DEFAULT '0', - `viewed` int(11) DEFAULT '0', - `bouncecount` int(11) DEFAULT '0', + `template` int DEFAULT NULL, + `processed` int unsigned DEFAULT '0', + `astext` int DEFAULT '0', + `ashtml` int DEFAULT '0', + `astextandhtml` int DEFAULT '0', + `aspdf` int DEFAULT '0', + `astextandpdf` int DEFAULT '0', + `viewed` int DEFAULT '0', + `bouncecount` int DEFAULT '0', `sendstart` datetime DEFAULT NULL, `rsstemplate` varchar(100) DEFAULT NULL, - `owner` int(11) DEFAULT NULL, - PRIMARY KEY (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8; + `owner` int DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `uuididx` (`uuid`) +) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_message_attachment` +-- DROP TABLE IF EXISTS `phplist_message_attachment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_message_attachment` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `messageid` int(11) NOT NULL, - `attachmentid` int(11) NOT NULL, + `id` int NOT NULL AUTO_INCREMENT, + `messageid` int NOT NULL, + `attachmentid` int NOT NULL, PRIMARY KEY (`id`), KEY `messageidx` (`messageid`), KEY `messageattidx` (`messageid`,`attachmentid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_messagedata` +-- DROP TABLE IF EXISTS `phplist_messagedata`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_messagedata` ( `name` varchar(100) NOT NULL, - `id` int(11) NOT NULL, - `data` text, + `id` int NOT NULL, + `data` longtext, PRIMARY KEY (`name`,`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - -DROP TABLE IF EXISTS `phplist_rssitem`; -CREATE TABLE `phplist_rssitem` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `title` varchar(100) NOT NULL, - `link` varchar(100) NOT NULL, - `source` varchar(255) DEFAULT NULL, - `list` int(11) NOT NULL, - `added` datetime DEFAULT NULL, - `processed` mediumint(8) unsigned DEFAULT '0', - `astext` int(11) DEFAULT '0', - `ashtml` int(11) DEFAULT '0', - PRIMARY KEY (`id`), - KEY `titlelinkidx` (`title`,`link`), - KEY `titleidx` (`title`), - KEY `listidx` (`list`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - -DROP TABLE IF EXISTS `phplist_rssitem_data`; -CREATE TABLE `phplist_rssitem_data` ( - `itemid` int(11) NOT NULL, - `tag` varchar(100) NOT NULL, - `data` text, - PRIMARY KEY (`itemid`,`tag`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; -DROP TABLE IF EXISTS `phplist_rssitem_user`; -CREATE TABLE `phplist_rssitem_user` ( - `itemid` int(11) NOT NULL, - `userid` int(11) NOT NULL, - `entered` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`itemid`,`userid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +-- +-- Table structure for table `phplist_sendprocess` +-- DROP TABLE IF EXISTS `phplist_sendprocess`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_sendprocess` ( - `id` int(11) NOT NULL AUTO_INCREMENT, + `id` int NOT NULL AUTO_INCREMENT, `started` datetime DEFAULT NULL, `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `alive` int(11) DEFAULT '1', + `alive` int DEFAULT '1', `ipaddress` varchar(50) DEFAULT NULL, `page` varchar(100) DEFAULT NULL, PRIMARY KEY (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=utf8; +) ENGINE=InnoDB AUTO_INCREMENT=58 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_subscribepage` +-- DROP TABLE IF EXISTS `phplist_subscribepage`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_subscribepage` ( - `id` int(11) NOT NULL AUTO_INCREMENT, + `id` int NOT NULL AUTO_INCREMENT, `title` varchar(255) NOT NULL, - `active` tinyint(4) DEFAULT '0', - `owner` int(11) DEFAULT NULL, + `active` tinyint DEFAULT '0', + `owner` int DEFAULT NULL, PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_subscribepage_data` +-- DROP TABLE IF EXISTS `phplist_subscribepage_data`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_subscribepage_data` ( - `id` int(11) NOT NULL, + `id` int NOT NULL, `name` varchar(100) NOT NULL, `data` text, PRIMARY KEY (`id`,`name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_template` +-- DROP TABLE IF EXISTS `phplist_template`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_template` ( - `id` int(11) NOT NULL AUTO_INCREMENT, + `id` int NOT NULL AUTO_INCREMENT, `title` varchar(255) NOT NULL, `template` longblob, - `listorder` int(11) DEFAULT NULL, + `template_text` longblob, + `listorder` int DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `title` (`title`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_templateimage` +-- DROP TABLE IF EXISTS `phplist_templateimage`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_templateimage` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `template` int(11) NOT NULL DEFAULT '0', + `id` int NOT NULL AUTO_INCREMENT, + `template` int NOT NULL DEFAULT '0', `mimetype` varchar(100) DEFAULT NULL, `filename` varchar(100) DEFAULT NULL, `data` longblob, - `width` int(11) DEFAULT NULL, - `height` int(11) DEFAULT NULL, + `width` int DEFAULT NULL, + `height` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `templateidx` (`template`) -) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8; +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_urlcache` +-- + +DROP TABLE IF EXISTS `phplist_urlcache`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_urlcache` ( + `id` int NOT NULL AUTO_INCREMENT, + `url` varchar(2083) NOT NULL, + `lastmodified` int DEFAULT NULL, + `added` datetime DEFAULT NULL, + `content` longblob, + PRIMARY KEY (`id`), + KEY `urlindex` (`url`(255)) +) ENGINE=InnoDB AUTO_INCREMENT=207 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; -DROP TABLE IF EXISTS `phplist_translation`; -CREATE TABLE `phplist_translation` ( - `tag` varchar(255) NOT NULL, - `page` varchar(100) NOT NULL, - `lan` varchar(10) NOT NULL, - `translation` text, - KEY `tagidx` (`tag`), - KEY `pageidx` (`page`), - KEY `lanidx` (`lan`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - -DROP TABLE IF EXISTS phplist_urlcache; -CREATE TABLE phplist_urlcache ( - id int(11) NOT NULL AUTO_INCREMENT, - url varchar(2083) NOT NULL, - lastmodified int(11) DEFAULT NULL, - added datetime DEFAULT NULL, - content mediumtext, - PRIMARY KEY (id), - KEY urlindex (url(255)) -) ENGINE=InnoDB AUTO_INCREMENT=20 DEFAULT CHARSET=utf8; +-- +-- Table structure for table `phplist_user_attribute` +-- DROP TABLE IF EXISTS `phplist_user_attribute`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_user_attribute` ( - `id` int(11) NOT NULL AUTO_INCREMENT, + `id` int NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, `type` varchar(30) DEFAULT NULL, - `listorder` int(11) DEFAULT NULL, + `listorder` int DEFAULT NULL, `default_value` varchar(255) DEFAULT NULL, - `required` tinyint(4) DEFAULT NULL, + `required` tinyint DEFAULT NULL, `tablename` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`), KEY `nameindex` (`name`), KEY `idnameindex` (`id`,`name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_user_blacklist` +-- DROP TABLE IF EXISTS `phplist_user_blacklist`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_user_blacklist` ( `email` varchar(255) NOT NULL, `added` datetime DEFAULT NULL, UNIQUE KEY `email` (`email`), KEY `emailidx` (`email`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_user_blacklist_data` +-- DROP TABLE IF EXISTS `phplist_user_blacklist_data`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_user_blacklist_data` ( `email` varchar(150) NOT NULL, `name` varchar(25) NOT NULL, @@ -456,27 +725,41 @@ CREATE TABLE `phplist_user_blacklist_data` ( UNIQUE KEY `email` (`email`), KEY `emailidx` (`email`), KEY `emailnameidx` (`email`,`name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_user_message_bounce` +-- DROP TABLE IF EXISTS `phplist_user_message_bounce`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_user_message_bounce` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `user` int(11) NOT NULL, - `message` int(11) NOT NULL, - `bounce` int(11) NOT NULL, + `id` int NOT NULL AUTO_INCREMENT, + `user` int NOT NULL, + `message` int NOT NULL, + `bounce` int NOT NULL, `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `umbindex` (`user`,`message`,`bounce`), KEY `useridx` (`user`), KEY `msgidx` (`message`), KEY `bounceidx` (`bounce`) -) ENGINE=InnoDB AUTO_INCREMENT=2168 DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_user_message_forward` +-- DROP TABLE IF EXISTS `phplist_user_message_forward`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_user_message_forward` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `user` int(11) NOT NULL, - `message` int(11) NOT NULL, + `id` int NOT NULL AUTO_INCREMENT, + `user` int NOT NULL, + `message` int NOT NULL, `forward` varchar(255) DEFAULT NULL, `status` varchar(255) DEFAULT NULL, `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -484,32 +767,54 @@ CREATE TABLE `phplist_user_message_forward` ( KEY `usermessageidx` (`user`,`message`), KEY `useridx` (`user`), KEY `messageidx` (`message`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_user_message_view` +-- + +DROP TABLE IF EXISTS `phplist_user_message_view`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_user_message_view` ( + `id` int NOT NULL AUTO_INCREMENT, + `messageid` int NOT NULL, + `userid` int NOT NULL, + `viewed` datetime DEFAULT NULL, + `ip` varchar(255) DEFAULT NULL, + `data` longtext, + PRIMARY KEY (`id`), + KEY `usermsgidx` (`userid`,`messageid`), + KEY `msgidx` (`messageid`), + KEY `useridx` (`userid`) +) ENGINE=InnoDB AUTO_INCREMENT=25 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; -DROP TABLE IF EXISTS `phplist_user_rss`; -CREATE TABLE `phplist_user_rss` ( - `userid` int(11) NOT NULL, - `last` datetime DEFAULT NULL, - PRIMARY KEY (`userid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +-- +-- Table structure for table `phplist_user_user` +-- DROP TABLE IF EXISTS `phplist_user_user`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_user_user` ( - `id` int(11) NOT NULL AUTO_INCREMENT, + `id` int NOT NULL AUTO_INCREMENT, `email` varchar(255) NOT NULL, - `confirmed` tinyint(4) DEFAULT '0', - `blacklisted` tinyint(4) DEFAULT '0', - `optedin` tinyint(4) DEFAULT '0', - `bouncecount` int(11) DEFAULT '0', + `confirmed` tinyint DEFAULT '0', + `blacklisted` tinyint DEFAULT '0', + `optedin` tinyint DEFAULT '0', + `bouncecount` int DEFAULT '0', `entered` datetime DEFAULT NULL, `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `uniqid` varchar(255) DEFAULT NULL, - `htmlemail` tinyint(4) DEFAULT '0', - `subscribepage` int(11) DEFAULT NULL, + `uuid` varchar(36) DEFAULT '', + `htmlemail` tinyint DEFAULT '0', + `subscribepage` int DEFAULT NULL, `rssfrequency` varchar(100) DEFAULT NULL, `password` varchar(255) DEFAULT NULL, `passwordchanged` date DEFAULT NULL, - `disabled` tinyint(4) DEFAULT '0', + `disabled` tinyint DEFAULT '0', `extradata` text, `foreignkey` varchar(100) DEFAULT NULL, PRIMARY KEY (`id`), @@ -519,24 +824,39 @@ CREATE TABLE `phplist_user_user` ( KEY `enteredindex` (`entered`), KEY `confidx` (`confirmed`), KEY `blidx` (`blacklisted`), - KEY `optidx` (`optedin`) -) ENGINE=InnoDB AUTO_INCREMENT=102306 DEFAULT CHARSET=utf8; + KEY `optidx` (`optedin`), + KEY `uuididx` (`uuid`) +) ENGINE=InnoDB AUTO_INCREMENT=46 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_user_user_attribute` +-- DROP TABLE IF EXISTS `phplist_user_user_attribute`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_user_user_attribute` ( - `attributeid` int(11) NOT NULL, - `userid` int(11) NOT NULL, - `value` varchar(255) DEFAULT NULL, + `attributeid` int NOT NULL, + `userid` int NOT NULL, + `value` text, PRIMARY KEY (`attributeid`,`userid`), KEY `userindex` (`userid`), KEY `attindex` (`attributeid`), KEY `attuserid` (`userid`,`attributeid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_user_user_history` +-- DROP TABLE IF EXISTS `phplist_user_user_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_user_user_history` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `userid` int(11) NOT NULL, + `id` int NOT NULL AUTO_INCREMENT, + `userid` int NOT NULL, `ip` varchar(255) DEFAULT NULL, `date` datetime DEFAULT NULL, `summary` varchar(255) DEFAULT NULL, @@ -545,12 +865,19 @@ CREATE TABLE `phplist_user_user_history` ( PRIMARY KEY (`id`), KEY `userididx` (`userid`), KEY `dateidx` (`date`) -) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8; +) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_usermessage` +-- DROP TABLE IF EXISTS `phplist_usermessage`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_usermessage` ( - `messageid` int(11) NOT NULL, - `userid` int(11) NOT NULL, + `messageid` int NOT NULL, + `userid` int NOT NULL, `entered` datetime NOT NULL, `viewed` datetime DEFAULT NULL, `status` varchar(255) DEFAULT NULL, @@ -560,19 +887,38 @@ CREATE TABLE `phplist_usermessage` ( KEY `enteredindex` (`entered`), KEY `statusidx` (`status`), KEY `viewedidx` (`viewed`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_userstats` +-- DROP TABLE IF EXISTS `phplist_userstats`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `phplist_userstats` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `unixdate` int(11) DEFAULT NULL, + `id` int NOT NULL AUTO_INCREMENT, + `unixdate` int DEFAULT NULL, `item` varchar(255) DEFAULT NULL, - `listid` int(11) DEFAULT '0', - `value` int(11) DEFAULT '0', + `listid` int DEFAULT '0', + `value` int DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `entry` (`unixdate`,`item`,`listid`), KEY `dateindex` (`unixdate`), KEY `itemindex` (`item`), KEY `listindex` (`listid`), KEY `listdateindex` (`listid`,`unixdate`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2025-10-28 14:22:41 diff --git a/src/Bounce/Service/SubscriberBlacklistService.php b/src/Bounce/Service/SubscriberBlacklistService.php index eee70215..38d37e7d 100644 --- a/src/Bounce/Service/SubscriberBlacklistService.php +++ b/src/Bounce/Service/SubscriberBlacklistService.php @@ -40,7 +40,7 @@ public function blacklist(Subscriber $subscriber, string $reason): void { $subscriber->setBlacklisted(true); $this->entityManager->flush(); - $this->blacklistManager->addEmailToBlacklist($subscriber->getEmail(), $reason); + $userBlacklist = $this->blacklistManager->addEmailToBlacklist($subscriber->getEmail(), $reason); $this->entityManager->flush(); foreach (['REMOTE_ADDR','HTTP_X_FORWARDED_FOR'] as $item) { @@ -50,7 +50,7 @@ public function blacklist(Subscriber $subscriber, string $reason): void } if ($request->server->get($item)) { $this->blacklistManager->addBlacklistData( - email: $subscriber->getEmail(), + userBlacklist: $userBlacklist, name: $item, data: $request->server->get($item) ); diff --git a/src/Core/Doctrine/OnlyOrmTablesFilter.php b/src/Core/Doctrine/OnlyOrmTablesFilter.php new file mode 100644 index 00000000..69097fcd --- /dev/null +++ b/src/Core/Doctrine/OnlyOrmTablesFilter.php @@ -0,0 +1,84 @@ +getName(); + $pos = strrpos($name, '.'); + if (false !== $pos) { + $name = substr($name, $pos + 1); + } + $nameLower = strtolower($name); + + [$allow, $allowPrefixes] = $this->buildAllowOnce(); + + if (\is_string($asset) || $asset instanceof Table) { + return \in_array($nameLower, $allow, true); + } + + // PostgreSQL sequences: allow those that belong to our ORM tables + // (default naming: {table}_{column}_seq, so we check table_ prefix) + if ($asset instanceof Sequence) { + foreach ($allowPrefixes as $prefix) { + if (str_starts_with($nameLower, $prefix)) { + return true; + } + } + // Disallow unrelated sequences + return false; + } + + // Other dependent assets (indexes, FKs) are tied to allowed tables → allow + return true; + } + + private function buildAllowOnce(): array + { + if ($this->allow !== null) { + return [$this->allow, $this->allowPrefixes]; + } + + $tables = []; + foreach ($this->entityManager->getMetadataFactory()->getAllMetadata() as $metadatum) { + $tableName = $metadatum->getTableName(); + if ($tableName) { + $tables[] = strtolower($tableName); + } + // many-to-many join tables + foreach ($metadatum->getAssociationMappings() as $assoc) { + if (!empty($assoc['joinTable']['name'])) { + $tables[] = strtolower($assoc['joinTable']['name']); + } + } + } + + $tables[] = 'doctrine_migration_versions'; + + $tables = array_values(array_unique($tables)); + $prefixes = array_map(static fn($table) => $table . '_', $tables); + + $this->allow = $tables; + $this->allowPrefixes = $prefixes; + + return [$this->allow, $this->allowPrefixes]; + } +} diff --git a/src/Domain/Analytics/Model/LinkTrack.php b/src/Domain/Analytics/Model/LinkTrack.php index 2dc32de6..848dde5e 100644 --- a/src/Domain/Analytics/Model/LinkTrack.php +++ b/src/Domain/Analytics/Model/LinkTrack.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Domain\Analytics\Model; +use DateTime; use DateTimeInterface; use Doctrine\ORM\Mapping as ORM; use PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository; @@ -12,11 +13,11 @@ #[ORM\Entity(repositoryClass: LinkTrackRepository::class)] #[ORM\Table(name: 'phplist_linktrack')] -#[ORM\UniqueConstraint(name: 'miduidurlindex', columns: ['messageid', 'userid', 'url'])] -#[ORM\Index(name: 'midindex', columns: ['messageid'])] -#[ORM\Index(name: 'miduidindex', columns: ['messageid', 'userid'])] -#[ORM\Index(name: 'uidindex', columns: ['userid'])] -#[ORM\Index(name: 'urlindex', columns: ['url'])] +#[ORM\UniqueConstraint(name: 'phplist_linktrack_miduidurlindex', columns: ['messageid', 'userid', 'url'])] +#[ORM\Index(name: 'phplist_linktrack_midindex', columns: ['messageid'])] +#[ORM\Index(name: 'phplist_linktrack_miduidindex', columns: ['messageid', 'userid'])] +#[ORM\Index(name: 'phplist_linktrack_uidindex', columns: ['userid'])] +#[ORM\Index(name: 'phplist_linktrack_urlindex', columns: ['url'])] class LinkTrack implements DomainModel, Identity { #[ORM\Id] @@ -39,12 +40,17 @@ class LinkTrack implements DomainModel, Identity #[ORM\Column(name: 'firstclick', type: 'datetime', nullable: true)] private ?DateTimeInterface $firstClick = null; - #[ORM\Column(name: 'latestclick', type: 'datetime', nullable: true, options: ['default' => 'CURRENT_TIMESTAMP'])] + #[ORM\Column(name: 'latestclick', type: 'datetime')] private ?DateTimeInterface $latestClick = null; #[ORM\Column(type: 'integer', nullable: true, options: ['default' => 0])] private int $clicked = 0; + public function __construct() + { + $this->latestClick = new DateTime(); + } + public function getId(): int { return $this->id; diff --git a/src/Domain/Analytics/Model/LinkTrackForward.php b/src/Domain/Analytics/Model/LinkTrackForward.php index 17049dfc..0e03c017 100644 --- a/src/Domain/Analytics/Model/LinkTrackForward.php +++ b/src/Domain/Analytics/Model/LinkTrackForward.php @@ -11,9 +11,9 @@ #[ORM\Entity(repositoryClass: LinkTrackForwardRepository::class)] #[ORM\Table(name: 'phplist_linktrack_forward')] -#[ORM\UniqueConstraint(name: 'urlunique', columns: ['urlhash'])] -#[ORM\Index(name: 'urlindex', columns: ['url'])] -#[ORM\Index(name: 'uuididx', columns: ['uuid'])] +#[ORM\UniqueConstraint(name: 'phplist_linktrack_forward_urlunique', columns: ['urlhash'])] +#[ORM\Index(name: 'phplist_linktrack_forward_urlindex', columns: ['url'])] +#[ORM\Index(name: 'phplist_linktrack_forward_uuididx', columns: ['uuid'])] class LinkTrackForward implements DomainModel, Identity { #[ORM\Id] diff --git a/src/Domain/Analytics/Model/LinkTrackMl.php b/src/Domain/Analytics/Model/LinkTrackMl.php index 8569fa14..419c7911 100644 --- a/src/Domain/Analytics/Model/LinkTrackMl.php +++ b/src/Domain/Analytics/Model/LinkTrackMl.php @@ -11,8 +11,8 @@ #[ORM\Entity(repositoryClass: LinkTrackMlRepository::class)] #[ORM\Table(name: 'phplist_linktrack_ml')] -#[ORM\Index(name: 'fwdindex', columns: ['forwardid'])] -#[ORM\Index(name: 'midindex', columns: ['messageid'])] +#[ORM\Index(name: 'phplist_linktrack_ml_fwdindex', columns: ['forwardid'])] +#[ORM\Index(name: 'phplist_linktrack_ml_midindex', columns: ['messageid'])] class LinkTrackMl implements DomainModel { #[ORM\Id] diff --git a/src/Domain/Analytics/Model/LinkTrackUmlClick.php b/src/Domain/Analytics/Model/LinkTrackUmlClick.php index 2b8c8068..3faf811d 100644 --- a/src/Domain/Analytics/Model/LinkTrackUmlClick.php +++ b/src/Domain/Analytics/Model/LinkTrackUmlClick.php @@ -12,10 +12,10 @@ #[ORM\Entity(repositoryClass: LinkTrackUmlClickRepository::class)] #[ORM\Table(name: 'phplist_linktrack_uml_click')] -#[ORM\UniqueConstraint(name: 'miduidfwdid', columns: ['messageid', 'userid', 'forwardid'])] -#[ORM\Index(name: 'midindex', columns: ['messageid'])] -#[ORM\Index(name: 'miduidindex', columns: ['messageid', 'userid'])] -#[ORM\Index(name: 'uidindex', columns: ['userid'])] +#[ORM\UniqueConstraint(name: 'phplist_linktrack_uml_click_miduidfwdid', columns: ['messageid', 'userid', 'forwardid'])] +#[ORM\Index(name: 'phplist_linktrack_uml_click_midindex', columns: ['messageid'])] +#[ORM\Index(name: 'phplist_linktrack_uml_click_miduidindex', columns: ['messageid', 'userid'])] +#[ORM\Index(name: 'phplist_linktrack_uml_click_uidindex', columns: ['userid'])] class LinkTrackUmlClick implements DomainModel, Identity { #[ORM\Id] diff --git a/src/Domain/Analytics/Model/LinkTrackUserClick.php b/src/Domain/Analytics/Model/LinkTrackUserClick.php index 464ae3e0..27205cbb 100644 --- a/src/Domain/Analytics/Model/LinkTrackUserClick.php +++ b/src/Domain/Analytics/Model/LinkTrackUserClick.php @@ -11,11 +11,11 @@ #[ORM\Entity(repositoryClass: LinkTrackUserClickRepository::class)] #[ORM\Table(name: 'phplist_linktrack_userclick')] -#[ORM\Index(name: 'linkindex', columns: ['linkid'])] -#[ORM\Index(name: 'linkuserindex', columns: ['linkid', 'userid'])] -#[ORM\Index(name: 'linkusermessageindex', columns: ['linkid', 'userid', 'messageid'])] -#[ORM\Index(name: 'midindex', columns: ['messageid'])] -#[ORM\Index(name: 'uidindex', columns: ['userid'])] +#[ORM\Index(name: 'phplist_linktrack_userclick_linkindex', columns: ['linkid'])] +#[ORM\Index(name: 'phplist_linktrack_userclick_linkuserindex', columns: ['linkid', 'userid'])] +#[ORM\Index(name: 'phplist_linktrack_userclick_linkusermessageindex', columns: ['linkid', 'userid', 'messageid'])] +#[ORM\Index(name: 'phplist_linktrack_userclick_midindex', columns: ['messageid'])] +#[ORM\Index(name: 'phplist_linktrack_userclick_uidindex', columns: ['userid'])] class LinkTrackUserClick implements DomainModel { #[ORM\Id] diff --git a/src/Domain/Analytics/Model/UserMessageView.php b/src/Domain/Analytics/Model/UserMessageView.php index 6b80b75e..846c8e97 100644 --- a/src/Domain/Analytics/Model/UserMessageView.php +++ b/src/Domain/Analytics/Model/UserMessageView.php @@ -12,9 +12,9 @@ #[ORM\Entity(repositoryClass: UserMessageViewRepository::class)] #[ORM\Table(name: 'phplist_user_message_view')] -#[ORM\Index(name: 'msgidx', columns: ['messageid'])] -#[ORM\Index(name: 'useridx', columns: ['userid'])] -#[ORM\Index(name: 'usermsgidx', columns: ['userid', 'messageid'])] +#[ORM\Index(name: 'phplist_user_message_view_msgidx', columns: ['messageid'])] +#[ORM\Index(name: 'phplist_user_message_view_useridx', columns: ['userid'])] +#[ORM\Index(name: 'phplist_user_message_view_usermsgidx', columns: ['userid', 'messageid'])] class UserMessageView implements DomainModel, Identity { #[ORM\Id] diff --git a/src/Domain/Analytics/Model/UserStats.php b/src/Domain/Analytics/Model/UserStats.php index a2f835bf..c7b4b97e 100644 --- a/src/Domain/Analytics/Model/UserStats.php +++ b/src/Domain/Analytics/Model/UserStats.php @@ -11,11 +11,11 @@ #[ORM\Entity(repositoryClass: UserStatsRepository::class)] #[ORM\Table(name: 'phplist_userstats')] -#[ORM\UniqueConstraint(name: 'entry', columns: ['unixdate', 'item', 'listid'])] -#[ORM\Index(name: 'dateindex', columns: ['unixdate'])] -#[ORM\Index(name: 'itemindex', columns: ['item'])] -#[ORM\Index(name: 'listdateindex', columns: ['listid', 'unixdate'])] -#[ORM\Index(name: 'listindex', columns: ['listid'])] +#[ORM\UniqueConstraint(name: 'phplist_userstats_entry', columns: ['unixdate', 'item', 'listid'])] +#[ORM\Index(name: 'phplist_userstats_dateindex', columns: ['unixdate'])] +#[ORM\Index(name: 'phplist_userstats_itemindex', columns: ['item'])] +#[ORM\Index(name: 'phplist_userstats_listdateindex', columns: ['listid', 'unixdate'])] +#[ORM\Index(name: 'phplist_userstats_listindex', columns: ['listid'])] class UserStats implements DomainModel, Identity { #[ORM\Id] @@ -29,11 +29,11 @@ class UserStats implements DomainModel, Identity #[ORM\Column(name: 'item', type: 'string', length: 255, nullable: true)] private ?string $item = null; - #[ORM\Column(name: 'listid', type: 'integer', options: ['default' => 0])] + #[ORM\Column(name: 'listid', type: 'integer', nullable: true, options: ['default' => 0])] private int $listId = 0; - #[ORM\Column(name: 'value', type: 'integer', options: ['default' => 0])] - private int $value = 0; + #[ORM\Column(name: 'value', type: 'integer', nullable: true, options: ['default' => 0])] + private ?int $value = null; public function getId(): ?int { diff --git a/src/Domain/Common/Model/Interfaces/ModificationDate.php b/src/Domain/Common/Model/Interfaces/ModificationDate.php index 22432b2f..11c383e2 100644 --- a/src/Domain/Common/Model/Interfaces/ModificationDate.php +++ b/src/Domain/Common/Model/Interfaces/ModificationDate.php @@ -20,14 +20,4 @@ interface ModificationDate * @return DateTime|null */ public function getUpdatedAt(): ?DateTime; - - /** - * Updates the modification date to be now. - * - * @Mapping\PrePersist - * @Mapping\PreUpdate - * - * @return DomainModel - */ - public function updateUpdatedAt(): DomainModel; } diff --git a/src/Domain/Configuration/Model/EventLog.php b/src/Domain/Configuration/Model/EventLog.php index 170171ee..c0cff22b 100644 --- a/src/Domain/Configuration/Model/EventLog.php +++ b/src/Domain/Configuration/Model/EventLog.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Domain\Configuration\Model; +use DateTimeImmutable; use DateTimeInterface; use Doctrine\ORM\Mapping as ORM; use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; @@ -12,8 +13,9 @@ #[ORM\Entity(repositoryClass: EventLogRepository::class)] #[ORM\Table(name: 'phplist_eventlog')] -#[ORM\Index(name: 'enteredidx', columns: ['entered'])] -#[ORM\Index(name: 'pageidx', columns: ['page'])] +#[ORM\Index(name: 'phplist_eventlog_enteredidx', columns: ['entered'])] +#[ORM\Index(name: 'phplist_eventlog_pageidx', columns: ['page'])] +#[ORM\HasLifecycleCallbacks] class EventLog implements DomainModel, Identity { #[ORM\Id] @@ -35,6 +37,13 @@ public function getId(): ?int return $this->id; } + #[ORM\PrePersist] + public function setCreatedTimestamps(): void + { + $now = new DateTimeImmutable(); + $this->entered = $now; + } + public function getEntered(): ?DateTimeInterface { return $this->entered; diff --git a/src/Domain/Configuration/Model/I18n.php b/src/Domain/Configuration/Model/I18n.php index b8eefd63..72397bb4 100644 --- a/src/Domain/Configuration/Model/I18n.php +++ b/src/Domain/Configuration/Model/I18n.php @@ -15,8 +15,8 @@ */ #[ORM\Entity(repositoryClass: I18nRepository::class)] #[ORM\Table(name: 'phplist_i18n')] -#[ORM\UniqueConstraint(name: 'lanorigunq', columns: ['lan', 'original'])] -#[ORM\Index(name: 'lanorigidx', columns: ['lan', 'original'])] +#[ORM\UniqueConstraint(name: 'phplist_i18n_lanorigunq', columns: ['lan', 'original'])] +#[ORM\Index(name: 'phplist_i18n_lanorigidx', columns: ['lan', 'original'])] class I18n implements DomainModel { #[ORM\Id] diff --git a/src/Domain/Configuration/Model/UrlCache.php b/src/Domain/Configuration/Model/UrlCache.php index 5d460104..b6d032b9 100644 --- a/src/Domain/Configuration/Model/UrlCache.php +++ b/src/Domain/Configuration/Model/UrlCache.php @@ -12,7 +12,8 @@ #[ORM\Entity(repositoryClass: UrlCacheRepository::class)] #[ORM\Table(name: 'phplist_urlcache')] -#[ORM\Index(name: 'urlindex', columns: ['url'])] +#[ORM\Index(name: 'phplist_urlcache_urlindex', columns: ['url'])] +#[ORM\HasLifecycleCallbacks] class UrlCache implements DomainModel, Identity { #[ORM\Id] @@ -71,10 +72,11 @@ public function setLastModified(?int $lastModified): self return $this; } - public function setAdded(?DateTime $added): self + #[ORM\PrePersist] + public function setCreatedTimestamps(): void { - $this->added = $added; - return $this; + $now = new DateTime(); + $this->added = $now; } public function setContent(?string $content): self diff --git a/src/Domain/Identity/Command/CleanUpOldSessionTokens.php b/src/Domain/Identity/Command/CleanUpOldSessionTokens.php index 820db648..981a5210 100644 --- a/src/Domain/Identity/Command/CleanUpOldSessionTokens.php +++ b/src/Domain/Identity/Command/CleanUpOldSessionTokens.php @@ -28,9 +28,6 @@ public function __construct(AdministratorTokenRepository $tokenRepository, Entit $this->entityManager = $entityManager; } - /** - * @SuppressWarnings("PHPMD.UnusedFormalParameter") - */ protected function execute(InputInterface $input, OutputInterface $output): int { try { diff --git a/src/Domain/Identity/Model/AdminLogin.php b/src/Domain/Identity/Model/AdminLogin.php index b7c30e58..91be3331 100644 --- a/src/Domain/Identity/Model/AdminLogin.php +++ b/src/Domain/Identity/Model/AdminLogin.php @@ -37,7 +37,7 @@ class AdminLogin implements DomainModel, Identity #[ORM\Column(name: 'sessionid', type: 'string', length: 50)] private string $sessionId; - #[ORM\Column(name: 'active', type: 'boolean')] + #[ORM\Column(name: 'active', type: 'boolean', nullable: false)] private bool $active = false; public function __construct( diff --git a/src/Domain/Identity/Model/AdminPasswordRequest.php b/src/Domain/Identity/Model/AdminPasswordRequest.php index c06a45eb..0d761adf 100644 --- a/src/Domain/Identity/Model/AdminPasswordRequest.php +++ b/src/Domain/Identity/Model/AdminPasswordRequest.php @@ -12,7 +12,6 @@ #[ORM\Entity(repositoryClass: AdminPasswordRequestRepository::class)] #[ORM\Table(name: 'phplist_admin_password_request')] -#[ORM\HasLifecycleCallbacks] class AdminPasswordRequest implements DomainModel, Identity { #[ORM\Id] diff --git a/src/Domain/Identity/Model/Administrator.php b/src/Domain/Identity/Model/Administrator.php index c22b4b9f..09ba46e0 100644 --- a/src/Domain/Identity/Model/Administrator.php +++ b/src/Domain/Identity/Model/Administrator.php @@ -23,6 +23,7 @@ */ #[ORM\Entity(repositoryClass: AdministratorRepository::class)] #[ORM\Table(name: 'phplist_admin')] +#[ORM\UniqueConstraint(name: 'phplist_admin_loginnameidx', columns: ['loginname'])] #[ORM\HasLifecycleCallbacks] class Administrator implements DomainModel, Identity, CreationDate, ModificationDate { @@ -31,50 +32,59 @@ class Administrator implements DomainModel, Identity, CreationDate, Modification #[ORM\GeneratedValue] private ?int $id = null; - #[ORM\Column(name: 'created', type: 'datetime')] - protected DateTime $createdAt; + #[ORM\Column(name: 'created', type: 'datetime', nullable: false)] + protected ?DateTime $createdAt = null; - #[ORM\Column(name: 'modified', type: 'datetime')] - private ?DateTime $updatedAt; + #[ORM\Column(name: 'modified', type: 'datetime', nullable: false)] + private DateTime $updatedAt; - #[ORM\Column(name: 'loginname')] + #[ORM\Column(name: 'loginname', type: 'string', length: 66, nullable: false)] private string $loginName; - #[ORM\Column(name: 'namelc', nullable: true)] - private string $namelc; + #[ORM\Column(name: 'namelc', type: 'string', length: 255, nullable: true)] + private ?string $namelc = null; - #[ORM\Column(name: 'email')] + #[ORM\Column(name: 'email', type: 'string', length: 255, nullable: false)] private string $email; #[ORM\Column(name: 'modifiedby', type: 'string', length: 66, nullable: true)] - private ?string $modifiedBy; + private ?string $modifiedBy = null; - #[ORM\Column(name: 'password')] - private string $passwordHash; + #[ORM\Column(name: 'password', type: 'string', length: 255, nullable: true)] + private ?string $passwordHash = null; #[ORM\Column(name: 'passwordchanged', type: 'date', nullable: true)] - private ?DateTime $passwordChangeDate; + private ?DateTime $passwordChangeDate = null; - #[ORM\Column(type: 'boolean')] - private bool $disabled; + #[ORM\Column(name: 'disabled', type: 'boolean', nullable: false)] + private bool $disabled = false; - #[ORM\Column(name: 'superuser', type: 'boolean')] - private bool $superUser; + #[ORM\Column(name: 'superuser', type: 'boolean', nullable: false)] + private bool $superUser = false; #[ORM\Column(name: 'privileges', type: 'text', nullable: true)] - private ?string $privileges; + private ?string $privileges = null; public function __construct() { - $this->disabled = false; - $this->superUser = false; - $this->passwordChangeDate = null; - $this->loginName = ''; - $this->passwordHash = ''; $this->createdAt = new DateTime(); - $this->updatedAt = null; + $this->updatedAt = new DateTime(); $this->email = ''; - $this->privileges = null; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?DateTime + { + return $this->createdAt; + } + + public function getUpdatedAt(): DateTime + { + return $this->updatedAt; } public function getLoginName(): string @@ -85,7 +95,6 @@ public function getLoginName(): string public function setLoginName(string $loginName): self { $this->loginName = $loginName; - return $this; } @@ -97,20 +106,29 @@ public function getEmail(): string public function setEmail(string $email): self { $this->email = $email; + return $this; + } + public function getNameLc(): ?string + { + return $this->namelc; + } + + public function setNameLc(?string $nameLc): self + { + $this->namelc = $nameLc; return $this; } - public function getPasswordHash(): string + public function getPasswordHash(): ?string { return $this->passwordHash; } - public function setPasswordHash(string $passwordHash): self + public function setPasswordHash(?string $passwordHash): self { $this->passwordHash = $passwordHash; - $this->passwordChangeDate = new DateTime(); - + $this->passwordChangeDate = $passwordHash !== null ? new DateTime() : null; return $this; } @@ -127,7 +145,6 @@ public function isDisabled(): bool public function setDisabled(bool $disabled): self { $this->disabled = $disabled; - return $this; } @@ -139,32 +156,19 @@ public function isSuperUser(): bool public function setSuperUser(bool $superUser): self { $this->superUser = $superUser; - return $this; } - public function setNameLc(string $nameLc): self - { - $this->namelc = $nameLc; - - return $this; - } - - public function getNameLc(): string - { - return $this->namelc; - } - public function setPrivileges(Privileges $privileges): self { $this->privileges = $privileges->toSerialized(); - return $this; } /** - * @SuppressWarnings(PHPMD.StaticAccess) * @throws InvalidArgumentException + * + * @SuppressWarnings(PHPMD.StaticAccess) */ public function setPrivilegesFromArray(array $privilegesData): void { @@ -172,46 +176,19 @@ public function setPrivilegesFromArray(array $privilegesData): void foreach ($privilegesData as $key => $value) { $flag = PrivilegeFlag::tryFrom($key); if (!$flag) { - throw new InvalidArgumentException('Unknown privilege key: '. $key); + throw new InvalidArgumentException('Unknown privilege key: ' . $key); } - $privileges = $value ? $privileges->grant($flag) : $privileges->revoke($flag); } $this->setPrivileges($privileges); } - /** - * @SuppressWarnings(PHPMD.StaticAccess) - */ + /** @SuppressWarnings(PHPMD.StaticAccess) */ public function getPrivileges(): Privileges { return Privileges::fromSerialized($this->privileges); } - public function getCreatedAt(): ?DateTime - { - return $this->createdAt; - } - - public function getId(): ?int - { - return $this->id; - } - - public function getUpdatedAt(): ?DateTime - { - return $this->updatedAt; - } - - #[ORM\PrePersist] - #[ORM\PreUpdate] - public function updateUpdatedAt(): DomainModel - { - $this->updatedAt = new DateTime(); - - return $this; - } - public function setModifiedBy(?string $modifiedBy): self { $this->modifiedBy = $modifiedBy; @@ -231,4 +208,10 @@ public function owns(OwnableInterface $resource): bool return $resource->getOwner()->getId() === $this->getId(); } + + #[ORM\PreUpdate] + public function setUpdatedAt(): void + { + $this->updatedAt = new DateTime(); + } } diff --git a/src/Domain/Identity/Model/AdministratorToken.php b/src/Domain/Identity/Model/AdministratorToken.php index 1a6c511f..4e37b2b5 100644 --- a/src/Domain/Identity/Model/AdministratorToken.php +++ b/src/Domain/Identity/Model/AdministratorToken.php @@ -7,7 +7,6 @@ use DateTime; use DateTimeZone; use Doctrine\ORM\Mapping as ORM; -use Doctrine\Persistence\Proxy; use PhpList\Core\Domain\Common\Model\Interfaces\CreationDate; use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Common\Model\Interfaces\Identity; @@ -44,11 +43,13 @@ class AdministratorToken implements DomainModel, Identity, CreationDate #[ORM\ManyToOne(targetEntity: Administrator::class)] #[ORM\JoinColumn(name: 'adminid', referencedColumnName: 'id', onDelete: 'CASCADE')] - private ?Administrator $administrator = null; + private Administrator $administrator; - public function __construct() + public function __construct(Administrator $administrator) { - $this->setExpiry(new DateTime()); + $this->generateExpiry(); + $this->generateKey(); + $this->administrator = $administrator; } public function getId(): ?int @@ -112,14 +113,8 @@ public function generateKey(): self return $this; } - public function getAdministrator(): Administrator|Proxy|null + public function getAdministrator(): Administrator { return $this->administrator; } - - public function setAdministrator(Administrator $administrator): self - { - $this->administrator = $administrator; - return $this; - } } diff --git a/src/Domain/Identity/Service/SessionManager.php b/src/Domain/Identity/Service/SessionManager.php index 658799f7..cfeaa706 100644 --- a/src/Domain/Identity/Service/SessionManager.php +++ b/src/Domain/Identity/Service/SessionManager.php @@ -47,10 +47,7 @@ public function createSession(string $loginName, string $password): Administrato throw new UnauthorizedHttpException('', $message, null, 1500567099); } - $token = new AdministratorToken(); - $token->setAdministrator($administrator); - $token->generateExpiry(); - $token->generateKey(); + $token = new AdministratorToken($administrator); $this->tokenRepository->persist($token); return $token; diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php index 600246cb..ec2d1af8 100644 --- a/src/Domain/Messaging/Command/ProcessQueueCommand.php +++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php @@ -57,9 +57,6 @@ public function __construct( $this->entityManager = $entityManager; } - /** - * @SuppressWarnings("PHPMD.UnusedFormalParameter") - */ protected function execute(InputInterface $input, OutputInterface $output): int { $lock = $this->lockFactory->createLock('queue_processor'); diff --git a/src/Domain/Messaging/Model/Bounce.php b/src/Domain/Messaging/Model/Bounce.php index 8d665d72..dbcb5106 100644 --- a/src/Domain/Messaging/Model/Bounce.php +++ b/src/Domain/Messaging/Model/Bounce.php @@ -12,8 +12,8 @@ #[ORM\Entity(repositoryClass: BounceRepository::class)] #[ORM\Table(name: 'phplist_bounce')] -#[ORM\Index(name: 'dateindex', columns: ['date'])] -#[ORM\Index(name: 'statusidx', columns: ['status'])] +#[ORM\Index(name: 'phplist_bounce_dateindex', columns: ['date'])] +#[ORM\Index(name: 'phplist_bounce_statusidx', columns: ['status'])] class Bounce implements DomainModel, Identity { #[ORM\Id] diff --git a/src/Domain/Messaging/Model/BounceRegex.php b/src/Domain/Messaging/Model/BounceRegex.php index 0401d26b..c54ca7c0 100644 --- a/src/Domain/Messaging/Model/BounceRegex.php +++ b/src/Domain/Messaging/Model/BounceRegex.php @@ -11,7 +11,7 @@ #[ORM\Entity(repositoryClass: BounceRegexRepository::class)] #[ORM\Table(name: 'phplist_bounceregex')] -#[ORM\UniqueConstraint(name: 'regex', columns: ['regexhash'])] +#[ORM\UniqueConstraint(name: 'phplist_bounceregex_regex', columns: ['regexhash'])] class BounceRegex implements DomainModel, Identity { #[ORM\Id] diff --git a/src/Domain/Messaging/Model/ListMessage.php b/src/Domain/Messaging/Model/ListMessage.php index a9751e7f..0a488bdd 100644 --- a/src/Domain/Messaging/Model/ListMessage.php +++ b/src/Domain/Messaging/Model/ListMessage.php @@ -15,8 +15,8 @@ #[ORM\Entity(repositoryClass: ListMessageRepository::class)] #[ORM\Table(name: 'phplist_listmessage')] -#[ORM\UniqueConstraint(name: 'messageid', columns: ['messageid', 'listid'])] -#[ORM\Index(name: 'listmessageidx', columns: ['listid', 'messageid'])] +#[ORM\UniqueConstraint(name: 'phplist_listmessage_messageid', columns: ['messageid', 'listid'])] +#[ORM\Index(name: 'phplist_listmessage_listmessageidx', columns: ['listid', 'messageid'])] #[ORM\HasLifecycleCallbacks] class ListMessage implements DomainModel, Identity, ModificationDate { @@ -39,6 +39,12 @@ class ListMessage implements DomainModel, Identity, ModificationDate #[ORM\Column(name: 'modified', type: 'datetime')] private ?DateTime $updatedAt = null; + public function __construct() + { + $this->updatedAt = new DateTime(); + $this->entered = new DateTime(); + } + public function getId(): ?int { return $this->id; @@ -71,23 +77,14 @@ public function getEntered(): ?DateTimeInterface return $this->entered; } - public function setEntered(?DateTimeInterface $entered): self - { - $this->entered = $entered; - return $this; - } - public function getUpdatedAt(): ?DateTime { return $this->updatedAt; } - #[ORM\PrePersist] #[ORM\PreUpdate] - public function updateUpdatedAt(): DomainModel + public function updateUpdatedAt(): void { $this->updatedAt = new DateTime(); - - return $this; } } diff --git a/src/Domain/Messaging/Model/Message.php b/src/Domain/Messaging/Model/Message.php index 5064c4f1..90f5036c 100644 --- a/src/Domain/Messaging/Model/Message.php +++ b/src/Domain/Messaging/Model/Message.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Domain\Messaging\Model; use DateTime; +use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -22,7 +23,7 @@ #[ORM\Entity(repositoryClass: MessageRepository::class)] #[ORM\Table(name: 'phplist_message')] -#[ORM\Index(name: 'uuididx', columns: ['uuid'])] +#[ORM\Index(name: 'phplist_message_uuididx', columns: ['uuid'])] #[ORM\HasLifecycleCallbacks] class Message implements DomainModel, Identity, ModificationDate, OwnableInterface { @@ -31,8 +32,8 @@ class Message implements DomainModel, Identity, ModificationDate, OwnableInterfa #[ORM\GeneratedValue] private ?int $id = null; - #[ORM\Column(name: 'modified', type: 'datetime')] - private ?DateTime $updatedAt = null; + #[ORM\Column(name: 'modified', type: 'datetime', nullable: false)] + private DateTime $updatedAt; #[ORM\Embedded(class: MessageFormat::class, columnPrefix: false)] private MessageFormat $format; @@ -81,6 +82,8 @@ public function __construct( $this->owner = $owner; $this->template = $template; $this->listMessages = new ArrayCollection(); + $this->updatedAt = new DateTime(); + $this->metadata->setEntered(new DateTime()); } public function getId(): ?int @@ -88,18 +91,15 @@ public function getId(): ?int return $this->id; } - public function getUpdatedAt(): ?DateTime + public function getUpdatedAt(): DateTime { return $this->updatedAt; } - #[ORM\PrePersist] #[ORM\PreUpdate] - public function updateUpdatedAt(): DomainModel + public function touchUpdatedTimestamp(): void { - $this->updatedAt = new DateTime(); - - return $this; + $this->updatedAt = new DateTime; } public function getFormat(): MessageFormat diff --git a/src/Domain/Messaging/Model/Message/MessageFormat.php b/src/Domain/Messaging/Model/Message/MessageFormat.php index 00af6df0..5deedefb 100644 --- a/src/Domain/Messaging/Model/Message/MessageFormat.php +++ b/src/Domain/Messaging/Model/Message/MessageFormat.php @@ -11,25 +11,25 @@ #[ORM\Embeddable] class MessageFormat implements EmbeddableInterface { - #[ORM\Column(name: 'htmlformatted', type: 'boolean', options: ['default' => false])] + #[ORM\Column(name: 'htmlformatted', type: 'boolean')] private bool $htmlFormatted = false; #[ORM\Column(name: 'sendformat', type: 'string', length: 20, nullable: true)] private ?string $sendFormat = null; - #[ORM\Column(name: 'astext', type: 'boolean', options: ['default' => false])] + #[ORM\Column(name: 'astext', type: 'boolean')] private bool $asText = false; - #[ORM\Column(name: 'ashtml', type: 'boolean', options: ['default' => false])] + #[ORM\Column(name: 'ashtml', type: 'boolean')] private bool $asHtml = false; - #[ORM\Column(name: 'aspdf', type: 'boolean', options: ['default' => false])] + #[ORM\Column(name: 'aspdf', type: 'boolean')] private bool $asPdf = false; - #[ORM\Column(name: 'astextandhtml', type: 'boolean', options: ['default' => false])] + #[ORM\Column(name: 'astextandhtml', type: 'boolean')] private bool $asTextAndHtml = false; - #[ORM\Column(name: 'astextandpdf', type: 'boolean', options: ['default' => false])] + #[ORM\Column(name: 'astextandpdf', type: 'boolean')] private bool $asTextAndPdf = false; public const FORMAT_TEXT = 'text'; diff --git a/src/Domain/Messaging/Model/Message/MessageMetadata.php b/src/Domain/Messaging/Model/Message/MessageMetadata.php index 156539b2..e2c43c72 100644 --- a/src/Domain/Messaging/Model/Message/MessageMetadata.php +++ b/src/Domain/Messaging/Model/Message/MessageMetadata.php @@ -16,7 +16,7 @@ class MessageMetadata implements EmbeddableInterface private ?string $status = null; #[ORM\Column(type: 'boolean', options: ['unsigned' => true, 'default' => false])] - private bool $processed; + private bool $processed = false; #[ORM\Column(type: 'integer', options: ['default' => 0])] private int $viewed = 0; diff --git a/src/Domain/Messaging/Model/MessageAttachment.php b/src/Domain/Messaging/Model/MessageAttachment.php index 2675790d..e26d0d87 100644 --- a/src/Domain/Messaging/Model/MessageAttachment.php +++ b/src/Domain/Messaging/Model/MessageAttachment.php @@ -10,8 +10,8 @@ #[ORM\Entity(repositoryClass: MessageAttachmentRepository::class)] #[ORM\Table(name: 'phplist_message_attachment')] -#[ORM\Index(name: 'messageattidx', columns: ['messageid', 'attachmentid'])] -#[ORM\Index(name: 'messageidx', columns: ['messageid'])] +#[ORM\Index(name: 'phplist_message_attachment_messageattidx', columns: ['messageid', 'attachmentid'])] +#[ORM\Index(name: 'phplist_message_attachment_messageidx', columns: ['messageid'])] class MessageAttachment implements Identity { #[ORM\Id] diff --git a/src/Domain/Messaging/Model/SendProcess.php b/src/Domain/Messaging/Model/SendProcess.php index 7b2287d4..5faeaf35 100644 --- a/src/Domain/Messaging/Model/SendProcess.php +++ b/src/Domain/Messaging/Model/SendProcess.php @@ -48,11 +48,9 @@ public function getUpdatedAt(): ?DateTime #[ORM\PrePersist] #[ORM\PreUpdate] - public function updateUpdatedAt(): DomainModel + public function updateUpdatedAt(): void { $this->updatedAt = new DateTime(); - - return $this; } public function getStartedDate(): ?DateTime diff --git a/src/Domain/Messaging/Model/Template.php b/src/Domain/Messaging/Model/Template.php index f7e3f5d0..efff0de4 100644 --- a/src/Domain/Messaging/Model/Template.php +++ b/src/Domain/Messaging/Model/Template.php @@ -13,7 +13,7 @@ #[ORM\Entity(repositoryClass: TemplateRepository::class)] #[ORM\Table(name: 'phplist_template')] -#[ORM\UniqueConstraint(name: 'title', columns: ['title'])] +#[ORM\UniqueConstraint(name: 'phplist_template_title', columns: ['title'])] class Template implements DomainModel, Identity { #[ORM\Id] diff --git a/src/Domain/Messaging/Model/TemplateImage.php b/src/Domain/Messaging/Model/TemplateImage.php index 1ef7d7b5..2cc5288f 100644 --- a/src/Domain/Messaging/Model/TemplateImage.php +++ b/src/Domain/Messaging/Model/TemplateImage.php @@ -11,7 +11,7 @@ #[ORM\Entity(repositoryClass: TemplateImageRepository::class)] #[ORM\Table(name: 'phplist_templateimage')] -#[ORM\Index(name: 'templateidx', columns: ['template'])] +#[ORM\Index(name: 'phplist_templateimage_templateidx', columns: ['template'])] class TemplateImage implements DomainModel, Identity { #[ORM\Id] diff --git a/src/Domain/Messaging/Model/UserMessage.php b/src/Domain/Messaging/Model/UserMessage.php index c846fa11..cdb6b7c2 100644 --- a/src/Domain/Messaging/Model/UserMessage.php +++ b/src/Domain/Messaging/Model/UserMessage.php @@ -13,11 +13,11 @@ #[ORM\Entity(repositoryClass: UserMessageRepository::class)] #[ORM\Table(name: 'phplist_usermessage')] -#[ORM\Index(name: 'enteredindex', columns: ['entered'])] -#[ORM\Index(name: 'messageidindex', columns: ['messageid'])] -#[ORM\Index(name: 'statusidx', columns: ['status'])] -#[ORM\Index(name: 'useridindex', columns: ['userid'])] -#[ORM\Index(name: 'viewedidx', columns: ['viewed'])] +#[ORM\Index(name: 'phplist_usermessage_enteredindex', columns: ['entered'])] +#[ORM\Index(name: 'phplist_usermessage_messageidindex', columns: ['messageid'])] +#[ORM\Index(name: 'phplist_usermessage_statusidx', columns: ['status'])] +#[ORM\Index(name: 'phplist_usermessage_useridindex', columns: ['userid'])] +#[ORM\Index(name: 'phplist_usermessage_viewedidx', columns: ['viewed'])] class UserMessage implements DomainModel { #[ORM\Id] diff --git a/src/Domain/Messaging/Model/UserMessageBounce.php b/src/Domain/Messaging/Model/UserMessageBounce.php index 5da0d139..3b58bf47 100644 --- a/src/Domain/Messaging/Model/UserMessageBounce.php +++ b/src/Domain/Messaging/Model/UserMessageBounce.php @@ -12,11 +12,10 @@ #[ORM\Entity(repositoryClass: UserMessageBounceRepository::class)] #[ORM\Table(name: 'phplist_user_message_bounce')] -#[ORM\Index(name: 'bounceidx', columns: ['bounce'])] -#[ORM\Index(name: 'msgidx', columns: ['message'])] -#[ORM\Index(name: 'umbindex', columns: ['user', 'message', 'bounce'])] -#[ORM\Index(name: 'useridx', columns: ['user'])] -#[ORM\HasLifecycleCallbacks] +#[ORM\Index(name: 'phplist_user_message_bounce_bounceidx', columns: ['bounce'])] +#[ORM\Index(name: 'phplist_user_message_bounce_msgidx', columns: ['message'])] +#[ORM\Index(name: 'phplist_user_message_bounce_umbindex', columns: ['user', 'message', 'bounce'])] +#[ORM\Index(name: 'phplist_user_message_bounce_useridx', columns: ['user'])] class UserMessageBounce implements DomainModel, Identity { #[ORM\Id] diff --git a/src/Domain/Messaging/Model/UserMessageForward.php b/src/Domain/Messaging/Model/UserMessageForward.php index 9b2a8ef4..3b920189 100644 --- a/src/Domain/Messaging/Model/UserMessageForward.php +++ b/src/Domain/Messaging/Model/UserMessageForward.php @@ -12,9 +12,9 @@ #[ORM\Entity(repositoryClass: UserMessageForwardRepository::class)] #[ORM\Table(name: 'phplist_user_message_forward')] -#[ORM\Index(name: 'messageidx', columns: ['message'])] -#[ORM\Index(name: 'useridx', columns: ['user'])] -#[ORM\Index(name: 'usermessageidx', columns: ['user', 'message'])] +#[ORM\Index(name: 'phplist_user_message_forward_messageidx', columns: ['message'])] +#[ORM\Index(name: 'phplist_user_message_forward_useridx', columns: ['user'])] +#[ORM\Index(name: 'phplist_user_message_forward_usermessageidx', columns: ['user', 'message'])] class UserMessageForward implements DomainModel, Identity { #[ORM\Id] diff --git a/src/Domain/Messaging/Service/Manager/ListMessageManager.php b/src/Domain/Messaging/Service/Manager/ListMessageManager.php index c481e5f8..bc6a6bfe 100644 --- a/src/Domain/Messaging/Service/Manager/ListMessageManager.php +++ b/src/Domain/Messaging/Service/Manager/ListMessageManager.php @@ -40,7 +40,6 @@ public function associateMessageWithList(Message $message, SubscriberList $subsc $listMessage = new ListMessage(); $listMessage->setMessage($message); $listMessage->setList($subscriberList); - $listMessage->setEntered(new DateTime()); $this->entityManager->persist($listMessage); diff --git a/src/Domain/Subscription/Model/Subscriber.php b/src/Domain/Subscription/Model/Subscriber.php index d48e5730..a0c35d73 100644 --- a/src/Domain/Subscription/Model/Subscriber.php +++ b/src/Domain/Subscription/Model/Subscriber.php @@ -19,13 +19,18 @@ * campaigns for those subscriber lists. * @author Oliver Klee * @author Tatevik Grigoryan + * @SuppressWarnings(TooManyFields) */ #[ORM\Entity(repositoryClass: SubscriberRepository::class)] #[ORM\Table(name: 'phplist_user_user')] -#[ORM\Index(name: 'idxuniqid', columns: ['uniqid'])] -#[ORM\Index(name: 'enteredindex', columns: ['entered'])] -#[ORM\Index(name: 'confidx', columns: ['confirmed'])] -#[ORM\Index(name: 'blidx', columns: ['blacklisted'])] +#[ORM\Index(name: 'phplist_user_user_idxuniqid', columns: ['uniqid'])] +#[ORM\Index(name: 'phplist_user_user_enteredindex', columns: ['entered'])] +#[ORM\Index(name: 'phplist_user_user_confidx', columns: ['confirmed'])] +#[ORM\Index(name: 'phplist_user_user_blidx', columns: ['blacklisted'])] +#[ORM\Index(name: 'phplist_user_user_optidx', columns: ['optedin'])] +#[ORM\Index(name: 'phplist_user_user_uuididx', columns: ['uuid'])] +#[ORM\Index(name: 'phplist_user_user_foreignkey', columns: ['foreignkey'])] +#[ORM\UniqueConstraint(name: 'phplist_user_user_email', columns: ['email'])] #[ORM\HasLifecycleCallbacks] class Subscriber implements DomainModel, Identity, CreationDate, ModificationDate { @@ -37,8 +42,8 @@ class Subscriber implements DomainModel, Identity, CreationDate, ModificationDat #[ORM\Column(name: 'entered', type: 'datetime', nullable: true)] protected ?DateTime $createdAt = null; - #[ORM\Column(name: 'modified', type: 'datetime')] - private ?DateTime $updatedAt = null; + #[ORM\Column(name: 'modified', type: 'datetime', nullable: false)] + private DateTime $updatedAt; #[ORM\Column(unique: true)] private string $email = ''; @@ -52,7 +57,7 @@ class Subscriber implements DomainModel, Identity, CreationDate, ModificationDat #[ORM\Column(name: 'bouncecount', type: 'integer')] private int $bounceCount = 0; - #[ORM\Column(name: 'uniqid', unique: true)] + #[ORM\Column(name: 'uniqid', type: 'string', length: 255, nullable: true)] private string $uniqueId = ''; #[ORM\Column(name: 'htmlemail', type: 'boolean')] @@ -61,8 +66,8 @@ class Subscriber implements DomainModel, Identity, CreationDate, ModificationDat #[ORM\Column(type: 'boolean')] private bool $disabled = false; - #[ORM\Column(name: 'extradata', type: 'text')] - private ?string $extraData; + #[ORM\Column(name: 'extradata', type: 'text', nullable: true)] + private ?string $extraData = null; #[ORM\OneToMany( targetEntity: Subscription::class, @@ -83,12 +88,34 @@ class Subscriber implements DomainModel, Identity, CreationDate, ModificationDat )] private Collection $attributes; + #[ORM\Column(name: 'optedin', type: 'boolean')] + private bool $optedIn = false; + + #[ORM\Column(name: 'uuid', type: 'string', length: 36)] + private string $uuid = ''; + + #[ORM\Column(name: 'subscribepage', type: 'integer', nullable: true)] + private ?int $subscribePage = null; + + #[ORM\Column(name: 'rssfrequency', type: 'string', length: 100, nullable: true)] + private ?string $rssFrequency = null; + + #[ORM\Column(name: 'password', type: 'string', length: 255, nullable: true)] + private ?string $password = null; + + #[ORM\Column(name: 'passwordchanged', type: 'datetime', nullable: true)] + private ?DateTime $passwordChanged = null; + + #[ORM\Column(name: 'foreignkey', type: 'string', length: 100, nullable: true)] + private ?string $foreignKey = null; + public function __construct() { $this->subscriptions = new ArrayCollection(); $this->attributes = new ArrayCollection(); $this->extraData = ''; $this->createdAt = new DateTime(); + $this->updatedAt = new DateTime(); } public function getId(): ?int @@ -101,18 +128,15 @@ public function getCreatedAt(): ?DateTime return $this->createdAt; } - public function getUpdatedAt(): ?DateTime + public function getUpdatedAt(): DateTime { return $this->updatedAt; } - #[ORM\PrePersist] #[ORM\PreUpdate] - public function updateUpdatedAt(): DomainModel + public function updateUpdatedAt(): void { $this->updatedAt = new DateTime(); - - return $this; } public function isConfirmed(): bool @@ -281,4 +305,74 @@ public function removeAttribute(SubscriberAttributeValue $attribute): self $this->attributes->removeElement($attribute); return $this; } + + public function isOptedIn(): bool + { + return $this->optedIn; + } + + public function setOptedIn(bool $optedIn): void + { + $this->optedIn = $optedIn; + } + + public function getUuid(): string + { + return $this->uuid; + } + + public function setUuid(string $uuid): void + { + $this->uuid = $uuid; + } + + public function getSubscribePage(): ?int + { + return $this->subscribePage; + } + + public function setSubscribePage(?int $subscribePage): void + { + $this->subscribePage = $subscribePage; + } + + public function getRssFrequency(): ?string + { + return $this->rssFrequency; + } + + public function setRssFrequency(?string $rssFrequency): void + { + $this->rssFrequency = $rssFrequency; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function setPassword(?string $password): void + { + $this->password = $password; + } + + public function getPasswordChanged(): ?DateTime + { + return $this->passwordChanged; + } + + public function setPasswordChanged(?DateTime $passwordChanged): void + { + $this->passwordChanged = $passwordChanged; + } + + public function getForeignKey(): ?string + { + return $this->foreignKey; + } + + public function setForeignKey(?string $foreignKey): void + { + $this->foreignKey = $foreignKey; + } } diff --git a/src/Domain/Subscription/Model/SubscriberAttributeDefinition.php b/src/Domain/Subscription/Model/SubscriberAttributeDefinition.php index dc7259d6..7b943b5c 100644 --- a/src/Domain/Subscription/Model/SubscriberAttributeDefinition.php +++ b/src/Domain/Subscription/Model/SubscriberAttributeDefinition.php @@ -11,8 +11,8 @@ #[ORM\Entity(repositoryClass: SubscriberAttributeDefinitionRepository::class)] #[ORM\Table(name: 'phplist_user_attribute')] -#[ORM\Index(name: 'idnameindex', columns: ['id', 'name'])] -#[ORM\Index(name: 'nameindex', columns: ['name'])] +#[ORM\Index(name: 'phplist_user_attribute_idnameindex', columns: ['id', 'name'])] +#[ORM\Index(name: 'phplist_user_attribute_nameindex', columns: ['name'])] class SubscriberAttributeDefinition implements DomainModel, Identity { #[ORM\Id] diff --git a/src/Domain/Subscription/Model/SubscriberAttributeValue.php b/src/Domain/Subscription/Model/SubscriberAttributeValue.php index 05209709..d2144f95 100644 --- a/src/Domain/Subscription/Model/SubscriberAttributeValue.php +++ b/src/Domain/Subscription/Model/SubscriberAttributeValue.php @@ -10,9 +10,9 @@ #[ORM\Entity(repositoryClass: SubscriberAttributeValueRepository::class)] #[ORM\Table(name: 'phplist_user_user_attribute')] -#[ORM\Index(name: 'attindex', columns: ['attributeid'])] -#[ORM\Index(name: 'attuserid', columns: ['userid', 'attributeid'])] -#[ORM\Index(name: 'userindex', columns: ['userid'])] +#[ORM\Index(name: 'phplist_user_user_attribute_attindex', columns: ['attributeid'])] +#[ORM\Index(name: 'phplist_user_user_attribute_attuserid', columns: ['userid', 'attributeid'])] +#[ORM\Index(name: 'phplist_user_user_attribute_userindex', columns: ['userid'])] class SubscriberAttributeValue implements DomainModel { #[ORM\Id] diff --git a/src/Domain/Subscription/Model/SubscriberHistory.php b/src/Domain/Subscription/Model/SubscriberHistory.php index 0eaa3f7a..1799c01b 100644 --- a/src/Domain/Subscription/Model/SubscriberHistory.php +++ b/src/Domain/Subscription/Model/SubscriberHistory.php @@ -12,8 +12,8 @@ #[ORM\Entity(repositoryClass: SubscriberHistoryRepository::class)] #[ORM\Table(name: 'phplist_user_user_history')] -#[ORM\Index(name: 'dateidx', columns: ['date'])] -#[ORM\Index(name: 'userididx', columns: ['userid'])] +#[ORM\Index(name: 'phplist_user_user_history_dateidx', columns: ['date'])] +#[ORM\Index(name: 'phplist_user_user_history_userididx', columns: ['userid'])] class SubscriberHistory implements DomainModel, Identity { #[ORM\Id] diff --git a/src/Domain/Subscription/Model/SubscriberList.php b/src/Domain/Subscription/Model/SubscriberList.php index 32f85f5d..e23e8735 100644 --- a/src/Domain/Subscription/Model/SubscriberList.php +++ b/src/Domain/Subscription/Model/SubscriberList.php @@ -26,8 +26,8 @@ */ #[ORM\Entity(repositoryClass: SubscriberListRepository::class)] #[ORM\Table(name: 'phplist_list')] -#[ORM\Index(name: 'nameidx', columns: ['name'])] -#[ORM\Index(name: 'listorderidx', columns: ['listorder'])] +#[ORM\Index(name: 'phplist_list_nameidx', columns: ['name'])] +#[ORM\Index(name: 'phplist_list_listorderidx', columns: ['listorder'])] #[ORM\HasLifecycleCallbacks] class SubscriberList implements DomainModel, Identity, CreationDate, ModificationDate, OwnableInterface { @@ -39,6 +39,9 @@ class SubscriberList implements DomainModel, Identity, CreationDate, Modificatio #[ORM\Column] private string $name = ''; + #[ORM\Column(name: 'rssfeed', type: 'string', length: 255, nullable: true)] + private ?string $rssFeed = null; + #[ORM\Column] private string $description = ''; @@ -51,7 +54,7 @@ class SubscriberList implements DomainModel, Identity, CreationDate, Modificatio #[ORM\Column(name: 'listorder', type: 'integer', nullable: true)] private ?int $listPosition; - #[ORM\Column(name: 'prefix')] + #[ORM\Column(name: 'prefix', length: 10, nullable: true)] private ?string $subjectPrefix; #[ORM\Column(name: 'active', type: 'boolean')] @@ -92,6 +95,17 @@ public function getId(): ?int return $this->id; } + public function getRssFeed(): ?string + { + return $this->rssFeed; + } + + public function setRssFeed(?string $rssFeed): self + { + $this->rssFeed = $rssFeed; + return $this; + } + public function getName(): string { return $this->name; @@ -103,12 +117,12 @@ public function setName(string $name): self return $this; } - public function getDescription(): string + public function getDescription(): ?string { return $this->description; } - public function setDescription(string $description): self + public function setDescription(?string $description): self { $this->description = $description; @@ -217,10 +231,9 @@ public function getUpdatedAt(): ?DateTime #[ORM\PrePersist] #[ORM\PreUpdate] - public function updateUpdatedAt(): DomainModel + public function updateUpdatedAt(): void { $this->updatedAt = new DateTime(); - return $this; } public function getListMessages(): Collection diff --git a/src/Domain/Subscription/Model/Subscription.php b/src/Domain/Subscription/Model/Subscription.php index 94e79965..fe4b5e2a 100644 --- a/src/Domain/Subscription/Model/Subscription.php +++ b/src/Domain/Subscription/Model/Subscription.php @@ -23,10 +23,10 @@ */ #[ORM\Entity(repositoryClass: SubscriptionRepository::class)] #[ORM\Table(name: 'phplist_listuser')] -#[ORM\Index(name: 'userenteredidx', columns: ['userid', 'entered'])] -#[ORM\Index(name: 'userlistenteredidx', columns: ['userid', 'entered', 'listid'])] -#[ORM\Index(name: 'useridx', columns: ['userid'])] -#[ORM\Index(name: 'listidx', columns: ['listid'])] +#[ORM\Index(name: 'phplist_listuser_userenteredidx', columns: ['userid', 'entered'])] +#[ORM\Index(name: 'phplist_listuser_userlistenteredidx', columns: ['userid', 'entered', 'listid'])] +#[ORM\Index(name: 'phplist_listuser_useridx', columns: ['userid'])] +#[ORM\Index(name: 'phplist_listuser_listidx', columns: ['listid'])] #[ORM\HasLifecycleCallbacks] class Subscription implements DomainModel, CreationDate, ModificationDate { @@ -95,10 +95,8 @@ public function getUpdatedAt(): ?DateTime #[ORM\PrePersist] #[ORM\PreUpdate] - public function updateUpdatedAt(): DomainModel + public function updateUpdatedAt(): void { $this->updatedAt = new DateTime(); - - return $this; } } diff --git a/src/Domain/Subscription/Model/UserBlacklist.php b/src/Domain/Subscription/Model/UserBlacklist.php index eb24ded3..9b150686 100644 --- a/src/Domain/Subscription/Model/UserBlacklist.php +++ b/src/Domain/Subscription/Model/UserBlacklist.php @@ -11,7 +11,7 @@ #[ORM\Entity(repositoryClass: UserBlacklistRepository::class)] #[ORM\Table(name: 'phplist_user_blacklist')] -#[ORM\Index(name: 'emailidx', columns: ['email'])] +#[ORM\Index(name: 'phplist_user_blacklist_emailidx', columns: ['email'])] class UserBlacklist implements DomainModel { #[ORM\Id] @@ -21,9 +21,15 @@ class UserBlacklist implements DomainModel #[ORM\Column(name: 'added', type: 'datetime', nullable: true)] private ?DateTime $added = null; - #[ORM\OneToOne(targetEntity: UserBlacklistData::class, mappedBy: 'email')] + #[ORM\OneToOne(targetEntity: UserBlacklistData::class, mappedBy: 'blacklist', cascade: ['persist', 'remove'])] private ?UserBlacklistData $blacklistData = null; + public function __construct(string $email) + { + $this->email = $email; + $this->added = new DateTime(); + } + public function getEmail(): string { return $this->email; @@ -34,12 +40,6 @@ public function getAdded(): ?DateTime return $this->added; } - public function setEmail(string $email): self - { - $this->email = $email; - return $this; - } - public function setAdded(?DateTime $added): self { $this->added = $added; @@ -50,4 +50,13 @@ public function getBlacklistData(): ?UserBlacklistData { return $this->blacklistData; } + + public function setBlacklistData(?UserBlacklistData $data): self + { + $this->blacklistData = $data; + if ($data && $data->getBlacklist() !== $this) { + $data->setBlacklist($this); + } + return $this; + } } diff --git a/src/Domain/Subscription/Model/UserBlacklistData.php b/src/Domain/Subscription/Model/UserBlacklistData.php index f8d78c59..4ac769e9 100644 --- a/src/Domain/Subscription/Model/UserBlacklistData.php +++ b/src/Domain/Subscription/Model/UserBlacklistData.php @@ -10,13 +10,14 @@ #[ORM\Entity(repositoryClass: UserBlacklistDataRepository::class)] #[ORM\Table(name: 'phplist_user_blacklist_data')] -#[ORM\Index(name: 'emailidx', columns: ['email'])] -#[ORM\Index(name: 'emailnameidx', columns: ['email', 'name'])] +#[ORM\Index(name: 'phplist_user_blacklist_data_emailidx', columns: ['email'])] +#[ORM\Index(name: 'phplist_user_blacklist_data_emailnameidx', columns: ['email', 'name'])] class UserBlacklistData implements DomainModel { #[ORM\Id] - #[ORM\Column(name: 'email', type: 'string', length: 150)] - private string $email; + #[ORM\OneToOne(targetEntity: UserBlacklist::class, inversedBy: 'blacklistData')] + #[ORM\JoinColumn(name: 'email', referencedColumnName: 'email', nullable: false, onDelete: 'CASCADE')] + private UserBlacklist $blacklist; #[ORM\Column(name: 'name', type: 'string', length: 25)] private string $name; @@ -24,9 +25,20 @@ class UserBlacklistData implements DomainModel #[ORM\Column(name: 'data', type: 'text', nullable: true)] private ?string $data = null; + public function getBlacklist(): UserBlacklist + { + return $this->blacklist; + } + + public function setBlacklist(UserBlacklist $blacklist): self + { + $this->blacklist = $blacklist; + return $this; + } + public function getEmail(): string { - return $this->email; + return $this->blacklist->getEmail(); } public function getName(): string @@ -39,12 +51,6 @@ public function getData(): ?string return $this->data; } - public function setEmail(string $email): self - { - $this->email = $email; - return $this; - } - public function setName(string $name): self { $this->name = $name; diff --git a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php index 3d124a31..c51d867f 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php @@ -39,15 +39,12 @@ public function addEmailToBlacklist(string $email, ?string $reasonData = null): return $this->getBlacklistInfo($email); } - $blacklistEntry = new UserBlacklist(); - $blacklistEntry->setEmail($email); - $blacklistEntry->setAdded(new DateTime()); - + $blacklistEntry = new UserBlacklist($email); $this->entityManager->persist($blacklistEntry); if ($reasonData !== null) { $blacklistData = new UserBlacklistData(); - $blacklistData->setEmail($email); + $blacklistData->setBlacklist($blacklistEntry); $blacklistData->setName('reason'); $blacklistData->setData($reasonData); $this->entityManager->persist($blacklistData); @@ -56,10 +53,10 @@ public function addEmailToBlacklist(string $email, ?string $reasonData = null): return $blacklistEntry; } - public function addBlacklistData(string $email, string $name, string $data): void + public function addBlacklistData(UserBlacklist $userBlacklist, string $name, string $data): void { $blacklistData = new UserBlacklistData(); - $blacklistData->setEmail($email); + $blacklistData->setBlacklist($userBlacklist); $blacklistData->setName($name); $blacklistData->setData($data); $this->entityManager->persist($blacklistData); diff --git a/src/Migrations/.gitkeep b/src/Migrations/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/Migrations/Version20251028092901MySqlInit.php b/src/Migrations/Version20251028092901MySqlInit.php new file mode 100644 index 00000000..7b345dad --- /dev/null +++ b/src/Migrations/Version20251028092901MySqlInit.php @@ -0,0 +1,34 @@ +connection->getDatabasePlatform(); + $this->skipIf( + !$platform instanceof MySQLPlatform, + sprintf( + 'This migration is only applicable for MySQL. Current platform: %s', + get_class($platform) + ) + ); + // this up() migration is auto-generated, please modify it to your needs + $this->addSql(file_get_contents(__DIR__.'/initial_schema.sql')); + } +} diff --git a/src/Migrations/Version20251028092902MySqlUpdate.php b/src/Migrations/Version20251028092902MySqlUpdate.php new file mode 100644 index 00000000..789bc1f0 --- /dev/null +++ b/src/Migrations/Version20251028092902MySqlUpdate.php @@ -0,0 +1,312 @@ +connection->getDatabasePlatform(); + $this->skipIf(!$platform instanceof MySQLPlatform, sprintf( + 'Unsupported platform for this migration: %s', + get_class($platform) + )); + + $this->addSql('ALTER TABLE phplist_admin CHANGE created created DATETIME NOT NULL, CHANGE modified modified DATETIME NOT NULL, CHANGE superuser superuser TINYINT(1) NOT NULL, CHANGE disabled disabled TINYINT(1) NOT NULL, CHANGE privileges privileges LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_admin RENAME INDEX loginnameidx TO phplist_admin_loginnameidx'); + $this->addSql('ALTER TABLE phplist_admin_attribute ADD CONSTRAINT FK_58E07690D3B10C48 FOREIGN KEY (adminattributeid) REFERENCES phplist_adminattribute (id)'); + $this->addSql('ALTER TABLE phplist_admin_attribute ADD CONSTRAINT FK_58E07690B8ED4D93 FOREIGN KEY (adminid) REFERENCES phplist_admin (id)'); + $this->addSql('CREATE INDEX IDX_58E07690D3B10C48 ON phplist_admin_attribute (adminattributeid)'); + $this->addSql('CREATE INDEX IDX_58E07690B8ED4D93 ON phplist_admin_attribute (adminid)'); + $this->addSql('ALTER TABLE phplist_admin_login CHANGE active active TINYINT(1) NOT NULL'); + $this->addSql('ALTER TABLE phplist_admin_login ADD CONSTRAINT FK_5FCE0842B8ED4D93 FOREIGN KEY (adminid) REFERENCES phplist_admin (id)'); + $this->addSql('CREATE INDEX IDX_5FCE0842B8ED4D93 ON phplist_admin_login (adminid)'); + $this->addSql('ALTER TABLE phplist_admin_password_request CHANGE id_key id_key INT UNSIGNED AUTO_INCREMENT NOT NULL'); + $this->addSql('ALTER TABLE phplist_admin_password_request ADD CONSTRAINT FK_DC146F3B880E0D76 FOREIGN KEY (`admin`) REFERENCES phplist_admin (id)'); + $this->addSql('CREATE INDEX IDX_DC146F3B880E0D76 ON phplist_admin_password_request (`admin`)'); + $this->addSql('ALTER TABLE phplist_admintoken CHANGE adminid adminid INT DEFAULT NULL, CHANGE value value VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE phplist_admintoken ADD CONSTRAINT FK_CB15D477B8ED4D93 FOREIGN KEY (adminid) REFERENCES phplist_admin (id) ON DELETE CASCADE'); + $this->addSql('CREATE INDEX IDX_CB15D477B8ED4D93 ON phplist_admintoken (adminid)'); + $this->addSql('ALTER TABLE phplist_attachment CHANGE description description LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_bounce DROP INDEX statusidx, ADD INDEX phplist_bounce_statusidx (status)'); + $this->addSql('ALTER TABLE phplist_bounce CHANGE header header LONGTEXT DEFAULT NULL, CHANGE data data LONGBLOB DEFAULT NULL, CHANGE comment comment LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_bounce RENAME INDEX dateindex TO phplist_bounce_dateindex'); + $this->addSql('ALTER TABLE phplist_bounceregex CHANGE regexhash regexhash VARCHAR(32) DEFAULT NULL, CHANGE comment comment LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_bounceregex RENAME INDEX regex TO phplist_bounceregex_regex'); + $this->addSql('ALTER TABLE phplist_config CHANGE editable editable TINYINT(1) DEFAULT 1 NOT NULL'); + $this->addSql('ALTER TABLE phplist_eventlog CHANGE entry entry LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_eventlog RENAME INDEX enteredidx TO phplist_eventlog_enteredidx'); + $this->addSql('ALTER TABLE phplist_eventlog RENAME INDEX pageidx TO phplist_eventlog_pageidx'); + $this->addSql('ALTER TABLE phplist_linktrack CHANGE latestclick latestclick DATETIME NOT NULL'); + $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX midindex TO phplist_linktrack_midindex'); + $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX miduidindex TO phplist_linktrack_miduidindex'); + $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX uidindex TO phplist_linktrack_uidindex'); + $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX urlindex TO phplist_linktrack_urlindex'); + $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX miduidurlindex TO phplist_linktrack_miduidurlindex'); + $this->addSql('ALTER TABLE phplist_linktrack_forward RENAME INDEX urlindex TO phplist_linktrack_forward_urlindex;'); + $this->addSql('ALTER TABLE phplist_linktrack_forward CHANGE urlhash urlhash VARCHAR(32) DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_linktrack_forward RENAME INDEX uuididx TO phplist_linktrack_forward_uuididx'); + $this->addSql('ALTER TABLE phplist_linktrack_forward RENAME INDEX urlunique TO phplist_linktrack_forward_urlunique'); + $this->addSql('ALTER TABLE phplist_linktrack_ml RENAME INDEX fwdindex TO phplist_linktrack_ml_fwdindex'); + $this->addSql('ALTER TABLE phplist_linktrack_ml RENAME INDEX midindex TO phplist_linktrack_ml_midindex'); + $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX midindex TO phplist_linktrack_uml_click_midindex'); + $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX miduidindex TO phplist_linktrack_uml_click_miduidindex'); + $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX uidindex TO phplist_linktrack_uml_click_uidindex'); + $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX miduidfwdid TO phplist_linktrack_uml_click_miduidfwdid'); + $this->addSql('ALTER TABLE phplist_linktrack_userclick CHANGE data data LONGTEXT DEFAULT NULL, ADD PRIMARY KEY (linkid, userid, messageid)'); + $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX linkindex TO phplist_linktrack_userclick_linkindex'); + $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX linkuserindex TO phplist_linktrack_userclick_linkuserindex'); + $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX linkusermessageindex TO phplist_linktrack_userclick_linkusermessageindex'); + $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX midindex TO phplist_linktrack_userclick_midindex'); + $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX uidindex TO phplist_linktrack_userclick_uidindex'); + $this->addSql('ALTER TABLE phplist_list CHANGE description description VARCHAR(255) NOT NULL, CHANGE modified modified DATETIME NOT NULL, CHANGE active active TINYINT(1) NOT NULL, CHANGE category category VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE phplist_list ADD CONSTRAINT FK_A4CE8621CF60E67C FOREIGN KEY (owner) REFERENCES phplist_admin (id)'); + $this->addSql('CREATE INDEX IDX_A4CE8621CF60E67C ON phplist_list (owner)'); + $this->addSql('ALTER TABLE phplist_list RENAME INDEX nameidx TO phplist_list_nameidx'); + $this->addSql('ALTER TABLE phplist_list RENAME INDEX listorderidx TO phplist_list_listorderidx'); + $this->addSql('ALTER TABLE phplist_listmessage CHANGE modified modified DATETIME NOT NULL'); + $this->addSql('ALTER TABLE phplist_listmessage ADD CONSTRAINT FK_83B22D7A31478478 FOREIGN KEY (messageid) REFERENCES phplist_message (id)'); + $this->addSql('ALTER TABLE phplist_listmessage ADD CONSTRAINT FK_83B22D7A8E44C1EF FOREIGN KEY (listid) REFERENCES phplist_list (id)'); + $this->addSql('CREATE INDEX IDX_83B22D7A31478478 ON phplist_listmessage (messageid)'); + $this->addSql('CREATE INDEX IDX_83B22D7A8E44C1EF ON phplist_listmessage (listid)'); + $this->addSql('ALTER TABLE phplist_listmessage RENAME INDEX listmessageidx TO phplist_listmessage_listmessageidx'); + $this->addSql('ALTER TABLE phplist_listmessage RENAME INDEX messageid TO phplist_listmessage_messageid'); + $this->addSql('DROP INDEX userlistenteredidx ON phplist_listuser'); + $this->addSql('ALTER TABLE phplist_listuser CHANGE modified modified DATETIME NOT NULL'); + $this->addSql('ALTER TABLE phplist_listuser ADD CONSTRAINT FK_F467E411F132696E FOREIGN KEY (userid) REFERENCES phplist_user_user (id)'); + $this->addSql('ALTER TABLE phplist_listuser ADD CONSTRAINT FK_F467E4118E44C1EF FOREIGN KEY (listid) REFERENCES phplist_list (id) ON DELETE CASCADE'); + $this->addSql('CREATE INDEX phplist_listuser_userlistenteredidx ON phplist_listuser (userid, entered, listid)'); + $this->addSql('ALTER TABLE phplist_listuser RENAME INDEX userenteredidx TO phplist_listuser_userenteredidx'); + $this->addSql('ALTER TABLE phplist_listuser RENAME INDEX useridx TO phplist_listuser_useridx'); + $this->addSql('ALTER TABLE phplist_listuser RENAME INDEX listidx TO phplist_listuser_listidx'); + $this->addSql('ALTER TABLE phplist_message CHANGE footer footer LONGTEXT DEFAULT NULL, CHANGE modified modified DATETIME NOT NULL, CHANGE userselection userselection LONGTEXT DEFAULT NULL, CHANGE htmlformatted htmlformatted TINYINT(1) NOT NULL, CHANGE processed processed TINYINT(1) DEFAULT 0 NOT NULL, CHANGE astext astext TINYINT(1) NOT NULL, CHANGE ashtml ashtml TINYINT(1) NOT NULL, CHANGE astextandhtml astextandhtml TINYINT(1) NOT NULL, CHANGE aspdf aspdf TINYINT(1) NOT NULL, CHANGE astextandpdf astextandpdf TINYINT(1) NOT NULL, CHANGE viewed viewed INT DEFAULT 0 NOT NULL, CHANGE bouncecount bouncecount INT DEFAULT 0 NOT NULL'); + $this->addSql('ALTER TABLE phplist_message ADD CONSTRAINT FK_C5D81FCDCF60E67C FOREIGN KEY (owner) REFERENCES phplist_admin (id)'); + $this->addSql('ALTER TABLE phplist_message ADD CONSTRAINT FK_C5D81FCD97601F83 FOREIGN KEY (template) REFERENCES phplist_template (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_C5D81FCDCF60E67C ON phplist_message (owner)'); + $this->addSql('CREATE INDEX IDX_C5D81FCD97601F83 ON phplist_message (template)'); + $this->addSql('ALTER TABLE phplist_message RENAME INDEX uuididx TO phplist_message_uuididx'); + $this->addSql('ALTER TABLE phplist_message_attachment RENAME INDEX messageattidx TO phplist_message_attachment_messageattidx'); + $this->addSql('ALTER TABLE phplist_message_attachment RENAME INDEX messageidx TO phplist_message_attachment_messageidx'); + $this->addSql('ALTER TABLE phplist_messagedata CHANGE data data LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_sendprocess CHANGE modified modified DATETIME NOT NULL'); + $this->addSql('ALTER TABLE phplist_subscribepage CHANGE active active TINYINT(1) DEFAULT 0 NOT NULL'); + $this->addSql('ALTER TABLE phplist_subscribepage ADD CONSTRAINT FK_5BAC7737CF60E67C FOREIGN KEY (owner) REFERENCES phplist_admin (id)'); + $this->addSql('CREATE INDEX IDX_5BAC7737CF60E67C ON phplist_subscribepage (owner)'); + $this->addSql('ALTER TABLE phplist_subscribepage_data CHANGE data data LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_template RENAME INDEX title TO phplist_template_title'); + $this->addSql('ALTER TABLE phplist_templateimage CHANGE template template INT NOT NULL'); + $this->addSql('ALTER TABLE phplist_templateimage ADD CONSTRAINT FK_30A85BA97601F83 FOREIGN KEY (template) REFERENCES phplist_template (id)'); + $this->addSql('ALTER TABLE phplist_templateimage RENAME INDEX templateidx TO phplist_templateimage_templateidx'); + $this->addSql('ALTER TABLE phplist_urlcache RENAME INDEX urlindex TO phplist_urlcache_urlindex'); + $this->addSql('ALTER TABLE phplist_user_attribute RENAME INDEX idnameindex TO phplist_user_attribute_idnameindex'); + $this->addSql('ALTER TABLE phplist_user_attribute RENAME INDEX nameindex TO phplist_user_attribute_nameindex'); + $this->addSql('DROP INDEX email ON phplist_user_blacklist'); + $this->addSql('ALTER TABLE phplist_user_blacklist ADD PRIMARY KEY (email)'); + $this->addSql('ALTER TABLE phplist_user_blacklist RENAME INDEX emailidx TO phplist_user_blacklist_emailidx'); + $this->addSql('DROP INDEX email ON phplist_user_blacklist_data'); + $this->addSql('ALTER TABLE phplist_user_blacklist_data CHANGE email email VARCHAR(255) NOT NULL, CHANGE data data LONGTEXT DEFAULT NULL, ADD PRIMARY KEY (email)'); + $this->addSql('ALTER TABLE phplist_user_blacklist_data ADD CONSTRAINT FK_6D67150CE7927C74 FOREIGN KEY (email) REFERENCES phplist_user_blacklist (email) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE phplist_user_blacklist_data RENAME INDEX emailidx TO phplist_user_blacklist_data_emailidx'); + $this->addSql('ALTER TABLE phplist_user_blacklist_data RENAME INDEX emailnameidx TO phplist_user_blacklist_data_emailnameidx'); + $this->addSql('ALTER TABLE phplist_user_message_bounce RENAME INDEX bounceidx TO phplist_user_message_bounce_bounceidx'); + $this->addSql('ALTER TABLE phplist_user_message_bounce RENAME INDEX msgidx TO phplist_user_message_bounce_msgidx'); + $this->addSql('ALTER TABLE phplist_user_message_bounce RENAME INDEX umbindex TO phplist_user_message_bounce_umbindex'); + $this->addSql('ALTER TABLE phplist_user_message_bounce RENAME INDEX useridx TO phplist_user_message_bounce_useridx'); + $this->addSql('ALTER TABLE phplist_user_message_forward RENAME INDEX messageidx TO phplist_user_message_forward_messageidx'); + $this->addSql('ALTER TABLE phplist_user_message_forward RENAME INDEX useridx TO phplist_user_message_forward_useridx'); + $this->addSql('ALTER TABLE phplist_user_message_forward RENAME INDEX usermessageidx TO phplist_user_message_forward_usermessageidx'); + $this->addSql('ALTER TABLE phplist_user_message_view RENAME INDEX msgidx TO phplist_user_message_view_msgidx'); + $this->addSql('ALTER TABLE phplist_user_message_view RENAME INDEX useridx TO phplist_user_message_view_useridx'); + $this->addSql('ALTER TABLE phplist_user_message_view RENAME INDEX usermsgidx TO phplist_user_message_view_usermsgidx'); + $this->addSql('ALTER TABLE phplist_user_user CHANGE confirmed confirmed TINYINT(1) NOT NULL, CHANGE blacklisted blacklisted TINYINT(1) NOT NULL, CHANGE optedin optedin TINYINT(1) NOT NULL, CHANGE bouncecount bouncecount INT NOT NULL, CHANGE modified modified DATETIME NOT NULL, CHANGE uuid uuid VARCHAR(36) NOT NULL, CHANGE htmlemail htmlemail TINYINT(1) NOT NULL, CHANGE passwordchanged passwordchanged DATETIME DEFAULT NULL, CHANGE disabled disabled TINYINT(1) NOT NULL, CHANGE extradata extradata LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX idxuniqid TO phplist_user_user_idxuniqid'); + $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX enteredindex TO phplist_user_user_enteredindex'); + $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX confidx TO phplist_user_user_confidx'); + $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX blidx TO phplist_user_user_blidx'); + $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX optidx TO phplist_user_user_optidx'); + $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX uuididx TO phplist_user_user_uuididx'); + $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX foreignkey TO phplist_user_user_foreignkey'); + $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX email TO phplist_user_user_email'); + $this->addSql('ALTER TABLE phplist_user_user_attribute CHANGE value value LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_user_user_attribute ADD CONSTRAINT FK_E24E310878C45AB5 FOREIGN KEY (attributeid) REFERENCES phplist_user_attribute (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE phplist_user_user_attribute ADD CONSTRAINT FK_E24E3108F132696E FOREIGN KEY (userid) REFERENCES phplist_user_user (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE phplist_user_user_attribute RENAME INDEX attindex TO phplist_user_user_attribute_attindex'); + $this->addSql('ALTER TABLE phplist_user_user_attribute RENAME INDEX attuserid TO phplist_user_user_attribute_attuserid'); + $this->addSql('ALTER TABLE phplist_user_user_attribute RENAME INDEX userindex TO phplist_user_user_attribute_userindex'); + $this->addSql('ALTER TABLE phplist_user_user_history CHANGE detail detail LONGTEXT DEFAULT NULL, CHANGE systeminfo systeminfo LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_user_user_history ADD CONSTRAINT FK_6DBB605CF132696E FOREIGN KEY (userid) REFERENCES phplist_user_user (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE phplist_user_user_history RENAME INDEX dateidx TO phplist_user_user_history_dateidx'); + $this->addSql('ALTER TABLE phplist_user_user_history RENAME INDEX userididx TO phplist_user_user_history_userididx'); + $this->addSql('ALTER TABLE phplist_usermessage ADD CONSTRAINT FK_7F30F469F132696E FOREIGN KEY (userid) REFERENCES phplist_user_user (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE phplist_usermessage ADD CONSTRAINT FK_7F30F46931478478 FOREIGN KEY (messageid) REFERENCES phplist_message (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX enteredindex TO phplist_usermessage_enteredindex'); + $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX messageidindex TO phplist_usermessage_messageidindex'); + $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX statusidx TO phplist_usermessage_statusidx'); + $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX useridindex TO phplist_usermessage_useridindex'); + $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX viewedidx TO phplist_usermessage_viewedidx'); + $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX dateindex TO phplist_userstats_dateindex'); + $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX itemindex TO phplist_userstats_itemindex'); + $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX listdateindex TO phplist_userstats_listdateindex'); + $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX listindex TO phplist_userstats_listindex'); + $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX entry TO phplist_userstats_entry'); + } + + public function down(Schema $schema): void + { + $platform = $this->connection->getDatabasePlatform(); + $this->skipIf(!$platform instanceof MySQLPlatform, sprintf( + 'Unsupported platform for this migration: %s', + get_class($platform) + )); + + $this->addSql('ALTER TABLE phplist_admin CHANGE created created DATETIME DEFAULT NULL, CHANGE modified modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CHANGE disabled disabled TINYINT(1) DEFAULT 0, CHANGE superuser superuser TINYINT(1) DEFAULT 0, CHANGE privileges privileges TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_admin RENAME INDEX phplist_admin_loginnameidx TO loginnameidx'); + $this->addSql('ALTER TABLE phplist_admin_attribute DROP FOREIGN KEY FK_58E07690D3B10C48'); + $this->addSql('ALTER TABLE phplist_admin_attribute DROP FOREIGN KEY FK_58E07690B8ED4D93'); + $this->addSql('DROP INDEX IDX_58E07690D3B10C48 ON phplist_admin_attribute'); + $this->addSql('DROP INDEX IDX_58E07690B8ED4D93 ON phplist_admin_attribute'); + $this->addSql('ALTER TABLE phplist_admin_login DROP FOREIGN KEY FK_5FCE0842B8ED4D93'); + $this->addSql('DROP INDEX IDX_5FCE0842B8ED4D93 ON phplist_admin_login'); + $this->addSql('ALTER TABLE phplist_admin_login CHANGE active active TINYINT(1) DEFAULT 0 NOT NULL'); + $this->addSql('ALTER TABLE phplist_admin_password_request DROP FOREIGN KEY FK_DC146F3B880E0D76'); + $this->addSql('DROP INDEX IDX_DC146F3B880E0D76 ON phplist_admin_password_request'); + $this->addSql('ALTER TABLE phplist_admin_password_request CHANGE id_key id_key INT AUTO_INCREMENT NOT NULL'); + $this->addSql('ALTER TABLE phplist_admintoken DROP FOREIGN KEY FK_CB15D477B8ED4D93'); + $this->addSql('DROP INDEX IDX_CB15D477B8ED4D93 ON phplist_admintoken'); + $this->addSql('ALTER TABLE phplist_admintoken CHANGE adminid adminid INT NOT NULL, CHANGE value value VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_attachment CHANGE description description TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_bounce DROP INDEX phplist_bounce_statusidx, ADD INDEX statusidx (status(20))'); + $this->addSql('ALTER TABLE phplist_bounce CHANGE header header TEXT DEFAULT NULL, CHANGE data data MEDIUMBLOB DEFAULT NULL, CHANGE comment comment TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_bounce RENAME INDEX phplist_bounce_dateindex TO dateindex'); + $this->addSql('ALTER TABLE phplist_bounceregex CHANGE regexhash regexhash CHAR(32) DEFAULT NULL, CHANGE comment comment TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_bounceregex RENAME INDEX phplist_bounceregex_regex TO regex'); + $this->addSql('ALTER TABLE phplist_config CHANGE editable editable TINYINT(1) DEFAULT 1'); + $this->addSql('ALTER TABLE phplist_eventlog CHANGE entry entry TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_eventlog RENAME INDEX phplist_eventlog_enteredidx TO enteredidx'); + $this->addSql('ALTER TABLE phplist_eventlog RENAME INDEX phplist_eventlog_pageidx TO pageidx'); + $this->addSql('ALTER TABLE phplist_linktrack CHANGE latestclick latestclick DATETIME DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX phplist_linktrack_miduidurlindex TO miduidurlindex'); + $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX phplist_linktrack_midindex TO midindex'); + $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX phplist_linktrack_uidindex TO uidindex'); + $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX phplist_linktrack_urlindex TO urlindex'); + $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX phplist_linktrack_miduidindex TO miduidindex'); + $this->addSql('ALTER TABLE phplist_linktrack_forward DROP INDEX phplist_linktrack_forward_urlindex, ADD INDEX urlindex (url(255))'); + $this->addSql('ALTER TABLE phplist_linktrack_forward CHANGE urlhash urlhash CHAR(32) DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_linktrack_forward RENAME INDEX phplist_linktrack_forward_urlunique TO urlunique'); + $this->addSql('ALTER TABLE phplist_linktrack_forward RENAME INDEX phplist_linktrack_forward_uuididx TO uuididx'); + $this->addSql('ALTER TABLE phplist_linktrack_ml RENAME INDEX phplist_linktrack_ml_midindex TO midindex'); + $this->addSql('ALTER TABLE phplist_linktrack_ml RENAME INDEX phplist_linktrack_ml_fwdindex TO fwdindex'); + $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX phplist_linktrack_uml_click_miduidfwdid TO miduidfwdid'); + $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX phplist_linktrack_uml_click_midindex TO midindex'); + $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX phplist_linktrack_uml_click_uidindex TO uidindex'); + $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX phplist_linktrack_uml_click_miduidindex TO miduidindex'); + $this->addSql('DROP INDEX `primary` ON phplist_linktrack_userclick'); + $this->addSql('ALTER TABLE phplist_linktrack_userclick CHANGE data data TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX phplist_linktrack_userclick_linkindex TO linkindex'); + $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX phplist_linktrack_userclick_uidindex TO uidindex'); + $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX phplist_linktrack_userclick_midindex TO midindex'); + $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX phplist_linktrack_userclick_linkuserindex TO linkuserindex'); + $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX phplist_linktrack_userclick_linkusermessageindex TO linkusermessageindex'); + $this->addSql('ALTER TABLE phplist_list DROP FOREIGN KEY FK_A4CE8621CF60E67C'); + $this->addSql('DROP INDEX IDX_A4CE8621CF60E67C ON phplist_list'); + $this->addSql('ALTER TABLE phplist_list CHANGE description description TEXT DEFAULT NULL, CHANGE modified modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CHANGE active active TINYINT(1) DEFAULT NULL, CHANGE category category VARCHAR(255) DEFAULT \'\''); + $this->addSql('ALTER TABLE phplist_list RENAME INDEX phplist_list_nameidx TO nameidx'); + $this->addSql('ALTER TABLE phplist_list RENAME INDEX phplist_list_listorderidx TO listorderidx'); + $this->addSql('ALTER TABLE phplist_listmessage DROP FOREIGN KEY FK_83B22D7A31478478'); + $this->addSql('ALTER TABLE phplist_listmessage DROP FOREIGN KEY FK_83B22D7A8E44C1EF'); + $this->addSql('DROP INDEX IDX_83B22D7A31478478 ON phplist_listmessage'); + $this->addSql('DROP INDEX IDX_83B22D7A8E44C1EF ON phplist_listmessage'); + $this->addSql('ALTER TABLE phplist_listmessage CHANGE modified modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL'); + $this->addSql('ALTER TABLE phplist_listmessage RENAME INDEX phplist_listmessage_messageid TO messageid'); + $this->addSql('ALTER TABLE phplist_listmessage RENAME INDEX phplist_listmessage_listmessageidx TO listmessageidx'); + $this->addSql('ALTER TABLE phplist_listuser DROP FOREIGN KEY FK_F467E411F132696E'); + $this->addSql('ALTER TABLE phplist_listuser DROP FOREIGN KEY FK_F467E4118E44C1EF'); + $this->addSql('DROP INDEX phplist_listuser_userlistenteredidx ON phplist_listuser'); + $this->addSql('ALTER TABLE phplist_listuser CHANGE modified modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL'); + $this->addSql('CREATE INDEX userlistenteredidx ON phplist_listuser (userid, listid, entered)'); + $this->addSql('ALTER TABLE phplist_listuser RENAME INDEX phplist_listuser_userenteredidx TO userenteredidx'); + $this->addSql('ALTER TABLE phplist_listuser RENAME INDEX phplist_listuser_useridx TO useridx'); + $this->addSql('ALTER TABLE phplist_listuser RENAME INDEX phplist_listuser_listidx TO listidx'); + $this->addSql('ALTER TABLE phplist_message DROP FOREIGN KEY FK_C5D81FCDCF60E67C'); + $this->addSql('ALTER TABLE phplist_message DROP FOREIGN KEY FK_C5D81FCD97601F83'); + $this->addSql('DROP INDEX IDX_C5D81FCDCF60E67C ON phplist_message'); + $this->addSql('DROP INDEX IDX_C5D81FCD97601F83 ON phplist_message'); + $this->addSql('ALTER TABLE phplist_message CHANGE modified modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CHANGE htmlformatted htmlformatted TINYINT(1) DEFAULT 0, CHANGE astext astext INT DEFAULT 0, CHANGE ashtml ashtml INT DEFAULT 0, CHANGE aspdf aspdf INT DEFAULT 0, CHANGE astextandhtml astextandhtml INT DEFAULT 0, CHANGE astextandpdf astextandpdf INT DEFAULT 0, CHANGE processed processed INT UNSIGNED DEFAULT 0, CHANGE viewed viewed INT DEFAULT 0, CHANGE bouncecount bouncecount INT DEFAULT 0, CHANGE footer footer TEXT DEFAULT NULL, CHANGE userselection userselection TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_message RENAME INDEX phplist_message_uuididx TO uuididx'); + $this->addSql('ALTER TABLE phplist_message_attachment RENAME INDEX phplist_message_attachment_messageidx TO messageidx'); + $this->addSql('ALTER TABLE phplist_message_attachment RENAME INDEX phplist_message_attachment_messageattidx TO messageattidx'); + $this->addSql('ALTER TABLE phplist_messagedata CHANGE data data LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_sendprocess CHANGE modified modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL'); + $this->addSql('ALTER TABLE phplist_subscribepage DROP FOREIGN KEY FK_5BAC7737CF60E67C'); + $this->addSql('DROP INDEX IDX_5BAC7737CF60E67C ON phplist_subscribepage'); + $this->addSql('ALTER TABLE phplist_subscribepage CHANGE active active TINYINT(1) DEFAULT 0'); + $this->addSql('ALTER TABLE phplist_subscribepage_data CHANGE data data TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_template RENAME INDEX phplist_template_title TO title'); + $this->addSql('ALTER TABLE phplist_templateimage DROP FOREIGN KEY FK_30A85BA97601F83'); + $this->addSql('ALTER TABLE phplist_templateimage CHANGE template template INT DEFAULT 0 NOT NULL'); + $this->addSql('ALTER TABLE phplist_templateimage RENAME INDEX phplist_templateimage_templateidx TO templateidx'); + $this->addSql('ALTER TABLE phplist_urlcache DROP INDEX phplist_urlcache_urlindex, ADD INDEX urlindex (url(255))'); + $this->addSql('ALTER TABLE phplist_user_attribute RENAME INDEX phplist_user_attribute_nameindex TO nameindex'); + $this->addSql('ALTER TABLE phplist_user_attribute RENAME INDEX phplist_user_attribute_idnameindex TO idnameindex'); + $this->addSql('ALTER TABLE phplist_user_blacklist DROP INDEX `primary`, ADD UNIQUE INDEX email (email)'); + $this->addSql('ALTER TABLE phplist_user_blacklist RENAME INDEX phplist_user_blacklist_emailidx TO emailidx'); + $this->addSql('ALTER TABLE phplist_user_blacklist_data DROP INDEX `primary`, ADD UNIQUE INDEX email (email)'); + $this->addSql('ALTER TABLE phplist_user_blacklist_data DROP FOREIGN KEY FK_6D67150CE7927C74'); + $this->addSql('ALTER TABLE phplist_user_blacklist_data CHANGE email email VARCHAR(150) NOT NULL, CHANGE data data TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_user_blacklist_data RENAME INDEX phplist_user_blacklist_data_emailidx TO emailidx'); + $this->addSql('ALTER TABLE phplist_user_blacklist_data RENAME INDEX phplist_user_blacklist_data_emailnameidx TO emailnameidx'); + $this->addSql('ALTER TABLE phplist_user_message_bounce RENAME INDEX phplist_user_message_bounce_umbindex TO umbindex'); + $this->addSql('ALTER TABLE phplist_user_message_bounce RENAME INDEX phplist_user_message_bounce_useridx TO useridx'); + $this->addSql('ALTER TABLE phplist_user_message_bounce RENAME INDEX phplist_user_message_bounce_msgidx TO msgidx'); + $this->addSql('ALTER TABLE phplist_user_message_bounce RENAME INDEX phplist_user_message_bounce_bounceidx TO bounceidx'); + $this->addSql('ALTER TABLE phplist_user_message_forward RENAME INDEX phplist_user_message_forward_usermessageidx TO usermessageidx'); + $this->addSql('ALTER TABLE phplist_user_message_forward RENAME INDEX phplist_user_message_forward_useridx TO useridx'); + $this->addSql('ALTER TABLE phplist_user_message_forward RENAME INDEX phplist_user_message_forward_messageidx TO messageidx'); + $this->addSql('ALTER TABLE phplist_user_message_view RENAME INDEX phplist_user_message_view_usermsgidx TO usermsgidx'); + $this->addSql('ALTER TABLE phplist_user_message_view RENAME INDEX phplist_user_message_view_msgidx TO msgidx'); + $this->addSql('ALTER TABLE phplist_user_message_view RENAME INDEX phplist_user_message_view_useridx TO useridx'); + $this->addSql('ALTER TABLE phplist_user_user CHANGE modified modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CHANGE confirmed confirmed TINYINT(1) DEFAULT 0, CHANGE blacklisted blacklisted TINYINT(1) DEFAULT 0, CHANGE bouncecount bouncecount INT DEFAULT 0, CHANGE htmlemail htmlemail TINYINT(1) DEFAULT 0, CHANGE disabled disabled TINYINT(1) DEFAULT 0, CHANGE extradata extradata TEXT DEFAULT NULL, CHANGE optedin optedin TINYINT(1) DEFAULT 0, CHANGE uuid uuid VARCHAR(36) DEFAULT \'\', CHANGE passwordchanged passwordchanged DATE DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX phplist_user_user_email TO email'); + $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX phplist_user_user_foreignkey TO foreignkey'); + $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX phplist_user_user_idxuniqid TO idxuniqid'); + $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX phplist_user_user_enteredindex TO enteredindex'); + $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX phplist_user_user_confidx TO confidx'); + $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX phplist_user_user_blidx TO blidx'); + $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX phplist_user_user_optidx TO optidx'); + $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX phplist_user_user_uuididx TO uuididx'); + $this->addSql('ALTER TABLE phplist_user_user_attribute DROP FOREIGN KEY FK_E24E310878C45AB5'); + $this->addSql('ALTER TABLE phplist_user_user_attribute DROP FOREIGN KEY FK_E24E3108F132696E'); + $this->addSql('ALTER TABLE phplist_user_user_attribute CHANGE value value TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_user_user_attribute RENAME INDEX phplist_user_user_attribute_userindex TO userindex'); + $this->addSql('ALTER TABLE phplist_user_user_attribute RENAME INDEX phplist_user_user_attribute_attindex TO attindex'); + $this->addSql('ALTER TABLE phplist_user_user_attribute RENAME INDEX phplist_user_user_attribute_attuserid TO attuserid'); + $this->addSql('ALTER TABLE phplist_user_user_history DROP FOREIGN KEY FK_6DBB605CF132696E'); + $this->addSql('ALTER TABLE phplist_user_user_history CHANGE detail detail TEXT DEFAULT NULL, CHANGE systeminfo systeminfo TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_user_user_history RENAME INDEX phplist_user_user_history_userididx TO userididx'); + $this->addSql('ALTER TABLE phplist_user_user_history RENAME INDEX phplist_user_user_history_dateidx TO dateidx'); + $this->addSql('ALTER TABLE phplist_usermessage DROP FOREIGN KEY FK_7F30F469F132696E'); + $this->addSql('ALTER TABLE phplist_usermessage DROP FOREIGN KEY FK_7F30F46931478478'); + $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX phplist_usermessage_messageidindex TO messageidindex'); + $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX phplist_usermessage_useridindex TO useridindex'); + $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX phplist_usermessage_enteredindex TO enteredindex'); + $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX phplist_usermessage_statusidx TO statusidx'); + $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX phplist_usermessage_viewedidx TO viewedidx'); + $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX phplist_userstats_entry TO entry'); + $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX phplist_userstats_dateindex TO dateindex'); + $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX phplist_userstats_itemindex TO itemindex'); + $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX phplist_userstats_listindex TO listindex'); + $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX phplist_userstats_listdateindex TO listdateindex'); + } +} diff --git a/src/Migrations/Version20251031072945PostGreInit.php b/src/Migrations/Version20251031072945PostGreInit.php new file mode 100644 index 00000000..206ec175 --- /dev/null +++ b/src/Migrations/Version20251031072945PostGreInit.php @@ -0,0 +1,311 @@ +connection->getDatabasePlatform(); + $this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf( + 'Unsupported platform for this migration: %s', + get_class($platform) + )); + + $this->addSql('CREATE SEQUENCE phplist_admin_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_admin_login_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_admin_password_request_id_key_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_adminattribute_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_admintoken_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_attachment_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_bounce_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_bounceregex_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_eventlog_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_linktrack_linkid_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_linktrack_forward_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_linktrack_uml_click_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_list_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_listmessage_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_message_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_message_attachment_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_sendprocess_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_subscribepage_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_template_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_templateimage_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_urlcache_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_user_attribute_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_user_message_bounce_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_user_message_forward_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_user_message_view_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_user_user_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_user_user_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE phplist_userstats_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE phplist_admin (id INT NOT NULL, created TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, modified TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, loginname VARCHAR(66) NOT NULL, namelc VARCHAR(255) DEFAULT NULL, email VARCHAR(255) NOT NULL, modifiedby VARCHAR(66) DEFAULT NULL, password VARCHAR(255) DEFAULT NULL, passwordchanged DATE DEFAULT NULL, disabled BOOLEAN NOT NULL, superuser BOOLEAN NOT NULL, privileges TEXT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX phplist_admin_loginnameidx ON phplist_admin (loginname)'); + $this->addSql('CREATE TABLE phplist_admin_attribute (adminattributeid INT NOT NULL, adminid INT NOT NULL, value VARCHAR(255) DEFAULT NULL, PRIMARY KEY(adminattributeid, adminid))'); + $this->addSql('CREATE INDEX IDX_58E07690D3B10C48 ON phplist_admin_attribute (adminattributeid)'); + $this->addSql('CREATE INDEX IDX_58E07690B8ED4D93 ON phplist_admin_attribute (adminid)'); + $this->addSql('CREATE TABLE phplist_admin_login (id INT NOT NULL, adminid INT NOT NULL, moment BIGINT NOT NULL, remote_ip4 VARCHAR(32) NOT NULL, remote_ip6 VARCHAR(50) NOT NULL, sessionid VARCHAR(50) NOT NULL, active BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_5FCE0842B8ED4D93 ON phplist_admin_login (adminid)'); + $this->addSql('CREATE TABLE phplist_admin_password_request (id_key INT NOT NULL, admin INT DEFAULT NULL, date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, key_value VARCHAR(32) NOT NULL, PRIMARY KEY(id_key))'); + $this->addSql('CREATE INDEX IDX_DC146F3B880E0D76 ON phplist_admin_password_request (admin)'); + $this->addSql('CREATE TABLE phplist_adminattribute (id INT NOT NULL, name VARCHAR(255) NOT NULL, type VARCHAR(30) DEFAULT NULL, listorder INT DEFAULT NULL, default_value VARCHAR(255) DEFAULT NULL, required BOOLEAN DEFAULT NULL, tablename VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE TABLE phplist_admintoken (id INT NOT NULL, adminid INT DEFAULT NULL, entered INT NOT NULL, expires TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, value VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_CB15D477B8ED4D93 ON phplist_admintoken (adminid)'); + $this->addSql('CREATE TABLE phplist_attachment (id INT NOT NULL, filename VARCHAR(255) DEFAULT NULL, remotefile VARCHAR(255) DEFAULT NULL, mimetype VARCHAR(255) DEFAULT NULL, description TEXT DEFAULT NULL, size INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE TABLE phplist_bounce (id INT NOT NULL, date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, header TEXT DEFAULT NULL, data BYTEA DEFAULT NULL, status VARCHAR(255) DEFAULT NULL, comment TEXT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX phplist_bounce_dateindex ON phplist_bounce (date)'); + $this->addSql('CREATE INDEX phplist_bounce_statusidx ON phplist_bounce (status)'); + $this->addSql('CREATE TABLE phplist_bounceregex (id INT NOT NULL, regex VARCHAR(2083) DEFAULT NULL, regexhash VARCHAR(32) DEFAULT NULL, action VARCHAR(255) DEFAULT NULL, listorder INT DEFAULT 0, admin INT DEFAULT NULL, comment TEXT DEFAULT NULL, status VARCHAR(255) DEFAULT NULL, count INT DEFAULT 0, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX phplist_bounceregex_regex ON phplist_bounceregex (regexhash)'); + $this->addSql('CREATE TABLE phplist_bounceregex_bounce (regex INT NOT NULL, bounce INT NOT NULL, PRIMARY KEY(regex, bounce))'); + $this->addSql('CREATE TABLE phplist_config (item VARCHAR(35) NOT NULL, value TEXT DEFAULT NULL, editable BOOLEAN DEFAULT true NOT NULL, type VARCHAR(25) DEFAULT NULL, PRIMARY KEY(item))'); + $this->addSql('CREATE TABLE phplist_eventlog (id INT NOT NULL, entered TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, page VARCHAR(100) DEFAULT NULL, entry TEXT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX phplist_eventlog_enteredidx ON phplist_eventlog (entered)'); + $this->addSql('CREATE INDEX phplist_eventlog_pageidx ON phplist_eventlog (page)'); + $this->addSql('CREATE TABLE phplist_i18n (lan VARCHAR(10) NOT NULL, original VARCHAR(255) NOT NULL, translation TEXT NOT NULL, PRIMARY KEY(lan, original))'); + $this->addSql('CREATE UNIQUE INDEX phplist_i18n_lanorigunq ON phplist_i18n (lan, original)'); + $this->addSql('CREATE TABLE phplist_linktrack (linkid INT NOT NULL, messageid INT NOT NULL, userid INT NOT NULL, url VARCHAR(255) DEFAULT NULL, forward VARCHAR(255) DEFAULT NULL, firstclick TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, latestclick TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, clicked INT DEFAULT 0, PRIMARY KEY(linkid))'); + $this->addSql('CREATE INDEX phplist_linktrack_midindex ON phplist_linktrack (messageid)'); + $this->addSql('CREATE INDEX phplist_linktrack_miduidindex ON phplist_linktrack (messageid, userid)'); + $this->addSql('CREATE INDEX phplist_linktrack_uidindex ON phplist_linktrack (userid)'); + $this->addSql('CREATE INDEX phplist_linktrack_urlindex ON phplist_linktrack (url)'); + $this->addSql('CREATE UNIQUE INDEX phplist_linktrack_miduidurlindex ON phplist_linktrack (messageid, userid, url)'); + $this->addSql('CREATE TABLE phplist_linktrack_forward (id INT NOT NULL, url VARCHAR(2083) DEFAULT NULL, urlhash VARCHAR(32) DEFAULT NULL, uuid VARCHAR(36) DEFAULT \'\', personalise BOOLEAN DEFAULT false, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX phplist_linktrack_forward_urlindex ON phplist_linktrack_forward (url)'); + $this->addSql('CREATE INDEX phplist_linktrack_forward_uuididx ON phplist_linktrack_forward (uuid)'); + $this->addSql('CREATE UNIQUE INDEX phplist_linktrack_forward_urlunique ON phplist_linktrack_forward (urlhash)'); + $this->addSql('CREATE TABLE phplist_linktrack_ml (messageid INT NOT NULL, forwardid INT NOT NULL, firstclick TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, latestclick TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, total INT DEFAULT 0, clicked INT DEFAULT 0, htmlclicked INT DEFAULT 0, textclicked INT DEFAULT 0, PRIMARY KEY(messageid, forwardid))'); + $this->addSql('CREATE INDEX phplist_linktrack_ml_fwdindex ON phplist_linktrack_ml (forwardid)'); + $this->addSql('CREATE INDEX phplist_linktrack_ml_midindex ON phplist_linktrack_ml (messageid)'); + $this->addSql('CREATE TABLE phplist_linktrack_uml_click (id INT NOT NULL, messageid INT NOT NULL, userid INT NOT NULL, forwardid INT DEFAULT NULL, firstclick TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, latestclick TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, clicked INT DEFAULT 0, htmlclicked INT DEFAULT 0, textclicked INT DEFAULT 0, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX phplist_linktrack_uml_click_midindex ON phplist_linktrack_uml_click (messageid)'); + $this->addSql('CREATE INDEX phplist_linktrack_uml_click_miduidindex ON phplist_linktrack_uml_click (messageid, userid)'); + $this->addSql('CREATE INDEX phplist_linktrack_uml_click_uidindex ON phplist_linktrack_uml_click (userid)'); + $this->addSql('CREATE UNIQUE INDEX phplist_linktrack_uml_click_miduidfwdid ON phplist_linktrack_uml_click (messageid, userid, forwardid)'); + $this->addSql('CREATE TABLE phplist_linktrack_userclick (linkid INT NOT NULL, userid INT NOT NULL, messageid INT NOT NULL, name VARCHAR(255) DEFAULT NULL, data TEXT DEFAULT NULL, date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(linkid, userid, messageid))'); + $this->addSql('CREATE INDEX phplist_linktrack_userclick_linkindex ON phplist_linktrack_userclick (linkid)'); + $this->addSql('CREATE INDEX phplist_linktrack_userclick_linkuserindex ON phplist_linktrack_userclick (linkid, userid)'); + $this->addSql('CREATE INDEX phplist_linktrack_userclick_linkusermessageindex ON phplist_linktrack_userclick (linkid, userid, messageid)'); + $this->addSql('CREATE INDEX phplist_linktrack_userclick_midindex ON phplist_linktrack_userclick (messageid)'); + $this->addSql('CREATE INDEX phplist_linktrack_userclick_uidindex ON phplist_linktrack_userclick (userid)'); + $this->addSql('CREATE TABLE phplist_list (id INT NOT NULL, owner INT DEFAULT NULL, name VARCHAR(255) NOT NULL, rssfeed VARCHAR(255) DEFAULT NULL, description VARCHAR(255) NOT NULL, entered TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, modified TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, listorder INT DEFAULT NULL, prefix VARCHAR(10) DEFAULT NULL, active BOOLEAN NOT NULL, category VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_A4CE8621CF60E67C ON phplist_list (owner)'); + $this->addSql('CREATE INDEX phplist_list_nameidx ON phplist_list (name)'); + $this->addSql('CREATE INDEX phplist_list_listorderidx ON phplist_list (listorder)'); + $this->addSql('CREATE TABLE phplist_listmessage (id INT NOT NULL, messageid INT NOT NULL, listid INT NOT NULL, entered TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, modified TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_83B22D7A31478478 ON phplist_listmessage (messageid)'); + $this->addSql('CREATE INDEX IDX_83B22D7A8E44C1EF ON phplist_listmessage (listid)'); + $this->addSql('CREATE INDEX phplist_listmessage_listmessageidx ON phplist_listmessage (listid, messageid)'); + $this->addSql('CREATE UNIQUE INDEX phplist_listmessage_messageid ON phplist_listmessage (messageid, listid)'); + $this->addSql('CREATE TABLE phplist_listuser (userid INT NOT NULL, listid INT NOT NULL, entered TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, modified TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(userid, listid))'); + $this->addSql('CREATE INDEX phplist_listuser_userenteredidx ON phplist_listuser (userid, entered)'); + $this->addSql('CREATE INDEX phplist_listuser_userlistenteredidx ON phplist_listuser (userid, entered, listid)'); + $this->addSql('CREATE INDEX phplist_listuser_useridx ON phplist_listuser (userid)'); + $this->addSql('CREATE INDEX phplist_listuser_listidx ON phplist_listuser (listid)'); + $this->addSql('CREATE TABLE phplist_message (id INT NOT NULL, owner INT DEFAULT NULL, template INT DEFAULT NULL, modified TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, uuid VARCHAR(36) DEFAULT \'\', htmlformatted BOOLEAN NOT NULL, sendformat VARCHAR(20) DEFAULT NULL, astext BOOLEAN NOT NULL, ashtml BOOLEAN NOT NULL, aspdf BOOLEAN NOT NULL, astextandhtml BOOLEAN NOT NULL, astextandpdf BOOLEAN NOT NULL, repeatinterval INT DEFAULT 0, repeatuntil TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, requeueinterval INT DEFAULT 0, requeueuntil TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, embargo TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, status VARCHAR(255) DEFAULT NULL, processed BOOLEAN DEFAULT false NOT NULL, viewed INT DEFAULT 0 NOT NULL, bouncecount INT DEFAULT 0 NOT NULL, entered TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, sent TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, sendstart TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, subject VARCHAR(255) DEFAULT \'(no subject)\' NOT NULL, message TEXT DEFAULT NULL, textmessage TEXT DEFAULT NULL, footer TEXT DEFAULT NULL, fromfield VARCHAR(255) DEFAULT \'\' NOT NULL, tofield VARCHAR(255) DEFAULT \'\' NOT NULL, replyto VARCHAR(255) DEFAULT \'\' NOT NULL, userselection TEXT DEFAULT NULL, rsstemplate VARCHAR(100) DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_C5D81FCDCF60E67C ON phplist_message (owner)'); + $this->addSql('CREATE INDEX IDX_C5D81FCD97601F83 ON phplist_message (template)'); + $this->addSql('CREATE INDEX phplist_message_uuididx ON phplist_message (uuid)'); + $this->addSql('CREATE TABLE phplist_message_attachment (id INT NOT NULL, messageid INT NOT NULL, attachmentid INT NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX phplist_message_attachment_messageattidx ON phplist_message_attachment (messageid, attachmentid)'); + $this->addSql('CREATE INDEX phplist_message_attachment_messageidx ON phplist_message_attachment (messageid)'); + $this->addSql('CREATE TABLE phplist_messagedata (name VARCHAR(100) NOT NULL, id INT NOT NULL, data TEXT DEFAULT NULL, PRIMARY KEY(name, id))'); + $this->addSql('CREATE TABLE phplist_sendprocess (id INT NOT NULL, modified TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, started TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, alive INT DEFAULT 1, ipaddress VARCHAR(50) DEFAULT NULL, page VARCHAR(100) DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE TABLE phplist_subscribepage (id INT NOT NULL, owner INT DEFAULT NULL, title VARCHAR(255) NOT NULL, active BOOLEAN DEFAULT false NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_5BAC7737CF60E67C ON phplist_subscribepage (owner)'); + $this->addSql('CREATE TABLE phplist_subscribepage_data (id INT NOT NULL, name VARCHAR(100) NOT NULL, data TEXT DEFAULT NULL, PRIMARY KEY(id, name))'); + $this->addSql('CREATE TABLE phplist_template (id INT NOT NULL, title VARCHAR(255) NOT NULL, template BYTEA DEFAULT NULL, template_text BYTEA DEFAULT NULL, listorder INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX phplist_template_title ON phplist_template (title)'); + $this->addSql('CREATE TABLE phplist_templateimage (id INT NOT NULL, template INT NOT NULL, mimetype VARCHAR(100) DEFAULT NULL, filename VARCHAR(100) DEFAULT NULL, data BYTEA DEFAULT NULL, width INT DEFAULT NULL, height INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX phplist_templateimage_templateidx ON phplist_templateimage (template)'); + $this->addSql('CREATE TABLE phplist_urlcache (id INT NOT NULL, url VARCHAR(2083) NOT NULL, lastmodified INT DEFAULT NULL, added TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, content BYTEA DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX phplist_urlcache_urlindex ON phplist_urlcache (url)'); + $this->addSql('CREATE TABLE phplist_user_attribute (id INT NOT NULL, name VARCHAR(255) NOT NULL, type VARCHAR(30) DEFAULT NULL, listorder INT DEFAULT NULL, default_value VARCHAR(255) DEFAULT NULL, required BOOLEAN DEFAULT NULL, tablename VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX phplist_user_attribute_idnameindex ON phplist_user_attribute (id, name)'); + $this->addSql('CREATE INDEX phplist_user_attribute_nameindex ON phplist_user_attribute (name)'); + $this->addSql('CREATE TABLE phplist_user_blacklist (email VARCHAR(255) NOT NULL, added TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(email))'); + $this->addSql('CREATE INDEX phplist_user_blacklist_emailidx ON phplist_user_blacklist (email)'); + $this->addSql('CREATE TABLE phplist_user_blacklist_data (email VARCHAR(255) NOT NULL, name VARCHAR(25) NOT NULL, data TEXT DEFAULT NULL, PRIMARY KEY(email))'); + $this->addSql('CREATE INDEX phplist_user_blacklist_data_emailidx ON phplist_user_blacklist_data (email)'); + $this->addSql('CREATE INDEX phplist_user_blacklist_data_emailnameidx ON phplist_user_blacklist_data (email, name)'); + $this->addSql('CREATE TABLE phplist_user_message_bounce (id INT NOT NULL, "user" INT NOT NULL, message INT NOT NULL, bounce INT NOT NULL, time TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX phplist_user_message_bounce_bounceidx ON phplist_user_message_bounce (bounce)'); + $this->addSql('CREATE INDEX phplist_user_message_bounce_msgidx ON phplist_user_message_bounce (message)'); + $this->addSql('CREATE INDEX phplist_user_message_bounce_umbindex ON phplist_user_message_bounce ("user", message, bounce)'); + $this->addSql('CREATE INDEX phplist_user_message_bounce_useridx ON phplist_user_message_bounce ("user")'); + $this->addSql('CREATE TABLE phplist_user_message_forward (id INT NOT NULL, "user" INT NOT NULL, message INT NOT NULL, forward VARCHAR(255) DEFAULT NULL, status VARCHAR(255) DEFAULT NULL, time TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX phplist_user_message_forward_messageidx ON phplist_user_message_forward (message)'); + $this->addSql('CREATE INDEX phplist_user_message_forward_useridx ON phplist_user_message_forward ("user")'); + $this->addSql('CREATE INDEX phplist_user_message_forward_usermessageidx ON phplist_user_message_forward ("user", message)'); + $this->addSql('CREATE TABLE phplist_user_message_view (id INT NOT NULL, messageid INT NOT NULL, userid INT NOT NULL, viewed TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, ip VARCHAR(255) DEFAULT NULL, data TEXT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX phplist_user_message_view_msgidx ON phplist_user_message_view (messageid)'); + $this->addSql('CREATE INDEX phplist_user_message_view_useridx ON phplist_user_message_view (userid)'); + $this->addSql('CREATE INDEX phplist_user_message_view_usermsgidx ON phplist_user_message_view (userid, messageid)'); + $this->addSql('CREATE TABLE phplist_user_user (id INT NOT NULL, entered TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, modified TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, email VARCHAR(255) NOT NULL, confirmed BOOLEAN NOT NULL, blacklisted BOOLEAN NOT NULL, bouncecount INT NOT NULL, uniqid VARCHAR(255) DEFAULT NULL, htmlemail BOOLEAN NOT NULL, disabled BOOLEAN NOT NULL, extradata TEXT DEFAULT NULL, optedin BOOLEAN NOT NULL, uuid VARCHAR(36) NOT NULL, subscribepage INT DEFAULT NULL, rssfrequency VARCHAR(100) DEFAULT NULL, password VARCHAR(255) DEFAULT NULL, passwordchanged TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, foreignkey VARCHAR(100) DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX phplist_user_user_idxuniqid ON phplist_user_user (uniqid)'); + $this->addSql('CREATE INDEX phplist_user_user_enteredindex ON phplist_user_user (entered)'); + $this->addSql('CREATE INDEX phplist_user_user_confidx ON phplist_user_user (confirmed)'); + $this->addSql('CREATE INDEX phplist_user_user_blidx ON phplist_user_user (blacklisted)'); + $this->addSql('CREATE INDEX phplist_user_user_optidx ON phplist_user_user (optedin)'); + $this->addSql('CREATE INDEX phplist_user_user_uuididx ON phplist_user_user (uuid)'); + $this->addSql('CREATE INDEX phplist_user_user_foreignkey ON phplist_user_user (foreignkey)'); + $this->addSql('CREATE UNIQUE INDEX phplist_user_user_email ON phplist_user_user (email)'); + $this->addSql('CREATE TABLE phplist_user_user_attribute (attributeid INT NOT NULL, userid INT NOT NULL, value TEXT DEFAULT NULL, PRIMARY KEY(attributeid, userid))'); + $this->addSql('CREATE INDEX phplist_user_user_attribute_attindex ON phplist_user_user_attribute (attributeid)'); + $this->addSql('CREATE INDEX phplist_user_user_attribute_attuserid ON phplist_user_user_attribute (userid, attributeid)'); + $this->addSql('CREATE INDEX phplist_user_user_attribute_userindex ON phplist_user_user_attribute (userid)'); + $this->addSql('CREATE TABLE phplist_user_user_history (id INT NOT NULL, userid INT NOT NULL, ip VARCHAR(255) DEFAULT NULL, date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, summary VARCHAR(255) DEFAULT NULL, detail TEXT DEFAULT NULL, systeminfo TEXT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX phplist_user_user_history_dateidx ON phplist_user_user_history (date)'); + $this->addSql('CREATE INDEX phplist_user_user_history_userididx ON phplist_user_user_history (userid)'); + $this->addSql('CREATE TABLE phplist_usermessage (userid INT NOT NULL, messageid INT NOT NULL, entered TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, viewed TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, status VARCHAR(255) DEFAULT NULL, PRIMARY KEY(userid, messageid))'); + $this->addSql('CREATE INDEX phplist_usermessage_enteredindex ON phplist_usermessage (entered)'); + $this->addSql('CREATE INDEX phplist_usermessage_messageidindex ON phplist_usermessage (messageid)'); + $this->addSql('CREATE INDEX phplist_usermessage_statusidx ON phplist_usermessage (status)'); + $this->addSql('CREATE INDEX phplist_usermessage_useridindex ON phplist_usermessage (userid)'); + $this->addSql('CREATE INDEX phplist_usermessage_viewedidx ON phplist_usermessage (viewed)'); + $this->addSql('CREATE TABLE phplist_userstats (id INT NOT NULL, unixdate INT DEFAULT NULL, item VARCHAR(255) DEFAULT NULL, listid INT DEFAULT 0, value INT DEFAULT 0, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX phplist_userstats_dateindex ON phplist_userstats (unixdate)'); + $this->addSql('CREATE INDEX phplist_userstats_itemindex ON phplist_userstats (item)'); + $this->addSql('CREATE INDEX phplist_userstats_listdateindex ON phplist_userstats (listid, unixdate)'); + $this->addSql('CREATE INDEX phplist_userstats_listindex ON phplist_userstats (listid)'); + $this->addSql('CREATE UNIQUE INDEX phplist_userstats_entry ON phplist_userstats (unixdate, item, listid)'); + $this->addSql('ALTER TABLE phplist_admin_attribute ADD CONSTRAINT FK_58E07690D3B10C48 FOREIGN KEY (adminattributeid) REFERENCES phplist_adminattribute (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_admin_attribute ADD CONSTRAINT FK_58E07690B8ED4D93 FOREIGN KEY (adminid) REFERENCES phplist_admin (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_admin_login ADD CONSTRAINT FK_5FCE0842B8ED4D93 FOREIGN KEY (adminid) REFERENCES phplist_admin (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_admin_password_request ADD CONSTRAINT FK_DC146F3B880E0D76 FOREIGN KEY (admin) REFERENCES phplist_admin (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_admintoken ADD CONSTRAINT FK_CB15D477B8ED4D93 FOREIGN KEY (adminid) REFERENCES phplist_admin (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_list ADD CONSTRAINT FK_A4CE8621CF60E67C FOREIGN KEY (owner) REFERENCES phplist_admin (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_listmessage ADD CONSTRAINT FK_83B22D7A31478478 FOREIGN KEY (messageid) REFERENCES phplist_message (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_listmessage ADD CONSTRAINT FK_83B22D7A8E44C1EF FOREIGN KEY (listid) REFERENCES phplist_list (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_listuser ADD CONSTRAINT FK_F467E411F132696E FOREIGN KEY (userid) REFERENCES phplist_user_user (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_listuser ADD CONSTRAINT FK_F467E4118E44C1EF FOREIGN KEY (listid) REFERENCES phplist_list (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_message ADD CONSTRAINT FK_C5D81FCDCF60E67C FOREIGN KEY (owner) REFERENCES phplist_admin (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_message ADD CONSTRAINT FK_C5D81FCD97601F83 FOREIGN KEY (template) REFERENCES phplist_template (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_subscribepage ADD CONSTRAINT FK_5BAC7737CF60E67C FOREIGN KEY (owner) REFERENCES phplist_admin (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_templateimage ADD CONSTRAINT FK_30A85BA97601F83 FOREIGN KEY (template) REFERENCES phplist_template (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_user_blacklist_data ADD CONSTRAINT FK_6D67150CE7927C74 FOREIGN KEY (email) REFERENCES phplist_user_blacklist (email) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_user_user_attribute ADD CONSTRAINT FK_E24E310878C45AB5 FOREIGN KEY (attributeid) REFERENCES phplist_user_attribute (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_user_user_attribute ADD CONSTRAINT FK_E24E3108F132696E FOREIGN KEY (userid) REFERENCES phplist_user_user (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_user_user_history ADD CONSTRAINT FK_6DBB605CF132696E FOREIGN KEY (userid) REFERENCES phplist_user_user (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_usermessage ADD CONSTRAINT FK_7F30F469F132696E FOREIGN KEY (userid) REFERENCES phplist_user_user (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE phplist_usermessage ADD CONSTRAINT FK_7F30F46931478478 FOREIGN KEY (messageid) REFERENCES phplist_message (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $platform = $this->connection->getDatabasePlatform(); + $this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf( + 'Unsupported platform for this migration: %s', + get_class($platform) + )); + + $this->addSql('DROP SEQUENCE phplist_admin_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_admin_login_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_admin_password_request_id_key_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_adminattribute_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_admintoken_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_attachment_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_bounce_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_bounceregex_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_eventlog_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_linktrack_linkid_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_linktrack_forward_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_linktrack_uml_click_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_list_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_listmessage_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_message_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_message_attachment_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_sendprocess_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_subscribepage_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_template_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_templateimage_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_urlcache_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_user_attribute_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_user_message_bounce_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_user_message_forward_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_user_message_view_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_user_user_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_user_user_history_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE phplist_userstats_id_seq CASCADE'); + $this->addSql('ALTER TABLE phplist_admin_attribute DROP CONSTRAINT FK_58E07690D3B10C48'); + $this->addSql('ALTER TABLE phplist_admin_attribute DROP CONSTRAINT FK_58E07690B8ED4D93'); + $this->addSql('ALTER TABLE phplist_admin_login DROP CONSTRAINT FK_5FCE0842B8ED4D93'); + $this->addSql('ALTER TABLE phplist_admin_password_request DROP CONSTRAINT FK_DC146F3B880E0D76'); + $this->addSql('ALTER TABLE phplist_admintoken DROP CONSTRAINT FK_CB15D477B8ED4D93'); + $this->addSql('ALTER TABLE phplist_list DROP CONSTRAINT FK_A4CE8621CF60E67C'); + $this->addSql('ALTER TABLE phplist_listmessage DROP CONSTRAINT FK_83B22D7A31478478'); + $this->addSql('ALTER TABLE phplist_listmessage DROP CONSTRAINT FK_83B22D7A8E44C1EF'); + $this->addSql('ALTER TABLE phplist_listuser DROP CONSTRAINT FK_F467E411F132696E'); + $this->addSql('ALTER TABLE phplist_listuser DROP CONSTRAINT FK_F467E4118E44C1EF'); + $this->addSql('ALTER TABLE phplist_message DROP CONSTRAINT FK_C5D81FCDCF60E67C'); + $this->addSql('ALTER TABLE phplist_message DROP CONSTRAINT FK_C5D81FCD97601F83'); + $this->addSql('ALTER TABLE phplist_subscribepage DROP CONSTRAINT FK_5BAC7737CF60E67C'); + $this->addSql('ALTER TABLE phplist_templateimage DROP CONSTRAINT FK_30A85BA97601F83'); + $this->addSql('ALTER TABLE phplist_user_blacklist_data DROP CONSTRAINT FK_6D67150CE7927C74'); + $this->addSql('ALTER TABLE phplist_user_user_attribute DROP CONSTRAINT FK_E24E310878C45AB5'); + $this->addSql('ALTER TABLE phplist_user_user_attribute DROP CONSTRAINT FK_E24E3108F132696E'); + $this->addSql('ALTER TABLE phplist_user_user_history DROP CONSTRAINT FK_6DBB605CF132696E'); + $this->addSql('ALTER TABLE phplist_usermessage DROP CONSTRAINT FK_7F30F469F132696E'); + $this->addSql('ALTER TABLE phplist_usermessage DROP CONSTRAINT FK_7F30F46931478478'); + $this->addSql('DROP TABLE phplist_admin'); + $this->addSql('DROP TABLE phplist_admin_attribute'); + $this->addSql('DROP TABLE phplist_admin_login'); + $this->addSql('DROP TABLE phplist_admin_password_request'); + $this->addSql('DROP TABLE phplist_adminattribute'); + $this->addSql('DROP TABLE phplist_admintoken'); + $this->addSql('DROP TABLE phplist_attachment'); + $this->addSql('DROP TABLE phplist_bounce'); + $this->addSql('DROP TABLE phplist_bounceregex'); + $this->addSql('DROP TABLE phplist_bounceregex_bounce'); + $this->addSql('DROP TABLE phplist_config'); + $this->addSql('DROP TABLE phplist_eventlog'); + $this->addSql('DROP TABLE phplist_i18n'); + $this->addSql('DROP TABLE phplist_linktrack'); + $this->addSql('DROP TABLE phplist_linktrack_forward'); + $this->addSql('DROP TABLE phplist_linktrack_ml'); + $this->addSql('DROP TABLE phplist_linktrack_uml_click'); + $this->addSql('DROP TABLE phplist_linktrack_userclick'); + $this->addSql('DROP TABLE phplist_list'); + $this->addSql('DROP TABLE phplist_listmessage'); + $this->addSql('DROP TABLE phplist_listuser'); + $this->addSql('DROP TABLE phplist_message'); + $this->addSql('DROP TABLE phplist_message_attachment'); + $this->addSql('DROP TABLE phplist_messagedata'); + $this->addSql('DROP TABLE phplist_sendprocess'); + $this->addSql('DROP TABLE phplist_subscribepage'); + $this->addSql('DROP TABLE phplist_subscribepage_data'); + $this->addSql('DROP TABLE phplist_template'); + $this->addSql('DROP TABLE phplist_templateimage'); + $this->addSql('DROP TABLE phplist_urlcache'); + $this->addSql('DROP TABLE phplist_user_attribute'); + $this->addSql('DROP TABLE phplist_user_blacklist'); + $this->addSql('DROP TABLE phplist_user_blacklist_data'); + $this->addSql('DROP TABLE phplist_user_message_bounce'); + $this->addSql('DROP TABLE phplist_user_message_forward'); + $this->addSql('DROP TABLE phplist_user_message_view'); + $this->addSql('DROP TABLE phplist_user_user'); + $this->addSql('DROP TABLE phplist_user_user_attribute'); + $this->addSql('DROP TABLE phplist_user_user_history'); + $this->addSql('DROP TABLE phplist_usermessage'); + $this->addSql('DROP TABLE phplist_userstats'); + } +} diff --git a/src/Migrations/_template_migration.php.tpl b/src/Migrations/_template_migration.php.tpl new file mode 100644 index 00000000..72561549 --- /dev/null +++ b/src/Migrations/_template_migration.php.tpl @@ -0,0 +1,47 @@ +; + +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\Migrations\AbstractMigration; +use Doctrine\DBAL\Schema\Schema; + +/** +* ⚠️ Wizard warning: +* Doctrine will `helpfully` remove url(255) prefixes and add collations 5.7 can’t read. +* Review the SQL unless you enjoy debugging key length errors at 2 AM. +* +* Ex: phplist_linktrack_forward phplist_linktrack_forward_urlindex (but there are more) +*/ +final class extends AbstractMigration +{ + public function getDescription(): string + { + return ''; + } + + public function up(Schema $schema): void + { + $platform = $this->connection->getDatabasePlatform(); + $this->skipIf(!$platform instanceof , sprintf( + 'Unsupported platform for this migration: %s', + get_class($platform) + )); + + + } + + public function down(Schema $schema): void + { + $platform = $this->connection->getDatabasePlatform(); + $this->skipIf(!$platform instanceof , sprintf( + 'Unsupported platform for this migration: %s', + get_class($platform) + )); + + + } +} diff --git a/src/Migrations/initial_schema.sql b/src/Migrations/initial_schema.sql new file mode 100644 index 00000000..7f9cf70a --- /dev/null +++ b/src/Migrations/initial_schema.sql @@ -0,0 +1,879 @@ +-- MySQL dump 10.13 Distrib 8.0.43, for Linux (x86_64) +-- +-- Host: localhost Database: phplistdb +-- ------------------------------------------------------ +-- Server version 8.0.43-0ubuntu0.20.04.1+esm1 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `phplist_admin` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_admin` ( + `id` int NOT NULL AUTO_INCREMENT, + `loginname` varchar(66) NOT NULL, + `namelc` varchar(255) DEFAULT NULL, + `email` varchar(255) NOT NULL, + `created` datetime DEFAULT NULL, + `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `modifiedby` varchar(66) DEFAULT NULL, + `password` varchar(255) DEFAULT NULL, + `passwordchanged` date DEFAULT NULL, + `superuser` tinyint DEFAULT '0', + `disabled` tinyint DEFAULT '0', + `privileges` text, + PRIMARY KEY (`id`), + UNIQUE KEY `loginnameidx` (`loginname`) +) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_admin_attribute` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_admin_attribute` ( + `adminattributeid` int NOT NULL, + `adminid` int NOT NULL, + `value` varchar(255) DEFAULT NULL, + PRIMARY KEY (`adminattributeid`,`adminid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_admin_login` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_admin_login` ( + `id` int NOT NULL AUTO_INCREMENT, + `moment` bigint NOT NULL, + `adminid` int NOT NULL, + `remote_ip4` varchar(32) NOT NULL, + `remote_ip6` varchar(50) NOT NULL, + `sessionid` varchar(50) NOT NULL, + `active` tinyint NOT NULL DEFAULT '0', + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=414 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_admin_password_request` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_admin_password_request` ( + `id_key` int NOT NULL AUTO_INCREMENT, + `date` datetime DEFAULT NULL, + `admin` int DEFAULT NULL, + `key_value` varchar(32) NOT NULL, + PRIMARY KEY (`id_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_adminattribute` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_adminattribute` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `type` varchar(30) DEFAULT NULL, + `listorder` int DEFAULT NULL, + `default_value` varchar(255) DEFAULT NULL, + `required` tinyint DEFAULT NULL, + `tablename` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_admintoken` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_admintoken` ( + `id` int NOT NULL AUTO_INCREMENT, + `adminid` int NOT NULL, + `value` varchar(255) DEFAULT NULL, + `entered` int NOT NULL, + `expires` datetime NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=3670 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_attachment` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_attachment` ( + `id` int NOT NULL AUTO_INCREMENT, + `filename` varchar(255) DEFAULT NULL, + `remotefile` varchar(255) DEFAULT NULL, + `mimetype` varchar(255) DEFAULT NULL, + `description` text, + `size` int DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_bounce` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_bounce` ( + `id` int NOT NULL AUTO_INCREMENT, + `date` datetime DEFAULT NULL, + `header` text, + `data` mediumblob, + `status` varchar(255) DEFAULT NULL, + `comment` text, + PRIMARY KEY (`id`), + KEY `dateindex` (`date`), + KEY `statusidx` (`status`(20)) +) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_bounceregex` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_bounceregex` ( + `id` int NOT NULL AUTO_INCREMENT, + `regex` varchar(2083) DEFAULT NULL, + `regexhash` char(32) DEFAULT NULL, + `action` varchar(255) DEFAULT NULL, + `listorder` int DEFAULT '0', + `admin` int DEFAULT NULL, + `comment` text, + `status` varchar(255) DEFAULT NULL, + `count` int DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `regex` (`regexhash`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_bounceregex_bounce` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_bounceregex_bounce` ( + `regex` int NOT NULL, + `bounce` int NOT NULL, + PRIMARY KEY (`regex`,`bounce`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_config` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_config` ( + `item` varchar(35) NOT NULL, + `value` longtext, + `editable` tinyint DEFAULT '1', + `type` varchar(25) DEFAULT NULL, + PRIMARY KEY (`item`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_eventlog` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_eventlog` ( + `id` int NOT NULL AUTO_INCREMENT, + `entered` datetime DEFAULT NULL, + `page` varchar(100) DEFAULT NULL, + `entry` text, + PRIMARY KEY (`id`), + KEY `enteredidx` (`entered`), + KEY `pageidx` (`page`) +) ENGINE=InnoDB AUTO_INCREMENT=343 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_i18n` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_i18n` ( + `lan` varchar(10) NOT NULL, + `original` text NOT NULL, + `translation` text NOT NULL, + UNIQUE KEY `lanorigunq` (`lan`,`original`(200)), + KEY `lanorigidx` (`lan`,`original`(200)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_linktrack` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_linktrack` ( + `linkid` int NOT NULL AUTO_INCREMENT, + `messageid` int NOT NULL, + `userid` int NOT NULL, + `url` varchar(255) DEFAULT NULL, + `forward` varchar(255) DEFAULT NULL, + `firstclick` datetime DEFAULT NULL, + `latestclick` timestamp NULL DEFAULT NULL, + `clicked` int DEFAULT '0', + PRIMARY KEY (`linkid`), + UNIQUE KEY `miduidurlindex` (`messageid`,`userid`,`url`), + KEY `midindex` (`messageid`), + KEY `uidindex` (`userid`), + KEY `urlindex` (`url`), + KEY `miduidindex` (`messageid`,`userid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_linktrack_forward` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_linktrack_forward` ( + `id` int NOT NULL AUTO_INCREMENT, + `url` varchar(2083) DEFAULT NULL, + `urlhash` char(32) DEFAULT NULL, + `uuid` varchar(36) DEFAULT '', + `personalise` tinyint DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `urlunique` (`urlhash`), + KEY `urlindex` (`url`(255)), + KEY `uuididx` (`uuid`) +) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_linktrack_ml` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_linktrack_ml` ( + `messageid` int NOT NULL, + `forwardid` int NOT NULL, + `firstclick` datetime DEFAULT NULL, + `latestclick` datetime DEFAULT NULL, + `total` int DEFAULT '0', + `clicked` int DEFAULT '0', + `htmlclicked` int DEFAULT '0', + `textclicked` int DEFAULT '0', + PRIMARY KEY (`messageid`,`forwardid`), + KEY `midindex` (`messageid`), + KEY `fwdindex` (`forwardid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_linktrack_uml_click` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_linktrack_uml_click` ( + `id` int NOT NULL AUTO_INCREMENT, + `messageid` int NOT NULL, + `userid` int NOT NULL, + `forwardid` int DEFAULT NULL, + `firstclick` datetime DEFAULT NULL, + `latestclick` datetime DEFAULT NULL, + `clicked` int DEFAULT '0', + `htmlclicked` int DEFAULT '0', + `textclicked` int DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `miduidfwdid` (`messageid`,`userid`,`forwardid`), + KEY `midindex` (`messageid`), + KEY `uidindex` (`userid`), + KEY `miduidindex` (`messageid`,`userid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_linktrack_userclick` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_linktrack_userclick` ( + `linkid` int NOT NULL, + `userid` int NOT NULL, + `messageid` int NOT NULL, + `name` varchar(255) DEFAULT NULL, + `data` text, + `date` datetime DEFAULT NULL, + KEY `linkindex` (`linkid`), + KEY `uidindex` (`userid`), + KEY `midindex` (`messageid`), + KEY `linkuserindex` (`linkid`,`userid`), + KEY `linkusermessageindex` (`linkid`,`userid`,`messageid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_list` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_list` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `description` text, + `entered` datetime DEFAULT NULL, + `listorder` int DEFAULT NULL, + `prefix` varchar(10) DEFAULT NULL, + `rssfeed` varchar(255) DEFAULT NULL, + `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `active` tinyint DEFAULT NULL, + `owner` int DEFAULT NULL, + `category` varchar(255) DEFAULT '', + PRIMARY KEY (`id`), + KEY `nameidx` (`name`), + KEY `listorderidx` (`listorder`) +) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_listattr_becities` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_listattr_becities` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(255) DEFAULT NULL, + `listorder` int DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`(150)) +) ENGINE=InnoDB AUTO_INCREMENT=2680 DEFAULT CHARSET=utf8mb4; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_listattr_termsofservice` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_listattr_termsofservice` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(255) DEFAULT NULL, + `listorder` int DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`(150)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_listattr_ukcounties` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_listattr_ukcounties` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(255) DEFAULT NULL, + `listorder` int DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`(150)) +) ENGINE=InnoDB AUTO_INCREMENT=184 DEFAULT CHARSET=utf8mb4; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_listattr_ukcounties1` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_listattr_ukcounties1` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(255) DEFAULT NULL, + `listorder` int DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`(150)) +) ENGINE=InnoDB AUTO_INCREMENT=184 DEFAULT CHARSET=utf8mb4; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_listmessage` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_listmessage` ( + `id` int NOT NULL AUTO_INCREMENT, + `messageid` int NOT NULL, + `listid` int NOT NULL, + `entered` datetime DEFAULT NULL, + `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `messageid` (`messageid`,`listid`), + KEY `listmessageidx` (`listid`,`messageid`) +) ENGINE=InnoDB AUTO_INCREMENT=36 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_listuser` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_listuser` ( + `userid` int NOT NULL, + `listid` int NOT NULL, + `entered` datetime DEFAULT NULL, + `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`userid`,`listid`), + KEY `userenteredidx` (`userid`,`entered`), + KEY `userlistenteredidx` (`userid`,`listid`,`entered`), + KEY `useridx` (`userid`), + KEY `listidx` (`listid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_message` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_message` ( + `id` int NOT NULL AUTO_INCREMENT, + `uuid` varchar(36) DEFAULT '', + `subject` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '(no subject)', + `fromfield` varchar(255) NOT NULL DEFAULT '', + `tofield` varchar(255) NOT NULL DEFAULT '', + `replyto` varchar(255) NOT NULL DEFAULT '', + `message` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci, + `textmessage` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci, + `footer` text, + `entered` datetime DEFAULT NULL, + `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `embargo` datetime DEFAULT NULL, + `repeatinterval` int DEFAULT '0', + `repeatuntil` datetime DEFAULT NULL, + `requeueinterval` int DEFAULT '0', + `requeueuntil` datetime DEFAULT NULL, + `status` varchar(255) DEFAULT NULL, + `userselection` text, + `sent` datetime DEFAULT NULL, + `htmlformatted` tinyint DEFAULT '0', + `sendformat` varchar(20) DEFAULT NULL, + `template` int DEFAULT NULL, + `processed` int unsigned DEFAULT '0', + `astext` int DEFAULT '0', + `ashtml` int DEFAULT '0', + `astextandhtml` int DEFAULT '0', + `aspdf` int DEFAULT '0', + `astextandpdf` int DEFAULT '0', + `viewed` int DEFAULT '0', + `bouncecount` int DEFAULT '0', + `sendstart` datetime DEFAULT NULL, + `rsstemplate` varchar(100) DEFAULT NULL, + `owner` int DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `uuididx` (`uuid`) +) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_message_attachment` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_message_attachment` ( + `id` int NOT NULL AUTO_INCREMENT, + `messageid` int NOT NULL, + `attachmentid` int NOT NULL, + PRIMARY KEY (`id`), + KEY `messageidx` (`messageid`), + KEY `messageattidx` (`messageid`,`attachmentid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_messagedata` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_messagedata` ( + `name` varchar(100) NOT NULL, + `id` int NOT NULL, + `data` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci, + PRIMARY KEY (`name`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_sendprocess` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_sendprocess` ( + `id` int NOT NULL AUTO_INCREMENT, + `started` datetime DEFAULT NULL, + `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `alive` int DEFAULT '1', + `ipaddress` varchar(50) DEFAULT NULL, + `page` varchar(100) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=58 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_subscribepage` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_subscribepage` ( + `id` int NOT NULL AUTO_INCREMENT, + `title` varchar(255) NOT NULL, + `active` tinyint DEFAULT '0', + `owner` int DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_subscribepage_data` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_subscribepage_data` ( + `id` int NOT NULL, + `name` varchar(100) NOT NULL, + `data` text, + PRIMARY KEY (`id`,`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_template` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_template` ( + `id` int NOT NULL AUTO_INCREMENT, + `title` varchar(255) NOT NULL, + `template` longblob, + `template_text` longblob, + `listorder` int DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `title` (`title`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_templateimage` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_templateimage` ( + `id` int NOT NULL AUTO_INCREMENT, + `template` int NOT NULL DEFAULT '0', + `mimetype` varchar(100) DEFAULT NULL, + `filename` varchar(100) DEFAULT NULL, + `data` longblob, + `width` int DEFAULT NULL, + `height` int DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `templateidx` (`template`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_urlcache` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_urlcache` ( + `id` int NOT NULL AUTO_INCREMENT, + `url` varchar(2083) NOT NULL, + `lastmodified` int DEFAULT NULL, + `added` datetime DEFAULT NULL, + `content` longblob, + PRIMARY KEY (`id`), + KEY `urlindex` (`url`(255)) +) ENGINE=InnoDB AUTO_INCREMENT=207 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_user_attribute` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_user_attribute` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `type` varchar(30) DEFAULT NULL, + `listorder` int DEFAULT NULL, + `default_value` varchar(255) DEFAULT NULL, + `required` tinyint DEFAULT NULL, + `tablename` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `nameindex` (`name`), + KEY `idnameindex` (`id`,`name`) +) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_user_blacklist` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_user_blacklist` ( + `email` varchar(255) NOT NULL, + `added` datetime DEFAULT NULL, + UNIQUE KEY `email` (`email`), + KEY `emailidx` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_user_blacklist_data` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_user_blacklist_data` ( + `email` varchar(150) NOT NULL, + `name` varchar(25) NOT NULL, + `data` text, + UNIQUE KEY `email` (`email`), + KEY `emailidx` (`email`), + KEY `emailnameidx` (`email`,`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_user_message_bounce` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_user_message_bounce` ( + `id` int NOT NULL AUTO_INCREMENT, + `user` int NOT NULL, + `message` int NOT NULL, + `bounce` int NOT NULL, + `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `umbindex` (`user`,`message`,`bounce`), + KEY `useridx` (`user`), + KEY `msgidx` (`message`), + KEY `bounceidx` (`bounce`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_user_message_forward` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_user_message_forward` ( + `id` int NOT NULL AUTO_INCREMENT, + `user` int NOT NULL, + `message` int NOT NULL, + `forward` varchar(255) DEFAULT NULL, + `status` varchar(255) DEFAULT NULL, + `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `usermessageidx` (`user`,`message`), + KEY `useridx` (`user`), + KEY `messageidx` (`message`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_user_message_view` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_user_message_view` ( + `id` int NOT NULL AUTO_INCREMENT, + `messageid` int NOT NULL, + `userid` int NOT NULL, + `viewed` datetime DEFAULT NULL, + `ip` varchar(255) DEFAULT NULL, + `data` longtext, + PRIMARY KEY (`id`), + KEY `usermsgidx` (`userid`,`messageid`), + KEY `msgidx` (`messageid`), + KEY `useridx` (`userid`) +) ENGINE=InnoDB AUTO_INCREMENT=25 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_user_user` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_user_user` ( + `id` int NOT NULL AUTO_INCREMENT, + `email` varchar(255) NOT NULL, + `confirmed` tinyint DEFAULT '0', + `blacklisted` tinyint DEFAULT '0', + `optedin` tinyint DEFAULT '0', + `bouncecount` int DEFAULT '0', + `entered` datetime DEFAULT NULL, + `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `uniqid` varchar(255) DEFAULT NULL, + `uuid` varchar(36) DEFAULT '', + `htmlemail` tinyint DEFAULT '0', + `subscribepage` int DEFAULT NULL, + `rssfrequency` varchar(100) DEFAULT NULL, + `password` varchar(255) DEFAULT NULL, + `passwordchanged` date DEFAULT NULL, + `disabled` tinyint DEFAULT '0', + `extradata` text, + `foreignkey` varchar(100) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`), + KEY `foreignkey` (`foreignkey`), + KEY `idxuniqid` (`uniqid`), + KEY `enteredindex` (`entered`), + KEY `confidx` (`confirmed`), + KEY `blidx` (`blacklisted`), + KEY `optidx` (`optedin`), + KEY `uuididx` (`uuid`) +) ENGINE=InnoDB AUTO_INCREMENT=46 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_user_user_attribute` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_user_user_attribute` ( + `attributeid` int NOT NULL, + `userid` int NOT NULL, + `value` text, + PRIMARY KEY (`attributeid`,`userid`), + KEY `userindex` (`userid`), + KEY `attindex` (`attributeid`), + KEY `attuserid` (`userid`,`attributeid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_user_user_history` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_user_user_history` ( + `id` int NOT NULL AUTO_INCREMENT, + `userid` int NOT NULL, + `ip` varchar(255) DEFAULT NULL, + `date` datetime DEFAULT NULL, + `summary` varchar(255) DEFAULT NULL, + `detail` text, + `systeminfo` text, + PRIMARY KEY (`id`), + KEY `userididx` (`userid`), + KEY `dateidx` (`date`) +) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_usermessage` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_usermessage` ( + `messageid` int NOT NULL, + `userid` int NOT NULL, + `entered` datetime NOT NULL, + `viewed` datetime DEFAULT NULL, + `status` varchar(255) DEFAULT NULL, + PRIMARY KEY (`userid`,`messageid`), + KEY `messageidindex` (`messageid`), + KEY `useridindex` (`userid`), + KEY `enteredindex` (`entered`), + KEY `statusidx` (`status`), + KEY `viewedidx` (`viewed`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `phplist_userstats` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `phplist_userstats` ( + `id` int NOT NULL AUTO_INCREMENT, + `unixdate` int DEFAULT NULL, + `item` varchar(255) DEFAULT NULL, + `listid` int DEFAULT '0', + `value` int DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `entry` (`unixdate`,`item`,`listid`), + KEY `dateindex` (`unixdate`), + KEY `itemindex` (`item`), + KEY `listindex` (`listid`), + KEY `listdateindex` (`listid`,`unixdate`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2025-10-28 14:22:41 diff --git a/src/Routing/ExtraLoader.php b/src/Routing/ExtraLoader.php index 39853031..d5f40ec1 100644 --- a/src/Routing/ExtraLoader.php +++ b/src/Routing/ExtraLoader.php @@ -43,8 +43,6 @@ public function __construct(ApplicationStructure $applicationStructure) /** * Loads a resource. * - * @SuppressWarnings("PHPMD.UnusedFormalParameter") - * * @param mixed $resource the resource (unused) * @param string|null $type the resource type or null if unknown (unused) * @@ -69,8 +67,6 @@ public function load($resource, string $type = null): RouteCollection /** * Checks whether this class supports the given resource. * - * @SuppressWarnings("PHPMD.UnusedFormalParameter") - * * @param mixed $resource a resource (unused) * @param string|null $type The resource type or null if unknown * diff --git a/tests/Integration/Domain/Identity/Fixtures/AdministratorTokenWithAdministratorFixture.php b/tests/Integration/Domain/Identity/Fixtures/AdministratorTokenWithAdministratorFixture.php index e382a4c7..18bbc08b 100644 --- a/tests/Integration/Domain/Identity/Fixtures/AdministratorTokenWithAdministratorFixture.php +++ b/tests/Integration/Domain/Identity/Fixtures/AdministratorTokenWithAdministratorFixture.php @@ -40,17 +40,16 @@ public function load(ObjectManager $manager): void $admin = $adminRepository->find($row['adminid']); if ($admin === null) { - $admin = new Administrator(); + $admin = (new Administrator())->setLoginName($row['id']); $this->setSubjectId($admin, (int)$row['adminid']); $manager->persist($admin); } - $adminToken = new AdministratorToken(); + $adminToken = new AdministratorToken($admin); $this->setSubjectId($adminToken, (int)$row['id']); $adminToken->setKey($row['value']); $this->setSubjectProperty($adminToken, 'expiry', new DateTime($row['expires'])); $this->setSubjectProperty($adminToken, 'createdAt', (bool) $row['entered']); - $adminToken->setAdministrator($admin); $manager->persist($adminToken); } while (true); diff --git a/tests/Integration/Domain/Identity/Fixtures/DetachedAdministratorTokenFixture.php b/tests/Integration/Domain/Identity/Fixtures/DetachedAdministratorTokenFixture.php index a3d16ea7..4468f131 100644 --- a/tests/Integration/Domain/Identity/Fixtures/DetachedAdministratorTokenFixture.php +++ b/tests/Integration/Domain/Identity/Fixtures/DetachedAdministratorTokenFixture.php @@ -7,6 +7,7 @@ use DateTime; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; +use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use RuntimeException; @@ -28,6 +29,9 @@ public function load(ObjectManager $manager): void } $headers = fgetcsv($handle); + $admin = (new Administrator())->setLoginName('admin'); + $this->setSubjectId($admin, 1); + $manager->persist($admin); do { $data = fgetcsv($handle); @@ -36,7 +40,7 @@ public function load(ObjectManager $manager): void } $row = array_combine($headers, $data); - $adminToken = new AdministratorToken(); + $adminToken = new AdministratorToken($admin); $this->setSubjectId($adminToken, (int)$row['id']); $adminToken->setKey($row['value']); diff --git a/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php b/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php index a5215789..a69a751b 100644 --- a/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php +++ b/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php @@ -93,7 +93,7 @@ public function testModificationDateOfExistingModelGetsUpdatedOnUpdate(): void public function testCreationDateOfNewModelIsSetToNowOnPersist() { - $model = new Administrator(); + $model = (new Administrator())->setLoginName('ta'); $this->entityManager->persist($model); $this->entityManager->flush(); @@ -104,7 +104,7 @@ public function testCreationDateOfNewModelIsSetToNowOnPersist() public function testModificationDateOfNewModelIsSetToNowOnPersist() { - $model = new Administrator(); + $model = (new Administrator())->setLoginName('tat'); $this->entityManager->persist($model); $this->entityManager->flush(); @@ -165,7 +165,7 @@ public function testFindOneByLoginCredentialsIgnoresNonSuperUser() public function testSavePersistsAndFlushesModel(): void { - $model = new Administrator(); + $model = (new Administrator())->setLoginName('t'); $this->repository->save($model); $this->assertSame($model, $this->repository->find($model->getId())); diff --git a/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php b/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php index ff750a63..015061d0 100644 --- a/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php +++ b/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php @@ -130,8 +130,7 @@ public function testSavePersistsAndFlushesModel() /** @var Administrator $administrator */ $administrator = $administratorRepository->find(1); - $model = new AdministratorToken(); - $model->setAdministrator($administrator); + $model = new AdministratorToken($administrator); $this->repository->save($model); self::assertSame($model, $this->repository->find($model->getId())); diff --git a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php index 1bd98735..388fa391 100644 --- a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php +++ b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php @@ -42,7 +42,7 @@ protected function tearDown(): void public function testMessageIsPersistedAndFetchedCorrectly(): void { - $admin = new Administrator(); + $admin = (new Administrator())->setLoginName('t'); $this->entityManager->persist($admin); $message = new Message( @@ -68,8 +68,8 @@ public function testMessageIsPersistedAndFetchedCorrectly(): void public function testGetByOwnerIdReturnsOnlyOwnedMessages(): void { - $admin1 = new Administrator(); - $admin2 = new Administrator(); + $admin1 = (new Administrator())->setLoginName('1'); + $admin2 = (new Administrator())->setLoginName('2'); $this->entityManager->persist($admin1); $this->entityManager->persist($admin2); diff --git a/tests/Integration/Domain/Subscription/Fixtures/SubscriberListFixture.php b/tests/Integration/Domain/Subscription/Fixtures/SubscriberListFixture.php index 133d2248..57345573 100644 --- a/tests/Integration/Domain/Subscription/Fixtures/SubscriberListFixture.php +++ b/tests/Integration/Domain/Subscription/Fixtures/SubscriberListFixture.php @@ -41,7 +41,8 @@ public function load(ObjectManager $manager): void $admin = $adminRepository->find($row['owner']); if ($admin === null) { - $admin = new Administrator(); + $admin = (new Administrator()) + ->setLoginName($row['name']); $this->setSubjectId($admin, (int)$row['owner']); $manager->persist($admin); } diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php index 9019fd30..8fb8e31c 100644 --- a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php +++ b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php @@ -52,7 +52,7 @@ protected function tearDown(): void public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError(): void { - $admin = new Administrator(); + $admin = (new Administrator())->setLoginName('ta'); $this->entityManager->persist($admin); $msg = new Message( diff --git a/tests/Unit/Domain/Identity/Model/AdministratorTest.php b/tests/Unit/Domain/Identity/Model/AdministratorTest.php index f721f58b..cf90e0c9 100644 --- a/tests/Unit/Domain/Identity/Model/AdministratorTest.php +++ b/tests/Unit/Domain/Identity/Model/AdministratorTest.php @@ -27,7 +27,7 @@ class AdministratorTest extends TestCase protected function setUp(): void { - $this->subject = new Administrator(); + $this->subject = (new Administrator())->setLoginName(''); } public function testIsDomainModel(): void @@ -69,21 +69,21 @@ public function testSetEmailAddressSetsEmailAddress(): void self::assertSame($value, $this->subject->getEmail()); } - public function testGetUpdatedAtInitiallyReturnsNull(): void + public function testGetUpdatedAtInitiallyReturnsNotNull(): void { - self::assertNull($this->subject->getUpdatedAt()); + self::assertNotNull($this->subject->getUpdatedAt()); } public function testUpdateModificationDateSetsModificationDateToNow(): void { - $this->subject->updateUpdatedAt(); + $this->subject->setEmail('update@email.com'); self::assertSimilarDates(new DateTime(), $this->subject->getUpdatedAt()); } - public function testGetPasswordHashInitiallyReturnsEmptyString(): void + public function testGetPasswordHashInitiallyReturnsNull(): void { - self::assertSame('', $this->subject->getPasswordHash()); + self::assertNull($this->subject->getPasswordHash()); } public function testSetPasswordHashSetsPasswordHash(): void diff --git a/tests/Unit/Domain/Identity/Model/AdministratorTokenTest.php b/tests/Unit/Domain/Identity/Model/AdministratorTokenTest.php index da824845..84a98df7 100644 --- a/tests/Unit/Domain/Identity/Model/AdministratorTokenTest.php +++ b/tests/Unit/Domain/Identity/Model/AdministratorTokenTest.php @@ -26,7 +26,7 @@ class AdministratorTokenTest extends TestCase protected function setUp(): void { - $this->subject = new AdministratorToken(); + $this->subject = new AdministratorToken((new Administrator())->setLoginName('admin')); } public function testIsDomainModel(): void @@ -54,11 +54,6 @@ public function testUpdateCreationDateSetsCreationDateToNow(): void self::assertSimilarDates(new DateTime(), $this->subject->getCreatedAt()); } - public function testGetKeyInitiallyReturnsEmptyString(): void - { - self::assertSame('', $this->subject->getKey()); - } - public function testSetKeySetsKey(): void { $value = 'Club-Mate'; @@ -74,8 +69,6 @@ public function testGetExpiryInitiallyReturnsDateTime(): void public function testGenerateExpirySetsExpiryOneHourInTheFuture(): void { - $this->subject->generateExpiry(); - self::assertSimilarDates(new DateTime('+1 hour'), $this->subject->getExpiry()); } @@ -97,16 +90,8 @@ public function testGenerateKeyCreatesDifferentKeysForEachCall(): void self::assertNotSame($firstKey, $secondKey); } - public function testGetAdministratorInitiallyReturnsNull(): void + public function testGetAdministratorReturnsConstructorProvidedAdministrator(): void { - self::assertNull($this->subject->getAdministrator()); - } - - public function testSetAdministratorSetsAdministrator(): void - { - $model = new Administrator(); - $this->subject->setAdministrator($model); - - self::assertSame($model, $this->subject->getAdministrator()); + self::assertNotNull($this->subject->getAdministrator()); } } diff --git a/tests/Unit/Domain/Subscription/Model/SubscriberTest.php b/tests/Unit/Domain/Subscription/Model/SubscriberTest.php index 5a60c5de..0827f7fd 100644 --- a/tests/Unit/Domain/Subscription/Model/SubscriberTest.php +++ b/tests/Unit/Domain/Subscription/Model/SubscriberTest.php @@ -56,9 +56,9 @@ public function testUpdateCreationDateSetsCreationDateToNow(): void self::assertSimilarDates(new \DateTime(), $this->subscriber->getCreatedAt()); } - public function testgetUpdatedAtInitiallyReturnsNull(): void + public function testGetUpdatedAtInitiallyReturnsNotNull(): void { - self::assertNull($this->subscriber->getUpdatedAt()); + self::assertNotNull($this->subscriber->getUpdatedAt()); } public function testUpdateModificationDateSetsModificationDateToNow(): void diff --git a/tests/Unit/Security/AuthenticationTest.php b/tests/Unit/Security/AuthenticationTest.php index 0dad0bec..58f75f61 100644 --- a/tests/Unit/Security/AuthenticationTest.php +++ b/tests/Unit/Security/AuthenticationTest.php @@ -34,10 +34,9 @@ public function testAuthenticateByApiKeyWithValidApiKeyInBasicAuthReturnsMatchin $request = new Request(); $request->headers->add(['php-auth-pw' => $apiKey]); - $token = new AdministratorToken(); $administrator = new Administrator(); $administrator->setSuperUser(true); - $token->setAdministrator($administrator); + $token = new AdministratorToken($administrator); $this->tokenRepository ->expects($this->any()) @@ -54,7 +53,7 @@ public function testAuthenticateByApiKeyWithValidApiKeyInBasicAuthWithoutAdminis $request = new Request(); $request->headers->add(['php-auth-pw' => $apiKey]); - $token = new AdministratorToken(); + $token = $this->createMock(AdministratorToken::class); $this->tokenRepository ->expects($this->any()) From 5231d0ad1001bf451bfc9df757934ffda3184a54 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Wed, 5 Nov 2025 11:04:59 +0400 Subject: [PATCH 18/20] Import by foreign key (#367) * Import with a foreign key --------- Co-authored-by: Tatevik --- resources/translations/messages.en.xlf | 4 + .../Model/Dto/ImportSubscriberDto.php | 10 +- .../Repository/SubscriberRepository.php | 6 + .../Service/CsvRowToDtoMapper.php | 39 ++- .../Service/Manager/SubscriberManager.php | 6 + .../Service/SubscriberCsvImporter.php | 63 ++++- .../Version20251103SeedInitialAdmin.php | 49 ++++ .../Service/SubscriberCsvImporterTest.php | 260 ++++++++++++++++++ 8 files changed, 412 insertions(+), 25 deletions(-) create mode 100644 src/Migrations/Version20251103SeedInitialAdmin.php diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf index b3204742..efeb8ad2 100644 --- a/resources/translations/messages.en.xlf +++ b/resources/translations/messages.en.xlf @@ -730,6 +730,10 @@ Thank you. Campaign not found or not in submitted status __Campaign not found or not in submitted status + + Conflict: email and foreign key refer to different subscribers. + __Conflict: email and foreign key refer to different subscribers. + diff --git a/src/Domain/Subscription/Model/Dto/ImportSubscriberDto.php b/src/Domain/Subscription/Model/Dto/ImportSubscriberDto.php index 425a57d6..ee468fa1 100644 --- a/src/Domain/Subscription/Model/Dto/ImportSubscriberDto.php +++ b/src/Domain/Subscription/Model/Dto/ImportSubscriberDto.php @@ -11,6 +11,12 @@ class ImportSubscriberDto #[Assert\NotBlank] public string $email; + /** + * Optional external identifier used for matching existing subscribers during import. + */ + #[Assert\Length(max: 191)] + public ?string $foreignKey = null; + #[Assert\Type('bool')] public bool $confirmed; @@ -37,7 +43,8 @@ public function __construct( bool $htmlEmail, bool $disabled, ?string $extraData = null, - array $extraAttributes = [] + array $extraAttributes = [], + ?string $foreignKey = null, ) { $this->email = $email; $this->confirmed = $confirmed; @@ -47,5 +54,6 @@ public function __construct( $this->disabled = $disabled; $this->extraData = $extraData; $this->extraAttributes = $extraAttributes; + $this->foreignKey = $foreignKey; } } diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index 2ea02474..af776a75 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -16,6 +16,7 @@ * * @author Oliver Klee * @author Tatevik Grigoryan + * @SuppressWarnings(PHPMD.TooManyPublicMethods) */ class SubscriberRepository extends AbstractRepository implements PaginatableRepositoryInterface { @@ -41,6 +42,11 @@ public function findOneByUniqueId(string $uniqueId): ?Subscriber return $this->findOneBy(['uniqueId' => $uniqueId]); } + public function findOneByForeignKey(string $foreignKey): ?Subscriber + { + return $this->findOneBy(['foreignKey' => $foreignKey]); + } + public function findSubscribersBySubscribedList(int $listId): ?Subscriber { return $this->createQueryBuilder('s') diff --git a/src/Domain/Subscription/Service/CsvRowToDtoMapper.php b/src/Domain/Subscription/Service/CsvRowToDtoMapper.php index 606e58d8..0657c538 100644 --- a/src/Domain/Subscription/Service/CsvRowToDtoMapper.php +++ b/src/Domain/Subscription/Service/CsvRowToDtoMapper.php @@ -8,24 +8,45 @@ class CsvRowToDtoMapper { + private const FK_HEADER = 'foreignkey'; private const KNOWN_HEADERS = [ - 'email', 'confirmed', 'blacklisted', 'html_email', 'disabled', 'extra_data', + 'email', 'confirmed', 'blacklisted', 'html_email', 'disabled', 'extra_data', 'foreignkey', ]; public function map(array $row): ImportSubscriberDto { - $extraAttributes = array_filter($row, function ($key) { + // Normalize keys to lower-case for header matching safety (CSV library keeps original headers) + $normalizedRow = $this->normalizeData($row); + + $email = strtolower(trim((string)($normalizedRow['email'] ?? ''))); + + if (array_key_exists(self::FK_HEADER, $normalizedRow) && $normalizedRow[self::FK_HEADER] !== '') { + $foreignKey = (string)$normalizedRow[self::FK_HEADER]; + } + + $extraAttributes = array_filter($normalizedRow, function ($key) { return !in_array($key, self::KNOWN_HEADERS, true); }, ARRAY_FILTER_USE_KEY); return new ImportSubscriberDto( - email: trim($row['email'] ?? ''), - confirmed: filter_var($row['confirmed'] ?? false, FILTER_VALIDATE_BOOLEAN), - blacklisted: filter_var($row['blacklisted'] ?? false, FILTER_VALIDATE_BOOLEAN), - htmlEmail: filter_var($row['html_email'] ?? false, FILTER_VALIDATE_BOOLEAN), - disabled: filter_var($row['disabled'] ?? false, FILTER_VALIDATE_BOOLEAN), - extraData: $row['extra_data'] ?? null, - extraAttributes: $extraAttributes + email: $email, + confirmed: filter_var($normalizedRow['confirmed'] ?? false, FILTER_VALIDATE_BOOLEAN), + blacklisted: filter_var($normalizedRow['blacklisted'] ?? false, FILTER_VALIDATE_BOOLEAN), + htmlEmail: filter_var($normalizedRow['html_email'] ?? false, FILTER_VALIDATE_BOOLEAN), + disabled: filter_var($normalizedRow['disabled'] ?? false, FILTER_VALIDATE_BOOLEAN), + extraData: $normalizedRow['extra_data'] ?? null, + extraAttributes: $extraAttributes, + foreignKey: $foreignKey ?? null, ); } + + private function normalizeData(array $row): array + { + $normalizedRow = []; + foreach ($row as $key => $value) { + $normalizedRow[strtolower((string)$key)] = is_string($value) ? trim($value) : $value; + } + + return $normalizedRow; + } } diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php index 1993cd9b..e0b7a3dd 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php @@ -114,6 +114,9 @@ public function createFromImport(ImportSubscriberDto $subscriberDto): Subscriber $subscriber->setHtmlEmail($subscriberDto->htmlEmail); $subscriber->setDisabled($subscriberDto->disabled); $subscriber->setExtraData($subscriberDto->extraData ?? ''); + if ($subscriberDto->foreignKey !== null) { + $subscriber->setForeignKey($subscriberDto->foreignKey); + } $this->entityManager->persist($subscriber); @@ -129,6 +132,9 @@ public function updateFromImport(Subscriber $existingSubscriber, ImportSubscribe $existingSubscriber->setHtmlEmail($subscriberDto->htmlEmail); $existingSubscriber->setDisabled($subscriberDto->disabled); $existingSubscriber->setExtraData($subscriberDto->extraData); + if ($subscriberDto->foreignKey !== null) { + $existingSubscriber->setForeignKey($subscriberDto->foreignKey); + } $uow = $this->entityManager->getUnitOfWork(); $meta = $this->entityManager->getClassMetadata(Subscriber::class); diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php index e80db165..6873a03a 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php @@ -182,7 +182,14 @@ private function processRow( return null; } - $subscriber = $this->subscriberRepository->findOneByEmail($dto->email); + [$subscriber, $conflictError] = $this->resolveSubscriber($dto); + + if ($conflictError !== null) { + $stats['skipped']++; + $stats['errors'][] = $conflictError; + return null; + } + if ($this->handleSkipCase($subscriber, $options, $stats)) { return null; } @@ -197,20 +204,7 @@ private function processRow( $this->attributeManager->processAttributes($subscriber, $dto->extraAttributes); - $addedNewSubscriberToList = false; - $listLines = []; - if (!$subscriber->isBlacklisted() && count($options->listIds) > 0) { - foreach ($options->listIds as $listId) { - $created = $this->subscriptionManager->addSubscriberToAList($subscriber, $listId); - if ($created) { - $addedNewSubscriberToList = true; - $listLines[] = $this->translator->trans( - 'Subscribed to %list%', - ['%list%' => $created->getSubscriberList()->getName()] - ); - } - } - } + [$listLines, $addedNewSubscriberToList] = $this->getHistoryListLines($subscriber, $options); if ($subscriber->isBlacklisted()) { $stats['blacklisted']++; @@ -226,6 +220,22 @@ private function processRow( return $this->prepareConfirmationMessage($subscriber, $options, $dto, $addedNewSubscriberToList); } + private function resolveSubscriber(ImportSubscriberDto $dto): array + { + $byEmail = $this->subscriberRepository->findOneByEmail($dto->email); + $byFk = null; + + if ($dto->foreignKey !== null) { + $byFk = $this->subscriberRepository->findOneByForeignKey($dto->foreignKey); + } + + if ($byEmail && $byFk && $byEmail->getId() !== $byFk->getId()) { + return [null, $this->translator->trans('Conflict: email and foreign key refer to different subscribers.')]; + } + + return [$byFk ?? $byEmail, null]; + } + private function handleInvalidEmail( ImportSubscriberDto $dto, SubscriberImportOptions $options, @@ -277,4 +287,27 @@ private function prepareConfirmationMessage( return null; } + + private function getHistoryListLines(Subscriber $subscriber, SubscriberImportOptions $options): array + { + $addedNewSubscriberToList = false; + $listLines = []; + if (!$subscriber->isBlacklisted() && count($options->listIds) > 0) { + foreach ($options->listIds as $listId) { + $created = $this->subscriptionManager->addSubscriberToAList($subscriber, $listId); + if ($created) { + $addedNewSubscriberToList = true; + $listLines[] = $this->translator->trans( + 'Subscribed to %list%', + ['%list%' => $created->getSubscriberList()->getName()] + ); + } + } + } + + return [ + $listLines, + $addedNewSubscriberToList, + ]; + } } diff --git a/src/Migrations/Version20251103SeedInitialAdmin.php b/src/Migrations/Version20251103SeedInitialAdmin.php new file mode 100644 index 00000000..c1d95889 --- /dev/null +++ b/src/Migrations/Version20251103SeedInitialAdmin.php @@ -0,0 +1,49 @@ +connection->getDatabasePlatform(); + $this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf( + 'Unsupported platform for this migration: %s', + get_class($platform) + )); + + $this->addSql(<<<'SQL' + INSERT INTO phplist_admin (id, created, modified, loginname, namelc, email, password, passwordchanged, disabled, superuser, privileges) + VALUES (1, NOW(), NOW(), 'admin', 'admin', 'admin@example.com', :hash, CURRENT_DATE, FALSE, TRUE, :privileges) + ON CONFLICT (id) DO UPDATE + SET + modified = EXCLUDED.modified, + privileges = EXCLUDED.privileges + SQL, [ + 'hash' => hash('sha256', 'password'), + 'privileges' => 'a:4:{s:11:"subscribers";b:1;s:9:"campaigns";b:1;s:10:"statistics";b:1;s:8:"settings";b:1;}', + ]); + } + + public function down(Schema $schema): void + { + $platform = $this->connection->getDatabasePlatform(); + $this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf( + 'Unsupported platform for this migration: %s', + get_class($platform) + )); + + $this->addSql('DELETE FROM phplist_admin WHERE id = 1'); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php index 424a7e0d..884bcc7e 100644 --- a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php +++ b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php @@ -158,6 +158,10 @@ public function testImportFromCsvUpdatesExistingSubscribers(): void ->with('existing@example.com') ->willReturn($existingSubscriber); + $this->subscriberRepositoryMock + ->method('findOneByForeignKey') + ->willReturn(null); + $importDto = new ImportSubscriberDto( email: 'existing@example.com', confirmed: true, @@ -190,6 +194,199 @@ public function testImportFromCsvUpdatesExistingSubscribers(): void ] )); + $this->attributeManagerMock + ->expects($this->once()) + ->method('processAttributes') + ->with($existingSubscriber); + + $options = new SubscriberImportOptions(updateExisting: true); + $result = $this->subject->importFromCsv($uploadedFile, $options); + + $this->assertSame(0, $result['created']); + $this->assertSame(1, $result['updated']); + $this->assertSame(0, $result['skipped']); + $this->assertEmpty($result['errors']); + + unlink($tempFile); + } + + public function testImportResolvesByForeignKeyWhenProvidedAndMatches(): void + { + $tempFile = tempnam(sys_get_temp_dir(), 'csv_test_fk'); + file_put_contents($tempFile, "email,confirmed,html_email,blacklisted,disabled,foreignkey\n" . + "user@example.com,1,1,0,0,EXT-123\n"); + + $uploadedFile = $this->createMock(UploadedFile::class); + $uploadedFile->method('getRealPath')->willReturn($tempFile); + + $existingByFk = $this->createMock(Subscriber::class); + $existingByFk->method('getId')->willReturn(10); + + $this->subscriberRepositoryMock + ->method('findOneByEmail') + ->with('user@example.com') + ->willReturn(null); + + $this->subscriberRepositoryMock + ->method('findOneByForeignKey') + ->with('EXT-123') + ->willReturn($existingByFk); + + $dto = new ImportSubscriberDto( + email: 'user@example.com', + confirmed: true, + blacklisted: false, + htmlEmail: true, + disabled: false, + extraData: null, + extraAttributes: [], + foreignKey: 'EXT-123', + ); + + $this->csvImporterMock + ->method('import') + ->with($tempFile) + ->willReturn([ + 'valid' => [$dto], + 'errors' => [] + ]); + + $this->subscriberManagerMock + ->expects($this->once()) + ->method('updateFromImport') + ->with($existingByFk, $dto) + ->willReturn(new ChangeSetDto()); + + $this->attributeManagerMock + ->expects($this->once()) + ->method('processAttributes') + ->with($existingByFk); + + $options = new SubscriberImportOptions(updateExisting: true); + $result = $this->subject->importFromCsv($uploadedFile, $options); + + $this->assertSame(0, $result['created']); + $this->assertSame(1, $result['updated']); + $this->assertSame(0, $result['skipped']); + $this->assertEmpty($result['errors']); + + unlink($tempFile); + } + + public function testImportConflictWhenEmailAndForeignKeyReferToDifferentSubscribers(): void + { + $tempFile = tempnam(sys_get_temp_dir(), 'csv_test_fk_conflict'); + file_put_contents($tempFile, "email,confirmed,html_email,blacklisted,disabled,foreignkey\n" . + "conflict@example.com,1,1,0,0,EXT-999\n"); + + $uploadedFile = $this->createMock(UploadedFile::class); + $uploadedFile->method('getRealPath')->willReturn($tempFile); + + $byEmail = $this->createMock(Subscriber::class); + $byEmail->method('getId')->willReturn(1); + $byFk = $this->createMock(Subscriber::class); + $byFk->method('getId')->willReturn(2); + + $this->subscriberRepositoryMock + ->method('findOneByEmail') + ->with('conflict@example.com') + ->willReturn($byEmail); + + $this->subscriberRepositoryMock + ->method('findOneByForeignKey') + ->with('EXT-999') + ->willReturn($byFk); + + $dto = new ImportSubscriberDto( + email: 'conflict@example.com', + confirmed: true, + blacklisted: false, + htmlEmail: true, + disabled: false, + extraData: null, + extraAttributes: [], + foreignKey: 'EXT-999', + ); + + $this->csvImporterMock + ->method('import') + ->with($tempFile) + ->willReturn([ + 'valid' => [$dto], + 'errors' => [] + ]); + + $this->subscriberManagerMock + ->expects($this->never()) + ->method('createFromImport'); + $this->subscriberManagerMock + ->expects($this->never()) + ->method('updateFromImport'); + + $options = new SubscriberImportOptions(updateExisting: true); + $result = $this->subject->importFromCsv($uploadedFile, $options); + + $this->assertSame(0, $result['created']); + $this->assertSame(0, $result['updated']); + $this->assertSame(1, $result['skipped']); + $this->assertCount(1, $result['errors']); + $this->assertSame('Conflict: email and foreign key refer to different subscribers.', $result['errors'][0]); + + unlink($tempFile); + } + + public function testImportResolvesByEmailWhenForeignKeyNotFound(): void + { + $tempFile = tempnam(sys_get_temp_dir(), 'csv_test_fk_email'); + file_put_contents($tempFile, "email,confirmed,html_email,blacklisted,disabled,foreignkey\n" . + "existing@example.com,1,1,0,0,EXT-404\n"); + + $uploadedFile = $this->createMock(UploadedFile::class); + $uploadedFile->method('getRealPath')->willReturn($tempFile); + + $existingByEmail = $this->createMock(Subscriber::class); + $existingByEmail->method('getId')->willReturn(5); + + $this->subscriberRepositoryMock + ->method('findOneByEmail') + ->with('existing@example.com') + ->willReturn($existingByEmail); + + $this->subscriberRepositoryMock + ->method('findOneByForeignKey') + ->with('EXT-404') + ->willReturn(null); + + $dto = new ImportSubscriberDto( + email: 'existing@example.com', + confirmed: true, + blacklisted: false, + htmlEmail: true, + disabled: false, + extraData: null, + extraAttributes: [], + foreignKey: 'EXT-404', + ); + + $this->csvImporterMock + ->method('import') + ->with($tempFile) + ->willReturn([ + 'valid' => [$dto], + 'errors' => [] + ]); + + $this->subscriberManagerMock + ->expects($this->once()) + ->method('updateFromImport') + ->with($existingByEmail, $dto) + ->willReturn(new ChangeSetDto()); + + $this->attributeManagerMock + ->expects($this->once()) + ->method('processAttributes') + ->with($existingByEmail); + $options = new SubscriberImportOptions(updateExisting: true); $result = $this->subject->importFromCsv($uploadedFile, $options); @@ -200,4 +397,67 @@ public function testImportFromCsvUpdatesExistingSubscribers(): void unlink($tempFile); } + + public function testImportCreatesNewWhenNeitherEmailNorForeignKeyFound(): void + { + $tempFile = tempnam(sys_get_temp_dir(), 'csv_test_fk_create'); + file_put_contents($tempFile, "email,confirmed,html_email,blacklisted,disabled,foreignkey\n" . + "new@example.com,0,1,0,0,NEW-KEY\n"); + + $uploadedFile = $this->createMock(UploadedFile::class); + $uploadedFile->method('getRealPath')->willReturn($tempFile); + + $this->subscriberRepositoryMock + ->method('findOneByEmail') + ->with('new@example.com') + ->willReturn(null); + + $this->subscriberRepositoryMock + ->method('findOneByForeignKey') + ->with('NEW-KEY') + ->willReturn(null); + + $dto = new ImportSubscriberDto( + email: 'new@example.com', + confirmed: false, + blacklisted: false, + htmlEmail: true, + disabled: false, + extraData: null, + extraAttributes: [], + foreignKey: 'NEW-KEY', + ); + + $this->csvImporterMock + ->method('import') + ->with($tempFile) + ->willReturn([ + 'valid' => [$dto], + 'errors' => [] + ]); + + $created = $this->createMock(Subscriber::class); + $created->method('getId')->willReturn(100); + + $this->subscriberManagerMock + ->expects($this->once()) + ->method('createFromImport') + ->with($dto) + ->willReturn($created); + + $this->attributeManagerMock + ->expects($this->once()) + ->method('processAttributes') + ->with($created); + + $options = new SubscriberImportOptions(updateExisting: false); + $result = $this->subject->importFromCsv($uploadedFile, $options); + + $this->assertSame(1, $result['created']); + $this->assertSame(0, $result['updated']); + $this->assertSame(0, $result['skipped']); + $this->assertEmpty($result['errors']); + + unlink($tempFile); + } } From 323e67ff52f70bfabc979a74130a173930a84aec Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Wed, 5 Nov 2025 11:46:33 +0400 Subject: [PATCH 19/20] Insert initial admin command (#368) * Update: codeRabbit configs * Update: Use command for initial admin insert --------- Co-authored-by: Tatevik --- .coderabbit.yaml | 41 +++++++ .github/AGENT.md | 103 ++++++++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 10 +- .../Command/ImportDefaultsCommand.php | 100 +++++++++++++++++ .../Version20251103SeedInitialAdmin.php | 49 --------- 5 files changed, 246 insertions(+), 57 deletions(-) create mode 100644 .github/AGENT.md create mode 100644 src/Domain/Identity/Command/ImportDefaultsCommand.php delete mode 100644 src/Migrations/Version20251103SeedInitialAdmin.php diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 3cc5119b..e6be6bb2 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,9 +1,50 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json language: "en-US" +tone_instructions: "chill" reviews: profile: "chill" high_level_summary: true + collapse_walkthrough: true + suggested_labels: false + high_level_summary_in_walkthrough: false + poem: false + path_instructions: + - path: "src/Domain/**" + instructions: | + You are reviewing PHP domain-layer code. Enforce strict domain purity: + - ❌ Do not allow infrastructure persistence side effects here. + - Flag ANY usage of Doctrine persistence APIs, especially: + - $entityManager->flush(...), $this->entityManager->flush(...) + - $em->persist(...), $em->remove(...) + - direct transaction control ($em->beginTransaction(), commit(), rollback()) + - If found, request moving these calls to application-layer Command handlers or background Jobs. + - Also flag repositories in Domain that invoke flush/transactional logic; Domain repositories should be abstractions without side effects. + - Encourage domain events/outbox or return-values to signal write-intent, leaving orchestration to Commands/Jobs. + + - path: "src/**/Command/**" + instructions: | + Application layer (Commands/Handlers) is the right place to coordinate persistence. + - ✅ It is acceptable to call $entityManager->flush() here. + - Check that flush is used atomically (once per unit of work) after all domain operations. + - Ensure no domain entity or domain service is calling flush; only the handler orchestrates it. + - Prefer $em->transactional(...) or explicit try/catch with rollback on failure. + + - path: "src/**/MessageHandler/**" + instructions: | + Background jobs/workers may perform persistence. + - ✅ Allow $entityManager->flush() here when the job is the orchestration boundary. + - Verify idempotency and that flush frequency is appropriate (batching where practical). + - Ensure no domain-layer code invoked by the job performs flush/transaction control. + auto_review: enabled: true base_branches: - ".*" drafts: false +# ignore_title_keywords: +# - '' + +#knowledge_base: +# code_guidelines: +# filePatterns: +# - ".github/AGENT.md" diff --git a/.github/AGENT.md b/.github/AGENT.md new file mode 100644 index 00000000..6fab2120 --- /dev/null +++ b/.github/AGENT.md @@ -0,0 +1,103 @@ +# AGENT.md — Code Review Knowledge Base for phpList/core + +## 🧭 Repository Overview + +This repository is the **core package** of **phpList 4**, a modular and extensible email campaign management system. + +- **Purpose:** Provides the reusable foundation and framework for phpList applications and modules. +- **Consumers:** `phpList/web-frontend`, `phpList/rest-api`, and `phpList/base-distribution`. +- **Core responsibilities:** + - Application bootstrapping and service configuration + - Database ORM (Doctrine) integration + - Command-line utilities (Symfony Console) + - Async email sending via Symfony Messenger + - Logging, configuration, routing, dependency injection + - Schema definition and updates + +> **Note:** This repository does *not* contain UI or REST endpoints. Those are part of other phpList packages. + +--- + +## ⚙️ Tech Stack + +| Category | Technology | +|-----------|-------------| +| Language | PHP ≥ 8.1 | +| Framework | Symfony 6.x components | +| ORM | Doctrine ORM 3.x | +| Async / Queue | Symfony Messenger | +| Tests | PHPUnit | +| Static analysis | PHPStan | +| Docs | PHPDocumentor | +| License | AGPL-3.0 | + +--- + +## 📁 Project Structure +- .github/ CI/CD and PR templates +- bin/ Symfony console entrypoints +- config/ Application & environment configs +- docs/ Developer documentation and generated API docs +- public/ Public entrypoint for local web server +- resources/Database/ Canonical SQL schema +- src/ Core PHP source code +- tests/ PHPUnit test suites +- composer.json Package metadata and dependencies +- phpunit.xml.dist Test configuration +- phpstan.neon Static analysis configuration + +--- + +## 💡 Code Design Principles + +1. **Modularity:** + The core remains framework-like — decoupled from frontend or API layers. + +2. **Dependency Injection:** + Use Symfony’s service container; avoid static/global dependencies. + +3. **Strict Typing:** + Always use `declare(strict_types=1);` and explicit type declarations. + +4. **Doctrine Entities:** + - Keep entities simple (no business logic). + - Mirror schema changes in `resources/Database/Schema.sql`. + - Maintain backward compatibility with phpList 3. + +5. **Symfony Best Practices:** + Follow Symfony structure and naming conventions. Use annotations or attributes for routing. + +6. **Error Handling & Logging:** + - Prefer structured logging via Graylog. + - Catch and handle exceptions at service or command boundaries. + +7. **Async Email:** + - Uses Symfony Messenger. + - Handlers must be idempotent and retry-safe. + - Avoid blocking or synchronous email sending. + +--- + +## 🧪 Testing Guidelines + +- **Framework:** PHPUnit +- **Database:** SQLite or mocks for unit tests; MySQL for integration tests. +- **Coverage target:** ≥85% for core logic. +- **Naming:** Mirror source structure (e.g., `Mailer.php` → `MailerTest.php`). + + +## 🧱 Code Style + +- Follow PSR-12 and Symfony coding conventions. +- Match the current codebase’s formatting and spacing. +- Use meaningful, consistent naming. +- Apply a single responsibility per class. + + +## 🔄 Pull Request Review Guidelines +### 🔐 Security Review Notes + +- Do not log sensitive data (passwords, tokens, SMTP credentials). +- Sanitize all user and external inputs. +- Always use parameterized Doctrine queries. +- Async jobs must be retry-safe and idempotent. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b4c9b3f7..a04f6ffa 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,10 +1,4 @@ -### Summary - -Provide a general description of the code changes in your pull request … -were there any bugs you had fixed? If so, mention them. If these bugs have open -GitHub issues, be sure to tag them here as well, to keep the conversation -linked together. - +"@coderabbitai summary" ### Unit test @@ -17,7 +11,7 @@ You can run the existing unit tests using this command: ### Code style -Have you checked that you code is well-documented and follows the PSR-2 coding +Have you checked that your code is well-documented and follows the PSR-2 coding style? You can check for this using this command: diff --git a/src/Domain/Identity/Command/ImportDefaultsCommand.php b/src/Domain/Identity/Command/ImportDefaultsCommand.php new file mode 100644 index 00000000..a1ff627e --- /dev/null +++ b/src/Domain/Identity/Command/ImportDefaultsCommand.php @@ -0,0 +1,100 @@ +allPrivilegesGranted(); + + $existing = $this->administratorRepository->findOneBy(['loginName' => $login]); + if ($existing === null) { + // If creating the default admin, require a password. Prefer env var, else prompt for input. + $password = $envPassword; + if ($password === null) { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new Question('Enter password for default admin (login "admin"): '); + $question->setHidden(true); + $question->setHiddenFallback(false); + $password = (string) $helper->ask($input, $output, $question); + if (trim($password) === '') { + $output->writeln('Password must not be empty.'); + return Command::FAILURE; + } + } + + $dto = new CreateAdministratorDto( + loginName: $login, + password: $password, + email: $email, + isSuperUser: true, + privileges: $allPrivileges, + ); + $admin = $this->administratorManager->createAdministrator($dto); + $this->entityManager->flush(); + + $output->writeln(sprintf( + 'Default admin created: login="%s", email="%s", superuser=yes, privileges=all', + $admin->getLoginName(), + $admin->getEmail() + )); + } else { + $output->writeln(sprintf( + 'Default admin already exists: login="%s", email="%s"', + $existing->getLoginName(), + $existing->getEmail(), + )); + } + + return Command::SUCCESS; + } + + /** + * @return array + * @SuppressWarnings(PHPMD.StaticAccess) + */ + private function allPrivilegesGranted(): array + { + $all = []; + foreach (PrivilegeFlag::cases() as $flag) { + $all[$flag->value] = true; + } + return $all; + } +} diff --git a/src/Migrations/Version20251103SeedInitialAdmin.php b/src/Migrations/Version20251103SeedInitialAdmin.php deleted file mode 100644 index c1d95889..00000000 --- a/src/Migrations/Version20251103SeedInitialAdmin.php +++ /dev/null @@ -1,49 +0,0 @@ -connection->getDatabasePlatform(); - $this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf( - 'Unsupported platform for this migration: %s', - get_class($platform) - )); - - $this->addSql(<<<'SQL' - INSERT INTO phplist_admin (id, created, modified, loginname, namelc, email, password, passwordchanged, disabled, superuser, privileges) - VALUES (1, NOW(), NOW(), 'admin', 'admin', 'admin@example.com', :hash, CURRENT_DATE, FALSE, TRUE, :privileges) - ON CONFLICT (id) DO UPDATE - SET - modified = EXCLUDED.modified, - privileges = EXCLUDED.privileges - SQL, [ - 'hash' => hash('sha256', 'password'), - 'privileges' => 'a:4:{s:11:"subscribers";b:1;s:9:"campaigns";b:1;s:10:"statistics";b:1;s:8:"settings";b:1;}', - ]); - } - - public function down(Schema $schema): void - { - $platform = $this->connection->getDatabasePlatform(); - $this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf( - 'Unsupported platform for this migration: %s', - get_class($platform) - )); - - $this->addSql('DELETE FROM phplist_admin WHERE id = 1'); - } -} From f2d12bc98516da3c3c6593ecd120157b166c9079 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 5 Nov 2025 11:49:24 +0400 Subject: [PATCH 20/20] Update pull request template --- .github/PULL_REQUEST_TEMPLATE.md | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a04f6ffa..aad3619f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,31 +1,3 @@ "@coderabbitai summary" -### Unit test - -Are your changes covered with unit tests, and do they not break anything? - -You can run the existing unit tests using this command: - - vendor/bin/phpunit tests/ - - -### Code style - -Have you checked that your code is well-documented and follows the PSR-2 coding -style? - -You can check for this using this command: - - vendor/bin/phpcs --standard=PSR2 src/ tests/ - - -### Other Information - -If there's anything else that's important and relevant to your pull -request, mention that information here. This could include benchmarks, -or other information. - -If you are updating any of the CHANGELOG files or are asked to update the -CHANGELOG files by reviewers, please add the CHANGELOG entry at the top of the file. - Thanks for contributing to phpList!