From ab6c5fd88ea1884ea719c6f80598f71cbdb429c4 Mon Sep 17 00:00:00 2001
From: TatevikGr
Date: Fri, 8 Aug 2025 22:23:15 +0400
Subject: [PATCH 01/10] Blacklist (#352)
* Blacklisted
* user repository methods
* fix configs
* add test
* fix: phpmd
* fix: repo configs
* return a created resource
---------
Co-authored-by: Tatevik
---
config/services/managers.yml | 4 +
config/services/repositories.yml | 10 +
.../UserBlacklistDataRepository.php | 11 -
.../Repository/UserBlacklistRepository.php | 11 -
.../Model/UserBlacklist.php | 12 +-
.../Model/UserBlacklistData.php | 4 +-
.../Repository/SubscriberRepository.php | 14 ++
.../UserBlacklistDataRepository.php | 16 ++
.../Repository/UserBlacklistRepository.php | 33 +++
.../Manager/SubscriberBlacklistManager.php | 86 ++++++++
.../SubscriberBlacklistManagerTest.php | 202 ++++++++++++++++++
11 files changed, 377 insertions(+), 26 deletions(-)
delete mode 100644 src/Domain/Identity/Repository/UserBlacklistDataRepository.php
delete mode 100644 src/Domain/Identity/Repository/UserBlacklistRepository.php
rename src/Domain/{Identity => Subscription}/Model/UserBlacklist.php (72%)
rename src/Domain/{Identity => Subscription}/Model/UserBlacklistData.php (90%)
create mode 100644 src/Domain/Subscription/Repository/UserBlacklistDataRepository.php
create mode 100644 src/Domain/Subscription/Repository/UserBlacklistRepository.php
create mode 100644 src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php
create mode 100644 tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php
diff --git a/config/services/managers.yml b/config/services/managers.yml
index 4f57fc11..0e1b1d8a 100644
--- a/config/services/managers.yml
+++ b/config/services/managers.yml
@@ -67,3 +67,7 @@ services:
PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager:
autowire: true
autoconfigure: true
+
+ PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager:
+ autowire: true
+ autoconfigure: true
diff --git a/config/services/repositories.yml b/config/services/repositories.yml
index eca3a31c..db3831dd 100644
--- a/config/services/repositories.yml
+++ b/config/services/repositories.yml
@@ -110,3 +110,13 @@ services:
parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
arguments:
- PhpList\Core\Domain\Messaging\Model\ListMessage
+
+ PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Subscription\Model\UserBlacklist
+
+ PhpList\Core\Domain\Subscription\Repository\UserBlacklistDataRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Subscription\Model\UserBlacklistData
diff --git a/src/Domain/Identity/Repository/UserBlacklistDataRepository.php b/src/Domain/Identity/Repository/UserBlacklistDataRepository.php
deleted file mode 100644
index 0f06722b..00000000
--- a/src/Domain/Identity/Repository/UserBlacklistDataRepository.php
+++ /dev/null
@@ -1,11 +0,0 @@
-email;
@@ -42,4 +45,9 @@ public function setAdded(?DateTime $added): self
$this->added = $added;
return $this;
}
+
+ public function getBlacklistData(): ?UserBlacklistData
+ {
+ return $this->blacklistData;
+ }
}
diff --git a/src/Domain/Identity/Model/UserBlacklistData.php b/src/Domain/Subscription/Model/UserBlacklistData.php
similarity index 90%
rename from src/Domain/Identity/Model/UserBlacklistData.php
rename to src/Domain/Subscription/Model/UserBlacklistData.php
index 09697616..f8d78c59 100644
--- a/src/Domain/Identity/Model/UserBlacklistData.php
+++ b/src/Domain/Subscription/Model/UserBlacklistData.php
@@ -2,11 +2,11 @@
declare(strict_types=1);
-namespace PhpList\Core\Domain\Identity\Model;
+namespace PhpList\Core\Domain\Subscription\Model;
use Doctrine\ORM\Mapping as ORM;
use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
-use PhpList\Core\Domain\Identity\Repository\UserBlacklistDataRepository;
+use PhpList\Core\Domain\Subscription\Repository\UserBlacklistDataRepository;
#[ORM\Entity(repositoryClass: UserBlacklistDataRepository::class)]
#[ORM\Table(name: 'phplist_user_blacklist_data')]
diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php
index 762096a0..6ebaee70 100644
--- a/src/Domain/Subscription/Repository/SubscriberRepository.php
+++ b/src/Domain/Subscription/Repository/SubscriberRepository.php
@@ -127,4 +127,18 @@ public function findSubscriberWithSubscriptions(int $id): ?Subscriber
->getQuery()
->getOneOrNullResult();
}
+
+ public function isEmailBlacklisted(string $email): bool
+ {
+ $queryBuilder = $this->getEntityManager()->createQueryBuilder();
+
+ $queryBuilder->select('u.email')
+ ->from(Subscriber::class, 'u')
+ ->where('u.email = :email')
+ ->andWhere('u.blacklisted = 1')
+ ->setParameter('email', $email)
+ ->setMaxResults(1);
+
+ return !($queryBuilder->getQuery()->getOneOrNullResult() === null);
+ }
}
diff --git a/src/Domain/Subscription/Repository/UserBlacklistDataRepository.php b/src/Domain/Subscription/Repository/UserBlacklistDataRepository.php
new file mode 100644
index 00000000..a64525b9
--- /dev/null
+++ b/src/Domain/Subscription/Repository/UserBlacklistDataRepository.php
@@ -0,0 +1,16 @@
+findOneBy(['email' => $email]);
+ }
+}
diff --git a/src/Domain/Subscription/Repository/UserBlacklistRepository.php b/src/Domain/Subscription/Repository/UserBlacklistRepository.php
new file mode 100644
index 00000000..665deb64
--- /dev/null
+++ b/src/Domain/Subscription/Repository/UserBlacklistRepository.php
@@ -0,0 +1,33 @@
+getEntityManager()->createQueryBuilder();
+
+ $queryBuilder->select('ub.email, ub.added, ubd.data AS reason')
+ ->from(UserBlacklist::class, 'ub')
+ ->innerJoin(UserBlacklistData::class, 'ubd', 'WITH', 'ub.email = ubd.email')
+ ->where('ub.email = :email')
+ ->setParameter('email', $email)
+ ->setMaxResults(1);
+
+ return $queryBuilder->getQuery()->getOneOrNullResult();
+ }
+
+ public function findOneByEmail(string $email): ?UserBlacklist
+ {
+ return $this->findOneBy([
+ 'email' => $email,
+ ]);
+ }
+}
diff --git a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php
new file mode 100644
index 00000000..d30bae2d
--- /dev/null
+++ b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php
@@ -0,0 +1,86 @@
+subscriberRepository->isEmailBlacklisted($email);
+ }
+
+ public function getBlacklistInfo(string $email): ?UserBlacklist
+ {
+ return $this->userBlacklistRepository->findBlacklistInfoByEmail($email);
+ }
+
+ public function addEmailToBlacklist(string $email, ?string $reasonData = null): UserBlacklist
+ {
+ $existing = $this->subscriberRepository->isEmailBlacklisted($email);
+ if ($existing) {
+ return $this->getBlacklistInfo($email);
+ }
+
+ $blacklistEntry = new UserBlacklist();
+ $blacklistEntry->setEmail($email);
+ $blacklistEntry->setAdded(new DateTime());
+
+ $this->entityManager->persist($blacklistEntry);
+
+ if ($reasonData !== null) {
+ $blacklistData = new UserBlacklistData();
+ $blacklistData->setEmail($email);
+ $blacklistData->setName('reason');
+ $blacklistData->setData($reasonData);
+ $this->entityManager->persist($blacklistData);
+ }
+
+ $this->entityManager->flush();
+
+ return $blacklistEntry;
+ }
+
+ public function removeEmailFromBlacklist(string $email): void
+ {
+ $blacklistEntry = $this->userBlacklistRepository->findOneByEmail($email);
+ if ($blacklistEntry) {
+ $this->entityManager->remove($blacklistEntry);
+ }
+
+ $blacklistData = $this->blacklistDataRepository->findOneByEmail($email);
+ if ($blacklistData) {
+ $this->entityManager->remove($blacklistData);
+ }
+
+ $subscriber = $this->subscriberRepository->findOneByEmail($email);
+ if ($subscriber) {
+ $subscriber->setBlacklisted(false);
+ }
+
+ $this->entityManager->flush();
+ }
+
+ public function getBlacklistReason(string $email): ?string
+ {
+ $data = $this->blacklistDataRepository->findOneByEmail($email);
+ return $data ? $data->getData() : null;
+ }
+}
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php
new file mode 100644
index 00000000..25fdf5ca
--- /dev/null
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php
@@ -0,0 +1,202 @@
+subscriberRepository = $this->createMock(SubscriberRepository::class);
+ $this->userBlacklistRepository = $this->createMock(UserBlacklistRepository::class);
+ $this->userBlacklistDataRepository = $this->createMock(UserBlacklistDataRepository::class);
+ $this->entityManager = $this->createMock(EntityManagerInterface::class);
+
+ $this->manager = new SubscriberBlacklistManager(
+ subscriberRepository: $this->subscriberRepository,
+ userBlacklistRepository: $this->userBlacklistRepository,
+ blacklistDataRepository: $this->userBlacklistDataRepository,
+ entityManager: $this->entityManager,
+ );
+ }
+
+ public function testIsEmailBlacklistedReturnsValueFromRepository(): void
+ {
+ $this->subscriberRepository
+ ->expects($this->once())
+ ->method('isEmailBlacklisted')
+ ->with('test@example.com')
+ ->willReturn(true);
+
+ $result = $this->manager->isEmailBlacklisted('test@example.com');
+
+ $this->assertTrue($result);
+ }
+
+ public function testGetBlacklistInfoReturnsResultFromRepository(): void
+ {
+ $userBlacklist = $this->createMock(UserBlacklist::class);
+
+ $this->userBlacklistRepository
+ ->expects($this->once())
+ ->method('findBlacklistInfoByEmail')
+ ->with('foo@bar.com')
+ ->willReturn($userBlacklist);
+
+ $result = $this->manager->getBlacklistInfo('foo@bar.com');
+
+ $this->assertSame($userBlacklist, $result);
+ }
+
+ public function testAddEmailToBlacklistDoesNotAddIfAlreadyBlacklisted(): void
+ {
+ $this->subscriberRepository
+ ->expects($this->once())
+ ->method('isEmailBlacklisted')
+ ->with('already@blacklisted.com')
+ ->willReturn(true);
+
+ $this->userBlacklistRepository
+ ->expects($this->once())
+ ->method('findBlacklistInfoByEmail')
+ ->willReturn($this->createMock(UserBlacklist::class));
+
+ $this->entityManager
+ ->expects($this->never())
+ ->method('persist');
+
+ $this->entityManager
+ ->expects($this->never())
+ ->method('flush');
+
+ $this->manager->addEmailToBlacklist('already@blacklisted.com', 'reason');
+ }
+
+ public function testAddEmailToBlacklistAddsEntryAndReason(): void
+ {
+ $this->subscriberRepository
+ ->expects($this->once())
+ ->method('isEmailBlacklisted')
+ ->with('new@blacklist.com')
+ ->willReturn(false);
+
+ $this->entityManager
+ ->expects($this->exactly(2))
+ ->method('persist')
+ ->withConsecutive(
+ [$this->isInstanceOf(UserBlacklist::class)],
+ [$this->isInstanceOf(UserBlacklistData::class)]
+ );
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('flush');
+
+ $this->manager->addEmailToBlacklist('new@blacklist.com', 'test reason');
+ }
+
+ public function testAddEmailToBlacklistAddsEntryWithoutReason(): void
+ {
+ $this->subscriberRepository
+ ->expects($this->once())
+ ->method('isEmailBlacklisted')
+ ->with('noreason@blacklist.com')
+ ->willReturn(false);
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('persist')
+ ->with($this->isInstanceOf(UserBlacklist::class));
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('flush');
+
+ $this->manager->addEmailToBlacklist('noreason@blacklist.com');
+ }
+
+ public function testRemoveEmailFromBlacklistRemovesAllRelatedData(): void
+ {
+ $blacklist = $this->createMock(UserBlacklist::class);
+ $blacklistData = $this->createMock(UserBlacklistData::class);
+ $subscriber = $this->getMockBuilder(Subscriber::class)
+ ->onlyMethods(['setBlacklisted'])
+ ->getMock();
+
+ $this->userBlacklistRepository
+ ->expects($this->once())
+ ->method('findOneByEmail')
+ ->with('remove@me.com')
+ ->willReturn($blacklist);
+
+ $this->userBlacklistDataRepository
+ ->expects($this->once())
+ ->method('findOneByEmail')
+ ->with('remove@me.com')
+ ->willReturn($blacklistData);
+
+ $this->subscriberRepository
+ ->expects($this->once())
+ ->method('findOneByEmail')
+ ->with('remove@me.com')
+ ->willReturn($subscriber);
+
+ $this->entityManager
+ ->expects($this->exactly(2))
+ ->method('remove')
+ ->withConsecutive([$blacklist], [$blacklistData]);
+
+ $subscriber->expects($this->once())->method('setBlacklisted')->with(false);
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('flush');
+
+ $this->manager->removeEmailFromBlacklist('remove@me.com');
+ }
+
+ public function testGetBlacklistReasonReturnsReasonOrNull(): void
+ {
+ $blacklistData = $this->createMock(UserBlacklistData::class);
+ $blacklistData->expects($this->once())->method('getData')->willReturn('my reason');
+
+ $this->userBlacklistDataRepository
+ ->expects($this->once())
+ ->method('findOneByEmail')
+ ->with('why@blacklist.com')
+ ->willReturn($blacklistData);
+
+ $result = $this->manager->getBlacklistReason('why@blacklist.com');
+ $this->assertSame('my reason', $result);
+ }
+
+ public function testGetBlacklistReasonReturnsNullIfNoData(): void
+ {
+ $this->userBlacklistDataRepository
+ ->expects($this->once())
+ ->method('findOneByEmail')
+ ->with('none@blacklist.com')
+ ->willReturn(null);
+
+ $result = $this->manager->getBlacklistReason('none@blacklist.com');
+ $this->assertNull($result);
+ }
+}
From f80edc8b42d233469ab31f33404b30a56eba646f Mon Sep 17 00:00:00 2001
From: TatevikGr
Date: Mon, 18 Aug 2025 09:10:01 +0400
Subject: [PATCH 02/10] Subscribepage (#353)
* subscriber page manager
* owner entity
* test
* ci fix
* getByPage data
---------
Co-authored-by: Tatevik
---
config/services/managers.yml | 4 +
config/services/repositories.yml | 10 +
.../Subscription/Model/SubscribePage.php | 10 +-
.../SubscriberPageDataRepository.php | 13 +
.../Repository/SubscriberPageRepository.php | 16 ++
.../Service/Manager/SubscribePageManager.php | 105 ++++++++
.../Manager/SubscribePageManagerTest.php | 234 ++++++++++++++++++
7 files changed, 388 insertions(+), 4 deletions(-)
create mode 100644 src/Domain/Subscription/Service/Manager/SubscribePageManager.php
create mode 100644 tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php
diff --git a/config/services/managers.yml b/config/services/managers.yml
index 0e1b1d8a..d253fc95 100644
--- a/config/services/managers.yml
+++ b/config/services/managers.yml
@@ -71,3 +71,7 @@ services:
PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager:
autowire: true
autoconfigure: true
+
+ PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager:
+ autowire: true
+ autoconfigure: true
diff --git a/config/services/repositories.yml b/config/services/repositories.yml
index db3831dd..02c9e7d3 100644
--- a/config/services/repositories.yml
+++ b/config/services/repositories.yml
@@ -120,3 +120,13 @@ services:
parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
arguments:
- PhpList\Core\Domain\Subscription\Model\UserBlacklistData
+
+ PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Subscription\Model\SubscribePage
+
+ PhpList\Core\Domain\Subscription\Repository\SubscriberPageDataRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Subscription\Model\SubscribePageData
diff --git a/src/Domain/Subscription/Model/SubscribePage.php b/src/Domain/Subscription/Model/SubscribePage.php
index 7ec518b2..e4696380 100644
--- a/src/Domain/Subscription/Model/SubscribePage.php
+++ b/src/Domain/Subscription/Model/SubscribePage.php
@@ -7,6 +7,7 @@
use Doctrine\ORM\Mapping as ORM;
use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
use PhpList\Core\Domain\Common\Model\Interfaces\Identity;
+use PhpList\Core\Domain\Identity\Model\Administrator;
use PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository;
#[ORM\Entity(repositoryClass: SubscriberPageRepository::class)]
@@ -24,8 +25,9 @@ class SubscribePage implements DomainModel, Identity
#[ORM\Column(name: 'active', type: 'boolean', options: ['default' => 0])]
private bool $active = false;
- #[ORM\Column(name: 'owner', type: 'integer', nullable: true)]
- private ?int $owner = null;
+ #[ORM\ManyToOne(targetEntity: Administrator::class)]
+ #[ORM\JoinColumn(name: 'owner', referencedColumnName: 'id', nullable: true)]
+ private ?Administrator $owner = null;
public function getId(): ?int
{
@@ -42,7 +44,7 @@ public function isActive(): bool
return $this->active;
}
- public function getOwner(): ?int
+ public function getOwner(): ?Administrator
{
return $this->owner;
}
@@ -59,7 +61,7 @@ public function setActive(bool $active): self
return $this;
}
- public function setOwner(?int $owner): self
+ public function setOwner(?Administrator $owner): self
{
$this->owner = $owner;
return $this;
diff --git a/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php b/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php
index 565930d4..68d0d6bc 100644
--- a/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php
+++ b/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php
@@ -7,8 +7,21 @@
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Subscription\Model\SubscribePage;
+use PhpList\Core\Domain\Subscription\Model\SubscribePageData;
class SubscriberPageDataRepository extends AbstractRepository implements PaginatableRepositoryInterface
{
use CursorPaginationTrait;
+
+ public function findByPageAndName(SubscribePage $page, string $name): ?SubscribePageData
+ {
+ return $this->findOneBy(['id' => $page->getId(), 'name' => $name]);
+ }
+
+ /** @return SubscribePageData[] */
+ public function getByPage(SubscribePage $page): array
+ {
+ return $this->findBy(['id' => $page->getId()]);
+ }
}
diff --git a/src/Domain/Subscription/Repository/SubscriberPageRepository.php b/src/Domain/Subscription/Repository/SubscriberPageRepository.php
index 2a8383c0..136b589c 100644
--- a/src/Domain/Subscription/Repository/SubscriberPageRepository.php
+++ b/src/Domain/Subscription/Repository/SubscriberPageRepository.php
@@ -7,8 +7,24 @@
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Subscription\Model\SubscribePage;
+use PhpList\Core\Domain\Subscription\Model\SubscribePageData;
class SubscriberPageRepository extends AbstractRepository implements PaginatableRepositoryInterface
{
use CursorPaginationTrait;
+
+ /** @return array{page: SubscribePage, data: SubscribePageData}[] */
+ public function findPagesWithData(int $pageId): array
+ {
+ return $this->createQueryBuilder('p')
+ ->select('p AS page, d AS data')
+ ->from(SubscribePage::class, 'p')
+ ->from(SubscribePageData::class, 'd')
+ ->where('p.id = :id')
+ ->andWhere('d.id = p.id')
+ ->setParameter('id', $pageId)
+ ->getQuery()
+ ->getResult();
+ }
}
diff --git a/src/Domain/Subscription/Service/Manager/SubscribePageManager.php b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php
new file mode 100644
index 00000000..8e429dc4
--- /dev/null
+++ b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php
@@ -0,0 +1,105 @@
+setTitle($title)
+ ->setActive($active)
+ ->setOwner($owner);
+
+ $this->pageRepository->save($page);
+
+ return $page;
+ }
+
+ public function getPage(int $id): SubscribePage
+ {
+ /** @var SubscribePage|null $page */
+ $page = $this->pageRepository->find($id);
+ if (!$page) {
+ throw new NotFoundHttpException('Subscribe page not found');
+ }
+
+ return $page;
+ }
+
+ public function updatePage(
+ SubscribePage $page,
+ ?string $title = null,
+ ?bool $active = null,
+ ?Administrator $owner = null
+ ): SubscribePage {
+ if ($title !== null) {
+ $page->setTitle($title);
+ }
+ if ($active !== null) {
+ $page->setActive($active);
+ }
+ if ($owner !== null) {
+ $page->setOwner($owner);
+ }
+
+ $this->entityManager->flush();
+
+ return $page;
+ }
+
+ public function setActive(SubscribePage $page, bool $active): void
+ {
+ $page->setActive($active);
+ $this->entityManager->flush();
+ }
+
+ public function deletePage(SubscribePage $page): void
+ {
+ $this->pageRepository->remove($page);
+ }
+
+ /** @return SubscribePageData[] */
+ public function getPageData(SubscribePage $page): array
+ {
+ return $this->pageDataRepository->getByPage($page,);
+ }
+
+ public function setPageData(SubscribePage $page, string $name, ?string $value): SubscribePageData
+ {
+ /** @var SubscribePageData|null $data */
+ $data = $this->pageDataRepository->findByPageAndName($page, $name);
+
+ if (!$data) {
+ $data = (new SubscribePageData())
+ ->setId((int)$page->getId())
+ ->setName($name);
+ $this->entityManager->persist($data);
+ }
+
+ $data->setData($value);
+ $this->entityManager->flush();
+
+ return $data;
+ }
+}
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php
new file mode 100644
index 00000000..422c78a7
--- /dev/null
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php
@@ -0,0 +1,234 @@
+pageRepository = $this->createMock(SubscriberPageRepository::class);
+ $this->pageDataRepository = $this->createMock(SubscriberPageDataRepository::class);
+ $this->entityManager = $this->createMock(EntityManagerInterface::class);
+
+ $this->manager = new SubscribePageManager(
+ pageRepository: $this->pageRepository,
+ pageDataRepository: $this->pageDataRepository,
+ entityManager: $this->entityManager,
+ );
+ }
+
+ public function testCreatePageCreatesAndSaves(): void
+ {
+ $owner = new Administrator();
+ $this->pageRepository
+ ->expects($this->once())
+ ->method('save')
+ ->with($this->isInstanceOf(SubscribePage::class));
+
+ $page = $this->manager->createPage('My Page', true, $owner);
+
+ $this->assertInstanceOf(SubscribePage::class, $page);
+ $this->assertSame('My Page', $page->getTitle());
+ $this->assertTrue($page->isActive());
+ $this->assertSame($owner, $page->getOwner());
+ }
+
+ public function testGetPageReturnsPage(): void
+ {
+ $page = new SubscribePage();
+ $this->pageRepository
+ ->expects($this->once())
+ ->method('find')
+ ->with(123)
+ ->willReturn($page);
+
+ $result = $this->manager->getPage(123);
+
+ $this->assertSame($page, $result);
+ }
+
+ public function testGetPageThrowsWhenNotFound(): void
+ {
+ $this->pageRepository
+ ->expects($this->once())
+ ->method('find')
+ ->with(999)
+ ->willReturn(null);
+
+ $this->expectException(NotFoundHttpException::class);
+ $this->expectExceptionMessage('Subscribe page not found');
+
+ $this->manager->getPage(999);
+ }
+
+ public function testUpdatePageUpdatesProvidedFieldsAndFlushes(): void
+ {
+ $originalOwner = new Administrator();
+ $newOwner = new Administrator();
+ $page = (new SubscribePage())
+ ->setTitle('Old Title')
+ ->setActive(false)
+ ->setOwner($originalOwner);
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('flush');
+
+ $updated = $this->manager->updatePage($page, title: 'New Title', active: true, owner: $newOwner);
+
+ $this->assertSame($page, $updated);
+ $this->assertSame('New Title', $updated->getTitle());
+ $this->assertTrue($updated->isActive());
+ $this->assertSame($newOwner, $updated->getOwner());
+ }
+
+ public function testUpdatePageLeavesNullFieldsUntouched(): void
+ {
+ $owner = new Administrator();
+ $page = (new SubscribePage())
+ ->setTitle('Keep Title')
+ ->setActive(true)
+ ->setOwner($owner);
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('flush');
+
+ $updated = $this->manager->updatePage(page: $page, title: null, active: null, owner: null);
+
+ $this->assertSame('Keep Title', $updated->getTitle());
+ $this->assertTrue($updated->isActive());
+ $this->assertSame($owner, $updated->getOwner());
+ }
+
+ public function testSetActiveSetsFlagAndFlushes(): void
+ {
+ $page = (new SubscribePage())
+ ->setTitle('Any')
+ ->setActive(false);
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('flush');
+
+ $this->manager->setActive($page, true);
+ $this->assertTrue($page->isActive());
+ }
+
+ public function testDeletePageCallsRepositoryRemove(): void
+ {
+ $page = new SubscribePage();
+
+ $this->pageRepository
+ ->expects($this->once())
+ ->method('remove')
+ ->with($page);
+
+ $this->manager->deletePage($page);
+ }
+
+ public function testGetPageDataReturnsStringWhenFound(): void
+ {
+ $page = new SubscribePage();
+ $data = $this->createMock(SubscribePageData::class);
+ $data->expects($this->once())->method('getData')->willReturn('value');
+
+ $this->pageDataRepository
+ ->expects($this->once())
+ ->method('getByPage')
+ ->with($page)
+ ->willReturn([$data]);
+
+ $result = $this->manager->getPageData($page);
+ $this->assertIsArray($result);
+ $this->assertSame('value', $result[0]->getData());
+ }
+
+ public function testGetPageDataReturnsNullWhenNotFound(): void
+ {
+ $page = new SubscribePage();
+
+ $this->pageDataRepository
+ ->expects($this->once())
+ ->method('getByPage')
+ ->with($page)
+ ->willReturn([]);
+
+ $result = $this->manager->getPageData($page);
+ $this->assertEmpty($result);
+ }
+
+ public function testSetPageDataUpdatesExistingDataAndFlushes(): void
+ {
+ $page = new SubscribePage();
+ $existing = new SubscribePageData();
+ $existing->setId(5)->setName('color')->setData('red');
+
+ $this->pageDataRepository
+ ->expects($this->once())
+ ->method('findByPageAndName')
+ ->with($page, 'color')
+ ->willReturn($existing);
+
+ $this->entityManager
+ ->expects($this->never())
+ ->method('persist');
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('flush');
+
+ $result = $this->manager->setPageData($page, 'color', 'blue');
+
+ $this->assertSame($existing, $result);
+ $this->assertSame('blue', $result->getData());
+ }
+
+ public function testSetPageDataCreatesNewWhenMissingAndPersistsAndFlushes(): void
+ {
+ $page = $this->getMockBuilder(SubscribePage::class)
+ ->onlyMethods(['getId'])
+ ->getMock();
+ $page->method('getId')->willReturn(123);
+
+ $this->pageDataRepository
+ ->expects($this->once())
+ ->method('findByPageAndName')
+ ->with($page, 'greeting')
+ ->willReturn(null);
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('persist')
+ ->with($this->isInstanceOf(SubscribePageData::class));
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('flush');
+
+ $result = $this->manager->setPageData($page, 'greeting', 'hello');
+
+ $this->assertInstanceOf(SubscribePageData::class, $result);
+ $this->assertSame(123, $result->getId());
+ $this->assertSame('greeting', $result->getName());
+ $this->assertSame('hello', $result->getData());
+ }
+}
From 9d0c1dba32ecc04ce07b885ea5817d04583913b2 Mon Sep 17 00:00:00 2001
From: TatevikGr
Date: Tue, 19 Aug 2025 10:03:12 +0400
Subject: [PATCH 03/10] Bounceregex manager (#354)
* BounceRegexManager
* Fix manager directory
* Prop name update admin -> adminId
---------
Co-authored-by: Tatevik
---
config/services/managers.yml | 40 ++---
config/services/repositories.yml | 5 +
src/Domain/Messaging/Model/BounceRegex.php | 16 +-
.../Repository/BounceRegexRepository.php | 6 +
.../Service/Manager/BounceRegexManager.php | 99 ++++++++++++
.../Service/{ => Manager}/MessageManager.php | 2 +-
.../{ => Manager}/TemplateImageManager.php | 2 +-
.../Service/{ => Manager}/TemplateManager.php | 2 +-
.../Manager/BounceRegexManagerTest.php | 144 ++++++++++++++++++
.../{ => Manager}/ListMessageManagerTest.php | 2 +-
.../{ => Manager}/MessageManagerTest.php | 4 +-
.../TemplateImageManagerTest.php | 4 +-
.../{ => Manager}/TemplateManagerTest.php | 6 +-
13 files changed, 295 insertions(+), 37 deletions(-)
create mode 100644 src/Domain/Messaging/Service/Manager/BounceRegexManager.php
rename src/Domain/Messaging/Service/{ => Manager}/MessageManager.php (96%)
rename src/Domain/Messaging/Service/{ => Manager}/TemplateImageManager.php (98%)
rename src/Domain/Messaging/Service/{ => Manager}/TemplateManager.php (98%)
create mode 100644 tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php
rename tests/Unit/Domain/Messaging/Service/{ => Manager}/ListMessageManagerTest.php (98%)
rename tests/Unit/Domain/Messaging/Service/{ => Manager}/MessageManagerTest.php (97%)
rename tests/Unit/Domain/Messaging/Service/{ => Manager}/TemplateImageManagerTest.php (95%)
rename tests/Unit/Domain/Messaging/Service/{ => Manager}/TemplateManagerTest.php (93%)
diff --git a/config/services/managers.yml b/config/services/managers.yml
index d253fc95..0f6bb119 100644
--- a/config/services/managers.yml
+++ b/config/services/managers.yml
@@ -4,74 +4,78 @@ services:
autoconfigure: true
public: false
- PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager:
+ PhpList\Core\Domain\Identity\Service\SessionManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Identity\Service\SessionManager:
+ PhpList\Core\Domain\Identity\Service\AdministratorManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager:
+ PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager:
+ PhpList\Core\Domain\Identity\Service\AdminAttributeManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Messaging\Service\MessageManager:
+ PhpList\Core\Domain\Identity\Service\PasswordManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Messaging\Service\TemplateManager:
+ PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Messaging\Service\TemplateImageManager:
+ PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Identity\Service\AdministratorManager:
+ PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager:
+ PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Identity\Service\AdminAttributeManager:
+ PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager:
+ PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager:
+ PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager:
+ PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager:
+ PhpList\Core\Domain\Messaging\Service\Manager\MessageManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Identity\Service\PasswordManager:
+ PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager:
+ PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager:
+ PhpList\Core\Domain\Messaging\Service\Manager\BounceRegexManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager:
+ PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager:
autowire: true
autoconfigure: true
diff --git a/config/services/repositories.yml b/config/services/repositories.yml
index 02c9e7d3..69bdb6ce 100644
--- a/config/services/repositories.yml
+++ b/config/services/repositories.yml
@@ -130,3 +130,8 @@ services:
parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
arguments:
- PhpList\Core\Domain\Subscription\Model\SubscribePageData
+
+ PhpList\Core\Domain\Messaging\Repository\BounceRegexRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Messaging\Model\BounceRegex
diff --git a/src/Domain/Messaging/Model/BounceRegex.php b/src/Domain/Messaging/Model/BounceRegex.php
index 510aaad8..0401d26b 100644
--- a/src/Domain/Messaging/Model/BounceRegex.php
+++ b/src/Domain/Messaging/Model/BounceRegex.php
@@ -31,8 +31,8 @@ class BounceRegex implements DomainModel, Identity
#[ORM\Column(name: 'listorder', type: 'integer', nullable: true, options: ['default' => 0])]
private ?int $listOrder = 0;
- #[ORM\Column(type: 'integer', nullable: true)]
- private ?int $admin;
+ #[ORM\Column(name: 'admin', type: 'integer', nullable: true)]
+ private ?int $adminId;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $comment;
@@ -48,7 +48,7 @@ public function __construct(
?string $regexHash = null,
?string $action = null,
?int $listOrder = 0,
- ?int $admin = null,
+ ?int $adminId = null,
?string $comment = null,
?string $status = null,
?int $count = 0
@@ -57,7 +57,7 @@ public function __construct(
$this->regexHash = $regexHash;
$this->action = $action;
$this->listOrder = $listOrder;
- $this->admin = $admin;
+ $this->adminId = $adminId;
$this->comment = $comment;
$this->status = $status;
$this->count = $count;
@@ -112,14 +112,14 @@ public function setListOrder(?int $listOrder): self
return $this;
}
- public function getAdmin(): ?int
+ public function getAdminId(): ?int
{
- return $this->admin;
+ return $this->adminId;
}
- public function setAdmin(?int $admin): self
+ public function setAdminId(?int $adminId): self
{
- $this->admin = $admin;
+ $this->adminId = $adminId;
return $this;
}
diff --git a/src/Domain/Messaging/Repository/BounceRegexRepository.php b/src/Domain/Messaging/Repository/BounceRegexRepository.php
index a08f65c0..f5088376 100644
--- a/src/Domain/Messaging/Repository/BounceRegexRepository.php
+++ b/src/Domain/Messaging/Repository/BounceRegexRepository.php
@@ -7,8 +7,14 @@
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Messaging\Model\BounceRegex;
class BounceRegexRepository extends AbstractRepository implements PaginatableRepositoryInterface
{
use CursorPaginationTrait;
+
+ public function findOneByRegexHash(string $regexHash): ?BounceRegex
+ {
+ return $this->findOneBy(['regexHash' => $regexHash]);
+ }
}
diff --git a/src/Domain/Messaging/Service/Manager/BounceRegexManager.php b/src/Domain/Messaging/Service/Manager/BounceRegexManager.php
new file mode 100644
index 00000000..c9d60580
--- /dev/null
+++ b/src/Domain/Messaging/Service/Manager/BounceRegexManager.php
@@ -0,0 +1,99 @@
+bounceRegexRepository = $bounceRegexRepository;
+ $this->entityManager = $entityManager;
+ }
+
+ /**
+ * Creates or updates (if exists) a BounceRegex from a raw regex pattern.
+ */
+ public function createOrUpdateFromPattern(
+ string $regex,
+ ?string $action = null,
+ ?int $listOrder = 0,
+ ?int $adminId = null,
+ ?string $comment = null,
+ ?string $status = null
+ ): BounceRegex {
+ $regexHash = md5($regex);
+
+ $existing = $this->bounceRegexRepository->findOneByRegexHash($regexHash);
+
+ if ($existing !== null) {
+ $existing->setRegex($regex)
+ ->setAction($action ?? $existing->getAction())
+ ->setListOrder($listOrder ?? $existing->getListOrder())
+ ->setAdminId($adminId ?? $existing->getAdminId())
+ ->setComment($comment ?? $existing->getComment())
+ ->setStatus($status ?? $existing->getStatus());
+
+ $this->bounceRegexRepository->save($existing);
+
+ return $existing;
+ }
+
+ $bounceRegex = new BounceRegex(
+ regex: $regex,
+ regexHash: $regexHash,
+ action: $action,
+ listOrder: $listOrder,
+ adminId: $adminId,
+ comment: $comment,
+ status: $status,
+ count: 0
+ );
+
+ $this->bounceRegexRepository->save($bounceRegex);
+
+ return $bounceRegex;
+ }
+
+ /** @return BounceRegex[] */
+ public function getAll(): array
+ {
+ return $this->bounceRegexRepository->findAll();
+ }
+
+ public function getByHash(string $regexHash): ?BounceRegex
+ {
+ return $this->bounceRegexRepository->findOneByRegexHash($regexHash);
+ }
+
+ public function delete(BounceRegex $bounceRegex): void
+ {
+ $this->bounceRegexRepository->remove($bounceRegex);
+ }
+
+ /**
+ * Associates a bounce with the regex it matched and increments usage count.
+ */
+ public function associateBounce(BounceRegex $regex, Bounce $bounce): BounceRegexBounce
+ {
+ $relation = new BounceRegexBounce($regex->getId() ?? 0, $bounce->getId() ?? 0);
+ $this->entityManager->persist($relation);
+
+ $regex->setCount(($regex->getCount() ?? 0) + 1);
+ $this->entityManager->flush();
+
+ return $relation;
+ }
+}
diff --git a/src/Domain/Messaging/Service/MessageManager.php b/src/Domain/Messaging/Service/Manager/MessageManager.php
similarity index 96%
rename from src/Domain/Messaging/Service/MessageManager.php
rename to src/Domain/Messaging/Service/Manager/MessageManager.php
index 9af4df0b..7b263083 100644
--- a/src/Domain/Messaging/Service/MessageManager.php
+++ b/src/Domain/Messaging/Service/Manager/MessageManager.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace PhpList\Core\Domain\Messaging\Service;
+namespace PhpList\Core\Domain\Messaging\Service\Manager;
use PhpList\Core\Domain\Identity\Model\Administrator;
use PhpList\Core\Domain\Messaging\Model\Dto\MessageContext;
diff --git a/src/Domain/Messaging/Service/TemplateImageManager.php b/src/Domain/Messaging/Service/Manager/TemplateImageManager.php
similarity index 98%
rename from src/Domain/Messaging/Service/TemplateImageManager.php
rename to src/Domain/Messaging/Service/Manager/TemplateImageManager.php
index c5ebd3f4..30705715 100644
--- a/src/Domain/Messaging/Service/TemplateImageManager.php
+++ b/src/Domain/Messaging/Service/Manager/TemplateImageManager.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace PhpList\Core\Domain\Messaging\Service;
+namespace PhpList\Core\Domain\Messaging\Service\Manager;
use Doctrine\ORM\EntityManagerInterface;
use DOMDocument;
diff --git a/src/Domain/Messaging/Service/TemplateManager.php b/src/Domain/Messaging/Service/Manager/TemplateManager.php
similarity index 98%
rename from src/Domain/Messaging/Service/TemplateManager.php
rename to src/Domain/Messaging/Service/Manager/TemplateManager.php
index 35678484..7de31843 100644
--- a/src/Domain/Messaging/Service/TemplateManager.php
+++ b/src/Domain/Messaging/Service/Manager/TemplateManager.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace PhpList\Core\Domain\Messaging\Service;
+namespace PhpList\Core\Domain\Messaging\Service\Manager;
use Doctrine\ORM\EntityManagerInterface;
use PhpList\Core\Domain\Common\Model\ValidationContext;
diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php
new file mode 100644
index 00000000..1cd432bc
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php
@@ -0,0 +1,144 @@
+regexRepository = $this->createMock(BounceRegexRepository::class);
+ $this->entityManager = $this->createMock(EntityManagerInterface::class);
+
+ $this->manager = new BounceRegexManager(
+ bounceRegexRepository: $this->regexRepository,
+ entityManager: $this->entityManager
+ );
+ }
+
+ public function testCreateNewRegex(): void
+ {
+ $pattern = 'user unknown';
+ $expectedHash = md5($pattern);
+
+ $this->regexRepository->expects($this->once())
+ ->method('findOneByRegexHash')
+ ->with($expectedHash)
+ ->willReturn(null);
+
+ $this->regexRepository->expects($this->once())
+ ->method('save')
+ ->with($this->isInstanceOf(BounceRegex::class));
+
+ $regex = $this->manager->createOrUpdateFromPattern(
+ regex: $pattern,
+ action: 'delete',
+ listOrder: 5,
+ adminId: 1,
+ comment: 'test',
+ status: 'active'
+ );
+
+ $this->assertInstanceOf(BounceRegex::class, $regex);
+ $this->assertSame($pattern, $regex->getRegex());
+ $this->assertSame($expectedHash, $regex->getRegexHash());
+ $this->assertSame('delete', $regex->getAction());
+ $this->assertSame(5, $regex->getListOrder());
+ $this->assertSame(1, $regex->getAdminId());
+ $this->assertSame('test', $regex->getComment());
+ $this->assertSame('active', $regex->getStatus());
+ }
+
+ public function testUpdateExistingRegex(): void
+ {
+ $pattern = 'mailbox full';
+ $hash = md5($pattern);
+
+ $existing = new BounceRegex(
+ regex: $pattern,
+ regexHash: $hash,
+ action: 'keep',
+ listOrder: 0,
+ adminId: null,
+ comment: null,
+ status: 'inactive',
+ count: 3
+ );
+
+ $this->regexRepository->expects($this->once())
+ ->method('findOneByRegexHash')
+ ->with($hash)
+ ->willReturn($existing);
+
+ $this->regexRepository->expects($this->once())
+ ->method('save')
+ ->with($existing);
+
+ $updated = $this->manager->createOrUpdateFromPattern(
+ regex: $pattern,
+ action: 'delete',
+ listOrder: 10,
+ adminId: 2,
+ comment: 'upd',
+ status: 'active'
+ );
+
+ $this->assertSame('delete', $updated->getAction());
+ $this->assertSame(10, $updated->getListOrder());
+ $this->assertSame(2, $updated->getAdminId());
+ $this->assertSame('upd', $updated->getComment());
+ $this->assertSame('active', $updated->getStatus());
+ $this->assertSame($hash, $updated->getRegexHash());
+ }
+
+ public function testDeleteRegex(): void
+ {
+ $model = $this->createMock(BounceRegex::class);
+
+ $this->regexRepository->expects($this->once())
+ ->method('remove')
+ ->with($model);
+
+ $this->manager->delete($model);
+ }
+
+ public function testAssociateBounceIncrementsCountAndPersistsRelation(): void
+ {
+ $regex = new BounceRegex(regex: 'x', regexHash: md5('x'));
+
+ $refRegex = new ReflectionProperty(BounceRegex::class, 'id');
+ $refRegex->setValue($regex, 7);
+
+ $bounce = $this->createMock(Bounce::class);
+ $bounce->method('getId')->willReturn(11);
+
+ $this->entityManager->expects($this->once())
+ ->method('persist')
+ ->with($this->callback(function ($entity) use ($regex) {
+ return $entity instanceof BounceRegexBounce
+ && $entity->getRegex() === $regex->getId();
+ }));
+
+ $this->entityManager->expects($this->once())
+ ->method('flush');
+
+ $this->assertSame(0, $regex->getCount());
+ $this->manager->associateBounce($regex, $bounce);
+ $this->assertSame(1, $regex->getCount());
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/ListMessageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.php
similarity index 98%
rename from tests/Unit/Domain/Messaging/Service/ListMessageManagerTest.php
rename to tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.php
index 2ec4180f..2f1af5fe 100644
--- a/tests/Unit/Domain/Messaging/Service/ListMessageManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
diff --git a/tests/Unit/Domain/Messaging/Service/MessageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php
similarity index 97%
rename from tests/Unit/Domain/Messaging/Service/MessageManagerTest.php
rename to tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php
index 8ee85915..aa1a47e0 100644
--- a/tests/Unit/Domain/Messaging/Service/MessageManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager;
use PhpList\Core\Domain\Identity\Model\Administrator;
use PhpList\Core\Domain\Messaging\Model\Dto\CreateMessageDto;
@@ -15,7 +15,7 @@
use PhpList\Core\Domain\Messaging\Model\Message;
use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
use PhpList\Core\Domain\Messaging\Service\Builder\MessageBuilder;
-use PhpList\Core\Domain\Messaging\Service\MessageManager;
+use PhpList\Core\Domain\Messaging\Service\Manager\MessageManager;
use PHPUnit\Framework\TestCase;
class MessageManagerTest extends TestCase
diff --git a/tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php
similarity index 95%
rename from tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php
rename to tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php
index bde3569a..7eb6afe7 100644
--- a/tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php
@@ -2,13 +2,13 @@
declare(strict_types=1);
-namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager;
use Doctrine\ORM\EntityManagerInterface;
use PhpList\Core\Domain\Messaging\Model\Template;
use PhpList\Core\Domain\Messaging\Model\TemplateImage;
use PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository;
-use PhpList\Core\Domain\Messaging\Service\TemplateImageManager;
+use PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
diff --git a/tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php
similarity index 93%
rename from tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php
rename to tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php
index fbbb4831..d3748244 100644
--- a/tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php
@@ -2,14 +2,14 @@
declare(strict_types=1);
-namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager;
use Doctrine\ORM\EntityManagerInterface;
use PhpList\Core\Domain\Messaging\Model\Dto\CreateTemplateDto;
use PhpList\Core\Domain\Messaging\Model\Template;
use PhpList\Core\Domain\Messaging\Repository\TemplateRepository;
-use PhpList\Core\Domain\Messaging\Service\TemplateImageManager;
-use PhpList\Core\Domain\Messaging\Service\TemplateManager;
+use PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager;
+use PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager;
use PhpList\Core\Domain\Messaging\Validator\TemplateImageValidator;
use PhpList\Core\Domain\Messaging\Validator\TemplateLinkValidator;
use PHPUnit\Framework\MockObject\MockObject;
From dc99df1bf9e445b7e3182384e831972a042c82d4 Mon Sep 17 00:00:00 2001
From: TatevikGr
Date: Tue, 2 Sep 2025 11:21:21 +0400
Subject: [PATCH 04/10] Bounce processing command (#355)
* BounceManager
* Add bounce email
* Move to the processor dir
* SendProcess lock service
* ClientIp + SystemInfo
* ProcessBouncesCommand
* ProcessBouncesCommand all methods
* BounceProcessingService
* AdvancedBounceRulesProcessor
* UnidentifiedBounceReprocessor
* ConsecutiveBounceHandler
* Refactor
* BounceDataProcessor
* ClientFactory + refactor
* BounceProcessorPass
* Register services + phpstan fix
* PhpMd
* PhpMd CyclomaticComplexity
* PhpCodeSniffer
* Tests
* Refactor
* Add tests
* More tests
* Fix tests
---------
Co-authored-by: Tatevik
---
composer.json | 4 +-
config/PHPMD/rules.xml | 2 +-
config/PhpCodeSniffer/ruleset.xml | 9 -
config/parameters.yml.dist | 26 ++
config/services.yml | 96 +++---
config/services/builders.yml | 2 +-
config/services/commands.yml | 4 +
config/services/managers.yml | 12 +
config/services/processor.yml | 21 ++
config/services/providers.yml | 4 +
config/services/repositories.yml | 287 +++++++++---------
config/services/services.yml | 143 ++++++---
src/Core/ApplicationKernel.php | 1 +
src/Core/BounceProcessorPass.php | 28 ++
src/Domain/Common/ClientIpResolver.php | 28 ++
.../Common/Mail/NativeImapMailReader.php | 65 ++++
src/Domain/Common/SystemInfoCollector.php | 77 +++++
.../Command/ProcessBouncesCommand.php | 114 +++++++
.../Messaging/Command/ProcessQueueCommand.php | 8 +-
.../Messaging/Model/BounceRegexBounce.php | 30 +-
.../Messaging/Model/UserMessageBounce.php | 16 +-
.../Repository/BounceRegexRepository.php | 12 +
.../Messaging/Repository/BounceRepository.php | 7 +
.../Repository/MessageRepository.php | 11 +
.../Repository/SendProcessRepository.php | 67 ++++
.../UserMessageBounceRepository.php | 69 +++++
.../Service/BounceActionResolver.php | 65 ++++
.../BounceProcessingServiceInterface.php | 10 +
.../Service/ConsecutiveBounceHandler.php | 141 +++++++++
src/Domain/Messaging/Service/EmailService.php | 17 +-
.../BlacklistEmailAndDeleteBounceHandler.php | 47 +++
.../Service/Handler/BlacklistEmailHandler.php | 42 +++
.../BlacklistUserAndDeleteBounceHandler.php | 47 +++
.../Service/Handler/BlacklistUserHandler.php | 42 +++
.../Handler/BounceActionHandlerInterface.php | 11 +
...CountConfirmUserAndDeleteBounceHandler.php | 51 ++++
.../Service/Handler/DeleteBounceHandler.php | 27 ++
.../Handler/DeleteUserAndBounceHandler.php | 33 ++
.../Service/Handler/DeleteUserHandler.php | 36 +++
.../UnconfirmUserAndDeleteBounceHandler.php | 44 +++
.../Service/Handler/UnconfirmUserHandler.php | 39 +++
src/Domain/Messaging/Service/LockService.php | 172 +++++++++++
.../Service/Manager/BounceManager.php | 138 +++++++++
.../Service/Manager/BounceRuleManager.php | 110 +++++++
.../Service/Manager/SendProcessManager.php | 57 ++++
.../Messaging/Service/MessageParser.php | 102 +++++++
.../Service/NativeBounceProcessingService.php | 138 +++++++++
.../AdvancedBounceRulesProcessor.php | 120 ++++++++
.../Service/Processor/BounceDataProcessor.php | 155 ++++++++++
.../Processor/BounceProtocolProcessor.php | 24 ++
.../{ => Processor}/CampaignProcessor.php | 3 +-
.../Service/Processor/MboxBounceProcessor.php | 46 +++
.../Service/Processor/PopBounceProcessor.php | 59 ++++
.../UnidentifiedBounceReprocessor.php | 70 +++++
.../WebklexBounceProcessingService.php | 268 ++++++++++++++++
.../Service/WebklexImapClientFactory.php | 79 +++++
.../Repository/SubscriberRepository.php | 47 +++
.../Manager/SubscriberBlacklistManager.php | 10 +
.../Manager/SubscriberHistoryManager.php | 28 +-
.../Service/Manager/SubscriberManager.php | 19 +-
.../Service/SubscriberBlacklistService.php | 69 +++++
.../Service/SubscriberDeletionServiceTest.php | 3 +-
.../Domain/Common/ClientIpResolverTest.php | 61 ++++
.../Domain/Common/SystemInfoCollectorTest.php | 95 ++++++
.../Command/ProcessBouncesCommandTest.php | 197 ++++++++++++
.../Command/ProcessQueueCommandTest.php | 2 +-
.../Service/BounceActionResolverTest.php | 66 ++++
.../Service/ConsecutiveBounceHandlerTest.php | 212 +++++++++++++
.../Messaging/Service/EmailServiceTest.php | 8 +-
...acklistEmailAndDeleteBounceHandlerTest.php | 78 +++++
.../Handler/BlacklistEmailHandlerTest.php | 73 +++++
...lacklistUserAndDeleteBounceHandlerTest.php | 90 ++++++
.../Handler/BlacklistUserHandlerTest.php | 84 +++++
...tConfirmUserAndDeleteBounceHandlerTest.php | 103 +++++++
.../Handler/DeleteBounceHandlerTest.php | 40 +++
.../DeleteUserAndBounceHandlerTest.php | 63 ++++
.../Service/Handler/DeleteUserHandlerTest.php | 71 +++++
...nconfirmUserAndDeleteBounceHandlerTest.php | 90 ++++++
.../Handler/UnconfirmUserHandlerTest.php | 77 +++++
.../Messaging/Service/LockServiceTest.php | 88 ++++++
.../Service/Manager/BounceManagerTest.php | 205 +++++++++++++
.../Manager/BounceRegexManagerTest.php | 2 +-
.../Service/Manager/BounceRuleManagerTest.php | 143 +++++++++
.../Manager/SendProcessManagerTest.php | 86 ++++++
.../Manager/TemplateImageManagerTest.php | 4 +-
.../Messaging/Service/MessageParserTest.php | 76 +++++
.../AdvancedBounceRulesProcessorTest.php | 177 +++++++++++
.../Processor/BounceDataProcessorTest.php | 168 ++++++++++
.../{ => Processor}/CampaignProcessorTest.php | 4 +-
.../Processor/MboxBounceProcessorTest.php | 76 +++++
.../Processor/PopBounceProcessorTest.php | 64 ++++
.../UnidentifiedBounceReprocessorTest.php | 75 +++++
.../Service/WebklexImapClientFactoryTest.php | 70 +++++
.../Manager/SubscriberHistoryManagerTest.php | 6 +-
.../Service/Manager/SubscriberManagerTest.php | 2 +-
95 files changed, 5884 insertions(+), 284 deletions(-)
create mode 100644 config/services/processor.yml
create mode 100644 src/Core/BounceProcessorPass.php
create mode 100644 src/Domain/Common/ClientIpResolver.php
create mode 100644 src/Domain/Common/Mail/NativeImapMailReader.php
create mode 100644 src/Domain/Common/SystemInfoCollector.php
create mode 100644 src/Domain/Messaging/Command/ProcessBouncesCommand.php
create mode 100644 src/Domain/Messaging/Service/BounceActionResolver.php
create mode 100644 src/Domain/Messaging/Service/BounceProcessingServiceInterface.php
create mode 100644 src/Domain/Messaging/Service/ConsecutiveBounceHandler.php
create mode 100644 src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php
create mode 100644 src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php
create mode 100644 src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php
create mode 100644 src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php
create mode 100644 src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php
create mode 100644 src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php
create mode 100644 src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php
create mode 100644 src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php
create mode 100644 src/Domain/Messaging/Service/Handler/DeleteUserHandler.php
create mode 100644 src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php
create mode 100644 src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php
create mode 100644 src/Domain/Messaging/Service/LockService.php
create mode 100644 src/Domain/Messaging/Service/Manager/BounceManager.php
create mode 100644 src/Domain/Messaging/Service/Manager/BounceRuleManager.php
create mode 100644 src/Domain/Messaging/Service/Manager/SendProcessManager.php
create mode 100644 src/Domain/Messaging/Service/MessageParser.php
create mode 100644 src/Domain/Messaging/Service/NativeBounceProcessingService.php
create mode 100644 src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php
create mode 100644 src/Domain/Messaging/Service/Processor/BounceDataProcessor.php
create mode 100644 src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php
rename src/Domain/Messaging/Service/{ => Processor}/CampaignProcessor.php (95%)
create mode 100644 src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php
create mode 100644 src/Domain/Messaging/Service/Processor/PopBounceProcessor.php
create mode 100644 src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php
create mode 100644 src/Domain/Messaging/Service/WebklexBounceProcessingService.php
create mode 100644 src/Domain/Messaging/Service/WebklexImapClientFactory.php
create mode 100644 src/Domain/Subscription/Service/SubscriberBlacklistService.php
create mode 100644 tests/Unit/Domain/Common/ClientIpResolverTest.php
create mode 100644 tests/Unit/Domain/Common/SystemInfoCollectorTest.php
create mode 100644 tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/LockServiceTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/MessageParserTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php
rename tests/Unit/Domain/Messaging/Service/{ => Processor}/CampaignProcessorTest.php (98%)
create mode 100644 tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php
diff --git a/composer.json b/composer.json
index be974681..2b391014 100644
--- a/composer.json
+++ b/composer.json
@@ -68,7 +68,9 @@
"symfony/sendgrid-mailer": "^6.4",
"symfony/twig-bundle": "^6.4",
"symfony/messenger": "^6.4",
- "symfony/lock": "^6.4"
+ "symfony/lock": "^6.4",
+ "webklex/php-imap": "^6.2",
+ "ext-imap": "*"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
diff --git a/config/PHPMD/rules.xml b/config/PHPMD/rules.xml
index 2d88410b..a0fbf650 100644
--- a/config/PHPMD/rules.xml
+++ b/config/PHPMD/rules.xml
@@ -51,7 +51,7 @@
-
+
diff --git a/config/PhpCodeSniffer/ruleset.xml b/config/PhpCodeSniffer/ruleset.xml
index d0258304..fdba2edf 100644
--- a/config/PhpCodeSniffer/ruleset.xml
+++ b/config/PhpCodeSniffer/ruleset.xml
@@ -15,7 +15,6 @@
-
@@ -41,9 +40,6 @@
-
-
-
@@ -54,7 +50,6 @@
-
@@ -66,9 +61,6 @@
-
-
-
@@ -110,6 +102,5 @@
-
diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist
index 621a8b81..54c649d8 100644
--- a/config/parameters.yml.dist
+++ b/config/parameters.yml.dist
@@ -32,6 +32,32 @@ parameters:
app.password_reset_url: '%%env(PASSWORD_RESET_URL)%%'
env(PASSWORD_RESET_URL): 'https://example.com/reset/'
+ # bounce email settings
+ imap_bounce.email: '%%env(BOUNCE_EMAIL)%%'
+ env(BOUNCE_EMAIL): 'bounce@phplist.com'
+ imap_bounce.password: '%%env(BOUNCE_IMAP_PASS)%%'
+ env(BOUNCE_IMAP_PASS): 'bounce@phplist.com'
+ imap_bounce.host: '%%env(BOUNCE_IMAP_HOST)%%'
+ env(BOUNCE_IMAP_HOST): 'imap.phplist.com'
+ imap_bounce.port: '%%env(BOUNCE_IMAP_PORT)%%'
+ env(BOUNCE_IMAP_PORT): '993'
+ imap_bounce.encryption: '%%env(BOUNCE_IMAP_ENCRYPTION)%%'
+ env(BOUNCE_IMAP_ENCRYPTION): 'ssl'
+ imap_bounce.mailbox: '%%env(BOUNCE_IMAP_MAILBOX)%%'
+ env(BOUNCE_IMAP_MAILBOX): '/var/spool/mail/bounces'
+ imap_bounce.mailbox_name: '%%env(BOUNCE_IMAP_MAILBOX_NAME)%%'
+ env(BOUNCE_IMAP_MAILBOX_NAME): 'INBOX,ONE_MORE'
+ imap_bounce.protocol: '%%env(BOUNCE_IMAP_PROTOCOL)%%'
+ env(BOUNCE_IMAP_PROTOCOL): 'imap'
+ imap_bounce.unsubscribe_threshold: '%%env(BOUNCE_IMAP_UNSUBSCRIBE_THRESHOLD)%%'
+ env(BOUNCE_IMAP_UNSUBSCRIBE_THRESHOLD): '5'
+ imap_bounce.blacklist_threshold: '%%env(BOUNCE_IMAP_BLACKLIST_THRESHOLD)%%'
+ env(BOUNCE_IMAP_BLACKLIST_THRESHOLD): '3'
+ imap_bounce.purge: '%%env(BOUNCE_IMAP_PURGE)%%'
+ env(BOUNCE_IMAP_PURGE): '0'
+ imap_bounce.purge_unprocessed: '%%env(BOUNCE_IMAP_PURGE_UNPROCESSED)%%'
+ env(BOUNCE_IMAP_PURGE_UNPROCESSED): '0'
+
# Messenger configuration for asynchronous processing
app.messenger_transport_dsn: '%%env(MESSENGER_TRANSPORT_DSN)%%'
env(MESSENGER_TRANSPORT_DSN): 'doctrine://default?auto_setup=true'
diff --git a/config/services.yml b/config/services.yml
index b83adce3..47be8241 100644
--- a/config/services.yml
+++ b/config/services.yml
@@ -1,51 +1,51 @@
imports:
- - { resource: 'services/*.yml' }
+ - { resource: 'services/*.yml' }
services:
- _defaults:
- autowire: true
- autoconfigure: true
- public: false
-
- PhpList\Core\Core\ConfigProvider:
- arguments:
- $config: '%app.config%'
-
- PhpList\Core\Core\ApplicationStructure:
- public: true
-
- PhpList\Core\Security\Authentication:
- public: true
-
- PhpList\Core\Security\HashGenerator:
- public: true
-
- PhpList\Core\Routing\ExtraLoader:
- tags: [routing.loader]
-
- PhpList\Core\Domain\Common\Repository\AbstractRepository:
- abstract: true
- autowire: true
- autoconfigure: false
- public: true
- factory: ['@doctrine.orm.entity_manager', getRepository]
-
- # controllers are imported separately to make sure they're public
- # and have a tag that allows actions to type-hint services
- PhpList\Core\EmptyStartPageBundle\Controller\:
- resource: '../src/EmptyStartPageBundle/Controller'
- public: true
- tags: [controller.service_arguments]
-
- doctrine.orm.metadata.annotation_reader:
- alias: doctrine.annotation_reader
-
- doctrine.annotation_reader:
- class: Doctrine\Common\Annotations\AnnotationReader
- autowire: true
-
- doctrine.orm.default_annotation_metadata_driver:
- class: Doctrine\ORM\Mapping\Driver\AnnotationDriver
- arguments:
- - '@annotation_reader'
- - '%kernel.project_dir%/src/Domain/Model/'
+ _defaults:
+ autowire: true
+ autoconfigure: true
+ public: false
+
+ PhpList\Core\Core\ConfigProvider:
+ arguments:
+ $config: '%app.config%'
+
+ PhpList\Core\Core\ApplicationStructure:
+ public: true
+
+ PhpList\Core\Security\Authentication:
+ public: true
+
+ PhpList\Core\Security\HashGenerator:
+ public: true
+
+ PhpList\Core\Routing\ExtraLoader:
+ tags: [routing.loader]
+
+ PhpList\Core\Domain\Common\Repository\AbstractRepository:
+ abstract: true
+ autowire: true
+ autoconfigure: false
+ public: true
+ factory: ['@doctrine.orm.entity_manager', getRepository]
+
+ # controllers are imported separately to make sure they're public
+ # and have a tag that allows actions to type-hint services
+ PhpList\Core\EmptyStartPageBundle\Controller\:
+ resource: '../src/EmptyStartPageBundle/Controller'
+ public: true
+ tags: [controller.service_arguments]
+
+ doctrine.orm.metadata.annotation_reader:
+ alias: doctrine.annotation_reader
+
+ doctrine.annotation_reader:
+ class: Doctrine\Common\Annotations\AnnotationReader
+ autowire: true
+
+ doctrine.orm.default_annotation_metadata_driver:
+ class: Doctrine\ORM\Mapping\Driver\AnnotationDriver
+ arguments:
+ - '@annotation_reader'
+ - '%kernel.project_dir%/src/Domain/Model/'
diff --git a/config/services/builders.yml b/config/services/builders.yml
index c18961d6..10a994a4 100644
--- a/config/services/builders.yml
+++ b/config/services/builders.yml
@@ -20,6 +20,6 @@ services:
autowire: true
autoconfigure: true
- PhpListPhpList\Core\Domain\Messaging\Service\Builder\MessageOptionsBuilder:
+ PhpList\Core\Domain\Messaging\Service\Builder\MessageOptionsBuilder:
autowire: true
autoconfigure: true
diff --git a/config/services/commands.yml b/config/services/commands.yml
index 5cc1a241..d9305748 100644
--- a/config/services/commands.yml
+++ b/config/services/commands.yml
@@ -11,3 +11,7 @@ services:
PhpList\Core\Domain\Identity\Command\:
resource: '../../src/Domain/Identity/Command'
tags: ['console.command']
+
+ PhpList\Core\Domain\Messaging\Command\ProcessBouncesCommand:
+ arguments:
+ $protocolProcessors: !tagged_iterator 'phplist.bounce_protocol_processor'
diff --git a/config/services/managers.yml b/config/services/managers.yml
index 0f6bb119..5ef215b3 100644
--- a/config/services/managers.yml
+++ b/config/services/managers.yml
@@ -72,6 +72,10 @@ services:
autowire: true
autoconfigure: true
+ PhpList\Core\Domain\Messaging\Service\Manager\BounceManager:
+ autowire: true
+ autoconfigure: true
+
PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager:
autowire: true
autoconfigure: true
@@ -79,3 +83,11 @@ services:
PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager:
autowire: true
autoconfigure: true
+
+ PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Messaging\Service\Manager\SendProcessManager:
+ autowire: true
+ autoconfigure: true
diff --git a/config/services/processor.yml b/config/services/processor.yml
new file mode 100644
index 00000000..acbd11c0
--- /dev/null
+++ b/config/services/processor.yml
@@ -0,0 +1,21 @@
+services:
+ _defaults:
+ autowire: true
+ autoconfigure: true
+ public: false
+
+ PhpList\Core\Domain\Messaging\Service\Processor\PopBounceProcessor:
+ arguments:
+ $host: '%imap_bounce.host%'
+ $port: '%imap_bounce.port%'
+ $mailboxNames: '%imap_bounce.mailbox_name%'
+ tags: ['phplist.bounce_protocol_processor']
+
+ PhpList\Core\Domain\Messaging\Service\Processor\MboxBounceProcessor:
+ tags: ['phplist.bounce_protocol_processor']
+
+ PhpList\Core\Domain\Messaging\Service\Processor\AdvancedBounceRulesProcessor: ~
+
+ PhpList\Core\Domain\Messaging\Service\Processor\UnidentifiedBounceReprocessor: ~
+
+ PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor: ~
diff --git a/config/services/providers.yml b/config/services/providers.yml
index 226c4e81..cb784988 100644
--- a/config/services/providers.yml
+++ b/config/services/providers.yml
@@ -2,3 +2,7 @@ services:
PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider:
autowire: true
autoconfigure: true
+
+ PhpList\Core\Domain\Messaging\Service\Provider\BounceActionProvider:
+ autowire: true
+ autoconfigure: true
diff --git a/config/services/repositories.yml b/config/services/repositories.yml
index 69bdb6ce..82ae6a82 100644
--- a/config/services/repositories.yml
+++ b/config/services/repositories.yml
@@ -1,137 +1,152 @@
services:
- PhpList\Core\Domain\Identity\Repository\AdministratorRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Identity\Model\Administrator
- - Doctrine\ORM\Mapping\ClassMetadata\ClassMetadata
- - PhpList\Core\Security\HashGenerator
-
- PhpList\Core\Domain\Identity\Repository\AdminAttributeValueRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Identity\Model\AdminAttributeValue
-
- PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition
-
- PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Identity\Model\AdministratorToken
-
- PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Identity\Model\AdminPasswordRequest
-
- PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Subscription\Model\SubscriberList
-
- PhpList\Core\Domain\Subscription\Repository\SubscriberRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Subscription\Model\Subscriber
-
- PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue
-
- PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition
-
- PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Subscription\Model\Subscription
-
- PhpList\Core\Domain\Messaging\Repository\MessageRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Messaging\Model\Message
-
- PhpList\Core\Domain\Messaging\Repository\TemplateRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Messaging\Model\Template
-
- PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Messaging\Model\TemplateImage
-
- PhpList\Core\Domain\Configuration\Repository\ConfigRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Configuration\Model\Config
-
- PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Messaging\Model\UserMessageBounce
-
- PhpList\Core\Domain\Messaging\Repository\UserMessageForwardRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Messaging\Model\UserMessageForward
-
- PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Analytics\Model\LinkTrack
-
- PhpList\Core\Domain\Analytics\Repository\UserMessageViewRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Analytics\Model\UserMessageView
-
- PhpList\Core\Domain\Analytics\Repository\LinkTrackUmlClickRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Analytics\Model\LinkTrackUmlClick
-
- PhpList\Core\Domain\Messaging\Repository\UserMessageRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Messaging\Model\UserMessage
-
- PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Subscription\Model\SubscriberHistory
-
- PhpList\Core\Domain\Messaging\Repository\ListMessageRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Messaging\Model\ListMessage
-
- PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Subscription\Model\UserBlacklist
-
- PhpList\Core\Domain\Subscription\Repository\UserBlacklistDataRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Subscription\Model\UserBlacklistData
-
- PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Subscription\Model\SubscribePage
-
- PhpList\Core\Domain\Subscription\Repository\SubscriberPageDataRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Subscription\Model\SubscribePageData
-
- PhpList\Core\Domain\Messaging\Repository\BounceRegexRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Messaging\Model\BounceRegex
+ PhpList\Core\Domain\Identity\Repository\AdministratorRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Identity\Model\Administrator
+ - Doctrine\ORM\Mapping\ClassMetadata\ClassMetadata
+ - PhpList\Core\Security\HashGenerator
+
+ PhpList\Core\Domain\Identity\Repository\AdminAttributeValueRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Identity\Model\AdminAttributeValue
+
+ PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition
+
+ PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Identity\Model\AdministratorToken
+
+ PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Identity\Model\AdminPasswordRequest
+
+ PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Subscription\Model\SubscriberList
+
+ PhpList\Core\Domain\Subscription\Repository\SubscriberRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Subscription\Model\Subscriber
+
+ PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue
+
+ PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition
+
+ PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Subscription\Model\Subscription
+
+ PhpList\Core\Domain\Messaging\Repository\MessageRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Messaging\Model\Message
+
+ PhpList\Core\Domain\Messaging\Repository\TemplateRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Messaging\Model\Template
+
+ PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Messaging\Model\TemplateImage
+
+ PhpList\Core\Domain\Configuration\Repository\ConfigRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Configuration\Model\Config
+
+ PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Messaging\Model\UserMessageBounce
+
+ PhpList\Core\Domain\Messaging\Repository\UserMessageForwardRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Messaging\Model\UserMessageForward
+
+ PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Analytics\Model\LinkTrack
+
+ PhpList\Core\Domain\Analytics\Repository\UserMessageViewRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Analytics\Model\UserMessageView
+
+ PhpList\Core\Domain\Analytics\Repository\LinkTrackUmlClickRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Analytics\Model\LinkTrackUmlClick
+
+ PhpList\Core\Domain\Messaging\Repository\UserMessageRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Messaging\Model\UserMessage
+
+ PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Subscription\Model\SubscriberHistory
+
+ PhpList\Core\Domain\Messaging\Repository\ListMessageRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Messaging\Model\ListMessage
+
+ PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Subscription\Model\UserBlacklist
+
+ PhpList\Core\Domain\Subscription\Repository\UserBlacklistDataRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Subscription\Model\UserBlacklistData
+
+ PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Subscription\Model\SubscribePage
+
+ PhpList\Core\Domain\Subscription\Repository\SubscriberPageDataRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Subscription\Model\SubscribePageData
+
+ PhpList\Core\Domain\Messaging\Repository\BounceRegexRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Messaging\Model\BounceRegex
+
+ PhpList\Core\Domain\Messaging\Repository\BounceRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Messaging\Model\Bounce
+
+ PhpList\Core\Domain\Messaging\Repository\BounceRegexBounceRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Messaging\Model\BounceRegex
+
+ PhpList\Core\Domain\Messaging\Repository\SendProcessRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Messaging\Model\SendProcess
diff --git a/config/services/services.yml b/config/services/services.yml
index 7b9f921c..19caddd8 100644
--- a/config/services/services.yml
+++ b/config/services/services.yml
@@ -1,36 +1,109 @@
services:
- PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter:
- autowire: true
- autoconfigure: true
- public: true
-
- PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter:
- autowire: true
- autoconfigure: true
- public: true
-
- PhpList\Core\Domain\Messaging\Service\EmailService:
- autowire: true
- autoconfigure: true
- arguments:
- $defaultFromEmail: '%app.mailer_from%'
-
- PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService:
- autowire: true
- autoconfigure: true
- public: true
-
- PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator:
- autowire: true
- autoconfigure: true
- public: true
-
- PhpList\Core\Domain\Analytics\Service\LinkTrackService:
- autowire: true
- autoconfigure: true
- public: true
-
- PhpList\Core\Domain\Messaging\Service\CampaignProcessor:
- autowire: true
- autoconfigure: true
- public: true
+ PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter:
+ autowire: true
+ autoconfigure: true
+ public: true
+
+ PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter:
+ autowire: true
+ autoconfigure: true
+ public: true
+
+ PhpList\Core\Domain\Messaging\Service\EmailService:
+ autowire: true
+ autoconfigure: true
+ arguments:
+ $defaultFromEmail: '%app.mailer_from%'
+ $bounceEmail: '%imap_bounce.email%'
+
+ PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService:
+ autowire: true
+ autoconfigure: true
+ public: true
+
+ PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator:
+ autowire: true
+ autoconfigure: true
+ public: true
+
+ PhpList\Core\Domain\Analytics\Service\LinkTrackService:
+ autowire: true
+ autoconfigure: true
+ public: true
+
+ PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor:
+ autowire: true
+ autoconfigure: true
+ public: true
+
+ PhpList\Core\Domain\Common\ClientIpResolver:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Common\SystemInfoCollector:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler:
+ autowire: true
+ autoconfigure: true
+ arguments:
+ $unsubscribeThreshold: '%imap_bounce.unsubscribe_threshold%'
+ $blacklistThreshold: '%imap_bounce.blacklist_threshold%'
+
+ Webklex\PHPIMAP\ClientManager: ~
+
+ PhpList\Core\Domain\Messaging\Service\WebklexImapClientFactory:
+ autowire: true
+ autoconfigure: true
+ arguments:
+ $mailbox: '%imap_bounce.mailbox%'# e.g. "{imap.example.com:993/imap/ssl}INBOX" or "/var/mail/user"
+ $host: '%imap_bounce.host%'
+ $port: '%imap_bounce.port%'
+ $encryption: '%imap_bounce.encryption%'
+ $username: '%imap_bounce.email%'
+ $password: '%imap_bounce.password%'
+ $protocol: '%imap_bounce.protocol%'
+
+ PhpList\Core\Domain\Common\Mail\NativeImapMailReader:
+ arguments:
+ $username: '%imap_bounce.email%'
+ $password: '%imap_bounce.password%'
+
+ PhpList\Core\Domain\Messaging\Service\NativeBounceProcessingService:
+ autowire: true
+ autoconfigure: true
+ arguments:
+ $purgeProcessed: '%imap_bounce.purge%'
+ $purgeUnprocessed: '%imap_bounce.purge_unprocessed%'
+
+ PhpList\Core\Domain\Messaging\Service\WebklexBounceProcessingService:
+ autowire: true
+ autoconfigure: true
+ arguments:
+ $purgeProcessed: '%imap_bounce.purge%'
+ $purgeUnprocessed: '%imap_bounce.purge_unprocessed%'
+
+ PhpList\Core\Domain\Messaging\Service\LockService:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Messaging\Service\MessageParser:
+ autowire: true
+ autoconfigure: true
+
+ _instanceof:
+ PhpList\Core\Domain\Messaging\Service\Handler\BounceActionHandlerInterface:
+ tags:
+ - { name: 'phplist.bounce_action_handler' }
+
+ PhpList\Core\Domain\Messaging\Service\Handler\:
+ resource: '../../src/Domain/Messaging/Service/Handler/*Handler.php'
+
+ PhpList\Core\Domain\Messaging\Service\BounceActionResolver:
+ arguments:
+ - !tagged_iterator { tag: 'phplist.bounce_action_handler' }
diff --git a/src/Core/ApplicationKernel.php b/src/Core/ApplicationKernel.php
index 97249b45..8f43e62b 100644
--- a/src/Core/ApplicationKernel.php
+++ b/src/Core/ApplicationKernel.php
@@ -106,6 +106,7 @@ protected function build(ContainerBuilder $container): void
{
$container->setParameter('kernel.application_dir', $this->getApplicationDir());
$container->addCompilerPass(new DoctrineMappingPass());
+ $container->addCompilerPass(new BounceProcessorPass());
}
/**
diff --git a/src/Core/BounceProcessorPass.php b/src/Core/BounceProcessorPass.php
new file mode 100644
index 00000000..2ab5c9c5
--- /dev/null
+++ b/src/Core/BounceProcessorPass.php
@@ -0,0 +1,28 @@
+hasDefinition($native) || !$container->hasDefinition($webklex)) {
+ return;
+ }
+
+ $aliasTo = extension_loaded('imap') ? $native : $webklex;
+
+ $container->setAlias(BounceProcessingServiceInterface::class, $aliasTo)->setPublic(false);
+ }
+}
diff --git a/src/Domain/Common/ClientIpResolver.php b/src/Domain/Common/ClientIpResolver.php
new file mode 100644
index 00000000..65cbbb6c
--- /dev/null
+++ b/src/Domain/Common/ClientIpResolver.php
@@ -0,0 +1,28 @@
+requestStack = $requestStack;
+ }
+
+ public function resolve(): string
+ {
+ $request = $this->requestStack->getCurrentRequest();
+
+ if ($request !== null) {
+ return $request->getClientIp() ?? '';
+ }
+
+ return (gethostname() ?: 'localhost') . ':' . getmypid();
+ }
+}
diff --git a/src/Domain/Common/Mail/NativeImapMailReader.php b/src/Domain/Common/Mail/NativeImapMailReader.php
new file mode 100644
index 00000000..472fea54
--- /dev/null
+++ b/src/Domain/Common/Mail/NativeImapMailReader.php
@@ -0,0 +1,65 @@
+username = $username;
+ $this->password = $password;
+ }
+
+ public function open(string $mailbox, int $options = 0): Connection
+ {
+ $link = imap_open($mailbox, $this->username, $this->password, $options);
+
+ if ($link === false) {
+ throw new RuntimeException('Cannot open mailbox: '.(imap_last_error() ?: 'unknown error'));
+ }
+
+ return $link;
+ }
+
+ public function numMessages(Connection $link): int
+ {
+ return imap_num_msg($link);
+ }
+
+ public function fetchHeader(Connection $link, int $msgNo): string
+ {
+ return imap_fetchheader($link, $msgNo) ?: '';
+ }
+
+ public function headerDate(Connection $link, int $msgNo): DateTimeImmutable
+ {
+ $info = imap_headerinfo($link, $msgNo);
+ $date = $info->date ?? null;
+
+ return $date ? new DateTimeImmutable($date) : new DateTimeImmutable();
+ }
+
+ public function body(Connection $link, int $msgNo): string
+ {
+ return imap_body($link, $msgNo) ?: '';
+ }
+
+ public function delete(Connection $link, int $msgNo): void
+ {
+ imap_delete($link, (string)$msgNo);
+ }
+
+ public function close(Connection $link, bool $expunge): void
+ {
+ $expunge ? imap_close($link, CL_EXPUNGE) : imap_close($link);
+ }
+}
diff --git a/src/Domain/Common/SystemInfoCollector.php b/src/Domain/Common/SystemInfoCollector.php
new file mode 100644
index 00000000..e66d27b1
--- /dev/null
+++ b/src/Domain/Common/SystemInfoCollector.php
@@ -0,0 +1,77 @@
+ use defaults)
+ */
+ public function __construct(
+ RequestStack $requestStack,
+ array $configuredKeys = []
+ ) {
+ $this->requestStack = $requestStack;
+ $this->configuredKeys = $configuredKeys;
+ }
+
+ /**
+ * Return key=>value pairs (already sanitized for safe logging/HTML display).
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ * @return array
+ */
+ public function collect(): array
+ {
+ $request = $this->requestStack->getCurrentRequest() ?? Request::createFromGlobals();
+ $data = [];
+ $headers = $request->headers;
+
+ $data['HTTP_USER_AGENT'] = (string) $headers->get('User-Agent', '');
+ $data['HTTP_REFERER'] = (string) $headers->get('Referer', '');
+ $data['HTTP_X_FORWARDED_FOR'] = (string) $headers->get('X-Forwarded-For', '');
+ $data['REQUEST_URI'] = $request->getRequestUri();
+ $data['REMOTE_ADDR'] = $request->getClientIp() ?? '';
+
+ $keys = $this->configuredKeys ?: $this->defaultKeys;
+
+ $out = [];
+ foreach ($keys as $key) {
+ if (!array_key_exists($key, $data)) {
+ continue;
+ }
+ $val = $data[$key];
+
+ $safeKey = strip_tags($key);
+ $safeVal = htmlspecialchars((string) $val, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+
+ $out[$safeKey] = $safeVal;
+ }
+
+ return $out;
+ }
+
+ /**
+ * Convenience to match the legacy multi-line string format.
+ */
+ public function collectAsString(): string
+ {
+ $pairs = $this->collect();
+ if (!$pairs) {
+ return '';
+ }
+ $lines = [];
+ foreach ($pairs as $k => $v) {
+ $lines[] = sprintf('%s = %s', $k, $v);
+ }
+ return "\n" . implode("\n", $lines);
+ }
+}
diff --git a/src/Domain/Messaging/Command/ProcessBouncesCommand.php b/src/Domain/Messaging/Command/ProcessBouncesCommand.php
new file mode 100644
index 00000000..f1e3b403
--- /dev/null
+++ b/src/Domain/Messaging/Command/ProcessBouncesCommand.php
@@ -0,0 +1,114 @@
+addOption('protocol', null, InputOption::VALUE_REQUIRED, 'Mailbox protocol: pop or mbox', 'pop')
+ ->addOption(
+ 'purge-unprocessed',
+ null,
+ InputOption::VALUE_NONE,
+ 'Delete/remove unprocessed messages from mailbox'
+ )
+ ->addOption('rules-batch-size', null, InputOption::VALUE_OPTIONAL, 'Advanced rules batch size', '1000')
+ ->addOption('test', 't', InputOption::VALUE_NONE, 'Test mode: do not delete from mailbox')
+ ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force run: kill other processes if locked');
+ }
+
+ public function __construct(
+ private readonly LockService $lockService,
+ private readonly LoggerInterface $logger,
+ /** @var iterable */
+ private readonly iterable $protocolProcessors,
+ private readonly AdvancedBounceRulesProcessor $advancedRulesProcessor,
+ private readonly UnidentifiedBounceReprocessor $unidentifiedReprocessor,
+ private readonly ConsecutiveBounceHandler $consecutiveBounceHandler,
+ ) {
+ parent::__construct();
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $inputOutput = new SymfonyStyle($input, $output);
+
+ if (!function_exists('imap_open')) {
+ $inputOutput->note(self::IMAP_NOT_AVAILABLE);
+ }
+
+ $force = (bool)$input->getOption('force');
+ $lock = $this->lockService->acquirePageLock('bounce_processor', $force);
+
+ if (($lock ?? 0) === 0) {
+ $inputOutput->warning($force ? self::FORCE_LOCK_FAILED : self::ALREADY_LOCKED);
+
+ return $force ? Command::FAILURE : Command::SUCCESS;
+ }
+
+ try {
+ $inputOutput->title('Processing bounces');
+ $protocol = (string)$input->getOption('protocol');
+
+ $downloadReport = '';
+
+ $processor = $this->findProcessorFor($protocol);
+ if ($processor === null) {
+ $inputOutput->error('Unsupported protocol: '.$protocol);
+
+ return Command::FAILURE;
+ }
+
+ $downloadReport .= $processor->process($input, $inputOutput);
+ $this->unidentifiedReprocessor->process($inputOutput);
+ $this->advancedRulesProcessor->process($inputOutput, (int)$input->getOption('rules-batch-size'));
+ $this->consecutiveBounceHandler->handle($inputOutput);
+
+ $this->logger->info('Bounce processing completed', ['downloadReport' => $downloadReport]);
+ $inputOutput->success('Bounce processing completed.');
+
+ return Command::SUCCESS;
+ } catch (Exception $e) {
+ $this->logger->error('Bounce processing failed', ['exception' => $e]);
+ $inputOutput->error('Error: '.$e->getMessage());
+
+ return Command::FAILURE;
+ } finally {
+ $this->lockService->release($lock);
+ }
+ }
+
+ private function findProcessorFor(string $protocol): ?BounceProtocolProcessor
+ {
+ foreach ($this->protocolProcessors as $processor) {
+ if ($processor->getProtocol() === $protocol) {
+ return $processor;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php
index 43937f91..820d403d 100644
--- a/src/Domain/Messaging/Command/ProcessQueueCommand.php
+++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php
@@ -4,14 +4,14 @@
namespace PhpList\Core\Domain\Messaging\Command;
-use PhpList\Core\Domain\Messaging\Service\CampaignProcessor;
+use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
+use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator;
+use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor;
+use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
-use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator;
use Symfony\Component\Lock\LockFactory;
-use Symfony\Component\Console\Attribute\AsCommand;
use Throwable;
#[AsCommand(
diff --git a/src/Domain/Messaging/Model/BounceRegexBounce.php b/src/Domain/Messaging/Model/BounceRegexBounce.php
index 9dbd3168..e815cd1f 100644
--- a/src/Domain/Messaging/Model/BounceRegexBounce.php
+++ b/src/Domain/Messaging/Model/BounceRegexBounce.php
@@ -13,38 +13,38 @@
class BounceRegexBounce implements DomainModel
{
#[ORM\Id]
- #[ORM\Column(type: 'integer')]
- private int $regex;
+ #[ORM\Column(name: 'regex', type: 'integer')]
+ private int $regexId;
#[ORM\Id]
- #[ORM\Column(type: 'integer')]
- private int $bounce;
+ #[ORM\Column(name: 'bounce', type: 'integer')]
+ private int $bounceId;
- public function __construct(int $regex, int $bounce)
+ public function __construct(int $regexId, int $bounceId)
{
- $this->regex = $regex;
- $this->bounce = $bounce;
+ $this->regexId = $regexId;
+ $this->bounceId = $bounceId;
}
- public function getRegex(): int
+ public function getRegexId(): int
{
- return $this->regex;
+ return $this->regexId;
}
- public function setRegex(int $regex): self
+ public function setRegexId(int $regexId): self
{
- $this->regex = $regex;
+ $this->regexId = $regexId;
return $this;
}
- public function getBounce(): int
+ public function getBounceId(): int
{
- return $this->bounce;
+ return $this->bounceId;
}
- public function setBounce(int $bounce): self
+ public function setBounceId(int $bounceId): self
{
- $this->bounce = $bounce;
+ $this->bounceId = $bounceId;
return $this;
}
}
diff --git a/src/Domain/Messaging/Model/UserMessageBounce.php b/src/Domain/Messaging/Model/UserMessageBounce.php
index ccb05597..5da0d139 100644
--- a/src/Domain/Messaging/Model/UserMessageBounce.php
+++ b/src/Domain/Messaging/Model/UserMessageBounce.php
@@ -31,15 +31,15 @@ class UserMessageBounce implements DomainModel, Identity
private int $messageId;
#[ORM\Column(name: 'bounce', type: 'integer')]
- private int $bounce;
+ private int $bounceId;
#[ORM\Column(name: 'time', type: 'datetime', options: ['default' => 'CURRENT_TIMESTAMP'])]
private DateTime $createdAt;
- public function __construct(int $bounce)
+ public function __construct(int $bounceId, DateTime $createdAt)
{
- $this->bounce = $bounce;
- $this->createdAt = new DateTime();
+ $this->bounceId = $bounceId;
+ $this->createdAt = $createdAt;
}
public function getId(): ?int
@@ -57,9 +57,9 @@ public function getMessageId(): int
return $this->messageId;
}
- public function getBounce(): int
+ public function getBounceId(): int
{
- return $this->bounce;
+ return $this->bounceId;
}
public function getCreatedAt(): DateTime
@@ -79,9 +79,9 @@ public function setMessageId(int $messageId): self
return $this;
}
- public function setBounce(int $bounce): self
+ public function setBounceId(int $bounceId): self
{
- $this->bounce = $bounce;
+ $this->bounceId = $bounceId;
return $this;
}
}
diff --git a/src/Domain/Messaging/Repository/BounceRegexRepository.php b/src/Domain/Messaging/Repository/BounceRegexRepository.php
index f5088376..9aecde78 100644
--- a/src/Domain/Messaging/Repository/BounceRegexRepository.php
+++ b/src/Domain/Messaging/Repository/BounceRegexRepository.php
@@ -17,4 +17,16 @@ public function findOneByRegexHash(string $regexHash): ?BounceRegex
{
return $this->findOneBy(['regexHash' => $regexHash]);
}
+
+ /** @return BounceRegex[] */
+ public function fetchAllOrdered(): array
+ {
+ return $this->findBy([], ['listOrder' => 'ASC']);
+ }
+
+ /** @return BounceRegex[] */
+ public function fetchActiveOrdered(): array
+ {
+ return $this->findBy(['active' => true], ['listOrder' => 'ASC']);
+ }
}
diff --git a/src/Domain/Messaging/Repository/BounceRepository.php b/src/Domain/Messaging/Repository/BounceRepository.php
index fa691a28..410f5da1 100644
--- a/src/Domain/Messaging/Repository/BounceRepository.php
+++ b/src/Domain/Messaging/Repository/BounceRepository.php
@@ -7,8 +7,15 @@
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Messaging\Model\Bounce;
class BounceRepository extends AbstractRepository implements PaginatableRepositoryInterface
{
use CursorPaginationTrait;
+
+ /** @return Bounce[] */
+ public function findByStatus(string $status): array
+ {
+ return $this->findBy(['status' => $status]);
+ }
}
diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php
index cf802300..3da7ebf3 100644
--- a/src/Domain/Messaging/Repository/MessageRepository.php
+++ b/src/Domain/Messaging/Repository/MessageRepository.php
@@ -63,4 +63,15 @@ public function getMessagesByList(SubscriberList $list): array
->getQuery()
->getResult();
}
+
+ public function incrementBounceCount(int $messageId): void
+ {
+ $this->createQueryBuilder('m')
+ ->update()
+ ->set('m.bounceCount', 'm.bounceCount + 1')
+ ->where('m.id = :messageId')
+ ->setParameter('messageId', $messageId)
+ ->getQuery()
+ ->execute();
+ }
}
diff --git a/src/Domain/Messaging/Repository/SendProcessRepository.php b/src/Domain/Messaging/Repository/SendProcessRepository.php
index 496adf9b..2a234a5a 100644
--- a/src/Domain/Messaging/Repository/SendProcessRepository.php
+++ b/src/Domain/Messaging/Repository/SendProcessRepository.php
@@ -7,8 +7,75 @@
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Messaging\Model\SendProcess;
class SendProcessRepository extends AbstractRepository implements PaginatableRepositoryInterface
{
use CursorPaginationTrait;
+
+ public function deleteByPage(string $page): void
+ {
+ $this->createQueryBuilder('sp')
+ ->delete()
+ ->where('sp.page = :page')
+ ->setParameter('page', $page)
+ ->getQuery()
+ ->execute();
+ }
+
+ public function countAliveByPage(string $page): int
+ {
+ return (int)$this->createQueryBuilder('sp')
+ ->select('COUNT(sp.id)')
+ ->where('sp.page = :page')
+ ->andWhere('sp.alive > 0')
+ ->setParameter('page', $page)
+ ->getQuery()
+ ->getSingleScalarResult();
+ }
+
+ public function findNewestAlive(string $page): ?SendProcess
+ {
+ return $this->createQueryBuilder('sp')
+ ->where('sp.page = :page')
+ ->andWhere('sp.alive > 0')
+ ->setParameter('page', $page)
+ ->orderBy('sp.started', 'DESC')
+ ->setMaxResults(1)
+ ->getQuery()
+ ->getOneOrNullResult();
+ }
+
+ public function markDeadById(int $id): void
+ {
+ $this->createQueryBuilder('sp')
+ ->update()
+ ->set('sp.alive', ':zero')
+ ->where('sp.id = :id')
+ ->setParameter('zero', 0)
+ ->setParameter('id', $id)
+ ->getQuery()
+ ->execute();
+ }
+
+ public function incrementAlive(int $id): void
+ {
+ $this->createQueryBuilder('sp')
+ ->update()
+ ->set('sp.alive', 'sp.alive + 1')
+ ->where('sp.id = :id')
+ ->setParameter('id', $id)
+ ->getQuery()
+ ->execute();
+ }
+
+ public function getAliveValue(int $id): int
+ {
+ return (int)$this->createQueryBuilder('sp')
+ ->select('sp.alive')
+ ->where('sp.id = :id')
+ ->setParameter('id', $id)
+ ->getQuery()
+ ->getSingleScalarResult();
+ }
}
diff --git a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php
index 16f07f79..1b315f5e 100644
--- a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php
+++ b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php
@@ -7,6 +7,10 @@
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Messaging\Model\Bounce;
+use PhpList\Core\Domain\Messaging\Model\UserMessage;
+use PhpList\Core\Domain\Messaging\Model\UserMessageBounce;
+use PhpList\Core\Domain\Subscription\Model\Subscriber;
class UserMessageBounceRepository extends AbstractRepository implements PaginatableRepositoryInterface
{
@@ -21,4 +25,69 @@ public function getCountByMessageId(int $messageId): int
->getQuery()
->getSingleScalarResult();
}
+
+ public function existsByMessageIdAndUserId(int $messageId, int $subscriberId): bool
+ {
+ return (bool) $this->createQueryBuilder('umb')
+ ->select('1')
+ ->where('umb.messageId = :messageId')
+ ->andWhere('umb.userId = :userId')
+ ->setParameter('messageId', $messageId)
+ ->setParameter('userId', $subscriberId)
+ ->setMaxResults(1)
+ ->getQuery()
+ ->getOneOrNullResult();
+ }
+
+ /**
+ * @return array
+ */
+ public function getPaginatedWithJoinNoRelation(int $fromId, int $limit): array
+ {
+ return $this->getEntityManager()
+ ->createQueryBuilder()
+ ->select('umb', 'bounce')
+ ->from(UserMessageBounce::class, 'umb')
+ ->innerJoin(Bounce::class, 'bounce', 'WITH', 'bounce.id = umb.bounce')
+ ->where('umb.id > :id')
+ ->setParameter('id', $fromId)
+ ->orderBy('umb.id', 'ASC')
+ ->setMaxResults($limit)
+ ->getQuery()
+ ->getResult();
+ }
+
+ /**
+ * @return array
+ */
+ public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array
+ {
+ return $this->getEntityManager()
+ ->createQueryBuilder()
+ ->select('um', 'umb', 'b')
+ ->from(UserMessage::class, 'um')
+ ->leftJoin(
+ join: UserMessageBounce::class,
+ alias: 'umb',
+ conditionType: 'WITH',
+ condition: 'umb.messageId = IDENTITY(um.message) AND umb.userId = IDENTITY(um.user)'
+ )
+ ->leftJoin(
+ join: Bounce::class,
+ alias: 'b',
+ conditionType: 'WITH',
+ condition: 'b.id = umb.bounceId'
+ )
+ ->where('um.user = :userId')
+ ->andWhere('um.status = :status')
+ ->setParameter('userId', $subscriber->getId())
+ ->setParameter('status', 'sent')
+ ->orderBy('um.entered', 'DESC')
+ ->getQuery()
+ ->getResult();
+ }
}
diff --git a/src/Domain/Messaging/Service/BounceActionResolver.php b/src/Domain/Messaging/Service/BounceActionResolver.php
new file mode 100644
index 00000000..93d432dd
--- /dev/null
+++ b/src/Domain/Messaging/Service/BounceActionResolver.php
@@ -0,0 +1,65 @@
+ */
+ private array $cache = [];
+
+ /**
+ * @param iterable $handlers
+ */
+ public function __construct(iterable $handlers)
+ {
+ foreach ($handlers as $handler) {
+ $this->handlers[] = $handler;
+ }
+ }
+
+ public function has(string $action): bool
+ {
+ return isset($this->cache[$action]) || $this->find($action) !== null;
+ }
+
+ public function resolve(string $action): BounceActionHandlerInterface
+ {
+ if (isset($this->cache[$action])) {
+ return $this->cache[$action];
+ }
+
+ $handler = $this->find($action);
+ if ($handler === null) {
+ throw new RuntimeException(sprintf('No handler found for action "%s".', $action));
+ }
+
+ $this->cache[$action] = $handler;
+
+ return $handler;
+ }
+
+ /** Convenience: resolve + execute */
+ public function handle(string $action, array $context): void
+ {
+ $this->resolve($action)->handle($context);
+ }
+
+ private function find(string $action): ?BounceActionHandlerInterface
+ {
+ foreach ($this->handlers as $handler) {
+ if ($handler->supports($action)) {
+ return $handler;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php b/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php
new file mode 100644
index 00000000..9d16702f
--- /dev/null
+++ b/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php
@@ -0,0 +1,10 @@
+bounceManager = $bounceManager;
+ $this->subscriberRepository = $subscriberRepository;
+ $this->subscriberHistoryManager = $subscriberHistoryManager;
+ $this->blacklistService = $blacklistService;
+ $this->unsubscribeThreshold = $unsubscribeThreshold;
+ $this->blacklistThreshold = $blacklistThreshold;
+ }
+
+ public function handle(SymfonyStyle $io): void
+ {
+ $io->section('Identifying consecutive bounces');
+
+ $users = $this->subscriberRepository->distinctUsersWithBouncesConfirmedNotBlacklisted();
+ $total = count($users);
+
+ if ($total === 0) {
+ $io->writeln('Nothing to do');
+ return;
+ }
+
+ $processed = 0;
+ foreach ($users as $user) {
+ $this->processUser($user);
+ $processed++;
+
+ if ($processed % 5 === 0) {
+ $io->writeln(\sprintf('processed %d out of %d subscribers', $processed, $total));
+ }
+ }
+
+ $io->writeln(\sprintf('total of %d subscribers processed', $total));
+ }
+
+ private function processUser(Subscriber $user): void
+ {
+ $history = $this->bounceManager->getUserMessageHistoryWithBounces($user);
+ if (count($history) === 0) {
+ return;
+ }
+
+ $consecutive = 0;
+ $unsubscribed = false;
+
+ foreach ($history as $row) {
+ /** @var array{um: UserMessage, umb: UserMessageBounce|null, b: Bounce|null} $row */
+ $bounce = $row['b'] ?? null;
+
+ if ($this->isDuplicate($bounce)) {
+ continue;
+ }
+
+ if (!$this->hasRealId($bounce)) {
+ break;
+ }
+
+ $consecutive++;
+
+ if ($this->applyThresholdActions($user, $consecutive, $unsubscribed)) {
+ break;
+ }
+
+ if (!$unsubscribed && $consecutive >= $this->unsubscribeThreshold) {
+ $unsubscribed = true;
+ }
+ }
+ }
+
+ private function isDuplicate(?Bounce $bounce): bool
+ {
+ if ($bounce === null) {
+ return false;
+ }
+ $status = strtolower($bounce->getStatus() ?? '');
+ $comment = strtolower($bounce->getComment() ?? '');
+
+ return str_contains($status, 'duplicate') || str_contains($comment, 'duplicate');
+ }
+
+ private function hasRealId(?Bounce $bounce): bool
+ {
+ return $bounce !== null && (int) $bounce->getId() > 0;
+ }
+
+ /**
+ * Returns true if processing should stop for this user (e.g., blacklisted).
+ */
+ private function applyThresholdActions($user, int $consecutive, bool $alreadyUnsubscribed): bool
+ {
+ if ($consecutive >= $this->unsubscribeThreshold && !$alreadyUnsubscribed) {
+ $this->subscriberRepository->markUnconfirmed($user->getId());
+ $this->subscriberHistoryManager->addHistory(
+ subscriber: $user,
+ message: 'Auto Unconfirmed',
+ details: sprintf('Subscriber auto unconfirmed for %d consecutive bounces', $consecutive)
+ );
+ }
+
+ if ($this->blacklistThreshold > 0 && $consecutive >= $this->blacklistThreshold) {
+ $this->blacklistService->blacklist(
+ subscriber: $user,
+ reason: sprintf('%d consecutive bounces, threshold reached', $consecutive)
+ );
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/Domain/Messaging/Service/EmailService.php b/src/Domain/Messaging/Service/EmailService.php
index 86b17ec5..2a45b0fd 100644
--- a/src/Domain/Messaging/Service/EmailService.php
+++ b/src/Domain/Messaging/Service/EmailService.php
@@ -6,6 +6,7 @@
use PhpList\Core\Domain\Messaging\Message\AsyncEmailMessage;
use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\Address;
@@ -13,17 +14,20 @@
class EmailService
{
private MailerInterface $mailer;
- private string $defaultFromEmail;
private MessageBusInterface $messageBus;
+ private string $defaultFromEmail;
+ private string $bounceEmail;
public function __construct(
MailerInterface $mailer,
+ MessageBusInterface $messageBus,
string $defaultFromEmail,
- MessageBusInterface $messageBus
+ string $bounceEmail,
) {
$this->mailer = $mailer;
- $this->defaultFromEmail = $defaultFromEmail;
$this->messageBus = $messageBus;
+ $this->defaultFromEmail = $defaultFromEmail;
+ $this->bounceEmail = $bounceEmail;
}
public function sendEmail(
@@ -68,7 +72,12 @@ public function sendEmailSync(
$email->attachFromPath($attachment);
}
- $this->mailer->send($email);
+ $envelope = new Envelope(
+ sender: new Address($this->bounceEmail, 'PHPList Bounce'),
+ recipients: [new Address($email->getTo()[0]->getAddress())]
+ );
+
+ $this->mailer->send(message: $email, envelope: $envelope);
}
public function sendBulkEmail(
diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php
new file mode 100644
index 00000000..d32cf68b
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php
@@ -0,0 +1,47 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+ $this->bounceManager = $bounceManager;
+ $this->blacklistService = $blacklistService;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'blacklistemailanddeletebounce';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber'])) {
+ $this->blacklistService->blacklist(
+ subscriber: $closureData['subscriber'],
+ reason: 'Email address auto blacklisted by bounce rule '.$closureData['ruleId']
+ );
+ $this->subscriberHistoryManager->addHistory(
+ $closureData['subscriber'],
+ 'Auto Unsubscribed',
+ 'User auto unsubscribed for bounce rule '.$closureData['ruleId']
+ );
+ }
+ $this->bounceManager->delete($closureData['bounce']);
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php
new file mode 100644
index 00000000..9a92088c
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php
@@ -0,0 +1,42 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+ $this->blacklistService = $blacklistService;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'blacklistemail';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber'])) {
+ $this->blacklistService->blacklist(
+ $closureData['subscriber'],
+ 'Email address auto blacklisted by bounce rule '.$closureData['ruleId']
+ );
+ $this->subscriberHistoryManager->addHistory(
+ $closureData['subscriber'],
+ 'Auto Unsubscribed',
+ 'email auto unsubscribed for bounce rule '.$closureData['ruleId']
+ );
+ }
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php
new file mode 100644
index 00000000..b017fe9c
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php
@@ -0,0 +1,47 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+ $this->bounceManager = $bounceManager;
+ $this->blacklistService = $blacklistService;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'blacklistuseranddeletebounce';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber']) && !$closureData['blacklisted']) {
+ $this->blacklistService->blacklist(
+ subscriber: $closureData['subscriber'],
+ reason: 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId']
+ );
+ $this->subscriberHistoryManager->addHistory(
+ subscriber: $closureData['subscriber'],
+ message: 'Auto Unsubscribed',
+ details: 'User auto unsubscribed for bounce rule '.$closureData['ruleId']
+ );
+ }
+ $this->bounceManager->delete($closureData['bounce']);
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php
new file mode 100644
index 00000000..75c8b810
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php
@@ -0,0 +1,42 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+ $this->blacklistService = $blacklistService;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'blacklistuser';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber']) && !$closureData['blacklisted']) {
+ $this->blacklistService->blacklist(
+ subscriber: $closureData['subscriber'],
+ reason: 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId']
+ );
+ $this->subscriberHistoryManager->addHistory(
+ subscriber: $closureData['subscriber'],
+ message: 'Auto Unsubscribed',
+ details: 'User auto unsubscribed for bounce rule '.$closureData['ruleId']
+ );
+ }
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php b/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php
new file mode 100644
index 00000000..6b90cb49
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php
@@ -0,0 +1,11 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+ $this->subscriberManager = $subscriberManager;
+ $this->bounceManager = $bounceManager;
+ $this->subscriberRepository = $subscriberRepository;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'decreasecountconfirmuseranddeletebounce';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber'])) {
+ $this->subscriberManager->decrementBounceCount($closureData['subscriber']);
+ if (!$closureData['confirmed']) {
+ $this->subscriberRepository->markConfirmed($closureData['userId']);
+ $this->subscriberHistoryManager->addHistory(
+ subscriber: $closureData['subscriber'],
+ message: 'Auto confirmed',
+ details: 'Subscriber auto confirmed for bounce rule '.$closureData['ruleId']
+ );
+ }
+ }
+ $this->bounceManager->delete($closureData['bounce']);
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php
new file mode 100644
index 00000000..80c881a1
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php
@@ -0,0 +1,27 @@
+bounceManager = $bounceManager;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'deletebounce';
+ }
+
+ public function handle(array $closureData): void
+ {
+ $this->bounceManager->delete($closureData['bounce']);
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php b/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php
new file mode 100644
index 00000000..d8887545
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php
@@ -0,0 +1,33 @@
+bounceManager = $bounceManager;
+ $this->subscriberManager = $subscriberManager;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'deleteuserandbounce';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber'])) {
+ $this->subscriberManager->deleteSubscriber($closureData['subscriber']);
+ }
+ $this->bounceManager->delete($closureData['bounce']);
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php b/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php
new file mode 100644
index 00000000..64b1a073
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php
@@ -0,0 +1,36 @@
+subscriberManager = $subscriberManager;
+ $this->logger = $logger;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'deleteuser';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber'])) {
+ $this->logger->info('User deleted by bounce rule', [
+ 'user' => $closureData['subscriber']->getEmail(),
+ 'rule' => $closureData['ruleId'],
+ ]);
+ $this->subscriberManager->deleteSubscriber($closureData['subscriber']);
+ }
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php
new file mode 100644
index 00000000..7ca39be8
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php
@@ -0,0 +1,44 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+ $this->subscriberRepository = $subscriberRepository;
+ $this->bounceManager = $bounceManager;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'unconfirmuseranddeletebounce';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber']) && $closureData['confirmed']) {
+ $this->subscriberRepository->markUnconfirmed($closureData['userId']);
+ $this->subscriberHistoryManager->addHistory(
+ subscriber: $closureData['subscriber'],
+ message: 'Auto unconfirmed',
+ details: 'Subscriber auto unconfirmed for bounce rule '.$closureData['ruleId']
+ );
+ }
+ $this->bounceManager->delete($closureData['bounce']);
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php
new file mode 100644
index 00000000..a5bdd0fe
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php
@@ -0,0 +1,39 @@
+subscriberRepository = $subscriberRepository;
+ $this->subscriberHistoryManager = $subscriberHistoryManager;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'unconfirmuser';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber']) && $closureData['confirmed']) {
+ $this->subscriberRepository->markUnconfirmed($closureData['userId']);
+ $this->subscriberHistoryManager->addHistory(
+ $closureData['subscriber'],
+ 'Auto Unconfirmed',
+ 'Subscriber auto unconfirmed for bounce rule '.$closureData['ruleId']
+ );
+ }
+ }
+}
diff --git a/src/Domain/Messaging/Service/LockService.php b/src/Domain/Messaging/Service/LockService.php
new file mode 100644
index 00000000..d2f1eb34
--- /dev/null
+++ b/src/Domain/Messaging/Service/LockService.php
@@ -0,0 +1,172 @@
+repo = $repo;
+ $this->manager = $manager;
+ $this->logger = $logger;
+ $this->staleAfterSeconds = $staleAfterSeconds;
+ $this->sleepSeconds = $sleepSeconds;
+ $this->maxWaitCycles = $maxWaitCycles;
+ }
+
+ /**
+ * @SuppressWarnings("BooleanArgumentFlag")
+ */
+ public function acquirePageLock(
+ string $page,
+ bool $force = false,
+ bool $isCli = false,
+ bool $multiSend = false,
+ int $maxSendProcesses = 1,
+ ?string $clientIp = null,
+ ): ?int {
+ $page = $this->sanitizePage($page);
+ $max = $this->resolveMax($isCli, $multiSend, $maxSendProcesses);
+
+ if ($force) {
+ $this->logger->info('Force set, killing other send processes (deleting lock rows).');
+ $this->repo->deleteByPage($page);
+ }
+
+ $waited = 0;
+
+ while (true) {
+ $count = $this->repo->countAliveByPage($page);
+ $running = $this->manager->findNewestAliveWithAge($page);
+
+ if ($count >= $max) {
+ if ($this->tryStealIfStale($running)) {
+ continue;
+ }
+
+ $this->logAliveAge($running);
+
+ if ($isCli) {
+ $this->logger->info("Running commandline, quitting. We'll find out what to do in the next run.");
+
+ return null;
+ }
+
+ if (!$this->waitOrGiveUp($waited)) {
+ $this->logger->info('We have been waiting too long, I guess the other process is still going ok');
+
+ return null;
+ }
+
+ continue;
+ }
+
+ $processIdentifier = $this->buildProcessIdentifier($isCli, $clientIp);
+ $sendProcess = $this->manager->create($page, $processIdentifier);
+
+ return $sendProcess->getId();
+ }
+ }
+
+ public function keepLock(int $processId): void
+ {
+ $this->repo->incrementAlive($processId);
+ }
+
+ public function checkLock(int $processId): int
+ {
+ return $this->repo->getAliveValue($processId);
+ }
+
+ public function release(int $processId): void
+ {
+ $this->repo->markDeadById($processId);
+ }
+
+ private function sanitizePage(string $page): string
+ {
+ $unicodeString = new UnicodeString($page);
+ $clean = preg_replace('/\W/', '', (string) $unicodeString);
+
+ return $clean === '' ? 'default' : $clean;
+ }
+
+ private function resolveMax(bool $isCli, bool $multiSend, int $maxSendProcesses): int
+ {
+ if (!$isCli) {
+ return 1;
+ }
+ return $multiSend ? \max(1, $maxSendProcesses) : 1;
+ }
+
+ /**
+ * Returns true if it detected a stale process and killed it (so caller should loop again).
+ *
+ * @param array{id?: int, age?: int}|null $running
+ */
+ private function tryStealIfStale(?array $running): bool
+ {
+ $age = (int)($running['age'] ?? 0);
+ if ($age > $this->staleAfterSeconds && isset($running['id'])) {
+ $this->repo->markDeadById((int)$running['id']);
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param array{id?: int, age?: int}|null $running
+ */
+ private function logAliveAge(?array $running): void
+ {
+ $age = (int)($running['age'] ?? 0);
+ $this->logger->info(
+ \sprintf(
+ 'A process for this page is already running and it was still alive %d seconds ago',
+ $age
+ )
+ );
+ }
+
+ /**
+ * Sleeps once and increments $waited. Returns false if we exceeded max wait cycles.
+ */
+ private function waitOrGiveUp(int &$waited): bool
+ {
+ $this->logger->info(\sprintf('Sleeping for %d seconds, aborting will quit', $this->sleepSeconds));
+ \sleep($this->sleepSeconds);
+ $waited++;
+ return $waited <= $this->maxWaitCycles;
+ }
+
+ private function buildProcessIdentifier(bool $isCli, ?string $clientIp): string
+ {
+ if ($isCli) {
+ $host = \php_uname('n') ?: 'localhost';
+ return $host . ':' . \getmypid();
+ }
+ return $clientIp ?? '0.0.0.0';
+ }
+}
diff --git a/src/Domain/Messaging/Service/Manager/BounceManager.php b/src/Domain/Messaging/Service/Manager/BounceManager.php
new file mode 100644
index 00000000..f13c46ff
--- /dev/null
+++ b/src/Domain/Messaging/Service/Manager/BounceManager.php
@@ -0,0 +1,138 @@
+bounceRepository = $bounceRepository;
+ $this->userMessageBounceRepo = $userMessageBounceRepo;
+ $this->entityManager = $entityManager;
+ $this->logger = $logger;
+ }
+
+ public function create(
+ ?DateTimeImmutable $date = null,
+ ?string $header = null,
+ ?string $data = null,
+ ?string $status = null,
+ ?string $comment = null
+ ): Bounce {
+ $bounce = new Bounce(
+ date: new DateTime($date->format('Y-m-d H:i:s')),
+ header: $header,
+ data: $data,
+ status: $status,
+ comment: $comment
+ );
+
+ $this->bounceRepository->save($bounce);
+
+ return $bounce;
+ }
+
+ public function update(Bounce $bounce, ?string $status = null, ?string $comment = null): Bounce
+ {
+ $bounce->setStatus($status);
+ $bounce->setComment($comment);
+ $this->bounceRepository->save($bounce);
+
+ return $bounce;
+ }
+
+ public function delete(Bounce $bounce): void
+ {
+ $this->bounceRepository->remove($bounce);
+ }
+
+ /** @return Bounce[] */
+ public function getAll(): array
+ {
+ return $this->bounceRepository->findAll();
+ }
+
+ public function getById(int $id): ?Bounce
+ {
+ /** @var Bounce|null $found */
+ $found = $this->bounceRepository->find($id);
+ return $found;
+ }
+
+ public function linkUserMessageBounce(
+ Bounce $bounce,
+ DateTimeImmutable $date,
+ int $subscriberId,
+ ?int $messageId = -1
+ ): UserMessageBounce {
+ $userMessageBounce = new UserMessageBounce($bounce->getId(), new DateTime($date->format('Y-m-d H:i:s')));
+ $userMessageBounce->setUserId($subscriberId);
+ $userMessageBounce->setMessageId($messageId);
+ $this->entityManager->flush();
+
+ return $userMessageBounce;
+ }
+
+ public function existsUserMessageBounce(int $subscriberId, int $messageId): bool
+ {
+ return $this->userMessageBounceRepo->existsByMessageIdAndUserId($messageId, $subscriberId);
+ }
+
+ /** @return Bounce[] */
+ public function findByStatus(string $status): array
+ {
+ return $this->bounceRepository->findByStatus($status);
+ }
+
+ public function getUserMessageBounceCount(): int
+ {
+ return $this->userMessageBounceRepo->count();
+ }
+
+ /**
+ * @return array
+ */
+ public function fetchUserMessageBounceBatch(int $fromId, int $batchSize): array
+ {
+ return $this->userMessageBounceRepo->getPaginatedWithJoinNoRelation($fromId, $batchSize);
+ }
+
+ /**
+ * @return array
+ */
+ public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array
+ {
+ return $this->userMessageBounceRepo->getUserMessageHistoryWithBounces($subscriber);
+ }
+
+ public function announceDeletionMode(bool $testMode): void
+ {
+ $message = $testMode ? self::TEST_MODE_MESSAGE : self::LIVE_MODE_MESSAGE;
+ $this->logger->info($message);
+ }
+}
diff --git a/src/Domain/Messaging/Service/Manager/BounceRuleManager.php b/src/Domain/Messaging/Service/Manager/BounceRuleManager.php
new file mode 100644
index 00000000..70a750a9
--- /dev/null
+++ b/src/Domain/Messaging/Service/Manager/BounceRuleManager.php
@@ -0,0 +1,110 @@
+
+ */
+ public function loadActiveRules(): array
+ {
+ return $this->mapRows($this->repository->fetchActiveOrdered());
+ }
+
+ /**
+ * @return array
+ */
+ public function loadAllRules(): array
+ {
+ return $this->mapRows($this->repository->fetchAllOrdered());
+ }
+
+ /**
+ * Internal helper to normalize repository rows into the legacy shape.
+ *
+ * @param BounceRegex[] $rows
+ * @return array
+ */
+ private function mapRows(array $rows): array
+ {
+ $result = [];
+
+ foreach ($rows as $row) {
+ $regex = $row->getRegex();
+ $action = $row->getAction();
+ $id = $row->getId();
+
+ if (!is_string($regex)
+ || $regex === ''
+ || !is_string($action)
+ || $action === ''
+ || !is_int($id)
+ ) {
+ continue;
+ }
+
+ $result[$regex] = $row;
+ }
+
+ return $result;
+ }
+
+
+ /**
+ * @param array $rules
+ */
+ public function matchBounceRules(string $text, array $rules): ?BounceRegex
+ {
+ foreach ($rules as $pattern => $rule) {
+ $quoted = '/'.preg_quote(str_replace(' ', '\s+', $pattern)).'/iUm';
+ if ($this->safePregMatch($quoted, $text)) {
+ return $rule;
+ }
+ $raw = '/'.str_replace(' ', '\s+', $pattern).'/iUm';
+ if ($this->safePregMatch($raw, $text)) {
+ return $rule;
+ }
+ }
+
+ return null;
+ }
+
+ private function safePregMatch(string $pattern, string $subject): bool
+ {
+ set_error_handler(static fn() => true);
+ $result = preg_match($pattern, $subject) === 1;
+ restore_error_handler();
+
+ return $result;
+ }
+
+ public function incrementCount(BounceRegex $rule): void
+ {
+ $rule->setCount($rule->getCount() + 1);
+
+ $this->repository->save($rule);
+ }
+
+ public function linkRuleToBounce(BounceRegex $rule, Bounce $bounce): BounceregexBounce
+ {
+ $relation = new BounceRegexBounce($rule->getId(), $bounce->getId());
+ $this->bounceRelationRepository->save($relation);
+
+ return $relation;
+ }
+}
diff --git a/src/Domain/Messaging/Service/Manager/SendProcessManager.php b/src/Domain/Messaging/Service/Manager/SendProcessManager.php
new file mode 100644
index 00000000..0100ed29
--- /dev/null
+++ b/src/Domain/Messaging/Service/Manager/SendProcessManager.php
@@ -0,0 +1,57 @@
+repository = $repository;
+ $this->entityManager = $entityManager;
+ }
+
+ public function create(string $page, string $processIdentifier): SendProcess
+ {
+ $sendProcess = new SendProcess();
+ $sendProcess->setStartedDate(new DateTime('now'));
+ $sendProcess->setAlive(1);
+ $sendProcess->setIpaddress($processIdentifier);
+ $sendProcess->setPage($page);
+
+ $this->entityManager->persist($sendProcess);
+ $this->entityManager->flush();
+
+ return $sendProcess;
+ }
+
+
+ /**
+ * @return array{id:int, age:int}|null
+ */
+ public function findNewestAliveWithAge(string $page): ?array
+ {
+ $row = $this->repository->findNewestAlive($page);
+
+ if (!$row instanceof SendProcess) {
+ return null;
+ }
+
+ $modified = $row->getUpdatedAt();
+ $age = $modified ? max(0, time() - (int)$modified->format('U')) : 0;
+
+ return [
+ 'id' => $row->getId(),
+ 'age' => $age,
+ ];
+ }
+}
diff --git a/src/Domain/Messaging/Service/MessageParser.php b/src/Domain/Messaging/Service/MessageParser.php
new file mode 100644
index 00000000..14b4f952
--- /dev/null
+++ b/src/Domain/Messaging/Service/MessageParser.php
@@ -0,0 +1,102 @@
+subscriberRepository = $subscriberRepository;
+ }
+
+ public function decodeBody(string $header, string $body): string
+ {
+ $transferEncoding = '';
+ if (preg_match('/Content-Transfer-Encoding: ([\w-]+)/i', $header, $regs)) {
+ $transferEncoding = strtolower($regs[1]);
+ }
+
+ return match ($transferEncoding) {
+ 'quoted-printable' => quoted_printable_decode($body),
+ 'base64' => base64_decode($body) ?: '',
+ default => $body,
+ };
+ }
+
+ public function findMessageId(string $text): ?string
+ {
+ if (preg_match('/(?:X-MessageId|X-Message): (.*)\r\n/iU', $text, $match)) {
+ return trim($match[1]);
+ }
+
+ return null;
+ }
+
+ public function findUserId(string $text): ?int
+ {
+ $candidate = $this->extractUserHeader($text);
+ if ($candidate) {
+ $id = $this->resolveUserIdentifier($candidate);
+ if ($id) {
+ return $id;
+ }
+ }
+
+ $emails = $this->extractEmails($text);
+
+ return $this->findFirstSubscriberId($emails);
+ }
+
+ private function extractUserHeader(string $text): ?string
+ {
+ if (preg_match('/^(?:X-ListMember|X-User):\s*(?P[^\r\n]+)/mi', $text, $matches)) {
+ $user = trim($matches['user']);
+
+ return $user !== '' ? $user : null;
+ }
+
+ return null;
+ }
+
+ private function resolveUserIdentifier(string $user): ?int
+ {
+ if (filter_var($user, FILTER_VALIDATE_EMAIL)) {
+ return $this->subscriberRepository->findOneByEmail($user)?->getId();
+ }
+
+ if (ctype_digit($user)) {
+ return (int) $user;
+ }
+
+ return $this->subscriberRepository->findOneByEmail($user)?->getId();
+ }
+
+ private function extractEmails(string $text): array
+ {
+ preg_match_all('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+/i', $text, $matches);
+ if (empty($matches[0])) {
+ return [];
+ }
+ $norm = array_map('strtolower', $matches[0]);
+
+ return array_values(array_unique($norm));
+ }
+
+ private function findFirstSubscriberId(array $emails): ?int
+ {
+ foreach ($emails as $email) {
+ $id = $this->subscriberRepository->findOneByEmail($email)?->getId();
+ if ($id !== null) {
+ return $id;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/Domain/Messaging/Service/NativeBounceProcessingService.php b/src/Domain/Messaging/Service/NativeBounceProcessingService.php
new file mode 100644
index 00000000..eee5bb98
--- /dev/null
+++ b/src/Domain/Messaging/Service/NativeBounceProcessingService.php
@@ -0,0 +1,138 @@
+bounceManager = $bounceManager;
+ $this->mailReader = $mailReader;
+ $this->messageParser = $messageParser;
+ $this->bounceDataProcessor = $bounceDataProcessor;
+ $this->logger = $logger;
+ $this->purgeProcessed = $purgeProcessed;
+ $this->purgeUnprocessed = $purgeUnprocessed;
+ }
+
+ public function processMailbox(
+ string $mailbox,
+ int $max,
+ bool $testMode
+ ): string {
+ $link = $this->openOrFail($mailbox, $testMode);
+
+ $num = $this->prepareAndCapCount($link, $max);
+ if ($num === 0) {
+ $this->mailReader->close($link, false);
+
+ return '';
+ }
+
+ $this->bounceManager->announceDeletionMode($testMode);
+
+ for ($messageNumber = 1; $messageNumber <= $num; $messageNumber++) {
+ $this->handleMessage($link, $messageNumber, $testMode);
+ }
+
+ $this->finalize($link, $testMode);
+
+ return '';
+ }
+
+ private function openOrFail(string $mailbox, bool $testMode): Connection
+ {
+ try {
+ return $this->mailReader->open($mailbox, $testMode ? 0 : CL_EXPUNGE);
+ } catch (Throwable $e) {
+ $this->logger->error('Cannot open mailbox file: '.$e->getMessage());
+ throw new RuntimeException('Cannot open mbox file');
+ }
+ }
+
+ private function prepareAndCapCount(Connection $link, int $max): int
+ {
+ $num = $this->mailReader->numMessages($link);
+ $this->logger->info(sprintf('%d bounces to fetch from the mailbox', $num));
+ if ($num === 0) {
+ return 0;
+ }
+
+ $this->logger->info('Please do not interrupt this process');
+ if ($num > $max) {
+ $this->logger->info(sprintf('Processing first %d bounces', $max));
+ $num = $max;
+ }
+
+ return $num;
+ }
+
+ private function handleMessage(Connection $link, int $messageNumber, bool $testMode): void
+ {
+ $header = $this->mailReader->fetchHeader($link, $messageNumber);
+ $processed = $this->processImapBounce($link, $messageNumber, $header);
+
+ if ($testMode) {
+ return;
+ }
+
+ if ($processed && $this->purgeProcessed) {
+ $this->mailReader->delete($link, $messageNumber);
+ return;
+ }
+
+ if (!$processed && $this->purgeUnprocessed) {
+ $this->mailReader->delete($link, $messageNumber);
+ }
+ }
+
+ private function finalize(Connection $link, bool $testMode): void
+ {
+ $this->logger->info('Closing mailbox, and purging messages');
+ $this->mailReader->close($link, !$testMode);
+ }
+
+ private function processImapBounce($link, int $num, string $header): bool
+ {
+ $bounceDate = $this->mailReader->headerDate($link, $num);
+ $body = $this->mailReader->body($link, $num);
+ $body = $this->messageParser->decodeBody($header, $body);
+
+ // Quick hack: ignore MsExchange delayed notices (as in original)
+ if (preg_match('/Action: delayed\s+Status: 4\.4\.7/im', $body)) {
+ return true;
+ }
+
+ $msgId = $this->messageParser->findMessageId($body);
+ $userId = $this->messageParser->findUserId($body);
+
+ $bounce = $this->bounceManager->create($bounceDate, $header, $body);
+
+ return $this->bounceDataProcessor->process($bounce, $msgId, $userId, $bounceDate);
+ }
+}
diff --git a/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php
new file mode 100644
index 00000000..568bf874
--- /dev/null
+++ b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php
@@ -0,0 +1,120 @@
+section('Processing bounces based on active bounce rules');
+
+ $rules = $this->ruleManager->loadActiveRules();
+ if (!$rules) {
+ $io->writeln('No active rules');
+ return;
+ }
+
+ $total = $this->bounceManager->getUserMessageBounceCount();
+ $fromId = 0;
+ $matched = 0;
+ $notMatched = 0;
+ $processed = 0;
+
+ while ($processed < $total) {
+ $batch = $this->bounceManager->fetchUserMessageBounceBatch($fromId, $batchSize);
+ if (!$batch) {
+ break;
+ }
+
+ foreach ($batch as $row) {
+ $fromId = $row['umb']->getId();
+
+ $bounce = $row['bounce'];
+ $userId = (int) $row['umb']->getUserId();
+ $text = $this->composeText($bounce);
+ $rule = $this->ruleManager->matchBounceRules($text, $rules);
+
+ if ($rule) {
+ $this->incrementRuleCounters($rule, $bounce);
+
+ $subscriber = $userId ? $this->subscriberManager->getSubscriberById($userId) : null;
+ $ctx = $this->makeContext($subscriber, $bounce, (int)$rule->getId());
+
+ $action = (string) $rule->getAction();
+ $this->actionResolver->handle($action, $ctx);
+
+ $matched++;
+ } else {
+ $notMatched++;
+ }
+
+ $processed++;
+ }
+
+ $io->writeln(sprintf(
+ 'processed %d out of %d bounces for advanced bounce rules',
+ min($processed, $total),
+ $total
+ ));
+ }
+
+ $io->writeln(sprintf('%d bounces processed by advanced processing', $matched));
+ $io->writeln(sprintf('%d bounces were not matched by advanced processing rules', $notMatched));
+ }
+
+ private function composeText(Bounce $bounce): string
+ {
+ return $bounce->getHeader() . "\n\n" . $bounce->getData();
+ }
+
+ private function incrementRuleCounters($rule, Bounce $bounce): void
+ {
+ $this->ruleManager->incrementCount($rule);
+ $rule->setCount($rule->getCount() + 1);
+ $this->ruleManager->linkRuleToBounce($rule, $bounce);
+ }
+
+ /**
+ * @return array{
+ * subscriber: ?Subscriber,
+ * bounce: Bounce,
+ * userId: int,
+ * confirmed: bool,
+ * blacklisted: bool,
+ * ruleId: int
+ * }
+ */
+ private function makeContext(?Subscriber $subscriber, Bounce $bounce, int $ruleId): array
+ {
+ $userId = $subscriber?->getId() ?? 0;
+ $confirmed = $subscriber?->isConfirmed() ?? false;
+ $blacklisted = $subscriber?->isBlacklisted() ?? false;
+
+ return [
+ 'subscriber' => $subscriber,
+ 'bounce' => $bounce,
+ 'userId' => $userId,
+ 'confirmed' => $confirmed,
+ 'blacklisted' => $blacklisted,
+ 'ruleId' => $ruleId,
+ ];
+ }
+}
diff --git a/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php
new file mode 100644
index 00000000..6f502a8c
--- /dev/null
+++ b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php
@@ -0,0 +1,155 @@
+bounceManager = $bounceManager;
+ $this->subscriberRepository = $subscriberRepository;
+ $this->messageRepository = $messageRepository;
+ $this->logger = $logger;
+ $this->subscriberManager = $subscriberManager;
+ $this->subscriberHistoryManager = $subscriberHistoryManager;
+ }
+
+ public function process(Bounce $bounce, ?string $msgId, ?int $userId, DateTimeImmutable $bounceDate): bool
+ {
+ $user = $userId ? $this->subscriberManager->getSubscriberById($userId) : null;
+
+ if ($msgId === 'systemmessage') {
+ return $userId ? $this->handleSystemMessageWithUser(
+ $bounce,
+ $bounceDate,
+ $userId,
+ $user
+ ) : $this->handleSystemMessageUnknownUser($bounce);
+ }
+
+ if ($msgId && $userId) {
+ return $this->handleKnownMessageAndUser($bounce, $bounceDate, (int)$msgId, $userId);
+ }
+
+ if ($userId) {
+ return $this->handleUserOnly($bounce, $userId);
+ }
+
+ if ($msgId) {
+ return $this->handleMessageOnly($bounce, (int)$msgId);
+ }
+
+ $this->bounceManager->update($bounce, 'unidentified bounce', 'not processed');
+
+ return false;
+ }
+
+ private function handleSystemMessageWithUser(
+ Bounce $bounce,
+ DateTimeImmutable $date,
+ int $userId,
+ $userOrNull
+ ): bool {
+ $this->bounceManager->update(
+ bounce: $bounce,
+ status: 'bounced system message',
+ comment: sprintf('%d marked unconfirmed', $userId)
+ );
+ $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId);
+ $this->subscriberRepository->markUnconfirmed($userId);
+ $this->logger->info('system message bounced, user marked unconfirmed', ['userId' => $userId]);
+
+ if ($userOrNull) {
+ $this->subscriberHistoryManager->addHistory(
+ subscriber: $userOrNull,
+ message: 'Bounced system message',
+ details: sprintf('User marked unconfirmed. Bounce #%d', $bounce->getId())
+ );
+ }
+
+ return true;
+ }
+
+ private function handleSystemMessageUnknownUser(Bounce $bounce): bool
+ {
+ $this->bounceManager->update($bounce, 'bounced system message', 'unknown user');
+ $this->logger->info('system message bounced, but unknown user');
+
+ return true;
+ }
+
+ private function handleKnownMessageAndUser(
+ Bounce $bounce,
+ DateTimeImmutable $date,
+ int $msgId,
+ int $userId
+ ): bool {
+ if (!$this->bounceManager->existsUserMessageBounce($userId, $msgId)) {
+ $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId, $msgId);
+ $this->bounceManager->update(
+ bounce: $bounce,
+ status: sprintf('bounced list message %d', $msgId),
+ comment: sprintf('%d bouncecount increased', $userId)
+ );
+ $this->messageRepository->incrementBounceCount($msgId);
+ $this->subscriberRepository->incrementBounceCount($userId);
+ } else {
+ $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId, $msgId);
+ $this->bounceManager->update(
+ bounce: $bounce,
+ status: sprintf('duplicate bounce for %d', $userId),
+ comment: sprintf('duplicate bounce for subscriber %d on message %d', $userId, $msgId)
+ );
+ }
+
+ return true;
+ }
+
+ private function handleUserOnly(Bounce $bounce, int $userId): bool
+ {
+ $this->bounceManager->update(
+ bounce: $bounce,
+ status: 'bounced unidentified message',
+ comment: sprintf('%d bouncecount increased', $userId)
+ );
+ $this->subscriberRepository->incrementBounceCount($userId);
+
+ return true;
+ }
+
+ private function handleMessageOnly(Bounce $bounce, int $msgId): bool
+ {
+ $this->bounceManager->update(
+ bounce: $bounce,
+ status: sprintf('bounced list message %d', $msgId),
+ comment: 'unknown user'
+ );
+ $this->messageRepository->incrementBounceCount($msgId);
+
+ return true;
+ }
+}
diff --git a/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php b/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php
new file mode 100644
index 00000000..a0e7d904
--- /dev/null
+++ b/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php
@@ -0,0 +1,24 @@
+processingService = $processingService;
+ }
+
+ public function getProtocol(): string
+ {
+ return 'mbox';
+ }
+
+ public function process(InputInterface $input, SymfonyStyle $inputOutput): string
+ {
+ $testMode = (bool)$input->getOption('test');
+ $max = (int)$input->getOption('maximum');
+
+ $file = (string)$input->getOption('mailbox');
+ if (!$file) {
+ $inputOutput->error('mbox file path must be provided with --mailbox.');
+ throw new RuntimeException('Missing --mailbox for mbox protocol');
+ }
+
+ $inputOutput->section('Opening mbox ' . $file);
+ $inputOutput->writeln('Please do not interrupt this process');
+
+ return $this->processingService->processMailbox(
+ mailbox: $file,
+ max: $max,
+ testMode: $testMode
+ );
+ }
+}
diff --git a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php
new file mode 100644
index 00000000..b6f59f65
--- /dev/null
+++ b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php
@@ -0,0 +1,59 @@
+processingService = $processingService;
+ $this->host = $host;
+ $this->port = $port;
+ $this->mailboxNames = $mailboxNames;
+ }
+
+ public function getProtocol(): string
+ {
+ return 'pop';
+ }
+
+ public function process(InputInterface $input, SymfonyStyle $inputOutput): string
+ {
+ $testMode = (bool)$input->getOption('test');
+ $max = (int)$input->getOption('maximum');
+
+ $downloadReport = '';
+ foreach (explode(',', $this->mailboxNames) as $mailboxName) {
+ $mailboxName = trim($mailboxName);
+ if ($mailboxName === '') {
+ $mailboxName = 'INBOX';
+ }
+ $mailbox = sprintf('{%s:%s}%s', $this->host, $this->port, $mailboxName);
+ $inputOutput->section('Connecting to ' . $mailbox);
+ $inputOutput->writeln('Please do not interrupt this process');
+
+ $downloadReport .= $this->processingService->processMailbox(
+ mailbox: $mailbox,
+ max: $max,
+ testMode: $testMode
+ );
+ }
+
+ return $downloadReport;
+ }
+}
diff --git a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php
new file mode 100644
index 00000000..503fc459
--- /dev/null
+++ b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php
@@ -0,0 +1,70 @@
+bounceManager = $bounceManager;
+ $this->messageParser = $messageParser;
+ $this->bounceDataProcessor = $bounceDataProcessor;
+ }
+
+ public function process(SymfonyStyle $inputOutput): void
+ {
+ $inputOutput->section('Reprocessing unidentified bounces');
+ $bounces = $this->bounceManager->findByStatus('unidentified bounce');
+ $total = count($bounces);
+ $inputOutput->writeln(sprintf('%d bounces to reprocess', $total));
+
+ $count = 0;
+ $reparsed = 0;
+ $reidentified = 0;
+ foreach ($bounces as $bounce) {
+ $count++;
+ if ($count % 25 === 0) {
+ $inputOutput->writeln(sprintf('%d out of %d processed', $count, $total));
+ }
+
+ $decodedBody = $this->messageParser->decodeBody($bounce->getHeader(), $bounce->getData());
+ $userId = $this->messageParser->findUserId($decodedBody);
+ $messageId = $this->messageParser->findMessageId($decodedBody);
+
+ if ($userId || $messageId) {
+ $reparsed++;
+ if ($this->bounceDataProcessor->process(
+ $bounce,
+ $messageId,
+ $userId,
+ new DateTimeImmutable()
+ )
+ ) {
+ $reidentified++;
+ }
+ }
+ }
+
+ $inputOutput->writeln(sprintf('%d out of %d processed', $count, $total));
+ $inputOutput->writeln(sprintf(
+ '%d bounces were re-processed and %d bounces were re-identified',
+ $reparsed,
+ $reidentified
+ ));
+ }
+}
diff --git a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php
new file mode 100644
index 00000000..01a94aff
--- /dev/null
+++ b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php
@@ -0,0 +1,268 @@
+bounceManager = $bounceManager;
+ $this->logger = $logger;
+ $this->messageParser = $messageParser;
+ $this->clientFactory = $clientFactory;
+ $this->bounceDataProcessor = $bounceDataProcessor;
+ $this->purgeProcessed = $purgeProcessed;
+ $this->purgeUnprocessed = $purgeUnprocessed;
+ }
+
+ /**
+ * Process unseen messages from the given mailbox using Webklex.
+ *
+ * $mailbox: IMAP host; if you pass "host#FOLDER", FOLDER will be used instead of INBOX.
+ *
+ * @throws RuntimeException If connection to the IMAP server cannot be established.
+ */
+ public function processMailbox(
+ string $mailbox,
+ int $max,
+ bool $testMode
+ ): string {
+ $client = $this->clientFactory->makeForMailbox();
+
+ try {
+ $client->connect();
+ } catch (Throwable $e) {
+ $this->logger->error('Cannot connect to mailbox: '.$e->getMessage());
+ throw new RuntimeException('Cannot connect to IMAP server');
+ }
+
+ try {
+ $folder = $client->getFolder($this->clientFactory->getFolderName());
+ $query = $folder->query()->unseen()->limit($max);
+
+ $messages = $query->get();
+ $num = $messages->count();
+
+ $this->logger->info(sprintf('%d bounces to fetch from the mailbox', $num));
+ if ($num === 0) {
+ return '';
+ }
+
+ $this->bounceManager->announceDeletionMode($testMode);
+
+ foreach ($messages as $message) {
+ $header = $this->headerToStringSafe($message);
+ $body = $this->bodyBestEffort($message);
+ $body = $this->messageParser->decodeBody($header, $body);
+
+ if (\preg_match('/Action: delayed\s+Status: 4\.4\.7/im', $body)) {
+ if (!$testMode && $this->purgeProcessed) {
+ $this->safeDelete($message);
+ }
+ continue;
+ }
+
+ $messageId = $this->messageParser->findMessageId($body."\r\n".$header);
+ $userId = $this->messageParser->findUserId($body."\r\n".$header);
+
+ $bounceDate = $this->extractDate($message);
+ $bounce = $this->bounceManager->create($bounceDate, $header, $body);
+
+ $processed = $this->bounceDataProcessor->process($bounce, $messageId, $userId, $bounceDate);
+
+ $this->processDelete($testMode, $processed, $message);
+ }
+
+ $this->logger->info('Closing mailbox, and purging messages');
+ $this->processExpunge($testMode, $folder, $client);
+
+ return '';
+ } finally {
+ try {
+ $client->disconnect();
+ } catch (Throwable $e) {
+ $this->logger->warning('Disconnect failed', ['error' => $e->getMessage()]);
+ }
+ }
+ }
+
+ private function headerToStringSafe(mixed $message): string
+ {
+ $raw = $this->tryRawHeader($message);
+ if ($raw !== null) {
+ return $raw;
+ }
+
+ $lines = [];
+ $subj = $message->getSubject() ?? '';
+ $from = $this->addrFirstToString($message->getFrom());
+ $messageTo = $this->addrManyToString($message->getTo());
+ $date = $this->extractDate($message)->format(\DATE_RFC2822);
+
+ if ($subj !== '') {
+ $lines[] = 'Subject: ' . $subj;
+ }
+ if ($from !== '') {
+ $lines[] = 'From: ' . $from;
+ }
+ if ($messageTo !== '') {
+ $lines[] = 'To: ' . $messageTo;
+ }
+ $lines[] = 'Date: ' . $date;
+
+ $mid = $message->getMessageId() ?? '';
+ if ($mid !== '') {
+ $lines[] = 'Message-ID: ' . $mid;
+ }
+
+ return implode("\r\n", $lines) . "\r\n";
+ }
+
+ private function tryRawHeader(mixed $message): ?string
+ {
+ if (!method_exists($message, 'getHeader')) {
+ return null;
+ }
+
+ try {
+ $headerObj = $message->getHeader();
+ if ($headerObj && method_exists($headerObj, 'toString')) {
+ $raw = (string) $headerObj->toString();
+ if ($raw !== '') {
+ return $raw;
+ }
+ }
+ } catch (Throwable $e) {
+ return null;
+ }
+
+ return null;
+ }
+
+ private function bodyBestEffort($message): string
+ {
+ $text = ($message->getTextBody() ?? '');
+ if ($text !== '') {
+ return $text;
+ }
+ $html = ($message->getHTMLBody() ?? '');
+ if ($html !== '') {
+ return trim(strip_tags($html));
+ }
+
+ return '';
+ }
+
+ private function extractDate(mixed $message): DateTimeImmutable
+ {
+ $date = $message->getDate();
+ if ($date instanceof DateTimeInterface) {
+ return new DateTimeImmutable($date->format('Y-m-d H:i:s'));
+ }
+
+ if (method_exists($message, 'getInternalDate')) {
+ $internalDate = (int) $message->getInternalDate();
+ if ($internalDate > 0) {
+ return new DateTimeImmutable('@'.$internalDate);
+ }
+ }
+
+ return new DateTimeImmutable();
+ }
+
+ private function addrFirstToString($addresses): string
+ {
+ $many = $this->addrManyToArray($addresses);
+ return $many[0] ?? '';
+ }
+
+ private function addrManyToString($addresses): string
+ {
+ $arr = $this->addrManyToArray($addresses);
+ return implode(', ', $arr);
+ }
+
+ private function addrManyToArray($addresses): array
+ {
+ if ($addresses === null) {
+ return [];
+ }
+ $out = [];
+ foreach ($addresses as $addr) {
+ $email = ($addr->mail ?? $addr->getAddress() ?? '');
+ $name = ($addr->personal ?? $addr->getName() ?? '');
+ $out[] = $name !== '' ? sprintf('%s <%s>', $name, $email) : $email;
+ }
+
+ return $out;
+ }
+
+ private function processDelete(bool $testMode, bool $processed, mixed $message): void
+ {
+ if (!$testMode) {
+ if ($processed && $this->purgeProcessed) {
+ $this->safeDelete($message);
+ } elseif (!$processed && $this->purgeUnprocessed) {
+ $this->safeDelete($message);
+ }
+ }
+ }
+
+ private function safeDelete($message): void
+ {
+ try {
+ if (method_exists($message, 'delete')) {
+ $message->delete();
+ } elseif (method_exists($message, 'setFlag')) {
+ $message->setFlag('DELETED');
+ }
+ } catch (Throwable $e) {
+ $this->logger->warning('Failed to delete message', ['error' => $e->getMessage()]);
+ }
+ }
+
+ private function processExpunge(bool $testMode, ?Folder $folder, Client $client): void
+ {
+ if (!$testMode) {
+ try {
+ if (method_exists($folder, 'expunge')) {
+ $folder->expunge();
+ } elseif (method_exists($client, 'expunge')) {
+ $client->expunge();
+ }
+ } catch (Throwable $e) {
+ $this->logger->warning('EXPUNGE failed', ['error' => $e->getMessage()]);
+ }
+ }
+ }
+}
diff --git a/src/Domain/Messaging/Service/WebklexImapClientFactory.php b/src/Domain/Messaging/Service/WebklexImapClientFactory.php
new file mode 100644
index 00000000..10271e4c
--- /dev/null
+++ b/src/Domain/Messaging/Service/WebklexImapClientFactory.php
@@ -0,0 +1,79 @@
+clientManager = $clientManager;
+ $this->mailbox = $mailbox;
+ $this->host = $host;
+ $this->username = $username;
+ $this->password = $password;
+ $this->protocol = $protocol;
+ $this->port = $port;
+ $this->encryption = $encryption;
+ }
+
+ /**
+ * @param array $config
+ * @throws MaskNotFoundException
+ */
+ public function make(array $config): Client
+ {
+ return $this->clientManager->make($config);
+ }
+
+ public function makeForMailbox(): Client
+ {
+ return $this->make([
+ 'host' => $this->host,
+ 'port' => $this->port,
+ 'encryption' => $this->encryption,
+ 'validate_cert' => true,
+ 'username' => $this->username,
+ 'password' => $this->password,
+ 'protocol' => $this->protocol,
+ ]);
+ }
+
+ public function getFolderName(): string
+ {
+ return $this->parseMailbox($this->mailbox)[1];
+ }
+
+ private function parseMailbox(string $mailbox): array
+ {
+ if (str_contains($mailbox, '#')) {
+ [$host, $folder] = explode('#', $mailbox, 2);
+ $host = trim($host);
+ $folder = trim($folder) ?: 'INBOX';
+ return [$host, $folder];
+ }
+ return [trim($mailbox), 'INBOX'];
+ }
+}
diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php
index 6ebaee70..3c3583b4 100644
--- a/src/Domain/Subscription/Repository/SubscriberRepository.php
+++ b/src/Domain/Subscription/Repository/SubscriberRepository.php
@@ -141,4 +141,51 @@ public function isEmailBlacklisted(string $email): bool
return !($queryBuilder->getQuery()->getOneOrNullResult() === null);
}
+
+ public function incrementBounceCount(int $subscriberId): void
+ {
+ $this->createQueryBuilder('s')
+ ->update()
+ ->set('s.bounceCount', 's.bounceCount + 1')
+ ->where('s.id = :subscriberId')
+ ->setParameter('subscriberId', $subscriberId)
+ ->getQuery()
+ ->execute();
+ }
+
+ public function markUnconfirmed(int $subscriberId): void
+ {
+ $this->createQueryBuilder('s')
+ ->update()
+ ->set('s.confirmed', ':confirmed')
+ ->where('s.id = :id')
+ ->setParameter('confirmed', false)
+ ->setParameter('id', $subscriberId)
+ ->getQuery()
+ ->execute();
+ }
+
+ public function markConfirmed(int $subscriberId): void
+ {
+ $this->createQueryBuilder('s')
+ ->update()
+ ->set('s.confirmed', ':confirmed')
+ ->where('s.id = :id')
+ ->setParameter('confirmed', true)
+ ->setParameter('id', $subscriberId)
+ ->getQuery()
+ ->execute();
+ }
+
+ /** @return Subscriber[] */
+ public function distinctUsersWithBouncesConfirmedNotBlacklisted(): array
+ {
+ return $this->createQueryBuilder('s')
+ ->select('s.id')
+ ->where('s.bounceCount > 0')
+ ->andWhere('s.confirmed = 1')
+ ->andWhere('s.blacklisted = 0')
+ ->getQuery()
+ ->getScalarResult();
+ }
}
diff --git a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php
index d30bae2d..d5828c2f 100644
--- a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php
@@ -58,6 +58,16 @@ public function addEmailToBlacklist(string $email, ?string $reasonData = null):
return $blacklistEntry;
}
+ public function addBlacklistData(string $email, string $name, string $data): void
+ {
+ $blacklistData = new UserBlacklistData();
+ $blacklistData->setEmail($email);
+ $blacklistData->setName($name);
+ $blacklistData->setData($data);
+ $this->entityManager->persist($blacklistData);
+ $this->entityManager->flush();
+ }
+
public function removeEmailFromBlacklist(string $email): void
{
$blacklistEntry = $this->userBlacklistRepository->findOneByEmail($email);
diff --git a/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php b/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php
index 4760acd8..bac2ef8d 100644
--- a/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php
@@ -4,20 +4,44 @@
namespace PhpList\Core\Domain\Subscription\Service\Manager;
+use PhpList\Core\Domain\Common\ClientIpResolver;
+use PhpList\Core\Domain\Common\SystemInfoCollector;
use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberHistoryFilter;
+use PhpList\Core\Domain\Subscription\Model\Subscriber;
+use PhpList\Core\Domain\Subscription\Model\SubscriberHistory;
use PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository;
class SubscriberHistoryManager
{
private SubscriberHistoryRepository $repository;
+ private ClientIpResolver $clientIpResolver;
+ private SystemInfoCollector $systemInfoCollector;
- public function __construct(SubscriberHistoryRepository $repository)
- {
+ public function __construct(
+ SubscriberHistoryRepository $repository,
+ ClientIpResolver $clientIpResolver,
+ SystemInfoCollector $systemInfoCollector,
+ ) {
$this->repository = $repository;
+ $this->clientIpResolver = $clientIpResolver;
+ $this->systemInfoCollector = $systemInfoCollector;
}
public function getHistory(int $lastId, int $limit, SubscriberHistoryFilter $filter): array
{
return $this->repository->getFilteredAfterId($lastId, $limit, $filter);
}
+
+ public function addHistory(Subscriber $subscriber, string $message, ?string $details = null): SubscriberHistory
+ {
+ $subscriberHistory = new SubscriberHistory($subscriber);
+ $subscriberHistory->setSummary($message);
+ $subscriberHistory->setDetail($details ?? $message);
+ $subscriberHistory->setSystemInfo($this->systemInfoCollector->collectAsString());
+ $subscriberHistory->setIp($this->clientIpResolver->resolve());
+
+ $this->repository->save($subscriberHistory);
+
+ return $subscriberHistory;
+ }
}
diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php
index e036f195..73531fbb 100644
--- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php
@@ -11,6 +11,7 @@
use PhpList\Core\Domain\Subscription\Model\Dto\UpdateSubscriberDto;
use PhpList\Core\Domain\Subscription\Model\Subscriber;
use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
+use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService;
use PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
@@ -26,7 +27,7 @@ public function __construct(
SubscriberRepository $subscriberRepository,
EntityManagerInterface $entityManager,
MessageBusInterface $messageBus,
- SubscriberDeletionService $subscriberDeletionService
+ SubscriberDeletionService $subscriberDeletionService,
) {
$this->subscriberRepository = $subscriberRepository;
$this->entityManager = $entityManager;
@@ -64,15 +65,9 @@ private function sendConfirmationEmail(Subscriber $subscriber): void
$this->messageBus->dispatch($message);
}
- public function getSubscriber(int $subscriberId): Subscriber
+ public function getSubscriberById(int $subscriberId): ?Subscriber
{
- $subscriber = $this->subscriberRepository->findSubscriberWithSubscriptions($subscriberId);
-
- if (!$subscriber) {
- throw new NotFoundHttpException('Subscriber not found');
- }
-
- return $subscriber;
+ return $this->subscriberRepository->find($subscriberId);
}
public function updateSubscriber(UpdateSubscriberDto $subscriberDto): Subscriber
@@ -140,4 +135,10 @@ public function updateFromImport(Subscriber $existingSubscriber, ImportSubscribe
return $existingSubscriber;
}
+
+ public function decrementBounceCount(Subscriber $subscriber): void
+ {
+ $subscriber->addToBounceCount(-1);
+ $this->entityManager->flush();
+ }
}
diff --git a/src/Domain/Subscription/Service/SubscriberBlacklistService.php b/src/Domain/Subscription/Service/SubscriberBlacklistService.php
new file mode 100644
index 00000000..d9ca5ea6
--- /dev/null
+++ b/src/Domain/Subscription/Service/SubscriberBlacklistService.php
@@ -0,0 +1,69 @@
+entityManager = $entityManager;
+ $this->blacklistManager = $blacklistManager;
+ $this->historyManager = $historyManager;
+ $this->requestStack = $requestStack;
+ }
+
+ /**
+ * @SuppressWarnings(PHPMD.Superglobals)
+ */
+ public function blacklist(Subscriber $subscriber, string $reason): void
+ {
+ $subscriber->setBlacklisted(true);
+ $this->entityManager->flush();
+ $this->blacklistManager->addEmailToBlacklist($subscriber->getEmail(), $reason);
+
+ foreach (['REMOTE_ADDR','HTTP_X_FORWARDED_FOR'] as $item) {
+ $request = $this->requestStack->getCurrentRequest();
+ if (!$request) {
+ return;
+ }
+ if ($request->server->get($item)) {
+ $this->blacklistManager->addBlacklistData(
+ email: $subscriber->getEmail(),
+ name: $item,
+ data: $request->server->get($item)
+ );
+ }
+ }
+
+ $this->historyManager->addHistory(
+ subscriber: $subscriber,
+ message: 'Added to blacklist',
+ details: sprintf('Added to blacklist for reason %s', $reason)
+ );
+
+ if (isset($GLOBALS['plugins']) && is_array($GLOBALS['plugins'])) {
+ foreach ($GLOBALS['plugins'] as $plugin) {
+ if (method_exists($plugin, 'blacklistEmail')) {
+ $plugin->blacklistEmail($subscriber->getEmail(), $reason);
+ }
+ }
+ }
+ }
+}
diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php
index e6d42236..b3bfda0c 100644
--- a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php
+++ b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php
@@ -4,6 +4,7 @@
namespace PhpList\Core\Tests\Integration\Domain\Subscription\Service;
+use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Exception;
@@ -94,7 +95,7 @@ public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError():
$userMessage->setStatus('sent');
$this->entityManager->persist($userMessage);
- $userMessageBounce = new UserMessageBounce(1);
+ $userMessageBounce = new UserMessageBounce(1, new DateTime());
$userMessageBounce->setUserId($subscriberId);
$userMessageBounce->setMessageId(1);
$this->entityManager->persist($userMessageBounce);
diff --git a/tests/Unit/Domain/Common/ClientIpResolverTest.php b/tests/Unit/Domain/Common/ClientIpResolverTest.php
new file mode 100644
index 00000000..e69e9f89
--- /dev/null
+++ b/tests/Unit/Domain/Common/ClientIpResolverTest.php
@@ -0,0 +1,61 @@
+requestStack = $this->createMock(RequestStack::class);
+ }
+
+ public function testResolveReturnsClientIpFromCurrentRequest(): void
+ {
+ $request = $this->createMock(Request::class);
+ $request->method('getClientIp')->willReturn('203.0.113.10');
+
+ $this->requestStack
+ ->method('getCurrentRequest')
+ ->willReturn($request);
+
+ $resolver = new ClientIpResolver($this->requestStack);
+ $this->assertSame('203.0.113.10', $resolver->resolve());
+ }
+
+ public function testResolveReturnsEmptyStringWhenClientIpIsNull(): void
+ {
+ $request = $this->createMock(Request::class);
+ $request->method('getClientIp')->willReturn(null);
+
+ $this->requestStack
+ ->method('getCurrentRequest')
+ ->willReturn($request);
+
+ $resolver = new ClientIpResolver($this->requestStack);
+ $this->assertSame('', $resolver->resolve());
+ }
+
+ public function testResolveReturnsHostAndPidWhenNoRequestAvailable(): void
+ {
+ $this->requestStack
+ ->method('getCurrentRequest')
+ ->willReturn(null);
+
+ $resolver = new ClientIpResolver($this->requestStack);
+
+ $expectedHost = gethostname() ?: 'localhost';
+ $expected = $expectedHost . ':' . getmypid();
+
+ $this->assertSame($expected, $resolver->resolve());
+ }
+}
diff --git a/tests/Unit/Domain/Common/SystemInfoCollectorTest.php b/tests/Unit/Domain/Common/SystemInfoCollectorTest.php
new file mode 100644
index 00000000..7bf964d7
--- /dev/null
+++ b/tests/Unit/Domain/Common/SystemInfoCollectorTest.php
@@ -0,0 +1,95 @@
+requestStack = $this->createMock(RequestStack::class);
+ }
+
+ public function testCollectReturnsSanitizedPairsWithDefaults(): void
+ {
+ $server = [
+ 'HTTP_USER_AGENT' => 'Agent X "',
+ 'HTTP_REFERER' => 'https://example.com/?q=',
+ 'HTTP_X_FORWARDED_FOR' => '198.51.100.5, 203.0.113.7',
+ 'REQUEST_URI' => '/path?x=1&y="z"',
+ 'REMOTE_ADDR' => '203.0.113.10',
+ ];
+ $request = new Request(query: [], request: [], attributes: [], cookies: [], files: [], server: $server);
+
+ $this->requestStack->method('getCurrentRequest')->willReturn($request);
+
+ $collector = new SystemInfoCollector($this->requestStack);
+ $result = $collector->collect();
+
+ $expected = [
+ 'HTTP_USER_AGENT' => 'Agent <b>X</b>"',
+ 'HTTP_REFERER' => 'https://example.com/?q=<script>alert(1)</script>',
+ 'REMOTE_ADDR' => '203.0.113.10',
+ 'REQUEST_URI' => '/path?x=1&y="z"<w>',
+ 'HTTP_X_FORWARDED_FOR' => '198.51.100.5, 203.0.113.7',
+ ];
+
+ $this->assertSame($expected, $result);
+ }
+
+ public function testCollectUsesConfiguredKeysAndSkipsMissing(): void
+ {
+ $server = [
+ 'HTTP_USER_AGENT' => 'UA',
+ 'REQUEST_URI' => '/only/uri',
+ 'REMOTE_ADDR' => '198.51.100.10',
+ ];
+ $request = new Request(query: [], request: [], attributes: [], cookies: [], files: [], server: $server);
+ $this->requestStack->method('getCurrentRequest')->willReturn($request);
+
+ $collector = new SystemInfoCollector($this->requestStack, ['REQUEST_URI', 'UNKNOWN', 'REMOTE_ADDR']);
+ $result = $collector->collect();
+
+ $expected = [
+ 'REQUEST_URI' => '/only/uri',
+ 'REMOTE_ADDR' => '198.51.100.10',
+ ];
+
+ $this->assertSame($expected, $result);
+ }
+
+ public function testCollectAsStringFormatsLinesWithLeadingNewline(): void
+ {
+ $server = [
+ 'HTTP_USER_AGENT' => 'UA',
+ 'HTTP_REFERER' => 'https://ref.example',
+ 'REMOTE_ADDR' => '192.0.2.5',
+ 'REQUEST_URI' => '/abc',
+ 'HTTP_X_FORWARDED_FOR' => '1.1.1.1',
+ ];
+ $request = new Request(query: [], request: [], attributes: [], cookies: [], files: [], server: $server);
+ $this->requestStack->method('getCurrentRequest')->willReturn($request);
+
+ $collector = new SystemInfoCollector($this->requestStack);
+ $string = $collector->collectAsString();
+
+ $expected = "\n" . implode("\n", [
+ 'HTTP_USER_AGENT = UA',
+ 'HTTP_REFERER = https://ref.example',
+ 'REMOTE_ADDR = 192.0.2.5',
+ 'REQUEST_URI = /abc',
+ 'HTTP_X_FORWARDED_FOR = 1.1.1.1',
+ ]);
+
+ $this->assertSame($expected, $string);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php
new file mode 100644
index 00000000..50cce9fa
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php
@@ -0,0 +1,197 @@
+lockService = $this->createMock(LockService::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->protocolProcessor = $this->createMock(BounceProtocolProcessor::class);
+ $this->advancedRulesProcessor = $this->createMock(AdvancedBounceRulesProcessor::class);
+ $this->unidentifiedReprocessor = $this->createMock(UnidentifiedBounceReprocessor::class);
+ $this->consecutiveBounceHandler = $this->createMock(ConsecutiveBounceHandler::class);
+
+ $command = new ProcessBouncesCommand(
+ lockService: $this->lockService,
+ logger: $this->logger,
+ protocolProcessors: [$this->protocolProcessor],
+ advancedRulesProcessor: $this->advancedRulesProcessor,
+ unidentifiedReprocessor: $this->unidentifiedReprocessor,
+ consecutiveBounceHandler: $this->consecutiveBounceHandler,
+ );
+
+ $this->commandTester = new CommandTester($command);
+ }
+
+ public function testExecuteWhenLockNotAcquired(): void
+ {
+ $this->lockService->expects($this->once())
+ ->method('acquirePageLock')
+ ->with('bounce_processor', false)
+ ->willReturn(null);
+
+ $this->protocolProcessor->expects($this->never())->method('getProtocol');
+ $this->protocolProcessor->expects($this->never())->method('process');
+ $this->unidentifiedReprocessor->expects($this->never())->method('process');
+ $this->advancedRulesProcessor->expects($this->never())->method('process');
+ $this->consecutiveBounceHandler->expects($this->never())->method('handle');
+
+ $this->commandTester->execute([]);
+
+ $output = $this->commandTester->getDisplay();
+ $this->assertStringContainsString('Another bounce processing is already running. Aborting.', $output);
+ $this->assertSame(0, $this->commandTester->getStatusCode());
+ }
+
+ public function testExecuteWithUnsupportedProtocol(): void
+ {
+ $this->lockService
+ ->expects($this->once())
+ ->method('acquirePageLock')
+ ->with('bounce_processor', false)
+ ->willReturn(123);
+ $this->lockService
+ ->expects($this->once())
+ ->method('release')
+ ->with(123);
+
+ $this->protocolProcessor->method('getProtocol')->willReturn('pop');
+ $this->protocolProcessor->expects($this->never())->method('process');
+
+ $this->commandTester->execute([
+ '--protocol' => 'mbox',
+ ]);
+
+ $output = $this->commandTester->getDisplay();
+ $this->assertStringContainsString('Unsupported protocol: mbox', $output);
+ $this->assertSame(1, $this->commandTester->getStatusCode());
+ }
+
+ public function testSuccessfulProcessingFlow(): void
+ {
+ $this->lockService
+ ->expects($this->once())
+ ->method('acquirePageLock')
+ ->with('bounce_processor', false)
+ ->willReturn(456);
+ $this->lockService
+ ->expects($this->once())
+ ->method('release')
+ ->with(456);
+
+ $this->protocolProcessor->method('getProtocol')->willReturn('pop');
+ $this->protocolProcessor
+ ->expects($this->once())
+ ->method('process')
+ ->with(
+ $this->callback(function ($input) {
+ return $input->getOption('protocol') === 'pop'
+ && $input->getOption('test') === false
+ && $input->getOption('purge-unprocessed') === false;
+ }),
+ $this->anything()
+ )
+ ->willReturn('downloaded 10 messages');
+
+ $this->unidentifiedReprocessor
+ ->expects($this->once())
+ ->method('process')
+ ->with($this->anything());
+
+ $this->advancedRulesProcessor
+ ->expects($this->once())
+ ->method('process')
+ ->with($this->anything(), 1000);
+
+ $this->consecutiveBounceHandler
+ ->expects($this->once())
+ ->method('handle')
+ ->with($this->anything());
+
+ $this->logger
+ ->expects($this->once())
+ ->method('info')
+ ->with('Bounce processing completed', $this->arrayHasKey('downloadReport'));
+
+ $this->commandTester->execute([]);
+
+ $output = $this->commandTester->getDisplay();
+ $this->assertStringContainsString('Bounce processing completed.', $output);
+ $this->assertSame(0, $this->commandTester->getStatusCode());
+ }
+
+ public function testProcessingFlowWhenProcessorThrowsException(): void
+ {
+ $this->lockService
+ ->expects($this->once())
+ ->method('acquirePageLock')
+ ->with('bounce_processor', false)
+ ->willReturn(42);
+ $this->lockService
+ ->expects($this->once())
+ ->method('release')
+ ->with(42);
+
+ $this->protocolProcessor->method('getProtocol')->willReturn('pop');
+
+ $this->protocolProcessor
+ ->expects($this->once())
+ ->method('process')
+ ->willThrowException(new Exception('boom'));
+
+ $this->unidentifiedReprocessor->expects($this->never())->method('process');
+ $this->advancedRulesProcessor->expects($this->never())->method('process');
+ $this->consecutiveBounceHandler->expects($this->never())->method('handle');
+
+ $this->logger
+ ->expects($this->once())
+ ->method('error')
+ ->with('Bounce processing failed', $this->arrayHasKey('exception'));
+
+ $this->commandTester->execute([]);
+
+ $output = $this->commandTester->getDisplay();
+ $this->assertStringContainsString('Error: boom', $output);
+ $this->assertSame(1, $this->commandTester->getStatusCode());
+ }
+
+ public function testForceOptionIsPassedToLockService(): void
+ {
+ $this->lockService->expects($this->once())
+ ->method('acquirePageLock')
+ ->with('bounce_processor', true)
+ ->willReturn(1);
+ $this->protocolProcessor->method('getProtocol')->willReturn('pop');
+
+ $this->commandTester->execute([
+ '--force' => true,
+ ]);
+
+ $this->assertSame(0, $this->commandTester->getStatusCode());
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php
index 489b5d60..79ece9bd 100644
--- a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php
+++ b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php
@@ -8,8 +8,8 @@
use PhpList\Core\Domain\Messaging\Command\ProcessQueueCommand;
use PhpList\Core\Domain\Messaging\Model\Message;
use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
-use PhpList\Core\Domain\Messaging\Service\CampaignProcessor;
use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator;
+use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
diff --git a/tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php b/tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php
new file mode 100644
index 00000000..49d4aadb
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php
@@ -0,0 +1,66 @@
+fooHandler = $this->createMock(BounceActionHandlerInterface::class);
+ $this->barHandler = $this->createMock(BounceActionHandlerInterface::class);
+ $this->fooHandler->method('supports')->willReturnCallback(fn ($action) => $action === 'foo');
+ $this->barHandler->method('supports')->willReturnCallback(fn ($action) => $action === 'bar');
+
+ $this->resolver = new BounceActionResolver(
+ [
+ $this->fooHandler,
+ $this->barHandler,
+ ]
+ );
+ }
+
+ public function testHasReturnsTrueWhenHandlerSupportsAction(): void
+ {
+ $this->assertTrue($this->resolver->has('foo'));
+ $this->assertTrue($this->resolver->has('bar'));
+ $this->assertFalse($this->resolver->has('baz'));
+ }
+
+ public function testResolveReturnsSameInstanceAndCaches(): void
+ {
+ $first = $this->resolver->resolve('foo');
+ $second = $this->resolver->resolve('foo');
+
+ $this->assertSame($first, $second);
+
+ $this->assertInstanceOf(BounceActionHandlerInterface::class, $first);
+ }
+
+ public function testResolveThrowsWhenNoHandlerFound(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('No handler found for action "baz".');
+
+ $this->resolver->resolve('baz');
+ }
+
+ public function testHandleDelegatesToResolvedHandler(): void
+ {
+ $context = ['key' => 'value', 'n' => 42];
+ $this->fooHandler->expects($this->once())->method('handle');
+ $this->barHandler->expects($this->never())->method('handle');
+ $this->resolver->handle('foo', $context);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php
new file mode 100644
index 00000000..1cb1b6d2
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php
@@ -0,0 +1,212 @@
+bounceManager = $this->createMock(BounceManager::class);
+ $this->subscriberRepository = $this->createMock(SubscriberRepository::class);
+ $this->subscriberHistoryManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->blacklistService = $this->createMock(SubscriberBlacklistService::class);
+ $this->io = $this->createMock(SymfonyStyle::class);
+
+ $this->io->method('section');
+ $this->io->method('writeln');
+
+ $unsubscribeThreshold = 2;
+ $blacklistThreshold = 3;
+
+ $this->handler = new ConsecutiveBounceHandler(
+ bounceManager: $this->bounceManager,
+ subscriberRepository: $this->subscriberRepository,
+ subscriberHistoryManager: $this->subscriberHistoryManager,
+ blacklistService: $this->blacklistService,
+ unsubscribeThreshold: $unsubscribeThreshold,
+ blacklistThreshold: $blacklistThreshold,
+ );
+ }
+
+ public function testHandleWithNoUsers(): void
+ {
+ $this->subscriberRepository
+ ->expects($this->once())
+ ->method('distinctUsersWithBouncesConfirmedNotBlacklisted')
+ ->willReturn([]);
+
+ $this->io->expects($this->once())->method('section')->with('Identifying consecutive bounces');
+ $this->io->expects($this->once())->method('writeln')->with('Nothing to do');
+
+ $this->handler->handle($this->io);
+ }
+
+ public function testUnsubscribeAtThresholdAddsHistoryAndMarksUnconfirmedOnce(): void
+ {
+ $user = $this->makeSubscriber(123);
+ $this->subscriberRepository
+ ->method('distinctUsersWithBouncesConfirmedNotBlacklisted')
+ ->willReturn([$user]);
+
+ $history = [
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(1)],
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(2)],
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(0)],
+ ];
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('getUserMessageHistoryWithBounces')
+ ->with($user)
+ ->willReturn($history);
+
+ $this->subscriberRepository
+ ->expects($this->once())
+ ->method('markUnconfirmed')
+ ->with(123);
+
+ $this->subscriberHistoryManager
+ ->expects($this->once())
+ ->method('addHistory')
+ ->with(
+ $user,
+ 'Auto Unconfirmed',
+ $this->stringContains('2 consecutive bounces')
+ );
+
+ $this->blacklistService->expects($this->never())->method('blacklist');
+
+ $this->io->expects($this->once())->method('section')->with('Identifying consecutive bounces');
+ $this->io->expects($this->once())->method('writeln')->with('total of 1 subscribers processed');
+
+ $this->handler->handle($this->io);
+ }
+
+ public function testBlacklistAtThresholdStopsProcessingAndAlsoUnsubscribesIfReached(): void
+ {
+ $user = $this->makeSubscriber(7);
+ $this->subscriberRepository
+ ->method('distinctUsersWithBouncesConfirmedNotBlacklisted')
+ ->willReturn([$user]);
+
+ $history = [
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(11)],
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(12)],
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(13)],
+ // Any further entries should be ignored after blacklist stop
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(14)],
+ ];
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('getUserMessageHistoryWithBounces')
+ ->with($user)
+ ->willReturn($history);
+
+ // Unsubscribe reached at 2
+ $this->subscriberRepository
+ ->expects($this->once())
+ ->method('markUnconfirmed')
+ ->with(7);
+
+ $this->subscriberHistoryManager
+ ->expects($this->once())
+ ->method('addHistory')
+ ->with(
+ $user,
+ 'Auto Unconfirmed',
+ $this->stringContains('consecutive bounces')
+ );
+
+ // Blacklist at 3
+ $this->blacklistService
+ ->expects($this->once())
+ ->method('blacklist')
+ ->with(
+ $user,
+ $this->stringContains('3 consecutive bounces')
+ );
+
+ $this->handler->handle($this->io);
+ }
+
+ public function testDuplicateBouncesAreIgnoredInCounting(): void
+ {
+ $user = $this->makeSubscriber(55);
+ $this->subscriberRepository->method('distinctUsersWithBouncesConfirmedNotBlacklisted')->willReturn([$user]);
+
+ // First is duplicate (by status), ignored; then two real => unsubscribe triggered once
+ $history = [
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(101, status: 'DUPLICATE bounce')],
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(102, comment: 'ok')],
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(103)],
+ ];
+ $this->bounceManager->method('getUserMessageHistoryWithBounces')->willReturn($history);
+
+ $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(55);
+ $this->subscriberHistoryManager->expects($this->once())->method('addHistory')->with(
+ $user,
+ 'Auto Unconfirmed',
+ $this->stringContains('2 consecutive bounces')
+ );
+ $this->blacklistService->expects($this->never())->method('blacklist');
+
+ $this->handler->handle($this->io);
+ }
+
+ public function testBreaksOnBounceWithoutRealId(): void
+ {
+ $user = $this->makeSubscriber(77);
+ $this->subscriberRepository->method('distinctUsersWithBouncesConfirmedNotBlacklisted')->willReturn([$user]);
+
+ // The first entry has null bounce (no real id) => processing for the user stops immediately; no actions
+ $history = [
+ ['um' => null, 'umb' => null, 'b' => null],
+ // should not be reached
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(1)],
+ ];
+ $this->bounceManager->method('getUserMessageHistoryWithBounces')->willReturn($history);
+
+ $this->subscriberRepository->expects($this->never())->method('markUnconfirmed');
+ $this->subscriberHistoryManager->expects($this->never())->method('addHistory');
+ $this->blacklistService->expects($this->never())->method('blacklist');
+
+ $this->handler->handle($this->io);
+ }
+
+ private function makeSubscriber(int $id): Subscriber
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $subscriber->method('getId')->willReturn($id);
+
+ return $subscriber;
+ }
+
+ private function makeBounce(int $id, ?string $status = null, ?string $comment = null): Bounce
+ {
+ $bounce = $this->createMock(Bounce::class);
+ $bounce->method('getId')->willReturn($id);
+ $bounce->method('getStatus')->willReturn($status);
+ $bounce->method('getComment')->willReturn($comment);
+
+ return $bounce;
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php b/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php
index 9409320b..950f1021 100644
--- a/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php
+++ b/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php
@@ -19,12 +19,18 @@ class EmailServiceTest extends TestCase
private MailerInterface&MockObject $mailer;
private MessageBusInterface&MockObject $messageBus;
private string $defaultFromEmail = 'default@example.com';
+ private string $bounceEmail = 'bounce@example.com';
protected function setUp(): void
{
$this->mailer = $this->createMock(MailerInterface::class);
$this->messageBus = $this->createMock(MessageBusInterface::class);
- $this->emailService = new EmailService($this->mailer, $this->defaultFromEmail, $this->messageBus);
+ $this->emailService = new EmailService(
+ mailer: $this->mailer,
+ messageBus: $this->messageBus,
+ defaultFromEmail: $this->defaultFromEmail,
+ bounceEmail: $this->bounceEmail,
+ );
}
public function testSendEmailWithDefaultFrom(): void
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php
new file mode 100644
index 00000000..8f5cdb11
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php
@@ -0,0 +1,78 @@
+historyManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->bounceManager = $this->createMock(BounceManager::class);
+ $this->blacklistService = $this->createMock(SubscriberBlacklistService::class);
+ $this->handler = new BlacklistEmailAndDeleteBounceHandler(
+ subscriberHistoryManager: $this->historyManager,
+ bounceManager: $this->bounceManager,
+ blacklistService: $this->blacklistService,
+ );
+ }
+
+ public function testSupportsOnlyBlacklistEmailAndDeleteBounce(): void
+ {
+ $this->assertTrue($this->handler->supports('blacklistemailanddeletebounce'));
+ $this->assertFalse($this->handler->supports('blacklistemail'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleBlacklistsAddsHistoryAndDeletesBounceWhenSubscriberPresent(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->blacklistService->expects($this->once())->method('blacklist')->with(
+ $subscriber,
+ $this->stringContains('Email address auto blacklisted by bounce rule 9')
+ );
+ $this->historyManager->expects($this->once())->method('addHistory')->with(
+ $subscriber,
+ 'Auto Unsubscribed',
+ $this->stringContains('User auto unsubscribed for bounce rule 9')
+ );
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'ruleId' => 9,
+ 'bounce' => $bounce,
+ ]);
+ }
+
+ public function testHandleSkipsBlacklistAndHistoryWhenNoSubscriberButDeletesBounce(): void
+ {
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->blacklistService->expects($this->never())->method('blacklist');
+ $this->historyManager->expects($this->never())->method('addHistory');
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'ruleId' => 9,
+ 'bounce' => $bounce,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php
new file mode 100644
index 00000000..54f7362b
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php
@@ -0,0 +1,73 @@
+historyManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->blacklistService = $this->createMock(SubscriberBlacklistService::class);
+ $this->handler = new BlacklistEmailHandler(
+ subscriberHistoryManager: $this->historyManager,
+ blacklistService: $this->blacklistService,
+ );
+ }
+
+ public function testSupportsOnlyBlacklistEmail(): void
+ {
+ $this->assertTrue($this->handler->supports('blacklistemail'));
+ $this->assertFalse($this->handler->supports('blacklistuser'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleBlacklistsAndAddsHistoryWhenSubscriberPresent(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+
+ $this->blacklistService
+ ->expects($this->once())
+ ->method('blacklist')
+ ->with(
+ $subscriber,
+ $this->stringContains('Email address auto blacklisted by bounce rule 42')
+ );
+
+ $this->historyManager
+ ->expects($this->once())
+ ->method('addHistory')
+ ->with(
+ $subscriber,
+ 'Auto Unsubscribed',
+ $this->stringContains('email auto unsubscribed for bounce rule 42')
+ );
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'ruleId' => 42,
+ ]);
+ }
+
+ public function testHandleDoesNothingWhenNoSubscriber(): void
+ {
+ $this->blacklistService->expects($this->never())->method('blacklist');
+ $this->historyManager->expects($this->never())->method('addHistory');
+
+ $this->handler->handle([
+ 'ruleId' => 1,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php
new file mode 100644
index 00000000..af1df32e
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php
@@ -0,0 +1,90 @@
+historyManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->bounceManager = $this->createMock(BounceManager::class);
+ $this->blacklistService = $this->createMock(SubscriberBlacklistService::class);
+ $this->handler = new BlacklistUserAndDeleteBounceHandler(
+ subscriberHistoryManager: $this->historyManager,
+ bounceManager: $this->bounceManager,
+ blacklistService: $this->blacklistService,
+ );
+ }
+
+ public function testSupportsOnlyBlacklistUserAndDeleteBounce(): void
+ {
+ $this->assertTrue($this->handler->supports('blacklistuseranddeletebounce'));
+ $this->assertFalse($this->handler->supports('blacklistuser'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleBlacklistsAddsHistoryAndDeletesBounceWhenSubscriberPresentAndNotBlacklisted(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->blacklistService->expects($this->once())->method('blacklist')->with(
+ $subscriber,
+ $this->stringContains('Subscriber auto blacklisted by bounce rule 13')
+ );
+ $this->historyManager->expects($this->once())->method('addHistory')->with(
+ $subscriber,
+ 'Auto Unsubscribed',
+ $this->stringContains('User auto unsubscribed for bounce rule 13')
+ );
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'blacklisted' => false,
+ 'ruleId' => 13,
+ 'bounce' => $bounce,
+ ]);
+ }
+
+ public function testHandleSkipsBlacklistAndHistoryWhenNoSubscriberOrAlreadyBlacklistedButDeletesBounce(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->blacklistService->expects($this->never())->method('blacklist');
+ $this->historyManager->expects($this->never())->method('addHistory');
+ $this->bounceManager->expects($this->exactly(2))->method('delete')->with($bounce);
+
+ // Already blacklisted
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'blacklisted' => true,
+ 'ruleId' => 13,
+ 'bounce' => $bounce,
+ ]);
+
+ // No subscriber
+ $this->handler->handle([
+ 'blacklisted' => false,
+ 'ruleId' => 13,
+ 'bounce' => $bounce,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php
new file mode 100644
index 00000000..72fe4584
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php
@@ -0,0 +1,84 @@
+historyManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->blacklistService = $this->createMock(SubscriberBlacklistService::class);
+ $this->handler = new BlacklistUserHandler(
+ subscriberHistoryManager: $this->historyManager,
+ blacklistService: $this->blacklistService
+ );
+ }
+
+ public function testSupportsOnlyBlacklistUser(): void
+ {
+ $this->assertTrue($this->handler->supports('blacklistuser'));
+ $this->assertFalse($this->handler->supports('unconfirmuser'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleBlacklistsAndAddsHistoryWhenSubscriberPresentAndNotBlacklisted(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+
+ $this->blacklistService
+ ->expects($this->once())
+ ->method('blacklist')
+ ->with(
+ $subscriber,
+ $this->stringContains('bounce rule 17')
+ );
+
+ $this->historyManager
+ ->expects($this->once())
+ ->method('addHistory')
+ ->with(
+ $subscriber,
+ 'Auto Unsubscribed',
+ $this->stringContains('bounce rule 17')
+ );
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'blacklisted' => false,
+ 'ruleId' => 17,
+ ]);
+ }
+
+ public function testHandleDoesNothingWhenAlreadyBlacklistedOrNoSubscriber(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $this->blacklistService->expects($this->never())->method('blacklist');
+ $this->historyManager->expects($this->never())->method('addHistory');
+
+ // Already blacklisted
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'blacklisted' => true,
+ 'ruleId' => 5,
+ ]);
+
+ // No subscriber provided
+ $this->handler->handle([
+ 'blacklisted' => false,
+ 'ruleId' => 5,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php
new file mode 100644
index 00000000..7d82336f
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php
@@ -0,0 +1,103 @@
+historyManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->subscriberManager = $this->createMock(SubscriberManager::class);
+ $this->bounceManager = $this->createMock(BounceManager::class);
+ $this->subscriberRepository = $this->createMock(SubscriberRepository::class);
+ $this->handler = new DecreaseCountConfirmUserAndDeleteBounceHandler(
+ subscriberHistoryManager: $this->historyManager,
+ subscriberManager: $this->subscriberManager,
+ bounceManager: $this->bounceManager,
+ subscriberRepository: $this->subscriberRepository,
+ );
+ }
+
+ public function testSupportsOnlyDecreaseCountConfirmUserAndDeleteBounce(): void
+ {
+ $this->assertTrue($this->handler->supports('decreasecountconfirmuseranddeletebounce'));
+ $this->assertFalse($this->handler->supports('deleteuser'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleDecrementsMarksConfirmedAddsHistoryAndDeletesWhenNotConfirmed(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->subscriberManager->expects($this->once())->method('decrementBounceCount')->with($subscriber);
+ $this->subscriberRepository->expects($this->once())->method('markConfirmed')->with(11);
+ $this->historyManager->expects($this->once())->method('addHistory')->with(
+ $subscriber,
+ 'Auto confirmed',
+ $this->stringContains('bounce rule 77')
+ );
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'userId' => 11,
+ 'confirmed' => false,
+ 'ruleId' => 77,
+ 'bounce' => $bounce,
+ ]);
+ }
+
+ public function testHandleOnlyDecrementsAndDeletesWhenAlreadyConfirmed(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->subscriberManager->expects($this->once())->method('decrementBounceCount')->with($subscriber);
+ $this->subscriberRepository->expects($this->never())->method('markConfirmed');
+ $this->historyManager->expects($this->never())->method('addHistory');
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'userId' => 11,
+ 'confirmed' => true,
+ 'ruleId' => 77,
+ 'bounce' => $bounce,
+ ]);
+ }
+
+ public function testHandleDeletesBounceEvenWithoutSubscriber(): void
+ {
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->subscriberManager->expects($this->never())->method('decrementBounceCount');
+ $this->subscriberRepository->expects($this->never())->method('markConfirmed');
+ $this->historyManager->expects($this->never())->method('addHistory');
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'confirmed' => true,
+ 'ruleId' => 1,
+ 'bounce' => $bounce,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php
new file mode 100644
index 00000000..25028345
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php
@@ -0,0 +1,40 @@
+bounceManager = $this->createMock(BounceManager::class);
+ $this->handler = new DeleteBounceHandler($this->bounceManager);
+ }
+
+ public function testSupportsOnlyDeleteBounce(): void
+ {
+ $this->assertTrue($this->handler->supports('deletebounce'));
+ $this->assertFalse($this->handler->supports('deleteuser'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleDeletesBounce(): void
+ {
+ $bounce = $this->createMock(Bounce::class);
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'bounce' => $bounce,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php
new file mode 100644
index 00000000..0d68b631
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php
@@ -0,0 +1,63 @@
+bounceManager = $this->createMock(BounceManager::class);
+ $this->subscriberManager = $this->createMock(SubscriberManager::class);
+ $this->handler = new DeleteUserAndBounceHandler(
+ bounceManager: $this->bounceManager,
+ subscriberManager: $this->subscriberManager
+ );
+ }
+
+ public function testSupportsOnlyDeleteUserAndBounce(): void
+ {
+ $this->assertTrue($this->handler->supports('deleteuserandbounce'));
+ $this->assertFalse($this->handler->supports('deleteuser'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleDeletesUserWhenPresentAndAlwaysDeletesBounce(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->subscriberManager->expects($this->once())->method('deleteSubscriber')->with($subscriber);
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'bounce' => $bounce,
+ ]);
+ }
+
+ public function testHandleSkipsUserDeletionWhenNoSubscriberButDeletesBounce(): void
+ {
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->subscriberManager->expects($this->never())->method('deleteSubscriber');
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'bounce' => $bounce,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php
new file mode 100644
index 00000000..427f8146
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php
@@ -0,0 +1,71 @@
+subscriberManager = $this->createMock(SubscriberManager::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->handler = new DeleteUserHandler(subscriberManager: $this->subscriberManager, logger: $this->logger);
+ }
+
+ public function testSupportsOnlyDeleteUser(): void
+ {
+ $this->assertTrue($this->handler->supports('deleteuser'));
+ $this->assertFalse($this->handler->supports('deleteuserandbounce'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleLogsAndDeletesWhenSubscriberPresent(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $subscriber->method('getEmail')->willReturn('user@example.com');
+
+ $this->logger
+ ->expects($this->once())
+ ->method('info')
+ ->with(
+ 'User deleted by bounce rule',
+ $this->callback(function ($context) {
+ return isset($context['user'], $context['rule'])
+ && $context['user'] === 'user@example.com'
+ && $context['rule'] === 42;
+ })
+ );
+
+ $this->subscriberManager
+ ->expects($this->once())
+ ->method('deleteSubscriber')
+ ->with($subscriber);
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'ruleId' => 42,
+ ]);
+ }
+
+ public function testHandleDoesNothingWhenNoSubscriber(): void
+ {
+ $this->logger->expects($this->never())->method('info');
+ $this->subscriberManager->expects($this->never())->method('deleteSubscriber');
+
+ $this->handler->handle([
+ 'ruleId' => 1,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php
new file mode 100644
index 00000000..7a4ac245
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php
@@ -0,0 +1,90 @@
+historyManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->subscriberRepository = $this->createMock(SubscriberRepository::class);
+ $this->bounceManager = $this->createMock(BounceManager::class);
+ $this->handler = new UnconfirmUserAndDeleteBounceHandler(
+ subscriberHistoryManager: $this->historyManager,
+ subscriberRepository: $this->subscriberRepository,
+ bounceManager: $this->bounceManager,
+ );
+ }
+
+ public function testSupportsOnlyUnconfirmUserAndDeleteBounce(): void
+ {
+ $this->assertTrue($this->handler->supports('unconfirmuseranddeletebounce'));
+ $this->assertFalse($this->handler->supports('unconfirmuser'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleUnconfirmsAndAddsHistoryAndDeletesBounce(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(10);
+ $this->historyManager->expects($this->once())->method('addHistory')->with(
+ $subscriber,
+ 'Auto unconfirmed',
+ $this->stringContains('bounce rule 3')
+ );
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'userId' => 10,
+ 'confirmed' => true,
+ 'ruleId' => 3,
+ 'bounce' => $bounce,
+ ]);
+ }
+
+ public function testHandleDeletesBounceAndSkipsUnconfirmWhenNotConfirmedOrNoSubscriber(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->subscriberRepository->expects($this->never())->method('markUnconfirmed');
+ $this->historyManager->expects($this->never())->method('addHistory');
+ $this->bounceManager->expects($this->exactly(2))->method('delete')->with($bounce);
+
+ // Not confirmed
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'userId' => 10,
+ 'confirmed' => false,
+ 'ruleId' => 3,
+ 'bounce' => $bounce,
+ ]);
+
+ // No subscriber
+ $this->handler->handle([
+ 'userId' => 10,
+ 'confirmed' => true,
+ 'ruleId' => 3,
+ 'bounce' => $bounce,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php
new file mode 100644
index 00000000..a395e110
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php
@@ -0,0 +1,77 @@
+subscriberRepository = $this->createMock(SubscriberRepository::class);
+ $this->historyManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->handler = new UnconfirmUserHandler(
+ subscriberRepository: $this->subscriberRepository,
+ subscriberHistoryManager: $this->historyManager
+ );
+ }
+
+ public function testSupportsOnlyUnconfirmUser(): void
+ {
+ $this->assertTrue($this->handler->supports('unconfirmuser'));
+ $this->assertFalse($this->handler->supports('blacklistuser'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleMarksUnconfirmedAndAddsHistoryWhenSubscriberPresentAndConfirmed(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+
+ $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(123);
+ $this->historyManager->expects($this->once())->method('addHistory')->with(
+ $subscriber,
+ 'Auto Unconfirmed',
+ $this->stringContains('bounce rule 9')
+ );
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'userId' => 123,
+ 'confirmed' => true,
+ 'ruleId' => 9,
+ ]);
+ }
+
+ public function testHandleDoesNothingWhenNotConfirmedOrNoSubscriber(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $this->subscriberRepository->expects($this->never())->method('markUnconfirmed');
+ $this->historyManager->expects($this->never())->method('addHistory');
+
+ // Not confirmed
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'userId' => 44,
+ 'confirmed' => false,
+ 'ruleId' => 1,
+ ]);
+
+ // No subscriber
+ $this->handler->handle([
+ 'userId' => 44,
+ 'confirmed' => true,
+ 'ruleId' => 1,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/LockServiceTest.php b/tests/Unit/Domain/Messaging/Service/LockServiceTest.php
new file mode 100644
index 00000000..8851d7de
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/LockServiceTest.php
@@ -0,0 +1,88 @@
+repo = $this->createMock(SendProcessRepository::class);
+ $this->manager = $this->createMock(SendProcessManager::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ }
+
+ public function testAcquirePageLockCreatesProcessWhenBelowMax(): void
+ {
+ $service = new LockService($this->repo, $this->manager, $this->logger, 600, 0, 0);
+
+ $this->repo->method('countAliveByPage')->willReturn(0);
+ $this->manager->method('findNewestAliveWithAge')->willReturn(null);
+
+ $sendProcess = $this->createConfiguredMock(SendProcess::class, ['getId' => 42]);
+ $this->manager->expects($this->once())
+ ->method('create')
+ ->with('mypage', $this->callback(fn(string $id) => $id !== ''))
+ ->willReturn($sendProcess);
+
+ $id = $service->acquirePageLock('my page');
+ $this->assertSame(42, $id);
+ }
+
+ public function testAcquirePageLockReturnsNullWhenAtMaxInCli(): void
+ {
+ $service = new LockService($this->repo, $this->manager, $this->logger, 600, 0, 0);
+
+ $this->repo->method('countAliveByPage')->willReturn(1);
+ $this->manager->method('findNewestAliveWithAge')->willReturn(['age' => 1, 'id' => 10]);
+
+ $this->logger->expects($this->atLeastOnce())->method('info');
+ $id = $service->acquirePageLock('page', false, true, false, 1);
+ $this->assertNull($id);
+ }
+
+ public function testAcquirePageLockStealsStale(): void
+ {
+ $service = new LockService($this->repo, $this->manager, $this->logger, 1, 0, 0);
+
+ $this->repo->expects($this->exactly(2))->method('countAliveByPage')->willReturnOnConsecutiveCalls(1, 0);
+ $this->manager
+ ->expects($this->exactly(2))
+ ->method('findNewestAliveWithAge')
+ ->willReturnOnConsecutiveCalls(['age' => 5, 'id' => 10], null);
+ $this->repo->expects($this->once())->method('markDeadById')->with(10);
+
+ $sendProcess = $this->createConfiguredMock(SendProcess::class, ['getId' => 99]);
+ $this->manager->method('create')->willReturn($sendProcess);
+
+ $id = $service->acquirePageLock('page', false, true);
+ $this->assertSame(99, $id);
+ }
+
+ public function testKeepCheckReleaseDelegatesToRepo(): void
+ {
+ $service = new LockService($this->repo, $this->manager, $this->logger);
+
+ $this->repo->expects($this->once())->method('incrementAlive')->with(5);
+ $service->keepLock(5);
+
+ $this->repo->expects($this->once())->method('getAliveValue')->with(5)->willReturn(7);
+ $this->assertSame(7, $service->checkLock(5));
+
+ $this->repo->expects($this->once())->method('markDeadById')->with(5);
+ $service->release(5);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php
new file mode 100644
index 00000000..bd1a4a68
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php
@@ -0,0 +1,205 @@
+repository = $this->createMock(BounceRepository::class);
+ $this->userMessageBounceRepository = $this->createMock(UserMessageBounceRepository::class);
+ $this->entityManager = $this->createMock(EntityManagerInterface::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->manager = new BounceManager(
+ bounceRepository: $this->repository,
+ userMessageBounceRepo: $this->userMessageBounceRepository,
+ entityManager: $this->entityManager,
+ logger: $this->logger,
+ );
+ }
+
+ public function testCreatePersistsAndReturnsBounce(): void
+ {
+ $date = new DateTimeImmutable('2020-01-01 00:00:00');
+ $header = 'X-Test: Header';
+ $data = 'raw bounce';
+ $status = 'new';
+ $comment = 'created by test';
+
+ $this->repository->expects($this->once())
+ ->method('save')
+ ->with($this->isInstanceOf(Bounce::class));
+
+ $bounce = $this->manager->create(
+ date: $date,
+ header: $header,
+ data: $data,
+ status: $status,
+ comment: $comment
+ );
+
+ $this->assertInstanceOf(Bounce::class, $bounce);
+ $this->assertSame($date->format('Y-m-d h:m:s'), $bounce->getDate()->format('Y-m-d h:m:s'));
+ $this->assertSame($header, $bounce->getHeader());
+ $this->assertSame($data, $bounce->getData());
+ $this->assertSame($status, $bounce->getStatus());
+ $this->assertSame($comment, $bounce->getComment());
+ }
+
+ public function testDeleteDelegatesToRepository(): void
+ {
+ $model = new Bounce();
+
+ $this->repository->expects($this->once())
+ ->method('remove')
+ ->with($model);
+
+ $this->manager->delete($model);
+ }
+
+ public function testGetAllReturnsArray(): void
+ {
+ $expected = [new Bounce(), new Bounce()];
+
+ $this->repository->expects($this->once())
+ ->method('findAll')
+ ->willReturn($expected);
+
+ $this->assertSame($expected, $this->manager->getAll());
+ }
+
+ public function testGetByIdReturnsBounce(): void
+ {
+ $expected = new Bounce();
+
+ $this->repository->expects($this->once())
+ ->method('find')
+ ->with(123)
+ ->willReturn($expected);
+
+ $this->assertSame($expected, $this->manager->getById(123));
+ }
+
+ public function testGetByIdReturnsNullWhenNotFound(): void
+ {
+ $this->repository->expects($this->once())
+ ->method('find')
+ ->with(999)
+ ->willReturn(null);
+
+ $this->assertNull($this->manager->getById(999));
+ }
+
+ public function testUpdateChangesFieldsAndSaves(): void
+ {
+ $bounce = new Bounce();
+ $this->repository->expects($this->once())
+ ->method('save')
+ ->with($bounce);
+
+ $updated = $this->manager->update($bounce, 'processed', 'done');
+ $this->assertSame($bounce, $updated);
+ $this->assertSame('processed', $bounce->getStatus());
+ $this->assertSame('done', $bounce->getComment());
+ }
+
+ public function testLinkUserMessageBounceFlushesAndSetsFields(): void
+ {
+ $bounce = $this->createMock(Bounce::class);
+ $bounce->method('getId')->willReturn(77);
+
+ $this->entityManager->expects($this->once())->method('flush');
+
+ $dt = new DateTimeImmutable('2024-05-01 12:34:56');
+ $umb = $this->manager->linkUserMessageBounce($bounce, $dt, 123, 456);
+
+ $this->assertSame(77, $umb->getBounceId());
+ $this->assertSame(123, $umb->getUserId());
+ $this->assertSame(456, $umb->getMessageId());
+ }
+
+ public function testExistsUserMessageBounceDelegatesToRepo(): void
+ {
+ $this->userMessageBounceRepository->expects($this->once())
+ ->method('existsByMessageIdAndUserId')
+ ->with(456, 123)
+ ->willReturn(true);
+
+ $this->assertTrue($this->manager->existsUserMessageBounce(123, 456));
+ }
+
+ public function testFindByStatusDelegatesToRepository(): void
+ {
+ $b1 = new Bounce();
+ $b2 = new Bounce();
+ $this->repository->expects($this->once())
+ ->method('findByStatus')
+ ->with('new')
+ ->willReturn([$b1, $b2]);
+
+ $this->assertSame([$b1, $b2], $this->manager->findByStatus('new'));
+ }
+
+ public function testGetUserMessageBounceCount(): void
+ {
+ $this->userMessageBounceRepository->expects($this->once())
+ ->method('count')
+ ->willReturn(5);
+ $this->assertSame(5, $this->manager->getUserMessageBounceCount());
+ }
+
+ public function testFetchUserMessageBounceBatchDelegates(): void
+ {
+ $expected = [['umb' => new UserMessageBounce(1, new \DateTime()), 'bounce' => new Bounce()]];
+ $this->userMessageBounceRepository->expects($this->once())
+ ->method('getPaginatedWithJoinNoRelation')
+ ->with(10, 50)
+ ->willReturn($expected);
+ $this->assertSame($expected, $this->manager->fetchUserMessageBounceBatch(10, 50));
+ }
+
+ public function testGetUserMessageHistoryWithBouncesDelegates(): void
+ {
+ $subscriber = new Subscriber();
+ $expected = [];
+ $this->userMessageBounceRepository->expects($this->once())
+ ->method('getUserMessageHistoryWithBounces')
+ ->with($subscriber)
+ ->willReturn($expected);
+ $this->assertSame($expected, $this->manager->getUserMessageHistoryWithBounces($subscriber));
+ }
+
+ public function testAnnounceDeletionModeLogsCorrectMessage(): void
+ {
+ $this->logger->expects($this->exactly(2))
+ ->method('info')
+ ->withConsecutive([
+ 'Running in test mode, not deleting messages from mailbox'
+ ], [
+ 'Processed messages will be deleted from the mailbox'
+ ]);
+
+ $this->manager->announceDeletionMode(true);
+ $this->manager->announceDeletionMode(false);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php
index 1cd432bc..fd526a64 100644
--- a/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php
@@ -131,7 +131,7 @@ public function testAssociateBounceIncrementsCountAndPersistsRelation(): void
->method('persist')
->with($this->callback(function ($entity) use ($regex) {
return $entity instanceof BounceRegexBounce
- && $entity->getRegex() === $regex->getId();
+ && $entity->getRegexId() === $regex->getId();
}));
$this->entityManager->expects($this->once())
diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php
new file mode 100644
index 00000000..040f98a8
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php
@@ -0,0 +1,143 @@
+regexRepository = $this->createMock(BounceRegexRepository::class);
+ $this->relationRepository = $this->createMock(BounceRegexBounceRepository::class);
+ $this->manager = new BounceRuleManager(
+ repository: $this->regexRepository,
+ bounceRelationRepository: $this->relationRepository,
+ );
+ }
+
+ public function testLoadActiveRulesMapsRowsAndSkipsInvalid(): void
+ {
+ $valid = $this->createMock(BounceRegex::class);
+ $valid->method('getId')->willReturn(1);
+ $valid->method('getAction')->willReturn('delete');
+ $valid->method('getRegex')->willReturn('user unknown');
+ $valid->method('getRegexHash')->willReturn(md5('user unknown'));
+
+ $noRegex = $this->createMock(BounceRegex::class);
+ $noRegex->method('getId')->willReturn(2);
+
+ $noAction = $this->createMock(BounceRegex::class);
+ $noAction->method('getId')->willReturn(3);
+ $noAction->method('getRegex')->willReturn('pattern');
+ $noAction->method('getRegexHash')->willReturn(md5('pattern'));
+
+ $noId = $this->createMock(BounceRegex::class);
+ $noId->method('getRegex')->willReturn('has no id');
+ $noId->method('getRegexHash')->willReturn(md5('has no id'));
+ $noId->method('getAction')->willReturn('keep');
+
+ $this->regexRepository->expects($this->once())
+ ->method('fetchActiveOrdered')
+ ->willReturn([$valid, $noRegex, $noAction, $noId]);
+
+ $result = $this->manager->loadActiveRules();
+
+ $this->assertSame(['user unknown' => $valid], $result);
+ }
+
+ public function testLoadAllRulesDelegatesToRepository(): void
+ {
+ $rule1 = $this->createMock(BounceRegex::class);
+ $rule1->method('getId')->willReturn(10);
+ $rule1->method('getAction')->willReturn('keep');
+ $rule1->method('getRegex')->willReturn('a');
+ $rule1->method('getRegexHash')->willReturn(md5('a'));
+
+ $rule2 = $this->createMock(BounceRegex::class);
+ $rule2->method('getId')->willReturn(11);
+ $rule2->method('getAction')->willReturn('delete');
+ $rule2->method('getRegex')->willReturn('b');
+ $rule2->method('getRegexHash')->willReturn(md5('b'));
+
+ $this->regexRepository->expects($this->once())
+ ->method('fetchAllOrdered')
+ ->willReturn([$rule1, $rule2]);
+
+ $result = $this->manager->loadAllRules();
+ $this->assertSame(['a' => $rule1, 'b' => $rule2], $result);
+ }
+
+ public function testMatchBounceRulesMatchesQuotedAndRawAndHandlesInvalidPatterns(): void
+ {
+ $valid = $this->createMock(BounceRegex::class);
+ $valid->method('getId')->willReturn(1);
+ $valid->method('getAction')->willReturn('delete');
+ $valid->method('getRegex')->willReturn('user unknown');
+ $valid->method('getRegexHash')->willReturn(md5('user unknown'));
+
+ $invalid = $this->createMock(BounceRegex::class);
+ $invalid->method('getId')->willReturn(2);
+ $invalid->method('getAction')->willReturn('keep');
+ $invalid->method('getRegex')->willReturn('([a-z');
+ $invalid->method('getRegexHash')->willReturn(md5('([a-z'));
+
+ $rules = ['user unknown' => $valid, '([a-z' => $invalid];
+
+ $matched = $this->manager->matchBounceRules('Delivery failed: user unknown at example', $rules);
+ $this->assertSame($valid, $matched);
+
+ // Ensure an invalid pattern does not throw and simply not match
+ $matchedInvalid = $this->manager->matchBounceRules('something else', ['([a-z' => $invalid]);
+ $this->assertNull($matchedInvalid);
+ }
+
+ public function testIncrementCountPersists(): void
+ {
+ $rule = new BounceRegex(regex: 'x', regexHash: md5('x'), action: 'keep', count: 0);
+ $this->setId($rule, 5);
+
+ $this->regexRepository->expects($this->once())
+ ->method('save')
+ ->with($rule);
+
+ $this->manager->incrementCount($rule);
+ $this->assertSame(1, $rule->getCount());
+ }
+
+ public function testLinkRuleToBounceCreatesRelationAndSaves(): void
+ {
+ $rule = new BounceRegex(regex: 'y', regexHash: md5('y'), action: 'delete');
+ $bounce = new Bounce();
+ $this->setId($rule, 9);
+ $this->setId($bounce, 20);
+
+ $this->relationRepository->expects($this->once())
+ ->method('save')
+ ->with($this->isInstanceOf(BounceRegexBounce::class));
+
+ $relation = $this->manager->linkRuleToBounce($rule, $bounce);
+
+ $this->assertInstanceOf(BounceRegexBounce::class, $relation);
+ $this->assertSame(9, $relation->getRegexId());
+ }
+
+ private function setId(object $entity, int $id): void
+ {
+ $ref = new \ReflectionProperty($entity, 'id');
+ $ref->setValue($entity, $id);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php
new file mode 100644
index 00000000..e56f11ca
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php
@@ -0,0 +1,86 @@
+repository = $this->createMock(SendProcessRepository::class);
+ $this->em = $this->createMock(EntityManagerInterface::class);
+ $this->manager = new SendProcessManager($this->repository, $this->em);
+ }
+
+ public function testCreatePersistsEntityAndSetsFields(): void
+ {
+ $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(SendProcess::class));
+ $this->em->expects($this->once())->method('flush');
+
+ $sp = $this->manager->create('pageA', 'proc-1');
+ $this->assertInstanceOf(SendProcess::class, $sp);
+ $this->assertSame('pageA', $sp->getPage());
+ $this->assertSame('proc-1', $sp->getIpaddress());
+ $this->assertSame(1, $sp->getAlive());
+ $this->assertInstanceOf(DateTime::class, $sp->getStartedDate());
+ }
+
+ public function testFindNewestAliveWithAgeReturnsNullWhenNotFound(): void
+ {
+ $this->repository->expects($this->once())
+ ->method('findNewestAlive')
+ ->with('pageX')
+ ->willReturn(null);
+
+ $this->assertNull($this->manager->findNewestAliveWithAge('pageX'));
+ }
+
+ public function testFindNewestAliveWithAgeReturnsIdAndAge(): void
+ {
+ $model = new SendProcess();
+ // set id
+ $this->setId($model, 42);
+ // set updatedAt to now - 5 seconds
+ $updated = new \DateTime('now');
+ $updated->sub(new DateInterval('PT5S'));
+ $this->setUpdatedAt($model, $updated);
+
+ $this->repository->expects($this->once())
+ ->method('findNewestAlive')
+ ->with('pageY')
+ ->willReturn($model);
+
+ $result = $this->manager->findNewestAliveWithAge('pageY');
+
+ $this->assertIsArray($result);
+ $this->assertSame(42, $result['id']);
+ $this->assertGreaterThanOrEqual(0, $result['age']);
+ $this->assertLessThan(60, $result['age']);
+ }
+
+ private function setId(object $entity, int $id): void
+ {
+ $ref = new \ReflectionProperty($entity, 'id');
+ $ref->setValue($entity, $id);
+ }
+
+ private function setUpdatedAt(SendProcess $entity, \DateTime $dt): void
+ {
+ $ref = new \ReflectionProperty($entity, 'updatedAt');
+ $ref->setValue($entity, $dt);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php
index 7eb6afe7..93907f02 100644
--- a/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php
@@ -24,8 +24,8 @@ protected function setUp(): void
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->manager = new TemplateImageManager(
- $this->templateImageRepository,
- $this->entityManager
+ templateImageRepository: $this->templateImageRepository,
+ entityManager: $this->entityManager
);
}
diff --git a/tests/Unit/Domain/Messaging/Service/MessageParserTest.php b/tests/Unit/Domain/Messaging/Service/MessageParserTest.php
new file mode 100644
index 00000000..49b38615
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/MessageParserTest.php
@@ -0,0 +1,76 @@
+repo = $this->createMock(SubscriberRepository::class);
+ }
+
+ public function testDecodeBodyQuotedPrintable(): void
+ {
+ $parser = new MessageParser($this->repo);
+ $header = "Content-Transfer-Encoding: quoted-printable\r\n";
+ $body = 'Hello=20World';
+ $this->assertSame('Hello World', $parser->decodeBody($header, $body));
+ }
+
+ public function testDecodeBodyBase64(): void
+ {
+ $parser = new MessageParser($this->repo);
+ $header = "Content-Transfer-Encoding: base64\r\n";
+ $body = base64_encode('hi there');
+ $this->assertSame('hi there', $parser->decodeBody($header, $body));
+ }
+
+ public function testFindMessageId(): void
+ {
+ $parser = new MessageParser($this->repo);
+ $text = "X-MessageId: abc-123\r\nOther: x\r\n";
+ $this->assertSame('abc-123', $parser->findMessageId($text));
+ }
+
+ public function testFindUserIdWithHeaderNumeric(): void
+ {
+ $parser = new MessageParser($this->repo);
+ $text = "X-User: 77\r\n";
+ $this->assertSame(77, $parser->findUserId($text));
+ }
+
+ public function testFindUserIdWithHeaderEmailAndLookup(): void
+ {
+ $parser = new MessageParser($this->repo);
+ $subscriber = $this->createConfiguredMock(Subscriber::class, ['getId' => 55]);
+ $this->repo->method('findOneByEmail')->with('john@example.com')->willReturn($subscriber);
+ $text = "X-User: john@example.com\r\n";
+ $this->assertSame(55, $parser->findUserId($text));
+ }
+
+ public function testFindUserIdByScanningEmails(): void
+ {
+ $parser = new MessageParser($this->repo);
+ $subscriber = $this->createConfiguredMock(Subscriber::class, ['getId' => 88]);
+ $this->repo->method('findOneByEmail')->with('user@acme.com')->willReturn($subscriber);
+ $text = 'Hello bounce for user@acme.com, thanks';
+ $this->assertSame(88, $parser->findUserId($text));
+ }
+
+ public function testFindUserReturnsNullWhenNoMatches(): void
+ {
+ $parser = new MessageParser($this->repo);
+ $this->repo->method('findOneByEmail')->willReturn(null);
+ $this->assertNull($parser->findUserId('no users here'));
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php
new file mode 100644
index 00000000..209fb583
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php
@@ -0,0 +1,177 @@
+bounceManager = $this->createMock(BounceManager::class);
+ $this->ruleManager = $this->createMock(BounceRuleManager::class);
+ $this->actionResolver = $this->createMock(BounceActionResolver::class);
+ $this->subscriberManager = $this->createMock(SubscriberManager::class);
+ $this->io = $this->createMock(SymfonyStyle::class);
+ }
+
+ public function testNoActiveRules(): void
+ {
+ $this->io->expects($this->once())->method('section')->with('Processing bounces based on active bounce rules');
+ $this->ruleManager->method('loadActiveRules')->willReturn([]);
+ $this->io->expects($this->once())->method('writeln')->with('No active rules');
+
+ $processor = new AdvancedBounceRulesProcessor(
+ bounceManager: $this->bounceManager,
+ ruleManager: $this->ruleManager,
+ actionResolver: $this->actionResolver,
+ subscriberManager: $this->subscriberManager,
+ );
+
+ $processor->process($this->io, 100);
+ }
+
+ public function testProcessingWithMatchesAndNonMatches(): void
+ {
+ $rule1 = $this->createMock(BounceRegex::class);
+ $rule1->method('getId')->willReturn(10);
+ $rule1->method('getAction')->willReturn('blacklist');
+ $rule1->method('getCount')->willReturn(0);
+
+ $rule2 = $this->createMock(BounceRegex::class);
+ $rule2->method('getId')->willReturn(20);
+ $rule2->method('getAction')->willReturn('notify');
+ $rule2->method('getCount')->willReturn(0);
+
+ $rules = [$rule1, $rule2];
+ $this->ruleManager->method('loadActiveRules')->willReturn($rules);
+
+ $this->bounceManager->method('getUserMessageBounceCount')->willReturn(3);
+
+ $bounce1 = $this->createMock(Bounce::class);
+ $bounce1->method('getHeader')->willReturn('H1');
+ $bounce1->method('getData')->willReturn('D1');
+
+ $bounce2 = $this->createMock(Bounce::class);
+ $bounce2->method('getHeader')->willReturn('H2');
+ $bounce2->method('getData')->willReturn('D2');
+
+ $bounce3 = $this->createMock(Bounce::class);
+ $bounce3->method('getHeader')->willReturn('H3');
+ $bounce3->method('getData')->willReturn('D3');
+
+ $umb1 = $this->createMock(UserMessageBounce::class);
+ $umb1->method('getId')->willReturn(1);
+ $umb1->method('getUserId')->willReturn(111);
+
+ $umb2 = $this->createMock(UserMessageBounce::class);
+ $umb2->method('getId')->willReturn(2);
+ $umb2->method('getUserId')->willReturn(0);
+
+ $umb3 = $this->createMock(UserMessageBounce::class);
+ $umb3->method('getId')->willReturn(3);
+ $umb3->method('getUserId')->willReturn(222);
+
+ $this->bounceManager->method('fetchUserMessageBounceBatch')->willReturnOnConsecutiveCalls(
+ [ ['umb' => $umb1, 'bounce' => $bounce1], ['umb' => $umb2, 'bounce' => $bounce2] ],
+ [ ['umb' => $umb3, 'bounce' => $bounce3] ]
+ );
+
+ // Rule matches for first and third, not for second
+ $this->ruleManager->expects($this->exactly(3))
+ ->method('matchBounceRules')
+ ->willReturnCallback(function (string $text, array $r) use ($rules) {
+ $this->assertSame($rules, $r);
+ if ($text === 'H1' . "\n\n" . 'D1') {
+ return $rules[0];
+ }
+ if ($text === 'H2' . "\n\n" . 'D2') {
+ return null;
+ }
+ if ($text === 'H3' . "\n\n" . 'D3') {
+ return $rules[1];
+ }
+ $this->fail('Unexpected arguments to matchBounceRules: ' . $text);
+ });
+
+ $this->ruleManager->expects($this->exactly(2))->method('incrementCount');
+ $this->ruleManager->expects($this->exactly(2))->method('linkRuleToBounce');
+
+ // subscriber lookups for umb1 and umb3 (111 and 222). umb2 has 0 user id so skip.
+ $subscriber111 = $this->createMock(Subscriber::class);
+ $subscriber111->method('getId')->willReturn(111);
+ $subscriber111->method('isConfirmed')->willReturn(true);
+ $subscriber111->method('isBlacklisted')->willReturn(false);
+
+ $subscriber222 = $this->createMock(Subscriber::class);
+ $subscriber222->method('getId')->willReturn(222);
+ $subscriber222->method('isConfirmed')->willReturn(false);
+ $subscriber222->method('isBlacklisted')->willReturn(true);
+
+ $this->subscriberManager->expects($this->exactly(2))
+ ->method('getSubscriberById')
+ ->willReturnCallback(function (int $id) use ($subscriber111, $subscriber222) {
+ if ($id === 111) {
+ return $subscriber111;
+ }
+ if ($id === 222) {
+ return $subscriber222;
+ }
+ $this->fail('Unexpected subscriber id: ' . $id);
+ });
+
+ $this->actionResolver->expects($this->exactly(2))
+ ->method('handle')
+ ->willReturnCallback(function (string $action, array $ctx) {
+ if ($action === 'blacklist') {
+ $this->assertSame(111, $ctx['userId']);
+ $this->assertTrue($ctx['confirmed']);
+ $this->assertFalse($ctx['blacklisted']);
+ $this->assertSame(10, $ctx['ruleId']);
+ $this->assertInstanceOf(Bounce::class, $ctx['bounce']);
+ } elseif ($action === 'notify') {
+ $this->assertSame(222, $ctx['userId']);
+ $this->assertFalse($ctx['confirmed']);
+ $this->assertTrue($ctx['blacklisted']);
+ $this->assertSame(20, $ctx['ruleId']);
+ } else {
+ $this->fail('Unexpected action: ' . $action);
+ }
+ return null;
+ });
+
+ $this->io
+ ->expects($this->once())
+ ->method('section')
+ ->with('Processing bounces based on active bounce rules');
+ $this->io->expects($this->exactly(4))->method('writeln');
+
+ $processor = new AdvancedBounceRulesProcessor(
+ bounceManager: $this->bounceManager,
+ ruleManager: $this->ruleManager,
+ actionResolver: $this->actionResolver,
+ subscriberManager: $this->subscriberManager,
+ );
+
+ $processor->process($this->io, 2);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php
new file mode 100644
index 00000000..b7009cd9
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php
@@ -0,0 +1,168 @@
+bounceManager = $this->createMock(BounceManager::class);
+ $this->subscriberRepository = $this->createMock(SubscriberRepository::class);
+ $this->messageRepository = $this->createMock(MessageRepository::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->subscriberManager = $this->createMock(SubscriberManager::class);
+ $this->historyManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->bounce = $this->createMock(Bounce::class);
+ }
+
+ private function makeProcessor(): BounceDataProcessor
+ {
+ return new BounceDataProcessor(
+ bounceManager: $this->bounceManager,
+ subscriberRepository: $this->subscriberRepository,
+ messageRepository: $this->messageRepository,
+ logger: $this->logger,
+ subscriberManager: $this->subscriberManager,
+ subscriberHistoryManager: $this->historyManager,
+ );
+ }
+
+ public function testSystemMessageWithUserAddsHistory(): void
+ {
+ $processor = $this->makeProcessor();
+ $date = new DateTimeImmutable('2020-01-01');
+
+ $this->bounce->method('getId')->willReturn(77);
+
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('update')
+ ->with($this->bounce, 'bounced system message', '123 marked unconfirmed');
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('linkUserMessageBounce')
+ ->with($this->bounce, $date, 123);
+ $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(123);
+ $this->logger
+ ->expects($this->once())
+ ->method('info')
+ ->with('system message bounced, user marked unconfirmed', ['userId' => 123]);
+
+ $subscriber = $this->createMock(Subscriber::class);
+ $subscriber->method('getId')->willReturn(123);
+ $this->subscriberManager->method('getSubscriberById')->with(123)->willReturn($subscriber);
+ $this->historyManager
+ ->expects($this->once())
+ ->method('addHistory')
+ ->with($subscriber, 'Bounced system message', 'User marked unconfirmed. Bounce #77');
+
+ $res = $processor->process($this->bounce, 'systemmessage', 123, $date);
+ $this->assertTrue($res);
+ }
+
+ public function testSystemMessageUnknownUser(): void
+ {
+ $processor = $this->makeProcessor();
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('update')
+ ->with($this->bounce, 'bounced system message', 'unknown user');
+ $this->logger->expects($this->once())->method('info')->with('system message bounced, but unknown user');
+ $res = $processor->process($this->bounce, 'systemmessage', null, new DateTimeImmutable());
+ $this->assertTrue($res);
+ }
+
+ public function testKnownMessageAndUserNew(): void
+ {
+ $processor = $this->makeProcessor();
+ $date = new DateTimeImmutable();
+ $this->bounceManager->method('existsUserMessageBounce')->with(5, 10)->willReturn(false);
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('linkUserMessageBounce')
+ ->with($this->bounce, $date, 5, 10);
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('update')
+ ->with($this->bounce, 'bounced list message 10', '5 bouncecount increased');
+ $this->messageRepository->expects($this->once())->method('incrementBounceCount')->with(10);
+ $this->subscriberRepository->expects($this->once())->method('incrementBounceCount')->with(5);
+ $res = $processor->process($this->bounce, '10', 5, $date);
+ $this->assertTrue($res);
+ }
+
+ public function testKnownMessageAndUserDuplicate(): void
+ {
+ $processor = $this->makeProcessor();
+ $date = new DateTimeImmutable();
+ $this->bounceManager->method('existsUserMessageBounce')->with(5, 10)->willReturn(true);
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('linkUserMessageBounce')
+ ->with($this->bounce, $date, 5, 10);
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('update')
+ ->with($this->bounce, 'duplicate bounce for 5', 'duplicate bounce for subscriber 5 on message 10');
+ $res = $processor->process($this->bounce, '10', 5, $date);
+ $this->assertTrue($res);
+ }
+
+ public function testUserOnly(): void
+ {
+ $processor = $this->makeProcessor();
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('update')
+ ->with($this->bounce, 'bounced unidentified message', '5 bouncecount increased');
+ $this->subscriberRepository->expects($this->once())->method('incrementBounceCount')->with(5);
+ $res = $processor->process($this->bounce, null, 5, new DateTimeImmutable());
+ $this->assertTrue($res);
+ }
+
+ public function testMessageOnly(): void
+ {
+ $processor = $this->makeProcessor();
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('update')
+ ->with($this->bounce, 'bounced list message 10', 'unknown user');
+ $this->messageRepository->expects($this->once())->method('incrementBounceCount')->with(10);
+ $res = $processor->process($this->bounce, '10', null, new DateTimeImmutable());
+ $this->assertTrue($res);
+ }
+
+ public function testNeitherMessageNorUser(): void
+ {
+ $processor = $this->makeProcessor();
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('update')
+ ->with($this->bounce, 'unidentified bounce', 'not processed');
+ $res = $processor->process($this->bounce, null, null, new DateTimeImmutable());
+ $this->assertFalse($res);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php
similarity index 98%
rename from tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php
rename to tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php
index f8bb28d3..b2c51c71 100644
--- a/tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php
@@ -2,15 +2,15 @@
declare(strict_types=1);
-namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Processor;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use PhpList\Core\Domain\Messaging\Model\Message;
use PhpList\Core\Domain\Messaging\Model\Message\MessageContent;
use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata;
-use PhpList\Core\Domain\Messaging\Service\CampaignProcessor;
use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator;
+use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor;
use PhpList\Core\Domain\Subscription\Model\Subscriber;
use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider;
use PHPUnit\Framework\MockObject\MockObject;
diff --git a/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php
new file mode 100644
index 00000000..210e000c
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php
@@ -0,0 +1,76 @@
+service = $this->createMock(BounceProcessingServiceInterface::class);
+ $this->input = $this->createMock(InputInterface::class);
+ $this->io = $this->createMock(SymfonyStyle::class);
+ }
+
+ public function testGetProtocol(): void
+ {
+ $processor = new MboxBounceProcessor($this->service);
+ $this->assertSame('mbox', $processor->getProtocol());
+ }
+
+ public function testProcessThrowsWhenMailboxMissing(): void
+ {
+ $processor = new MboxBounceProcessor($this->service);
+
+ $this->input->method('getOption')->willReturnMap([
+ ['test', false],
+ ['maximum', 0],
+ ['mailbox', ''],
+ ]);
+
+ $this->io
+ ->expects($this->once())
+ ->method('error')
+ ->with('mbox file path must be provided with --mailbox.');
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Missing --mailbox for mbox protocol');
+
+ $processor->process($this->input, $this->io);
+ }
+
+ public function testProcessSuccess(): void
+ {
+ $processor = new MboxBounceProcessor($this->service);
+
+ $this->input->method('getOption')->willReturnMap([
+ ['test', true],
+ ['maximum', 50],
+ ['mailbox', '/var/mail/bounce.mbox'],
+ ]);
+
+ $this->io->expects($this->once())->method('section')->with('Opening mbox /var/mail/bounce.mbox');
+ $this->io->expects($this->once())->method('writeln')->with('Please do not interrupt this process');
+
+ $this->service->expects($this->once())
+ ->method('processMailbox')
+ ->with('/var/mail/bounce.mbox', 50, true)
+ ->willReturn('OK');
+
+ $result = $processor->process($this->input, $this->io);
+ $this->assertSame('OK', $result);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php
new file mode 100644
index 00000000..fad4cfbe
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php
@@ -0,0 +1,64 @@
+service = $this->createMock(BounceProcessingServiceInterface::class);
+ $this->input = $this->createMock(InputInterface::class);
+ $this->io = $this->createMock(SymfonyStyle::class);
+ }
+
+ public function testGetProtocol(): void
+ {
+ $processor = new PopBounceProcessor($this->service, 'mail.example.com', 995, 'INBOX');
+ $this->assertSame('pop', $processor->getProtocol());
+ }
+
+ public function testProcessWithMultipleMailboxesAndDefaults(): void
+ {
+ $processor = new PopBounceProcessor($this->service, 'pop.example.com', 110, 'INBOX, ,Custom');
+
+ $this->input->method('getOption')->willReturnMap([
+ ['test', true],
+ ['maximum', 100],
+ ]);
+
+ $this->io->expects($this->exactly(3))->method('section');
+ $this->io->expects($this->exactly(3))->method('writeln');
+
+ $this->service->expects($this->exactly(3))
+ ->method('processMailbox')
+ ->willReturnCallback(function (string $mailbox, int $max, bool $test) {
+ $expectedThird = '{pop.example.com:110}Custom';
+ $expectedFirst = '{pop.example.com:110}INBOX';
+ $this->assertSame(100, $max);
+ $this->assertTrue($test);
+ if ($mailbox === $expectedFirst) {
+ return 'A';
+ }
+ if ($mailbox === $expectedThird) {
+ return 'C';
+ }
+ $this->fail('Unexpected mailbox: ' . $mailbox);
+ });
+
+ $result = $processor->process($this->input, $this->io);
+ $this->assertSame('AAC', $result);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php
new file mode 100644
index 00000000..a671e74c
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php
@@ -0,0 +1,75 @@
+bounceManager = $this->createMock(BounceManager::class);
+ $this->messageParser = $this->createMock(MessageParser::class);
+ $this->dataProcessor = $this->createMock(BounceDataProcessor::class);
+ $this->io = $this->createMock(SymfonyStyle::class);
+ }
+
+ public function testProcess(): void
+ {
+ $bounce1 = $this->createBounce('H1', 'D1');
+ $bounce2 = $this->createBounce('H2', 'D2');
+ $bounce3 = $this->createBounce('H3', 'D3');
+ $this->bounceManager
+ ->method('findByStatus')
+ ->with('unidentified bounce')
+ ->willReturn([$bounce1, $bounce2, $bounce3]);
+
+ $this->io->expects($this->once())->method('section')->with('Reprocessing unidentified bounces');
+ $this->io->expects($this->exactly(3))->method('writeln');
+
+ // For b1: only userId found -> should process
+ $this->messageParser->expects($this->exactly(3))->method('decodeBody');
+ $this->messageParser->method('findUserId')->willReturnOnConsecutiveCalls(111, null, 222);
+ $this->messageParser->method('findMessageId')->willReturnOnConsecutiveCalls(null, '555', '666');
+
+ // process called for b1 and b3 (two calls return true and true),
+ // and also for b2 since it has messageId -> should be called too -> total 3 calls
+ $this->dataProcessor->expects($this->exactly(3))
+ ->method('process')
+ ->with(
+ $this->anything(),
+ $this->callback(fn($messageId) => $messageId === null || is_string($messageId)),
+ $this->callback(fn($messageId) => $messageId === null || is_int($messageId)),
+ $this->isInstanceOf(DateTimeImmutable::class)
+ )
+ ->willReturnOnConsecutiveCalls(true, false, true);
+
+ $processor = new UnidentifiedBounceReprocessor(
+ bounceManager: $this->bounceManager,
+ messageParser: $this->messageParser,
+ bounceDataProcessor: $this->dataProcessor
+ );
+ $processor->process($this->io);
+ }
+
+ private function createBounce(string $header, string $data): Bounce
+ {
+ // Bounce constructor: (DateTime|null, header, data, status, comment)
+ return new Bounce(null, $header, $data, null, null);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php b/tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php
new file mode 100644
index 00000000..e75766f5
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php
@@ -0,0 +1,70 @@
+manager = $this->createMock(ClientManager::class);
+ }
+
+ public function testMakeForMailboxBuildsClientWithConfiguredParams(): void
+ {
+ $factory = new WebklexImapClientFactory(
+ clientManager: $this->manager,
+ mailbox: 'imap.example.com#BOUNCES',
+ host: 'imap.example.com',
+ username: 'user',
+ password: 'pass',
+ protocol: 'imap',
+ port: 993,
+ encryption: 'ssl'
+ );
+
+ $client = $this->createMock(Client::class);
+
+ $this->manager
+ ->expects($this->once())
+ ->method('make')
+ ->with($this->callback(function (array $cfg) {
+ $this->assertSame('imap.example.com', $cfg['host']);
+ $this->assertSame(993, $cfg['port']);
+ $this->assertSame('ssl', $cfg['encryption']);
+ $this->assertTrue($cfg['validate_cert']);
+ $this->assertSame('user', $cfg['username']);
+ $this->assertSame('pass', $cfg['password']);
+ $this->assertSame('imap', $cfg['protocol']);
+ return true;
+ }))
+ ->willReturn($client);
+
+ $out = $factory->makeForMailbox();
+ $this->assertSame($client, $out);
+ $this->assertSame('BOUNCES', $factory->getFolderName());
+ }
+
+ public function testGetFolderNameDefaultsToInbox(): void
+ {
+ $factory = new WebklexImapClientFactory(
+ clientManager: $this->manager,
+ mailbox: 'imap.example.com',
+ host: 'imap.example.com',
+ username: 'u',
+ password: 'p',
+ protocol: 'imap',
+ port: 993
+ );
+ $this->assertSame('INBOX', $factory->getFolderName());
+ }
+}
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php
index 8df0f4d8..43ae2fcc 100644
--- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php
@@ -4,6 +4,8 @@
namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service\Manager;
+use PhpList\Core\Domain\Common\ClientIpResolver;
+use PhpList\Core\Domain\Common\SystemInfoCollector;
use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberHistoryFilter;
use PhpList\Core\Domain\Subscription\Model\SubscriberHistory;
use PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository;
@@ -20,7 +22,9 @@ protected function setUp(): void
{
$this->subscriberHistoryRepository = $this->createMock(SubscriberHistoryRepository::class);
$this->subscriptionHistoryService = new SubscriberHistoryManager(
- repository: $this->subscriberHistoryRepository
+ repository: $this->subscriberHistoryRepository,
+ clientIpResolver: $this->createMock(ClientIpResolver::class),
+ systemInfoCollector: $this->createMock(SystemInfoCollector::class),
);
}
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php
index 9a177312..b7a99366 100644
--- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php
@@ -34,7 +34,7 @@ protected function setUp(): void
subscriberRepository: $this->subscriberRepository,
entityManager: $this->entityManager,
messageBus: $this->messageBus,
- subscriberDeletionService: $subscriberDeletionService
+ subscriberDeletionService: $subscriberDeletionService,
);
}
From 793c260640717c095a747c813df89aedde20cfe8 Mon Sep 17 00:00:00 2001
From: TatevikGr
Date: Thu, 4 Sep 2025 13:32:22 +0400
Subject: [PATCH 05/10] EventLog + translator (#356)
* EventLogManager
* Log failed logins + translate messages
* weblate
* test fix
* Use translations
* Fix pipeline
* Weblate
* Deprecate DB translation table
---------
Co-authored-by: Tatevik
---
.github/workflows/i18n-validate.yml | 69 ++++++++++++++
.weblate | 23 +++++
config/config.yml | 5 +-
config/services/managers.yml | 12 ++-
config/services/repositories.yml | 15 ++-
config/services/services.yml | 7 ++
resources/translations/messages.en.xlf | 44 +++++++++
src/Domain/Common/I18n/Messages.php | 29 ++++++
.../Model/Filter/EventLogFilter.php | 33 +++++++
src/Domain/Configuration/Model/I18n.php | 5 +
.../Repository/EventLogRepository.php | 37 ++++++++
.../Repository/I18nRepository.php | 1 +
.../Service/Manager/EventLogManager.php | 54 +++++++++++
.../Identity/Service/PasswordManager.php | 10 +-
.../Identity/Service/SessionManager.php | 21 ++++-
.../Service/Manager/SubscriptionManager.php | 16 +++-
.../Service/Manager/EventLogManagerTest.php | 94 +++++++++++++++++++
.../Identity/Service/PasswordManagerTest.php | 4 +-
.../Identity/Service/SessionManagerTest.php | 28 +++++-
.../Manager/SubscriptionManagerTest.php | 11 ++-
20 files changed, 492 insertions(+), 26 deletions(-)
create mode 100644 .github/workflows/i18n-validate.yml
create mode 100644 .weblate
create mode 100644 resources/translations/messages.en.xlf
create mode 100644 src/Domain/Common/I18n/Messages.php
create mode 100644 src/Domain/Configuration/Model/Filter/EventLogFilter.php
create mode 100644 src/Domain/Configuration/Service/Manager/EventLogManager.php
create mode 100644 tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php
diff --git a/.github/workflows/i18n-validate.yml b/.github/workflows/i18n-validate.yml
new file mode 100644
index 00000000..4e49efa8
--- /dev/null
+++ b/.github/workflows/i18n-validate.yml
@@ -0,0 +1,69 @@
+name: I18n Validate
+
+on:
+ pull_request:
+ paths:
+ - 'resources/translations/**/*.xlf'
+ - 'composer.lock'
+ - 'composer.json'
+
+jobs:
+ validate-xliff:
+ runs-on: ubuntu-22.04
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.1']
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: imap, zip
+ tools: composer:v2
+ coverage: none
+
+ - name: Cache Composer packages
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.composer/cache/files
+ key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-composer-${{ matrix.php }}-
+
+ - name: Install dependencies (no dev autoloader scripts)
+ run: |
+ set -euo pipefail
+ composer install --no-interaction --no-progress --prefer-dist
+
+ - name: Lint XLIFF with Symfony
+ run: |
+ set -euo pipefail
+ # Adjust the directory to match your repo layout
+ php bin/console lint:xliff resources/translations
+
+ - name: Validate XLIFF XML with xmllint
+ run: |
+ set -euo pipefail
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends libxml2-utils
+ # Adjust root dir; prune vendor; accept spaces/newlines safely
+ find resources/translations -type f -name '*.xlf' -not -path '*/vendor/*' -print0 \
+ | xargs -0 -n1 xmllint --noout
+
+ - name: Symfony translation sanity (extract dry-run)
+ run: |
+ set -euo pipefail
+ # Show what would be created/updated without writing files
+ php bin/console translation:extract en \
+ --format=xlf \
+ --domain=messages \
+ --dump-messages \
+ --no-interaction
+ # Note: omit --force to keep this a dry-run
diff --git a/.weblate b/.weblate
new file mode 100644
index 00000000..5917a8b8
--- /dev/null
+++ b/.weblate
@@ -0,0 +1,23 @@
+# .weblate
+---
+projects:
+ - slug: phplist-core
+ name: phpList core
+ components:
+ - slug: messages
+ name: Messages
+ files:
+ # {language} is Weblate’s placeholder (e.g., fr, de, es)
+ - src: resources/translations/messages.en.xlf
+ template: true
+ # Where localized files live (mirrors Symfony layout)
+ target: resources/translations/messages.{language}.xlf
+ file_format: xliff
+ language_code_style: bcp
+ # Ensure placeholders like %name% are preserved
+ parse_file_headers: true
+ check_flags:
+ - xml-invalid
+ - placeholders
+ - urls
+ - accelerated
diff --git a/config/config.yml b/config/config.yml
index e235f999..7de6dca6 100644
--- a/config/config.yml
+++ b/config/config.yml
@@ -10,7 +10,10 @@ parameters:
framework:
#esi: ~
- #translator: { fallbacks: ['%locale%'] }
+ translator:
+ default_path: '%kernel.project_dir%/resources/translations'
+ fallbacks: ['%locale%']
+
secret: '%secret%'
router:
resource: '%kernel.project_dir%/config/routing.yml'
diff --git a/config/services/managers.yml b/config/services/managers.yml
index 5ef215b3..22dbe066 100644
--- a/config/services/managers.yml
+++ b/config/services/managers.yml
@@ -4,6 +4,14 @@ services:
autoconfigure: true
public: false
+ PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager:
+ autowire: true
+ autoconfigure: true
+
PhpList\Core\Domain\Identity\Service\SessionManager:
autowire: true
autoconfigure: true
@@ -80,10 +88,6 @@ services:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager:
- autowire: true
- autoconfigure: true
-
PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager:
autowire: true
autoconfigure: true
diff --git a/config/services/repositories.yml b/config/services/repositories.yml
index 82ae6a82..1289bea7 100644
--- a/config/services/repositories.yml
+++ b/config/services/repositories.yml
@@ -1,4 +1,14 @@
services:
+ PhpList\Core\Domain\Configuration\Repository\ConfigRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Configuration\Model\Config
+
+ PhpList\Core\Domain\Configuration\Repository\EventLogRepository:
+ parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+ arguments:
+ - PhpList\Core\Domain\Configuration\Model\EventLog
+
PhpList\Core\Domain\Identity\Repository\AdministratorRepository:
parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
arguments:
@@ -66,11 +76,6 @@ services:
arguments:
- PhpList\Core\Domain\Messaging\Model\TemplateImage
- PhpList\Core\Domain\Configuration\Repository\ConfigRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Configuration\Model\Config
-
PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository:
parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
arguments:
diff --git a/config/services/services.yml b/config/services/services.yml
index 19caddd8..1f509787 100644
--- a/config/services/services.yml
+++ b/config/services/services.yml
@@ -107,3 +107,10 @@ services:
PhpList\Core\Domain\Messaging\Service\BounceActionResolver:
arguments:
- !tagged_iterator { tag: 'phplist.bounce_action_handler' }
+
+ # I18n
+ PhpList\Core\Domain\Common\I18n\SimpleTranslator:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Common\I18n\TranslatorInterface: '@PhpList\Core\Domain\Common\I18n\SimpleTranslator'
diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf
new file mode 100644
index 00000000..7e176e3e
--- /dev/null
+++ b/resources/translations/messages.en.xlf
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+ Not authorized
+ Not authorized
+
+
+
+ Failed admin login attempt for '%login%'
+ Failed admin login attempt for '%login%'
+
+
+
+ Login attempt for disabled admin '%login%'
+ Login attempt for disabled admin '%login%'
+
+
+
+
+ Administrator not found
+ Administrator not found
+
+
+
+
+ Subscriber list not found.
+ Subscriber list not found.
+
+
+ Subscriber does not exists.
+ Subscriber does not exists.
+
+
+ Subscription not found for this subscriber and list.
+ Subscription not found for this subscriber and list.
+
+
+
+
+
diff --git a/src/Domain/Common/I18n/Messages.php b/src/Domain/Common/I18n/Messages.php
new file mode 100644
index 00000000..f9e8822f
--- /dev/null
+++ b/src/Domain/Common/I18n/Messages.php
@@ -0,0 +1,29 @@
+page;
+ }
+
+ public function getDateFrom(): ?DateTimeInterface
+ {
+ return $this->dateFrom;
+ }
+
+ public function getDateTo(): ?DateTimeInterface
+ {
+ return $this->dateTo;
+ }
+}
diff --git a/src/Domain/Configuration/Model/I18n.php b/src/Domain/Configuration/Model/I18n.php
index bffed897..b8eefd63 100644
--- a/src/Domain/Configuration/Model/I18n.php
+++ b/src/Domain/Configuration/Model/I18n.php
@@ -8,6 +8,11 @@
use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
use PhpList\Core\Domain\Configuration\Repository\I18nRepository;
+/**
+ * @deprecated
+ *
+ * Symfony\Contracts\Translation will be used instead.
+ */
#[ORM\Entity(repositoryClass: I18nRepository::class)]
#[ORM\Table(name: 'phplist_i18n')]
#[ORM\UniqueConstraint(name: 'lanorigunq', columns: ['lan', 'original'])]
diff --git a/src/Domain/Configuration/Repository/EventLogRepository.php b/src/Domain/Configuration/Repository/EventLogRepository.php
index 7caf5462..47640007 100644
--- a/src/Domain/Configuration/Repository/EventLogRepository.php
+++ b/src/Domain/Configuration/Repository/EventLogRepository.php
@@ -4,11 +4,48 @@
namespace PhpList\Core\Domain\Configuration\Repository;
+use InvalidArgumentException;
+use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface;
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Configuration\Model\Filter\EventLogFilter;
+use PhpList\Core\Domain\Configuration\Model\EventLog;
class EventLogRepository extends AbstractRepository implements PaginatableRepositoryInterface
{
use CursorPaginationTrait;
+
+ /**
+ * @return EventLog[]
+ * @throws InvalidArgumentException
+ */
+ public function getFilteredAfterId(int $lastId, int $limit, ?FilterRequestInterface $filter = null): array
+ {
+ $queryBuilder = $this->createQueryBuilder('e')
+ ->andWhere('e.id > :lastId')
+ ->setParameter('lastId', $lastId)
+ ->orderBy('e.id', 'ASC')
+ ->setMaxResults($limit);
+
+ if ($filter === null) {
+ return $queryBuilder->getQuery()->getResult();
+ }
+
+ if (!$filter instanceof EventLogFilter) {
+ throw new InvalidArgumentException('Expected EventLogFilter.');
+ }
+
+ if ($filter->getPage() !== null) {
+ $queryBuilder->andWhere('e.page = :page')->setParameter('page', $filter->getPage());
+ }
+ if ($filter->getDateFrom() !== null) {
+ $queryBuilder->andWhere('e.entered >= :dateFrom')->setParameter('dateFrom', $filter->getDateFrom());
+ }
+ if ($filter->getDateTo() !== null) {
+ $queryBuilder->andWhere('e.entered <= :dateTo')->setParameter('dateTo', $filter->getDateTo());
+ }
+
+ return $queryBuilder->getQuery()->getResult();
+ }
}
diff --git a/src/Domain/Configuration/Repository/I18nRepository.php b/src/Domain/Configuration/Repository/I18nRepository.php
index f4465103..33fa599a 100644
--- a/src/Domain/Configuration/Repository/I18nRepository.php
+++ b/src/Domain/Configuration/Repository/I18nRepository.php
@@ -6,6 +6,7 @@
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
+/** @deprecated */
class I18nRepository extends AbstractRepository
{
}
diff --git a/src/Domain/Configuration/Service/Manager/EventLogManager.php b/src/Domain/Configuration/Service/Manager/EventLogManager.php
new file mode 100644
index 00000000..374db7ed
--- /dev/null
+++ b/src/Domain/Configuration/Service/Manager/EventLogManager.php
@@ -0,0 +1,54 @@
+repository = $repository;
+ }
+
+ public function log(string $page, string $entry): EventLog
+ {
+ $log = (new EventLog())
+ ->setEntered(new DateTimeImmutable())
+ ->setPage($page)
+ ->setEntry($entry);
+
+ $this->repository->save($log);
+
+ return $log;
+ }
+
+ /**
+ * Get event logs with optional filters (page and date range) and cursor pagination.
+ *
+ * @return EventLog[]
+ */
+ public function get(
+ int $lastId = 0,
+ int $limit = 50,
+ ?string $page = null,
+ ?DateTimeInterface $dateFrom = null,
+ ?DateTimeInterface $dateTo = null
+ ): array {
+ $filter = new EventLogFilter($page, $dateFrom, $dateTo);
+ return $this->repository->getFilteredAfterId($lastId, $limit, $filter);
+ }
+
+ public function delete(EventLog $log): void
+ {
+ $this->repository->remove($log);
+ }
+}
diff --git a/src/Domain/Identity/Service/PasswordManager.php b/src/Domain/Identity/Service/PasswordManager.php
index f6ad2a9e..2c7ebe1e 100644
--- a/src/Domain/Identity/Service/PasswordManager.php
+++ b/src/Domain/Identity/Service/PasswordManager.php
@@ -5,6 +5,7 @@
namespace PhpList\Core\Domain\Identity\Service;
use DateTime;
+use PhpList\Core\Domain\Common\I18n\Messages;
use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest;
use PhpList\Core\Domain\Identity\Model\Administrator;
use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository;
@@ -13,6 +14,7 @@
use PhpList\Core\Security\HashGenerator;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
class PasswordManager
{
@@ -22,17 +24,20 @@ class PasswordManager
private AdministratorRepository $administratorRepository;
private HashGenerator $hashGenerator;
private MessageBusInterface $messageBus;
+ private TranslatorInterface $translator;
public function __construct(
AdminPasswordRequestRepository $passwordRequestRepository,
AdministratorRepository $administratorRepository,
HashGenerator $hashGenerator,
- MessageBusInterface $messageBus
+ MessageBusInterface $messageBus,
+ TranslatorInterface $translator
) {
$this->passwordRequestRepository = $passwordRequestRepository;
$this->administratorRepository = $administratorRepository;
$this->hashGenerator = $hashGenerator;
$this->messageBus = $messageBus;
+ $this->translator = $translator;
}
/**
@@ -47,7 +52,8 @@ public function generatePasswordResetToken(string $email): string
{
$administrator = $this->administratorRepository->findOneBy(['email' => $email]);
if ($administrator === null) {
- throw new NotFoundHttpException('Administrator not found', null, 1500567100);
+ $message = $this->translator->trans(Messages::IDENTITY_ADMIN_NOT_FOUND);
+ throw new NotFoundHttpException($message, null, 1500567100);
}
$existingRequests = $this->passwordRequestRepository->findByAdmin($administrator);
diff --git a/src/Domain/Identity/Service/SessionManager.php b/src/Domain/Identity/Service/SessionManager.php
index 52daafa3..82f52af1 100644
--- a/src/Domain/Identity/Service/SessionManager.php
+++ b/src/Domain/Identity/Service/SessionManager.php
@@ -4,6 +4,9 @@
namespace PhpList\Core\Domain\Identity\Service;
+use PhpList\Core\Domain\Common\I18n\Messages;
+use Symfony\Contracts\Translation\TranslatorInterface;
+use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager;
use PhpList\Core\Domain\Identity\Model\AdministratorToken;
use PhpList\Core\Domain\Identity\Repository\AdministratorRepository;
use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository;
@@ -13,24 +16,36 @@ class SessionManager
{
private AdministratorTokenRepository $tokenRepository;
private AdministratorRepository $administratorRepository;
+ private EventLogManager $eventLogManager;
+ private TranslatorInterface $translator;
public function __construct(
AdministratorTokenRepository $tokenRepository,
- AdministratorRepository $administratorRepository
+ AdministratorRepository $administratorRepository,
+ EventLogManager $eventLogManager,
+ TranslatorInterface $translator
) {
$this->tokenRepository = $tokenRepository;
$this->administratorRepository = $administratorRepository;
+ $this->eventLogManager = $eventLogManager;
+ $this->translator = $translator;
}
public function createSession(string $loginName, string $password): AdministratorToken
{
$administrator = $this->administratorRepository->findOneByLoginCredentials($loginName, $password);
if ($administrator === null) {
- throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567098);
+ $entry = $this->translator->trans(Messages::AUTH_LOGIN_FAILED, ['login' => $loginName]);
+ $this->eventLogManager->log('login', $entry);
+ $message = $this->translator->trans(Messages::AUTH_NOT_AUTHORIZED);
+ throw new UnauthorizedHttpException('', $message, null, 1500567098);
}
if ($administrator->isDisabled()) {
- throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567099);
+ $entry = $this->translator->trans(Messages::AUTH_LOGIN_DISABLED, ['login' => $loginName]);
+ $this->eventLogManager->log('login', $entry);
+ $message = $this->translator->trans(Messages::AUTH_NOT_AUTHORIZED);
+ throw new UnauthorizedHttpException('', $message, null, 1500567099);
}
$token = new AdministratorToken();
diff --git a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php
index bb3a0e14..764106ec 100644
--- a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php
@@ -4,6 +4,7 @@
namespace PhpList\Core\Domain\Subscription\Service\Manager;
+use PhpList\Core\Domain\Common\I18n\Messages;
use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException;
use PhpList\Core\Domain\Subscription\Model\Subscriber;
use PhpList\Core\Domain\Subscription\Model\SubscriberList;
@@ -11,21 +12,25 @@
use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository;
use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
use PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository;
+use Symfony\Contracts\Translation\TranslatorInterface;
class SubscriptionManager
{
private SubscriptionRepository $subscriptionRepository;
private SubscriberRepository $subscriberRepository;
private SubscriberListRepository $subscriberListRepository;
+ private TranslatorInterface $translator;
public function __construct(
SubscriptionRepository $subscriptionRepository,
SubscriberRepository $subscriberRepository,
- SubscriberListRepository $subscriberListRepository
+ SubscriberListRepository $subscriberListRepository,
+ TranslatorInterface $translator
) {
$this->subscriptionRepository = $subscriptionRepository;
$this->subscriberRepository = $subscriberRepository;
$this->subscriberListRepository = $subscriberListRepository;
+ $this->translator = $translator;
}
public function addSubscriberToAList(Subscriber $subscriber, int $listId): Subscription
@@ -37,7 +42,8 @@ public function addSubscriberToAList(Subscriber $subscriber, int $listId): Subsc
}
$subscriberList = $this->subscriberListRepository->find($listId);
if (!$subscriberList) {
- throw new SubscriptionCreationException('Subscriber list not found.', 404);
+ $message = $this->translator->trans(Messages::SUBSCRIPTION_LIST_NOT_FOUND);
+ throw new SubscriptionCreationException($message, 404);
}
$subscription = new Subscription();
@@ -64,7 +70,8 @@ private function createSubscription(SubscriberList $subscriberList, string $emai
{
$subscriber = $this->subscriberRepository->findOneBy(['email' => $email]);
if (!$subscriber) {
- throw new SubscriptionCreationException('Subscriber does not exists.', 404);
+ $message = $this->translator->trans(Messages::SUBSCRIPTION_SUBSCRIBER_NOT_FOUND);
+ throw new SubscriptionCreationException($message, 404);
}
$existingSubscription = $this->subscriptionRepository
@@ -101,7 +108,8 @@ private function deleteSubscription(SubscriberList $subscriberList, string $emai
->findOneBySubscriberEmailAndListId($subscriberList->getId(), $email);
if (!$subscription) {
- throw new SubscriptionCreationException('Subscription not found for this subscriber and list.', 404);
+ $message = $this->translator->trans(Messages::SUBSCRIPTION_NOT_FOUND_FOR_LIST_AND_SUBSCRIBER);
+ throw new SubscriptionCreationException($message, 404);
}
$this->subscriptionRepository->remove($subscription);
diff --git a/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php b/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php
new file mode 100644
index 00000000..818b8de0
--- /dev/null
+++ b/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php
@@ -0,0 +1,94 @@
+repository = $this->createMock(EventLogRepository::class);
+ $this->manager = new EventLogManager($this->repository);
+ }
+
+ public function testLogCreatesAndPersists(): void
+ {
+ $this->repository->expects($this->once())
+ ->method('save')
+ ->with($this->isInstanceOf(EventLog::class));
+
+ $log = $this->manager->log('dashboard', 'Viewed dashboard');
+
+ $this->assertInstanceOf(EventLog::class, $log);
+ $this->assertSame('dashboard', $log->getPage());
+ $this->assertSame('Viewed dashboard', $log->getEntry());
+ $this->assertNotNull($log->getEntered());
+ $this->assertInstanceOf(DateTimeImmutable::class, $log->getEntered());
+ }
+
+ public function testDelete(): void
+ {
+ $log = new EventLog();
+ $this->repository->expects($this->once())
+ ->method('remove')
+ ->with($log);
+
+ $this->manager->delete($log);
+ }
+
+ public function testGetWithFiltersDelegatesToRepository(): void
+ {
+ $expected = [new EventLog(), new EventLog()];
+
+ $this->repository->expects($this->once())
+ ->method('getFilteredAfterId')
+ ->with(
+ 100,
+ 25,
+ $this->callback(function (EventLogFilter $filter) {
+ // Use getters to validate
+ return method_exists($filter, 'getPage')
+ && $filter->getPage() === 'settings'
+ && $filter->getDateFrom() instanceof DateTimeImmutable
+ && $filter->getDateTo() instanceof DateTimeImmutable
+ && $filter->getDateFrom() <= $filter->getDateTo();
+ })
+ )
+ ->willReturn($expected);
+
+ $from = new DateTimeImmutable('-2 days');
+ $to = new DateTimeImmutable('now');
+ $result = $this->manager->get(lastId: 100, limit: 25, page: 'settings', dateFrom: $from, dateTo: $to);
+
+ $this->assertSame($expected, $result);
+ }
+
+ public function testGetWithoutFiltersDefaults(): void
+ {
+ $expected = [];
+
+ $this->repository->expects($this->once())
+ ->method('getFilteredAfterId')
+ ->with(
+ 0,
+ 50,
+ $this->anything()
+ )
+ ->willReturn($expected);
+
+ $result = $this->manager->get();
+ $this->assertSame($expected, $result);
+ }
+}
diff --git a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php
index 85e02f81..59ace13d 100644
--- a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php
+++ b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php
@@ -17,6 +17,7 @@
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Contracts\Translation\TranslatorInterface;
class PasswordManagerTest extends TestCase
{
@@ -36,7 +37,8 @@ protected function setUp(): void
passwordRequestRepository: $this->passwordRequestRepository,
administratorRepository: $this->administratorRepository,
hashGenerator: $this->hashGenerator,
- messageBus: $this->messageBus
+ messageBus: $this->messageBus,
+ translator: $this->createMock(TranslatorInterface::class)
);
}
diff --git a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php
index 44072452..14419b0e 100644
--- a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php
+++ b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php
@@ -4,16 +4,19 @@
namespace PhpList\Core\Tests\Unit\Domain\Identity\Service;
+use PhpList\Core\Domain\Common\I18n\Messages;
+use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager;
use PhpList\Core\Domain\Identity\Model\AdministratorToken;
use PhpList\Core\Domain\Identity\Repository\AdministratorRepository;
use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository;
use PhpList\Core\Domain\Identity\Service\SessionManager;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
+use Symfony\Contracts\Translation\TranslatorInterface;
class SessionManagerTest extends TestCase
{
- public function testCreateSessionWithInvalidCredentialsThrowsException(): void
+ public function testCreateSessionWithInvalidCredentialsThrowsExceptionAndLogs(): void
{
$adminRepo = $this->createMock(AdministratorRepository::class);
$adminRepo->expects(self::once())
@@ -24,7 +27,24 @@ public function testCreateSessionWithInvalidCredentialsThrowsException(): void
$tokenRepo = $this->createMock(AdministratorTokenRepository::class);
$tokenRepo->expects(self::never())->method('save');
- $manager = new SessionManager($tokenRepo, $adminRepo);
+ $eventLogManager = $this->createMock(EventLogManager::class);
+ $eventLogManager->expects(self::once())
+ ->method('log')
+ ->with('login', $this->stringContains('admin'));
+
+ $translator = $this->createMock(TranslatorInterface::class);
+ $translator->expects(self::exactly(2))
+ ->method('trans')
+ ->withConsecutive(
+ [Messages::AUTH_LOGIN_FAILED, ['login' => 'admin']],
+ [Messages::AUTH_NOT_AUTHORIZED, []]
+ )
+ ->willReturnOnConsecutiveCalls(
+ "Failed admin login attempt for 'admin'",
+ 'Not authorized'
+ );
+
+ $manager = new SessionManager($tokenRepo, $adminRepo, $eventLogManager, $translator);
$this->expectException(UnauthorizedHttpException::class);
$this->expectExceptionMessage('Not authorized');
@@ -42,8 +62,10 @@ public function testDeleteSessionCallsRemove(): void
->with($token);
$adminRepo = $this->createMock(AdministratorRepository::class);
+ $eventLogManager = $this->createMock(EventLogManager::class);
+ $translator = $this->createMock(TranslatorInterface::class);
- $manager = new SessionManager($tokenRepo, $adminRepo);
+ $manager = new SessionManager($tokenRepo, $adminRepo, $eventLogManager, $translator);
$manager->deleteSession($token);
}
}
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php
index e535a7fe..f0c1d3af 100644
--- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php
@@ -14,11 +14,13 @@
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use Symfony\Contracts\Translation\TranslatorInterface;
class SubscriptionManagerTest extends TestCase
{
private SubscriptionRepository&MockObject $subscriptionRepository;
private SubscriberRepository&MockObject $subscriberRepository;
+ private TranslatorInterface&MockObject $translator;
private SubscriptionManager $manager;
protected function setUp(): void
@@ -26,10 +28,12 @@ protected function setUp(): void
$this->subscriptionRepository = $this->createMock(SubscriptionRepository::class);
$this->subscriberRepository = $this->createMock(SubscriberRepository::class);
$subscriberListRepository = $this->createMock(SubscriberListRepository::class);
+ $this->translator = $this->createMock(TranslatorInterface::class);
$this->manager = new SubscriptionManager(
- $this->subscriptionRepository,
- $this->subscriberRepository,
- $subscriberListRepository
+ subscriptionRepository: $this->subscriptionRepository,
+ subscriberRepository: $this->subscriberRepository,
+ subscriberListRepository: $subscriberListRepository,
+ translator: $this->translator,
);
}
@@ -51,6 +55,7 @@ public function testCreateSubscriptionWhenSubscriberExists(): void
public function testCreateSubscriptionThrowsWhenSubscriberMissing(): void
{
+ $this->translator->method('trans')->willReturn('Subscriber does not exists.');
$this->expectException(SubscriptionCreationException::class);
$this->expectExceptionMessage('Subscriber does not exists.');
From 936cabcdef5339b36e334cc1501b113c65ad8761 Mon Sep 17 00:00:00 2001
From: TatevikGr
Date: Fri, 5 Sep 2025 12:15:52 +0400
Subject: [PATCH 06/10] Access level check (#358)
* OwnableInterface
* PermissionChecker
* Check related
* Register service + test
* Style fix
---------
Co-authored-by: Tatevik
---
config/services/providers.yml | 4 -
config/services/services.yml | 6 +-
.../Model/Interfaces/OwnableInterface.php | 12 +++
src/Domain/Identity/Model/Administrator.php | 10 +++
.../Identity/Service/PermissionChecker.php | 89 +++++++++++++++++++
src/Domain/Messaging/Model/Message.php | 3 +-
.../Subscription/Model/SubscribePage.php | 3 +-
.../Subscription/Model/SubscriberList.php | 3 +-
.../Service/PermissionCheckerTest.php | 35 ++++++++
9 files changed, 154 insertions(+), 11 deletions(-)
create mode 100644 src/Domain/Common/Model/Interfaces/OwnableInterface.php
create mode 100644 src/Domain/Identity/Service/PermissionChecker.php
create mode 100644 tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php
diff --git a/config/services/providers.yml b/config/services/providers.yml
index cb784988..226c4e81 100644
--- a/config/services/providers.yml
+++ b/config/services/providers.yml
@@ -2,7 +2,3 @@ services:
PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider:
autowire: true
autoconfigure: true
-
- PhpList\Core\Domain\Messaging\Service\Provider\BounceActionProvider:
- autowire: true
- autoconfigure: true
diff --git a/config/services/services.yml b/config/services/services.yml
index 1f509787..f1b68e74 100644
--- a/config/services/services.yml
+++ b/config/services/services.yml
@@ -108,9 +108,7 @@ services:
arguments:
- !tagged_iterator { tag: 'phplist.bounce_action_handler' }
- # I18n
- PhpList\Core\Domain\Common\I18n\SimpleTranslator:
+ PhpList\Core\Domain\Identity\Service\PermissionChecker:
autowire: true
autoconfigure: true
-
- PhpList\Core\Domain\Common\I18n\TranslatorInterface: '@PhpList\Core\Domain\Common\I18n\SimpleTranslator'
+ public: true
diff --git a/src/Domain/Common/Model/Interfaces/OwnableInterface.php b/src/Domain/Common/Model/Interfaces/OwnableInterface.php
new file mode 100644
index 00000000..16e54e40
--- /dev/null
+++ b/src/Domain/Common/Model/Interfaces/OwnableInterface.php
@@ -0,0 +1,12 @@
+modifiedBy;
}
+
+ public function owns(OwnableInterface $resource): bool
+ {
+ if ($this->getId() === null) {
+ return false;
+ }
+
+ return $resource->getOwner()->getId() === $this->getId();
+ }
}
diff --git a/src/Domain/Identity/Service/PermissionChecker.php b/src/Domain/Identity/Service/PermissionChecker.php
new file mode 100644
index 00000000..8fc241b7
--- /dev/null
+++ b/src/Domain/Identity/Service/PermissionChecker.php
@@ -0,0 +1,89 @@
+ PrivilegeFlag::Subscribers,
+ SubscriberList::class => PrivilegeFlag::Subscribers,
+ Message::class => PrivilegeFlag::Campaigns,
+ ];
+
+ private const OWNERSHIP_MAP = [
+ Subscriber::class => SubscriberList::class,
+ Message::class => SubscriberList::class
+ ];
+
+ public function canManage(Administrator $actor, DomainModel $resource): bool
+ {
+ if ($actor->isSuperUser()) {
+ return true;
+ }
+
+ $required = $this->resolveRequiredPrivilege($resource);
+ if ($required !== null && !$actor->getPrivileges()->has($required)) {
+ return false;
+ }
+
+ if ($resource instanceof OwnableInterface) {
+ return $actor->owns($resource);
+ }
+
+ $notRestricted = true;
+ foreach (self::OWNERSHIP_MAP as $resourceClass => $relatedClass) {
+ if ($resource instanceof $resourceClass) {
+ $related = $this->resolveRelatedEntity($resource, $relatedClass);
+ $notRestricted = $this->checkRelatedResources($related, $actor);
+ }
+ }
+
+ return $notRestricted;
+ }
+
+ private function resolveRequiredPrivilege(DomainModel $resource): ?PrivilegeFlag
+ {
+ foreach (self::REQUIRED_PRIVILEGE_MAP as $class => $flag) {
+ if ($resource instanceof $class) {
+ return $flag;
+ }
+ }
+
+ return null;
+ }
+
+ /** @return OwnableInterface[] */
+ private function resolveRelatedEntity(DomainModel $resource, string $relatedClass): array
+ {
+ if ($resource instanceof Subscriber && $relatedClass === SubscriberList::class) {
+ return $resource->getSubscribedLists()->toArray();
+ }
+
+ if ($resource instanceof Message && $relatedClass === SubscriberList::class) {
+ return $resource->getListMessages()->map(fn($lm) => $lm->getSubscriberList())->toArray();
+ }
+
+ return [];
+ }
+
+ private function checkRelatedResources(array $related, Administrator $actor): bool
+ {
+ foreach ($related as $relatedResource) {
+ if ($actor->owns($relatedResource)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Domain/Messaging/Model/Message.php b/src/Domain/Messaging/Model/Message.php
index fbbfec8a..5064c4f1 100644
--- a/src/Domain/Messaging/Model/Message.php
+++ b/src/Domain/Messaging/Model/Message.php
@@ -11,6 +11,7 @@
use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
use PhpList\Core\Domain\Common\Model\Interfaces\Identity;
use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate;
+use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface;
use PhpList\Core\Domain\Identity\Model\Administrator;
use PhpList\Core\Domain\Messaging\Model\Message\MessageContent;
use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat;
@@ -23,7 +24,7 @@
#[ORM\Table(name: 'phplist_message')]
#[ORM\Index(name: 'uuididx', columns: ['uuid'])]
#[ORM\HasLifecycleCallbacks]
-class Message implements DomainModel, Identity, ModificationDate
+class Message implements DomainModel, Identity, ModificationDate, OwnableInterface
{
#[ORM\Id]
#[ORM\Column(type: 'integer')]
diff --git a/src/Domain/Subscription/Model/SubscribePage.php b/src/Domain/Subscription/Model/SubscribePage.php
index e4696380..979b3c4c 100644
--- a/src/Domain/Subscription/Model/SubscribePage.php
+++ b/src/Domain/Subscription/Model/SubscribePage.php
@@ -7,12 +7,13 @@
use Doctrine\ORM\Mapping as ORM;
use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
use PhpList\Core\Domain\Common\Model\Interfaces\Identity;
+use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface;
use PhpList\Core\Domain\Identity\Model\Administrator;
use PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository;
#[ORM\Entity(repositoryClass: SubscriberPageRepository::class)]
#[ORM\Table(name: 'phplist_subscribepage')]
-class SubscribePage implements DomainModel, Identity
+class SubscribePage implements DomainModel, Identity, OwnableInterface
{
#[ORM\Id]
#[ORM\Column(type: 'integer')]
diff --git a/src/Domain/Subscription/Model/SubscriberList.php b/src/Domain/Subscription/Model/SubscriberList.php
index 947cbe26..32f85f5d 100644
--- a/src/Domain/Subscription/Model/SubscriberList.php
+++ b/src/Domain/Subscription/Model/SubscriberList.php
@@ -12,6 +12,7 @@
use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
use PhpList\Core\Domain\Common\Model\Interfaces\Identity;
use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate;
+use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface;
use PhpList\Core\Domain\Identity\Model\Administrator;
use PhpList\Core\Domain\Messaging\Model\ListMessage;
use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository;
@@ -28,7 +29,7 @@
#[ORM\Index(name: 'nameidx', columns: ['name'])]
#[ORM\Index(name: 'listorderidx', columns: ['listorder'])]
#[ORM\HasLifecycleCallbacks]
-class SubscriberList implements DomainModel, Identity, CreationDate, ModificationDate
+class SubscriberList implements DomainModel, Identity, CreationDate, ModificationDate, OwnableInterface
{
#[ORM\Id]
#[ORM\Column(type: 'integer')]
diff --git a/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php b/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php
new file mode 100644
index 00000000..50820026
--- /dev/null
+++ b/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php
@@ -0,0 +1,35 @@
+checker = self::getContainer()->get(PermissionChecker::class);
+ }
+
+ public function testServiceIsRegisteredInContainer(): void
+ {
+ self::assertInstanceOf(PermissionChecker::class, $this->checker);
+ self::assertSame($this->checker, self::getContainer()->get(PermissionChecker::class));
+ }
+
+ public function testSuperUserCanManageAnyResource(): void
+ {
+ $admin = new Administrator();
+ $admin->setSuperUser(true);
+ $resource = $this->createMock(SubscriberList::class);
+ $this->assertTrue($this->checker->canManage($admin, $resource));
+ }
+}
From fa422581db081fbe93f20bffe8a17e8dc0526f5c Mon Sep 17 00:00:00 2001
From: TatevikGr
Date: Wed, 17 Sep 2025 12:19:14 +0400
Subject: [PATCH 07/10] Message processor (#359)
* MessageStatusEnum
* Status validate
* Embargo check
* IspRestrictions
* IspRestrictions
* SendRateLimiter
* UserMessageStatus
* Refactor
* RateLimitedCampaignMailer
* RateLimitedCampaignMailerTest
* Rate limit initialized from history
* Check maintenance mode
* Max processing time limiter
---------
Co-authored-by: Tatevik
---
config/parameters.yml.dist | 13 ++
config/services.yml | 4 -
config/services/providers.yml | 10 ++
config/services/services.yml | 19 +++
src/Domain/Common/IspRestrictionsProvider.php | 137 ++++++++++++++++
src/Domain/Common/Model/IspRestrictions.php | 21 +++
.../Service/Manager/ConfigManager.php | 6 +
.../Messaging/Command/ProcessQueueCommand.php | 17 +-
.../Model/Dto/Message/MessageMetadataDto.php | 4 +-
.../Model/Message/MessageMetadata.php | 20 ++-
.../Messaging/Model/Message/MessageStatus.php | 38 +++++
.../Model/Message/UserMessageStatus.php | 16 ++
src/Domain/Messaging/Model/UserMessage.php | 12 +-
.../Repository/MessageRepository.php | 12 ++
.../Repository/UserMessageRepository.php | 24 +++
.../Service/Builder/MessageBuilder.php | 2 +-
.../Service/Handler/RequeueHandler.php | 60 +++++++
.../Service/Manager/MessageManager.php | 8 +
.../Service/MaxProcessTimeLimiter.php | 46 ++++++
.../Service/Processor/CampaignProcessor.php | 92 +++++++++--
.../Service/RateLimitedCampaignMailer.php | 50 ++++++
.../Messaging/Service/SendRateLimiter.php | 103 ++++++++++++
.../Service/Provider/SubscriberProvider.php | 2 +-
.../Repository/MessageRepositoryTest.php | 6 +-
.../Service/SubscriberDeletionServiceTest.php | 4 +-
.../Command/ProcessQueueCommandTest.php | 20 ++-
.../Service/Builder/MessageBuilderTest.php | 15 +-
.../Builder/MessageContentBuilderTest.php | 2 +-
.../Builder/MessageFormatBuilderTest.php | 2 +-
.../Builder/MessageOptionsBuilderTest.php | 2 +-
.../Builder/MessageScheduleBuilderTest.php | 2 +-
.../Service/Handler/RequeueHandlerTest.php | 155 ++++++++++++++++++
.../Service/Manager/MessageManagerTest.php | 17 +-
.../Service/MaxProcessTimeLimiterTest.php | 53 ++++++
.../Processor/CampaignProcessorTest.php | 105 ++++++------
.../Service/RateLimitedCampaignMailerTest.php | 134 +++++++++++++++
.../Messaging/Service/SendRateLimiterTest.php | 90 ++++++++++
.../Provider/SubscriberProviderTest.php | 14 +-
38 files changed, 1207 insertions(+), 130 deletions(-)
create mode 100644 src/Domain/Common/IspRestrictionsProvider.php
create mode 100644 src/Domain/Common/Model/IspRestrictions.php
create mode 100644 src/Domain/Messaging/Model/Message/MessageStatus.php
create mode 100644 src/Domain/Messaging/Model/Message/UserMessageStatus.php
create mode 100644 src/Domain/Messaging/Service/Handler/RequeueHandler.php
create mode 100644 src/Domain/Messaging/Service/MaxProcessTimeLimiter.php
create mode 100644 src/Domain/Messaging/Service/RateLimitedCampaignMailer.php
create mode 100644 src/Domain/Messaging/Service/SendRateLimiter.php
create mode 100644 tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php
create mode 100644 tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php
diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist
index 54c649d8..e34a7d2b 100644
--- a/config/parameters.yml.dist
+++ b/config/parameters.yml.dist
@@ -68,3 +68,16 @@ parameters:
graylog_host: 'graylog.example.com'
graylog_port: 12201
+
+ app.phplist_isp_conf_path: '%%env(APP_PHPLIST_ISP_CONF_PATH)%%'
+ env(APP_PHPLIST_ISP_CONF_PATH): '/etc/phplist.conf'
+
+ # Message sending
+ messaging.mail_queue_batch_size: '%%env(MAILQUEUE_BATCH_SIZE)%%'
+ env(MAILQUEUE_BATCH_SIZE): '5'
+ messaging.mail_queue_period: '%%env(MAILQUEUE_BATCH_PERIOD)%%'
+ env(MAILQUEUE_BATCH_PERIOD): '5'
+ messaging.mail_queue_throttle: '%%env(MAILQUEUE_THROTTLE)%%'
+ env(MAILQUEUE_THROTTLE): '5'
+ messaging.max_process_time: '%%env(MESSAGING_MAX_PROCESS_TIME)%%'
+ env(MESSAGING_MAX_PROCESS_TIME): '600'
diff --git a/config/services.yml b/config/services.yml
index 47be8241..b21dc5aa 100644
--- a/config/services.yml
+++ b/config/services.yml
@@ -7,10 +7,6 @@ services:
autoconfigure: true
public: false
- PhpList\Core\Core\ConfigProvider:
- arguments:
- $config: '%app.config%'
-
PhpList\Core\Core\ApplicationStructure:
public: true
diff --git a/config/services/providers.yml b/config/services/providers.yml
index 226c4e81..bb4524c3 100644
--- a/config/services/providers.yml
+++ b/config/services/providers.yml
@@ -2,3 +2,13 @@ services:
PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider:
autowire: true
autoconfigure: true
+
+ PhpList\Core\Core\ConfigProvider:
+ arguments:
+ $config: '%app.config%'
+
+ PhpList\Core\Domain\Common\IspRestrictionsProvider:
+ autowire: true
+ autoconfigure: true
+ arguments:
+ $confPath: '%app.phplist_isp_conf_path%'
diff --git a/config/services/services.yml b/config/services/services.yml
index f1b68e74..1afd1fc5 100644
--- a/config/services/services.yml
+++ b/config/services/services.yml
@@ -36,6 +36,14 @@ services:
autoconfigure: true
public: true
+ PhpList\Core\Domain\Messaging\Service\SendRateLimiter:
+ autowire: true
+ autoconfigure: true
+ arguments:
+ $mailqueueBatchSize: '%messaging.mail_queue_batch_size%'
+ $mailqueueBatchPeriod: '%messaging.mail_queue_period%'
+ $mailqueueThrottle: '%messaging.mail_queue_throttle%'
+
PhpList\Core\Domain\Common\ClientIpResolver:
autowire: true
autoconfigure: true
@@ -44,6 +52,10 @@ services:
autowire: true
autoconfigure: true
+ PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer:
+ autowire: true
+ autoconfigure: true
+
PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler:
autowire: true
autoconfigure: true
@@ -108,6 +120,13 @@ services:
arguments:
- !tagged_iterator { tag: 'phplist.bounce_action_handler' }
+ PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter:
+ autowire: true
+ autoconfigure: true
+ arguments:
+ $maxSeconds: '%messaging.max_process_time%'
+
+
PhpList\Core\Domain\Identity\Service\PermissionChecker:
autowire: true
autoconfigure: true
diff --git a/src/Domain/Common/IspRestrictionsProvider.php b/src/Domain/Common/IspRestrictionsProvider.php
new file mode 100644
index 00000000..4095f5ce
--- /dev/null
+++ b/src/Domain/Common/IspRestrictionsProvider.php
@@ -0,0 +1,137 @@
+readConfigFile();
+ if ($contents === null) {
+ return new IspRestrictions(null, null, null);
+ }
+
+ [$raw, $maxBatch, $minBatchPeriod, $lockFile] = $this->parseContents($contents);
+
+ $this->logIfDetected($maxBatch, $minBatchPeriod, $lockFile);
+
+ return new IspRestrictions($maxBatch, $minBatchPeriod, $lockFile, $raw);
+ }
+
+ private function readConfigFile(): ?string
+ {
+ if (!is_file($this->confPath) || !is_readable($this->confPath)) {
+ return null;
+ }
+ $contents = file_get_contents($this->confPath);
+ if ($contents === false) {
+ $this->logger->warning('Cannot read ISP restrictions file', ['path' => $this->confPath]);
+ return null;
+ }
+ return $contents;
+ }
+
+ /**
+ * @return array{0: array, 1: ?int, 2: ?int, 3: ?string}
+ */
+ private function parseContents(string $contents): array
+ {
+ $maxBatch = null;
+ $minBatchPeriod = null;
+ $lockFile = null;
+ $raw = [];
+
+ foreach (preg_split('/\R/', $contents) as $line) {
+ [$key, $val] = $this->parseLine($line);
+ if ($key === null) {
+ continue;
+ }
+ $raw[$key] = $val;
+ [$maxBatch, $minBatchPeriod, $lockFile] = $this->applyKeyValue(
+ $key,
+ $val,
+ $maxBatch,
+ $minBatchPeriod,
+ $lockFile
+ );
+ }
+
+ return [$raw, $maxBatch, $minBatchPeriod, $lockFile];
+ }
+
+ /**
+ * @return array{0: ?string, 1: string}
+ */
+ private function parseLine(string $line): array
+ {
+ $line = trim($line);
+ if ($line === '' || str_starts_with($line, '#') || str_starts_with($line, ';')) {
+ return [null, ''];
+ }
+ $parts = explode('=', $line, 2);
+ if (\count($parts) !== 2) {
+ return [null, ''];
+ }
+
+ return array_map('trim', $parts);
+ }
+
+ /**
+ * @param string $key
+ * @param string $val
+ * @param ?int $maxBatch
+ * @param ?int $minBatchPeriod
+ * @param ?string $lockFile
+ * @return array{0: ?int, 1: ?int, 2: ?string}
+ */
+ private function applyKeyValue(
+ string $key,
+ string $val,
+ ?int $maxBatch,
+ ?int $minBatchPeriod,
+ ?string $lockFile
+ ): array {
+ if ($key === 'maxbatch') {
+ if ($val !== '' && ctype_digit($val)) {
+ $maxBatch = (int) $val;
+ }
+ return [$maxBatch, $minBatchPeriod, $lockFile];
+ }
+ if ($key === 'minbatchperiod') {
+ if ($val !== '' && ctype_digit($val)) {
+ $minBatchPeriod = (int) $val;
+ }
+ return [$maxBatch, $minBatchPeriod, $lockFile];
+ }
+ if ($key === 'lockfile') {
+ if ($val !== '') {
+ $lockFile = $val;
+ }
+ return [$maxBatch, $minBatchPeriod, $lockFile];
+ }
+ return [$maxBatch, $minBatchPeriod, $lockFile];
+ }
+
+ private function logIfDetected(?int $maxBatch, ?int $minBatchPeriod, ?string $lockFile): void
+ {
+ if ($maxBatch !== null || $minBatchPeriod !== null || $lockFile !== null) {
+ $this->logger->info('ISP restrictions detected', [
+ 'path' => $this->confPath,
+ 'maxbatch' => $maxBatch,
+ 'minbatchperiod' => $minBatchPeriod,
+ 'lockfile' => $lockFile,
+ ]);
+ }
+ }
+}
diff --git a/src/Domain/Common/Model/IspRestrictions.php b/src/Domain/Common/Model/IspRestrictions.php
new file mode 100644
index 00000000..c3fc56b4
--- /dev/null
+++ b/src/Domain/Common/Model/IspRestrictions.php
@@ -0,0 +1,21 @@
+maxBatch === null && $this->minBatchPeriod === null && $this->lockFile === null;
+ }
+}
diff --git a/src/Domain/Configuration/Service/Manager/ConfigManager.php b/src/Domain/Configuration/Service/Manager/ConfigManager.php
index cae380be..1a9c356f 100644
--- a/src/Domain/Configuration/Service/Manager/ConfigManager.php
+++ b/src/Domain/Configuration/Service/Manager/ConfigManager.php
@@ -17,6 +17,12 @@ public function __construct(ConfigRepository $configRepository)
$this->configRepository = $configRepository;
}
+ public function inMaintenanceMode(): bool
+ {
+ $config = $this->getByItem('maintenancemode');
+ return $config?->getValue() === '1';
+ }
+
/**
* Get a configuration item by its key
*/
diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php
index 820d403d..d2c7cbfa 100644
--- a/src/Domain/Messaging/Command/ProcessQueueCommand.php
+++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php
@@ -4,6 +4,9 @@
namespace PhpList\Core\Domain\Messaging\Command;
+use DateTimeImmutable;
+use PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager;
+use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus;
use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator;
use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor;
@@ -24,18 +27,21 @@ class ProcessQueueCommand extends Command
private LockFactory $lockFactory;
private MessageProcessingPreparator $messagePreparator;
private CampaignProcessor $campaignProcessor;
+ private ConfigManager $configManager;
public function __construct(
MessageRepository $messageRepository,
LockFactory $lockFactory,
MessageProcessingPreparator $messagePreparator,
CampaignProcessor $campaignProcessor,
+ ConfigManager $configManager
) {
parent::__construct();
$this->messageRepository = $messageRepository;
$this->lockFactory = $lockFactory;
$this->messagePreparator = $messagePreparator;
$this->campaignProcessor = $campaignProcessor;
+ $this->configManager = $configManager;
}
/**
@@ -50,11 +56,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return Command::FAILURE;
}
+ if ($this->configManager->inMaintenanceMode()) {
+ $output->writeln('The system is in maintenance mode, stopping. Try again later.');
+
+ return Command::FAILURE;
+ }
+
try {
$this->messagePreparator->ensureSubscribersHaveUuid($output);
$this->messagePreparator->ensureCampaignsHaveUuid($output);
- $campaigns = $this->messageRepository->findBy(['status' => 'submitted']);
+ $campaigns = $this->messageRepository->getByStatusAndEmbargo(
+ status: MessageStatus::Submitted,
+ embargo: new DateTimeImmutable()
+ );
foreach ($campaigns as $campaign) {
$this->campaignProcessor->process($campaign, $output);
diff --git a/src/Domain/Messaging/Model/Dto/Message/MessageMetadataDto.php b/src/Domain/Messaging/Model/Dto/Message/MessageMetadataDto.php
index 0776c1d1..91802df2 100644
--- a/src/Domain/Messaging/Model/Dto/Message/MessageMetadataDto.php
+++ b/src/Domain/Messaging/Model/Dto/Message/MessageMetadataDto.php
@@ -4,10 +4,12 @@
namespace PhpList\Core\Domain\Messaging\Model\Dto\Message;
+use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus;
+
class MessageMetadataDto
{
public function __construct(
- public readonly string $status,
+ public readonly MessageStatus $status,
) {
}
}
diff --git a/src/Domain/Messaging/Model/Message/MessageMetadata.php b/src/Domain/Messaging/Model/Message/MessageMetadata.php
index 123103ff..156539b2 100644
--- a/src/Domain/Messaging/Model/Message/MessageMetadata.php
+++ b/src/Domain/Messaging/Model/Message/MessageMetadata.php
@@ -6,6 +6,7 @@
use DateTime;
use Doctrine\ORM\Mapping as ORM;
+use InvalidArgumentException;
use PhpList\Core\Domain\Common\Model\Interfaces\EmbeddableInterface;
#[ORM\Embeddable]
@@ -33,13 +34,13 @@ class MessageMetadata implements EmbeddableInterface
private ?DateTime $sendStart;
public function __construct(
- ?string $status = null,
+ ?MessageStatus $status = null,
int $bounceCount = 0,
?DateTime $entered = null,
?DateTime $sent = null,
?DateTime $sendStart = null,
) {
- $this->status = $status;
+ $this->status = $status->value ?? null;
$this->processed = false;
$this->viewed = 0;
$this->bounceCount = $bounceCount;
@@ -48,14 +49,21 @@ public function __construct(
$this->sendStart = $sendStart;
}
- public function getStatus(): ?string
+ /**
+ * @SuppressWarnings("PHPMD.StaticAccess")
+ */
+ public function getStatus(): ?MessageStatus
{
- return $this->status;
+ return MessageStatus::from($this->status);
}
- public function setStatus(string $status): self
+ public function setStatus(MessageStatus $status): self
{
- $this->status = $status;
+ if (!$this->getStatus()->canTransitionTo($status)) {
+ throw new InvalidArgumentException('Invalid status transition');
+ }
+ $this->status = $status->value;
+
return $this;
}
diff --git a/src/Domain/Messaging/Model/Message/MessageStatus.php b/src/Domain/Messaging/Model/Message/MessageStatus.php
new file mode 100644
index 00000000..90b7f987
--- /dev/null
+++ b/src/Domain/Messaging/Model/Message/MessageStatus.php
@@ -0,0 +1,38 @@
+ [self::Submitted],
+ self::Submitted => [self::Prepared, self::InProcess],
+ self::Prepared => [self::InProcess],
+ self::InProcess => [self::Sent, self::Suspended, self::Submitted],
+ self::Requeued => [self::InProcess, self::Suspended],
+ self::Sent => [],
+ };
+ }
+
+ public function canTransitionTo(self $next): bool
+ {
+ return in_array($next, $this->allowedTransitions(), true);
+ }
+}
diff --git a/src/Domain/Messaging/Model/Message/UserMessageStatus.php b/src/Domain/Messaging/Model/Message/UserMessageStatus.php
new file mode 100644
index 00000000..1237cfe8
--- /dev/null
+++ b/src/Domain/Messaging/Model/Message/UserMessageStatus.php
@@ -0,0 +1,16 @@
+viewed;
}
- public function getStatus(): ?string
+ /**
+ * @SuppressWarnings("PHPMD.StaticAccess")
+ */
+ public function getStatus(): ?UserMessageStatus
{
- return $this->status;
+ return UserMessageStatus::from($this->status);
}
public function setViewed(?DateTime $viewed): self
@@ -76,9 +80,9 @@ public function setViewed(?DateTime $viewed): self
return $this;
}
- public function setStatus(?string $status): self
+ public function setStatus(?UserMessageStatus $status): self
{
- $this->status = $status;
+ $this->status = $status->value;
return $this;
}
}
diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php
index 3da7ebf3..0ae8bc18 100644
--- a/src/Domain/Messaging/Repository/MessageRepository.php
+++ b/src/Domain/Messaging/Repository/MessageRepository.php
@@ -4,6 +4,7 @@
namespace PhpList\Core\Domain\Messaging\Repository;
+use DateTimeImmutable;
use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface;
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
@@ -74,4 +75,15 @@ public function incrementBounceCount(int $messageId): void
->getQuery()
->execute();
}
+
+ public function getByStatusAndEmbargo(Message\MessageStatus $status, DateTimeImmutable $embargo): array
+ {
+ return $this->createQueryBuilder('m')
+ ->where('m.status = :status')
+ ->andWhere('m.embargo IS NULL OR m.embargo <= :embargo')
+ ->setParameter('status', $status->value)
+ ->setParameter('embargo', $embargo)
+ ->getQuery()
+ ->getResult();
+ }
}
diff --git a/src/Domain/Messaging/Repository/UserMessageRepository.php b/src/Domain/Messaging/Repository/UserMessageRepository.php
index a19c5823..e8268025 100644
--- a/src/Domain/Messaging/Repository/UserMessageRepository.php
+++ b/src/Domain/Messaging/Repository/UserMessageRepository.php
@@ -4,8 +4,32 @@
namespace PhpList\Core\Domain\Messaging\Repository;
+use DateTimeInterface;
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
+use PhpList\Core\Domain\Messaging\Model\Message;
+use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus;
+use PhpList\Core\Domain\Messaging\Model\UserMessage;
+use PhpList\Core\Domain\Subscription\Model\Subscriber;
class UserMessageRepository extends AbstractRepository
{
+ public function findOneByUserAndMessage(Subscriber $subscriber, Message $campaign): ?UserMessage
+ {
+ return $this->findOneBy(['user' => $subscriber, 'message' => $campaign]);
+ }
+
+ /**
+ * Counts how many user messages have status "sent" since the given time.
+ */
+ public function countSentSince(DateTimeInterface $since): int
+ {
+ $queryBuilder = $this->createQueryBuilder('um');
+ $queryBuilder->select('COUNT(um)')
+ ->where('um.createdAt > :since')
+ ->andWhere('um.status = :status')
+ ->setParameter('since', $since)
+ ->setParameter('status', UserMessageStatus::Sent->value);
+
+ return (int) $queryBuilder->getQuery()->getSingleScalarResult();
+ }
}
diff --git a/src/Domain/Messaging/Service/Builder/MessageBuilder.php b/src/Domain/Messaging/Service/Builder/MessageBuilder.php
index e8807170..85e74331 100644
--- a/src/Domain/Messaging/Service/Builder/MessageBuilder.php
+++ b/src/Domain/Messaging/Service/Builder/MessageBuilder.php
@@ -45,7 +45,7 @@ public function build(MessageDtoInterface $createMessageDto, object $context = n
return $context->getExisting();
}
- $metadata = new Message\MessageMetadata($createMessageDto->getMetadata()->status);
+ $metadata = new Message\MessageMetadata(Message\MessageStatus::Draft);
return new Message($format, $schedule, $metadata, $content, $options, $context->getOwner(), $template);
}
diff --git a/src/Domain/Messaging/Service/Handler/RequeueHandler.php b/src/Domain/Messaging/Service/Handler/RequeueHandler.php
new file mode 100644
index 00000000..6a1d9d95
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/RequeueHandler.php
@@ -0,0 +1,60 @@
+getSchedule();
+ $interval = $schedule->getRequeueInterval() ?? 0;
+ $until = $schedule->getRequeueUntil();
+
+ if ($interval <= 0) {
+ return false;
+ }
+ $now = new DateTime();
+ if ($until instanceof DateTime && $now > $until) {
+ return false;
+ }
+
+ $embargoIsInFuture = $schedule->getEmbargo() instanceof DateTime && $schedule->getEmbargo() > new DateTime();
+ $base = $embargoIsInFuture ? clone $schedule->getEmbargo() : new DateTime();
+ $next = (clone $base)->add(new DateInterval('PT' . max(1, $interval) . 'M'));
+ if ($until instanceof DateTime && $next > $until) {
+ return false;
+ }
+
+ $schedule->setEmbargo($next);
+ $campaign->setSchedule($schedule);
+ $campaign->getMetadata()->setStatus(MessageStatus::Submitted);
+ $this->entityManager->flush();
+
+ $output?->writeln(sprintf(
+ 'Requeued campaign; next embargo at %s',
+ $next->format(DateTime::ATOM)
+ ));
+ $this->logger->info('Campaign requeued with new embargo', [
+ 'campaign_id' => $campaign->getId(),
+ 'embargo' => $next->format(DateTime::ATOM),
+ ]);
+
+ return true;
+ }
+}
diff --git a/src/Domain/Messaging/Service/Manager/MessageManager.php b/src/Domain/Messaging/Service/Manager/MessageManager.php
index 7b263083..c73f31ca 100644
--- a/src/Domain/Messaging/Service/Manager/MessageManager.php
+++ b/src/Domain/Messaging/Service/Manager/MessageManager.php
@@ -43,6 +43,14 @@ public function updateMessage(
return $message;
}
+ public function updateStatus(Message $message, Message\MessageStatus $status): Message
+ {
+ $message->getMetadata()->setStatus($status);
+ $this->messageRepository->save($message);
+
+ return $message;
+ }
+
public function delete(Message $message): void
{
$this->messageRepository->remove($message);
diff --git a/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php b/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php
new file mode 100644
index 00000000..c5269aaa
--- /dev/null
+++ b/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php
@@ -0,0 +1,46 @@
+maxSeconds = $maxSeconds ?? 600;
+ }
+
+ public function start(): void
+ {
+ $this->startedAt = microtime(true);
+ }
+
+ public function shouldStop(?OutputInterface $output = null): bool
+ {
+ if ($this->maxSeconds <= 0) {
+ return false;
+ }
+ if ($this->startedAt <= 0.0) {
+ $this->start();
+ }
+ $elapsed = microtime(true) - $this->startedAt;
+ if ($elapsed >= $this->maxSeconds) {
+ $this->logger->warning(sprintf('Reached max processing time of %d seconds', $this->maxSeconds));
+ $output?->writeln('Reached max processing time; stopping cleanly.');
+
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/Domain/Messaging/Service/Processor/CampaignProcessor.php b/src/Domain/Messaging/Service/Processor/CampaignProcessor.php
index 13a100a3..92313e28 100644
--- a/src/Domain/Messaging/Service/Processor/CampaignProcessor.php
+++ b/src/Domain/Messaging/Service/Processor/CampaignProcessor.php
@@ -6,70 +6,126 @@
use Doctrine\ORM\EntityManagerInterface;
use PhpList\Core\Domain\Messaging\Model\Message;
+use PhpList\Core\Domain\Messaging\Model\UserMessage;
+use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus;
+use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus;
+use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository;
+use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler;
+use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer;
+use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter;
use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator;
use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider;
+use PhpList\Core\Domain\Subscription\Model\Subscriber;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Mailer\MailerInterface;
-use Symfony\Component\Mime\Email;
use Throwable;
+/**
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
+ */
class CampaignProcessor
{
- private MailerInterface $mailer;
+ private RateLimitedCampaignMailer $mailer;
private EntityManagerInterface $entityManager;
private SubscriberProvider $subscriberProvider;
private MessageProcessingPreparator $messagePreparator;
private LoggerInterface $logger;
+ private UserMessageRepository $userMessageRepository;
+ private MaxProcessTimeLimiter $timeLimiter;
+ private RequeueHandler $requeueHandler;
public function __construct(
- MailerInterface $mailer,
+ RateLimitedCampaignMailer $mailer,
EntityManagerInterface $entityManager,
SubscriberProvider $subscriberProvider,
MessageProcessingPreparator $messagePreparator,
LoggerInterface $logger,
+ UserMessageRepository $userMessageRepository,
+ MaxProcessTimeLimiter $timeLimiter,
+ RequeueHandler $requeueHandler
) {
$this->mailer = $mailer;
$this->entityManager = $entityManager;
$this->subscriberProvider = $subscriberProvider;
$this->messagePreparator = $messagePreparator;
$this->logger = $logger;
+ $this->userMessageRepository = $userMessageRepository;
+ $this->timeLimiter = $timeLimiter;
+ $this->requeueHandler = $requeueHandler;
}
public function process(Message $campaign, ?OutputInterface $output = null): void
{
+ $this->updateMessageStatus($campaign, MessageStatus::Prepared);
$subscribers = $this->subscriberProvider->getSubscribersForMessage($campaign);
- // phpcs:ignore Generic.Commenting.Todo
- // @todo check $ISPrestrictions logic
+
+ $this->updateMessageStatus($campaign, MessageStatus::InProcess);
+
+ $this->timeLimiter->start();
+ $stoppedEarly = false;
+
foreach ($subscribers as $subscriber) {
+ if ($this->timeLimiter->shouldStop($output)) {
+ $stoppedEarly = true;
+ break;
+ }
+
+ $existing = $this->userMessageRepository->findOneByUserAndMessage($subscriber, $campaign);
+ if ($existing && $existing->getStatus() !== UserMessageStatus::Todo) {
+ continue;
+ }
+
+ $userMessage = $existing ?? new UserMessage($subscriber, $campaign);
+ $userMessage->setStatus(UserMessageStatus::Active);
+ $this->userMessageRepository->save($userMessage);
+
if (!filter_var($subscriber->getEmail(), FILTER_VALIDATE_EMAIL)) {
+ $this->updateUserMessageStatus($userMessage, UserMessageStatus::InvalidEmailAddress);
+ $this->unconfirmSubscriber($subscriber);
+ $output?->writeln('Invalid email, marking unconfirmed: ' . $subscriber->getEmail());
continue;
}
- $this->messagePreparator->processMessageLinks($campaign, $subscriber->getId());
- $email = (new Email())
- ->from('news@example.com')
- ->to($subscriber->getEmail())
- ->subject($campaign->getContent()->getSubject())
- ->text($campaign->getContent()->getTextMessage())
- ->html($campaign->getContent()->getText());
+
+ $processed = $this->messagePreparator->processMessageLinks($campaign, $subscriber->getId());
try {
+ $email = $this->mailer->composeEmail($processed, $subscriber);
$this->mailer->send($email);
-
- // phpcs:ignore Generic.Commenting.Todo
- // @todo log somewhere that this subscriber got email
+ $this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent);
} catch (Throwable $e) {
+ $this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent);
$this->logger->error($e->getMessage(), [
'subscriber_id' => $subscriber->getId(),
'campaign_id' => $campaign->getId(),
]);
$output?->writeln('Failed to send to: ' . $subscriber->getEmail());
}
+ }
- usleep(100000);
+ if ($stoppedEarly && $this->requeueHandler->handle($campaign, $output)) {
+ return;
}
- $campaign->getMetadata()->setStatus('sent');
+ $this->updateMessageStatus($campaign, MessageStatus::Sent);
+ }
+
+ private function unconfirmSubscriber(Subscriber $subscriber): void
+ {
+ if ($subscriber->isConfirmed()) {
+ $subscriber->setConfirmed(false);
+ $this->entityManager->flush();
+ }
+ }
+
+ private function updateMessageStatus(Message $message, MessageStatus $status): void
+ {
+ $message->getMetadata()->setStatus($status);
+ $this->entityManager->flush();
+ }
+
+ private function updateUserMessageStatus(UserMessage $userMessage, UserMessageStatus $status): void
+ {
+ $userMessage->setStatus($status);
$this->entityManager->flush();
}
}
diff --git a/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php b/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php
new file mode 100644
index 00000000..7691f970
--- /dev/null
+++ b/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php
@@ -0,0 +1,50 @@
+mailer = $mailer;
+ $this->limiter = $limiter;
+ }
+
+ public function composeEmail(Message $processed, Subscriber $subscriber): Email
+ {
+ $email = new Email();
+ if ($processed->getOptions()->getFromField() !== '') {
+ $email->from($processed->getOptions()->getFromField());
+ }
+
+ if ($processed->getOptions()->getReplyTo() !== '') {
+ $email->replyTo($processed->getOptions()->getReplyTo());
+ }
+
+ return $email
+ ->to($subscriber->getEmail())
+ ->subject($processed->getContent()->getSubject())
+ ->text($processed->getContent()->getTextMessage())
+ ->html($processed->getContent()->getText());
+ }
+
+ /**
+ * @throws TransportExceptionInterface
+ */
+ public function send(Email $email): void
+ {
+ $this->limiter->awaitTurn();
+ $this->mailer->send($email);
+ $this->limiter->afterSend();
+ }
+}
diff --git a/src/Domain/Messaging/Service/SendRateLimiter.php b/src/Domain/Messaging/Service/SendRateLimiter.php
new file mode 100644
index 00000000..378b80d5
--- /dev/null
+++ b/src/Domain/Messaging/Service/SendRateLimiter.php
@@ -0,0 +1,103 @@
+initializeLimits();
+ }
+
+ private function initializeLimits(): void
+ {
+ $isp = $this->ispRestrictionsProvider->load();
+
+ $cfgBatch = $this->mailqueueBatchSize ?? 0;
+ $ispMax = isset($isp->maxBatch) ? (int)$isp->maxBatch : null;
+
+ $cfgPeriod = $this->mailqueueBatchPeriod ?? 0;
+ $ispMinPeriod = $isp->minBatchPeriod ?? 0;
+
+ $cfgThrottle = $this->mailqueueThrottle ?? 0;
+ $ispMinThrottle = (int)($isp->minThrottle ?? 0);
+
+ if ($cfgBatch <= 0) {
+ $this->batchSize = $ispMax !== null ? max(0, $ispMax) : 0;
+ } else {
+ $this->batchSize = $ispMax !== null ? min($cfgBatch, max(1, $ispMax)) : $cfgBatch;
+ }
+ $this->batchPeriod = max(0, $cfgPeriod, $ispMinPeriod);
+ $this->throttleSec = max(0, $cfgThrottle, $ispMinThrottle);
+
+ $this->sentInBatch = 0;
+ $this->batchStart = microtime(true);
+ $this->initializedFromHistory = false;
+ }
+
+ /**
+ * Call before attempting to send another message. It will sleep if needed to
+ * respect batch limits. Returns true when it's okay to proceed.
+ */
+ public function awaitTurn(?OutputInterface $output = null): bool
+ {
+ if (!$this->initializedFromHistory && $this->batchSize > 0 && $this->batchPeriod > 0) {
+ $since = (new DateTimeImmutable())->sub(new DateInterval('PT' . $this->batchPeriod . 'S'));
+ $alreadySent = $this->userMessageRepository->countSentSince($since);
+ $this->sentInBatch = max($this->sentInBatch, $alreadySent);
+ $this->initializedFromHistory = true;
+ }
+
+ if ($this->batchSize > 0 && $this->batchPeriod > 0 && $this->sentInBatch >= $this->batchSize) {
+ $elapsed = microtime(true) - $this->batchStart;
+ $remaining = (int)ceil($this->batchPeriod - $elapsed);
+ if ($remaining > 0) {
+ $output?->writeln(sprintf(
+ 'Batch limit reached, sleeping %ds to respect MAILQUEUE_BATCH_PERIOD',
+ $remaining
+ ));
+ sleep($remaining);
+ }
+ $this->batchStart = microtime(true);
+ $this->sentInBatch = 0;
+ $this->initializedFromHistory = false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Call after a successful sending to update counters and apply per-message throttle.
+ */
+ public function afterSend(): void
+ {
+ $this->sentInBatch++;
+ if ($this->throttleSec > 0) {
+ sleep($this->throttleSec);
+ }
+ }
+}
diff --git a/src/Domain/Subscription/Service/Provider/SubscriberProvider.php b/src/Domain/Subscription/Service/Provider/SubscriberProvider.php
index 5ec6b177..72b473be 100644
--- a/src/Domain/Subscription/Service/Provider/SubscriberProvider.php
+++ b/src/Domain/Subscription/Service/Provider/SubscriberProvider.php
@@ -36,7 +36,7 @@ public function getSubscribersForMessage(Message $message): array
foreach ($lists as $list) {
$listSubscribers = $this->subscriberRepository->getSubscribersBySubscribedListId($list->getId());
foreach ($listSubscribers as $subscriber) {
- $subscribers[$subscriber->getId()] = $subscriber;
+ $subscribers[$subscriber->getEmail()] = $subscriber;
}
}
diff --git a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php
index d7435815..1bd98735 100644
--- a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php
+++ b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php
@@ -48,7 +48,7 @@ public function testMessageIsPersistedAndFetchedCorrectly(): void
$message = new Message(
new MessageFormat(true, 'text'),
new MessageSchedule(1, null, 3, null, null),
- new MessageMetadata('done'),
+ new MessageMetadata(Message\MessageStatus::Sent),
new MessageContent('Hello world!'),
new MessageOptions(),
$admin
@@ -62,7 +62,7 @@ public function testMessageIsPersistedAndFetchedCorrectly(): void
self::assertCount(1, $foundMessages);
self::assertInstanceOf(Message::class, $foundMessages[0]);
- self::assertSame('done', $foundMessages[0]->getMetadata()->getStatus());
+ self::assertSame(Message\MessageStatus::Sent, $foundMessages[0]->getMetadata()->getStatus());
self::assertSame('Hello world!', $foundMessages[0]->getContent()->getSubject());
}
@@ -77,7 +77,7 @@ public function testGetByOwnerIdReturnsOnlyOwnedMessages(): void
$msg1 = new Message(
new MessageFormat(true, MessageFormat::FORMAT_TEXT),
new MessageSchedule(1, null, 3, null, null),
- new MessageMetadata('done'),
+ new MessageMetadata(Message\MessageStatus::Sent),
new MessageContent('Owned by Admin 1!'),
new MessageOptions(),
$admin1
diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php
index b3bfda0c..9019fd30 100644
--- a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php
+++ b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php
@@ -58,7 +58,7 @@ public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError():
$msg = new Message(
format: new MessageFormat(true, MessageFormat::FORMAT_TEXT),
schedule: new MessageSchedule(1, null, 3, null, null),
- metadata: new MessageMetadata('done'),
+ metadata: new MessageMetadata(Message\MessageStatus::Sent),
content: new MessageContent('Owned by Admin 1!'),
options: new MessageOptions(),
owner: $admin
@@ -92,7 +92,7 @@ public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError():
$this->entityManager->persist($linkTrackUmlClick);
$userMessage = new UserMessage($subscriber, $msg);
- $userMessage->setStatus('sent');
+ $userMessage->setStatus(Message\UserMessageStatus::Sent);
$this->entityManager->persist($userMessage);
$userMessageBounce = new UserMessageBounce(1, new DateTime());
diff --git a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php
index 79ece9bd..d76f63c0 100644
--- a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php
+++ b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php
@@ -5,6 +5,7 @@
namespace PhpList\Core\Tests\Unit\Domain\Messaging\Command;
use Exception;
+use PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager;
use PhpList\Core\Domain\Messaging\Command\ProcessQueueCommand;
use PhpList\Core\Domain\Messaging\Model\Message;
use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
@@ -41,7 +42,8 @@ protected function setUp(): void
$this->messageRepository,
$lockFactory,
$this->messageProcessingPreparator,
- $this->campaignProcessor
+ $this->campaignProcessor,
+ $this->createMock(ConfigManager::class),
);
$application = new Application();
@@ -82,8 +84,8 @@ public function testExecuteWithNoCampaigns(): void
->method('ensureCampaignsHaveUuid');
$this->messageRepository->expects($this->once())
- ->method('findBy')
- ->with(['status' => 'submitted'])
+ ->method('getByStatusAndEmbargo')
+ ->with($this->anything(), $this->anything())
->willReturn([]);
$this->campaignProcessor->expects($this->never())
@@ -112,8 +114,8 @@ public function testExecuteWithCampaigns(): void
$campaign = $this->createMock(Message::class);
$this->messageRepository->expects($this->once())
- ->method('findBy')
- ->with(['status' => 'submitted'])
+ ->method('getByStatusAndEmbargo')
+ ->with($this->anything(), $this->anything())
->willReturn([$campaign]);
$this->campaignProcessor->expects($this->once())
@@ -145,8 +147,8 @@ public function testExecuteWithMultipleCampaigns(): void
$campaign2 = $this->createMock(Message::class);
$this->messageRepository->expects($this->once())
- ->method('findBy')
- ->with(['status' => 'submitted'])
+ ->method('getByStatusAndEmbargo')
+ ->with($this->anything(), $this->anything())
->willReturn([$campaign1, $campaign2]);
$this->campaignProcessor->expects($this->exactly(2))
@@ -179,8 +181,8 @@ public function testExecuteWithProcessorException(): void
$campaign = $this->createMock(Message::class);
$this->messageRepository->expects($this->once())
- ->method('findBy')
- ->with(['status' => 'submitted'])
+ ->method('getByStatusAndEmbargo')
+ ->with($this->anything(), $this->anything())
->willReturn([$campaign]);
$this->campaignProcessor->expects($this->once())
diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php
index 564bd34d..d99d041a 100644
--- a/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php
@@ -2,9 +2,8 @@
declare(strict_types=1);
-namespace PhpList\Core\Tests\Unit\Domain\Service\Builder;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder;
-use Error;
use InvalidArgumentException;
use PhpList\Core\Domain\Identity\Model\Administrator;
use PhpList\Core\Domain\Messaging\Model\Dto\CreateMessageDto;
@@ -64,7 +63,7 @@ private function createRequest(): CreateMessageDto
formatOptions: []
),
metadata: new MessageMetadataDto(
- status: 'draft'
+ status: Message\MessageStatus::Draft
),
options: new MessageOptionsDto(
fromField: '',
@@ -117,16 +116,6 @@ public function testBuildsNewMessage(): void
$this->builder->build($request, $context);
}
- public function testThrowsExceptionOnInvalidRequest(): void
- {
- $this->expectException(Error::class);
-
- $this->builder->build(
- $this->createMock(CreateMessageDto::class),
- new MessageContext($this->createMock(Administrator::class))
- );
- }
-
public function testThrowsExceptionOnInvalidContext(): void
{
$this->expectException(InvalidArgumentException::class);
diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php
index 2b1aa771..21f90692 100644
--- a/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace PhpList\Core\Tests\Unit\Domain\Service\Builder;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder;
use InvalidArgumentException;
use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageContentDto;
diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php
index 8d9320a0..1bd576f5 100644
--- a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace PhpList\Core\Tests\Unit\Domain\Service\Builder;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder;
use InvalidArgumentException;
use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageFormatDto;
diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php
index c6795d29..754177a2 100644
--- a/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace PhpList\Core\Tests\Unit\Domain\Service\Builder;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder;
use InvalidArgumentException;
use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageOptionsDto;
diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php
index 38f04338..25a89052 100644
--- a/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace PhpList\Core\Tests\Unit\Domain\Service\Builder;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder;
use DateTime;
use InvalidArgumentException;
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php
new file mode 100644
index 00000000..5bfb1114
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php
@@ -0,0 +1,155 @@
+logger = $this->createMock(LoggerInterface::class);
+ $this->em = $this->createMock(EntityManagerInterface::class);
+ $this->output = $this->createMock(OutputInterface::class);
+ }
+
+ private function createMessage(
+ ?int $requeueInterval,
+ ?DateTime $requeueUntil,
+ ?DateTime $embargo
+ ): Message {
+ $format = new MessageFormat(htmlFormatted: false, sendFormat: null);
+ $schedule = new MessageSchedule(
+ repeatInterval: null,
+ repeatUntil: null,
+ requeueInterval: $requeueInterval,
+ requeueUntil: $requeueUntil,
+ embargo: $embargo
+ );
+ $metadata = new MessageMetadata(MessageStatus::Draft);
+ $content = new MessageContent('(no subject)');
+ $options = new MessageOptions();
+
+ return new Message($format, $schedule, $metadata, $content, $options, owner: null, template: null);
+ }
+
+ public function testReturnsFalseWhenIntervalIsZeroOrNegative(): void
+ {
+ $handler = new RequeueHandler($this->logger, $this->em);
+ $message = $this->createMessage(0, null, null);
+
+ $this->em->expects($this->never())->method('flush');
+ $this->output->expects($this->never())->method('writeln');
+ $this->logger->expects($this->never())->method('info');
+
+ $result = $handler->handle($message, $this->output);
+
+ $this->assertFalse($result);
+ $this->assertSame(MessageStatus::Draft, $message->getMetadata()->getStatus());
+ }
+
+ public function testReturnsFalseWhenNowIsAfterRequeueUntil(): void
+ {
+ $handler = new RequeueHandler($this->logger, $this->em);
+ $past = (new DateTime())->sub(new DateInterval('PT5M'));
+ $message = $this->createMessage(5, $past, null);
+
+ $this->em->expects($this->never())->method('flush');
+ $this->logger->expects($this->never())->method('info');
+
+ $result = $handler->handle($message, $this->output);
+
+ $this->assertFalse($result);
+ $this->assertSame(MessageStatus::Draft, $message->getMetadata()->getStatus());
+ }
+
+ public function testRequeuesFromFutureEmbargoAndSetsSubmittedStatus(): void
+ {
+ $handler = new RequeueHandler($this->logger, $this->em);
+ $embargo = (new DateTime())->add(new DateInterval('PT5M'));
+ $interval = 10;
+ $message = $this->createMessage($interval, null, $embargo);
+
+ $this->em->expects($this->once())->method('flush');
+ $this->output->expects($this->once())->method('writeln');
+ $this->logger->expects($this->once())->method('info');
+
+ $result = $handler->handle($message, $this->output);
+
+ $this->assertTrue($result);
+ $this->assertSame(MessageStatus::Submitted, $message->getMetadata()->getStatus());
+
+ $expectedNext = (clone $embargo)->add(new DateInterval('PT' . $interval . 'M'));
+ $actualNext = $message->getSchedule()->getEmbargo();
+ $this->assertInstanceOf(DateTime::class, $actualNext);
+ $this->assertEquals($expectedNext->format(DateTime::ATOM), $actualNext->format(DateTime::ATOM));
+ }
+
+ public function testRequeuesFromNowWhenEmbargoIsNullOrPast(): void
+ {
+ $handler = new RequeueHandler($this->logger, $this->em);
+ $interval = 3;
+ $message = $this->createMessage($interval, null, null);
+
+ $this->em->expects($this->once())->method('flush');
+ $this->logger->expects($this->once())->method('info');
+
+ $before = new DateTime();
+ $result = $handler->handle($message, $this->output);
+ $after = new DateTime();
+
+ $this->assertTrue($result);
+ $this->assertSame(MessageStatus::Submitted, $message->getMetadata()->getStatus());
+
+ $embargo = $message->getSchedule()->getEmbargo();
+ $this->assertInstanceOf(DateTime::class, $embargo);
+
+ $minExpected = (clone $before)->add(new DateInterval('PT' . $interval . 'M'));
+ $maxExpected = (clone $after)->add(new DateInterval('PT' . $interval . 'M'));
+
+ $this->assertGreaterThanOrEqual($minExpected->getTimestamp(), $embargo->getTimestamp());
+ $this->assertLessThanOrEqual($maxExpected->getTimestamp(), $embargo->getTimestamp());
+ }
+
+ public function testReturnsFalseWhenNextEmbargoExceedsUntil(): void
+ {
+ $handler = new RequeueHandler($this->logger, $this->em);
+ $embargo = (new DateTime())->add(new DateInterval('PT1M'));
+ $interval = 10;
+ // next would be +10, which exceeds until
+ $until = (clone $embargo)->add(new DateInterval('PT5M'));
+ $message = $this->createMessage($interval, $until, $embargo);
+
+ $this->em->expects($this->never())->method('flush');
+ $this->logger->expects($this->never())->method('info');
+
+ $result = $handler->handle($message, $this->output);
+
+ $this->assertFalse($result);
+ $this->assertSame(MessageStatus::Draft, $message->getMetadata()->getStatus());
+ $this->assertEquals(
+ $embargo->format(DateTime::ATOM),
+ $message->getSchedule()->getEmbargo()?->format(DateTime::ATOM)
+ );
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php
index aa1a47e0..94064485 100644
--- a/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php
@@ -13,6 +13,7 @@
use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageScheduleDto;
use PhpList\Core\Domain\Messaging\Model\Dto\UpdateMessageDto;
use PhpList\Core\Domain\Messaging\Model\Message;
+use PhpList\Core\Domain\Messaging\Model\Message\MessageContent;
use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
use PhpList\Core\Domain\Messaging\Service\Builder\MessageBuilder;
use PhpList\Core\Domain\Messaging\Service\Manager\MessageManager;
@@ -34,7 +35,7 @@ public function testCreateMessageReturnsPersistedMessage(): void
requeueInterval: 60 * 12,
requeueUntil: '2025-04-20T00:00:00+00:00',
);
- $metadata = new MessageMetadataDto('draft');
+ $metadata = new MessageMetadataDto(Message\MessageStatus::Draft);
$content = new MessageContentDto('Subject', 'Full text', 'Short text', 'Footer');
$options = new MessageOptionsDto('from@example.com', 'to@example.com', 'reply@example.com', 'all-users');
@@ -50,11 +51,11 @@ public function testCreateMessageReturnsPersistedMessage(): void
$authUser = $this->createMock(Administrator::class);
$expectedMessage = $this->createMock(Message::class);
- $expectedContent = $this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class);
+ $expectedContent = $this->createMock(MessageContent::class);
$expectedMetadata = $this->createMock(Message\MessageMetadata::class);
$expectedContent->method('getSubject')->willReturn('Subject');
- $expectedMetadata->method('getStatus')->willReturn('draft');
+ $expectedMetadata->method('getStatus')->willReturn(Message\MessageStatus::Draft);
$expectedMessage->method('getContent')->willReturn($expectedContent);
$expectedMessage->method('getMetadata')->willReturn($expectedMetadata);
@@ -71,7 +72,7 @@ public function testCreateMessageReturnsPersistedMessage(): void
$message = $manager->createMessage($request, $authUser);
$this->assertSame('Subject', $message->getContent()->getSubject());
- $this->assertSame('draft', $message->getMetadata()->getStatus());
+ $this->assertSame(Message\MessageStatus::Draft, $message->getMetadata()->getStatus());
}
public function testUpdateMessageReturnsUpdatedMessage(): void
@@ -88,7 +89,7 @@ public function testUpdateMessageReturnsUpdatedMessage(): void
requeueInterval: 0,
requeueUntil: '2025-04-20T00:00:00+00:00',
);
- $metadata = new MessageMetadataDto('draft');
+ $metadata = new MessageMetadataDto(Message\MessageStatus::Draft);
$content = new MessageContentDto(
'Updated Subject',
'Updated Full text',
@@ -115,11 +116,11 @@ public function testUpdateMessageReturnsUpdatedMessage(): void
$authUser = $this->createMock(Administrator::class);
$existingMessage = $this->createMock(Message::class);
- $expectedContent = $this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class);
+ $expectedContent = $this->createMock(MessageContent::class);
$expectedMetadata = $this->createMock(Message\MessageMetadata::class);
$expectedContent->method('getSubject')->willReturn('Updated Subject');
- $expectedMetadata->method('getStatus')->willReturn('draft');
+ $expectedMetadata->method('getStatus')->willReturn(Message\MessageStatus::Draft);
$existingMessage->method('getContent')->willReturn($expectedContent);
$existingMessage->method('getMetadata')->willReturn($expectedMetadata);
@@ -136,6 +137,6 @@ public function testUpdateMessageReturnsUpdatedMessage(): void
$message = $manager->updateMessage($updateRequest, $existingMessage, $authUser);
$this->assertSame('Updated Subject', $message->getContent()->getSubject());
- $this->assertSame('draft', $message->getMetadata()->getStatus());
+ $this->assertSame(Message\MessageStatus::Draft, $message->getMetadata()->getStatus());
}
}
diff --git a/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php b/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php
new file mode 100644
index 00000000..5944ca3e
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php
@@ -0,0 +1,53 @@
+logger = $this->createMock(LoggerInterface::class);
+ }
+
+ public function testShouldNotStopWhenMaxSecondsIsZero(): void
+ {
+ $limiter = new MaxProcessTimeLimiter(logger: $this->logger, maxSeconds: 0);
+
+ $output = $this->createMock(OutputInterface::class);
+ $output->expects($this->never())->method('writeln');
+ $this->logger->expects($this->never())->method('warning');
+
+ $limiter->start();
+ usleep(200_000);
+ $this->assertFalse($limiter->shouldStop($output));
+ }
+
+ public function testShouldStopAfterThresholdAndLogAndOutput(): void
+ {
+ $limiter = new MaxProcessTimeLimiter(logger: $this->logger, maxSeconds: 1);
+
+ $output = $this->createMock(OutputInterface::class);
+ $output->expects($this->once())
+ ->method('writeln')
+ ->with('Reached max processing time; stopping cleanly.');
+
+ $this->logger->expects($this->once())
+ ->method('warning')
+ ->with($this->stringContains('Reached max processing time of 1 seconds'));
+
+ $this->assertFalse($limiter->shouldStop($output));
+
+ usleep(1_200_000);
+ $this->assertTrue($limiter->shouldStop($output));
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php
index b2c51c71..26aec09f 100644
--- a/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php
@@ -9,42 +9,50 @@
use PhpList\Core\Domain\Messaging\Model\Message;
use PhpList\Core\Domain\Messaging\Model\Message\MessageContent;
use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata;
+use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository;
+use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler;
+use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter;
use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator;
use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor;
+use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer;
use PhpList\Core\Domain\Subscription\Model\Subscriber;
use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
class CampaignProcessorTest extends TestCase
{
- private MailerInterface&MockObject $mailer;
- private EntityManagerInterface&MockObject $entityManager;
- private SubscriberProvider&MockObject $subscriberProvider;
- private MessageProcessingPreparator&MockObject $messagePreparator;
- private LoggerInterface&MockObject $logger;
- private OutputInterface&MockObject $output;
+ private RateLimitedCampaignMailer|MockObject $mailer;
+ private EntityManagerInterface|MockObject $entityManager;
+ private SubscriberProvider|MockObject $subscriberProvider;
+ private MessageProcessingPreparator|MockObject $messagePreparator;
+ private LoggerInterface|MockObject $logger;
+ private OutputInterface|MockObject $output;
private CampaignProcessor $campaignProcessor;
+ private UserMessageRepository|MockObject $userMessageRepository;
protected function setUp(): void
{
- $this->mailer = $this->createMock(MailerInterface::class);
+ $this->mailer = $this->createMock(RateLimitedCampaignMailer::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->subscriberProvider = $this->createMock(SubscriberProvider::class);
$this->messagePreparator = $this->createMock(MessageProcessingPreparator::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->output = $this->createMock(OutputInterface::class);
+ $this->userMessageRepository = $this->createMock(UserMessageRepository::class);
$this->campaignProcessor = new CampaignProcessor(
- $this->mailer,
- $this->entityManager,
- $this->subscriberProvider,
- $this->messagePreparator,
- $this->logger
+ mailer: $this->mailer,
+ entityManager: $this->entityManager,
+ subscriberProvider: $this->subscriberProvider,
+ messagePreparator: $this->messagePreparator,
+ logger: $this->logger,
+ userMessageRepository: $this->userMessageRepository,
+ timeLimiter: $this->createMock(MaxProcessTimeLimiter::class),
+ requeueHandler: $this->createMock(RequeueHandler::class),
);
}
@@ -59,11 +67,10 @@ public function testProcessWithNoSubscribers(): void
->with($campaign)
->willReturn([]);
- $metadata->expects($this->once())
- ->method('setStatus')
- ->with('sent');
+ $metadata->expects($this->atLeastOnce())
+ ->method('setStatus');
- $this->entityManager->expects($this->once())
+ $this->entityManager->expects($this->atLeastOnce())
->method('flush');
$this->mailer->expects($this->never())
@@ -87,11 +94,10 @@ public function testProcessWithInvalidSubscriberEmail(): void
->with($campaign)
->willReturn([$subscriber]);
- $metadata->expects($this->once())
- ->method('setStatus')
- ->with('sent');
+ $metadata->expects($this->atLeastOnce())
+ ->method('setStatus');
- $this->entityManager->expects($this->once())
+ $this->entityManager->expects($this->atLeastOnce())
->method('flush');
$this->messagePreparator->expects($this->never())
@@ -123,22 +129,28 @@ public function testProcessWithValidSubscriberEmail(): void
->with($campaign, 1)
->willReturn($campaign);
+ $this->mailer->expects($this->once())
+ ->method('composeEmail')
+ ->with($campaign, $subscriber)
+ ->willReturnCallback(function ($processed, $sub) use ($campaign, $subscriber) {
+ $this->assertSame($campaign, $processed);
+ $this->assertSame($subscriber, $sub);
+ return (new Email())
+ ->from('news@example.com')
+ ->to('test@example.com')
+ ->subject('Test Subject')
+ ->text('Test text message')
+ ->html('Test HTML message
');
+ });
+
$this->mailer->expects($this->once())
->method('send')
- ->with($this->callback(function (Email $email) {
- $this->assertEquals('test@example.com', $email->getTo()[0]->getAddress());
- $this->assertEquals('news@example.com', $email->getFrom()[0]->getAddress());
- $this->assertEquals('Test Subject', $email->getSubject());
- $this->assertEquals('Test text message', $email->getTextBody());
- $this->assertEquals('Test HTML message
', $email->getHtmlBody());
- return true;
- }));
-
- $metadata->expects($this->once())
- ->method('setStatus')
- ->with('sent');
-
- $this->entityManager->expects($this->once())
+ ->with($this->isInstanceOf(Email::class));
+
+ $metadata->expects($this->atLeastOnce())
+ ->method('setStatus');
+
+ $this->entityManager->expects($this->atLeastOnce())
->method('flush');
$this->campaignProcessor->process($campaign, $this->output);
@@ -181,11 +193,10 @@ public function testProcessWithMailerException(): void
->method('writeln')
->with('Failed to send to: test@example.com');
- $metadata->expects($this->once())
- ->method('setStatus')
- ->with('sent');
+ $metadata->expects($this->atLeastOnce())
+ ->method('setStatus');
- $this->entityManager->expects($this->once())
+ $this->entityManager->expects($this->atLeastOnce())
->method('flush');
$this->campaignProcessor->process($campaign, $this->output);
@@ -221,11 +232,10 @@ public function testProcessWithMultipleSubscribers(): void
$this->mailer->expects($this->exactly(2))
->method('send');
- $metadata->expects($this->once())
- ->method('setStatus')
- ->with('sent');
+ $metadata->expects($this->atLeastOnce())
+ ->method('setStatus');
- $this->entityManager->expects($this->once())
+ $this->entityManager->expects($this->atLeastOnce())
->method('flush');
$this->campaignProcessor->process($campaign, $this->output);
@@ -264,11 +274,10 @@ public function testProcessWithNullOutput(): void
'campaign_id' => 123,
]);
- $metadata->expects($this->once())
- ->method('setStatus')
- ->with('sent');
+ $metadata->expects($this->atLeastOnce())
+ ->method('setStatus');
- $this->entityManager->expects($this->once())
+ $this->entityManager->expects($this->atLeastOnce())
->method('flush');
$this->campaignProcessor->process($campaign, null);
@@ -277,7 +286,7 @@ public function testProcessWithNullOutput(): void
/**
* Creates a mock for the Message class with content
*/
- private function createCampaignMock(): Message&MockObject
+ private function createCampaignMock(): Message|MockObject
{
$campaign = $this->createMock(Message::class);
$content = $this->createMock(MessageContent::class);
diff --git a/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php b/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php
new file mode 100644
index 00000000..6e2011ff
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php
@@ -0,0 +1,134 @@
+mailer = $this->createMock(MailerInterface::class);
+ $this->limiter = $this->createMock(SendRateLimiter::class);
+ $this->sut = new RateLimitedCampaignMailer($this->mailer, $this->limiter);
+ }
+
+ public function testComposeEmailSetsHeadersAndBody(): void
+ {
+ $message = $this->buildMessage(
+ subject: 'Subject',
+ textBody: 'Plain text',
+ htmlBody: 'HTML
',
+ from: 'from@example.com',
+ replyTo: 'reply@example.com'
+ );
+
+ $subscriber = new Subscriber();
+ $this->setSubscriberEmail($subscriber, 'user@example.com');
+
+ $email = $this->sut->composeEmail($message, $subscriber);
+
+ $this->assertInstanceOf(Email::class, $email);
+ $this->assertSame('user@example.com', $email->getTo()[0]->getAddress());
+ $this->assertSame('Subject', $email->getSubject());
+ $this->assertSame('from@example.com', $email->getFrom()[0]->getAddress());
+ $this->assertSame('reply@example.com', $email->getReplyTo()[0]->getAddress());
+ $this->assertSame('Plain text', $email->getTextBody());
+ $this->assertSame('HTML
', $email->getHtmlBody());
+ }
+
+ public function testComposeEmailWithoutOptionalHeaders(): void
+ {
+ $message = $this->buildMessage(
+ subject: 'No headers',
+ textBody: 'text',
+ htmlBody: 'h ',
+ from: '',
+ replyTo: ''
+ );
+
+ $subscriber = new Subscriber();
+ $this->setSubscriberEmail($subscriber, 'user2@example.com');
+
+ $email = $this->sut->composeEmail($message, $subscriber);
+
+ $this->assertSame('user2@example.com', $email->getTo()[0]->getAddress());
+ $this->assertSame('No headers', $email->getSubject());
+ $this->assertSame([], $email->getFrom());
+ $this->assertSame([], $email->getReplyTo());
+ }
+
+ public function testSendUsesLimiterAroundMailer(): void
+ {
+ $email = (new Email())->to('someone@example.com');
+
+ $this->limiter->expects($this->once())->method('awaitTurn');
+ $this->mailer
+ ->expects($this->once())
+ ->method('send')
+ ->with($this->isInstanceOf(Email::class));
+ $this->limiter->expects($this->once())->method('afterSend');
+
+ $this->sut->send($email);
+ }
+
+ private function buildMessage(
+ string $subject,
+ string $textBody,
+ string $htmlBody,
+ string $from,
+ string $replyTo
+ ): Message {
+ $content = new MessageContent(
+ subject: $subject,
+ text: $htmlBody,
+ textMessage: $textBody,
+ footer: null,
+ );
+ $format = new MessageFormat(
+ htmlFormatted: true,
+ sendFormat: MessageFormat::FORMAT_HTML,
+ formatOptions: [MessageFormat::FORMAT_HTML]
+ );
+ $schedule = new MessageSchedule(
+ repeatInterval: 0,
+ repeatUntil: null,
+ requeueInterval: 0,
+ requeueUntil: null,
+ embargo: null
+ );
+ $metadata = new MessageMetadata();
+ $options = new MessageOptions(fromField: $from, toField: '', replyTo: $replyTo);
+
+ return new Message($format, $schedule, $metadata, $content, $options, null, null);
+ }
+
+ /**
+ * Subscriber has no public setter for email, so we use reflection.
+ */
+ private function setSubscriberEmail(Subscriber $subscriber, string $email): void
+ {
+ $ref = new ReflectionProperty($subscriber, 'email');
+ $ref->setValue($subscriber, $email);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php b/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php
new file mode 100644
index 00000000..e9ba27c0
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php
@@ -0,0 +1,90 @@
+ispProvider = $this->createMock(IspRestrictionsProvider::class);
+ }
+
+ public function testInitializesLimitsFromConfigOnly(): void
+ {
+ $this->ispProvider->method('load')->willReturn(new IspRestrictions(null, null, null));
+ $limiter = new SendRateLimiter(
+ ispRestrictionsProvider: $this->ispProvider,
+ userMessageRepository: $this->createMock(UserMessageRepository::class),
+ mailqueueBatchSize: 5,
+ mailqueueBatchPeriod: 10,
+ mailqueueThrottle: 2
+ );
+
+ $output = $this->createMock(OutputInterface::class);
+ $output->expects($this->never())->method('writeln');
+
+ $this->assertTrue($limiter->awaitTurn($output));
+ }
+
+ public function testBatchLimitTriggersWaitMessageAndResetsCounters(): void
+ {
+ $this->ispProvider->method('load')->willReturn(new IspRestrictions(2, 1, null));
+ $limiter = new SendRateLimiter(
+ ispRestrictionsProvider: $this->ispProvider,
+ userMessageRepository: $this->createMock(UserMessageRepository::class),
+ mailqueueBatchSize: 10,
+ mailqueueBatchPeriod: 1,
+ mailqueueThrottle: 0
+ );
+
+ $limiter->afterSend();
+ $limiter->afterSend();
+
+ $output = $this->createMock(OutputInterface::class);
+ // We cannot reliably assert the exact second, but we assert a message called at least once
+ $output->expects($this->atLeast(0))->method('writeln');
+
+ // Now awaitTurn should detect batch full and attempt to sleep and reset.
+ $this->assertTrue($limiter->awaitTurn($output));
+
+ // Next afterSend should increase the counter again without exception
+ $limiter->afterSend();
+ // Reaching here means no fatal due to internal counter/reset logic
+ $this->assertTrue(true);
+ }
+
+ public function testThrottleSleepsPerMessagePathIsCallable(): void
+ {
+ $this->ispProvider->method('load')->willReturn(new IspRestrictions(null, null, null));
+ $limiter = new SendRateLimiter(
+ ispRestrictionsProvider: $this->ispProvider,
+ userMessageRepository: $this->createMock(UserMessageRepository::class),
+ mailqueueBatchSize: 0,
+ mailqueueBatchPeriod: 0,
+ mailqueueThrottle: 1
+ );
+
+ // We cannot speed up sleep without extensions; just call method to ensure no exceptions
+ $start = microtime(true);
+ $limiter->afterSend();
+ $elapsed = microtime(true) - $start;
+
+ // Ensure it likely slept at least ~0.5s
+ if ($elapsed < 0.3) {
+ $this->markTestIncomplete('Environment too fast to detect sleep; logic path executed.');
+ }
+ $this->assertTrue(true);
+ }
+}
diff --git a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php
index 6adbde10..9efdeac2 100644
--- a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php
@@ -26,8 +26,8 @@ protected function setUp(): void
$this->subscriberListRepository = $this->createMock(SubscriberListRepository::class);
$this->subscriberProvider = new SubscriberProvider(
- $this->subscriberRepository,
- $this->subscriberListRepository,
+ subscriberRepository: $this->subscriberRepository,
+ subscriberListRepository: $this->subscriberListRepository,
);
}
@@ -82,9 +82,9 @@ public function testGetSubscribersForMessageWithOneListAndSubscribersReturnsSubs
->willReturn([$subscriberList]);
$subscriber1 = $this->createMock(Subscriber::class);
- $subscriber1->method('getId')->willReturn(1);
+ $subscriber1->method('getEmail')->willReturn('test1@example.am');
$subscriber2 = $this->createMock(Subscriber::class);
- $subscriber2->method('getId')->willReturn(2);
+ $subscriber2->method('getEmail')->willReturn('test2@exsmple.am');
$this->subscriberRepository
->expects($this->once())
@@ -114,11 +114,11 @@ public function testGetSubscribersForMessageWithMultipleListsReturnsUniqueSubscr
->willReturn([$subscriberList1, $subscriberList2]);
$subscriber1 = $this->createMock(Subscriber::class);
- $subscriber1->method('getId')->willReturn(1);
+ $subscriber1->method('getEmail')->willReturn('test1@example.am');
$subscriber2 = $this->createMock(Subscriber::class);
- $subscriber2->method('getId')->willReturn(2);
+ $subscriber2->method('getEmail')->willReturn('test2@example.am');
$subscriber3 = $this->createMock(Subscriber::class);
- $subscriber3->method('getId')->willReturn(3);
+ $subscriber3->method('getEmail')->willReturn('test3@example.am');
$this->subscriberRepository
->expects($this->exactly(2))
From fd51731d9b39d452581fe55ac4e1cfc543676b5c Mon Sep 17 00:00:00 2001
From: TatevikGr
Date: Tue, 23 Sep 2025 10:57:25 +0400
Subject: [PATCH 08/10] Exceptions + translations (#361)
* MissingMessageIdException
* MailboxConnectionException
* BadMethodCallException (style fix)
* Translate user facing messages
* Translate
* Translate PasswordResetMessageHandler texts
* Translate SubscriberConfirmationMessageHandler texts
* MessageBuilder exceptions
* BlacklistEmailAndDeleteBounceHandler
* BlacklistEmailHandler - AdvancedBounceRulesProcessor
* AdvancedBounceRulesProcessor
* BounceStatus
* CampaignProcessor
* UnidentifiedBounceReprocessor
* Style fix
* Test fix
* ConsecutiveBounceHandler
---------
Co-authored-by: Tatevik
---
resources/translations/messages.en.xlf | 488 ++++++++++++++++--
.../Exception/MissingMessageIdException.php | 15 +
.../Analytics/Service/LinkTrackService.php | 6 +-
.../Exception/MailboxConnectionException.php | 23 +
src/Domain/Common/I18n/Messages.php | 29 --
src/Domain/Common/IspRestrictionsProvider.php | 6 +
.../Common/Mail/NativeImapMailReader.php | 4 +-
.../Repository/CursorPaginationTrait.php | 8 +-
src/Domain/Common/SystemInfoCollector.php | 7 +-
.../Service/Manager/EventLogManager.php | 1 +
.../Command/CleanUpOldSessionTokens.php | 1 +
.../AdminAttributeDefinitionManager.php | 11 +-
.../Identity/Service/PasswordManager.php | 3 +-
.../Identity/Service/SessionManager.php | 9 +-
.../Command/ProcessBouncesCommand.php | 17 +-
.../Messaging/Command/ProcessQueueCommand.php | 12 +-
.../Command/SendTestEmailCommand.php | 30 +-
.../Exception/ImapConnectionException.php | 16 +
.../Exception/InvalidContextTypeException.php | 15 +
.../Exception/InvalidDtoTypeException.php | 15 +
.../Exception/OpenMboxFileException.php | 16 +
.../PasswordResetMessageHandler.php | 42 +-
.../SubscriberConfirmationMessageHandler.php | 34 +-
src/Domain/Messaging/Model/BounceStatus.php | 19 +
.../Service/Builder/MessageBuilder.php | 14 +-
.../Service/Builder/MessageContentBuilder.php | 12 +-
.../Service/Builder/MessageFormatBuilder.php | 10 +-
.../Service/Builder/MessageOptionsBuilder.php | 14 +-
.../Builder/MessageScheduleBuilder.php | 14 +-
.../Service/ConsecutiveBounceHandler.php | 26 +-
.../BlacklistEmailAndDeleteBounceHandler.php | 20 +-
.../Service/Handler/BlacklistEmailHandler.php | 16 +-
.../BlacklistUserAndDeleteBounceHandler.php | 14 +-
.../Service/Handler/BlacklistUserHandler.php | 14 +-
...CountConfirmUserAndDeleteBounceHandler.php | 10 +-
.../Service/Handler/RequeueHandler.php | 10 +-
.../UnconfirmUserAndDeleteBounceHandler.php | 10 +-
.../Service/Handler/UnconfirmUserHandler.php | 12 +-
.../Service/Manager/BounceManager.php | 13 +-
.../Service/MaxProcessTimeLimiter.php | 10 +-
.../Service/MessageProcessingPreparator.php | 14 +-
.../Service/NativeBounceProcessingService.php | 11 +-
.../AdvancedBounceRulesProcessor.php | 24 +-
.../Service/Processor/BounceDataProcessor.php | 62 ++-
.../Service/Processor/CampaignProcessor.php | 14 +-
.../Service/Processor/MboxBounceProcessor.php | 11 +-
.../Service/Processor/PopBounceProcessor.php | 10 +-
.../UnidentifiedBounceReprocessor.php | 39 +-
.../Messaging/Service/SendRateLimiter.php | 8 +-
.../WebklexBounceProcessingService.php | 13 +-
.../Validator/TemplateImageValidator.php | 21 +-
.../Validator/TemplateLinkValidator.php | 12 +-
.../CouldNotReadUploadedFileException.php | 12 +
.../SubscriberAttributeCreationException.php | 2 +-
.../Subscription/Service/CsvImporter.php | 6 +-
.../Manager/AttributeDefinitionManager.php | 16 +-
.../Service/Manager/SubscribePageManager.php | 4 +-
.../Manager/SubscriberAttributeManager.php | 6 +-
.../Service/Manager/SubscriberManager.php | 6 +-
.../Service/Manager/SubscriptionManager.php | 7 +-
.../Service/SubscriberBlacklistService.php | 6 +-
.../Service/SubscriberCsvImporter.php | 24 +-
.../Validator/AttributeTypeValidator.php | 17 +-
.../Service/LinkTrackServiceTest.php | 4 +-
.../Repository/CursorPaginationTraitTest.php | 6 +-
.../AdminAttributeDefinitionManagerTest.php | 14 +-
.../Identity/Service/SessionManagerTest.php | 5 +-
.../Command/ProcessBouncesCommandTest.php | 5 +
.../Command/ProcessQueueCommandTest.php | 21 +-
.../Command/SendTestEmailCommandTest.php | 11 +-
.../PasswordResetMessageHandlerTest.php | 7 +-
...bscriberConfirmationMessageHandlerTest.php | 7 +-
.../Service/Builder/MessageBuilderTest.php | 26 +-
.../Builder/MessageContentBuilderTest.php | 4 +-
.../Builder/MessageFormatBuilderTest.php | 4 +-
.../Builder/MessageOptionsBuilderTest.php | 4 +-
.../Builder/MessageScheduleBuilderTest.php | 4 +-
.../Service/ConsecutiveBounceHandlerTest.php | 10 +-
...acklistEmailAndDeleteBounceHandlerTest.php | 2 +
.../Handler/BlacklistEmailHandlerTest.php | 2 +
...lacklistUserAndDeleteBounceHandlerTest.php | 2 +
.../Handler/BlacklistUserHandlerTest.php | 4 +-
...tConfirmUserAndDeleteBounceHandlerTest.php | 2 +
.../Service/Handler/RequeueHandlerTest.php | 11 +-
...nconfirmUserAndDeleteBounceHandlerTest.php | 2 +
.../Handler/UnconfirmUserHandlerTest.php | 6 +-
.../Service/Manager/BounceManagerTest.php | 2 +
.../Service/MaxProcessTimeLimiterTest.php | 5 +-
.../MessageProcessingPreparatorTest.php | 15 +-
.../AdvancedBounceRulesProcessorTest.php | 17 +-
.../Processor/CampaignProcessorTest.php | 7 +-
.../Processor/MboxBounceProcessorTest.php | 21 +-
.../Processor/PopBounceProcessorTest.php | 6 +-
.../UnidentifiedBounceReprocessorTest.php | 4 +-
.../Messaging/Service/SendRateLimiterTest.php | 4 +
.../Validator/TemplateImageValidatorTest.php | 3 +-
.../Validator/TemplateLinkValidatorTest.php | 3 +-
.../AttributeDefinitionManagerTest.php | 31 +-
.../Manager/SubscribePageManagerTest.php | 2 +
.../SubscriberAttributeManagerTest.php | 11 +-
.../Service/Manager/SubscriberManagerTest.php | 2 +
.../Service/SubscriberCsvImporterTest.php | 12 +-
.../Validator/AttributeTypeValidatorTest.php | 3 +-
103 files changed, 1305 insertions(+), 397 deletions(-)
create mode 100644 src/Domain/Analytics/Exception/MissingMessageIdException.php
create mode 100644 src/Domain/Common/Exception/MailboxConnectionException.php
delete mode 100644 src/Domain/Common/I18n/Messages.php
create mode 100644 src/Domain/Messaging/Exception/ImapConnectionException.php
create mode 100644 src/Domain/Messaging/Exception/InvalidContextTypeException.php
create mode 100644 src/Domain/Messaging/Exception/InvalidDtoTypeException.php
create mode 100644 src/Domain/Messaging/Exception/OpenMboxFileException.php
create mode 100644 src/Domain/Messaging/Model/BounceStatus.php
create mode 100644 src/Domain/Subscription/Exception/CouldNotReadUploadedFileException.php
diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf
index 7e176e3e..6f128be1 100644
--- a/resources/translations/messages.en.xlf
+++ b/resources/translations/messages.en.xlf
@@ -1,44 +1,450 @@
-
-
-
-
-
- Not authorized
- Not authorized
-
-
-
- Failed admin login attempt for '%login%'
- Failed admin login attempt for '%login%'
-
-
-
- Login attempt for disabled admin '%login%'
- Login attempt for disabled admin '%login%'
-
-
-
-
- Administrator not found
- Administrator not found
-
-
-
-
- Subscriber list not found.
- Subscriber list not found.
-
-
- Subscriber does not exists.
- Subscriber does not exists.
-
-
- Subscription not found for this subscriber and list.
- Subscription not found for this subscriber and list.
-
-
-
-
+
+
+
+
+ Not authorized
+ Not authorized
+
+
+
+ Failed admin login attempt for '%login%'
+ Failed admin login attempt for '%login%'
+
+
+
+ Login attempt for disabled admin '%login%'
+ Login attempt for disabled admin '%login%'
+
+
+
+
+ Administrator not found
+ Administrator not found
+
+
+
+ Attribute definition already exists.
+ Attribute definition already exists.
+
+
+
+ Password Reset Request
+
+
+
+ Hello,
+
+ A password reset has been requested for your account.
+ Please use the following token to reset your password:
+
+ %token%
+
+ If you did not request this password reset, please ignore this email.
+
+ Thank you.
+
+
+ Hello,
+
+ A password reset has been requested for your account.
+ Please use the following token to reset your password:
+
+ %token%
+
+ If you did not request this password reset, please ignore this email.
+
+ Thank you.
+
+
+
+
+ Password Reset Request!
+Hello! A password reset has been requested for your account.
+Please use the following token to reset your password:
+Reset Password
+If you did not request this password reset, please ignore this email.
+Thank you.
]]>
+
+ Password Reset Request!
+ Hello! A password reset has been requested for your account.
+ Please use the following token to reset your password:
+ Reset Password
+ If you did not request this password reset, please ignore this email.
+ Thank you.
+ ]]>
+
+
+
+
+ Please confirm your subscription
+ Please confirm your subscription
+
+
+
+ Thank you for subscribing!
+
+ Please confirm your subscription by clicking the link below:
+
+ %confirmation_link%
+
+ If you did not request this subscription, please ignore this email.
+
+ Thank you for subscribing!
+
+ Please confirm your subscription by clicking the link below:
+
+ %confirmation_link%
+
+ If you did not request this subscription, please ignore this email.
+
+
+
+
+ Thank you for subscribing!
+Please confirm your subscription by clicking the link below:
+Confirm Subscription
+If you did not request this subscription, please ignore this email.
]]>
+
+ Thank you for subscribing!
+Please confirm your subscription by clicking the link below:
+Confirm Subscription
+If you did not request this subscription, please ignore this email.
]]>
+
+
+
+
+
+ PHP IMAP extension not available. Falling back to Webklex IMAP.
+ PHP IMAP extension not available. Falling back to Webklex IMAP.
+
+
+
+ Could not apply force lock. Aborting.
+ Could not apply force lock. Aborting.
+
+
+
+ Another bounce processing is already running. Aborting.
+ Another bounce processing is already running. Aborting.
+
+
+
+ Queue is already being processed by another instance.
+ Queue is already being processed by another instance.
+
+
+
+ The system is in maintenance mode, stopping. Try again later.
+ The system is in maintenance mode, stopping. Try again later.
+
+
+
+ Bounce processing completed.
+ Bounce processing completed.
+
+
+
+ Recipient email address not provided
+ Recipient email address not provided
+
+
+
+ Invalid email address: %email%
+ Invalid email address: %email%
+
+
+
+ Sending test email synchronously to %email%
+ Sending test email synchronously to %email%
+
+
+
+ Queuing test email for %email%
+ Queuing test email for %email%
+
+
+
+ Test email sent successfully!
+ Test email sent successfully!
+
+
+
+ Test email queued successfully! It will be sent asynchronously.
+ Test email queued successfully! It will be sent asynchronously.
+
+
+
+ Failed to send test email: %error%
+ Failed to send test email: %error%
+
+
+
+ Email address auto blacklisted by bounce rule %rule_id%
+ Email address auto blacklisted by bounce rule %rule_id%
+
+
+
+ Auto Unsubscribed
+ Auto Unsubscribed
+
+
+
+ User auto unsubscribed for bounce rule %rule_id%
+ User auto unsubscribed for bounce rule %rule_id%
+
+
+
+ email auto unsubscribed for bounce rule %rule_id%
+ email auto unsubscribed for bounce rule %rule_id%
+
+
+
+ Subscriber auto blacklisted by bounce rule %rule_id%
+ Subscriber auto blacklisted by bounce rule %rule_id%
+
+
+
+ User auto unsubscribed for bounce rule %%rule_id%
+ User auto unsubscribed for bounce rule %%rule_id%
+
+
+
+ Auto confirmed
+ Auto confirmed
+
+
+
+ Auto unconfirmed
+ Auto unconfirmed
+
+
+
+ Subscriber auto confirmed for bounce rule %rule_id%
+ Subscriber auto confirmed for bounce rule %rule_id%
+
+
+
+ Requeued campaign; next embargo at %time%
+ Requeued campaign; next embargo at %time%
+
+
+
+ Subscriber auto unconfirmed for bounce rule %rule_id%
+ Subscriber auto unconfirmed for bounce rule %rule_id%
+
+
+
+ Running in test mode, not deleting messages from mailbox
+ Running in test mode, not deleting messages from mailbox
+
+
+
+ Processed messages will be deleted from the mailbox
+ Processed messages will be deleted from the mailbox
+
+
+
+ Processing bounces based on active bounce rules
+ Processing bounces based on active bounce rules
+
+
+
+ No active rules
+ No active rules
+
+
+
+ Processed %processed% out of %total% bounces for advanced bounce rules
+ Processed %processed% out of %total% bounces for advanced bounce rules
+
+
+
+ %processed% bounces processed by advanced processing
+ %processed% bounces processed by advanced processing
+
+
+ %not_processed% bounces were not matched by advanced processing rules
+ %not_processed% bounces were not matched by advanced processing rules
+
+
+
+ Opening mbox %file%
+ Opening mbox %file%
+
+
+ Connecting to %mailbox%
+ Connecting to %mailbox%
+
+
+ Please do not interrupt this process
+ Please do not interrupt this process
+
+
+ mbox file path must be provided with --mailbox.
+ mbox file path must be provided with --mailbox.
+
+
+
+ Invalid email, marking unconfirmed: %email%
+ Invalid email, marking unconfirmed: %email%
+
+
+ Failed to send to: %email%
+ Failed to send to: %email%
+
+
+
+ Reprocessing unidentified bounces
+ Reprocessing unidentified bounces
+
+
+ %total% bounces to reprocess
+ %total% bounces to reprocess
+
+
+ %count% out of %total% processed
+ %count% out of %total% processed
+
+
+ %reparsed% bounces were re-processed and %reidentified% bounces were re-identified
+ %reparsed% bounces were re-processed and %reidentified% bounces were re-identified
+
+
+
+ Identifying consecutive bounces
+ Identifying consecutive bounces
+
+
+ Nothing to do
+ Nothing to do
+
+
+ Processed %processed% out of %total% subscribers
+ Processed %processed% out of %total% subscribers
+
+
+ Total of %total% subscribers processed
+ Total of %total% subscribers processed
+
+
+ Subscriber auto unconfirmed for %count% consecutive bounces
+ Subscriber auto unconfirmed for %count% consecutive bounces
+
+
+ %count% consecutive bounces, threshold reached
+ %count% consecutive bounces, threshold reached
+
+
+
+ Reached max processing time; stopping cleanly.
+ Reached max processing time; stopping cleanly.
+
+
+
+ Giving a UUID to %count% subscribers, this may take a while
+ Giving a UUID to %count% subscribers, this may take a while
+
+
+ Giving a UUID to %count% campaigns
+ Giving a UUID to %count% campaigns
+
+
+
+ Batch limit reached, sleeping %sleep%s to respect MAILQUEUE_BATCH_PERIOD
+ Batch limit reached, sleeping %sleep%s to respect MAILQUEUE_BATCH_PERIOD
+
+
+
+ Value must be an array of image URLs.
+ Value must be an array of image URLs.
+
+
+ Image "%url%" is not a full URL.
+ Image "%url%" is not a full URL.
+
+
+ Image "%url%" does not exist (HTTP %code%)
+ Image "%url%" does not exist (HTTP %code%)
+
+
+ Image "%url%" could not be validated: %message%
+ Image "%url%" could not be validated: %message%
+
+
+
+ Not full URLs: %urls%
+ Not full URLs: %urls%
+
+
+
+
+
+ Subscriber list not found.
+ Subscriber list not found.
+
+
+
+ Subscriber does not exists.
+ Subscriber does not exists.
+
+
+
+ Subscription not found for this subscriber and list.
+ Subscription not found for this subscriber and list.
+
+
+ Attribute definition already exists
+ Attribute definition already exists
+
+
+ Another attribute with this name already exists.
+ Another attribute with this name already exists.
+
+
+
+ Subscribe page not found
+ Subscribe page not found
+
+
+ Value is required
+ Value is required
+
+
+ Subscriber not found
+ Subscriber not found
+
+
+ Unexpected error: %error%
+ Unexpected error: %error%
+
+
+ Added to blacklist for reason %reason%
+ Added to blacklist for reason %reason%
+
+
+ Could not read the uploaded file.
+ Could not read the uploaded file.
+
+
+ Error processing %email%: %error%
+ Error processing %email%: %error%
+
+
+ General import error: %error%
+ General import error: %error%
+
+
+ Value must be a string.
+ Value must be a string.
+
+
+ Invalid attribute type: "%type%". Valid types are: %valid_types%
+ Invalid attribute type: "%type%". Valid types are: %valid_types%
+
+
+
+
diff --git a/src/Domain/Analytics/Exception/MissingMessageIdException.php b/src/Domain/Analytics/Exception/MissingMessageIdException.php
new file mode 100644
index 00000000..71479ff0
--- /dev/null
+++ b/src/Domain/Analytics/Exception/MissingMessageIdException.php
@@ -0,0 +1,15 @@
+getId();
if ($messageId === null) {
- throw new InvalidArgumentException('Message must have an ID');
+ throw new MissingMessageIdException();
}
$links = $this->extractLinksFromHtml($content->getText() ?? '');
diff --git a/src/Domain/Common/Exception/MailboxConnectionException.php b/src/Domain/Common/Exception/MailboxConnectionException.php
new file mode 100644
index 00000000..20a927b5
--- /dev/null
+++ b/src/Domain/Common/Exception/MailboxConnectionException.php
@@ -0,0 +1,23 @@
+confPath);
if ($contents === false) {
$this->logger->warning('Cannot read ISP restrictions file', ['path' => $this->confPath]);
+
return null;
}
+
return $contents;
}
@@ -106,20 +108,24 @@ private function applyKeyValue(
if ($val !== '' && ctype_digit($val)) {
$maxBatch = (int) $val;
}
+
return [$maxBatch, $minBatchPeriod, $lockFile];
}
if ($key === 'minbatchperiod') {
if ($val !== '' && ctype_digit($val)) {
$minBatchPeriod = (int) $val;
}
+
return [$maxBatch, $minBatchPeriod, $lockFile];
}
if ($key === 'lockfile') {
if ($val !== '') {
$lockFile = $val;
}
+
return [$maxBatch, $minBatchPeriod, $lockFile];
}
+
return [$maxBatch, $minBatchPeriod, $lockFile];
}
diff --git a/src/Domain/Common/Mail/NativeImapMailReader.php b/src/Domain/Common/Mail/NativeImapMailReader.php
index 472fea54..ba90151d 100644
--- a/src/Domain/Common/Mail/NativeImapMailReader.php
+++ b/src/Domain/Common/Mail/NativeImapMailReader.php
@@ -6,7 +6,7 @@
use DateTimeImmutable;
use IMAP\Connection;
-use RuntimeException;
+use PhpList\Core\Domain\Common\Exception\MailboxConnectionException;
class NativeImapMailReader
{
@@ -24,7 +24,7 @@ public function open(string $mailbox, int $options = 0): Connection
$link = imap_open($mailbox, $this->username, $this->password, $options);
if ($link === false) {
- throw new RuntimeException('Cannot open mailbox: '.(imap_last_error() ?: 'unknown error'));
+ throw new MailboxConnectionException($mailbox);
}
return $link;
diff --git a/src/Domain/Common/Repository/CursorPaginationTrait.php b/src/Domain/Common/Repository/CursorPaginationTrait.php
index 8be64ee2..3cf67a72 100644
--- a/src/Domain/Common/Repository/CursorPaginationTrait.php
+++ b/src/Domain/Common/Repository/CursorPaginationTrait.php
@@ -4,9 +4,9 @@
namespace PhpList\Core\Domain\Common\Repository;
+use BadMethodCallException;
use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface;
use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
-use RuntimeException;
trait CursorPaginationTrait
{
@@ -30,14 +30,14 @@ public function getAfterId(int $lastId, int $limit): array
* Get filtered + paginated messages for a given owner and status.
*
* @return DomainModel[]
- * @throws RuntimeException
- */
+ * @throws BadMethodCallException
+ * */
public function getFilteredAfterId(int $lastId, int $limit, ?FilterRequestInterface $filter = null): array
{
if ($filter === null) {
return $this->getAfterId($lastId, $limit);
}
- throw new RuntimeException('Filter method not implemented');
+ throw new BadMethodCallException('getFilteredAfterId method not implemented');
}
}
diff --git a/src/Domain/Common/SystemInfoCollector.php b/src/Domain/Common/SystemInfoCollector.php
index e66d27b1..56b30579 100644
--- a/src/Domain/Common/SystemInfoCollector.php
+++ b/src/Domain/Common/SystemInfoCollector.php
@@ -16,10 +16,8 @@ class SystemInfoCollector
/**
* @param string[] $configuredKeys keys to include (empty => use defaults)
*/
- public function __construct(
- RequestStack $requestStack,
- array $configuredKeys = []
- ) {
+ public function __construct(RequestStack $requestStack, array $configuredKeys = [])
+ {
$this->requestStack = $requestStack;
$this->configuredKeys = $configuredKeys;
}
@@ -72,6 +70,7 @@ public function collectAsString(): string
foreach ($pairs as $k => $v) {
$lines[] = sprintf('%s = %s', $k, $v);
}
+
return "\n" . implode("\n", $lines);
}
}
diff --git a/src/Domain/Configuration/Service/Manager/EventLogManager.php b/src/Domain/Configuration/Service/Manager/EventLogManager.php
index 374db7ed..d896a8f1 100644
--- a/src/Domain/Configuration/Service/Manager/EventLogManager.php
+++ b/src/Domain/Configuration/Service/Manager/EventLogManager.php
@@ -44,6 +44,7 @@ public function get(
?DateTimeInterface $dateTo = null
): array {
$filter = new EventLogFilter($page, $dateFrom, $dateTo);
+
return $this->repository->getFilteredAfterId($lastId, $limit, $filter);
}
diff --git a/src/Domain/Identity/Command/CleanUpOldSessionTokens.php b/src/Domain/Identity/Command/CleanUpOldSessionTokens.php
index 348ea025..364d5ea9 100644
--- a/src/Domain/Identity/Command/CleanUpOldSessionTokens.php
+++ b/src/Domain/Identity/Command/CleanUpOldSessionTokens.php
@@ -35,6 +35,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$output->writeln(sprintf('Successfully removed %d expired session token(s).', $deletedCount));
} catch (Throwable $throwable) {
$output->writeln(sprintf('Error removing expired session tokens: %s', $throwable->getMessage()));
+
return Command::FAILURE;
}
diff --git a/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php b/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php
index f0a18e07..24a70de8 100644
--- a/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php
+++ b/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php
@@ -9,25 +9,32 @@
use PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository;
use PhpList\Core\Domain\Identity\Exception\AttributeDefinitionCreationException;
use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator;
+use Symfony\Contracts\Translation\TranslatorInterface;
class AdminAttributeDefinitionManager
{
private AdminAttributeDefinitionRepository $definitionRepository;
private AttributeTypeValidator $attributeTypeValidator;
+ private TranslatorInterface $translator;
public function __construct(
AdminAttributeDefinitionRepository $definitionRepository,
- AttributeTypeValidator $attributeTypeValidator
+ AttributeTypeValidator $attributeTypeValidator,
+ TranslatorInterface $translator
) {
$this->definitionRepository = $definitionRepository;
$this->attributeTypeValidator = $attributeTypeValidator;
+ $this->translator = $translator;
}
public function create(AdminAttributeDefinitionDto $attributeDefinitionDto): AdminAttributeDefinition
{
$existingAttribute = $this->definitionRepository->findOneByName($attributeDefinitionDto->name);
if ($existingAttribute) {
- throw new AttributeDefinitionCreationException('Attribute definition already exists', 409);
+ throw new AttributeDefinitionCreationException(
+ $this->translator->trans('Attribute definition already exists.'),
+ 409
+ );
}
$this->attributeTypeValidator->validate($attributeDefinitionDto->type);
diff --git a/src/Domain/Identity/Service/PasswordManager.php b/src/Domain/Identity/Service/PasswordManager.php
index 2c7ebe1e..36c88570 100644
--- a/src/Domain/Identity/Service/PasswordManager.php
+++ b/src/Domain/Identity/Service/PasswordManager.php
@@ -5,7 +5,6 @@
namespace PhpList\Core\Domain\Identity\Service;
use DateTime;
-use PhpList\Core\Domain\Common\I18n\Messages;
use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest;
use PhpList\Core\Domain\Identity\Model\Administrator;
use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository;
@@ -52,7 +51,7 @@ public function generatePasswordResetToken(string $email): string
{
$administrator = $this->administratorRepository->findOneBy(['email' => $email]);
if ($administrator === null) {
- $message = $this->translator->trans(Messages::IDENTITY_ADMIN_NOT_FOUND);
+ $message = $this->translator->trans('Administrator not found');
throw new NotFoundHttpException($message, null, 1500567100);
}
diff --git a/src/Domain/Identity/Service/SessionManager.php b/src/Domain/Identity/Service/SessionManager.php
index 82f52af1..105d3645 100644
--- a/src/Domain/Identity/Service/SessionManager.php
+++ b/src/Domain/Identity/Service/SessionManager.php
@@ -4,7 +4,6 @@
namespace PhpList\Core\Domain\Identity\Service;
-use PhpList\Core\Domain\Common\I18n\Messages;
use Symfony\Contracts\Translation\TranslatorInterface;
use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager;
use PhpList\Core\Domain\Identity\Model\AdministratorToken;
@@ -35,16 +34,16 @@ public function createSession(string $loginName, string $password): Administrato
{
$administrator = $this->administratorRepository->findOneByLoginCredentials($loginName, $password);
if ($administrator === null) {
- $entry = $this->translator->trans(Messages::AUTH_LOGIN_FAILED, ['login' => $loginName]);
+ $entry = $this->translator->trans("Failed admin login attempt for '%login%'", ['login' => $loginName]);
$this->eventLogManager->log('login', $entry);
- $message = $this->translator->trans(Messages::AUTH_NOT_AUTHORIZED);
+ $message = $this->translator->trans('Not authorized');
throw new UnauthorizedHttpException('', $message, null, 1500567098);
}
if ($administrator->isDisabled()) {
- $entry = $this->translator->trans(Messages::AUTH_LOGIN_DISABLED, ['login' => $loginName]);
+ $entry = $this->translator->trans("Login attempt for disabled admin '%login%'", ['login' => $loginName]);
$this->eventLogManager->log('login', $entry);
- $message = $this->translator->trans(Messages::AUTH_NOT_AUTHORIZED);
+ $message = $this->translator->trans('Not authorized');
throw new UnauthorizedHttpException('', $message, null, 1500567099);
}
diff --git a/src/Domain/Messaging/Command/ProcessBouncesCommand.php b/src/Domain/Messaging/Command/ProcessBouncesCommand.php
index f1e3b403..0d51d4f1 100644
--- a/src/Domain/Messaging/Command/ProcessBouncesCommand.php
+++ b/src/Domain/Messaging/Command/ProcessBouncesCommand.php
@@ -17,14 +17,11 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Contracts\Translation\TranslatorInterface;
#[AsCommand(name: 'phplist:bounces:process', description: 'Process bounce mailbox')]
class ProcessBouncesCommand extends Command
{
- private const IMAP_NOT_AVAILABLE = 'PHP IMAP extension not available. Falling back to Webklex IMAP.';
- private const FORCE_LOCK_FAILED = 'Could not apply force lock. Aborting.';
- private const ALREADY_LOCKED = 'Another bounce processing is already running. Aborting.';
-
protected function configure(): void
{
$this
@@ -48,6 +45,7 @@ public function __construct(
private readonly AdvancedBounceRulesProcessor $advancedRulesProcessor,
private readonly UnidentifiedBounceReprocessor $unidentifiedReprocessor,
private readonly ConsecutiveBounceHandler $consecutiveBounceHandler,
+ private readonly TranslatorInterface $translator,
) {
parent::__construct();
}
@@ -57,14 +55,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$inputOutput = new SymfonyStyle($input, $output);
if (!function_exists('imap_open')) {
- $inputOutput->note(self::IMAP_NOT_AVAILABLE);
+ $inputOutput->note($this->translator->trans(
+ 'PHP IMAP extension not available. Falling back to Webklex IMAP.'
+ ));
}
$force = (bool)$input->getOption('force');
$lock = $this->lockService->acquirePageLock('bounce_processor', $force);
if (($lock ?? 0) === 0) {
- $inputOutput->warning($force ? self::FORCE_LOCK_FAILED : self::ALREADY_LOCKED);
+ $forceLockFailed = $this->translator->trans('Could not apply force lock. Aborting.');
+ $lockFailed = $this->translator->trans('Another bounce processing is already running. Aborting.');
+
+ $inputOutput->warning($force ? $forceLockFailed : $lockFailed);
return $force ? Command::FAILURE : Command::SUCCESS;
}
@@ -88,7 +91,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->consecutiveBounceHandler->handle($inputOutput);
$this->logger->info('Bounce processing completed', ['downloadReport' => $downloadReport]);
- $inputOutput->success('Bounce processing completed.');
+ $inputOutput->success($this->translator->trans('Bounce processing completed.'));
return Command::SUCCESS;
} catch (Exception $e) {
diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php
index d2c7cbfa..b9a9068a 100644
--- a/src/Domain/Messaging/Command/ProcessQueueCommand.php
+++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php
@@ -15,6 +15,7 @@
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Lock\LockFactory;
+use Symfony\Contracts\Translation\TranslatorInterface;
use Throwable;
#[AsCommand(
@@ -28,13 +29,15 @@ class ProcessQueueCommand extends Command
private MessageProcessingPreparator $messagePreparator;
private CampaignProcessor $campaignProcessor;
private ConfigManager $configManager;
+ private TranslatorInterface $translator;
public function __construct(
MessageRepository $messageRepository,
LockFactory $lockFactory,
MessageProcessingPreparator $messagePreparator,
CampaignProcessor $campaignProcessor,
- ConfigManager $configManager
+ ConfigManager $configManager,
+ TranslatorInterface $translator
) {
parent::__construct();
$this->messageRepository = $messageRepository;
@@ -42,6 +45,7 @@ public function __construct(
$this->messagePreparator = $messagePreparator;
$this->campaignProcessor = $campaignProcessor;
$this->configManager = $configManager;
+ $this->translator = $translator;
}
/**
@@ -51,13 +55,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int
{
$lock = $this->lockFactory->createLock('queue_processor');
if (!$lock->acquire()) {
- $output->writeln('Queue is already being processed by another instance.');
+ $output->writeln($this->translator->trans('Queue is already being processed by another instance.'));
return Command::FAILURE;
}
if ($this->configManager->inMaintenanceMode()) {
- $output->writeln('The system is in maintenance mode, stopping. Try again later.');
+ $output->writeln(
+ $this->translator->trans('The system is in maintenance mode, stopping. Try again later.')
+ );
return Command::FAILURE;
}
diff --git a/src/Domain/Messaging/Command/SendTestEmailCommand.php b/src/Domain/Messaging/Command/SendTestEmailCommand.php
index bb6ba06b..e9670239 100644
--- a/src/Domain/Messaging/Command/SendTestEmailCommand.php
+++ b/src/Domain/Messaging/Command/SendTestEmailCommand.php
@@ -12,6 +12,7 @@
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Mime\Address;
+use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Component\Mime\Email;
#[AsCommand(
@@ -21,11 +22,13 @@
class SendTestEmailCommand extends Command
{
private EmailService $emailService;
+ private TranslatorInterface $translator;
- public function __construct(EmailService $emailService)
+ public function __construct(EmailService $emailService, TranslatorInterface $translator)
{
parent::__construct();
$this->emailService = $emailService;
+ $this->translator = $translator;
}
protected function configure(): void
@@ -48,13 +51,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int
{
$recipient = $input->getArgument('recipient');
if (!$recipient) {
- $output->writeln('Recipient email address not provided');
+ $output->writeln($this->translator->trans('Recipient email address not provided'));
return Command::FAILURE;
}
if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
- $output->writeln('Invalid email address: ' . $recipient);
+ $output->writeln($this->translator->trans('Invalid email address: %email%', ['%email%' => $recipient]));
return Command::FAILURE;
}
@@ -63,9 +66,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$syncMode = $input->getOption('sync');
if ($syncMode) {
- $output->writeln('Sending test email synchronously to ' . $recipient);
+ $output->writeln($this->translator->trans(
+ 'Sending test email synchronously to %email%',
+ ['%email%' => $recipient]
+ ));
} else {
- $output->writeln('Queuing test email for ' . $recipient);
+ $output->writeln($this->translator->trans(
+ 'Queuing test email for %email%',
+ ['%email%' => $recipient]
+ ));
}
$email = (new Email())
@@ -77,15 +86,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int
if ($syncMode) {
$this->emailService->sendEmailSync($email);
- $output->writeln('Test email sent successfully!');
+ $output->writeln($this->translator->trans('Test email sent successfully!'));
} else {
$this->emailService->sendEmail($email);
- $output->writeln('Test email queued successfully! It will be sent asynchronously.');
+ $output->writeln($this->translator->trans(
+ 'Test email queued successfully! It will be sent asynchronously.'
+ ));
}
return Command::SUCCESS;
} catch (Exception $e) {
- $output->writeln('Failed to send test email: ' . $e->getMessage());
+ $output->writeln($this->translator->trans(
+ 'Failed to send test email: %error%',
+ ['%error%' => $e->getMessage()]
+ ));
return Command::FAILURE;
}
diff --git a/src/Domain/Messaging/Exception/ImapConnectionException.php b/src/Domain/Messaging/Exception/ImapConnectionException.php
new file mode 100644
index 00000000..8e5295e2
--- /dev/null
+++ b/src/Domain/Messaging/Exception/ImapConnectionException.php
@@ -0,0 +1,16 @@
+emailService = $emailService;
+ $this->translator = $translator;
$this->passwordResetUrl = $passwordResetUrl;
}
@@ -28,19 +31,30 @@ public function __invoke(PasswordResetMessage $message): void
{
$confirmationLink = $this->generateLink($message->getToken());
- $subject = 'Password Reset Request';
- $textContent = "Hello,\n\n"
- . "A password reset has been requested for your account.\n"
- . "Please use the following token to reset your password:\n\n"
- . $message->getToken()
- . "\n\nIf you did not request this password reset, please ignore this email.\n\nThank you.";
-
- $htmlContent = 'Password Reset Request!
'
- . 'Hello! A password reset has been requested for your account.
'
- . 'Please use the following token to reset your password:
'
- . 'Reset Password
'
- . 'If you did not request this password reset, please ignore this email.
'
- . 'Thank you.
';
+ $subject = $this->translator->trans('Password Reset Request');
+
+ $textContent = $this->translator->trans(
+ "Hello,\n\n" .
+ "A password reset has been requested for your account.\n" .
+ "Please use the following token to reset your password:\n\n" .
+ "%token%\n\n" .
+ "If you did not request this password reset, please ignore this email.\n\n" .
+ 'Thank you.',
+ ['%token%' => $message->getToken()]
+ );
+
+ $htmlContent = $this->translator->trans(
+ 'Password Reset Request!
' .
+ 'Hello! A password reset has been requested for your account.
' .
+ 'Please use the following token to reset your password:
' .
+ 'Reset Password
' .
+ 'If you did not request this password reset, please ignore this email.
' .
+ 'Thank you.
',
+ [
+ '%confirmation_link%' => $confirmationLink,
+ ]
+ );
+
$email = (new Email())
->to($message->getEmail())
diff --git a/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php b/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php
index 8c487849..69ec42cb 100644
--- a/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php
+++ b/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php
@@ -8,6 +8,7 @@
use PhpList\Core\Domain\Messaging\Service\EmailService;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Email;
+use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Handler for processing asynchronous subscriber confirmation email messages
@@ -16,11 +17,13 @@
class SubscriberConfirmationMessageHandler
{
private EmailService $emailService;
+ private TranslatorInterface $translator;
private string $confirmationUrl;
- public function __construct(EmailService $emailService, string $confirmationUrl)
+ public function __construct(EmailService $emailService, TranslatorInterface $translator, string $confirmationUrl)
{
$this->emailService = $emailService;
+ $this->translator = $translator;
$this->confirmationUrl = $confirmationUrl;
}
@@ -31,18 +34,29 @@ public function __invoke(SubscriberConfirmationMessage $message): void
{
$confirmationLink = $this->generateConfirmationLink($message->getUniqueId());
- $subject = 'Please confirm your subscription';
- $textContent = "Thank you for subscribing!\n\n"
- . "Please confirm your subscription by clicking the link below:\n"
- . $confirmationLink . "\n\n"
- . 'If you did not request this subscription, please ignore this email.';
+ $subject = $this->translator->trans('Please confirm your subscription');
+
+ $textContent = $this->translator->trans(
+ "Thank you for subscribing!\n\n" .
+ "Please confirm your subscription by clicking the link below:\n\n" .
+ "%confirmation_link%\n\n" .
+ 'If you did not request this subscription, please ignore this email.',
+ [
+ '%confirmation_link%' => $confirmationLink
+ ]
+ );
$htmlContent = '';
if ($message->hasHtmlEmail()) {
- $htmlContent = 'Thank you for subscribing!
'
- . 'Please confirm your subscription by clicking the link below:
'
- . 'Confirm Subscription
'
- . 'If you did not request this subscription, please ignore this email.
';
+ $htmlContent = $this->translator->trans(
+ 'Thank you for subscribing!
' .
+ 'Please confirm your subscription by clicking the link below:
' .
+ 'Confirm Subscription
' .
+ 'If you did not request this subscription, please ignore this email.
',
+ [
+ '%confirmation_link%' => $confirmationLink,
+ ]
+ );
}
$email = (new Email())
diff --git a/src/Domain/Messaging/Model/BounceStatus.php b/src/Domain/Messaging/Model/BounceStatus.php
new file mode 100644
index 00000000..be77473f
--- /dev/null
+++ b/src/Domain/Messaging/Model/BounceStatus.php
@@ -0,0 +1,19 @@
+value, $userId);
+ }
+}
diff --git a/src/Domain/Messaging/Service/Builder/MessageBuilder.php b/src/Domain/Messaging/Service/Builder/MessageBuilder.php
index 85e74331..bb7fd852 100644
--- a/src/Domain/Messaging/Service/Builder/MessageBuilder.php
+++ b/src/Domain/Messaging/Service/Builder/MessageBuilder.php
@@ -4,7 +4,7 @@
namespace PhpList\Core\Domain\Messaging\Service\Builder;
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidContextTypeException;
use PhpList\Core\Domain\Messaging\Model\Dto\MessageContext;
use PhpList\Core\Domain\Messaging\Model\Dto\MessageDtoInterface;
use PhpList\Core\Domain\Messaging\Model\Message;
@@ -24,7 +24,7 @@ public function __construct(
public function build(MessageDtoInterface $createMessageDto, object $context = null): Message
{
if (!$context instanceof MessageContext) {
- throw new InvalidArgumentException('Invalid context type');
+ throw new InvalidContextTypeException(get_debug_type($context));
}
$format = $this->messageFormatBuilder->build($createMessageDto->getFormat());
@@ -47,6 +47,14 @@ public function build(MessageDtoInterface $createMessageDto, object $context = n
$metadata = new Message\MessageMetadata(Message\MessageStatus::Draft);
- return new Message($format, $schedule, $metadata, $content, $options, $context->getOwner(), $template);
+ return new Message(
+ format: $format,
+ schedule: $schedule,
+ metadata: $metadata,
+ content: $content,
+ options: $options,
+ owner: $context->getOwner(),
+ template: $template
+ );
}
}
diff --git a/src/Domain/Messaging/Service/Builder/MessageContentBuilder.php b/src/Domain/Messaging/Service/Builder/MessageContentBuilder.php
index 1e9e442d..806afe00 100644
--- a/src/Domain/Messaging/Service/Builder/MessageContentBuilder.php
+++ b/src/Domain/Messaging/Service/Builder/MessageContentBuilder.php
@@ -4,7 +4,7 @@
namespace PhpList\Core\Domain\Messaging\Service\Builder;
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException;
use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageContentDto;
use PhpList\Core\Domain\Messaging\Model\Message\MessageContent;
@@ -13,14 +13,14 @@ class MessageContentBuilder
public function build(object $dto): MessageContent
{
if (!$dto instanceof MessageContentDto) {
- throw new InvalidArgumentException('Invalid request dto type: ' . get_class($dto));
+ throw new InvalidDtoTypeException(get_debug_type($dto));
}
return new MessageContent(
- $dto->subject,
- $dto->text,
- $dto->textMessage,
- $dto->footer
+ subject: $dto->subject,
+ text: $dto->text,
+ textMessage: $dto->textMessage,
+ footer: $dto->footer
);
}
}
diff --git a/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php b/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php
index 7bf9be8b..c6b05fc2 100644
--- a/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php
+++ b/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php
@@ -4,7 +4,7 @@
namespace PhpList\Core\Domain\Messaging\Service\Builder;
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException;
use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageFormatDto;
use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat;
@@ -13,13 +13,13 @@ class MessageFormatBuilder
public function build(object $dto): MessageFormat
{
if (!$dto instanceof MessageFormatDto) {
- throw new InvalidArgumentException('Invalid request dto type: ' . get_class($dto));
+ throw new InvalidDtoTypeException(get_debug_type($dto));
}
return new MessageFormat(
- $dto->htmlFormated,
- $dto->sendFormat,
- $dto->formatOptions
+ htmlFormatted: $dto->htmlFormated,
+ sendFormat: $dto->sendFormat,
+ formatOptions: $dto->formatOptions
);
}
}
diff --git a/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php b/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php
index 0a241f0f..91689d1e 100644
--- a/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php
+++ b/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php
@@ -4,7 +4,7 @@
namespace PhpList\Core\Domain\Messaging\Service\Builder;
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException;
use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageOptionsDto;
use PhpList\Core\Domain\Messaging\Model\Message\MessageOptions;
@@ -13,15 +13,15 @@ class MessageOptionsBuilder
public function build(object $dto): MessageOptions
{
if (!$dto instanceof MessageOptionsDto) {
- throw new InvalidArgumentException('Invalid request dto type: ' . get_class($dto));
+ throw new InvalidDtoTypeException(get_debug_type($dto));
}
return new MessageOptions(
- $dto->fromField ?? '',
- $dto->toField ?? '',
- $dto->replyTo ?? '',
- $dto->userSelection,
- null,
+ fromField: $dto->fromField ?? '',
+ toField: $dto->toField ?? '',
+ replyTo: $dto->replyTo ?? '',
+ userSelection: $dto->userSelection,
+ rssTemplate: null,
);
}
}
diff --git a/src/Domain/Messaging/Service/Builder/MessageScheduleBuilder.php b/src/Domain/Messaging/Service/Builder/MessageScheduleBuilder.php
index df847eaf..dbe86731 100644
--- a/src/Domain/Messaging/Service/Builder/MessageScheduleBuilder.php
+++ b/src/Domain/Messaging/Service/Builder/MessageScheduleBuilder.php
@@ -5,7 +5,7 @@
namespace PhpList\Core\Domain\Messaging\Service\Builder;
use DateTime;
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException;
use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageScheduleDto;
use PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule;
@@ -14,15 +14,15 @@ class MessageScheduleBuilder
public function build(object $dto): MessageSchedule
{
if (!$dto instanceof MessageScheduleDto) {
- throw new InvalidArgumentException('Invalid request dto type: ' . get_class($dto));
+ throw new InvalidDtoTypeException(get_debug_type($dto));
}
return new MessageSchedule(
- $dto->repeatInterval,
- new DateTime($dto->repeatUntil),
- $dto->requeueInterval,
- new DateTime($dto->requeueUntil),
- new DateTime($dto->embargo)
+ repeatInterval: $dto->repeatInterval,
+ repeatUntil: new DateTime($dto->repeatUntil),
+ requeueInterval: $dto->requeueInterval,
+ requeueUntil: new DateTime($dto->requeueUntil),
+ embargo: new DateTime($dto->embargo)
);
}
}
diff --git a/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php b/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php
index 0805c156..91c4c041 100644
--- a/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php
+++ b/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php
@@ -13,6 +13,7 @@
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService;
use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Contracts\Translation\TranslatorInterface;
class ConsecutiveBounceHandler
{
@@ -20,6 +21,7 @@ class ConsecutiveBounceHandler
private SubscriberRepository $subscriberRepository;
private SubscriberHistoryManager $subscriberHistoryManager;
private SubscriberBlacklistService $blacklistService;
+ private TranslatorInterface $translator;
private int $unsubscribeThreshold;
private int $blacklistThreshold;
@@ -28,6 +30,7 @@ public function __construct(
SubscriberRepository $subscriberRepository,
SubscriberHistoryManager $subscriberHistoryManager,
SubscriberBlacklistService $blacklistService,
+ TranslatorInterface $translator,
int $unsubscribeThreshold,
int $blacklistThreshold,
) {
@@ -35,19 +38,21 @@ public function __construct(
$this->subscriberRepository = $subscriberRepository;
$this->subscriberHistoryManager = $subscriberHistoryManager;
$this->blacklistService = $blacklistService;
+ $this->translator = $translator;
$this->unsubscribeThreshold = $unsubscribeThreshold;
$this->blacklistThreshold = $blacklistThreshold;
}
public function handle(SymfonyStyle $io): void
{
- $io->section('Identifying consecutive bounces');
+ $io->section($this->translator->trans('Identifying consecutive bounces'));
$users = $this->subscriberRepository->distinctUsersWithBouncesConfirmedNotBlacklisted();
$total = count($users);
if ($total === 0) {
- $io->writeln('Nothing to do');
+ $io->writeln($this->translator->trans('Nothing to do'));
+
return;
}
@@ -57,11 +62,14 @@ public function handle(SymfonyStyle $io): void
$processed++;
if ($processed % 5 === 0) {
- $io->writeln(\sprintf('processed %d out of %d subscribers', $processed, $total));
+ $io->writeln($this->translator->trans('Processed %processed% out of %total% subscribers', [
+ '%processed%' => $processed,
+ '%total%' => $total,
+ ]));
}
}
- $io->writeln(\sprintf('total of %d subscribers processed', $total));
+ $io->writeln($this->translator->trans('Total of %total% subscribers processed', ['%total%' => $total]));
}
private function processUser(Subscriber $user): void
@@ -123,15 +131,19 @@ private function applyThresholdActions($user, int $consecutive, bool $alreadyUns
$this->subscriberRepository->markUnconfirmed($user->getId());
$this->subscriberHistoryManager->addHistory(
subscriber: $user,
- message: 'Auto Unconfirmed',
- details: sprintf('Subscriber auto unconfirmed for %d consecutive bounces', $consecutive)
+ message: $this->translator->trans('Auto unconfirmed'),
+ details: $this->translator->trans('Subscriber auto unconfirmed for %count% consecutive bounces', [
+ '%count%' => $consecutive
+ ])
);
}
if ($this->blacklistThreshold > 0 && $consecutive >= $this->blacklistThreshold) {
$this->blacklistService->blacklist(
subscriber: $user,
- reason: sprintf('%d consecutive bounces, threshold reached', $consecutive)
+ reason: $this->translator->trans('%count% consecutive bounces, threshold reached', [
+ '%count%' => $consecutive
+ ])
);
return true;
}
diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php
index d32cf68b..e3b743cb 100644
--- a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php
+++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php
@@ -7,21 +7,25 @@
use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService;
+use Symfony\Contracts\Translation\TranslatorInterface;
class BlacklistEmailAndDeleteBounceHandler implements BounceActionHandlerInterface
{
private SubscriberHistoryManager $subscriberHistoryManager;
private BounceManager $bounceManager;
private SubscriberBlacklistService $blacklistService;
+ private TranslatorInterface $translator;
public function __construct(
SubscriberHistoryManager $subscriberHistoryManager,
BounceManager $bounceManager,
- SubscriberBlacklistService $blacklistService
+ SubscriberBlacklistService $blacklistService,
+ TranslatorInterface $translator,
) {
$this->subscriberHistoryManager = $subscriberHistoryManager;
$this->bounceManager = $bounceManager;
$this->blacklistService = $blacklistService;
+ $this->translator = $translator;
}
public function supports(string $action): bool
@@ -32,14 +36,20 @@ public function supports(string $action): bool
public function handle(array $closureData): void
{
if (!empty($closureData['subscriber'])) {
+ $reason = $this->translator->trans('Email address auto blacklisted by bounce rule %rule_id%', [
+ '%rule_id%' => $closureData['ruleId']
+ ]);
$this->blacklistService->blacklist(
subscriber: $closureData['subscriber'],
- reason: 'Email address auto blacklisted by bounce rule '.$closureData['ruleId']
+ reason: $reason
);
+ $details = $this->translator->trans('User auto unsubscribed for bounce rule %rule_id%', [
+ '%rule_id%' => $closureData['ruleId']
+ ]);
$this->subscriberHistoryManager->addHistory(
- $closureData['subscriber'],
- 'Auto Unsubscribed',
- 'User auto unsubscribed for bounce rule '.$closureData['ruleId']
+ subscriber: $closureData['subscriber'],
+ message: $this->translator->trans('Auto Unsubscribed'),
+ details: $details
);
}
$this->bounceManager->delete($closureData['bounce']);
diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php
index 9a92088c..eac3b7a9 100644
--- a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php
+++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php
@@ -6,18 +6,22 @@
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService;
+use Symfony\Contracts\Translation\TranslatorInterface;
class BlacklistEmailHandler implements BounceActionHandlerInterface
{
private SubscriberHistoryManager $subscriberHistoryManager;
private SubscriberBlacklistService $blacklistService;
+ private TranslatorInterface $translator;
public function __construct(
SubscriberHistoryManager $subscriberHistoryManager,
SubscriberBlacklistService $blacklistService,
+ TranslatorInterface $translator,
) {
$this->subscriberHistoryManager = $subscriberHistoryManager;
$this->blacklistService = $blacklistService;
+ $this->translator = $translator;
}
public function supports(string $action): bool
@@ -29,13 +33,17 @@ public function handle(array $closureData): void
{
if (!empty($closureData['subscriber'])) {
$this->blacklistService->blacklist(
- $closureData['subscriber'],
- 'Email address auto blacklisted by bounce rule '.$closureData['ruleId']
+ subscriber: $closureData['subscriber'],
+ reason: $this->translator->trans('Email address auto blacklisted by bounce rule %rule_id%', [
+ '%rule_id%' => $closureData['ruleId']
+ ]),
);
$this->subscriberHistoryManager->addHistory(
$closureData['subscriber'],
- 'Auto Unsubscribed',
- 'email auto unsubscribed for bounce rule '.$closureData['ruleId']
+ $this->translator->trans('Auto Unsubscribed'),
+ $this->translator->trans('email auto unsubscribed for bounce rule %rule_id%', [
+ '%rule_id%' => $closureData['ruleId']
+ ])
);
}
}
diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php
index b017fe9c..3fda46c2 100644
--- a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php
+++ b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php
@@ -7,21 +7,25 @@
use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService;
+use Symfony\Contracts\Translation\TranslatorInterface;
class BlacklistUserAndDeleteBounceHandler implements BounceActionHandlerInterface
{
private SubscriberHistoryManager $subscriberHistoryManager;
private BounceManager $bounceManager;
private SubscriberBlacklistService $blacklistService;
+ private TranslatorInterface $translator;
public function __construct(
SubscriberHistoryManager $subscriberHistoryManager,
BounceManager $bounceManager,
SubscriberBlacklistService $blacklistService,
+ TranslatorInterface $translator,
) {
$this->subscriberHistoryManager = $subscriberHistoryManager;
$this->bounceManager = $bounceManager;
$this->blacklistService = $blacklistService;
+ $this->translator = $translator;
}
public function supports(string $action): bool
@@ -34,12 +38,16 @@ public function handle(array $closureData): void
if (!empty($closureData['subscriber']) && !$closureData['blacklisted']) {
$this->blacklistService->blacklist(
subscriber: $closureData['subscriber'],
- reason: 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId']
+ reason: $this->translator->trans('Subscriber auto blacklisted by bounce rule %rule_id%', [
+ '%rule_id%' => $closureData['ruleId']
+ ])
);
$this->subscriberHistoryManager->addHistory(
subscriber: $closureData['subscriber'],
- message: 'Auto Unsubscribed',
- details: 'User auto unsubscribed for bounce rule '.$closureData['ruleId']
+ message: $this->translator->trans('Auto Unsubscribed'),
+ details: $this->translator->trans('User auto unsubscribed for bounce rule %rule_id%', [
+ '%rule_id%' => $closureData['ruleId']
+ ])
);
}
$this->bounceManager->delete($closureData['bounce']);
diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php
index 75c8b810..555ad3bf 100644
--- a/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php
+++ b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php
@@ -6,18 +6,22 @@
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService;
+use Symfony\Contracts\Translation\TranslatorInterface;
class BlacklistUserHandler implements BounceActionHandlerInterface
{
private SubscriberHistoryManager $subscriberHistoryManager;
private SubscriberBlacklistService $blacklistService;
+ private TranslatorInterface $translator;
public function __construct(
SubscriberHistoryManager $subscriberHistoryManager,
SubscriberBlacklistService $blacklistService,
+ TranslatorInterface $translator,
) {
$this->subscriberHistoryManager = $subscriberHistoryManager;
$this->blacklistService = $blacklistService;
+ $this->translator = $translator;
}
public function supports(string $action): bool
@@ -30,12 +34,16 @@ public function handle(array $closureData): void
if (!empty($closureData['subscriber']) && !$closureData['blacklisted']) {
$this->blacklistService->blacklist(
subscriber: $closureData['subscriber'],
- reason: 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId']
+ reason: $this->translator->trans('Subscriber auto blacklisted by bounce rule %rule_id%', [
+ '%rule_id%' => $closureData['ruleId']
+ ])
);
$this->subscriberHistoryManager->addHistory(
subscriber: $closureData['subscriber'],
- message: 'Auto Unsubscribed',
- details: 'User auto unsubscribed for bounce rule '.$closureData['ruleId']
+ message: $this->translator->trans('Auto Unsubscribed'),
+ details: $this->translator->trans('User auto unsubscribed for bounce rule %rule_id%', [
+ '%rule_id%' => $closureData['ruleId']
+ ])
);
}
}
diff --git a/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php
index a8ecdfb5..4b7471eb 100644
--- a/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php
+++ b/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php
@@ -8,6 +8,7 @@
use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager;
+use Symfony\Contracts\Translation\TranslatorInterface;
class DecreaseCountConfirmUserAndDeleteBounceHandler implements BounceActionHandlerInterface
{
@@ -15,17 +16,20 @@ class DecreaseCountConfirmUserAndDeleteBounceHandler implements BounceActionHand
private SubscriberManager $subscriberManager;
private BounceManager $bounceManager;
private SubscriberRepository $subscriberRepository;
+ private TranslatorInterface $translator;
public function __construct(
SubscriberHistoryManager $subscriberHistoryManager,
SubscriberManager $subscriberManager,
BounceManager $bounceManager,
SubscriberRepository $subscriberRepository,
+ TranslatorInterface $translator,
) {
$this->subscriberHistoryManager = $subscriberHistoryManager;
$this->subscriberManager = $subscriberManager;
$this->bounceManager = $bounceManager;
$this->subscriberRepository = $subscriberRepository;
+ $this->translator = $translator;
}
public function supports(string $action): bool
@@ -41,8 +45,10 @@ public function handle(array $closureData): void
$this->subscriberRepository->markConfirmed($closureData['userId']);
$this->subscriberHistoryManager->addHistory(
subscriber: $closureData['subscriber'],
- message: 'Auto confirmed',
- details: 'Subscriber auto confirmed for bounce rule '.$closureData['ruleId']
+ message: $this->translator->trans('Auto confirmed'),
+ details: $this->translator->trans('Subscriber auto confirmed for bounce rule %rule_id%', [
+ '%rule_id%' => $closureData['ruleId']
+ ])
);
}
}
diff --git a/src/Domain/Messaging/Service/Handler/RequeueHandler.php b/src/Domain/Messaging/Service/Handler/RequeueHandler.php
index 6a1d9d95..ddd0035d 100644
--- a/src/Domain/Messaging/Service/Handler/RequeueHandler.php
+++ b/src/Domain/Messaging/Service/Handler/RequeueHandler.php
@@ -11,12 +11,14 @@
use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
class RequeueHandler
{
public function __construct(
private readonly LoggerInterface $logger,
- private readonly EntityManagerInterface $entityManager
+ private readonly EntityManagerInterface $entityManager,
+ private readonly TranslatorInterface $translator,
) {
}
@@ -46,9 +48,9 @@ public function handle(Message $campaign, ?OutputInterface $output = null): bool
$campaign->getMetadata()->setStatus(MessageStatus::Submitted);
$this->entityManager->flush();
- $output?->writeln(sprintf(
- 'Requeued campaign; next embargo at %s',
- $next->format(DateTime::ATOM)
+ $output?->writeln($this->translator->trans(
+ 'Requeued campaign; next embargo at %time%',
+ ['%time%' => $next->format(DateTime::ATOM)],
));
$this->logger->info('Campaign requeued with new embargo', [
'campaign_id' => $campaign->getId(),
diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php
index 7ca39be8..0653900f 100644
--- a/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php
+++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php
@@ -7,21 +7,25 @@
use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager;
use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
+use Symfony\Contracts\Translation\TranslatorInterface;
class UnconfirmUserAndDeleteBounceHandler implements BounceActionHandlerInterface
{
private SubscriberHistoryManager $subscriberHistoryManager;
private SubscriberRepository $subscriberRepository;
private BounceManager $bounceManager;
+ private TranslatorInterface $translator;
public function __construct(
SubscriberHistoryManager $subscriberHistoryManager,
SubscriberRepository $subscriberRepository,
BounceManager $bounceManager,
+ TranslatorInterface $translator,
) {
$this->subscriberHistoryManager = $subscriberHistoryManager;
$this->subscriberRepository = $subscriberRepository;
$this->bounceManager = $bounceManager;
+ $this->translator = $translator;
}
public function supports(string $action): bool
@@ -35,8 +39,10 @@ public function handle(array $closureData): void
$this->subscriberRepository->markUnconfirmed($closureData['userId']);
$this->subscriberHistoryManager->addHistory(
subscriber: $closureData['subscriber'],
- message: 'Auto unconfirmed',
- details: 'Subscriber auto unconfirmed for bounce rule '.$closureData['ruleId']
+ message: $this->translator->trans('Auto unconfirmed'),
+ details: $this->translator->trans('Subscriber auto unconfirmed for bounce rule %rule_id%', [
+ '%rule_id%' => $closureData['ruleId']
+ ])
);
}
$this->bounceManager->delete($closureData['bounce']);
diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php
index a5bdd0fe..971863f3 100644
--- a/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php
+++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php
@@ -6,18 +6,22 @@
use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
+use Symfony\Contracts\Translation\TranslatorInterface;
class UnconfirmUserHandler implements BounceActionHandlerInterface
{
private SubscriberRepository $subscriberRepository;
private SubscriberHistoryManager $subscriberHistoryManager;
+ private TranslatorInterface $translator;
public function __construct(
SubscriberRepository $subscriberRepository,
SubscriberHistoryManager $subscriberHistoryManager,
+ TranslatorInterface $translator,
) {
$this->subscriberRepository = $subscriberRepository;
$this->subscriberHistoryManager = $subscriberHistoryManager;
+ $this->translator = $translator;
}
public function supports(string $action): bool
@@ -30,9 +34,11 @@ public function handle(array $closureData): void
if (!empty($closureData['subscriber']) && $closureData['confirmed']) {
$this->subscriberRepository->markUnconfirmed($closureData['userId']);
$this->subscriberHistoryManager->addHistory(
- $closureData['subscriber'],
- 'Auto Unconfirmed',
- 'Subscriber auto unconfirmed for bounce rule '.$closureData['ruleId']
+ subscriber: $closureData['subscriber'],
+ message: $this->translator->trans('Auto unconfirmed'),
+ details: $this->translator->trans('Subscriber auto unconfirmed for bounce rule %rule_id%', [
+ '%rule_id%' => $closureData['ruleId']
+ ])
);
}
}
diff --git a/src/Domain/Messaging/Service/Manager/BounceManager.php b/src/Domain/Messaging/Service/Manager/BounceManager.php
index f13c46ff..4945b881 100644
--- a/src/Domain/Messaging/Service/Manager/BounceManager.php
+++ b/src/Domain/Messaging/Service/Manager/BounceManager.php
@@ -14,27 +14,28 @@
use PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository;
use PhpList\Core\Domain\Subscription\Model\Subscriber;
use Psr\Log\LoggerInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
class BounceManager
{
- private const TEST_MODE_MESSAGE = 'Running in test mode, not deleting messages from mailbox';
- private const LIVE_MODE_MESSAGE = 'Processed messages will be deleted from the mailbox';
-
private BounceRepository $bounceRepository;
private UserMessageBounceRepository $userMessageBounceRepo;
private EntityManagerInterface $entityManager;
private LoggerInterface $logger;
+ private TranslatorInterface $translator;
public function __construct(
BounceRepository $bounceRepository,
UserMessageBounceRepository $userMessageBounceRepo,
EntityManagerInterface $entityManager,
LoggerInterface $logger,
+ TranslatorInterface $translator,
) {
$this->bounceRepository = $bounceRepository;
$this->userMessageBounceRepo = $userMessageBounceRepo;
$this->entityManager = $entityManager;
$this->logger = $logger;
+ $this->translator = $translator;
}
public function create(
@@ -132,7 +133,9 @@ public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array
public function announceDeletionMode(bool $testMode): void
{
- $message = $testMode ? self::TEST_MODE_MESSAGE : self::LIVE_MODE_MESSAGE;
- $this->logger->info($message);
+ $testModeMessage = $this->translator->trans('Running in test mode, not deleting messages from mailbox');
+ $liveModeMessage = $this->translator->trans('Processed messages will be deleted from the mailbox');
+
+ $this->logger->info($testMode ? $testModeMessage : $liveModeMessage);
}
}
diff --git a/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php b/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php
index c5269aaa..b3de16f9 100644
--- a/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php
+++ b/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php
@@ -6,6 +6,7 @@
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Limits the total processing time of a long-running operation.
@@ -15,8 +16,11 @@ class MaxProcessTimeLimiter
private float $startedAt = 0.0;
private int $maxSeconds;
- public function __construct(private readonly LoggerInterface $logger, ?int $maxSeconds = null)
- {
+ public function __construct(
+ private readonly LoggerInterface $logger,
+ private readonly TranslatorInterface $translator,
+ ?int $maxSeconds = null
+ ) {
$this->maxSeconds = $maxSeconds ?? 600;
}
@@ -36,7 +40,7 @@ public function shouldStop(?OutputInterface $output = null): bool
$elapsed = microtime(true) - $this->startedAt;
if ($elapsed >= $this->maxSeconds) {
$this->logger->warning(sprintf('Reached max processing time of %d seconds', $this->maxSeconds));
- $output?->writeln('Reached max processing time; stopping cleanly.');
+ $output?->writeln($this->translator->trans('Reached max processing time; stopping cleanly.'));
return true;
}
diff --git a/src/Domain/Messaging/Service/MessageProcessingPreparator.php b/src/Domain/Messaging/Service/MessageProcessingPreparator.php
index c602f7d4..9faa72fb 100644
--- a/src/Domain/Messaging/Service/MessageProcessingPreparator.php
+++ b/src/Domain/Messaging/Service/MessageProcessingPreparator.php
@@ -10,6 +10,7 @@
use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
class MessageProcessingPreparator
{
@@ -20,17 +21,20 @@ class MessageProcessingPreparator
private SubscriberRepository $subscriberRepository;
private MessageRepository $messageRepository;
private LinkTrackService $linkTrackService;
+ private TranslatorInterface $translator;
public function __construct(
EntityManagerInterface $entityManager,
SubscriberRepository $subscriberRepository,
MessageRepository $messageRepository,
- LinkTrackService $linkTrackService
+ LinkTrackService $linkTrackService,
+ TranslatorInterface $translator,
) {
$this->entityManager = $entityManager;
$this->subscriberRepository = $subscriberRepository;
$this->messageRepository = $messageRepository;
$this->linkTrackService = $linkTrackService;
+ $this->translator = $translator;
}
public function ensureSubscribersHaveUuid(OutputInterface $output): void
@@ -39,7 +43,9 @@ public function ensureSubscribersHaveUuid(OutputInterface $output): void
$numSubscribers = count($subscribersWithoutUuid);
if ($numSubscribers > 0) {
- $output->writeln(sprintf('Giving a UUID to %d subscribers, this may take a while', $numSubscribers));
+ $output->writeln($this->translator->trans('Giving a UUID to %count% subscribers, this may take a while', [
+ '%count%' => $numSubscribers
+ ]));
foreach ($subscribersWithoutUuid as $subscriber) {
$subscriber->setUniqueId(bin2hex(random_bytes(16)));
}
@@ -53,7 +59,9 @@ public function ensureCampaignsHaveUuid(OutputInterface $output): void
$numCampaigns = count($campaignsWithoutUuid);
if ($numCampaigns > 0) {
- $output->writeln(sprintf('Giving a UUID to %d campaigns', $numCampaigns));
+ $output->writeln($this->translator->trans('Giving a UUID to %count% campaigns', [
+ '%count%' => $numCampaigns
+ ]));
foreach ($campaignsWithoutUuid as $campaign) {
$campaign->setUuid(bin2hex(random_bytes(18)));
}
diff --git a/src/Domain/Messaging/Service/NativeBounceProcessingService.php b/src/Domain/Messaging/Service/NativeBounceProcessingService.php
index eee5bb98..0cdc7cb4 100644
--- a/src/Domain/Messaging/Service/NativeBounceProcessingService.php
+++ b/src/Domain/Messaging/Service/NativeBounceProcessingService.php
@@ -6,10 +6,10 @@
use IMAP\Connection;
use PhpList\Core\Domain\Common\Mail\NativeImapMailReader;
+use PhpList\Core\Domain\Messaging\Exception\OpenMboxFileException;
use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager;
use PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor;
use Psr\Log\LoggerInterface;
-use RuntimeException;
use Throwable;
class NativeBounceProcessingService implements BounceProcessingServiceInterface
@@ -69,9 +69,12 @@ private function openOrFail(string $mailbox, bool $testMode): Connection
{
try {
return $this->mailReader->open($mailbox, $testMode ? 0 : CL_EXPUNGE);
- } catch (Throwable $e) {
- $this->logger->error('Cannot open mailbox file: '.$e->getMessage());
- throw new RuntimeException('Cannot open mbox file');
+ } catch (Throwable $throwable) {
+ $this->logger->error('Cannot open mailbox file', [
+ 'mailbox' => $mailbox,
+ 'error' => $throwable->getMessage(),
+ ]);
+ throw new OpenMboxFileException($throwable);
}
}
diff --git a/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php
index 568bf874..0e1c3fe0 100644
--- a/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php
+++ b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php
@@ -11,6 +11,7 @@
use PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager;
use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Contracts\Translation\TranslatorInterface;
class AdvancedBounceRulesProcessor
{
@@ -19,16 +20,18 @@ public function __construct(
private readonly BounceRuleManager $ruleManager,
private readonly BounceActionResolver $actionResolver,
private readonly SubscriberManager $subscriberManager,
+ private readonly TranslatorInterface $translator,
) {
}
public function process(SymfonyStyle $io, int $batchSize): void
{
- $io->section('Processing bounces based on active bounce rules');
+ $io->section($this->translator->trans('Processing bounces based on active bounce rules'));
$rules = $this->ruleManager->loadActiveRules();
if (!$rules) {
- $io->writeln('No active rules');
+ $io->writeln($this->translator->trans('No active rules'));
+
return;
}
@@ -69,15 +72,20 @@ public function process(SymfonyStyle $io, int $batchSize): void
$processed++;
}
- $io->writeln(sprintf(
- 'processed %d out of %d bounces for advanced bounce rules',
- min($processed, $total),
- $total
+ $io->writeln($this->translator->trans(
+ 'Processed %processed% out of %total% bounces for advanced bounce rules',
+ ['%processed%' => min($processed, $total), '%total%' => $total]
));
}
- $io->writeln(sprintf('%d bounces processed by advanced processing', $matched));
- $io->writeln(sprintf('%d bounces were not matched by advanced processing rules', $notMatched));
+ $io->writeln($this->translator->trans(
+ '%processed% bounces processed by advanced processing',
+ ['%processed%' => $matched]
+ ));
+ $io->writeln($this->translator->trans(
+ '%not_processed% bounces were not matched by advanced processing rules',
+ ['%not_processed%' => $notMatched]
+ ));
}
private function composeText(Bounce $bounce): string
diff --git a/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php
index 6f502a8c..7a33a7e9 100644
--- a/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php
+++ b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php
@@ -6,6 +6,7 @@
use DateTimeImmutable;
use PhpList\Core\Domain\Messaging\Model\Bounce;
+use PhpList\Core\Domain\Messaging\Model\BounceStatus;
use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager;
use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
@@ -44,26 +45,35 @@ public function process(Bounce $bounce, ?string $msgId, ?int $userId, DateTimeIm
if ($msgId === 'systemmessage') {
return $userId ? $this->handleSystemMessageWithUser(
- $bounce,
- $bounceDate,
- $userId,
- $user
- ) : $this->handleSystemMessageUnknownUser($bounce);
+ bounce: $bounce,
+ date: $bounceDate,
+ userId: $userId,
+ userOrNull: $user
+ ) : $this->handleSystemMessageUnknownUser(bounce: $bounce);
}
if ($msgId && $userId) {
- return $this->handleKnownMessageAndUser($bounce, $bounceDate, (int)$msgId, $userId);
+ return $this->handleKnownMessageAndUser(
+ bounce: $bounce,
+ date: $bounceDate,
+ msgId: (int)$msgId,
+ userId: $userId
+ );
}
if ($userId) {
- return $this->handleUserOnly($bounce, $userId);
+ return $this->handleUserOnly(bounce: $bounce, userId: $userId);
}
if ($msgId) {
- return $this->handleMessageOnly($bounce, (int)$msgId);
+ return $this->handleMessageOnly(bounce: $bounce, msgId: (int)$msgId);
}
- $this->bounceManager->update($bounce, 'unidentified bounce', 'not processed');
+ $this->bounceManager->update(
+ bounce: $bounce,
+ status: BounceStatus::UnidentifiedBounce->value,
+ comment: 'not processed'
+ );
return false;
}
@@ -76,10 +86,10 @@ private function handleSystemMessageWithUser(
): bool {
$this->bounceManager->update(
bounce: $bounce,
- status: 'bounced system message',
+ status: BounceStatus::SystemMessage->value,
comment: sprintf('%d marked unconfirmed', $userId)
);
- $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId);
+ $this->bounceManager->linkUserMessageBounce(bounce: $bounce, date: $date, subscriberId: $userId);
$this->subscriberRepository->markUnconfirmed($userId);
$this->logger->info('system message bounced, user marked unconfirmed', ['userId' => $userId]);
@@ -96,7 +106,11 @@ private function handleSystemMessageWithUser(
private function handleSystemMessageUnknownUser(Bounce $bounce): bool
{
- $this->bounceManager->update($bounce, 'bounced system message', 'unknown user');
+ $this->bounceManager->update(
+ bounce:$bounce,
+ status: BounceStatus::SystemMessage->value,
+ comment: 'unknown user'
+ );
$this->logger->info('system message bounced, but unknown user');
return true;
@@ -108,20 +122,30 @@ private function handleKnownMessageAndUser(
int $msgId,
int $userId
): bool {
- if (!$this->bounceManager->existsUserMessageBounce($userId, $msgId)) {
- $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId, $msgId);
+ if (!$this->bounceManager->existsUserMessageBounce(subscriberId: $userId, messageId: $msgId)) {
+ $this->bounceManager->linkUserMessageBounce(
+ bounce: $bounce,
+ date: $date,
+ subscriberId: $userId,
+ messageId: $msgId
+ );
$this->bounceManager->update(
bounce: $bounce,
- status: sprintf('bounced list message %d', $msgId),
+ status: BounceStatus::BouncedList->format($msgId),
comment: sprintf('%d bouncecount increased', $userId)
);
$this->messageRepository->incrementBounceCount($msgId);
$this->subscriberRepository->incrementBounceCount($userId);
} else {
- $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId, $msgId);
+ $this->bounceManager->linkUserMessageBounce(
+ bounce: $bounce,
+ date: $date,
+ subscriberId: $userId,
+ messageId: $msgId
+ );
$this->bounceManager->update(
bounce: $bounce,
- status: sprintf('duplicate bounce for %d', $userId),
+ status: BounceStatus::DuplicateBounce->format($userId),
comment: sprintf('duplicate bounce for subscriber %d on message %d', $userId, $msgId)
);
}
@@ -133,7 +157,7 @@ private function handleUserOnly(Bounce $bounce, int $userId): bool
{
$this->bounceManager->update(
bounce: $bounce,
- status: 'bounced unidentified message',
+ status: BounceStatus::UnidentifiedMessage->value,
comment: sprintf('%d bouncecount increased', $userId)
);
$this->subscriberRepository->incrementBounceCount($userId);
@@ -145,7 +169,7 @@ private function handleMessageOnly(Bounce $bounce, int $msgId): bool
{
$this->bounceManager->update(
bounce: $bounce,
- status: sprintf('bounced list message %d', $msgId),
+ status: BounceStatus::BouncedList->format($msgId),
comment: 'unknown user'
);
$this->messageRepository->incrementBounceCount($msgId);
diff --git a/src/Domain/Messaging/Service/Processor/CampaignProcessor.php b/src/Domain/Messaging/Service/Processor/CampaignProcessor.php
index 92313e28..a5deb074 100644
--- a/src/Domain/Messaging/Service/Processor/CampaignProcessor.php
+++ b/src/Domain/Messaging/Service/Processor/CampaignProcessor.php
@@ -18,6 +18,7 @@
use PhpList\Core\Domain\Subscription\Model\Subscriber;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
use Throwable;
/**
@@ -33,6 +34,7 @@ class CampaignProcessor
private UserMessageRepository $userMessageRepository;
private MaxProcessTimeLimiter $timeLimiter;
private RequeueHandler $requeueHandler;
+ private TranslatorInterface $translator;
public function __construct(
RateLimitedCampaignMailer $mailer,
@@ -42,7 +44,8 @@ public function __construct(
LoggerInterface $logger,
UserMessageRepository $userMessageRepository,
MaxProcessTimeLimiter $timeLimiter,
- RequeueHandler $requeueHandler
+ RequeueHandler $requeueHandler,
+ TranslatorInterface $translator,
) {
$this->mailer = $mailer;
$this->entityManager = $entityManager;
@@ -52,6 +55,7 @@ public function __construct(
$this->userMessageRepository = $userMessageRepository;
$this->timeLimiter = $timeLimiter;
$this->requeueHandler = $requeueHandler;
+ $this->translator = $translator;
}
public function process(Message $campaign, ?OutputInterface $output = null): void
@@ -82,7 +86,9 @@ public function process(Message $campaign, ?OutputInterface $output = null): voi
if (!filter_var($subscriber->getEmail(), FILTER_VALIDATE_EMAIL)) {
$this->updateUserMessageStatus($userMessage, UserMessageStatus::InvalidEmailAddress);
$this->unconfirmSubscriber($subscriber);
- $output?->writeln('Invalid email, marking unconfirmed: ' . $subscriber->getEmail());
+ $output?->writeln($this->translator->trans('Invalid email, marking unconfirmed: %email%', [
+ '%email%' => $subscriber->getEmail(),
+ ]));
continue;
}
@@ -98,7 +104,9 @@ public function process(Message $campaign, ?OutputInterface $output = null): voi
'subscriber_id' => $subscriber->getId(),
'campaign_id' => $campaign->getId(),
]);
- $output?->writeln('Failed to send to: ' . $subscriber->getEmail());
+ $output?->writeln($this->translator->trans('Failed to send to: %email%', [
+ '%email%' => $subscriber->getEmail(),
+ ]));
}
}
diff --git a/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php b/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php
index a52b6f2f..d61742d5 100644
--- a/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php
+++ b/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php
@@ -8,14 +8,17 @@
use RuntimeException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Contracts\Translation\TranslatorInterface;
class MboxBounceProcessor implements BounceProtocolProcessor
{
private BounceProcessingServiceInterface $processingService;
+ private TranslatorInterface $translator;
- public function __construct(BounceProcessingServiceInterface $processingService)
+ public function __construct(BounceProcessingServiceInterface $processingService, TranslatorInterface $translator)
{
$this->processingService = $processingService;
+ $this->translator = $translator;
}
public function getProtocol(): string
@@ -30,12 +33,12 @@ public function process(InputInterface $input, SymfonyStyle $inputOutput): strin
$file = (string)$input->getOption('mailbox');
if (!$file) {
- $inputOutput->error('mbox file path must be provided with --mailbox.');
+ $inputOutput->error($this->translator->trans('mbox file path must be provided with --mailbox.'));
throw new RuntimeException('Missing --mailbox for mbox protocol');
}
- $inputOutput->section('Opening mbox ' . $file);
- $inputOutput->writeln('Please do not interrupt this process');
+ $inputOutput->section($this->translator->trans('Opening mbox %file%', ['%file%' => $file]));
+ $inputOutput->writeln($this->translator->trans('Please do not interrupt this process'));
return $this->processingService->processMailbox(
mailbox: $file,
diff --git a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php
index b6f59f65..b0079774 100644
--- a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php
+++ b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php
@@ -7,6 +7,7 @@
use PhpList\Core\Domain\Messaging\Service\BounceProcessingServiceInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Contracts\Translation\TranslatorInterface;
class PopBounceProcessor implements BounceProtocolProcessor
{
@@ -14,17 +15,20 @@ class PopBounceProcessor implements BounceProtocolProcessor
private string $host;
private int $port;
private string $mailboxNames;
+ private TranslatorInterface $translator;
public function __construct(
BounceProcessingServiceInterface $processingService,
string $host,
int $port,
- string $mailboxNames
+ string $mailboxNames,
+ TranslatorInterface $translator
) {
$this->processingService = $processingService;
$this->host = $host;
$this->port = $port;
$this->mailboxNames = $mailboxNames;
+ $this->translator = $translator;
}
public function getProtocol(): string
@@ -44,8 +48,8 @@ public function process(InputInterface $input, SymfonyStyle $inputOutput): strin
$mailboxName = 'INBOX';
}
$mailbox = sprintf('{%s:%s}%s', $this->host, $this->port, $mailboxName);
- $inputOutput->section('Connecting to ' . $mailbox);
- $inputOutput->writeln('Please do not interrupt this process');
+ $inputOutput->section($this->translator->trans('Connecting to %mailbox%', ['%mailbox%' => $mailbox]));
+ $inputOutput->writeln($this->translator->trans('Please do not interrupt this process'));
$downloadReport .= $this->processingService->processMailbox(
mailbox: $mailbox,
diff --git a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php
index 503fc459..2646ede6 100644
--- a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php
+++ b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php
@@ -5,33 +5,37 @@
namespace PhpList\Core\Domain\Messaging\Service\Processor;
use DateTimeImmutable;
+use PhpList\Core\Domain\Messaging\Model\BounceStatus;
use PhpList\Core\Domain\Messaging\Service\MessageParser;
use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager;
use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Contracts\Translation\TranslatorInterface;
class UnidentifiedBounceReprocessor
{
private BounceManager $bounceManager;
private MessageParser $messageParser;
private BounceDataProcessor $bounceDataProcessor;
-
+ private TranslatorInterface $translator;
public function __construct(
BounceManager $bounceManager,
MessageParser $messageParser,
BounceDataProcessor $bounceDataProcessor,
+ TranslatorInterface $translator,
) {
$this->bounceManager = $bounceManager;
$this->messageParser = $messageParser;
$this->bounceDataProcessor = $bounceDataProcessor;
+ $this->translator = $translator;
}
public function process(SymfonyStyle $inputOutput): void
{
- $inputOutput->section('Reprocessing unidentified bounces');
- $bounces = $this->bounceManager->findByStatus('unidentified bounce');
+ $inputOutput->section($this->translator->trans('Reprocessing unidentified bounces'));
+ $bounces = $this->bounceManager->findByStatus(BounceStatus::UnidentifiedBounce->value);
$total = count($bounces);
- $inputOutput->writeln(sprintf('%d bounces to reprocess', $total));
+ $inputOutput->writeln($this->translator->trans('%total% bounces to reprocess', ['%total%' => $total]));
$count = 0;
$reparsed = 0;
@@ -39,20 +43,23 @@ public function process(SymfonyStyle $inputOutput): void
foreach ($bounces as $bounce) {
$count++;
if ($count % 25 === 0) {
- $inputOutput->writeln(sprintf('%d out of %d processed', $count, $total));
+ $inputOutput->writeln($this->translator->trans('%count% out of %total% processed', [
+ '%count%' => $count,
+ '%total%' => $total
+ ]));
}
- $decodedBody = $this->messageParser->decodeBody($bounce->getHeader(), $bounce->getData());
+ $decodedBody = $this->messageParser->decodeBody(header: $bounce->getHeader(), body: $bounce->getData());
$userId = $this->messageParser->findUserId($decodedBody);
$messageId = $this->messageParser->findMessageId($decodedBody);
if ($userId || $messageId) {
$reparsed++;
if ($this->bounceDataProcessor->process(
- $bounce,
- $messageId,
- $userId,
- new DateTimeImmutable()
+ bounce: $bounce,
+ msgId: $messageId,
+ userId: $userId,
+ bounceDate: new DateTimeImmutable()
)
) {
$reidentified++;
@@ -60,11 +67,13 @@ public function process(SymfonyStyle $inputOutput): void
}
}
- $inputOutput->writeln(sprintf('%d out of %d processed', $count, $total));
- $inputOutput->writeln(sprintf(
- '%d bounces were re-processed and %d bounces were re-identified',
- $reparsed,
- $reidentified
+ $inputOutput->writeln($this->translator->trans('%count% out of %total% processed', [
+ '%count%' => $count,
+ '%total%' => $total
+ ]));
+ $inputOutput->writeln($this->translator->trans(
+ '%reparsed% bounces were re-processed and %reidentified% bounces were re-identified',
+ ['%reparsed%' => $reparsed, '%reidentified%' => $reidentified]
));
}
}
diff --git a/src/Domain/Messaging/Service/SendRateLimiter.php b/src/Domain/Messaging/Service/SendRateLimiter.php
index 378b80d5..2590e721 100644
--- a/src/Domain/Messaging/Service/SendRateLimiter.php
+++ b/src/Domain/Messaging/Service/SendRateLimiter.php
@@ -9,6 +9,7 @@
use PhpList\Core\Domain\Common\IspRestrictionsProvider;
use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository;
use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Encapsulates batching and throttling logic for sending emails respecting
@@ -26,6 +27,7 @@ class SendRateLimiter
public function __construct(
private readonly IspRestrictionsProvider $ispRestrictionsProvider,
private readonly UserMessageRepository $userMessageRepository,
+ private readonly TranslatorInterface $translator,
private readonly ?int $mailqueueBatchSize = null,
private readonly ?int $mailqueueBatchPeriod = null,
private readonly ?int $mailqueueThrottle = null,
@@ -76,9 +78,9 @@ public function awaitTurn(?OutputInterface $output = null): bool
$elapsed = microtime(true) - $this->batchStart;
$remaining = (int)ceil($this->batchPeriod - $elapsed);
if ($remaining > 0) {
- $output?->writeln(sprintf(
- 'Batch limit reached, sleeping %ds to respect MAILQUEUE_BATCH_PERIOD',
- $remaining
+ $output?->writeln($this->translator->trans(
+ 'Batch limit reached, sleeping %sleep%s to respect MAILQUEUE_BATCH_PERIOD',
+ ['%sleep%' => $remaining]
));
sleep($remaining);
}
diff --git a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php
index 01a94aff..09a1c14a 100644
--- a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php
+++ b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php
@@ -6,10 +6,10 @@
use DateTimeImmutable;
use DateTimeInterface;
+use PhpList\Core\Domain\Messaging\Exception\ImapConnectionException;
use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager;
use PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor;
use Psr\Log\LoggerInterface;
-use RuntimeException;
use Throwable;
use Webklex\PHPIMAP\Client;
use Webklex\PHPIMAP\Folder;
@@ -50,7 +50,7 @@ public function __construct(
*
* $mailbox: IMAP host; if you pass "host#FOLDER", FOLDER will be used instead of INBOX.
*
- * @throws RuntimeException If connection to the IMAP server cannot be established.
+ * @throws ImapConnectionException If connection to the IMAP server cannot be established.
*/
public function processMailbox(
string $mailbox,
@@ -61,9 +61,12 @@ public function processMailbox(
try {
$client->connect();
- } catch (Throwable $e) {
- $this->logger->error('Cannot connect to mailbox: '.$e->getMessage());
- throw new RuntimeException('Cannot connect to IMAP server');
+ } catch (Throwable $throwable) {
+ $this->logger->error('Cannot connect to mailbox', [
+ 'mailbox' => $mailbox,
+ 'error' => $throwable->getMessage()
+ ]);
+ throw new ImapConnectionException($throwable);
}
try {
diff --git a/src/Domain/Messaging/Validator/TemplateImageValidator.php b/src/Domain/Messaging/Validator/TemplateImageValidator.php
index 11bcc329..5e50e075 100644
--- a/src/Domain/Messaging/Validator/TemplateImageValidator.php
+++ b/src/Domain/Messaging/Validator/TemplateImageValidator.php
@@ -9,18 +9,21 @@
use PhpList\Core\Domain\Common\Model\ValidationContext;
use PhpList\Core\Domain\Common\Validator\ValidatorInterface;
use Symfony\Component\Validator\Exception\ValidatorException;
+use Symfony\Contracts\Translation\TranslatorInterface;
use Throwable;
class TemplateImageValidator implements ValidatorInterface
{
- public function __construct(private readonly ClientInterface $httpClient)
- {
+ public function __construct(
+ private readonly ClientInterface $httpClient,
+ private readonly TranslatorInterface $translator,
+ ) {
}
public function validate(mixed $value, ValidationContext $context = null): void
{
if (!is_array($value)) {
- throw new InvalidArgumentException('Value must be an array of image URLs.');
+ throw new InvalidArgumentException($this->translator->trans('Value must be an array of image URLs.'));
}
$checkFull = $context?->get('checkImages', false);
@@ -42,7 +45,7 @@ private function validateFullUrls(array $urls): array
foreach ($urls as $url) {
if (!preg_match('#^https?://#i', $url)) {
- $errors[] = sprintf('Image "%s" is not a full URL.', $url);
+ $errors[] = $this->translator->trans('Image "%url%" is not a full URL.', ['%url%' => $url]);
}
}
@@ -61,10 +64,16 @@ private function validateExistence(array $urls): array
try {
$response = $this->httpClient->request('HEAD', $url);
if ($response->getStatusCode() !== 200) {
- $errors[] = sprintf('Image "%s" does not exist (HTTP %s)', $url, $response->getStatusCode());
+ $errors[] = $this->translator->trans('Image "%url%" does not exist (HTTP %code%)', [
+ '%url%' => $url,
+ '%code%' => $response->getStatusCode()
+ ]);
}
} catch (Throwable $e) {
- $errors[] = sprintf('Image "%s" could not be validated: %s', $url, $e->getMessage());
+ $errors[] = $this->translator->trans('Image "%url%" could not be validated: %message%', [
+ '%url%' => $url,
+ '%message%' => $e->getMessage()
+ ]);
}
}
diff --git a/src/Domain/Messaging/Validator/TemplateLinkValidator.php b/src/Domain/Messaging/Validator/TemplateLinkValidator.php
index 18c772df..621f35a7 100644
--- a/src/Domain/Messaging/Validator/TemplateLinkValidator.php
+++ b/src/Domain/Messaging/Validator/TemplateLinkValidator.php
@@ -8,9 +8,14 @@
use PhpList\Core\Domain\Common\Model\ValidationContext;
use PhpList\Core\Domain\Common\Validator\ValidatorInterface;
use Symfony\Component\Validator\Exception\ValidatorException;
+use Symfony\Contracts\Translation\TranslatorInterface;
class TemplateLinkValidator implements ValidatorInterface
{
+ public function __construct(private readonly TranslatorInterface $translator)
+ {
+ }
+
private const PLACEHOLDERS = [
'[PREFERENCESURL]',
'[UNSUBSCRIBEURL]',
@@ -37,10 +42,9 @@ public function validate(mixed $value, ValidationContext $context = null): void
}
if (!empty($invalid)) {
- throw new ValidatorException(sprintf(
- 'Not full URLs: %s',
- implode(', ', $invalid)
- ));
+ throw new ValidatorException(
+ $this->translator->trans('Not full URLs: %urls%', ['%urls%' => implode(', ', $invalid)]),
+ );
}
}
diff --git a/src/Domain/Subscription/Exception/CouldNotReadUploadedFileException.php b/src/Domain/Subscription/Exception/CouldNotReadUploadedFileException.php
new file mode 100644
index 00000000..9a9d9c8b
--- /dev/null
+++ b/src/Domain/Subscription/Exception/CouldNotReadUploadedFileException.php
@@ -0,0 +1,12 @@
+statusCode = $statusCode;
diff --git a/src/Domain/Subscription/Service/CsvImporter.php b/src/Domain/Subscription/Service/CsvImporter.php
index 3b3729e3..01fb51ea 100644
--- a/src/Domain/Subscription/Service/CsvImporter.php
+++ b/src/Domain/Subscription/Service/CsvImporter.php
@@ -8,6 +8,7 @@
use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use League\Csv\Exception as CsvException;
+use Symfony\Contracts\Translation\TranslatorInterface;
use Throwable;
class CsvImporter
@@ -15,6 +16,7 @@ class CsvImporter
public function __construct(
private readonly CsvRowToDtoMapper $rowMapper,
private readonly ValidatorInterface $validator,
+ private readonly TranslatorInterface $translator,
) {
}
@@ -46,7 +48,9 @@ public function import(string $csvFilePath): array
$validDtos[] = $dto;
} catch (Throwable $e) {
- $errors[$index + 1][] = 'Unexpected error: ' . $e->getMessage();
+ $errors[$index + 1][] = $this->translator->trans('Unexpected error: %error%', [
+ '%error%' => $e->getMessage()
+ ]);
}
}
diff --git a/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php b/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php
index d8983e65..d91956c6 100644
--- a/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php
+++ b/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php
@@ -9,25 +9,32 @@
use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition;
use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository;
use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator;
+use Symfony\Contracts\Translation\TranslatorInterface;
class AttributeDefinitionManager
{
private SubscriberAttributeDefinitionRepository $definitionRepository;
private AttributeTypeValidator $attributeTypeValidator;
+ private TranslatorInterface $translator;
public function __construct(
SubscriberAttributeDefinitionRepository $definitionRepository,
- AttributeTypeValidator $attributeTypeValidator
+ AttributeTypeValidator $attributeTypeValidator,
+ TranslatorInterface $translator,
) {
$this->definitionRepository = $definitionRepository;
$this->attributeTypeValidator = $attributeTypeValidator;
+ $this->translator = $translator;
}
public function create(AttributeDefinitionDto $attributeDefinitionDto): SubscriberAttributeDefinition
{
$existingAttribute = $this->definitionRepository->findOneByName($attributeDefinitionDto->name);
if ($existingAttribute) {
- throw new AttributeDefinitionCreationException('Attribute definition already exists', 409);
+ throw new AttributeDefinitionCreationException(
+ message: $this->translator->trans('Attribute definition already exists'),
+ statusCode: 409
+ );
}
$this->attributeTypeValidator->validate($attributeDefinitionDto->type);
@@ -50,7 +57,10 @@ public function update(
): SubscriberAttributeDefinition {
$existingAttribute = $this->definitionRepository->findOneByName($attributeDefinitionDto->name);
if ($existingAttribute && $existingAttribute->getId() !== $attributeDefinition->getId()) {
- throw new AttributeDefinitionCreationException('Another attribute with this name already exists.', 409);
+ throw new AttributeDefinitionCreationException(
+ message: $this->translator->trans('Another attribute with this name already exists.'),
+ statusCode: 409
+ );
}
$this->attributeTypeValidator->validate($attributeDefinitionDto->type);
diff --git a/src/Domain/Subscription/Service/Manager/SubscribePageManager.php b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php
index 8e429dc4..b0017e6c 100644
--- a/src/Domain/Subscription/Service/Manager/SubscribePageManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php
@@ -11,6 +11,7 @@
use PhpList\Core\Domain\Subscription\Repository\SubscriberPageDataRepository;
use PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Contracts\Translation\TranslatorInterface;
class SubscribePageManager
{
@@ -18,6 +19,7 @@ public function __construct(
private readonly SubscriberPageRepository $pageRepository,
private readonly SubscriberPageDataRepository $pageDataRepository,
private readonly EntityManagerInterface $entityManager,
+ private readonly TranslatorInterface $translator,
) {
}
@@ -41,7 +43,7 @@ public function getPage(int $id): SubscribePage
/** @var SubscribePage|null $page */
$page = $this->pageRepository->find($id);
if (!$page) {
- throw new NotFoundHttpException('Subscribe page not found');
+ throw new NotFoundHttpException($this->translator->trans('Subscribe page not found'));
}
return $page;
diff --git a/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php b/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php
index cf83ca75..4446e0bf 100644
--- a/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php
@@ -10,18 +10,22 @@
use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition;
use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue;
use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository;
+use Symfony\Contracts\Translation\TranslatorInterface;
class SubscriberAttributeManager
{
private SubscriberAttributeValueRepository $attributeRepository;
private EntityManagerInterface $entityManager;
+ private TranslatorInterface $translator;
public function __construct(
SubscriberAttributeValueRepository $attributeRepository,
EntityManagerInterface $entityManager,
+ TranslatorInterface $translator,
) {
$this->attributeRepository = $attributeRepository;
$this->entityManager = $entityManager;
+ $this->translator = $translator;
}
public function createOrUpdate(
@@ -38,7 +42,7 @@ public function createOrUpdate(
$value = $value ?? $definition->getDefaultValue();
if ($value === null) {
- throw new SubscriberAttributeCreationException('Value is required', 400);
+ throw new SubscriberAttributeCreationException($this->translator->trans('Value is required'));
}
$subscriberAttribute->setValue($value);
diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php
index 73531fbb..25d7045a 100644
--- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php
@@ -15,6 +15,7 @@
use PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
class SubscriberManager
{
@@ -22,17 +23,20 @@ class SubscriberManager
private EntityManagerInterface $entityManager;
private MessageBusInterface $messageBus;
private SubscriberDeletionService $subscriberDeletionService;
+ private TranslatorInterface $translator;
public function __construct(
SubscriberRepository $subscriberRepository,
EntityManagerInterface $entityManager,
MessageBusInterface $messageBus,
SubscriberDeletionService $subscriberDeletionService,
+ TranslatorInterface $translator
) {
$this->subscriberRepository = $subscriberRepository;
$this->entityManager = $entityManager;
$this->messageBus = $messageBus;
$this->subscriberDeletionService = $subscriberDeletionService;
+ $this->translator = $translator;
}
public function createSubscriber(CreateSubscriberDto $subscriberDto): Subscriber
@@ -91,7 +95,7 @@ public function markAsConfirmedByUniqueId(string $uniqueId): Subscriber
{
$subscriber = $this->subscriberRepository->findOneByUniqueId($uniqueId);
if (!$subscriber) {
- throw new NotFoundHttpException('Subscriber not found');
+ throw new NotFoundHttpException($this->translator->trans('Subscriber not found'));
}
$subscriber->setConfirmed(true);
diff --git a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php
index 764106ec..6bed4d5b 100644
--- a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php
@@ -4,7 +4,6 @@
namespace PhpList\Core\Domain\Subscription\Service\Manager;
-use PhpList\Core\Domain\Common\I18n\Messages;
use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException;
use PhpList\Core\Domain\Subscription\Model\Subscriber;
use PhpList\Core\Domain\Subscription\Model\SubscriberList;
@@ -42,7 +41,7 @@ public function addSubscriberToAList(Subscriber $subscriber, int $listId): Subsc
}
$subscriberList = $this->subscriberListRepository->find($listId);
if (!$subscriberList) {
- $message = $this->translator->trans(Messages::SUBSCRIPTION_LIST_NOT_FOUND);
+ $message = $this->translator->trans('Subscriber list not found.');
throw new SubscriptionCreationException($message, 404);
}
@@ -70,7 +69,7 @@ private function createSubscription(SubscriberList $subscriberList, string $emai
{
$subscriber = $this->subscriberRepository->findOneBy(['email' => $email]);
if (!$subscriber) {
- $message = $this->translator->trans(Messages::SUBSCRIPTION_SUBSCRIBER_NOT_FOUND);
+ $message = $this->translator->trans('Subscriber does not exists.');
throw new SubscriptionCreationException($message, 404);
}
@@ -108,7 +107,7 @@ private function deleteSubscription(SubscriberList $subscriberList, string $emai
->findOneBySubscriberEmailAndListId($subscriberList->getId(), $email);
if (!$subscription) {
- $message = $this->translator->trans(Messages::SUBSCRIPTION_NOT_FOUND_FOR_LIST_AND_SUBSCRIBER);
+ $message = $this->translator->trans('Subscription not found for this subscriber and list.');
throw new SubscriptionCreationException($message, 404);
}
diff --git a/src/Domain/Subscription/Service/SubscriberBlacklistService.php b/src/Domain/Subscription/Service/SubscriberBlacklistService.php
index d9ca5ea6..3a40f042 100644
--- a/src/Domain/Subscription/Service/SubscriberBlacklistService.php
+++ b/src/Domain/Subscription/Service/SubscriberBlacklistService.php
@@ -9,6 +9,7 @@
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Contracts\Translation\TranslatorInterface;
class SubscriberBlacklistService
{
@@ -16,17 +17,20 @@ class SubscriberBlacklistService
private SubscriberBlacklistManager $blacklistManager;
private SubscriberHistoryManager $historyManager;
private RequestStack $requestStack;
+ private TranslatorInterface $translator;
public function __construct(
EntityManagerInterface $entityManager,
SubscriberBlacklistManager $blacklistManager,
SubscriberHistoryManager $historyManager,
RequestStack $requestStack,
+ TranslatorInterface $translator,
) {
$this->entityManager = $entityManager;
$this->blacklistManager = $blacklistManager;
$this->historyManager = $historyManager;
$this->requestStack = $requestStack;
+ $this->translator = $translator;
}
/**
@@ -55,7 +59,7 @@ public function blacklist(Subscriber $subscriber, string $reason): void
$this->historyManager->addHistory(
subscriber: $subscriber,
message: 'Added to blacklist',
- details: sprintf('Added to blacklist for reason %s', $reason)
+ details: $this->translator->trans('Added to blacklist for reason %reason%', ['%reason%' => $reason])
);
if (isset($GLOBALS['plugins']) && is_array($GLOBALS['plugins'])) {
diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php
index 4c58f22c..c88b935e 100644
--- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php
+++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php
@@ -5,6 +5,7 @@
namespace PhpList\Core\Domain\Subscription\Service;
use Doctrine\ORM\EntityManagerInterface;
+use PhpList\Core\Domain\Subscription\Exception\CouldNotReadUploadedFileException;
use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto;
use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions;
use PhpList\Core\Domain\Subscription\Model\Subscriber;
@@ -13,8 +14,8 @@
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager;
-use RuntimeException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
+use Symfony\Contracts\Translation\TranslatorInterface;
use Throwable;
/**
@@ -30,6 +31,7 @@ class SubscriberCsvImporter
private CsvImporter $csvImporter;
private SubscriberAttributeDefinitionRepository $attrDefinitionRepository;
private EntityManagerInterface $entityManager;
+ private TranslatorInterface $translator;
public function __construct(
SubscriberManager $subscriberManager,
@@ -38,7 +40,8 @@ public function __construct(
SubscriberRepository $subscriberRepository,
CsvImporter $csvImporter,
SubscriberAttributeDefinitionRepository $attrDefinitionRepository,
- EntityManagerInterface $entityManager
+ EntityManagerInterface $entityManager,
+ TranslatorInterface $translator,
) {
$this->subscriberManager = $subscriberManager;
$this->attributeManager = $attributeManager;
@@ -47,6 +50,7 @@ public function __construct(
$this->csvImporter = $csvImporter;
$this->attrDefinitionRepository = $attrDefinitionRepository;
$this->entityManager = $entityManager;
+ $this->translator = $translator;
}
/**
@@ -55,7 +59,7 @@ public function __construct(
* @param UploadedFile $file The uploaded CSV file
* @param SubscriberImportOptions $options
* @return array Import statistics
- * @throws RuntimeException When the uploaded file cannot be read or for any other errors during import
+ * @throws CouldNotReadUploadedFileException When the uploaded file cannot be read during import
*/
public function importFromCsv(UploadedFile $file, SubscriberImportOptions $options): array
{
@@ -69,7 +73,9 @@ public function importFromCsv(UploadedFile $file, SubscriberImportOptions $optio
try {
$path = $file->getRealPath();
if ($path === false) {
- throw new RuntimeException('Could not read the uploaded file.');
+ throw new CouldNotReadUploadedFileException(
+ $this->translator->trans('Could not read the uploaded file.')
+ );
}
$result = $this->csvImporter->import($path);
@@ -81,7 +87,10 @@ public function importFromCsv(UploadedFile $file, SubscriberImportOptions $optio
$this->entityManager->flush();
}
} catch (Throwable $e) {
- $stats['errors'][] = 'Error processing ' . $dto->email . ': ' . $e->getMessage();
+ $stats['errors'][] = $this->translator->trans(
+ 'Error processing %email%: %error%',
+ ['%email%' => $dto->email, '%error%' => $e->getMessage()]
+ );
$stats['skipped']++;
}
}
@@ -91,7 +100,10 @@ public function importFromCsv(UploadedFile $file, SubscriberImportOptions $optio
$stats['skipped']++;
}
} catch (Throwable $e) {
- $stats['errors'][] = 'General import error: ' . $e->getMessage();
+ $stats['errors'][] = $this->translator->trans(
+ 'General import error: %error%',
+ ['%error%' => $e->getMessage()]
+ );
}
return $stats;
diff --git a/src/Domain/Subscription/Validator/AttributeTypeValidator.php b/src/Domain/Subscription/Validator/AttributeTypeValidator.php
index 3923cdfc..36bcd45d 100644
--- a/src/Domain/Subscription/Validator/AttributeTypeValidator.php
+++ b/src/Domain/Subscription/Validator/AttributeTypeValidator.php
@@ -8,9 +8,14 @@
use PhpList\Core\Domain\Common\Model\ValidationContext;
use PhpList\Core\Domain\Common\Validator\ValidatorInterface;
use Symfony\Component\Validator\Exception\ValidatorException;
+use Symfony\Contracts\Translation\TranslatorInterface;
class AttributeTypeValidator implements ValidatorInterface
{
+ public function __construct(private readonly TranslatorInterface $translator)
+ {
+ }
+
private const VALID_TYPES = [
'textline',
'checkbox',
@@ -25,15 +30,17 @@ class AttributeTypeValidator implements ValidatorInterface
public function validate(mixed $value, ValidationContext $context = null): void
{
if (!is_string($value)) {
- throw new InvalidArgumentException('Value must be a string.');
+ throw new InvalidArgumentException($this->translator->trans('Value must be a string.'));
}
$errors = [];
if (!in_array($value, self::VALID_TYPES, true)) {
- $errors[] = sprintf(
- 'Invalid attribute type: "%s". Valid types are: %s',
- $value,
- implode(', ', self::VALID_TYPES)
+ $errors[] = $this->translator->trans(
+ 'Invalid attribute type: "%type%". Valid types are: %valid_types%',
+ [
+ '%type%' => $value,
+ '%valid_types%' => implode(', ', self::VALID_TYPES),
+ ]
);
}
diff --git a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php
index 613e2c1f..109fb634 100644
--- a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php
+++ b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php
@@ -4,8 +4,8 @@
namespace PhpList\Core\Tests\Unit\Domain\Analytics\Service;
-use InvalidArgumentException;
use PhpList\Core\Core\ConfigProvider;
+use PhpList\Core\Domain\Analytics\Exception\MissingMessageIdException;
use PhpList\Core\Domain\Analytics\Model\LinkTrack;
use PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository;
use PhpList\Core\Domain\Analytics\Service\LinkTrackService;
@@ -172,7 +172,7 @@ public function testExtractAndSaveLinksWithMessageWithoutId(): void
$message->method('getId')->willReturn(null);
$message->method('getContent')->willReturn($messageContent);
- $this->expectException(InvalidArgumentException::class);
+ $this->expectException(MissingMessageIdException::class);
$this->expectExceptionMessage('Message must have an ID');
$this->subject->extractAndSaveLinks($message, $userId);
diff --git a/tests/Unit/Domain/Common/Repository/CursorPaginationTraitTest.php b/tests/Unit/Domain/Common/Repository/CursorPaginationTraitTest.php
index 137da779..02cd5c40 100644
--- a/tests/Unit/Domain/Common/Repository/CursorPaginationTraitTest.php
+++ b/tests/Unit/Domain/Common/Repository/CursorPaginationTraitTest.php
@@ -4,12 +4,12 @@
namespace PhpList\Core\Tests\Unit\Domain\Common\Repository;
+use BadMethodCallException;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
-use RuntimeException;
final class CursorPaginationTraitTest extends TestCase
{
@@ -59,8 +59,8 @@ public function testGetFilteredAfterIdWithFilterThrows(): void
{
$dummyFilter = $this->createMock(FilterRequestInterface::class);
- $this->expectException(RuntimeException::class);
- $this->expectExceptionMessage('Filter method not implemented');
+ $this->expectException(BadMethodCallException::class);
+ $this->expectExceptionMessage('getFilteredAfterId method not implemented');
$this->repo->getFilteredAfterId(0, 10, $dummyFilter);
}
diff --git a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php
index e42aba74..1a4deef4 100644
--- a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php
+++ b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php
@@ -12,17 +12,24 @@
use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use Symfony\Contracts\Translation\TranslatorInterface;
class AdminAttributeDefinitionManagerTest extends TestCase
{
private AdminAttributeDefinitionRepository&MockObject $repository;
private AdminAttributeDefinitionManager $subject;
+ private TranslatorInterface&MockObject $translator;
protected function setUp(): void
{
$this->repository = $this->createMock(AdminAttributeDefinitionRepository::class);
$attributeTypeValidator = $this->createMock(AttributeTypeValidator::class);
- $this->subject = new AdminAttributeDefinitionManager($this->repository, $attributeTypeValidator);
+ $this->translator = $this->createMock(TranslatorInterface::class);
+ $this->subject = new AdminAttributeDefinitionManager(
+ definitionRepository: $this->repository,
+ attributeTypeValidator: $attributeTypeValidator,
+ translator: $this->translator,
+ );
}
public function testCreateCreatesNewAttributeDefinition(): void
@@ -76,6 +83,11 @@ public function testCreateThrowsExceptionIfAttributeAlreadyExists(): void
->with('test-attribute')
->willReturn($existingAttribute);
+ $this->translator->expects($this->once())
+ ->method('trans')
+ ->with('Attribute definition already exists.')
+ ->willReturn('Attribute definition already exists.');
+
$this->expectException(AttributeDefinitionCreationException::class);
$this->expectExceptionMessage('Attribute definition already exists');
diff --git a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php
index 14419b0e..da620f12 100644
--- a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php
+++ b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php
@@ -4,7 +4,6 @@
namespace PhpList\Core\Tests\Unit\Domain\Identity\Service;
-use PhpList\Core\Domain\Common\I18n\Messages;
use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager;
use PhpList\Core\Domain\Identity\Model\AdministratorToken;
use PhpList\Core\Domain\Identity\Repository\AdministratorRepository;
@@ -36,8 +35,8 @@ public function testCreateSessionWithInvalidCredentialsThrowsExceptionAndLogs():
$translator->expects(self::exactly(2))
->method('trans')
->withConsecutive(
- [Messages::AUTH_LOGIN_FAILED, ['login' => 'admin']],
- [Messages::AUTH_NOT_AUTHORIZED, []]
+ ["Failed admin login attempt for '%login%'", ['login' => 'admin']],
+ ['Not authorized', []]
)
->willReturnOnConsecutiveCalls(
"Failed admin login attempt for 'admin'",
diff --git a/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php
index 50cce9fa..3e8e24b6 100644
--- a/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php
+++ b/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php
@@ -15,6 +15,8 @@
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Tester\CommandTester;
+use Symfony\Component\Translation\Translator;
+use Symfony\Contracts\Translation\TranslatorInterface;
class ProcessBouncesCommandTest extends TestCase
{
@@ -26,6 +28,7 @@ class ProcessBouncesCommandTest extends TestCase
private ConsecutiveBounceHandler&MockObject $consecutiveBounceHandler;
private CommandTester $commandTester;
+ private TranslatorInterface|MockObject $translator;
protected function setUp(): void
{
@@ -35,6 +38,7 @@ protected function setUp(): void
$this->advancedRulesProcessor = $this->createMock(AdvancedBounceRulesProcessor::class);
$this->unidentifiedReprocessor = $this->createMock(UnidentifiedBounceReprocessor::class);
$this->consecutiveBounceHandler = $this->createMock(ConsecutiveBounceHandler::class);
+ $this->translator = new Translator('en');
$command = new ProcessBouncesCommand(
lockService: $this->lockService,
@@ -43,6 +47,7 @@ protected function setUp(): void
advancedRulesProcessor: $this->advancedRulesProcessor,
unidentifiedReprocessor: $this->unidentifiedReprocessor,
consecutiveBounceHandler: $this->consecutiveBounceHandler,
+ translator: $this->translator,
);
$this->commandTester = new CommandTester($command);
diff --git a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php
index d76f63c0..d8e837ba 100644
--- a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php
+++ b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php
@@ -17,6 +17,7 @@
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\LockInterface;
+use Symfony\Component\Translation\Translator;
class ProcessQueueCommandTest extends TestCase
{
@@ -25,6 +26,7 @@ class ProcessQueueCommandTest extends TestCase
private CampaignProcessor&MockObject $campaignProcessor;
private LockInterface&MockObject $lock;
private CommandTester $commandTester;
+ private Translator&MockObject $translator;
protected function setUp(): void
{
@@ -33,17 +35,19 @@ protected function setUp(): void
$this->messageProcessingPreparator = $this->createMock(MessageProcessingPreparator::class);
$this->campaignProcessor = $this->createMock(CampaignProcessor::class);
$this->lock = $this->createMock(LockInterface::class);
+ $this->translator = $this->createMock(Translator::class);
$lockFactory->method('createLock')
->with('queue_processor')
->willReturn($this->lock);
$command = new ProcessQueueCommand(
- $this->messageRepository,
- $lockFactory,
- $this->messageProcessingPreparator,
- $this->campaignProcessor,
- $this->createMock(ConfigManager::class),
+ messageRepository: $this->messageRepository,
+ lockFactory: $lockFactory,
+ messagePreparator: $this->messageProcessingPreparator,
+ campaignProcessor: $this->campaignProcessor,
+ configManager: $this->createMock(ConfigManager::class),
+ translator: $this->translator,
);
$application = new Application();
@@ -61,10 +65,15 @@ public function testExecuteWithLockAlreadyAcquired(): void
$this->messageProcessingPreparator->expects($this->never())
->method('ensureSubscribersHaveUuid');
+ $this->translator->expects($this->once())
+ ->method('trans')
+ ->with('Queue is already being processed by another instance.')
+ ->willReturn('Queue is already being processed by another instance.');
+
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
- $this->assertStringContainsString('Queue is already being processed by another instance', $output);
+ $this->assertStringContainsString('Queue is already being processed by another instance.', $output);
$this->assertEquals(1, $this->commandTester->getStatusCode());
}
diff --git a/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php b/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php
index c1b4a92c..4e8bae26 100644
--- a/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php
+++ b/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php
@@ -4,6 +4,7 @@
namespace PhpList\Core\Tests\Unit\Domain\Messaging\Command;
+use Exception;
use PhpList\Core\Domain\Messaging\Command\SendTestEmailCommand;
use PhpList\Core\Domain\Messaging\Service\EmailService;
use PHPUnit\Framework\MockObject\MockObject;
@@ -11,16 +12,20 @@
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Mime\Email;
+use Symfony\Component\Translation\Translator;
+use Symfony\Contracts\Translation\TranslatorInterface;
class SendTestEmailCommandTest extends TestCase
{
private EmailService&MockObject $emailService;
private CommandTester $commandTester;
+ private TranslatorInterface $translator;
protected function setUp(): void
{
$this->emailService = $this->createMock(EmailService::class);
- $command = new SendTestEmailCommand($this->emailService);
+ $this->translator = new Translator('en');
+ $command = new SendTestEmailCommand($this->emailService, $this->translator);
$application = new Application();
$application->add($command);
@@ -165,7 +170,7 @@ public function testExecuteWithEmailServiceException(): void
{
$this->emailService->expects($this->once())
->method('sendEmail')
- ->willThrowException(new \Exception('Test exception'));
+ ->willThrowException(new Exception('Test exception'));
$this->commandTester->execute([
'recipient' => 'test@example.com',
@@ -182,7 +187,7 @@ public function testExecuteWithEmailServiceExceptionSync(): void
{
$this->emailService->expects($this->once())
->method('sendEmailSync')
- ->willThrowException(new \Exception('Test sync exception'));
+ ->willThrowException(new Exception('Test sync exception'));
$this->commandTester->execute([
'recipient' => 'test@example.com',
diff --git a/tests/Unit/Domain/Messaging/MessageHandler/PasswordResetMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/PasswordResetMessageHandlerTest.php
index ae7aa184..22f83bfc 100644
--- a/tests/Unit/Domain/Messaging/MessageHandler/PasswordResetMessageHandlerTest.php
+++ b/tests/Unit/Domain/Messaging/MessageHandler/PasswordResetMessageHandlerTest.php
@@ -10,6 +10,7 @@
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mime\Email;
+use Symfony\Component\Translation\Translator;
class PasswordResetMessageHandlerTest extends TestCase
{
@@ -20,7 +21,11 @@ class PasswordResetMessageHandlerTest extends TestCase
protected function setUp(): void
{
$this->emailService = $this->createMock(EmailService::class);
- $this->handler = new PasswordResetMessageHandler($this->emailService, $this->passwordResetUrl);
+ $this->handler = new PasswordResetMessageHandler(
+ $this->emailService,
+ new Translator('en'),
+ $this->passwordResetUrl
+ );
}
public function testInvoke(): void
diff --git a/tests/Unit/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandlerTest.php
index 4bd89243..550a6160 100644
--- a/tests/Unit/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandlerTest.php
+++ b/tests/Unit/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandlerTest.php
@@ -10,6 +10,7 @@
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mime\Email;
+use Symfony\Component\Translation\Translator;
class SubscriberConfirmationMessageHandlerTest extends TestCase
{
@@ -20,7 +21,11 @@ class SubscriberConfirmationMessageHandlerTest extends TestCase
protected function setUp(): void
{
$this->emailService = $this->createMock(EmailService::class);
- $this->handler = new SubscriberConfirmationMessageHandler($this->emailService, $this->confirmationUrl);
+ $this->handler = new SubscriberConfirmationMessageHandler(
+ emailService: $this->emailService,
+ translator: new Translator('en'),
+ confirmationUrl: $this->confirmationUrl
+ );
}
public function testInvokeWithTextEmail(): void
diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php
index d99d041a..d08ee9a1 100644
--- a/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php
@@ -4,8 +4,8 @@
namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder;
-use InvalidArgumentException;
use PhpList\Core\Domain\Identity\Model\Administrator;
+use PhpList\Core\Domain\Messaging\Exception\InvalidContextTypeException;
use PhpList\Core\Domain\Messaging\Model\Dto\CreateMessageDto;
use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageContentDto;
use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageFormatDto;
@@ -14,6 +14,8 @@
use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageScheduleDto;
use PhpList\Core\Domain\Messaging\Model\Dto\MessageContext;
use PhpList\Core\Domain\Messaging\Model\Message;
+use PhpList\Core\Domain\Messaging\Model\Message\MessageContent;
+use PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule;
use PhpList\Core\Domain\Messaging\Repository\TemplateRepository;
use PhpList\Core\Domain\Messaging\Service\Builder\MessageBuilder;
use PhpList\Core\Domain\Messaging\Service\Builder\MessageContentBuilder;
@@ -40,11 +42,11 @@ protected function setUp(): void
$this->optionsBuilder = $this->createMock(MessageOptionsBuilder::class);
$this->builder = new MessageBuilder(
- $templateRepository,
- $this->formatBuilder,
- $this->scheduleBuilder,
- $this->contentBuilder,
- $this->optionsBuilder
+ templateRepository: $templateRepository,
+ messageFormatBuilder: $this->formatBuilder,
+ messageScheduleBuilder: $this->scheduleBuilder,
+ messageContentBuilder: $this->contentBuilder,
+ messageOptionsBuilder: $this->optionsBuilder
);
}
@@ -92,12 +94,12 @@ private function mockBuildCalls(CreateMessageDto $createMessageDto): void
$this->scheduleBuilder->expects($this->once())
->method('build')
->with($createMessageDto->schedule)
- ->willReturn($this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule::class));
+ ->willReturn($this->createMock(MessageSchedule::class));
$this->contentBuilder->expects($this->once())
->method('build')
->with($createMessageDto->content)
- ->willReturn($this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class));
+ ->willReturn($this->createMock(MessageContent::class));
$this->optionsBuilder->expects($this->once())
->method('build')
@@ -113,12 +115,12 @@ public function testBuildsNewMessage(): void
$this->mockBuildCalls($request);
- $this->builder->build($request, $context);
+ $this->builder->build(createMessageDto: $request, context: $context);
}
public function testThrowsExceptionOnInvalidContext(): void
{
- $this->expectException(InvalidArgumentException::class);
+ $this->expectException(InvalidContextTypeException::class);
$this->builder->build($this->createMock(CreateMessageDto::class), new \stdClass());
}
@@ -139,11 +141,11 @@ public function testUpdatesExistingMessage(): void
$existingMessage
->expects($this->once())
->method('setSchedule')
- ->with($this->isInstanceOf(\PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule::class));
+ ->with($this->isInstanceOf(MessageSchedule::class));
$existingMessage
->expects($this->once())
->method('setContent')
- ->with($this->isInstanceOf(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class));
+ ->with($this->isInstanceOf(MessageContent::class));
$existingMessage
->expects($this->once())
->method('setOptions')
diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php
index 21f90692..62475884 100644
--- a/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php
@@ -4,7 +4,7 @@
namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder;
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException;
use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageContentDto;
use PhpList\Core\Domain\Messaging\Service\Builder\MessageContentBuilder;
use PHPUnit\Framework\TestCase;
@@ -37,7 +37,7 @@ public function testBuildsMessageContentSuccessfully(): void
public function testThrowsExceptionOnInvalidDto(): void
{
- $this->expectException(InvalidArgumentException::class);
+ $this->expectException(InvalidDtoTypeException::class);
$invalidDto = new \stdClass();
$this->builder->build($invalidDto);
diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php
index 1bd576f5..17d93eae 100644
--- a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php
@@ -4,7 +4,7 @@
namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder;
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException;
use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageFormatDto;
use PhpList\Core\Domain\Messaging\Service\Builder\MessageFormatBuilder;
use PHPUnit\Framework\TestCase;
@@ -30,7 +30,7 @@ public function testBuildsMessageFormatSuccessfully(): void
public function testThrowsExceptionOnInvalidDto(): void
{
- $this->expectException(InvalidArgumentException::class);
+ $this->expectException(InvalidDtoTypeException::class);
$invalidDto = new \stdClass();
$this->builder->build($invalidDto);
diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php
index 754177a2..e2de8398 100644
--- a/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php
@@ -4,7 +4,7 @@
namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder;
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException;
use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageOptionsDto;
use PhpList\Core\Domain\Messaging\Service\Builder\MessageOptionsBuilder;
use PHPUnit\Framework\TestCase;
@@ -37,7 +37,7 @@ public function testBuildsMessageOptionsSuccessfully(): void
public function testThrowsExceptionOnInvalidDto(): void
{
- $this->expectException(InvalidArgumentException::class);
+ $this->expectException(InvalidDtoTypeException::class);
$invalidDto = new \stdClass();
$this->builder->build($invalidDto);
diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php
index 25a89052..8e9e5fb8 100644
--- a/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php
@@ -5,7 +5,7 @@
namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder;
use DateTime;
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException;
use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageScheduleDto;
use PhpList\Core\Domain\Messaging\Service\Builder\MessageScheduleBuilder;
use PHPUnit\Framework\TestCase;
@@ -40,7 +40,7 @@ public function testBuildsMessageScheduleSuccessfully(): void
public function testThrowsExceptionOnInvalidDto(): void
{
- $this->expectException(InvalidArgumentException::class);
+ $this->expectException(InvalidDtoTypeException::class);
$invalidDto = new \stdClass();
$this->builder->build($invalidDto);
diff --git a/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php
index 1cb1b6d2..5fc375cd 100644
--- a/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php
@@ -14,6 +14,7 @@
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\Translation\Translator;
class ConsecutiveBounceHandlerTest extends TestCase
{
@@ -43,6 +44,7 @@ protected function setUp(): void
subscriberRepository: $this->subscriberRepository,
subscriberHistoryManager: $this->subscriberHistoryManager,
blacklistService: $this->blacklistService,
+ translator: new Translator('en'),
unsubscribeThreshold: $unsubscribeThreshold,
blacklistThreshold: $blacklistThreshold,
);
@@ -89,14 +91,14 @@ public function testUnsubscribeAtThresholdAddsHistoryAndMarksUnconfirmedOnce():
->method('addHistory')
->with(
$user,
- 'Auto Unconfirmed',
+ 'Auto unconfirmed',
$this->stringContains('2 consecutive bounces')
);
$this->blacklistService->expects($this->never())->method('blacklist');
$this->io->expects($this->once())->method('section')->with('Identifying consecutive bounces');
- $this->io->expects($this->once())->method('writeln')->with('total of 1 subscribers processed');
+ $this->io->expects($this->once())->method('writeln')->with('Total of 1 subscribers processed');
$this->handler->handle($this->io);
}
@@ -132,7 +134,7 @@ public function testBlacklistAtThresholdStopsProcessingAndAlsoUnsubscribesIfReac
->method('addHistory')
->with(
$user,
- 'Auto Unconfirmed',
+ 'Auto unconfirmed',
$this->stringContains('consecutive bounces')
);
@@ -164,7 +166,7 @@ public function testDuplicateBouncesAreIgnoredInCounting(): void
$this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(55);
$this->subscriberHistoryManager->expects($this->once())->method('addHistory')->with(
$user,
- 'Auto Unconfirmed',
+ 'Auto unconfirmed',
$this->stringContains('2 consecutive bounces')
);
$this->blacklistService->expects($this->never())->method('blacklist');
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php
index 8f5cdb11..cc0ff38d 100644
--- a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php
@@ -12,6 +12,7 @@
use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
class BlacklistEmailAndDeleteBounceHandlerTest extends TestCase
{
@@ -29,6 +30,7 @@ protected function setUp(): void
subscriberHistoryManager: $this->historyManager,
bounceManager: $this->bounceManager,
blacklistService: $this->blacklistService,
+ translator: new Translator('en')
);
}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php
index 54f7362b..cb009022 100644
--- a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php
@@ -10,6 +10,7 @@
use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
class BlacklistEmailHandlerTest extends TestCase
{
@@ -24,6 +25,7 @@ protected function setUp(): void
$this->handler = new BlacklistEmailHandler(
subscriberHistoryManager: $this->historyManager,
blacklistService: $this->blacklistService,
+ translator: new Translator('en'),
);
}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php
index af1df32e..0368d695 100644
--- a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php
@@ -12,6 +12,7 @@
use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
class BlacklistUserAndDeleteBounceHandlerTest extends TestCase
{
@@ -29,6 +30,7 @@ protected function setUp(): void
subscriberHistoryManager: $this->historyManager,
bounceManager: $this->bounceManager,
blacklistService: $this->blacklistService,
+ translator: new Translator('en')
);
}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php
index 72fe4584..e25f54c8 100644
--- a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php
@@ -10,6 +10,7 @@
use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
class BlacklistUserHandlerTest extends TestCase
{
@@ -23,7 +24,8 @@ protected function setUp(): void
$this->blacklistService = $this->createMock(SubscriberBlacklistService::class);
$this->handler = new BlacklistUserHandler(
subscriberHistoryManager: $this->historyManager,
- blacklistService: $this->blacklistService
+ blacklistService: $this->blacklistService,
+ translator: new Translator('en')
);
}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php
index 7d82336f..34d707e5 100644
--- a/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php
@@ -13,6 +13,7 @@
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
class DecreaseCountConfirmUserAndDeleteBounceHandlerTest extends TestCase
{
@@ -33,6 +34,7 @@ protected function setUp(): void
subscriberManager: $this->subscriberManager,
bounceManager: $this->bounceManager,
subscriberRepository: $this->subscriberRepository,
+ translator: new Translator('en'),
);
}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php
index 5bfb1114..079d06a8 100644
--- a/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php
@@ -19,6 +19,7 @@
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Translation\Translator;
class RequeueHandlerTest extends TestCase
{
@@ -55,7 +56,7 @@ private function createMessage(
public function testReturnsFalseWhenIntervalIsZeroOrNegative(): void
{
- $handler = new RequeueHandler($this->logger, $this->em);
+ $handler = new RequeueHandler($this->logger, $this->em, new Translator('en'));
$message = $this->createMessage(0, null, null);
$this->em->expects($this->never())->method('flush');
@@ -70,7 +71,7 @@ public function testReturnsFalseWhenIntervalIsZeroOrNegative(): void
public function testReturnsFalseWhenNowIsAfterRequeueUntil(): void
{
- $handler = new RequeueHandler($this->logger, $this->em);
+ $handler = new RequeueHandler($this->logger, $this->em, new Translator('en'));
$past = (new DateTime())->sub(new DateInterval('PT5M'));
$message = $this->createMessage(5, $past, null);
@@ -85,7 +86,7 @@ public function testReturnsFalseWhenNowIsAfterRequeueUntil(): void
public function testRequeuesFromFutureEmbargoAndSetsSubmittedStatus(): void
{
- $handler = new RequeueHandler($this->logger, $this->em);
+ $handler = new RequeueHandler($this->logger, $this->em, new Translator('en'));
$embargo = (new DateTime())->add(new DateInterval('PT5M'));
$interval = 10;
$message = $this->createMessage($interval, null, $embargo);
@@ -107,7 +108,7 @@ public function testRequeuesFromFutureEmbargoAndSetsSubmittedStatus(): void
public function testRequeuesFromNowWhenEmbargoIsNullOrPast(): void
{
- $handler = new RequeueHandler($this->logger, $this->em);
+ $handler = new RequeueHandler($this->logger, $this->em, new Translator('en'));
$interval = 3;
$message = $this->createMessage($interval, null, null);
@@ -133,7 +134,7 @@ public function testRequeuesFromNowWhenEmbargoIsNullOrPast(): void
public function testReturnsFalseWhenNextEmbargoExceedsUntil(): void
{
- $handler = new RequeueHandler($this->logger, $this->em);
+ $handler = new RequeueHandler($this->logger, $this->em, new Translator('en'));
$embargo = (new DateTime())->add(new DateInterval('PT1M'));
$interval = 10;
// next would be +10, which exceeds until
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php
index 7a4ac245..6ddc4e3d 100644
--- a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php
@@ -12,6 +12,7 @@
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
class UnconfirmUserAndDeleteBounceHandlerTest extends TestCase
{
@@ -29,6 +30,7 @@ protected function setUp(): void
subscriberHistoryManager: $this->historyManager,
subscriberRepository: $this->subscriberRepository,
bounceManager: $this->bounceManager,
+ translator: new Translator('en')
);
}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php
index a395e110..fbbc265a 100644
--- a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php
@@ -10,6 +10,7 @@
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
class UnconfirmUserHandlerTest extends TestCase
{
@@ -23,7 +24,8 @@ protected function setUp(): void
$this->historyManager = $this->createMock(SubscriberHistoryManager::class);
$this->handler = new UnconfirmUserHandler(
subscriberRepository: $this->subscriberRepository,
- subscriberHistoryManager: $this->historyManager
+ subscriberHistoryManager: $this->historyManager,
+ translator: new Translator('en')
);
}
@@ -41,7 +43,7 @@ public function testHandleMarksUnconfirmedAndAddsHistoryWhenSubscriberPresentAnd
$this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(123);
$this->historyManager->expects($this->once())->method('addHistory')->with(
$subscriber,
- 'Auto Unconfirmed',
+ 'Auto unconfirmed',
$this->stringContains('bounce rule 9')
);
diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php
index bd1a4a68..0dbde7ad 100644
--- a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php
@@ -15,6 +15,7 @@
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
+use Symfony\Component\Translation\Translator;
class BounceManagerTest extends TestCase
{
@@ -35,6 +36,7 @@ protected function setUp(): void
userMessageBounceRepo: $this->userMessageBounceRepository,
entityManager: $this->entityManager,
logger: $this->logger,
+ translator: new Translator('en')
);
}
diff --git a/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php b/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php
index 5944ca3e..57b2f07f 100644
--- a/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php
+++ b/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php
@@ -9,6 +9,7 @@
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Translation\Translator;
class MaxProcessTimeLimiterTest extends TestCase
{
@@ -21,7 +22,7 @@ protected function setUp(): void
public function testShouldNotStopWhenMaxSecondsIsZero(): void
{
- $limiter = new MaxProcessTimeLimiter(logger: $this->logger, maxSeconds: 0);
+ $limiter = new MaxProcessTimeLimiter(logger: $this->logger, translator: new Translator('en'), maxSeconds: 0);
$output = $this->createMock(OutputInterface::class);
$output->expects($this->never())->method('writeln');
@@ -34,7 +35,7 @@ public function testShouldNotStopWhenMaxSecondsIsZero(): void
public function testShouldStopAfterThresholdAndLogAndOutput(): void
{
- $limiter = new MaxProcessTimeLimiter(logger: $this->logger, maxSeconds: 1);
+ $limiter = new MaxProcessTimeLimiter(logger: $this->logger, translator: new Translator('en'), maxSeconds: 1);
$output = $this->createMock(OutputInterface::class);
$output->expects($this->once())
diff --git a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php
index c2c0d0a5..85066691 100644
--- a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php
+++ b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php
@@ -16,6 +16,7 @@
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Translation\Translator;
class MessageProcessingPreparatorTest extends TestCase
{
@@ -35,10 +36,11 @@ protected function setUp(): void
$this->output = $this->createMock(OutputInterface::class);
$this->preparator = new MessageProcessingPreparator(
- $this->entityManager,
- $this->subscriberRepository,
- $this->messageRepository,
- $this->linkTrackService
+ entityManager: $this->entityManager,
+ subscriberRepository: $this->subscriberRepository,
+ messageRepository: $this->messageRepository,
+ linkTrackService: $this->linkTrackService,
+ translator: new Translator('en'),
);
}
@@ -189,7 +191,10 @@ public function testProcessMessageLinksWithLinksExtracted(): void
$savedLinks = [$linkTrack1, $linkTrack2];
$this->linkTrackService->method('isExtractAndSaveLinksApplicable')->willReturn(true);
- $this->linkTrackService->method('extractAndSaveLinks')->with($message, $userId)->willReturn($savedLinks);
+ $this->linkTrackService
+ ->method('extractAndSaveLinks')
+ ->with($message, $userId)
+ ->willReturn($savedLinks);
$message->method('getContent')->willReturn($content);
diff --git a/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php
index 209fb583..a4590052 100644
--- a/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php
@@ -16,6 +16,7 @@
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\Translation\Translator;
class AdvancedBounceRulesProcessorTest extends TestCase
{
@@ -36,15 +37,23 @@ protected function setUp(): void
public function testNoActiveRules(): void
{
- $this->io->expects($this->once())->method('section')->with('Processing bounces based on active bounce rules');
+ $translator = new Translator('en');
+ $this->io
+ ->expects($this->once())
+ ->method('section')
+ ->with($translator->trans('Processing bounces based on active bounce rules'));
$this->ruleManager->method('loadActiveRules')->willReturn([]);
- $this->io->expects($this->once())->method('writeln')->with('No active rules');
+ $this->io
+ ->expects($this->once())
+ ->method('writeln')
+ ->with($translator->trans('No active rules'));
$processor = new AdvancedBounceRulesProcessor(
bounceManager: $this->bounceManager,
ruleManager: $this->ruleManager,
actionResolver: $this->actionResolver,
subscriberManager: $this->subscriberManager,
+ translator: $translator,
);
$processor->process($this->io, 100);
@@ -159,10 +168,11 @@ public function testProcessingWithMatchesAndNonMatches(): void
return null;
});
+ $translator = new Translator('en');
$this->io
->expects($this->once())
->method('section')
- ->with('Processing bounces based on active bounce rules');
+ ->with($translator->trans('Processing bounces based on active bounce rules'));
$this->io->expects($this->exactly(4))->method('writeln');
$processor = new AdvancedBounceRulesProcessor(
@@ -170,6 +180,7 @@ public function testProcessingWithMatchesAndNonMatches(): void
ruleManager: $this->ruleManager,
actionResolver: $this->actionResolver,
subscriberManager: $this->subscriberManager,
+ translator: $translator,
);
$processor->process($this->io, 2);
diff --git a/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php
index 26aec09f..e1976202 100644
--- a/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php
@@ -22,6 +22,7 @@
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Mime\Email;
+use Symfony\Component\Translation\Translator;
class CampaignProcessorTest extends TestCase
{
@@ -32,7 +33,6 @@ class CampaignProcessorTest extends TestCase
private LoggerInterface|MockObject $logger;
private OutputInterface|MockObject $output;
private CampaignProcessor $campaignProcessor;
- private UserMessageRepository|MockObject $userMessageRepository;
protected function setUp(): void
{
@@ -42,7 +42,7 @@ protected function setUp(): void
$this->messagePreparator = $this->createMock(MessageProcessingPreparator::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->output = $this->createMock(OutputInterface::class);
- $this->userMessageRepository = $this->createMock(UserMessageRepository::class);
+ $userMessageRepository = $this->createMock(UserMessageRepository::class);
$this->campaignProcessor = new CampaignProcessor(
mailer: $this->mailer,
@@ -50,9 +50,10 @@ protected function setUp(): void
subscriberProvider: $this->subscriberProvider,
messagePreparator: $this->messagePreparator,
logger: $this->logger,
- userMessageRepository: $this->userMessageRepository,
+ userMessageRepository: $userMessageRepository,
timeLimiter: $this->createMock(MaxProcessTimeLimiter::class),
requeueHandler: $this->createMock(RequeueHandler::class),
+ translator: new Translator('en'),
);
}
diff --git a/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php
index 210e000c..9bf1c92f 100644
--- a/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php
@@ -11,6 +11,7 @@
use RuntimeException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\Translation\Translator;
class MboxBounceProcessorTest extends TestCase
{
@@ -27,13 +28,14 @@ protected function setUp(): void
public function testGetProtocol(): void
{
- $processor = new MboxBounceProcessor($this->service);
+ $processor = new MboxBounceProcessor($this->service, new Translator('en'));
$this->assertSame('mbox', $processor->getProtocol());
}
public function testProcessThrowsWhenMailboxMissing(): void
{
- $processor = new MboxBounceProcessor($this->service);
+ $translator = new Translator('en');
+ $processor = new MboxBounceProcessor($this->service, $translator);
$this->input->method('getOption')->willReturnMap([
['test', false],
@@ -44,7 +46,7 @@ public function testProcessThrowsWhenMailboxMissing(): void
$this->io
->expects($this->once())
->method('error')
- ->with('mbox file path must be provided with --mailbox.');
+ ->with($translator->trans('mbox file path must be provided with --mailbox.'));
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Missing --mailbox for mbox protocol');
@@ -54,7 +56,8 @@ public function testProcessThrowsWhenMailboxMissing(): void
public function testProcessSuccess(): void
{
- $processor = new MboxBounceProcessor($this->service);
+ $translator = new Translator('en');
+ $processor = new MboxBounceProcessor($this->service, $translator);
$this->input->method('getOption')->willReturnMap([
['test', true],
@@ -62,8 +65,14 @@ public function testProcessSuccess(): void
['mailbox', '/var/mail/bounce.mbox'],
]);
- $this->io->expects($this->once())->method('section')->with('Opening mbox /var/mail/bounce.mbox');
- $this->io->expects($this->once())->method('writeln')->with('Please do not interrupt this process');
+ $this->io
+ ->expects($this->once())
+ ->method('section')
+ ->with($translator->trans('Opening mbox %file%', ['%file%' => '/var/mail/bounce.mbox']));
+ $this->io
+ ->expects($this->once())
+ ->method('writeln')
+ ->with($translator->trans('Please do not interrupt this process'));
$this->service->expects($this->once())
->method('processMailbox')
diff --git a/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php
index fad4cfbe..d0141386 100644
--- a/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php
@@ -10,6 +10,7 @@
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\Translation\Translator;
class PopBounceProcessorTest extends TestCase
{
@@ -26,13 +27,14 @@ protected function setUp(): void
public function testGetProtocol(): void
{
- $processor = new PopBounceProcessor($this->service, 'mail.example.com', 995, 'INBOX');
+ $processor = new PopBounceProcessor($this->service, 'mail.example.com', 995, 'INBOX', new Translator('en'));
$this->assertSame('pop', $processor->getProtocol());
}
public function testProcessWithMultipleMailboxesAndDefaults(): void
{
- $processor = new PopBounceProcessor($this->service, 'pop.example.com', 110, 'INBOX, ,Custom');
+ $translator = new Translator('en');
+ $processor = new PopBounceProcessor($this->service, 'pop.example.com', 110, 'INBOX, ,Custom', $translator);
$this->input->method('getOption')->willReturnMap([
['test', true],
diff --git a/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php
index a671e74c..ac1c9173 100644
--- a/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php
@@ -13,6 +13,7 @@
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\Translation\Translator;
class UnidentifiedBounceReprocessorTest extends TestCase
{
@@ -62,7 +63,8 @@ public function testProcess(): void
$processor = new UnidentifiedBounceReprocessor(
bounceManager: $this->bounceManager,
messageParser: $this->messageParser,
- bounceDataProcessor: $this->dataProcessor
+ bounceDataProcessor: $this->dataProcessor,
+ translator: new Translator('en'),
);
$processor->process($this->io);
}
diff --git a/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php b/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php
index e9ba27c0..e29f6929 100644
--- a/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php
+++ b/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php
@@ -11,6 +11,7 @@
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Translation\Translator;
class SendRateLimiterTest extends TestCase
{
@@ -27,6 +28,7 @@ public function testInitializesLimitsFromConfigOnly(): void
$limiter = new SendRateLimiter(
ispRestrictionsProvider: $this->ispProvider,
userMessageRepository: $this->createMock(UserMessageRepository::class),
+ translator: new Translator('en'),
mailqueueBatchSize: 5,
mailqueueBatchPeriod: 10,
mailqueueThrottle: 2
@@ -44,6 +46,7 @@ public function testBatchLimitTriggersWaitMessageAndResetsCounters(): void
$limiter = new SendRateLimiter(
ispRestrictionsProvider: $this->ispProvider,
userMessageRepository: $this->createMock(UserMessageRepository::class),
+ translator: new Translator('en'),
mailqueueBatchSize: 10,
mailqueueBatchPeriod: 1,
mailqueueThrottle: 0
@@ -71,6 +74,7 @@ public function testThrottleSleepsPerMessagePathIsCallable(): void
$limiter = new SendRateLimiter(
ispRestrictionsProvider: $this->ispProvider,
userMessageRepository: $this->createMock(UserMessageRepository::class),
+ translator: new Translator('en'),
mailqueueBatchSize: 0,
mailqueueBatchPeriod: 0,
mailqueueThrottle: 1
diff --git a/tests/Unit/Domain/Messaging/Validator/TemplateImageValidatorTest.php b/tests/Unit/Domain/Messaging/Validator/TemplateImageValidatorTest.php
index 88af2c8c..40e1064a 100644
--- a/tests/Unit/Domain/Messaging/Validator/TemplateImageValidatorTest.php
+++ b/tests/Unit/Domain/Messaging/Validator/TemplateImageValidatorTest.php
@@ -12,6 +12,7 @@
use PhpList\Core\Domain\Messaging\Validator\TemplateImageValidator;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
use Symfony\Component\Validator\Exception\ValidatorException;
class TemplateImageValidatorTest extends TestCase
@@ -22,7 +23,7 @@ class TemplateImageValidatorTest extends TestCase
protected function setUp(): void
{
$this->httpClient = $this->createMock(ClientInterface::class);
- $this->validator = new TemplateImageValidator($this->httpClient);
+ $this->validator = new TemplateImageValidator($this->httpClient, new Translator('en'));
}
public function testThrowsExceptionIfValueIsNotArray(): void
diff --git a/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php b/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php
index d0ab6566..5767f193 100644
--- a/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php
+++ b/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php
@@ -7,6 +7,7 @@
use PhpList\Core\Domain\Common\Model\ValidationContext;
use PhpList\Core\Domain\Messaging\Validator\TemplateLinkValidator;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
use Symfony\Component\Validator\Exception\ValidatorException;
class TemplateLinkValidatorTest extends TestCase
@@ -15,7 +16,7 @@ class TemplateLinkValidatorTest extends TestCase
protected function setUp(): void
{
- $this->validator = new TemplateLinkValidator();
+ $this->validator = new TemplateLinkValidator(new Translator('en'));
}
public function testSkipsValidationIfNotString(): void
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php
index 279a6ff7..7e7bcfb7 100644
--- a/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php
@@ -11,6 +11,7 @@
use PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager;
use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
class AttributeDefinitionManagerTest extends TestCase
{
@@ -18,7 +19,11 @@ public function testCreateAttributeDefinition(): void
{
$repository = $this->createMock(SubscriberAttributeDefinitionRepository::class);
$validator = $this->createMock(AttributeTypeValidator::class);
- $manager = new AttributeDefinitionManager($repository, $validator);
+ $manager = new AttributeDefinitionManager(
+ definitionRepository: $repository,
+ attributeTypeValidator: $validator,
+ translator: new Translator('en')
+ );
$dto = new AttributeDefinitionDto(
name: 'Country',
@@ -51,7 +56,11 @@ public function testCreateThrowsWhenAttributeAlreadyExists(): void
{
$repository = $this->createMock(SubscriberAttributeDefinitionRepository::class);
$validator = $this->createMock(AttributeTypeValidator::class);
- $manager = new AttributeDefinitionManager($repository, $validator);
+ $manager = new AttributeDefinitionManager(
+ definitionRepository: $repository,
+ attributeTypeValidator: $validator,
+ translator: new Translator('en'),
+ );
$dto = new AttributeDefinitionDto(
name: 'Country',
@@ -78,7 +87,11 @@ public function testUpdateAttributeDefinition(): void
{
$repository = $this->createMock(SubscriberAttributeDefinitionRepository::class);
$validator = $this->createMock(AttributeTypeValidator::class);
- $manager = new AttributeDefinitionManager($repository, $validator);
+ $manager = new AttributeDefinitionManager(
+ definitionRepository: $repository,
+ attributeTypeValidator: $validator,
+ translator: new Translator('en'),
+ );
$attribute = new SubscriberAttributeDefinition();
$attribute->setName('Old');
@@ -113,7 +126,11 @@ public function testUpdateThrowsWhenAnotherAttributeWithSameNameExists(): void
{
$repository = $this->createMock(SubscriberAttributeDefinitionRepository::class);
$validator = $this->createMock(AttributeTypeValidator::class);
- $manager = new AttributeDefinitionManager($repository, $validator);
+ $manager = new AttributeDefinitionManager(
+ definitionRepository: $repository,
+ attributeTypeValidator: $validator,
+ translator: new Translator('en'),
+ );
$dto = new AttributeDefinitionDto(
name: 'Existing',
@@ -144,7 +161,11 @@ public function testDeleteAttributeDefinition(): void
{
$repository = $this->createMock(SubscriberAttributeDefinitionRepository::class);
$validator = $this->createMock(AttributeTypeValidator::class);
- $manager = new AttributeDefinitionManager($repository, $validator);
+ $manager = new AttributeDefinitionManager(
+ definitionRepository: $repository,
+ attributeTypeValidator: $validator,
+ translator: new Translator('en'),
+ );
$attribute = new SubscriberAttributeDefinition();
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php
index 422c78a7..6add5016 100644
--- a/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php
@@ -14,6 +14,7 @@
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Component\Translation\Translator;
class SubscribePageManagerTest extends TestCase
{
@@ -32,6 +33,7 @@ protected function setUp(): void
pageRepository: $this->pageRepository,
pageDataRepository: $this->pageDataRepository,
entityManager: $this->entityManager,
+ translator: new Translator('en'),
);
}
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php
index 355de90f..a827ab3f 100644
--- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php
@@ -12,6 +12,7 @@
use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
class SubscriberAttributeManagerTest extends TestCase
{
@@ -34,7 +35,7 @@ public function testCreateNewSubscriberAttribute(): void
return $attr->getValue() === 'US';
}));
- $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager);
+ $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager, new Translator('en'));
$attribute = $manager->createOrUpdate($subscriber, $definition, 'US');
self::assertInstanceOf(SubscriberAttributeValue::class, $attribute);
@@ -60,7 +61,7 @@ public function testUpdateExistingSubscriberAttribute(): void
->method('persist')
->with($existing);
- $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager);
+ $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager, new Translator('en'));
$result = $manager->createOrUpdate($subscriber, $definition, 'Updated');
self::assertSame('Updated', $result->getValue());
@@ -76,7 +77,7 @@ public function testCreateFailsWhenValueAndDefaultAreNull(): void
$subscriberAttrRepo->method('findOneBySubscriberAndAttribute')->willReturn(null);
- $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager);
+ $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager, new Translator('en'));
$this->expectException(SubscriberAttributeCreationException::class);
$this->expectExceptionMessage('Value is required');
@@ -95,7 +96,7 @@ public function testGetSubscriberAttribute(): void
->with(5, 10)
->willReturn($expected);
- $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager);
+ $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager, new Translator('en'));
$result = $manager->getSubscriberAttribute(5, 10);
self::assertSame($expected, $result);
@@ -111,7 +112,7 @@ public function testDeleteSubscriberAttribute(): void
->method('remove')
->with($attribute);
- $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager);
+ $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager, new Translator('en'));
$manager->delete($attribute);
self::assertTrue(true);
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php
index b7a99366..f96f32e2 100644
--- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php
@@ -15,6 +15,7 @@
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Translation\Translator;
class SubscriberManagerTest extends TestCase
{
@@ -35,6 +36,7 @@ protected function setUp(): void
entityManager: $this->entityManager,
messageBus: $this->messageBus,
subscriberDeletionService: $subscriberDeletionService,
+ translator: new Translator('en'),
);
}
diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php
index 0bacd756..f825f704 100644
--- a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php
+++ b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php
@@ -19,36 +19,36 @@
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
+use Symfony\Component\Translation\Translator;
class SubscriberCsvImporterTest extends TestCase
{
private SubscriberManager&MockObject $subscriberManagerMock;
private SubscriberAttributeManager&MockObject $attributeManagerMock;
- private SubscriptionManager&MockObject $subscriptionManagerMock;
private SubscriberRepository&MockObject $subscriberRepositoryMock;
private CsvImporter&MockObject $csvImporterMock;
private SubscriberAttributeDefinitionRepository&MockObject $attributeDefinitionRepositoryMock;
- private EntityManagerInterface $entityManager;
private SubscriberCsvImporter $subject;
protected function setUp(): void
{
$this->subscriberManagerMock = $this->createMock(SubscriberManager::class);
$this->attributeManagerMock = $this->createMock(SubscriberAttributeManager::class);
- $this->subscriptionManagerMock = $this->createMock(SubscriptionManager::class);
+ $subscriptionManagerMock = $this->createMock(SubscriptionManager::class);
$this->subscriberRepositoryMock = $this->createMock(SubscriberRepository::class);
$this->csvImporterMock = $this->createMock(CsvImporter::class);
$this->attributeDefinitionRepositoryMock = $this->createMock(SubscriberAttributeDefinitionRepository::class);
- $this->entityManager = $this->createMock(EntityManagerInterface::class);
+ $entityManager = $this->createMock(EntityManagerInterface::class);
$this->subject = new SubscriberCsvImporter(
subscriberManager: $this->subscriberManagerMock,
attributeManager: $this->attributeManagerMock,
- subscriptionManager: $this->subscriptionManagerMock,
+ subscriptionManager: $subscriptionManagerMock,
subscriberRepository: $this->subscriberRepositoryMock,
csvImporter: $this->csvImporterMock,
attrDefinitionRepository: $this->attributeDefinitionRepositoryMock,
- entityManager: $this->entityManager,
+ entityManager: $entityManager,
+ translator: new Translator('en'),
);
}
diff --git a/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php b/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php
index c0ab3a5a..cf691324 100644
--- a/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php
+++ b/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php
@@ -7,6 +7,7 @@
use InvalidArgumentException;
use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
use Symfony\Component\Validator\Exception\ValidatorException;
class AttributeTypeValidatorTest extends TestCase
@@ -15,7 +16,7 @@ class AttributeTypeValidatorTest extends TestCase
protected function setUp(): void
{
- $this->validator = new AttributeTypeValidator();
+ $this->validator = new AttributeTypeValidator(new Translator('en'));
}
public function testValidatesValidType(): void
From 6e65b28d63f6b908ddb80e8c90735f229084aa1f Mon Sep 17 00:00:00 2001
From: Tatevik
Date: Tue, 23 Sep 2025 11:50:14 +0400
Subject: [PATCH 09/10] Fix autowiring
---
config/services/services.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/config/services/services.yml b/config/services/services.yml
index 1afd1fc5..89bf99b9 100644
--- a/config/services/services.yml
+++ b/config/services/services.yml
@@ -114,6 +114,8 @@ services:
- { name: 'phplist.bounce_action_handler' }
PhpList\Core\Domain\Messaging\Service\Handler\:
+ autowire: true
+ autoconfigure: true
resource: '../../src/Domain/Messaging/Service/Handler/*Handler.php'
PhpList\Core\Domain\Messaging\Service\BounceActionResolver:
From da72f1fb0f221c3bb53d61744aaa1fcc03054042 Mon Sep 17 00:00:00 2001
From: Tatevik
Date: Wed, 24 Sep 2025 12:11:54 +0400
Subject: [PATCH 10/10] Reset subscriber bounce count
---
.../Messaging/Service/Handler/BlacklistEmailHandler.php | 6 +++---
.../Subscription/Service/Manager/SubscriberManager.php | 8 ++++++++
2 files changed, 11 insertions(+), 3 deletions(-)
diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php
index eac3b7a9..4f95c18b 100644
--- a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php
+++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php
@@ -39,9 +39,9 @@ public function handle(array $closureData): void
]),
);
$this->subscriberHistoryManager->addHistory(
- $closureData['subscriber'],
- $this->translator->trans('Auto Unsubscribed'),
- $this->translator->trans('email auto unsubscribed for bounce rule %rule_id%', [
+ subscriber: $closureData['subscriber'],
+ message: $this->translator->trans('Auto Unsubscribed'),
+ details: $this->translator->trans('email auto unsubscribed for bounce rule %rule_id%', [
'%rule_id%' => $closureData['ruleId']
])
);
diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php
index 25d7045a..59cd2505 100644
--- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php
@@ -91,6 +91,14 @@ public function updateSubscriber(UpdateSubscriberDto $subscriberDto): Subscriber
return $subscriber;
}
+ public function resetBounceCount(Subscriber $subscriber): Subscriber
+ {
+ $subscriber->setBounceCount(0);
+ $this->entityManager->flush();
+
+ return $subscriber;
+ }
+
public function markAsConfirmedByUniqueId(string $uniqueId): Subscriber
{
$subscriber = $this->subscriberRepository->findOneByUniqueId($uniqueId);