From 3ebedfbfce05a0a3a5b48baedbeaf7e4cafe1c38 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 26 Oct 2026 13:32:10 +0400 Subject: [PATCH 01/46] Update TemplateImageManager to set correct MIME type for resized images and add tests for logo image creation --- .../Service/Manager/TemplateImageManager.php | 8 ++- .../Manager/TemplateImageManagerTest.php | 52 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/Domain/Messaging/Service/Manager/TemplateImageManager.php b/src/Domain/Messaging/Service/Manager/TemplateImageManager.php index bb6c0771..15cfbc6c 100644 --- a/src/Domain/Messaging/Service/Manager/TemplateImageManager.php +++ b/src/Domain/Messaging/Service/Manager/TemplateImageManager.php @@ -211,10 +211,14 @@ public function createCachedLogoImage(int $size): void $orgLogoImage ); + // resized output is always PNG, so take the mime type from the final content + $finalSize = getimagesizefromstring($imageContent); + $mimeType = $finalSize !== false ? $finalSize['mime'] : $imgSize['mime']; + // else copy original $templateImage = (new TemplateImage()) ->setFilename('ORGANISATIONLOGO' . $size . '.png') - ->setMimetype($imgSize['mime']) + ->setMimetype($mimeType) ->setData(base64_encode($imageContent)) ->setWidth($newWidth) ->setHeight($newHeight); @@ -266,7 +270,7 @@ private function resizeImageIfNecessary( //# creates a black image (why would you want that....) $resized = imagecreatetruecolor($newWidth, $newHeight); imagesavealpha($resized, true); - //# white. All the methods to make it transparent didn't work for me @@TODO really make transparent + //# white. All the methods to make it transparent didn't work for me $transparent = imagecolorallocatealpha($resized, 255, 255, 255, 127); imagefill($resized, 0, 0, $transparent); [$sizeW, $sizeH] = [$imgSize[0], $imgSize[1]]; diff --git a/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php index 296e0c1d..4d7fb047 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php @@ -95,6 +95,58 @@ public function testCreateImagesFromImagePathsStoresImageDataAndReplacesExisting $this->assertSame(self::ONE_PIXEL_PNG, $capturedImage->getData()); } + public function testCreateCachedLogoImageStoresResizedLogoAsPng(): void + { + $capturedImage = $this->createCachedLogoImageFromJpeg(width: 100, height: 50, size: 20); + + $this->assertSame('ORGANISATIONLOGO20.png', $capturedImage->getFilename()); + $this->assertSame('image/png', $capturedImage->getMimeType()); + $this->assertSame(20, $capturedImage->getWidth()); + $this->assertSame(10, $capturedImage->getHeight()); + } + + public function testCreateCachedLogoImageKeepsOriginalMimeTypeWhenNotResized(): void + { + $capturedImage = $this->createCachedLogoImageFromJpeg(width: 10, height: 10, size: 500); + + $this->assertSame('image/jpeg', $capturedImage->getMimeType()); + } + + private function createCachedLogoImageFromJpeg(int $width, int $height, int $size): TemplateImage + { + $canvas = imagecreatetruecolor($width, $height); + ob_start(); + imagejpeg($canvas); + $jpeg = (string) ob_get_clean(); + + $logoImage = $this->createMock(TemplateImage::class); + $logoImage->method('getData')->willReturn(base64_encode($jpeg)); + + $this->configProvider->method('getValue')->willReturn('1'); + $this->templateImageRepository->method('findByFilename')->willReturn(null); + $this->templateImageRepository->method('findById')->with(1)->willReturn($logoImage); + + $capturedImage = null; + $this->templateImageRepository->expects($this->once()) + ->method('persist') + ->with($this->callback(static function (TemplateImage $image) use (&$capturedImage): bool { + $capturedImage = $image; + return true; + })); + + // resizing closes the buffer that was active when it started, so provide one to keep PHPUnit's intact + $obLevel = ob_get_level(); + ob_start(); + $this->manager->createCachedLogoImage($size); + while (ob_get_level() > $obLevel) { + ob_end_clean(); + } + + $this->assertInstanceOf(TemplateImage::class, $capturedImage); + + return $capturedImage; + } + public function testGuessMimeType(): void { $reflection = new \ReflectionClass($this->manager); From 7327e7277e6c3309e5066861357bd6b85355509d Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 26 Oct 2026 13:40:01 +0400 Subject: [PATCH 02/46] Refactor UserMessageBounce and UserMessageView to update TODO comments for indexing --- src/Domain/Analytics/Model/UserMessageView.php | 2 +- src/Domain/Messaging/Model/UserMessageBounce.php | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Domain/Analytics/Model/UserMessageView.php b/src/Domain/Analytics/Model/UserMessageView.php index 66240fee..f5eeb46c 100644 --- a/src/Domain/Analytics/Model/UserMessageView.php +++ b/src/Domain/Analytics/Model/UserMessageView.php @@ -15,7 +15,7 @@ #[ORM\Index(name: 'phplist_user_message_view_msgidx', columns: ['messageid'])] #[ORM\Index(name: 'phplist_user_message_view_useridx', columns: ['userid'])] #[ORM\Index(name: 'phplist_user_message_view_usermsgidx', columns: ['userid', 'messageid'])] -// todo: #[ORM\Index(name: 'phplist_user_message_view_viewedidx', columns: ['viewed'])] +// @performance #[ORM\Index(name: 'phplist_user_message_view_viewedidx', columns: ['viewed'])] class UserMessageView implements DomainModel, Identity { #[ORM\Id] diff --git a/src/Domain/Messaging/Model/UserMessageBounce.php b/src/Domain/Messaging/Model/UserMessageBounce.php index 9a350f88..efb6ea61 100644 --- a/src/Domain/Messaging/Model/UserMessageBounce.php +++ b/src/Domain/Messaging/Model/UserMessageBounce.php @@ -19,7 +19,6 @@ #[ORM\Index(name: 'phplist_user_message_bounce_msgidx', columns: ['message'])] #[ORM\Index(name: 'phplist_user_message_bounce_umbindex', columns: ['user', 'message', 'bounce'])] #[ORM\Index(name: 'phplist_user_message_bounce_useridx', columns: ['user'])] -// todo: #[ORM\Index(name: 'phplist_user_message_bounce_timeidx', columns: ['time'])] class UserMessageBounce implements DomainModel, Identity, From 2777d0e54b21ba97f5e964314295dab969ccc5b1 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 26 Oct 2026 13:44:58 +0400 Subject: [PATCH 03/46] Improve writeFileToDirectory method to handle temp file management and add tests for edge cases --- src/Domain/Common/FileHelper.php | 30 ++++++++++++--------- tests/Unit/Domain/Common/FileHelperTest.php | 17 ++++++++++++ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/Domain/Common/FileHelper.php b/src/Domain/Common/FileHelper.php index b8995b35..e1f4da01 100644 --- a/src/Domain/Common/FileHelper.php +++ b/src/Domain/Common/FileHelper.php @@ -33,31 +33,37 @@ public function readFileContents(string $path): ?string } } - // todo: check - Handle temp-file rename and write failures. - // Appending an extension leaves the original temp file orphaned, and fopen/fwrite failures aren’t - // checked (so the method can return a path that wasn’t actually written) public function writeFileToDirectory(string $directory, string $originalFilename, string $contents): ?string { + // tempnam() silently falls back to the system temp dir when the directory is unusable + if (!is_dir($directory) || !is_writable($directory)) { + return null; + } + $pathInfo = pathinfo($originalFilename); $name = $pathInfo['filename'] === '' ? 'file' : $pathInfo['filename']; $ext = $pathInfo['extension'] ?? ''; - $newFile = tempnam($directory, $name); - if ($newFile === false) { + $tempFile = tempnam($directory, $name); + if ($tempFile === false) { return null; } + $fullPath = $directory . '/' . basename($tempFile); if ($ext !== '') { - $newFile .= '.' . $ext; - } - $relativeName = basename($newFile); + $fullPath .= '.' . $ext; + if (!rename($tempFile, $fullPath)) { + unlink($tempFile); - $fullPath = $directory . '/' . $relativeName; + return null; + } + } - $fileHandle = fopen($fullPath, 'w'); + if (file_put_contents($fullPath, $contents) !== strlen($contents)) { + unlink($fullPath); - fwrite($fileHandle, $contents); - fclose($fileHandle); + return null; + } return $fullPath; } diff --git a/tests/Unit/Domain/Common/FileHelperTest.php b/tests/Unit/Domain/Common/FileHelperTest.php index 9f652623..fae886d4 100644 --- a/tests/Unit/Domain/Common/FileHelperTest.php +++ b/tests/Unit/Domain/Common/FileHelperTest.php @@ -74,6 +74,23 @@ public function testWriteFileToDirectoryCreatesFileWithExtensionAndContents(): v $this->assertSame('payload', file_get_contents($writtenPath)); } + public function testWriteFileToDirectoryLeavesNoOrphanedTempFile(): void + { + $helper = new FileHelper(); + + $writtenPath = $helper->writeFileToDirectory($this->tmpDir, 'report.txt', 'payload'); + + $this->assertSame([$writtenPath], glob($this->tmpDir . '/*')); + } + + public function testWriteFileToDirectoryReturnsNullWhenDirectoryIsMissing(): void + { + $helper = new FileHelper(); + + $this->assertNull($helper->writeFileToDirectory($this->tmpDir . '/missing', 'report.txt', 'payload')); + $this->assertSame([], glob($this->tmpDir . '/*')); + } + public function testWriteFileToDirectoryUsesDefaultNameWhenMissing(): void { $helper = new FileHelper(); From 54e8f5f4e583e6f741b87a90d7b595c97a9f6e9f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 26 Oct 2026 13:53:50 +0400 Subject: [PATCH 04/46] Remove TODO comment for upload directory configuration in LocalUploadStorage --- src/Domain/Common/Storage/LocalUploadStorage.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Domain/Common/Storage/LocalUploadStorage.php b/src/Domain/Common/Storage/LocalUploadStorage.php index bc731088..b08bedb4 100644 --- a/src/Domain/Common/Storage/LocalUploadStorage.php +++ b/src/Domain/Common/Storage/LocalUploadStorage.php @@ -22,7 +22,6 @@ public function __construct( public function store(UploadedFile $file, string $filename): StoredFile { - // todo: get upload_dir from configs $targetDirectory = $this->projectDir . DIRECTORY_SEPARATOR . 'public' From c52c7cb2686f16ff2e0d5a9cee1339dc9abe61fc Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 26 Oct 2026 14:09:13 +0400 Subject: [PATCH 05/46] Add methods to retrieve bounce report data and refactor database queries in UserMessageBounceElasticsearchHybridReader --- .../Messaging/Repository/BounceRepository.php | 19 +++++ .../Repository/MessageRepository.php | 21 ++++++ ...MessageBounceElasticsearchHybridReader.php | 75 +++---------------- .../Repository/UserMessageRepository.php | 15 ++++ .../Repository/SubscriberRepository.php | 24 ++++++ ...ageBounceElasticsearchHybridReaderTest.php | 9 ++- 6 files changed, 97 insertions(+), 66 deletions(-) diff --git a/src/Domain/Messaging/Repository/BounceRepository.php b/src/Domain/Messaging/Repository/BounceRepository.php index e3e0ab4f..1ff480f8 100644 --- a/src/Domain/Messaging/Repository/BounceRepository.php +++ b/src/Domain/Messaging/Repository/BounceRepository.php @@ -97,4 +97,23 @@ public function getFilteredAfterId(FilterRequestInterface $filter): PaginatedRes lastId: $lastId, ); } + + /** + * @param int[] $ids + * @return array indexed by bounce id + */ + public function findByIdsIndexedById(array $ids): array + { + if ($ids === []) { + return []; + } + + $bouncesById = []; + /** @var Bounce $bounce */ + foreach ($this->findBy(['id' => $ids]) as $bounce) { + $bouncesById[$bounce->getId()] = $bounce; + } + + return $bouncesById; + } } diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php index c5698775..1d95eb41 100644 --- a/src/Domain/Messaging/Repository/MessageRepository.php +++ b/src/Domain/Messaging/Repository/MessageRepository.php @@ -274,6 +274,27 @@ public function getNonEmptyFields(int $id): array return $message; } + /** + * Campaign ids and subjects for bounce reports, without any bounce counts. + * + * @param int|null $ownerId Limit results to campaigns owned by this admin when provided. + * @return array + */ + public function getSubjectsForBounceReport(?int $ownerId = null): array + { + $queryBuilder = $this->createQueryBuilder('m') + ->select('m.id AS messageId', 'm.content.subject AS subject') + ->orderBy('m.id', 'ASC'); + + if ($ownerId !== null) { + $queryBuilder + ->andWhere('IDENTITY(m.owner) = :ownerId') + ->setParameter('ownerId', $ownerId); + } + + return $queryBuilder->getQuery()->getArrayResult(); + } + /** * Counts active campaigns between two dates. * "Active" here means messages that were sent (or in process) during this period. diff --git a/src/Domain/Messaging/Repository/UserMessageBounceElasticsearchHybridReader.php b/src/Domain/Messaging/Repository/UserMessageBounceElasticsearchHybridReader.php index 9bc94e1f..6bca9465 100644 --- a/src/Domain/Messaging/Repository/UserMessageBounceElasticsearchHybridReader.php +++ b/src/Domain/Messaging/Repository/UserMessageBounceElasticsearchHybridReader.php @@ -5,17 +5,15 @@ namespace PhpList\Core\Domain\Messaging\Repository; use DateTime; -use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Model\Interfaces\UserMessageBounceRecordInterface; -use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\ReadModel\UserMessageBounceReadModel; use PhpList\Core\Domain\Messaging\Model\UserMessage; use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; use PhpList\Core\Domain\Messaging\Repository\Interfaces\UserMessageBounceReportReaderInterface; use PhpList\Core\Domain\Search\Client\ElasticsearchClientInterface; use PhpList\Core\Domain\Subscription\Model\Subscriber; -use PhpList\Core\Domain\Subscription\Model\Subscription; +use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; /** * Reporting/joining queries that correlate Elasticsearch-backed bounce data with related entities @@ -33,11 +31,13 @@ */ class UserMessageBounceElasticsearchHybridReader implements UserMessageBounceReportReaderInterface { - // todo: move db queries into repositories and inject them here, rather than using the entity manager directly public function __construct( private readonly ElasticsearchClientInterface $client, private readonly string $indexPrefix, - private readonly EntityManagerInterface $entityManager, + private readonly SubscriberRepository $subscriberRepository, + private readonly MessageRepository $messageRepository, + private readonly UserMessageRepository $userMessageRepository, + private readonly BounceRepository $bounceRepository, ) { } @@ -52,21 +52,7 @@ public function __construct( */ public function getListBounceTotals(int $listId): array { - $rows = $this->entityManager->createQueryBuilder() - ->select( - 'subscriber.id AS subscriberId', - 'subscriber.email AS email', - 'subscriber.confirmed AS confirmed', - 'subscriber.blacklisted AS blacklisted' - ) - ->from(Subscriber::class, 'subscriber') - ->innerJoin(Subscription::class, 'subscription', 'ON', 'subscription.subscriber = subscriber') - ->where('IDENTITY(subscription.subscriberList) = :listId') - ->setParameter('listId', $listId) - ->groupBy('subscriber.id, subscriber.email, subscriber.confirmed, subscriber.blacklisted') - ->orderBy('subscriber.id', 'ASC') - ->getQuery() - ->getArrayResult(); + $rows = $this->subscriberRepository->getBounceReportRowsByListId($listId); if ($rows === []) { return []; @@ -99,19 +85,7 @@ public function getListBounceTotals(int $listId): array /** @return array */ public function getCampaignBounceTotals(?int $ownerId = null): array { - $queryBuilder = $this->entityManager->createQueryBuilder() - ->select('m.id AS messageId', 'm.content.subject AS subject') - ->from(Message::class, 'm') - ->orderBy('m.id', 'ASC'); - - if ($ownerId !== null) { - $queryBuilder - ->andWhere('IDENTITY(m.owner) = :ownerId') - ->setParameter('ownerId', $ownerId); - } - - /** @var array $rows */ - $rows = $queryBuilder->getQuery()->getArrayResult(); + $rows = $this->messageRepository->getSubjectsForBounceReport($ownerId); if ($rows === []) { return []; @@ -162,7 +136,7 @@ public function getPaginatedWithJoinNoRelation(int $fromId, int $limit): array $records ))); - $bouncesById = $this->findBouncesByIdIndexedById($bounceIds); + $bouncesById = $this->bounceRepository->findByIdsIndexedById($bounceIds); $result = []; foreach ($records as $record) { @@ -187,24 +161,14 @@ public function getPaginatedWithJoinNoRelation(int $fromId, int $limit): array */ public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array { - /** @var UserMessage[] $userMessages */ - $userMessages = $this->entityManager->createQueryBuilder() - ->select('um') - ->from(UserMessage::class, 'um') - ->where('um.user = :userId') - ->andWhere('um.status = :status') - ->setParameter('userId', $subscriber->getId()) - ->setParameter('status', 'sent') - ->orderBy('um.createdAt', 'DESC') - ->getQuery() - ->getResult(); + $userMessages = $this->userMessageRepository->findSentByUserId((int) $subscriber->getId()); if ($userMessages === []) { return []; } $docsByMessageId = $this->groupByMessageId($this->fetchDocsByUserId((int) $subscriber->getId())); - $bouncesById = $this->findBouncesByIdIndexedById($this->bounceIdsUsedIn($docsByMessageId)); + $bouncesById = $this->bounceRepository->findByIdsIndexedById($this->bounceIdsUsedIn($docsByMessageId)); $result = []; foreach ($userMessages as $userMessage) { @@ -274,25 +238,6 @@ private function bounceIdsUsedIn(array $docsByMessageId): array return array_values(array_unique($bounceIds)); } - /** - * @param int[] $bounceIds - * @return array - */ - private function findBouncesByIdIndexedById(array $bounceIds): array - { - if ($bounceIds === []) { - return []; - } - - $bouncesById = []; - /** @var Bounce $bounce */ - foreach ($this->entityManager->getRepository(Bounce::class)->findBy(['id' => $bounceIds]) as $bounce) { - $bouncesById[$bounce->getId()] = $bounce; - } - - return $bouncesById; - } - /** @param array{_source: array} $hit */ private function hydrate(array $hit): UserMessageBounceReadModel { diff --git a/src/Domain/Messaging/Repository/UserMessageRepository.php b/src/Domain/Messaging/Repository/UserMessageRepository.php index bf307737..b92fa470 100644 --- a/src/Domain/Messaging/Repository/UserMessageRepository.php +++ b/src/Domain/Messaging/Repository/UserMessageRepository.php @@ -39,6 +39,21 @@ public function countSentBetween(DateTimeInterface $start, DateTimeInterface $en ->getSingleScalarResult(); } + /** + * @return UserMessage[] + */ + public function findSentByUserId(int $userId): array + { + return $this->createQueryBuilder('um') + ->where('um.user = :userId') + ->andWhere('um.status = :status') + ->setParameter('userId', $userId) + ->setParameter('status', UserMessageStatus::Sent->value) + ->orderBy('um.createdAt', 'DESC') + ->getQuery() + ->getResult(); + } + /** * Counts how many user messages have status "sent" since the given time. */ diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index b55164c3..4ee046c2 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -13,6 +13,7 @@ use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberFilter; use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Model\Subscription; /** * Repository for Subscriber models. @@ -272,6 +273,29 @@ public function findSubscriberWithSubscriptions(int $id): ?Subscriber ->getOneOrNullResult(); } + /** + * Subscribers of a list with the data needed for bounce reports, without any bounce counts. + * + * @return array + */ + public function getBounceReportRowsByListId(int $listId): array + { + return $this->createQueryBuilder('subscriber') + ->select( + 'subscriber.id AS subscriberId', + 'subscriber.email AS email', + 'subscriber.confirmed AS confirmed', + 'subscriber.blacklisted AS blacklisted' + ) + ->innerJoin(Subscription::class, 'subscription', 'ON', 'subscription.subscriber = subscriber') + ->where('IDENTITY(subscription.subscriberList) = :listId') + ->setParameter('listId', $listId) + ->groupBy('subscriber.id, subscriber.email, subscriber.confirmed, subscriber.blacklisted') + ->orderBy('subscriber.id', 'ASC') + ->getQuery() + ->getArrayResult(); + } + public function isEmailBlacklisted(string $email): bool { $queryBuilder = $this->getEntityManager()->createQueryBuilder(); diff --git a/tests/Integration/Domain/Messaging/Repository/UserMessageBounceElasticsearchHybridReaderTest.php b/tests/Integration/Domain/Messaging/Repository/UserMessageBounceElasticsearchHybridReaderTest.php index 4459a4da..0a28f547 100644 --- a/tests/Integration/Domain/Messaging/Repository/UserMessageBounceElasticsearchHybridReaderTest.php +++ b/tests/Integration/Domain/Messaging/Repository/UserMessageBounceElasticsearchHybridReaderTest.php @@ -43,7 +43,14 @@ protected function setUp(): void $this->loadSchema(); $this->client = $this->createMock(ElasticsearchClientInterface::class); - $this->reader = new UserMessageBounceElasticsearchHybridReader($this->client, 'phplist_', $this->entityManager); + $this->reader = new UserMessageBounceElasticsearchHybridReader( + $this->client, + 'phplist_', + $this->entityManager->getRepository(Subscriber::class), + $this->entityManager->getRepository(Message::class), + $this->entityManager->getRepository(UserMessage::class), + $this->entityManager->getRepository(Bounce::class), + ); } protected function tearDown(): void From 3914068fa2d5ef437507c72628abc9d0df8a774f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 26 Oct 2026 14:16:46 +0400 Subject: [PATCH 06/46] Refactor comments in CampaignProcessorMessageHandler and MessageForwardService to clarify logic and remove TODOs --- .../MessageHandler/CampaignProcessorMessageHandler.php | 5 ++++- src/Domain/Messaging/Service/MessageForwardService.php | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php index af7d08d2..c3b8ad07 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php @@ -48,7 +48,10 @@ public function __construct( public function __invoke(CampaignProcessorMessage|SyncCampaignProcessorMessage $data): void { - // todo: recheck this stuckCampaignThresholdSeconds logic + // Note: a running campaign only proves it is alive by updating `modified` when it sends. Keep + // the threshold larger than the longest expected gap without a send (throttle sleeps, slow + // precache, large subscriber queries), otherwise a second dispatch can reclaim a live campaign + // and two workers will process it at once. 0 disables reclaiming. $campaign = $this->messageRepository->tryClaimForProcessing( $data->getMessageId(), $this->stuckCampaignThresholdSeconds diff --git a/src/Domain/Messaging/Service/MessageForwardService.php b/src/Domain/Messaging/Service/MessageForwardService.php index b93f5d76..c27af684 100644 --- a/src/Domain/Messaging/Service/MessageForwardService.php +++ b/src/Domain/Messaging/Service/MessageForwardService.php @@ -78,7 +78,6 @@ public function forward(MessageForwardDto $messageForwardDto, Message $campaign) | EmailBlacklistedException | InvalidRecipientOrSubjectException $e ) { - // todo: check if need to catch MessageCacheMissingException $forwardingRecipientResult = $this->handleFailure( campaign: $campaign, forwardingSubscriber: $forwardingSubscriber, From c09147b7d2d461fddc1acd4067f92588807ab325 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 26 Oct 2026 16:11:21 +0400 Subject: [PATCH 07/46] Validate temp file directory in FileHelper and unlink if it doesn't match --- src/Domain/Common/FileHelper.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Domain/Common/FileHelper.php b/src/Domain/Common/FileHelper.php index e1f4da01..122c804a 100644 --- a/src/Domain/Common/FileHelper.php +++ b/src/Domain/Common/FileHelper.php @@ -49,6 +49,12 @@ public function writeFileToDirectory(string $directory, string $originalFilename return null; } + if (dirname((string) realpath($tempFile)) !== realpath($directory)) { + unlink($tempFile); + + return null; + } + $fullPath = $directory . '/' . basename($tempFile); if ($ext !== '') { $fullPath .= '.' . $ext; From 93a4548a4debb734b5f4d095578a51b9e2a14980 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 27 Oct 2026 15:29:46 +0400 Subject: [PATCH 08/46] ref: MessageDataLoader and MessageRepository for improved data handling and remove TODO comments --- .../Repository/MessageRepository.php | 57 +++++++++++++------ .../Messaging/Service/MessageDataLoader.php | 22 +++---- .../Repository/MessageRepositoryTest.php | 21 +++++++ .../Service/MessageDataLoaderTest.php | 32 +++++++++++ 4 files changed, 105 insertions(+), 27 deletions(-) diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php index 1d95eb41..84f4da7d 100644 --- a/src/Domain/Messaging/Repository/MessageRepository.php +++ b/src/Domain/Messaging/Repository/MessageRepository.php @@ -7,7 +7,7 @@ use DateTime; use DateTimeImmutable; use DateTimeInterface; -use Doctrine\ORM\AbstractQuery; +use Doctrine\DBAL\Exception; use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface; use PhpList\Core\Domain\Common\Model\PaginatedResult; use PhpList\Core\Domain\Common\Repository\AbstractRepository; @@ -175,7 +175,11 @@ public function findByIdAndStatus(int $id, Message\MessageStatus $status): ?Mess public function tryClaimForProcessing(int $id, int $staleAfterSeconds = 0): ?Message { $connection = $this->getEntityManager()->getConnection(); - $table = $connection->quoteIdentifier($this->getClassMetadata()->getTableName()); + $metadata = $this->getClassMetadata(); + $table = $connection->quoteIdentifier($metadata->getTableName()); + $idColumn = $metadata->getColumnName('id'); + $statusColumn = $metadata->getColumnName('metadata.status'); + $modifiedColumn = $metadata->getColumnName('updatedAt'); $now = new DateTime(); $params = [ @@ -185,9 +189,13 @@ public function tryClaimForProcessing(int $id, int $staleAfterSeconds = 0): ?Mes 'from' => Message\MessageStatus::Submitted->value, ]; - $claimCondition = 'status = :from'; + $claimCondition = sprintf('%s = :from', $statusColumn); if ($staleAfterSeconds > 0) { - $claimCondition = '(status = :from OR (status IN (:prepared, :inProcess) AND modified < :staleBefore))'; + $claimCondition = sprintf( + '(%1$s = :from OR (%1$s IN (:prepared, :inProcess) AND %2$s < :staleBefore))', + $statusColumn, + $modifiedColumn + ); $params['prepared'] = Message\MessageStatus::Prepared->value; $params['inProcess'] = Message\MessageStatus::InProcess->value; $params['staleBefore'] = (clone $now) @@ -195,7 +203,14 @@ public function tryClaimForProcessing(int $id, int $staleAfterSeconds = 0): ?Mes ->format('Y-m-d H:i:s'); } - $sql = sprintf('UPDATE %s SET status = :to, modified = :now WHERE id = :id AND %s', $table, $claimCondition); + $sql = sprintf( + 'UPDATE %s SET %s = :to, %s = :now WHERE %s = :id AND %s', + $table, + $statusColumn, + $modifiedColumn, + $idColumn, + $claimCondition + ); $affected = $connection->executeStatement($sql, $params); @@ -257,21 +272,29 @@ public function incrementSentCounts(int $messageId, OutputFormat $sentAs): void ->execute(); } + /** + * Non-empty columns of the message row keyed by their database column names (subject, fromfield, + * message, status, ...), like the legacy `select * from message`. Entity hydration is not used on + * purpose: it would key embedded values as `content.subject` etc. + * + * @return array + * @throws Exception + */ public function getNonEmptyFields(int $id): array { - $message = $this->createQueryBuilder('m') - ->where('m.id = :id') - ->setParameter('id', $id) - ->getQuery() - ->getOneOrNullResult(AbstractQuery::HYDRATE_ARRAY) ?? []; - - foreach ($message as $key => $value) { - if ($value === null || $value === '') { - unset($message[$key]); - } - } + $connection = $this->getEntityManager()->getConnection(); + $metadata = $this->getClassMetadata(); + + $message = $connection->fetchAssociative( + sprintf( + 'SELECT * FROM %s WHERE %s = :id', + $connection->quoteIdentifier($metadata->getTableName()), + $metadata->getColumnName('id') + ), + ['id' => $id] + ); - return $message; + return array_filter($message ?: [], static fn (mixed $value): bool => $value !== null && $value !== ''); } /** diff --git a/src/Domain/Messaging/Service/MessageDataLoader.php b/src/Domain/Messaging/Service/MessageDataLoader.php index 161d302d..24a36e26 100644 --- a/src/Domain/Messaging/Service/MessageDataLoader.php +++ b/src/Domain/Messaging/Service/MessageDataLoader.php @@ -11,7 +11,6 @@ use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use Psr\Log\LoggerInterface; -// todo: check types class MessageDataLoader { private const AS_FORMAT_FIELDS = ['astext', 'ashtml', 'astextandhtml', 'aspdf', 'astextandpdf']; @@ -39,7 +38,6 @@ public function __invoke(Message $message): array $this->normaliseScheduleFields($messageData); $this->populateTargetLists($messageData, $message); $this->ensureSendUrlAndMethod($messageData); - // todo: check how should $message->getOptions()->getFromField() be used here $this->normaliseFromField($messageData, $defaultFrom); $this->cleanupSelections($messageData); $this->deriveCampaignTitle($messageData); @@ -72,12 +70,13 @@ private function buildDefaultMessageData(): array 'textmessage' => '', 'rsstemplate' => '', 'embargo' => $this->getDateArray(), - 'repeatinterval' => '0', + 'repeatinterval' => 0, 'repeatuntil' => $this->getDateArray(), - 'requeueinterval' => '0', + 'requeueinterval' => 0, 'requeueuntil' => $this->getDateArray(), 'finishsending' => $this->getDateArray($finishSending), 'fromfield' => '', + 'tofield' => '', 'subject' => '', 'forwardsubject' => '', 'footer' => $this->configProvider->getValue(ConfigOption::MessageFooter), @@ -95,9 +94,8 @@ private function buildDefaultMessageData(): array value: $this->configProvider->getValue(ConfigOption::AlwaysAddGoogleTracking), filter: FILTER_VALIDATE_BOOL ), - // todo: check where this is set 'excludelist' => [], - 'sentastest' => '0', + 'sentastest' => 0, ]; } @@ -121,19 +119,23 @@ private function mergeStoredMessageData(array &$messageData, Message $message): { $storedMessageData = $this->messageDataRepository->getForMessage($message->getId()); foreach ($storedMessageData as $storedMessageDatum) { - if (str_starts_with($storedMessageDatum->getData(), 'SER:')) { - $unserialized = unserialize(substr($storedMessageDatum->getData(), 4), ['allowed_classes' => false]); + $rawData = $storedMessageDatum->getData() ?? ''; + if (str_starts_with($rawData, 'SER:')) { + $unserialized = unserialize(substr($rawData, 4), ['allowed_classes' => false]); if (!is_array($unserialized)) { $this->logger->warning('Invalid serialized data for message ID: ' . $message->getId()); continue; } array_walk_recursive($unserialized, function (&$val) { - $val = stripslashes($val); + // stored values can be ints/bools (e.g. ids stored through the API), only strings have slashes + if (is_string($val)) { + $val = stripslashes($val); + } }); $data = $unserialized; } else { - $data = stripslashes($storedMessageDatum->getData()); + $data = stripslashes($rawData); } if (!in_array($storedMessageDatum->getName(), self::AS_FORMAT_FIELDS)) { //# don't overwrite counters in the message table from the data table diff --git a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php index a384457e..a496f42c 100644 --- a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php +++ b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php @@ -330,4 +330,25 @@ public function testGetFilteredAfterIdSortsDescendingAndCursorsBackward(): void self::assertCount(1, $secondPage->getItems()); self::assertSame($first->getId(), $secondPage->getItems()[0]->getId()); } + + public function testGetNonEmptyFieldsReturnsLegacyColumnNames(): void + { + $message = $this->persistMessage(Message\MessageStatus::Submitted, 'Ready to send'); + $this->entityManager->flush(); + $id = $message->getId(); + $this->entityManager->clear(); + + $fields = $this->messageRepository->getNonEmptyFields($id); + + self::assertSame('Ready to send', $fields['subject']); + self::assertSame('submitted', $fields['status']); + self::assertSame('text', $fields['sendformat']); + self::assertArrayNotHasKey('content.subject', $fields); + self::assertArrayNotHasKey('fromfield', $fields, 'empty columns are dropped'); + } + + public function testGetNonEmptyFieldsReturnsEmptyArrayForUnknownMessage(): void + { + self::assertSame([], $this->messageRepository->getNonEmptyFields(999999)); + } } diff --git a/tests/Unit/Domain/Messaging/Service/MessageDataLoaderTest.php b/tests/Unit/Domain/Messaging/Service/MessageDataLoaderTest.php index b3d945ae..f10a83db 100644 --- a/tests/Unit/Domain/Messaging/Service/MessageDataLoaderTest.php +++ b/tests/Unit/Domain/Messaging/Service/MessageDataLoaderTest.php @@ -168,6 +168,38 @@ public function testParsesFromFieldIntoNameAndEmail(string $fromField, string $n $this->assertSame($email, $result['fromemail']); } + public function testStoredSerializedDataWithNonStringValuesAndNullDataDoNotBreakLoading(): void + { + $this->config->method('getValue')->willReturnMap([ + [ConfigOption::MessageFromAddress, 'from@example.com'], + ]); + $this->messageRepository->method('getNonEmptyFields')->willReturn([]); + + $excludeList = (new MessageData()) + ->setId(10) + ->setName('excludelist') + ->setData('SER:' . serialize([3, '4', true])); + $nullData = (new MessageData())->setId(10)->setName('footer')->setData(null); + $this->messageDataRepository->method('getForMessage')->willReturn([$excludeList, $nullData]); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn(10); + $message->method('getListMessages')->willReturn(new ArrayCollection()); + + $loader = new MessageDataLoader( + configProvider: $this->config, + messageDataRepository: $this->messageDataRepository, + messageRepository: $this->messageRepository, + logger: $this->createMock(LoggerInterface::class), + defaultMessageAge: 3600 + ); + + $result = ($loader)($message); + + $this->assertSame([3, '4', true], $result['excludelist']); + $this->assertSame('', $result['footer']); + } + public static function fromFieldProvider(): array { return [ From deff3319e06bc172768c9dcf0ca396bb038e8420 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 27 Oct 2026 15:30:00 +0400 Subject: [PATCH 09/46] feat: link tracking functionality with token generation and click tracking services --- config/services/parameters.yml | 2 + config/services/repositories.yml | 8 + config/services/services.yml | 9 + .../Analytics/Model/Dto/LinkTrackToken.php | 30 ++ .../Repository/LinkTrackForwardRepository.php | 64 +++ .../Repository/LinkTrackMlRepository.php | 98 +++++ .../LinkTrackUmlClickRepository.php | 65 ++++ .../Analytics/Service/LinkClickService.php | 104 +++++ .../Analytics/Service/LinkTrackService.php | 200 +++++++--- .../Service/LinkTrackTokenService.php | 85 ++++ .../Service/ForwardContentService.php | 12 +- .../Service/MessageProcessingPreparator.php | 46 +-- .../LinkTrackClickRepositoriesTest.php | 130 +++++++ .../Service/LinkClickServiceTest.php | 201 ++++++++++ .../Service/LinkTrackServiceTest.php | 368 +++++++++--------- .../Service/LinkTrackTokenServiceTest.php | 95 +++++ .../Service/ForwardContentServiceTest.php | 20 +- .../MessageProcessingPreparatorTest.php | 78 +--- 18 files changed, 1256 insertions(+), 359 deletions(-) create mode 100644 src/Domain/Analytics/Model/Dto/LinkTrackToken.php create mode 100644 src/Domain/Analytics/Service/LinkClickService.php create mode 100644 src/Domain/Analytics/Service/LinkTrackTokenService.php create mode 100644 tests/Integration/Domain/Analytics/Repository/LinkTrackClickRepositoriesTest.php create mode 100644 tests/Unit/Domain/Analytics/Service/LinkClickServiceTest.php create mode 100644 tests/Unit/Domain/Analytics/Service/LinkTrackTokenServiceTest.php diff --git a/config/services/parameters.yml b/config/services/parameters.yml index 18aa6ccf..9a9b4aea 100644 --- a/config/services/parameters.yml +++ b/config/services/parameters.yml @@ -14,3 +14,5 @@ parameters: notify_end_default: 'end@example.com' always_add_google_tracking: true click_track: true + # sign click-tracking links with the application secret, see LinkTrackTokenService + click_track_sign_with_hmac: false diff --git a/config/services/repositories.yml b/config/services/repositories.yml index 95952280..739b5254 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -11,6 +11,14 @@ services: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Analytics\Model\LinkTrackUmlClick + PhpList\Core\Domain\Analytics\Repository\LinkTrackForwardRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Analytics\Model\LinkTrackForward + PhpList\Core\Domain\Analytics\Repository\LinkTrackMlRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Analytics\Model\LinkTrackMl PhpList\Core\Domain\Configuration\Repository\ConfigRepository: diff --git a/config/services/services.yml b/config/services/services.yml index ac841afa..ae06bc01 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -61,6 +61,15 @@ services: autoconfigure: true public: true + PhpList\Core\Domain\Analytics\Service\LinkTrackTokenService: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Analytics\Service\LinkClickService: + autowire: true + autoconfigure: true + public: true + PhpList\Core\Domain\Analytics\Service\AnalyticsService: autowire: true autoconfigure: true diff --git a/src/Domain/Analytics/Model/Dto/LinkTrackToken.php b/src/Domain/Analytics/Model/Dto/LinkTrackToken.php new file mode 100644 index 00000000..4b6746a6 --- /dev/null +++ b/src/Domain/Analytics/Model/Dto/LinkTrackToken.php @@ -0,0 +1,30 @@ +type === self::TYPE_HTML; + } + + public static function isValidType(string $type): bool + { + return in_array($type, [self::TYPE_HTML, self::TYPE_TEXT], true); + } +} diff --git a/src/Domain/Analytics/Repository/LinkTrackForwardRepository.php b/src/Domain/Analytics/Repository/LinkTrackForwardRepository.php index 38de3f74..85ec05eb 100644 --- a/src/Domain/Analytics/Repository/LinkTrackForwardRepository.php +++ b/src/Domain/Analytics/Repository/LinkTrackForwardRepository.php @@ -4,6 +4,9 @@ namespace PhpList\Core\Domain\Analytics\Repository; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use Doctrine\DBAL\Types\Types; +use PhpList\Core\Domain\Analytics\Model\LinkTrackForward; use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; @@ -11,4 +14,65 @@ class LinkTrackForwardRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + public function findOneByUuid(string $uuid): ?LinkTrackForward + { + return $this->findOneBy(['uuid' => $uuid]); + } + + public function findOneByUrlHash(string $urlHash): ?LinkTrackForward + { + return $this->findOneBy(['urlHash' => $urlHash]); + } + + /** + * Returns the forward for a (cleaned) URL, creating it when it does not exist yet. Creation uses a plain + * insert, so two workers racing on the same URL end up with the one row enforced by the urlhash unique key + * instead of a closed entity manager - this is an exception of the usual workflow of creating entities in manager + * classes. + */ + public function findOrCreateByUrl(string $url, bool $personalise): LinkTrackForward + { + $urlHash = md5($url); + + $forward = $this->findOneByUrlHash($urlHash); + if ($forward === null) { + // false when a concurrent worker created it first, the select then finds that row + $this->insert($url, $urlHash, $personalise); + $forward = $this->findOneByUrlHash($urlHash); + } + + if (empty($forward->getUuid())) { + $forward->setUuid(self::generateUuid()); + $this->getEntityManager()->flush(); + } + + return $forward; + } + + private function insert(string $url, string $urlHash, bool $personalise): bool + { + $metadata = $this->getClassMetadata(); + try { + $this->getEntityManager()->getConnection()->insert( + $metadata->getTableName(), + [ + $metadata->getColumnName('url') => $url, + $metadata->getColumnName('urlHash') => $urlHash, + $metadata->getColumnName('personalise') => $personalise, + $metadata->getColumnName('uuid') => self::generateUuid(), + ], + [$metadata->getColumnName('personalise') => Types::BOOLEAN], + ); + } catch (UniqueConstraintViolationException) { + return false; + } + + return true; + } + + private static function generateUuid(): string + { + return bin2hex(random_bytes(18)); + } } diff --git a/src/Domain/Analytics/Repository/LinkTrackMlRepository.php b/src/Domain/Analytics/Repository/LinkTrackMlRepository.php index 6914105b..3e1292b4 100644 --- a/src/Domain/Analytics/Repository/LinkTrackMlRepository.php +++ b/src/Domain/Analytics/Repository/LinkTrackMlRepository.php @@ -4,8 +4,106 @@ namespace PhpList\Core\Domain\Analytics\Repository; +use DateTimeInterface; +use Doctrine\DBAL\Exception; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use PhpList\Core\Domain\Common\Repository\AbstractRepository; +/** + * Per campaign and link aggregates. Counters are changed with atomic statements so that concurrent sending + * workers and clicking subscribers cannot lose updates. Column names come from the entity mapping. + */ class LinkTrackMlRepository extends AbstractRepository { + /** Counts one more email of the campaign that contains the link. */ + public function incrementTotal(int $messageId, int $forwardId): void + { + $total = $this->column('total'); + + $this->upsert( + updateSql: sprintf('%1$s = COALESCE(%1$s, 0) + 1', $total), + updateParams: [], + insertColumns: [$total => 1], + messageId: $messageId, + forwardId: $forwardId, + ); + } + + public function recordClick(int $messageId, int $forwardId, bool $isHtml, DateTimeInterface $now): void + { + $timestamp = $now->format('Y-m-d H:i:s'); + [$clicked, $firstClick, $latestClick, $htmlClicked, $textClicked] = array_map( + $this->column(...), + ['clicked', 'firstClick', 'latestClick', 'htmlClicked', 'textClicked'] + ); + + $this->upsert( + updateSql: sprintf( + '%1$s = COALESCE(%1$s, 0) + 1, %2$s = COALESCE(%2$s, :now), %3$s = :now,' + . ' %4$s = COALESCE(%4$s, 0) + :html, %5$s = COALESCE(%5$s, 0) + :text', + $clicked, + $firstClick, + $latestClick, + $htmlClicked, + $textClicked + ), + updateParams: ['now' => $timestamp, 'html' => (int) $isHtml, 'text' => (int) !$isHtml], + insertColumns: [ + $clicked => 1, + $firstClick => $timestamp, + $latestClick => $timestamp, + $htmlClicked => (int) $isHtml, + $textClicked => (int) !$isHtml, + ], + messageId: $messageId, + forwardId: $forwardId, + ); + } + + private function column(string $field): string + { + return $this->getClassMetadata()->getColumnName($field); + } + + /** + * @param string $updateSql the assignments of the UPDATE, without SET + * @param array $updateParams + * @param array $insertColumns column => value + * @throws Exception + */ + private function upsert( + string $updateSql, + array $updateParams, + array $insertColumns, + int $messageId, + int $forwardId, + ): void { + $connection = $this->getEntityManager()->getConnection(); + $tableName = $this->getClassMetadata()->getTableName(); + $messageColumn = $this->column('messageId'); + $forwardColumn = $this->column('forwardId'); + + $sql = sprintf( + 'UPDATE %s SET %s WHERE %s = :messageId AND %s = :forwardId', + $connection->quoteIdentifier($tableName), + $updateSql, + $messageColumn, + $forwardColumn + ); + $params = $updateParams + ['messageId' => $messageId, 'forwardId' => $forwardId]; + + if ($connection->executeStatement($sql, $params) > 0) { + return; + } + + try { + $connection->insert( + $tableName, + [$messageColumn => $messageId, $forwardColumn => $forwardId] + $insertColumns + ); + } catch (UniqueConstraintViolationException) { + // inserted by a concurrent request in the meantime + $connection->executeStatement($sql, $params); + } + } } diff --git a/src/Domain/Analytics/Repository/LinkTrackUmlClickRepository.php b/src/Domain/Analytics/Repository/LinkTrackUmlClickRepository.php index 5662d672..2f3254c9 100644 --- a/src/Domain/Analytics/Repository/LinkTrackUmlClickRepository.php +++ b/src/Domain/Analytics/Repository/LinkTrackUmlClickRepository.php @@ -4,6 +4,8 @@ namespace PhpList\Core\Domain\Analytics\Repository; +use DateTimeInterface; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; @@ -11,4 +13,67 @@ class LinkTrackUmlClickRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + /** + * Counts a click of a subscriber on a link of a campaign, creating the row on the first click. Column names + * come from the entity mapping. + */ + public function recordClick( + int $messageId, + int $userId, + int $forwardId, + bool $isHtml, + DateTimeInterface $now, + ): void { + $connection = $this->getEntityManager()->getConnection(); + $metadata = $this->getClassMetadata(); + $timestamp = $now->format('Y-m-d H:i:s'); + [$message, $user, $forward, $clicked, $firstClick, $latestClick, $htmlClicked, $textClicked] = array_map( + $metadata->getColumnName(...), + ['messageId', 'userId', 'forwardId', 'clicked', 'firstClick', 'latestClick', 'htmlClicked', 'textClicked'] + ); + + $sql = sprintf( + 'UPDATE %4$s SET %5$s = COALESCE(%5$s, 0) + 1, %6$s = COALESCE(%6$s, :now), %7$s = :now,' + . ' %8$s = COALESCE(%8$s, 0) + :html, %9$s = COALESCE(%9$s, 0) + :text' + . ' WHERE %1$s = :messageId AND %2$s = :userId AND %3$s = :forwardId', + $message, + $user, + $forward, + $connection->quoteIdentifier($metadata->getTableName()), + $clicked, + $firstClick, + $latestClick, + $htmlClicked, + $textClicked + ); + $params = [ + 'now' => $timestamp, + 'html' => (int) $isHtml, + 'text' => (int) !$isHtml, + 'messageId' => $messageId, + 'userId' => $userId, + 'forwardId' => $forwardId, + ]; + + if ($connection->executeStatement($sql, $params) > 0) { + return; + } + + try { + $connection->insert($metadata->getTableName(), [ + $message => $messageId, + $user => $userId, + $forward => $forwardId, + $firstClick => $timestamp, + $latestClick => $timestamp, + $clicked => 1, + $htmlClicked => (int) $isHtml, + $textClicked => (int) !$isHtml, + ]); + } catch (UniqueConstraintViolationException) { + // inserted by a concurrent request in the meantime + $connection->executeStatement($sql, $params); + } + } } diff --git a/src/Domain/Analytics/Service/LinkClickService.php b/src/Domain/Analytics/Service/LinkClickService.php new file mode 100644 index 00000000..75ad3d9c --- /dev/null +++ b/src/Domain/Analytics/Service/LinkClickService.php @@ -0,0 +1,104 @@ + $metadata HTTP_USER_AGENT, HTTP_REFERER and client_ip of the request + * @return string|null the url to redirect to, or null when the token is invalid or the link is unknown + */ + public function trackClick(string $tid, ?string $signature, array $metadata): ?string + { + $token = $this->tokenService->parse($tid, $signature); + if ($token === null) { + return null; + } + + $forward = $this->forwardRepository->findOneByUuid($token->forwardUuid); + $message = $this->messageRepository->findOneBy(['uuid' => $token->messageUuid]); + $subscriber = $this->subscriberRepository->findOneBy(['uuid' => $token->subscriberUuid]); + if (!$forward instanceof LinkTrackForward + || !$message instanceof Message + || !$subscriber instanceof Subscriber + ) { + return null; + } + + $userMessage = $this->userMessageRepository->findByUserAndMessage($subscriber, $message); + $url = $this->resolveUrl($forward, $subscriber, $userMessage); + if ($url === null) { + return null; + } + + $now = new DateTimeImmutable(); + $messageId = (int) $message->getId(); + $forwardId = (int) $forward->getId(); + $this->mlRepository->recordClick($messageId, $forwardId, $token->isHtml(), $now); + + // a click on a link also proves the campaign was opened, but only the first one counts as a view + if ($userMessage !== null && !$userMessage->isViewed()) { + $this->userMessageService->trackUserMessageView($subscriber->getUniqueId(), $messageId, $metadata); + } + + $this->umlClickRepository->recordClick( + $messageId, + (int) $subscriber->getId(), + $forwardId, + $token->isHtml(), + $now + ); + + return $url; + } + + private function resolveUrl(LinkTrackForward $forward, Subscriber $subscriber, ?UserMessage $userMessage): ?string + { + $url = $forward->getUrl(); + if ($url === null) { + return null; + } + + if ($forward->isPersonalise()) { + // personalised links are only valid for subscribers the campaign was actually sent to + if ($userMessage === null) { + return null; + } + $url .= (str_contains($url, '?') ? '&' : '?') . 'uid=' . $subscriber->getUniqueId(); + } + + return preg_match('~^(https?|ftp)://~i', $url) ? $url : null; + } +} diff --git a/src/Domain/Analytics/Service/LinkTrackService.php b/src/Domain/Analytics/Service/LinkTrackService.php index dc478e84..05a9baa6 100644 --- a/src/Domain/Analytics/Service/LinkTrackService.php +++ b/src/Domain/Analytics/Service/LinkTrackService.php @@ -4,19 +4,37 @@ namespace PhpList\Core\Domain\Analytics\Service; -use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Core\ParameterProvider; -use PhpList\Core\Domain\Analytics\Exception\MissingMessageIdException; -use PhpList\Core\Domain\Analytics\Model\LinkTrack; +use PhpList\Core\Domain\Analytics\Model\Dto\LinkTrackToken; +use PhpList\Core\Domain\Analytics\Repository\LinkTrackForwardRepository; +use PhpList\Core\Domain\Analytics\Repository\LinkTrackMlRepository; use PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository; use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; - +use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Subscription\Model\Subscriber; + +/** + * Replaces the links of a campaign email with click-tracking links, like phplist3's sendemaillib.php. + * + * Each distinct URL is stored once as a LinkTrackForward. The replacement link carries a token that identifies + * the forward, the campaign and the subscriber (see LinkTrackTokenService); LinkClickService resolves it again. + */ class LinkTrackService { + private const MAX_URL_LENGTH = 255; + private const HTML_ANCHOR_PATTERN = '/(]*?[\s"\'])?)href=(["\'])(.*?)\2([^>]*>)(.*?)<\/a>/is'; + private const TEXT_URL_PATTERN = '#https?://[^\s<>},]+#i'; + private const PLACEHOLDER_PATTERN = '/\[[A-Z][A-Z0-9_]*(?::[^\]]*)?\]/'; + + /** @var array forwards by url hash, kept for the worker's lifetime */ + private array $forwards = []; + public function __construct( private readonly LinkTrackRepository $linkTrackRepository, + private readonly LinkTrackForwardRepository $linkTrackForwardRepository, + private readonly LinkTrackMlRepository $linkTrackMlRepository, + private readonly LinkTrackTokenService $tokenService, private readonly ParameterProvider $paramProvider, - private readonly EntityManagerInterface $entityManager, ) { } @@ -26,71 +44,165 @@ public function getUrlById(int $id): ?string return $linkTrack?->getUrl(); } - public function isExtractAndSaveLinksApplicable(): bool + public function isClickTrackingEnabled(): bool { return (bool)$this->paramProvider->get('click_track', false); } /** - * Extract links from message content and save them to the LinkTrackRepository + * Returns a copy of the content in which the links of the HTML parts (content, HTML footer) and of the text + * parts (text content, text footer) point to the click-tracking endpoint for this subscriber. * - * @return LinkTrack[] The saved LinkTrack entities - * @throws MissingMessageIdException + * Links are left alone when they are not http(s)/ftp, are longer than the forward url column, still contain + * placeholders (the redirect could not expand them), or are shown as text that itself contains a URL (the + * rewrite would look like phishing). */ - public function extractAndSaveLinks(MessagePrecacheDto $content, int $userId, ?int $messageId = null): array - { - // todo: in case of forwarded message, we need to use 'forwarded' instead of user id - if (!$this->isExtractAndSaveLinksApplicable()) { - return []; + public function trackLinks( + MessagePrecacheDto $content, + Message $campaign, + Subscriber $subscriber + ): MessagePrecacheDto { + $messageUuid = (string) $campaign->getUuid(); + $subscriberUuid = $subscriber->getUuid(); + if (!$this->isClickTrackingEnabled() || $messageUuid === '' || $subscriberUuid === '') { + return $content; + } + + $tracked = clone $content; + $forwardIds = []; + $trackUrl = function (string $url, string $type) use ($messageUuid, $subscriberUuid, &$forwardIds): ?string { + return $this->createTrackingUrl($url, $type, $messageUuid, $subscriberUuid, $forwardIds); + }; + + $tracked->content = $this->rewriteHtml($tracked->content, $trackUrl); + $tracked->htmlFooter = $this->rewriteHtml($tracked->htmlFooter, $trackUrl); + $tracked->textContent = $this->rewriteText($tracked->textContent, $trackUrl); + if ($tracked->textFooter !== null) { + $tracked->textFooter = $this->rewriteText($tracked->textFooter, $trackUrl); } - if ($messageId === null) { - throw new MissingMessageIdException(); + foreach (array_keys($forwardIds) as $forwardId) { + $this->linkTrackMlRepository->incrementTotal((int) $campaign->getId(), $forwardId); } - $links = $this->extractLinksFromHtml($content->content); + return $tracked; + } - if ($content->htmlFooter) { - $links = array_merge($links, $this->extractLinksFromHtml($content->htmlFooter)); + /** @param callable(string, string): ?string $trackUrl */ + private function rewriteHtml(string $html, callable $trackUrl): string + { + if ($html === '') { + return $html; } - $links = array_unique($links); + return (string) preg_replace_callback( + self::HTML_ANCHOR_PATTERN, + function (array $match) use ($trackUrl): string { + $linkText = strip_tags($match[5]); + if (stripos($linkText, 'http://') !== false || stripos($linkText, 'https://') !== false) { + return $match[0]; + } + + $href = html_entity_decode(trim($match[3]), ENT_QUOTES | ENT_HTML5); + $url = $trackUrl($href, LinkTrackToken::TYPE_HTML); + if ($url === null) { + return $match[0]; + } + + return $match[1] . 'href=' . $match[2] . htmlspecialchars($url, ENT_QUOTES) . $match[2] + . $match[4] . $match[5] . ''; + }, + $html + ); + } - $savedLinks = []; - foreach ($links as $url) { - $existingLinkTrack = $this->linkTrackRepository->findByUrlUserIdAndMessageId($url, $userId, $messageId); - if ($existingLinkTrack !== null) { - $savedLinks[] = $existingLinkTrack; - continue; - } - $linkTrack = new LinkTrack(); - $linkTrack->setMessageId($messageId); - $linkTrack->setUserId($userId); - $linkTrack->setUrl($url); + /** @param callable(string, string): ?string $trackUrl */ + private function rewriteText(string $text, callable $trackUrl): string + { + if ($text === '') { + return $text; + } - $this->linkTrackRepository->persist($linkTrack); - $savedLinks[] = $linkTrack; + return (string) preg_replace_callback( + self::TEXT_URL_PATTERN, + function (array $match) use ($trackUrl): string { + $trailing = str_ends_with($match[0], '.') ? '.' : ''; + $link = substr($match[0], 0, strlen($match[0]) - strlen($trailing)); + $url = $trackUrl($link, LinkTrackToken::TYPE_TEXT); + + return $url === null ? $match[0] : $url . $trailing; + }, + $text + ); + } + + /** @param array $forwardIds collects the ids of the forwards used in the email */ + private function createTrackingUrl( + string $url, + string $type, + string $messageUuid, + string $subscriberUuid, + array &$forwardIds, + ): ?string { + $personalise = false; + $cleanUrl = $this->cleanUrl($url, $personalise); + + if (!preg_match('~^(https?|ftp)://~i', $cleanUrl) + || strlen($cleanUrl) > self::MAX_URL_LENGTH + || preg_match(self::PLACEHOLDER_PATTERN, $cleanUrl) + || str_contains($cleanUrl, LinkTrackTokenService::ENDPOINT_PATH . '?tid=') + ) { + return null; } - $this->entityManager->flush(); + $forward = $this->getForward($cleanUrl, $personalise); + $forwardIds[$forward['id']] = true; + + return $this->tokenService->createUrl( + new LinkTrackToken($type, $forward['uuid'], $messageUuid, $subscriberUuid) + ); + } + + /** @return array{id: int, uuid: string} */ + private function getForward(string $cleanUrl, bool $personalise): array + { + $urlHash = md5($cleanUrl); + if (!isset($this->forwards[$urlHash])) { + $forward = $this->linkTrackForwardRepository->findOrCreateByUrl($cleanUrl, $personalise); + $this->forwards[$urlHash] = ['id' => (int) $forward->getId(), 'uuid' => (string) $forward->getUuid()]; + } - return $savedLinks; + return $this->forwards[$urlHash]; } /** - * Extract links from HTML content - * - * @return string[] The extracted links + * Removes the personal `uid` and `PHPSESSID` query parameters, which must not end up in the stored url. + * $personalise is set when a `uid` was present: the redirect then adds the subscriber's uid again. */ - private function extractLinksFromHtml(string $html): array + private function cleanUrl(string $url, bool &$personalise): string { - $links = []; + $fragment = ''; + $fragmentPosition = strpos($url, '#'); + if ($fragmentPosition !== false) { + $fragment = substr($url, $fragmentPosition); + $url = substr($url, 0, $fragmentPosition); + } - $pattern = '/]*href=(["\'])([^"\']+)\1[^>]*>/i'; - if (preg_match_all($pattern, $html, $matches)) { - $links = $matches[2]; + $queryPosition = strpos($url, '?'); + if ($queryPosition === false) { + return $url . $fragment; + } + + $kept = []; + foreach (explode('&', substr($url, $queryPosition + 1)) as $pair) { + $name = strtolower(urldecode(explode('=', $pair, 2)[0])); + if ($name === 'uid') { + $personalise = true; + } elseif ($name !== 'phpsessid' && $pair !== '') { + $kept[] = $pair; + } } - return $links; + return substr($url, 0, $queryPosition) . ($kept === [] ? '' : '?' . implode('&', $kept)) . $fragment; } } diff --git a/src/Domain/Analytics/Service/LinkTrackTokenService.php b/src/Domain/Analytics/Service/LinkTrackTokenService.php new file mode 100644 index 00000000..371f14d5 --- /dev/null +++ b/src/Domain/Analytics/Service/LinkTrackTokenService.php @@ -0,0 +1,85 @@ +/lt?tid=`), the equivalent of + * phplist3's lt.php links. + * + * The token is the url-safe base64 of `|||`. The uuids are + * random, so a token cannot be guessed. With the `click_track_sign_with_hmac` parameter enabled the token is + * additionally signed (`hm`) with the application secret, so a request can be rejected before touching the + * database. Keep it off if the secret may be rotated, as that would invalidate the links of sent campaigns. + */ +class LinkTrackTokenService +{ + public const ENDPOINT_PATH = '/lt'; + + public function __construct( + private readonly ParameterProvider $parameterProvider, + #[Autowire('%app.rest_api_base_url%')] private readonly string $restApiBaseUrl, + #[Autowire('%app.secret%')] private readonly string $secret, + ) { + } + + public function createUrl(LinkTrackToken $token): string + { + $encoded = rtrim(strtr(base64_encode(implode('|', [ + $token->type, + $token->forwardUuid, + $token->messageUuid, + $token->subscriberUuid, + ])), '+/', '-_'), '='); + + $url = rtrim($this->restApiBaseUrl, '/') . self::ENDPOINT_PATH . '?tid=' . $encoded; + if ($this->isSigningEnabled()) { + $url .= '&hm=' . $this->sign($encoded); + } + + return $url; + } + + /** Returns null for malformed tokens and, when signing is enabled, for missing or wrong signatures. */ + public function parse(string $tid, ?string $signature = null): ?LinkTrackToken + { + if ($this->isSigningEnabled() + && ($signature === null || $signature === '' || !hash_equals($this->sign($tid), $signature)) + ) { + return null; + } + + $decoded = base64_decode(strtr($tid, '-_', '+/'), true); + if ($decoded === false) { + return null; + } + + $parts = explode('|', $decoded); + if (count($parts) !== 4 || !LinkTrackToken::isValidType($parts[0])) { + return null; + } + + foreach (array_slice($parts, 1) as $uuid) { + if (!preg_match('/^[A-Za-z0-9-]{1,64}$/', $uuid)) { + return null; + } + } + + return new LinkTrackToken($parts[0], $parts[1], $parts[2], $parts[3]); + } + + private function isSigningEnabled(): bool + { + return (bool) $this->parameterProvider->get('click_track_sign_with_hmac', false); + } + + private function sign(string $encodedToken): string + { + return hash_hmac('sha256', $encodedToken, $this->secret); + } +} diff --git a/src/Domain/Messaging/Service/ForwardContentService.php b/src/Domain/Messaging/Service/ForwardContentService.php index 2fa0e17d..48320a21 100644 --- a/src/Domain/Messaging/Service/ForwardContentService.php +++ b/src/Domain/Messaging/Service/ForwardContentService.php @@ -19,7 +19,6 @@ class ForwardContentService { public function __construct( private readonly CacheInterface $cache, - private readonly MessageProcessingPreparator $messagePreparator, private readonly ForwardEmailBuilder $forwardEmailBuilder, ) { } @@ -39,18 +38,13 @@ public function getContents( throw new MessageCacheMissingException(); } - // todo: check how should links be handled in case of forwarding - $processed = $this->messagePreparator->processMessageLinks( - campaignId: $campaign->getId(), - cachedMessageDto: $messagePrecacheDto, - subscriber: $forwardingSubscriber - ); - + // links of forwarded messages are not click tracked: the forwarding subscriber would + // be credited with the clicks of the friend return $this->forwardEmailBuilder->buildForwardEmail( messageId: $campaign->getId(), friendEmail: $friendEmail, forwardedBy: $forwardingSubscriber, - data: $processed, + data: $messagePrecacheDto, htmlPref: $forwardingSubscriber->hasHtmlEmail(), fromName: $forwardDto->getFromName(), fromEmail: $forwardDto->getFromEmail(), diff --git a/src/Domain/Messaging/Service/MessageProcessingPreparator.php b/src/Domain/Messaging/Service/MessageProcessingPreparator.php index 05f044ea..cfbcc148 100644 --- a/src/Domain/Messaging/Service/MessageProcessingPreparator.php +++ b/src/Domain/Messaging/Service/MessageProcessingPreparator.php @@ -4,7 +4,6 @@ namespace PhpList\Core\Domain\Messaging\Service; -use PhpList\Core\Domain\Analytics\Model\LinkTrack; use PhpList\Core\Domain\Analytics\Service\LinkTrackService; use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; @@ -15,9 +14,6 @@ class MessageProcessingPreparator { - // @todo: create functionality to track - public const LINK_TRACK_ENDPOINT = '/api/v2/link-track'; - public function __construct( private readonly SubscriberRepository $subscriberRepository, private readonly MessageRepository $messageRepository, @@ -57,53 +53,23 @@ public function ensureCampaignsHaveUuid(OutputInterface $output): void } /** - * Process message content to extract URLs and replace them with link track URLs + * Replaces the links of the cached message content with click-tracking links for this subscriber. + * Returns the content unchanged when click tracking is off or the campaign is unknown. */ public function processMessageLinks( int $campaignId, MessagePrecacheDto $cachedMessageDto, Subscriber $subscriber ): MessagePrecacheDto { - if (!$this->linkTrackService->isExtractAndSaveLinksApplicable()) { + if (!$this->linkTrackService->isClickTrackingEnabled()) { return $cachedMessageDto; } - $savedLinks = $this->linkTrackService->extractAndSaveLinks( - content: $cachedMessageDto, - userId: $subscriber->getId(), - messageId: $campaignId, - ); - if (empty($savedLinks)) { + $campaign = $this->messageRepository->findById($campaignId); + if ($campaign === null) { return $cachedMessageDto; } - // todo: check if getTextMessage should replace links as well - if ($cachedMessageDto->content) { - $cachedMessageDto->content = $this->replaceLinks( - savedLinks: $savedLinks, - htmlText: $cachedMessageDto->content - ); - } - - if ($cachedMessageDto->htmlFooter) { - $cachedMessageDto->htmlFooter = $this->replaceLinks( - savedLinks: $savedLinks, - htmlText: $cachedMessageDto->htmlFooter - ); - } - - return $cachedMessageDto; - } - - private function replaceLinks(array $savedLinks, string $htmlText): string - { - /** @var LinkTrack $linkTrack */ - foreach ($savedLinks as $linkTrack) { - $originalUrl = $linkTrack->getUrl(); - $trackUrl = self::LINK_TRACK_ENDPOINT . '?id=' . $linkTrack->getId(); - $htmlText = str_replace('href="' . $originalUrl . '"', 'href="' . $trackUrl . '"', $htmlText); - } - - return $htmlText; + return $this->linkTrackService->trackLinks($cachedMessageDto, $campaign, $subscriber); } } diff --git a/tests/Integration/Domain/Analytics/Repository/LinkTrackClickRepositoriesTest.php b/tests/Integration/Domain/Analytics/Repository/LinkTrackClickRepositoriesTest.php new file mode 100644 index 00000000..c20d1a1f --- /dev/null +++ b/tests/Integration/Domain/Analytics/Repository/LinkTrackClickRepositoriesTest.php @@ -0,0 +1,130 @@ +loadSchema(); + + $this->forwardRepository = self::getContainer()->get(LinkTrackForwardRepository::class); + $this->mlRepository = self::getContainer()->get(LinkTrackMlRepository::class); + $this->umlClickRepository = self::getContainer()->get(LinkTrackUmlClickRepository::class); + } + + protected function tearDown(): void + { + $schemaTool = new SchemaTool($this->entityManager); + $schemaTool->dropDatabase(); + parent::tearDown(); + } + + public function testFindOrCreateByUrlCreatesOneForwardPerUrl(): void + { + $first = $this->forwardRepository->findOrCreateByUrl('https://example.com/a', false); + $again = $this->forwardRepository->findOrCreateByUrl('https://example.com/a', false); + $other = $this->forwardRepository->findOrCreateByUrl('https://example.com/b', true); + + self::assertSame($first->getId(), $again->getId()); + self::assertNotSame($first->getId(), $other->getId()); + self::assertSame('https://example.com/a', $first->getUrl()); + self::assertSame(md5('https://example.com/a'), $first->getUrlHash()); + self::assertFalse($first->isPersonalise()); + self::assertTrue($other->isPersonalise()); + self::assertSame(36, strlen((string) $first->getUuid())); + self::assertNotSame($first->getUuid(), $other->getUuid()); + self::assertSame($first->getId(), $this->forwardRepository->findOneByUuid($first->getUuid())->getId()); + self::assertNull($this->forwardRepository->findOneByUuid('unknown')); + } + + public function testIncrementTotalCreatesAndIncrementsTheRow(): void + { + $this->mlRepository->incrementTotal(7, 11); + $this->mlRepository->incrementTotal(7, 11); + $this->mlRepository->incrementTotal(7, 12); + + self::assertSame(2, $this->findMl(7, 11)->getTotal()); + self::assertSame(1, $this->findMl(7, 12)->getTotal()); + self::assertSame(0, $this->findMl(7, 11)->getClicked()); + } + + public function testRecordClickOnMlKeepsTheFirstClickAndSplitsHtmlAndText(): void + { + $this->mlRepository->incrementTotal(7, 11); + $this->mlRepository->recordClick(7, 11, true, new DateTimeImmutable('2026-01-01 10:00:00')); + $this->mlRepository->recordClick(7, 11, false, new DateTimeImmutable('2026-01-02 11:00:00')); + $this->mlRepository->recordClick(7, 11, true, new DateTimeImmutable('2026-01-03 12:00:00')); + + $ml = $this->findMl(7, 11); + self::assertSame(1, $ml->getTotal(), 'sending total is not touched by clicks'); + self::assertSame(3, $ml->getClicked()); + self::assertSame(2, $ml->getHtmlClicked()); + self::assertSame(1, $ml->getTextClicked()); + self::assertSame('2026-01-01 10:00:00', $ml->getFirstClick()->format('Y-m-d H:i:s')); + self::assertSame('2026-01-03 12:00:00', $ml->getLatestClick()->format('Y-m-d H:i:s')); + } + + public function testRecordClickOnMlCreatesTheRowWhenNothingWasSentYet(): void + { + $this->mlRepository->recordClick(7, 11, false, new DateTimeImmutable('2026-01-01 10:00:00')); + + $ml = $this->findMl(7, 11); + self::assertSame(1, $ml->getClicked()); + self::assertSame(1, $ml->getTextClicked()); + self::assertSame(0, $ml->getHtmlClicked()); + } + + public function testRecordClickPerSubscriberIsUniquePerCampaignSubscriberAndLink(): void + { + $this->umlClickRepository->recordClick(7, 3, 11, true, new DateTimeImmutable('2026-01-01 10:00:00')); + $this->umlClickRepository->recordClick(7, 3, 11, false, new DateTimeImmutable('2026-01-02 11:00:00')); + $this->umlClickRepository->recordClick(7, 4, 11, true, new DateTimeImmutable('2026-01-02 11:00:00')); + $this->umlClickRepository->recordClick(7, 3, 12, true, new DateTimeImmutable('2026-01-02 11:00:00')); + + $click = $this->findUml(7, 3, 11); + self::assertSame(2, $click->getClicked()); + self::assertSame(1, $click->getHtmlClicked()); + self::assertSame(1, $click->getTextClicked()); + self::assertSame('2026-01-01 10:00:00', $click->getFirstClick()->format('Y-m-d H:i:s')); + self::assertSame('2026-01-02 11:00:00', $click->getLatestClick()->format('Y-m-d H:i:s')); + self::assertSame(1, $this->findUml(7, 4, 11)->getClicked()); + self::assertSame(1, $this->findUml(7, 3, 12)->getClicked()); + self::assertCount(3, $this->umlClickRepository->findAll()); + } + + private function findMl(int $messageId, int $forwardId): LinkTrackMl + { + $this->entityManager->clear(); + + return $this->mlRepository->findOneBy(['messageId' => $messageId, 'forwardId' => $forwardId]); + } + + private function findUml(int $messageId, int $userId, int $forwardId): LinkTrackUmlClick + { + $this->entityManager->clear(); + + return $this->umlClickRepository->findOneBy( + ['messageId' => $messageId, 'userId' => $userId, 'forwardId' => $forwardId] + ); + } +} diff --git a/tests/Unit/Domain/Analytics/Service/LinkClickServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkClickServiceTest.php new file mode 100644 index 00000000..370aefa7 --- /dev/null +++ b/tests/Unit/Domain/Analytics/Service/LinkClickServiceTest.php @@ -0,0 +1,201 @@ +forwardRepository = $this->createMock(LinkTrackForwardRepository::class); + $this->mlRepository = $this->createMock(LinkTrackMlRepository::class); + $this->umlClickRepository = $this->createMock(LinkTrackUmlClickRepository::class); + $this->messageRepository = $this->createMock(MessageRepository::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->userMessageRepository = $this->createMock(UserMessageRepository::class); + $this->userMessageService = $this->createMock(UserMessageService::class); + $this->tokenService = new LinkTrackTokenService(new ParameterProvider([]), 'https://example.com/api/v2', 's'); + + $this->service = new LinkClickService( + $this->tokenService, + $this->forwardRepository, + $this->mlRepository, + $this->umlClickRepository, + $this->messageRepository, + $this->subscriberRepository, + $this->userMessageRepository, + $this->userMessageService, + ); + + $this->message = $this->createMock(Message::class); + $this->message->method('getId')->willReturn(7); + $this->subscriber = $this->createMock(Subscriber::class); + $this->subscriber->method('getId')->willReturn(3); + $this->subscriber->method('getUniqueId')->willReturn('uniq-3'); + } + + private function tid(string $type = 'H'): string + { + $url = $this->tokenService->createUrl(new LinkTrackToken($type, 'fwd', 'msg', 'usr')); + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + + return $query['tid']; + } + + private function givenKnownLink(string $url = 'https://example.com/a', bool $personalise = false): void + { + $forward = $this->createMock(LinkTrackForward::class); + $forward->method('getId')->willReturn(11); + $forward->method('getUrl')->willReturn($url); + $forward->method('isPersonalise')->willReturn($personalise); + + $this->forwardRepository->method('findOneByUuid')->with('fwd')->willReturn($forward); + $this->messageRepository->method('findOneBy')->with(['uuid' => 'msg'])->willReturn($this->message); + $this->subscriberRepository->method('findOneBy')->with(['uuid' => 'usr'])->willReturn($this->subscriber); + } + + private function givenUserMessage(bool $viewed): void + { + $userMessage = $this->createMock(UserMessage::class); + $userMessage->method('isViewed')->willReturn($viewed); + $this->userMessageRepository->method('findByUserAndMessage')->willReturn($userMessage); + } + + public function testRedirectsToTheStoredUrlAndCountsTheClick(): void + { + $this->givenKnownLink(); + $this->givenUserMessage(viewed: false); + + $this->mlRepository->expects(self::once()) + ->method('recordClick') + ->with(7, 11, true, self::anything()); + $this->umlClickRepository->expects(self::once()) + ->method('recordClick') + ->with(7, 3, 11, true, self::anything()); + $this->userMessageService->expects(self::once()) + ->method('trackUserMessageView') + ->with('uniq-3', 7, ['HTTP_USER_AGENT' => 'ua']); + + self::assertSame( + 'https://example.com/a', + $this->service->trackClick($this->tid(), null, ['HTTP_USER_AGENT' => 'ua']) + ); + } + + public function testTextClicksAreCountedAsText(): void + { + $this->givenKnownLink(); + $this->givenUserMessage(viewed: true); + + $this->mlRepository->expects(self::once())->method('recordClick')->with(7, 11, false, self::anything()); + $this->umlClickRepository->expects(self::once()) + ->method('recordClick') + ->with(7, 3, 11, false, self::anything()); + + self::assertNotNull($this->service->trackClick($this->tid('T'), null, [])); + } + + public function testOnlyTheFirstClickCountsAsView(): void + { + $this->givenKnownLink(); + $this->givenUserMessage(viewed: true); + + $this->userMessageService->expects(self::never())->method('trackUserMessageView'); + + self::assertNotNull($this->service->trackClick($this->tid(), null, [])); + } + + public function testPersonalisedLinkGetsTheSubscribersUid(): void + { + $this->givenKnownLink('https://example.com/p?a=1', personalise: true); + $this->givenUserMessage(viewed: true); + + self::assertSame('https://example.com/p?a=1&uid=uniq-3', $this->service->trackClick($this->tid(), null, [])); + } + + public function testPersonalisedLinkIsRefusedForSubscribersThatNeverGotTheCampaign(): void + { + $this->givenKnownLink('https://example.com/p', personalise: true); + $this->userMessageRepository->method('findByUserAndMessage')->willReturn(null); + + $this->mlRepository->expects(self::never())->method('recordClick'); + + self::assertNull($this->service->trackClick($this->tid(), null, [])); + } + + public function testNonPersonalisedLinkWorksWithoutUserMessage(): void + { + $this->givenKnownLink(); + $this->userMessageRepository->method('findByUserAndMessage')->willReturn(null); + + $this->userMessageService->expects(self::never())->method('trackUserMessageView'); + + self::assertSame('https://example.com/a', $this->service->trackClick($this->tid(), null, [])); + } + + public function testInvalidTokenIsRejected(): void + { + $this->forwardRepository->expects(self::never())->method('findOneByUuid'); + + self::assertNull($this->service->trackClick('garbage', null, [])); + } + + public function testUnknownForwardIsRejected(): void + { + $this->forwardRepository->method('findOneByUuid')->willReturn(null); + $this->mlRepository->expects(self::never())->method('recordClick'); + + self::assertNull($this->service->trackClick($this->tid(), null, [])); + } + + public function testUnknownSubscriberIsRejected(): void + { + $forward = $this->createMock(LinkTrackForward::class); + $forward->method('getUrl')->willReturn('https://example.com/a'); + $this->forwardRepository->method('findOneByUuid')->willReturn($forward); + $this->messageRepository->method('findOneBy')->willReturn($this->message); + $this->subscriberRepository->method('findOneBy')->willReturn(null); + $this->mlRepository->expects(self::never())->method('recordClick'); + + self::assertNull($this->service->trackClick($this->tid(), null, [])); + } + + public function testNeverRedirectsToNonWebUrls(): void + { + $this->givenKnownLink('javascript:alert(1)'); + $this->mlRepository->expects(self::never())->method('recordClick'); + + self::assertNull($this->service->trackClick($this->tid(), null, [])); + } +} diff --git a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php index c7495f99..7457c0bb 100644 --- a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php @@ -4,257 +4,243 @@ namespace PhpList\Core\Tests\Unit\Domain\Analytics\Service; -use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Core\ParameterProvider; -use PhpList\Core\Domain\Analytics\Exception\MissingMessageIdException; -use PhpList\Core\Domain\Analytics\Model\LinkTrack; +use PhpList\Core\Domain\Analytics\Model\LinkTrackForward; +use PhpList\Core\Domain\Analytics\Repository\LinkTrackForwardRepository; +use PhpList\Core\Domain\Analytics\Repository\LinkTrackMlRepository; use PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository; use PhpList\Core\Domain\Analytics\Service\LinkTrackService; +use PhpList\Core\Domain\Analytics\Service\LinkTrackTokenService; use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Model\Message; -use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; +use PhpList\Core\Domain\Subscription\Model\Subscriber; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class LinkTrackServiceTest extends TestCase { - private LinkTrackService $subject; - private LinkTrackRepository|MockObject $linkTrackRepository; + private LinkTrackForwardRepository&MockObject $forwardRepository; + private LinkTrackMlRepository&MockObject $mlRepository; + private LinkTrackTokenService $tokenService; + private Message&MockObject $campaign; + private Subscriber $subscriber; + /** @var array */ + private array $createdForwards = []; protected function setUp(): void { - $this->linkTrackRepository = $this->createMock(LinkTrackRepository::class); - $paramProvider = $this->createMock(ParameterProvider::class); + $this->forwardRepository = $this->createMock(LinkTrackForwardRepository::class); + $this->mlRepository = $this->createMock(LinkTrackMlRepository::class); + $this->tokenService = new LinkTrackTokenService( + new ParameterProvider([]), + 'https://example.com/api/v2', + 'secret' + ); + + $this->campaign = $this->createMock(Message::class); + $this->campaign->method('getId')->willReturn(7); + $this->campaign->method('getUuid')->willReturn('campaign-uuid'); + + $this->subscriber = new Subscriber('alice@example.com'); + $this->subscriber->setUuid('subscriber-uuid'); - $paramProvider->method('get') - ->with('click_track', false) - ->willReturn(true); + $this->forwardRepository->method('findOrCreateByUrl')->willReturnCallback( + function (string $url, bool $personalise): LinkTrackForward { + $this->createdForwards[$url] = ['url' => $url, 'personalise' => $personalise]; + $id = array_search($url, array_keys($this->createdForwards), true) + 1; - $this->subject = new LinkTrackService( - linkTrackRepository: $this->linkTrackRepository, - paramProvider: $paramProvider, - entityManager: $this->createMock(EntityManagerInterface::class) + return $this->createForward($id, $url); + } ); } - public function testExtractAndSaveLinksWithNoLinks(): void + private function createForward(int $id, string $url): LinkTrackForward&MockObject { - $messageId = 123; - $userId = 456; + $forward = $this->createMock(LinkTrackForward::class); + $forward->method('getId')->willReturn($id); + $forward->method('getUuid')->willReturn('forward-uuid-' . $id); + $forward->method('getUrl')->willReturn($url); - $messageContent = new MessagePrecacheDto(); - $messageContent->subject = 'Test Subject'; - $messageContent->content = 'No links here'; + return $forward; + } - $message = $this->createMock(Message::class); - $message->method('getId')->willReturn($messageId); - $message->method('getContent')->willReturn(new MessageContent('Test Subject', 'No links here')); + private function createService(bool $clickTrack = true): LinkTrackService + { + return new LinkTrackService( + $this->createMock(LinkTrackRepository::class), + $this->forwardRepository, + $this->mlRepository, + $this->tokenService, + new ParameterProvider(['click_track' => $clickTrack]), + ); + } - $this->linkTrackRepository->expects(self::never())->method('persist'); + /** @return string[] the uuids found in all tracking links of the text, in order */ + private function trackedForwardUuids(string $text): array + { + preg_match_all('~/lt\?tid=([A-Za-z0-9_-]+)~', $text, $matches); - $result = $this->subject->extractAndSaveLinks($messageContent, $userId, $messageId); + return array_map( + fn (string $tid): string => $this->tokenService->parse($tid)->forwardUuid, + $matches[1] + ); + } - self::assertEmpty($result); + public function testIsClickTrackingEnabledFollowsTheParameter(): void + { + self::assertTrue($this->createService(true)->isClickTrackingEnabled()); + self::assertFalse($this->createService(false)->isClickTrackingEnabled()); } - public function testExtractAndSaveLinksWithLinks(): void + public function testContentIsReturnedUntouchedWhenClickTrackingIsDisabled(): void { - $messageId = 123; - $userId = 456; - $htmlContent = '

Check out this link and ' - . 'this one.

'; - - $messageContent = new MessageContent('Test Subject', $htmlContent); - $messagePrecacheDto = new MessagePrecacheDto(); - $messagePrecacheDto->subject = 'Test Subject'; - $messagePrecacheDto->content = $htmlContent; - - $message = $this->createMock(Message::class); - $message->method('getId')->willReturn($messageId); - $message->method('getContent')->willReturn($messageContent); - - $this->linkTrackRepository->expects(self::exactly(2)) - ->method('persist') - ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) { - self::assertSame($messageId, $linkTrack->getMessageId()); - self::assertSame($userId, $linkTrack->getUserId()); - self::assertContains($linkTrack->getUrl(), ['https://example.com', 'https://test.com']); - return null; - }); + $content = new MessagePrecacheDto(); + $content->content = 'x'; + + $this->forwardRepository->expects(self::never())->method('findOrCreateByUrl'); - $result = $this->subject->extractAndSaveLinks($messagePrecacheDto, $userId, $messageId); + $result = $this->createService(false)->trackLinks($content, $this->campaign, $this->subscriber); - self::assertCount(2, $result); - self::assertSame('https://example.com', $result[0]->getUrl()); - self::assertSame('https://test.com', $result[1]->getUrl()); + self::assertSame($content, $result); } - public function testExtractAndSaveLinksWithFooter(): void + public function testContentIsReturnedUntouchedWhenCampaignHasNoUuid(): void { - $messageId = 123; - $userId = 456; - $htmlContent = '

Main content with a link.

'; - $footerContent = '

Footer with another link.

'; - - $messageContent = new MessageContent('Test Subject', $htmlContent, null, $footerContent); - $messagePrecacheDto = new MessagePrecacheDto(); - $messagePrecacheDto->subject = 'Test Subject'; - $messagePrecacheDto->content = $htmlContent; - $messagePrecacheDto->htmlFooter = $footerContent; - - $message = $this->createMock(Message::class); - $message->method('getId')->willReturn($messageId); - $message->method('getContent')->willReturn($messageContent); - - $this->linkTrackRepository->expects(self::exactly(2)) - ->method('persist') - ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) { - self::assertSame($messageId, $linkTrack->getMessageId()); - self::assertSame($userId, $linkTrack->getUserId()); - self::assertContains($linkTrack->getUrl(), ['https://example.com', 'https://footer.com']); - return null; - }); - - $result = $this->subject->extractAndSaveLinks($messagePrecacheDto, $userId, $messageId); + $campaign = $this->createMock(Message::class); + $campaign->method('getUuid')->willReturn(null); + $content = new MessagePrecacheDto(); + $content->content = 'x'; - self::assertCount(2, $result); - self::assertSame('https://example.com', $result[0]->getUrl()); - self::assertSame('https://footer.com', $result[1]->getUrl()); + self::assertSame($content, $this->createService()->trackLinks($content, $campaign, $this->subscriber)); } - public function testExtractAndSaveLinksWithDuplicateLinks(): void + public function testHtmlLinksAreReplacedWithTrackingLinks(): void { - $messageId = 123; - $userId = 456; - $htmlContent = '

Link 1 and Link 2.

'; - - $messageContent = new MessageContent('Test Subject', $htmlContent); - $messagePrecacheDto = new MessagePrecacheDto(); - $messagePrecacheDto->subject = 'Test Subject'; - $messagePrecacheDto->content = $htmlContent; - - $message = $this->createMock(Message::class); - $message->method('getId')->willReturn($messageId); - $message->method('getContent')->willReturn($messageContent); - - $this->linkTrackRepository->expects(self::once()) - ->method('persist') - ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) { - self::assertSame($messageId, $linkTrack->getMessageId()); - self::assertSame($userId, $linkTrack->getUserId()); - self::assertSame('https://example.com', $linkTrack->getUrl()); - return null; - }); + $content = new MessagePrecacheDto(); + $content->content = '

A ' + . "

"; + + $result = $this->createService()->trackLinks($content, $this->campaign, $this->subscriber); + + self::assertStringContainsString('A', $result->content); + self::assertStringContainsString("content); + self::assertArrayHasKey('https://example.com/a?x=1&y=2', $this->createdForwards, 'stored url is decoded'); + self::assertArrayHasKey('http://example.org/b', $this->createdForwards); + } - $result = $this->subject->extractAndSaveLinks($messagePrecacheDto, $userId, $messageId); + public function testTrackingLinkIdentifiesForwardCampaignAndSubscriber(): void + { + $content = new MessagePrecacheDto(); + $content->content = 'A'; + + $result = $this->createService()->trackLinks($content, $this->campaign, $this->subscriber); + + preg_match('~tid=([A-Za-z0-9_-]+)~', $result->content, $match); + $token = $this->tokenService->parse($match[1]); + self::assertNotNull($token); + self::assertTrue($token->isHtml()); + self::assertSame('forward-uuid-1', $token->forwardUuid); + self::assertSame('campaign-uuid', $token->messageUuid); + self::assertSame('subscriber-uuid', $token->subscriberUuid); + } - self::assertCount(1, $result); - self::assertSame('https://example.com', $result[0]->getUrl()); + public function testLinksThatMustNotBeTrackedAreLeftAlone(): void + { + $html = implode(' ', [ + 'mail', + 'unsubscribe', + 'placeholder', + 'script', + 'https://example.com/other', + 'too long', + 'already tracked', + 'no href', + ]); + $content = new MessagePrecacheDto(); + $content->content = $html; + + $this->forwardRepository->expects(self::never())->method('findOrCreateByUrl'); + $this->mlRepository->expects(self::never())->method('incrementTotal'); + + $result = $this->createService()->trackLinks($content, $this->campaign, $this->subscriber); + + self::assertSame($html, $result->content); } - public function testExtractAndSaveLinksWithNullText(): void + public function testPersonalUidAndSessionParametersAreStrippedAndMarkTheForwardPersonal(): void { - $messageId = 123; - $userId = 456; - $footerContent = '

Footer with a link.

'; - - $messageContent = new MessageContent('Test Subject', null, null, $footerContent); - $messagePrecacheDto = new MessagePrecacheDto(); - $messagePrecacheDto->subject = 'Test Subject'; - $messagePrecacheDto->htmlFooter = $footerContent; - - $message = $this->createMock(Message::class); - $message->method('getId')->willReturn($messageId); - $message->method('getContent')->willReturn($messageContent); - - $this->linkTrackRepository->expects(self::once()) - ->method('persist') - ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) { - self::assertSame($messageId, $linkTrack->getMessageId()); - self::assertSame($userId, $linkTrack->getUserId()); - self::assertSame('https://footer.com', $linkTrack->getUrl()); - return null; - }); + $content = new MessagePrecacheDto(); + $content->content = 'P'; - $result = $this->subject->extractAndSaveLinks($messagePrecacheDto, $userId, $messageId); + $this->createService()->trackLinks($content, $this->campaign, $this->subscriber); - self::assertCount(1, $result); - self::assertSame('https://footer.com', $result[0]->getUrl()); + self::assertSame( + ['https://example.com/p?a=1#top' => ['url' => 'https://example.com/p?a=1#top', 'personalise' => true]], + $this->createdForwards + ); } - public function testExtractAndSaveLinksWithMessageWithoutId(): void + public function testTextUrlsAreReplacedWithTextTokens(): void { - $userId = 456; - $htmlContent = '

Link

'; + $content = new MessagePrecacheDto(); + $content->textContent = "Read https://example.com/a. Or see http://example.org/b?x=1\nbye"; + $content->textFooter = 'More: https://example.com/a'; - $messageContent = new MessageContent('Test Subject', $htmlContent); - $messagePrecacheDto = new MessagePrecacheDto(); - $messagePrecacheDto->subject = 'Test Subject'; - $messagePrecacheDto->content = $htmlContent; + $result = $this->createService()->trackLinks($content, $this->campaign, $this->subscriber); - $message = $this->createMock(Message::class); - $message->method('getId')->willReturn(null); - $message->method('getContent')->willReturn($messageContent); - - $this->expectException(MissingMessageIdException::class); - $this->expectExceptionMessage('Message must have an ID'); - - $this->subject->extractAndSaveLinks($messagePrecacheDto, $userId, $message->getId()); + self::assertMatchesRegularExpression( + '~^Read https://example.com/api/v2/lt\?tid=[A-Za-z0-9_-]+\. Or see https://example.com/api/v2/lt\?tid=~', + $result->textContent + ); + preg_match('~tid=([A-Za-z0-9_-]+)~', $result->textContent, $match); + self::assertSame('T', $this->tokenService->parse($match[1])->type); + self::assertSame( + [$this->trackedForwardUuids($result->textContent)[0]], + $this->trackedForwardUuids((string) $result->textFooter), + 'the same url gets the same forward' + ); + self::assertSame("\nbye", substr($result->textContent, -4)); } - public function testIsExtractAndSaveLinksApplicableWhenClickTrackIsTrue(): void + public function testFootersAreTrackedToo(): void { - self::assertTrue($this->subject->isExtractAndSaveLinksApplicable()); - } + $content = new MessagePrecacheDto(); + $content->htmlFooter = 'Footer'; - public function testIsExtractAndSaveLinksApplicableWhenClickTrackIsFalse(): void - { - $paramProvider = $this->createMock(ParameterProvider::class); - $paramProvider->method('get') - ->with('click_track', false) - ->willReturn(false); - - $subject = new LinkTrackService( - linkTrackRepository: $this->linkTrackRepository, - paramProvider: $paramProvider, - entityManager: $this->createMock(EntityManagerInterface::class) - ); + $result = $this->createService()->trackLinks($content, $this->campaign, $this->subscriber); - self::assertFalse($subject->isExtractAndSaveLinksApplicable()); + self::assertSame(['forward-uuid-1'], $this->trackedForwardUuids($result->htmlFooter)); } - public function testExtractAndSaveLinksWithExistingLink(): void + public function testTotalIsIncrementedOncePerDistinctLinkOfTheEmail(): void { - $messageId = 123; - $userId = 456; - $url = 'https://example.com'; - $htmlContent = '

Check out this link.

'; - - $messageContent = new MessageContent('Test Subject', $htmlContent); - - $message = $this->createMock(Message::class); - $message->method('getId')->willReturn($messageId); - $message->method('getContent')->willReturn($messageContent); - $messagePrecacheDto = new MessagePrecacheDto(); - $messagePrecacheDto->subject = 'Test Subject'; - $messagePrecacheDto->content = $htmlContent; + $content = new MessagePrecacheDto(); + $content->content = '12' + . '3'; + $content->textContent = 'https://example.com/a'; + + $incremented = []; + $this->mlRepository->expects(self::exactly(2)) + ->method('incrementTotal') + ->willReturnCallback(function (int $messageId, int $forwardId) use (&$incremented): void { + $incremented[] = [$messageId, $forwardId]; + }); - $existingLinkTrack = new LinkTrack(); - $existingLinkTrack->setMessageId($messageId); - $existingLinkTrack->setUserId($userId); - $existingLinkTrack->setUrl($url); + $this->createService()->trackLinks($content, $this->campaign, $this->subscriber); - $this->linkTrackRepository->expects(self::once()) - ->method('findByUrlUserIdAndMessageId') - ->with($url, $userId, $messageId) - ->willReturn($existingLinkTrack); + self::assertSame([[7, 1], [7, 2]], $incremented); + } - $this->linkTrackRepository->expects(self::never()) - ->method('persist'); + public function testTheGivenContentIsNotModified(): void + { + $content = new MessagePrecacheDto(); + $content->content = 'A'; - $result = $this->subject->extractAndSaveLinks($messagePrecacheDto, $userId, $messageId); + $result = $this->createService()->trackLinks($content, $this->campaign, $this->subscriber); - self::assertCount(1, $result); - self::assertSame($existingLinkTrack, $result[0]); + self::assertNotSame($content, $result); + self::assertSame('A', $content->content); } } diff --git a/tests/Unit/Domain/Analytics/Service/LinkTrackTokenServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkTrackTokenServiceTest.php new file mode 100644 index 00000000..e97f1fc5 --- /dev/null +++ b/tests/Unit/Domain/Analytics/Service/LinkTrackTokenServiceTest.php @@ -0,0 +1,95 @@ + $sign]), + 'https://example.com/api/v2/', + $secret + ); + } + + /** @return array */ + private function queryOf(string $url): array + { + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + + return $query; + } + + public function testCreatesAbsoluteUrlToTheLinkTrackEndpoint(): void + { + $url = $this->createService()->createUrl(new LinkTrackToken('H', 'fwd-1', 'msg-2', 'usr-3')); + + self::assertStringStartsWith('https://example.com/api/v2/lt?tid=', $url); + self::assertSame(['tid'], array_keys($this->queryOf($url))); + } + + public function testParsesWhatItCreated(): void + { + $service = $this->createService(); + $url = $service->createUrl(new LinkTrackToken('T', 'fwd-1', 'msg-2', 'usr-3')); + + $token = $service->parse($this->queryOf($url)['tid']); + + self::assertNotNull($token); + self::assertSame('T', $token->type); + self::assertFalse($token->isHtml()); + self::assertSame('fwd-1', $token->forwardUuid); + self::assertSame('msg-2', $token->messageUuid); + self::assertSame('usr-3', $token->subscriberUuid); + } + + /** @dataProvider malformedTokenProvider */ + public function testRejectsMalformedTokens(string $tid): void + { + self::assertNull($this->createService()->parse($tid)); + } + + public static function malformedTokenProvider(): array + { + $encode = static fn (string $value): string => rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + + return [ + 'not base64' => ['!!!'], + 'too few parts' => [$encode('H|a|b')], + 'too many parts' => [$encode('H|a|b|c|d')], + 'unknown type' => [$encode('X|a|b|c')], + 'illegal characters' => [$encode("H|a|b|c'; drop table")], + 'empty uuid' => [$encode('H||b|c')], + ]; + } + + public function testSignedUrlsAreVerified(): void + { + $service = $this->createService(sign: true); + $query = $this->queryOf($service->createUrl(new LinkTrackToken('H', 'f', 'm', 'u'))); + + self::assertArrayHasKey('hm', $query); + self::assertNotNull($service->parse($query['tid'], $query['hm'])); + self::assertNull($service->parse($query['tid']), 'missing signature'); + self::assertNull($service->parse($query['tid'], 'wrong'), 'wrong signature'); + self::assertNull( + $this->createService(sign: true, secret: 'other')->parse($query['tid'], $query['hm']), + 'signature of another secret' + ); + } + + public function testUnsignedUrlsHaveNoSignature(): void + { + $query = $this->queryOf($this->createService()->createUrl(new LinkTrackToken('H', 'f', 'm', 'u'))); + + self::assertArrayNotHasKey('hm', $query); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php b/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php index 28891f78..65bcc57e 100644 --- a/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php @@ -11,7 +11,6 @@ use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Service\ForwardContentService; -use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; use PhpList\Core\Domain\Messaging\Service\Builder\ForwardEmailBuilder; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PHPUnit\Framework\MockObject\MockObject; @@ -22,13 +21,11 @@ class ForwardContentServiceTest extends TestCase { private CacheInterface&MockObject $cache; - private MessageProcessingPreparator&MockObject $preparator; private ForwardEmailBuilder&MockObject $builder; protected function setUp(): void { $this->cache = $this->createMock(CacheInterface::class); - $this->preparator = $this->createMock(MessageProcessingPreparator::class); $this->builder = $this->createMock(ForwardEmailBuilder::class); } @@ -36,7 +33,6 @@ public function testThrowsWhenCacheMissing(): void { $service = new ForwardContentService( cache: $this->cache, - messagePreparator: $this->preparator, forwardEmailBuilder: $this->builder, ); @@ -66,11 +62,10 @@ public function testThrowsWhenCacheMissing(): void ); } - public function testProcessesLinksAndDelegatesToBuilder(): void + public function testDelegatesCachedContentToBuilderWithoutClickTracking(): void { $service = new ForwardContentService( cache: $this->cache, - messagePreparator: $this->preparator, forwardEmailBuilder: $this->builder, ); @@ -80,7 +75,6 @@ public function testProcessesLinksAndDelegatesToBuilder(): void $subscriber->setHtmlEmail(true); $cached = new MessagePrecacheDto(); - $processed = new MessagePrecacheDto(); $this->cache ->expects(self::once()) @@ -88,16 +82,6 @@ public function testProcessesLinksAndDelegatesToBuilder(): void ->with('messaging.message.base.42.1.0') ->willReturn($cached); - $this->preparator - ->expects(self::once()) - ->method('processMessageLinks') - ->with( - 42, - $cached, - $subscriber - ) - ->willReturn($processed); - $expectedEmail = new Email(); $this->builder ->expects(self::once()) @@ -106,7 +90,7 @@ public function testProcessesLinksAndDelegatesToBuilder(): void 42, 'f@example.com', $subscriber, - $processed, + $cached, true, 'From Name', 'from@example.com', diff --git a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php index 4cd2e800..08ffa892 100644 --- a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php +++ b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php @@ -4,7 +4,6 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; -use PhpList\Core\Domain\Analytics\Model\LinkTrack; use PhpList\Core\Domain\Analytics\Service\LinkTrackService; use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; @@ -116,87 +115,52 @@ public function testEnsureCampaignsHaveUuidWithCampaigns(): void $this->preparator->ensureCampaignsHaveUuid($this->output); } - public function testProcessMessageLinksWhenLinkTrackingNotApplicable(): void + public function testProcessMessageLinksWhenClickTrackingIsDisabled(): void { $messageContent = new MessagePrecacheDto(); $subscriber = $this->createMock(Subscriber::class); - $subscriber->method('getId')->willReturn(123); - $subscriber->method('getEmail')->willReturn('test@example.com'); $this->linkTrackService->expects($this->once()) - ->method('isExtractAndSaveLinksApplicable') + ->method('isClickTrackingEnabled') ->willReturn(false); - - $this->linkTrackService->expects($this->never()) - ->method('extractAndSaveLinks'); + $this->messageRepository->expects($this->never())->method('findById'); + $this->linkTrackService->expects($this->never())->method('trackLinks'); $result = $this->preparator->processMessageLinks(1, $messageContent, $subscriber); $this->assertSame($messageContent, $result); } - public function testProcessMessageLinksWhenNoLinksExtracted(): void + public function testProcessMessageLinksWhenCampaignIsUnknown(): void { - $messageId = 1; $messageContent = new MessagePrecacheDto(); $subscriber = $this->createMock(Subscriber::class); - $subscriber->method('getId')->willReturn(123); - $subscriber->method('getEmail')->willReturn('test@example.com'); - - $this->linkTrackService->expects($this->once()) - ->method('isExtractAndSaveLinksApplicable') - ->willReturn(true); - $this->linkTrackService->expects($this->once()) - ->method('extractAndSaveLinks') - ->with($messageContent, 123, $messageId) - ->willReturn([]); + $this->linkTrackService->method('isClickTrackingEnabled')->willReturn(true); + $this->messageRepository->expects($this->once())->method('findById')->with(1)->willReturn(null); + $this->linkTrackService->expects($this->never())->method('trackLinks'); - $result = $this->preparator->processMessageLinks($messageId, $messageContent, $subscriber); + $result = $this->preparator->processMessageLinks(1, $messageContent, $subscriber); $this->assertSame($messageContent, $result); } - public function testProcessMessageLinksWithLinksExtracted(): void + public function testProcessMessageLinksReturnsTheContentWithTrackedLinks(): void { - $content = new MessagePrecacheDto(); + $messageContent = new MessagePrecacheDto(); + $tracked = new MessagePrecacheDto(); $subscriber = $this->createMock(Subscriber::class); - $subscriber->method('getId')->willReturn(123); - $subscriber->method('getEmail')->willReturn('test@example.com'); - - $linkTrack1 = $this->createMock(LinkTrack::class); - $linkTrack1->method('getId')->willReturn(1); - $linkTrack1->method('getUrl')->willReturn('https://example.com'); + $campaign = $this->createMock(Message::class); - $linkTrack2 = $this->createMock(LinkTrack::class); - $linkTrack2->method('getId')->willReturn(2); - $linkTrack2->method('getUrl')->willReturn('https://example.org'); - - $savedLinks = [$linkTrack1, $linkTrack2]; - - $this->linkTrackService->method('isExtractAndSaveLinksApplicable')->willReturn(true); - $this->linkTrackService - ->method('extractAndSaveLinks') - ->with($content, 123, 1) - ->willReturn($savedLinks); - - $content->content = 'Link 1 Link 2'; - $content->htmlFooter = 'Footer Link'; + $this->linkTrackService->method('isClickTrackingEnabled')->willReturn(true); + $this->messageRepository->method('findById')->with(1)->willReturn($campaign); + $this->linkTrackService->expects($this->once()) + ->method('trackLinks') + ->with($messageContent, $campaign, $subscriber) + ->willReturn($tracked); - $result = $this->preparator->processMessageLinks(1, $content, $subscriber); + $result = $this->preparator->processMessageLinks(1, $messageContent, $subscriber); - $this->assertSame($content, $result); - $this->assertStringContainsString( - MessageProcessingPreparator::LINK_TRACK_ENDPOINT . '?id=1', - $content->content - ); - $this->assertStringContainsString( - MessageProcessingPreparator::LINK_TRACK_ENDPOINT . '?id=2', - $content->content - ); - $this->assertStringContainsString( - MessageProcessingPreparator::LINK_TRACK_ENDPOINT . '?id=1', - $content->htmlFooter - ); + $this->assertSame($tracked, $result); } } From ee412fd3af8a415bb286527ed482ab2fb35c5de4 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 27 Oct 2026 15:39:08 +0400 Subject: [PATCH 10/46] ref: improve security in FileHelper --- src/Domain/Common/FileHelper.php | 35 ++++++++++----------- tests/Unit/Domain/Common/FileHelperTest.php | 2 +- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/Domain/Common/FileHelper.php b/src/Domain/Common/FileHelper.php index 122c804a..27a7ed93 100644 --- a/src/Domain/Common/FileHelper.php +++ b/src/Domain/Common/FileHelper.php @@ -35,37 +35,36 @@ public function readFileContents(string $path): ?string public function writeFileToDirectory(string $directory, string $originalFilename, string $contents): ?string { - // tempnam() silently falls back to the system temp dir when the directory is unusable if (!is_dir($directory) || !is_writable($directory)) { return null; } - $pathInfo = pathinfo($originalFilename); - $name = $pathInfo['filename'] === '' ? 'file' : $pathInfo['filename']; - $ext = $pathInfo['extension'] ?? ''; - - $tempFile = tempnam($directory, $name); - if ($tempFile === false) { + $realDirectory = realpath($directory); + if ($realDirectory === false) { return null; } - if (dirname((string) realpath($tempFile)) !== realpath($directory)) { - unlink($tempFile); - + // Untrusted writers could swap entries in the directory between checks and writes + $perms = fileperms($realDirectory); + if ($perms === false || (($perms & 0022) !== 0 && ($perms & 01000) === 0)) { return null; } - $fullPath = $directory . '/' . basename($tempFile); - if ($ext !== '') { - $fullPath .= '.' . $ext; - if (!rename($tempFile, $fullPath)) { - unlink($tempFile); + $pathInfo = pathinfo($originalFilename); + $name = $pathInfo['filename'] === '' ? 'file' : $pathInfo['filename']; + $ext = $pathInfo['extension'] ?? ''; - return null; - } + // Exclusive create (O_EXCL) never follows a symlink or reuses an existing entry, + // so the file is created and written in a single step without rename. + $fullPath = $realDirectory . '/' . $name . bin2hex(random_bytes(6)) . ($ext !== '' ? '.' . $ext : ''); + $handle = fopen($fullPath, 'xb'); + if ($handle === false) { + return null; } - if (file_put_contents($fullPath, $contents) !== strlen($contents)) { + $written = fwrite($handle, $contents); + fclose($handle); + if ($written !== strlen($contents)) { unlink($fullPath); return null; diff --git a/tests/Unit/Domain/Common/FileHelperTest.php b/tests/Unit/Domain/Common/FileHelperTest.php index fae886d4..f9f231d9 100644 --- a/tests/Unit/Domain/Common/FileHelperTest.php +++ b/tests/Unit/Domain/Common/FileHelperTest.php @@ -14,7 +14,7 @@ class FileHelperTest extends TestCase protected function setUp(): void { $this->tmpDir = sys_get_temp_dir() . '/phplist-filehelper-' . bin2hex(random_bytes(6)); - mkdir($this->tmpDir, 0777, true); + mkdir($this->tmpDir, 0700, true); } protected function tearDown(): void From 6cbdced3ed15e73c5bd16005713faf5592c47331 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 27 Oct 2026 15:48:03 +0400 Subject: [PATCH 11/46] ref: use executeStatement instead of flush --- .../Repository/LinkTrackForwardRepository.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Domain/Analytics/Repository/LinkTrackForwardRepository.php b/src/Domain/Analytics/Repository/LinkTrackForwardRepository.php index 85ec05eb..1db61194 100644 --- a/src/Domain/Analytics/Repository/LinkTrackForwardRepository.php +++ b/src/Domain/Analytics/Repository/LinkTrackForwardRepository.php @@ -43,8 +43,21 @@ public function findOrCreateByUrl(string $url, bool $personalise): LinkTrackForw } if (empty($forward->getUuid())) { - $forward->setUuid(self::generateUuid()); - $this->getEntityManager()->flush(); + $metadata = $this->getClassMetadata(); + $uuidColumn = $metadata->getColumnName('uuid'); + // only fills an empty uuid, so a worker that got there first is never overwritten + $this->getEntityManager()->getConnection()->executeStatement( + sprintf( + 'UPDATE %s SET %s = :uuid WHERE %s = :id AND (%s IS NULL OR %s = \'\')', + $metadata->getTableName(), + $uuidColumn, + $metadata->getColumnName('id'), + $uuidColumn, + $uuidColumn + ), + ['uuid' => self::generateUuid(), 'id' => $forward->getId()], + ); + $this->getEntityManager()->refresh($forward); } return $forward; From 8f307403de301fce96189afcf15b2c9fa285a4a2 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 27 Oct 2026 15:49:59 +0400 Subject: [PATCH 12/46] fix: ensure UID is added before fragment in personalized links --- .../Analytics/Service/LinkClickService.php | 6 ++++- .../Service/LinkClickServiceTest.php | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/Domain/Analytics/Service/LinkClickService.php b/src/Domain/Analytics/Service/LinkClickService.php index 75ad3d9c..538f2dd3 100644 --- a/src/Domain/Analytics/Service/LinkClickService.php +++ b/src/Domain/Analytics/Service/LinkClickService.php @@ -96,7 +96,11 @@ private function resolveUrl(LinkTrackForward $forward, Subscriber $subscriber, ? if ($userMessage === null) { return null; } - $url .= (str_contains($url, '?') ? '&' : '?') . 'uid=' . $subscriber->getUniqueId(); + // the uid has to go before any fragment, which is never sent to the server + $fragmentPos = strpos($url, '#'); + $fragment = $fragmentPos === false ? '' : substr($url, $fragmentPos); + $base = $fragmentPos === false ? $url : substr($url, 0, $fragmentPos); + $url = $base . (str_contains($base, '?') ? '&' : '?') . 'uid=' . $subscriber->getUniqueId() . $fragment; } return preg_match('~^(https?|ftp)://~i', $url) ? $url : null; diff --git a/tests/Unit/Domain/Analytics/Service/LinkClickServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkClickServiceTest.php index 370aefa7..5b59986d 100644 --- a/tests/Unit/Domain/Analytics/Service/LinkClickServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/LinkClickServiceTest.php @@ -144,6 +144,28 @@ public function testPersonalisedLinkGetsTheSubscribersUid(): void self::assertSame('https://example.com/p?a=1&uid=uniq-3', $this->service->trackClick($this->tid(), null, [])); } + public function testPersonalisedLinkPutsTheUidBeforeTheFragment(): void + { + $this->givenKnownLink('https://example.com/p?a=1#top', personalise: true); + $this->givenUserMessage(viewed: true); + + self::assertSame( + 'https://example.com/p?a=1&uid=uniq-3#top', + $this->service->trackClick($this->tid(), null, []) + ); + } + + public function testPersonalisedLinkWithOnlyAFragmentStartsTheQueryBeforeIt(): void + { + $this->givenKnownLink('https://example.com/p#a?b', personalise: true); + $this->givenUserMessage(viewed: true); + + self::assertSame( + 'https://example.com/p?uid=uniq-3#a?b', + $this->service->trackClick($this->tid(), null, []) + ); + } + public function testPersonalisedLinkIsRefusedForSubscribersThatNeverGotTheCampaign(): void { $this->givenKnownLink('https://example.com/p', personalise: true); From e37ba434743b858e9a3c32c1379f8d9b41ee17aa Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 27 Oct 2026 15:51:40 +0400 Subject: [PATCH 13/46] feat: add LIST_FIELDS constant and handle null data for target and exclude lists --- .../Messaging/Service/MessageDataLoader.php | 8 ++++- .../Service/MessageDataLoaderTest.php | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/Domain/Messaging/Service/MessageDataLoader.php b/src/Domain/Messaging/Service/MessageDataLoader.php index 24a36e26..c1db86b5 100644 --- a/src/Domain/Messaging/Service/MessageDataLoader.php +++ b/src/Domain/Messaging/Service/MessageDataLoader.php @@ -14,6 +14,7 @@ class MessageDataLoader { private const AS_FORMAT_FIELDS = ['astext', 'ashtml', 'astextandhtml', 'aspdf', 'astextandpdf']; + private const LIST_FIELDS = ['targetlist', 'excludelist']; private const SCHEDULE_FIELDS = ['embargo', 'repeatuntil', 'requeueuntil']; private ?string $messageFromAddress; @@ -119,7 +120,12 @@ private function mergeStoredMessageData(array &$messageData, Message $message): { $storedMessageData = $this->messageDataRepository->getForMessage($message->getId()); foreach ($storedMessageData as $storedMessageDatum) { - $rawData = $storedMessageDatum->getData() ?? ''; + $rawData = $storedMessageDatum->getData(); + if ($rawData === null && in_array($storedMessageDatum->getName(), self::LIST_FIELDS, true)) { + // keep the array defaults, a string here would break populateTargetLists + continue; + } + $rawData ??= ''; if (str_starts_with($rawData, 'SER:')) { $unserialized = unserialize(substr($rawData, 4), ['allowed_classes' => false]); if (!is_array($unserialized)) { diff --git a/tests/Unit/Domain/Messaging/Service/MessageDataLoaderTest.php b/tests/Unit/Domain/Messaging/Service/MessageDataLoaderTest.php index f10a83db..b1446640 100644 --- a/tests/Unit/Domain/Messaging/Service/MessageDataLoaderTest.php +++ b/tests/Unit/Domain/Messaging/Service/MessageDataLoaderTest.php @@ -200,6 +200,40 @@ public function testStoredSerializedDataWithNonStringValuesAndNullDataDoNotBreak $this->assertSame('', $result['footer']); } + public function testNullTargetAndExcludeListDataKeepArrayDefaults(): void + { + $this->config->method('getValue')->willReturnMap([ + [ConfigOption::MessageFromAddress, 'from@example.com'], + ]); + $this->messageRepository->method('getNonEmptyFields')->willReturn([]); + + $targetList = (new MessageData())->setId(11)->setName('targetlist')->setData(null); + $excludeList = (new MessageData())->setId(11)->setName('excludelist')->setData(null); + $this->messageDataRepository->method('getForMessage')->willReturn([$targetList, $excludeList]); + + $listMock = $this->createMock(SubscriberList::class); + $listMock->method('getId')->willReturn(42); + $listMessageMock = $this->createMock(ListMessage::class); + $listMessageMock->method('getList')->willReturn($listMock); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn(11); + $message->method('getListMessages')->willReturn(new ArrayCollection([$listMessageMock])); + + $loader = new MessageDataLoader( + configProvider: $this->config, + messageDataRepository: $this->messageDataRepository, + messageRepository: $this->messageRepository, + logger: $this->createMock(LoggerInterface::class), + defaultMessageAge: 3600 + ); + + $result = ($loader)($message); + + $this->assertSame([42 => 1], $result['targetlist']); + $this->assertSame([], $result['excludelist']); + } + public static function fromFieldProvider(): array { return [ From 4e4c6d01d3c20c004f8e24bf0b8eb12f45a22c3e Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 27 Oct 2026 15:54:18 +0400 Subject: [PATCH 14/46] ref: to fix phpmd --- .../Service/LinkTrackTokenService.php | 30 ++++++++++++---- src/Domain/Common/FileHelper.php | 34 ++++++++++++------- 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/src/Domain/Analytics/Service/LinkTrackTokenService.php b/src/Domain/Analytics/Service/LinkTrackTokenService.php index 371f14d5..796432ea 100644 --- a/src/Domain/Analytics/Service/LinkTrackTokenService.php +++ b/src/Domain/Analytics/Service/LinkTrackTokenService.php @@ -48,9 +48,7 @@ public function createUrl(LinkTrackToken $token): string /** Returns null for malformed tokens and, when signing is enabled, for missing or wrong signatures. */ public function parse(string $tid, ?string $signature = null): ?LinkTrackToken { - if ($this->isSigningEnabled() - && ($signature === null || $signature === '' || !hash_equals($this->sign($tid), $signature)) - ) { + if (!$this->hasValidSignature($tid, $signature)) { return null; } @@ -60,17 +58,35 @@ public function parse(string $tid, ?string $signature = null): ?LinkTrackToken } $parts = explode('|', $decoded); - if (count($parts) !== 4 || !LinkTrackToken::isValidType($parts[0])) { + if (count($parts) !== 4 + || !LinkTrackToken::isValidType($parts[0]) + || !$this->areValidUuids(array_slice($parts, 1)) + ) { return null; } - foreach (array_slice($parts, 1) as $uuid) { + return new LinkTrackToken($parts[0], $parts[1], $parts[2], $parts[3]); + } + + private function hasValidSignature(string $tid, ?string $signature): bool + { + if (!$this->isSigningEnabled()) { + return true; + } + + return $signature !== null && $signature !== '' && hash_equals($this->sign($tid), $signature); + } + + /** @param string[] $uuids */ + private function areValidUuids(array $uuids): bool + { + foreach ($uuids as $uuid) { if (!preg_match('/^[A-Za-z0-9-]{1,64}$/', $uuid)) { - return null; + return false; } } - return new LinkTrackToken($parts[0], $parts[1], $parts[2], $parts[3]); + return true; } private function isSigningEnabled(): bool diff --git a/src/Domain/Common/FileHelper.php b/src/Domain/Common/FileHelper.php index 27a7ed93..7073abf8 100644 --- a/src/Domain/Common/FileHelper.php +++ b/src/Domain/Common/FileHelper.php @@ -35,18 +35,8 @@ public function readFileContents(string $path): ?string public function writeFileToDirectory(string $directory, string $originalFilename, string $contents): ?string { - if (!is_dir($directory) || !is_writable($directory)) { - return null; - } - - $realDirectory = realpath($directory); - if ($realDirectory === false) { - return null; - } - - // Untrusted writers could swap entries in the directory between checks and writes - $perms = fileperms($realDirectory); - if ($perms === false || (($perms & 0022) !== 0 && ($perms & 01000) === 0)) { + $realDirectory = $this->resolveTrustedDirectory($directory); + if ($realDirectory === null) { return null; } @@ -72,4 +62,24 @@ public function writeFileToDirectory(string $directory, string $originalFilename return $fullPath; } + + private function resolveTrustedDirectory(string $directory): ?string + { + if (!is_dir($directory) || !is_writable($directory)) { + return null; + } + + $realDirectory = realpath($directory); + if ($realDirectory === false) { + return null; + } + + // Untrusted writers could swap entries in the directory between checks and writes + $perms = fileperms($realDirectory); + if ($perms === false || (($perms & 0022) !== 0 && ($perms & 01000) === 0)) { + return null; + } + + return $realDirectory; + } } From b7eb0a82f7b5600a2ff3422a9ec8a7ae4aeeaf34 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 1 Nov 2026 20:19:15 +0400 Subject: [PATCH 15/46] feat: add APP_WEBSITE to environment configuration for server domain setting --- .env.dist | 3 +++ config/parameters.yml | 1 + config/services/providers.yml | 2 ++ .../Configuration/Model/ConfigOption.php | 1 - .../Provider/DefaultConfigProvider.php | 21 +++++++++++++++++-- .../Provider/DefaultConfigProviderTest.php | 8 +++++++ 6 files changed, 33 insertions(+), 3 deletions(-) diff --git a/.env.dist b/.env.dist index 07a41b6a..80161df9 100644 --- a/.env.dist +++ b/.env.dist @@ -24,6 +24,9 @@ APP_POWERED_BY_PHPLIST=0 PREFERENCEPAGE_SHOW_PRIVATE_LISTS=0 API_BASE_URL=http://api.phplist.local/ +# Website address without scheme (e.g. www.example.com). Used as the default of the "website" setting; +# the "domain" setting defaults to the same value without a leading "www.". Both are editable in the admin UI. +APP_WEBSITE=phplist.local FRONT_END_BASE_URL=http://frontend.phplist.local PARALLEL_USE_WITH_PHPLIST3=0 diff --git a/config/parameters.yml b/config/parameters.yml index 28d4ed9c..36860be3 100644 --- a/config/parameters.yml +++ b/config/parameters.yml @@ -23,6 +23,7 @@ parameters: app.rest_api_base_url: '%env(API_BASE_URL)%/api/v2' app.api_base_url: '%env(API_BASE_URL)%' app.frontend_base_url: '%env(FRONT_END_BASE_URL)%' + app.website: '%env(APP_WEBSITE)%' app.parallel_use_with_phplist3: '%env(PARALLEL_USE_WITH_PHPLIST3)%' diff --git a/config/services/providers.yml b/config/services/providers.yml index 481b23a3..830abb14 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -16,6 +16,8 @@ services: PhpList\Core\Domain\Configuration\Service\Provider\DefaultConfigProvider: autowire: true + arguments: + $website: '%app.website%' PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider: autowire: true diff --git a/src/Domain/Configuration/Model/ConfigOption.php b/src/Domain/Configuration/Model/ConfigOption.php index 1c4a3376..34a2b306 100644 --- a/src/Domain/Configuration/Model/ConfigOption.php +++ b/src/Domain/Configuration/Model/ConfigOption.php @@ -15,7 +15,6 @@ enum ConfigOption: string case ConfirmationUrl = 'confirmationurl'; case PreferencesUrl = 'preferencesurl'; case SubscribeUrl = 'subscribeurl'; - // todo: check where is this defined case Domain = 'domain'; case Website = 'website'; case MessageFromAddress = 'message_from_address'; diff --git a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php index 3222acc9..7d8b0f3d 100644 --- a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php @@ -16,8 +16,10 @@ class DefaultConfigProvider */ private array $defaults = []; - public function __construct(private TranslatorInterface $translator) - { + public function __construct( + private readonly TranslatorInterface $translator, + private readonly string $website = '', + ) { } /** @SuppressWarnings("PHPMD.ExcessiveMethodLength") */ @@ -31,6 +33,21 @@ private function init(): void $pageRoot = '/api/v2'; $this->defaults = [ + 'website' => [ + 'value' => $this->website, + 'description' => $this->translator->trans('Website address (without http://)'), + 'infoicon' => true, + 'type' => 'text', + 'allowempty' => false, + 'category' => 'general', + ], + 'domain' => [ + 'value' => preg_replace('/^www\./i', '', $this->website), + 'description' => $this->translator->trans('Domain Name of your server (for email)'), + 'type' => 'text', + 'allowempty' => false, + 'category' => 'general', + ], 'admin_address' => [ 'value' => 'webmaster@[DOMAIN]', 'description' => $this->translator->trans('Person in charge of this system (one email address)'), diff --git a/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php b/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php index 1d79369a..c296ccea 100644 --- a/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php +++ b/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php @@ -25,6 +25,14 @@ protected function setUp(): void $this->provider = new DefaultConfigProvider($this->translator); } + public function testWebsiteAndDomainDefaultsComeFromConfiguredWebsite(): void + { + $provider = new DefaultConfigProvider($this->translator, 'www.example.com'); + + $this->assertSame('www.example.com', $provider->get(ConfigOption::Website)['value']); + $this->assertSame('example.com', $provider->get(ConfigOption::Domain)['value']); + } + public function testHasReturnsTrueForKnownKey(): void { $this->assertTrue($this->provider->has(ConfigOption::AdminAddress)); From ba200910c790b4891c0331839e1f47c1d8d065e1 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 1 Nov 2026 20:32:33 +0400 Subject: [PATCH 16/46] fix: clarify envelope sender configuration in SystemNotificationMailer --- src/Domain/Messaging/Service/SystemNotificationMailer.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Domain/Messaging/Service/SystemNotificationMailer.php b/src/Domain/Messaging/Service/SystemNotificationMailer.php index 62c60628..160ad669 100644 --- a/src/Domain/Messaging/Service/SystemNotificationMailer.php +++ b/src/Domain/Messaging/Service/SystemNotificationMailer.php @@ -40,9 +40,8 @@ public function send(int $messageId, string $toEmail, string $subject, string $c return false; } - // todo: check if from name should be from config $envelope = new Envelope( - sender: new Address($this->bounceEmail, 'PHPList'), + sender: new Address($this->bounceEmail), recipients: [new Address($email->getTo()[0]->getAddress())], ); $this->mailer->send(message: $email, envelope: $envelope); From 3ec60da8e40d8e9b848f577f867c303a23e16377 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 1 Nov 2026 20:42:36 +0400 Subject: [PATCH 17/46] feat: integrate user blacklist check in SubscriberCsvImporter for email validation --- .../Service/SubscriberCsvImporter.php | 22 +++-- .../Service/SubscriberCsvImporterTest.php | 97 ++++++++++++++++++- 2 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php index f9db822a..d82b0c4d 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php @@ -13,6 +13,7 @@ use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; +use PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; @@ -44,6 +45,7 @@ public function __construct( private readonly TranslatorInterface $translator, private readonly MessageBusInterface $messageBus, private readonly SubscriberHistoryManager $subscriberHistoryManager, + private readonly UserBlacklistRepository $blacklistRepository, ) { } @@ -187,13 +189,16 @@ private function processRow( $this->attributeManager->processAttributes($subscriber, $dto->extraAttributes); - [$listLines, $addedNewSubscriberToList] = $this->getHistoryListLines($subscriber, $options); - - // todo: use isBlacklisted logic (grace time) not function from Subscriber class - if ($subscriber->isBlacklisted()) { + // as in phplist3, only the blacklist table decides; a blacklisted flag from the CSV alone does not + $isBlacklisted = $this->blacklistRepository->isEmailBlacklisted($subscriber->getEmail()); + if ($isBlacklisted) { + // make sure the flag reflects the blacklist table + $subscriber->setBlacklisted(true); $stats['blacklisted']++; } + [$listLines, $addedNewSubscriberToList] = $this->getHistoryListLines($subscriber, $options, $isBlacklisted); + $this->subscriberHistoryManager->addHistoryFromImport( subscriber: $subscriber, listLines: $listLines, @@ -273,11 +278,14 @@ private function prepareConfirmationMessage( return null; } - private function getHistoryListLines(Subscriber $subscriber, SubscriberImportOptions $options): array - { + private function getHistoryListLines( + Subscriber $subscriber, + SubscriberImportOptions $options, + bool $isBlacklisted, + ): array { $addedNewSubscriberToList = false; $listLines = []; - if (!$subscriber->isBlacklisted() && count($options->listIds) > 0) { + if (!$isBlacklisted && count($options->listIds) > 0) { foreach ($options->listIds as $listId) { $created = $this->subscriptionManager->addSubscriberToAList($subscriber, $listId); if ($created) { diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php index 1b2a7bf6..d429c5ca 100644 --- a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php +++ b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php @@ -17,6 +17,7 @@ use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager; +use PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository; use PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -31,28 +32,34 @@ class SubscriberCsvImporterTest extends TestCase private SubscriberRepository&MockObject $subscriberRepositoryMock; private CsvToDtoImporter&MockObject $csvImporterMock; private SubscriberAttributeDefinitionRepository&MockObject $attributeDefinitionRepositoryMock; + private UserBlacklistRepository&MockObject $blacklistRepositoryMock; + private SubscriptionManager&MockObject $subscriptionManagerMock; + private MessageBusInterface&MockObject $messageBusMock; private SubscriberCsvImporter $subject; protected function setUp(): void { $this->subscriberManagerMock = $this->createMock(SubscriberManager::class); $this->attributeManagerMock = $this->createMock(SubscriberAttributeManager::class); - $subscriptionManagerMock = $this->createMock(SubscriptionManager::class); + $this->subscriptionManagerMock = $this->createMock(SubscriptionManager::class); $this->subscriberRepositoryMock = $this->createMock(SubscriberRepository::class); $this->csvImporterMock = $this->createMock(CsvToDtoImporter::class); $this->attributeDefinitionRepositoryMock = $this->createMock(SubscriberAttributeDefinitionRepository::class); + $this->messageBusMock = $this->createMock(MessageBusInterface::class); $entityManager = $this->createMock(EntityManagerInterface::class); + $this->blacklistRepositoryMock = $this->createMock(UserBlacklistRepository::class); $this->subject = new SubscriberCsvImporter( subscriberManager: $this->subscriberManagerMock, attributeManager: $this->attributeManagerMock, - subscriptionManager: $subscriptionManagerMock, + subscriptionManager: $this->subscriptionManagerMock, subscriberRepository: $this->subscriberRepositoryMock, csvToDtoImporter: $this->csvImporterMock, entityManager: $entityManager, translator: new Translator('en'), - messageBus: $this->createMock(MessageBusInterface::class), + messageBus: $this->messageBusMock, subscriberHistoryManager: $this->createMock(SubscriberHistoryManager::class), + blacklistRepository: $this->blacklistRepositoryMock, ); } @@ -499,4 +506,88 @@ public function testImportCreatesNewWhenNeitherEmailNorForeignKeyFound(): void unlink($tempFile); } + + public function testImportCountsSubscriberWhoseEmailIsInBlacklistTable(): void + { + $tempFile = tempnam(sys_get_temp_dir(), 'csv_test_blacklist'); + file_put_contents($tempFile, "email,confirmed,html_email,blacklisted,disabled\nbl@example.com,0,1,0,0\n"); + + $uploadedFile = $this->createMock(UploadedFile::class); + $uploadedFile->method('getRealPath')->willReturn($tempFile); + + $this->subscriberRepositoryMock->method('findOneByEmail')->willReturn(null); + + $dto = new ImportSubscriberDto( + email: 'bl@example.com', + confirmed: false, + blacklisted: false, + htmlEmail: true, + disabled: false, + ); + $this->csvImporterMock->method('parseAndValidate')->willReturn(['valid' => [$dto], 'errors' => []]); + + $created = $this->createMock(Subscriber::class); + $created->method('getId')->willReturn(7); + $created->method('getEmail')->willReturn('bl@example.com'); + $created->method('isBlacklisted')->willReturn(false); + $created->expects($this->once())->method('setBlacklisted')->with(true); + $this->subscriberManagerMock->method('createFromImport')->willReturn($created); + + $this->blacklistRepositoryMock + ->expects($this->once()) + ->method('isEmailBlacklisted') + ->with('bl@example.com') + ->willReturn(true); + + $this->subscriptionManagerMock->expects($this->never())->method('addSubscriberToAList'); + $this->messageBusMock->expects($this->never())->method('dispatch'); + + $result = $this->subject->importFromCsv( + $uploadedFile, + new SubscriberImportOptions(updateExisting: false, listIds: [1, 2]) + ); + + $this->assertSame(1, $result['blacklisted']); + + unlink($tempFile); + } + + public function testImportIgnoresBlacklistedFlagWhenEmailIsNotInBlacklistTable(): void + { + $tempFile = tempnam(sys_get_temp_dir(), 'csv_test_flag_only'); + file_put_contents($tempFile, "email,confirmed,html_email,blacklisted,disabled\nflag@example.com,1,1,1,0\n"); + + $uploadedFile = $this->createMock(UploadedFile::class); + $uploadedFile->method('getRealPath')->willReturn($tempFile); + + $this->subscriberRepositoryMock->method('findOneByEmail')->willReturn(null); + + $dto = new ImportSubscriberDto( + email: 'flag@example.com', + confirmed: true, + blacklisted: true, + htmlEmail: true, + disabled: false, + ); + $this->csvImporterMock->method('parseAndValidate')->willReturn(['valid' => [$dto], 'errors' => []]); + + $created = $this->createMock(Subscriber::class); + $created->method('getId')->willReturn(8); + $created->method('getEmail')->willReturn('flag@example.com'); + $created->method('isBlacklisted')->willReturn(true); + $this->subscriberManagerMock->method('createFromImport')->willReturn($created); + + $this->blacklistRepositoryMock->method('isEmailBlacklisted')->willReturn(false); + + $this->subscriptionManagerMock->expects($this->once())->method('addSubscriberToAList'); + + $result = $this->subject->importFromCsv( + $uploadedFile, + new SubscriberImportOptions(updateExisting: false, listIds: [1]) + ); + + $this->assertSame(0, $result['blacklisted']); + + unlink($tempFile); + } } From c517d5332578148dc02f2ea711a10facef0cfc12 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 1 Nov 2026 20:53:51 +0400 Subject: [PATCH 18/46] feat: blacklisting logic in SubscriberCsvExporter to re-verify against the blacklist table --- src/Domain/Subscription/Model/Subscriber.php | 1 - .../Subscription/Service/SubscriberCsvExporter.php | 12 +++++++++++- .../Service/SubscriberCsvExporterTest.php | 4 +++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Domain/Subscription/Model/Subscriber.php b/src/Domain/Subscription/Model/Subscriber.php index e29a9b7d..7e8ce0e4 100644 --- a/src/Domain/Subscription/Model/Subscriber.php +++ b/src/Domain/Subscription/Model/Subscriber.php @@ -174,7 +174,6 @@ public function setConfirmed(bool $confirmed): self return $this; } - // todo: check where is this used and if should be replaced by isBlacklisted with grace time public function isBlacklisted(): bool { return $this->blacklisted; diff --git a/src/Domain/Subscription/Service/SubscriberCsvExporter.php b/src/Domain/Subscription/Service/SubscriberCsvExporter.php index 48802fe0..9429349b 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvExporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvExporter.php @@ -9,6 +9,7 @@ use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; +use PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; use PhpList\Core\Domain\Subscription\Service\Resolver\AttributeValueResolver; use Psr\Log\LoggerInterface; @@ -27,6 +28,7 @@ public function __construct( private readonly SubscriberRepository $subscriberRepository, private readonly SubscriberAttributeDefinitionRepository $definitionRepository, private readonly LoggerInterface $logger, + private readonly UserBlacklistRepository $blacklistRepository, ) { } @@ -253,6 +255,14 @@ private function configureResponse(BinaryFileResponse $response): Response return $response; } + /** + * re-verify the flag against the blacklist table so stale flags are not exported. + */ + private function isBlacklisted(Subscriber $subscriber): bool + { + return $subscriber->isBlacklisted() || $this->blacklistRepository->isEmailBlacklisted($subscriber->getEmail()); + } + /** @SuppressWarnings("CyclomaticComplexity") */ private function normalizerSubscriberData(Subscriber $subscriber): array { @@ -260,7 +270,7 @@ private function normalizerSubscriberData(Subscriber $subscriber): array 'id' => $subscriber->getId(), 'email' => $subscriber->getEmail(), 'confirmed' => $subscriber->isConfirmed() ? '1' : '0', - 'blacklisted' => $subscriber->isBlacklisted() ? '1' : '0', + 'blacklisted' => $this->isBlacklisted($subscriber) ? '1' : '0', 'bounceCount' => $subscriber->getBounceCount(), 'createdAt' => $subscriber->getCreatedAt()?->format('Y-m-d H:i:s') ?? '', 'updatedAt' => $subscriber->getUpdatedAt()->format('Y-m-d H:i:s'), diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberCsvExporterTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberCsvExporterTest.php index 3c7ae4b3..5bb70054 100644 --- a/tests/Unit/Domain/Subscription/Service/SubscriberCsvExporterTest.php +++ b/tests/Unit/Domain/Subscription/Service/SubscriberCsvExporterTest.php @@ -11,6 +11,7 @@ use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; +use PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; use PhpList\Core\Domain\Subscription\Service\Resolver\AttributeValueResolver; use PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter; @@ -37,7 +38,8 @@ protected function setUp(): void $this->createMock(AttributeValueResolver::class), $this->subscriberRepositoryMock, $this->attributeDefinitionRepositoryMock, - $this->createMock(LoggerInterface::class) + $this->createMock(LoggerInterface::class), + $this->createMock(UserBlacklistRepository::class), ); } From 5e71eb4357564f679b6135020a9875ed03ac9a07 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 1 Nov 2026 20:57:46 +0400 Subject: [PATCH 19/46] fix: test --- .../Provider/DefaultConfigProviderTest.php | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php b/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php index c296ccea..b0aab919 100644 --- a/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php +++ b/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php @@ -8,7 +8,6 @@ use PhpList\Core\Domain\Configuration\Service\Provider\DefaultConfigProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use ReflectionClass; use Symfony\Contracts\Translation\TranslatorInterface; /** @@ -87,24 +86,23 @@ public function testUnsubscribeUrlDefaults(): void public function testTranslatorIsUsedOnlyOnFirstInit(): void { + $calls = 0; $this->translator - ->expects($this->atLeastOnce()) ->method('trans') - ->willReturnArgument(0); + ->willReturnCallback(function (string $id) use (&$calls): string { + $calls++; + + return $id; + }); $this->provider->get(ConfigOption::AdminAddress); + $callsAfterInit = $calls; + $this->assertGreaterThan(0, $callsAfterInit); // Subsequent calls should not trigger init again - $translator = $this->createMock(TranslatorInterface::class); - $translator - ->expects($this->never()) - ->method('trans'); - - $reflection = new ReflectionClass($this->provider); - $prop = $reflection->getProperty('translator'); - - $prop->setValue($this->provider, $translator); $this->provider->get(ConfigOption::UnsubscribeUrl); $this->provider->has(ConfigOption::BlacklistUrl); + + $this->assertSame($callsAfterInit, $calls); } public function testKnownKeysHaveReasonableTypes(): void From 1277dec6e133affff2d62cfadfe210c2328888b6 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 1 Nov 2026 21:00:16 +0400 Subject: [PATCH 20/46] ref: update comments in ExternalImageService and RemotePageFetcher for clarity --- src/Domain/Common/RemotePageFetcher.php | 2 +- .../Common/Service/ExternalImageService.php | 2 +- .../Service/Manager/MessageDataManager.php | 28 +------------------ 3 files changed, 3 insertions(+), 29 deletions(-) diff --git a/src/Domain/Common/RemotePageFetcher.php b/src/Domain/Common/RemotePageFetcher.php index fdeb01db..5605c96d 100644 --- a/src/Domain/Common/RemotePageFetcher.php +++ b/src/Domain/Common/RemotePageFetcher.php @@ -52,7 +52,7 @@ public function __invoke(string $url, array $userData): string $cacheUrl = $this->urlCacheRepository->findByUrlAndLastModified($url, $lastModified); $content = $cacheUrl?->getContent(); if ($cacheUrl) { - // todo: check what the page should be for this log + // no page context here; phplist3 logs 'unknown page' in that case $this->eventLogManager->log(page: 'unknown page', entry: $url . ' was cached in database'); } else { $content = $this->fetchUrlDirect($url); diff --git a/src/Domain/Common/Service/ExternalImageService.php b/src/Domain/Common/Service/ExternalImageService.php index 63d9f35c..4f2314e2 100644 --- a/src/Domain/Common/Service/ExternalImageService.php +++ b/src/Domain/Common/Service/ExternalImageService.php @@ -91,7 +91,7 @@ private function removeOldFilesInCache(): void if ($cacheFile === false) { break; } - // todo: make sure that this is what we need + // skip hidden files and the . and .. entries, as phplist3 does if (!str_starts_with($cacheFile, '.')) { // phpcs:ignore Generic.PHP.NoSilencedErrors $cfmt = @filemtime($this->externalCacheDir . '/' . $cacheFile); diff --git a/src/Domain/Messaging/Service/Manager/MessageDataManager.php b/src/Domain/Messaging/Service/Manager/MessageDataManager.php index 760fda60..b8dab1c2 100644 --- a/src/Domain/Messaging/Service/Manager/MessageDataManager.php +++ b/src/Domain/Messaging/Service/Manager/MessageDataManager.php @@ -34,33 +34,7 @@ public function setMessageData(Message $campaign, string $name, mixed $value): v $value = $this->normalizeValueByName($name, $value); - // todo: remove this once we have a proper way to handle targetlists -// if ($name === 'targetlist' && is_array($value)) { -// $this->listMessageRepository->removeAllListAssociationsForMessage($campaign); -// -// if (!empty($value['all']) || !empty($value['allactive'])) { -// // todo: should be with $GLOBALS['subselect'] filter for access control -// foreach ($this->subscriberListRepository->getAllActive() as $list) { -// $listMessage = (new ListMessage()) -// ->setMessage($campaign) -// ->setList($list); -// $this->listMessageRepository->persist($listMessage); -// } -// // once we used "all" to set all, unset it, to avoid confusion trying to unselect lists -// unset($value['all']); -// } else { -// foreach ($value as $listId => $val) { -// // see #16940 - ignore a list called "unselect" which is there to allow unselecting all -// if ($listId !== 'unselect') { -// $list = $this->subscriberListRepository->find($listId); -// $listMessage = (new ListMessage()) -// ->setMessage($campaign) -// ->setList($list); -// $this->listMessageRepository->persist($listMessage); -// } -// } -// } -// } + // todo: targetLists (not only here) should be with $GLOBALS['subselect'] filter for access control if (is_array($value) || is_object($value)) { $value = 'SER:' . serialize($value); From cbdf76c863c846f7ed400eb9d8152ea950c243b9 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 2 Nov 2026 15:50:15 +0400 Subject: [PATCH 21/46] feat: implement link tracking in CampaignMailContentBuilder after placeholders are expanded --- .../Analytics/Service/LinkTrackService.php | 38 +++--- .../CampaignProcessorTestMessageHandler.php | 13 +- .../Messaging/Service/CampaignEmailSender.php | 10 +- .../CampaignMailContentBuilder.php | 31 ++++- .../Service/MessageProcessingPreparator.php | 25 ---- .../Service/LinkTrackServiceTest.php | 114 +++++++++--------- .../Service/CampaignEmailSenderTest.php | 6 - .../CampaignMailContentBuilderTest.php | 56 +++++++++ .../MessageProcessingPreparatorTest.php | 54 --------- 9 files changed, 162 insertions(+), 185 deletions(-) diff --git a/src/Domain/Analytics/Service/LinkTrackService.php b/src/Domain/Analytics/Service/LinkTrackService.php index 05a9baa6..5eda1067 100644 --- a/src/Domain/Analytics/Service/LinkTrackService.php +++ b/src/Domain/Analytics/Service/LinkTrackService.php @@ -9,8 +9,7 @@ use PhpList\Core\Domain\Analytics\Repository\LinkTrackForwardRepository; use PhpList\Core\Domain\Analytics\Repository\LinkTrackMlRepository; use PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository; -use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; -use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Subscription\Model\Subscriber; /** @@ -33,6 +32,7 @@ public function __construct( private readonly LinkTrackRepository $linkTrackRepository, private readonly LinkTrackForwardRepository $linkTrackForwardRepository, private readonly LinkTrackMlRepository $linkTrackMlRepository, + private readonly MessageRepository $messageRepository, private readonly LinkTrackTokenService $tokenService, private readonly ParameterProvider $paramProvider, ) { @@ -50,42 +50,42 @@ public function isClickTrackingEnabled(): bool } /** - * Returns a copy of the content in which the links of the HTML parts (content, HTML footer) and of the text - * parts (text content, text footer) point to the click-tracking endpoint for this subscriber. + * Replaces the links of the final HTML and text bodies with click-tracking links for this subscriber. It has to + * run once all placeholders are expanded, so that template links and links coming from placeholders (unsubscribe, + * preferences, ...) are tracked too, like phplist3 does. * * Links are left alone when they are not http(s)/ftp, are longer than the forward url column, still contain * placeholders (the redirect could not expand them), or are shown as text that itself contains a URL (the * rewrite would look like phishing). + * + * @return array{0: string, 1: string} the tracked HTML and text */ - public function trackLinks( - MessagePrecacheDto $content, - Message $campaign, - Subscriber $subscriber - ): MessagePrecacheDto { + public function trackLinks(string $html, string $text, int $campaignId, Subscriber $subscriber): array + { + $campaign = $this->isClickTrackingEnabled() ? $this->messageRepository->findById($campaignId) : null; + if ($campaign === null) { + return [$html, $text]; + } + $messageUuid = (string) $campaign->getUuid(); $subscriberUuid = $subscriber->getUuid(); if (!$this->isClickTrackingEnabled() || $messageUuid === '' || $subscriberUuid === '') { - return $content; + return [$html, $text]; } - $tracked = clone $content; $forwardIds = []; $trackUrl = function (string $url, string $type) use ($messageUuid, $subscriberUuid, &$forwardIds): ?string { return $this->createTrackingUrl($url, $type, $messageUuid, $subscriberUuid, $forwardIds); }; - $tracked->content = $this->rewriteHtml($tracked->content, $trackUrl); - $tracked->htmlFooter = $this->rewriteHtml($tracked->htmlFooter, $trackUrl); - $tracked->textContent = $this->rewriteText($tracked->textContent, $trackUrl); - if ($tracked->textFooter !== null) { - $tracked->textFooter = $this->rewriteText($tracked->textFooter, $trackUrl); - } + $html = $this->rewriteHtml($html, $trackUrl); + $text = $this->rewriteText($text, $trackUrl); foreach (array_keys($forwardIds) as $forwardId) { - $this->linkTrackMlRepository->incrementTotal((int) $campaign->getId(), $forwardId); + $this->linkTrackMlRepository->incrementTotal($campaignId, $forwardId); } - return $tracked; + return [$html, $text]; } /** @param callable(string, string): ?string $trackUrl */ diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessorTestMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessorTestMessageHandler.php index 61f47384..21895457 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessorTestMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessorTestMessageHandler.php @@ -18,7 +18,6 @@ use PhpList\Core\Domain\Messaging\Service\MailSizeChecker; use PhpList\Core\Domain\Messaging\Service\MessageDataLoader; use PhpList\Core\Domain\Messaging\Service\MessagePrecacheService; -use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider; use Psr\Log\LoggerInterface; @@ -41,7 +40,6 @@ class CampaignProcessorTestMessageHandler public function __construct( private readonly MailerInterface $mailer, private readonly SubscriberProvider $subscriberProvider, - private readonly MessageProcessingPreparator $messagePreparator, private readonly LoggerInterface $logger, private readonly CacheInterface $cache, private readonly TranslatorInterface $translator, @@ -89,17 +87,10 @@ private function handleEmailSending( Subscriber $subscriber, MessagePrecacheDto $precachedContent, ): void { - // todo: check at which point link tracking should be applied (maybe after constructing full text?) - $processed = $this->messagePreparator->processMessageLinks( - campaignId: $campaign->getId(), - cachedMessageDto: $precachedContent, - subscriber: $subscriber - ); - try { $result = $this->campaignEmailBuilder->buildCampaignEmail( messageId: $campaign->getId(), - data: $processed, + data: $precachedContent, toEmail: $subscriber->getEmail(), skipBlacklistCheck: false, inBlast: true, @@ -132,7 +123,7 @@ private function handleEmailSending( ); $envelope = new Envelope( - sender: new Address($this->bounceEmail, 'PHPList'), + sender: new Address($this->bounceEmail), recipients: [new Address($email->getTo()[0]->getAddress())], ); $this->mailer->send(message: $email, envelope: $envelope); diff --git a/src/Domain/Messaging/Service/CampaignEmailSender.php b/src/Domain/Messaging/Service/CampaignEmailSender.php index 7cb590b0..e4babbfb 100644 --- a/src/Domain/Messaging/Service/CampaignEmailSender.php +++ b/src/Domain/Messaging/Service/CampaignEmailSender.php @@ -36,7 +36,6 @@ public function __construct( private readonly EmailBuilder $campaignEmailBuilder, private readonly RateLimitedCampaignMailer $rateLimitedCampaignMailer, private readonly MailSizeChecker $mailSizeChecker, - private readonly MessageProcessingPreparator $messagePreparator, private readonly MessageRepository $messageRepository, private readonly MessageStatusUpdater $messageStatusUpdater, private readonly SystemNotificationMailer $notificationMailer, @@ -54,17 +53,10 @@ public function send( UserMessage $userMessage, MessagePrecacheDto $precachedContent, ): void { - // todo: check at which point link tracking should be applied (maybe after constructing full text?) - $processed = $this->messagePreparator->processMessageLinks( - campaignId: $campaign->getId(), - cachedMessageDto: $precachedContent, - subscriber: $subscriber - ); - try { $result = $this->campaignEmailBuilder->buildCampaignEmail( messageId: $campaign->getId(), - data: $processed, + data: $precachedContent, toEmail: $subscriber->getEmail(), skipBlacklistCheck: false, inBlast: true, diff --git a/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php b/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php index c7398a20..44ed2eb5 100644 --- a/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php +++ b/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Domain\Messaging\Service\Constructor; +use PhpList\Core\Domain\Analytics\Service\LinkTrackService; use PhpList\Core\Domain\Common\Html2Text; use PhpList\Core\Domain\Common\RemotePageFetcher; use PhpList\Core\Domain\Common\TextParser; @@ -28,6 +29,7 @@ public function __construct( private readonly Html2Text $html2Text, private readonly TextParser $textParser, private readonly MessagePlaceholderProcessor $placeholderProcessor, + private readonly LinkTrackService $linkTrackService, #[Autowire('%phplist.forward_personal_note_size%')] private readonly int $forwardPersonalNoteSize = 0, ) { } @@ -90,8 +92,15 @@ public function __invoke( forwardedBy: $forwardedBy, ); + [$htmlMessage, $textMessage] = $this->trackLinks( + htmlMessage: $htmlMessage, + textMessage: $textMessage, + receiver: $receiver, + campaignId: $campaignId, + forwardedBy: $forwardedBy, + ); + $htmlMessage = $this->ensureHtmlFormating(content: $htmlMessage, addDefaultStyle: $addDefaultStyle); - // todo: add link CLICKTRACK to $htmlMessage [$htmlMessage, $textMessage] = $this->ensureNoteInCaseOfForwarded( htmlMessage: $htmlMessage, @@ -102,6 +111,26 @@ public function __invoke( return [$htmlMessage, $textMessage]; } + /** + * As in phplist3, links are tracked once all placeholders are expanded, but not in forwarded messages as the + * receivers are not necessarily subscribers. + * + * @return array{0: string, 1: string} + */ + private function trackLinks( + string $htmlMessage, + string $textMessage, + Subscriber $receiver, + ?int $campaignId, + ?Subscriber $forwardedBy, + ): array { + if ($campaignId === null || $forwardedBy !== null) { + return [$htmlMessage, $textMessage]; + } + + return $this->linkTrackService->trackLinks($htmlMessage, $textMessage, $campaignId, $receiver); + } + private function replaceUserSpecificRemoteContent( MessagePrecacheDto $messagePrecacheDto, Subscriber $subscriber, diff --git a/src/Domain/Messaging/Service/MessageProcessingPreparator.php b/src/Domain/Messaging/Service/MessageProcessingPreparator.php index cfbcc148..acdd1914 100644 --- a/src/Domain/Messaging/Service/MessageProcessingPreparator.php +++ b/src/Domain/Messaging/Service/MessageProcessingPreparator.php @@ -4,10 +4,7 @@ namespace PhpList\Core\Domain\Messaging\Service; -use PhpList\Core\Domain\Analytics\Service\LinkTrackService; -use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; -use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -17,7 +14,6 @@ class MessageProcessingPreparator public function __construct( private readonly SubscriberRepository $subscriberRepository, private readonly MessageRepository $messageRepository, - private readonly LinkTrackService $linkTrackService, private readonly TranslatorInterface $translator, ) { } @@ -51,25 +47,4 @@ public function ensureCampaignsHaveUuid(OutputInterface $output): void } } } - - /** - * Replaces the links of the cached message content with click-tracking links for this subscriber. - * Returns the content unchanged when click tracking is off or the campaign is unknown. - */ - public function processMessageLinks( - int $campaignId, - MessagePrecacheDto $cachedMessageDto, - Subscriber $subscriber - ): MessagePrecacheDto { - if (!$this->linkTrackService->isClickTrackingEnabled()) { - return $cachedMessageDto; - } - - $campaign = $this->messageRepository->findById($campaignId); - if ($campaign === null) { - return $cachedMessageDto; - } - - return $this->linkTrackService->trackLinks($cachedMessageDto, $campaign, $subscriber); - } } diff --git a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php index 7457c0bb..ace74a03 100644 --- a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php @@ -11,8 +11,8 @@ use PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository; use PhpList\Core\Domain\Analytics\Service\LinkTrackService; use PhpList\Core\Domain\Analytics\Service\LinkTrackTokenService; -use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -21,6 +21,7 @@ class LinkTrackServiceTest extends TestCase { private LinkTrackForwardRepository&MockObject $forwardRepository; private LinkTrackMlRepository&MockObject $mlRepository; + private MessageRepository&MockObject $messageRepository; private LinkTrackTokenService $tokenService; private Message&MockObject $campaign; private Subscriber $subscriber; @@ -31,6 +32,7 @@ protected function setUp(): void { $this->forwardRepository = $this->createMock(LinkTrackForwardRepository::class); $this->mlRepository = $this->createMock(LinkTrackMlRepository::class); + $this->messageRepository = $this->createMock(MessageRepository::class); $this->tokenService = new LinkTrackTokenService( new ParameterProvider([]), 'https://example.com/api/v2', @@ -40,6 +42,7 @@ protected function setUp(): void $this->campaign = $this->createMock(Message::class); $this->campaign->method('getId')->willReturn(7); $this->campaign->method('getUuid')->willReturn('campaign-uuid'); + $this->messageRepository->method('findById')->with(7)->willReturn($this->campaign); $this->subscriber = new Subscriber('alice@example.com'); $this->subscriber->setUuid('subscriber-uuid'); @@ -70,6 +73,7 @@ private function createService(bool $clickTrack = true): LinkTrackService $this->createMock(LinkTrackRepository::class), $this->forwardRepository, $this->mlRepository, + $this->messageRepository, $this->tokenService, new ParameterProvider(['click_track' => $clickTrack]), ); @@ -94,50 +98,59 @@ public function testIsClickTrackingEnabledFollowsTheParameter(): void public function testContentIsReturnedUntouchedWhenClickTrackingIsDisabled(): void { - $content = new MessagePrecacheDto(); - $content->content = 'x'; + $html = 'x'; $this->forwardRepository->expects(self::never())->method('findOrCreateByUrl'); - $result = $this->createService(false)->trackLinks($content, $this->campaign, $this->subscriber); + $result = $this->createService(false)->trackLinks($html, 'https://example.com/', 7, $this->subscriber); - self::assertSame($content, $result); + self::assertSame([$html, 'https://example.com/'], $result); } public function testContentIsReturnedUntouchedWhenCampaignHasNoUuid(): void { $campaign = $this->createMock(Message::class); $campaign->method('getUuid')->willReturn(null); - $content = new MessagePrecacheDto(); - $content->content = 'x'; + $repository = $this->createMock(MessageRepository::class); + $repository->method('findById')->willReturn($campaign); + $service = new LinkTrackService( + $this->createMock(LinkTrackRepository::class), + $this->forwardRepository, + $this->mlRepository, + $repository, + $this->tokenService, + new ParameterProvider(['click_track' => true]), + ); + $html = 'x'; - self::assertSame($content, $this->createService()->trackLinks($content, $campaign, $this->subscriber)); + self::assertSame([$html, ''], $service->trackLinks($html, '', 7, $this->subscriber)); } public function testHtmlLinksAreReplacedWithTrackingLinks(): void { - $content = new MessagePrecacheDto(); - $content->content = '

A ' + $html = '

A ' . "

"; - $result = $this->createService()->trackLinks($content, $this->campaign, $this->subscriber); + [$result] = $this->createService()->trackLinks($html, '', 7, $this->subscriber); - self::assertStringContainsString('A', $result->content); - self::assertStringContainsString("content); + self::assertStringContainsString('A', $result); + self::assertStringContainsString("createdForwards, 'stored url is decoded'); self::assertArrayHasKey('http://example.org/b', $this->createdForwards); } public function testTrackingLinkIdentifiesForwardCampaignAndSubscriber(): void { - $content = new MessagePrecacheDto(); - $content->content = 'A'; - - $result = $this->createService()->trackLinks($content, $this->campaign, $this->subscriber); + [$result] = $this->createService()->trackLinks( + 'A', + '', + 7, + $this->subscriber + ); - preg_match('~tid=([A-Za-z0-9_-]+)~', $result->content, $match); + preg_match('~tid=([A-Za-z0-9_-]+)~', $result, $match); $token = $this->tokenService->parse($match[1]); self::assertNotNull($token); self::assertTrue($token->isHtml()); @@ -158,23 +171,23 @@ public function testLinksThatMustNotBeTrackedAreLeftAlone(): void 'already tracked', 'no href', ]); - $content = new MessagePrecacheDto(); - $content->content = $html; $this->forwardRepository->expects(self::never())->method('findOrCreateByUrl'); $this->mlRepository->expects(self::never())->method('incrementTotal'); - $result = $this->createService()->trackLinks($content, $this->campaign, $this->subscriber); + [$result] = $this->createService()->trackLinks($html, '', 7, $this->subscriber); - self::assertSame($html, $result->content); + self::assertSame($html, $result); } public function testPersonalUidAndSessionParametersAreStrippedAndMarkTheForwardPersonal(): void { - $content = new MessagePrecacheDto(); - $content->content = 'P'; - - $this->createService()->trackLinks($content, $this->campaign, $this->subscriber); + $this->createService()->trackLinks( + 'P', + '', + 7, + $this->subscriber + ); self::assertSame( ['https://example.com/p?a=1#top' => ['url' => 'https://example.com/p?a=1#top', 'personalise' => true]], @@ -184,42 +197,34 @@ public function testPersonalUidAndSessionParametersAreStrippedAndMarkTheForwardP public function testTextUrlsAreReplacedWithTextTokens(): void { - $content = new MessagePrecacheDto(); - $content->textContent = "Read https://example.com/a. Or see http://example.org/b?x=1\nbye"; - $content->textFooter = 'More: https://example.com/a'; + $text = "Read https://example.com/a. Or see http://example.org/b?x=1\nbye\nMore: https://example.com/a"; - $result = $this->createService()->trackLinks($content, $this->campaign, $this->subscriber); + [, $result] = $this->createService()->trackLinks('', $text, 7, $this->subscriber); self::assertMatchesRegularExpression( '~^Read https://example.com/api/v2/lt\?tid=[A-Za-z0-9_-]+\. Or see https://example.com/api/v2/lt\?tid=~', - $result->textContent + $result ); - preg_match('~tid=([A-Za-z0-9_-]+)~', $result->textContent, $match); + preg_match('~tid=([A-Za-z0-9_-]+)~', $result, $match); self::assertSame('T', $this->tokenService->parse($match[1])->type); - self::assertSame( - [$this->trackedForwardUuids($result->textContent)[0]], - $this->trackedForwardUuids((string) $result->textFooter), - 'the same url gets the same forward' - ); - self::assertSame("\nbye", substr($result->textContent, -4)); + $uuids = $this->trackedForwardUuids($result); + self::assertSame($uuids[0], $uuids[2], 'the same url gets the same forward'); + self::assertSame("\nbye\n", substr($result, strpos($result, "\n"), 5)); } - public function testFootersAreTrackedToo(): void + public function testLinksFromExpandedPlaceholdersAreTracked(): void { - $content = new MessagePrecacheDto(); - $content->htmlFooter = 'Footer'; + $html = 'Unsubscribe'; - $result = $this->createService()->trackLinks($content, $this->campaign, $this->subscriber); + [$result] = $this->createService()->trackLinks($html, '', 7, $this->subscriber); - self::assertSame(['forward-uuid-1'], $this->trackedForwardUuids($result->htmlFooter)); + self::assertSame(['forward-uuid-1'], $this->trackedForwardUuids($result)); } public function testTotalIsIncrementedOncePerDistinctLinkOfTheEmail(): void { - $content = new MessagePrecacheDto(); - $content->content = '12' + $html = '12' . '3'; - $content->textContent = 'https://example.com/a'; $incremented = []; $this->mlRepository->expects(self::exactly(2)) @@ -228,19 +233,8 @@ public function testTotalIsIncrementedOncePerDistinctLinkOfTheEmail(): void $incremented[] = [$messageId, $forwardId]; }); - $this->createService()->trackLinks($content, $this->campaign, $this->subscriber); + $this->createService()->trackLinks($html, 'https://example.com/a', 7, $this->subscriber); self::assertSame([[7, 1], [7, 2]], $incremented); } - - public function testTheGivenContentIsNotModified(): void - { - $content = new MessagePrecacheDto(); - $content->content = 'A'; - - $result = $this->createService()->trackLinks($content, $this->campaign, $this->subscriber); - - self::assertNotSame($content, $result); - self::assertSame('A', $content->content); - } } diff --git a/tests/Unit/Domain/Messaging/Service/CampaignEmailSenderTest.php b/tests/Unit/Domain/Messaging/Service/CampaignEmailSenderTest.php index b673f442..22ef8593 100644 --- a/tests/Unit/Domain/Messaging/Service/CampaignEmailSenderTest.php +++ b/tests/Unit/Domain/Messaging/Service/CampaignEmailSenderTest.php @@ -19,7 +19,6 @@ use PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder; use PhpList\Core\Domain\Messaging\Service\CampaignEmailSender; use PhpList\Core\Domain\Messaging\Service\MailSizeChecker; -use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; use PhpList\Core\Domain\Messaging\Service\MessageStatusUpdater; use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer; use PhpList\Core\Domain\Messaging\Service\SystemNotificationMailer; @@ -37,7 +36,6 @@ class CampaignEmailSenderTest extends TestCase private EmailBuilder|MockObject $campaignEmailBuilder; private RateLimitedCampaignMailer|MockObject $rateLimitedCampaignMailer; private MailSizeChecker|MockObject $mailSizeChecker; - private MessageProcessingPreparator|MockObject $messagePreparator; private MessageRepository|MockObject $messageRepository; private MessageStatusUpdater|MockObject $messageStatusUpdater; private SystemNotificationMailer|MockObject $notificationMailer; @@ -57,7 +55,6 @@ protected function setUp(): void $this->campaignEmailBuilder = $this->createMock(EmailBuilder::class); $this->rateLimitedCampaignMailer = $this->createMock(RateLimitedCampaignMailer::class); $this->mailSizeChecker = $this->createMock(MailSizeChecker::class); - $this->messagePreparator = $this->createMock(MessageProcessingPreparator::class); $this->messageRepository = $this->createMock(MessageRepository::class); $this->messageStatusUpdater = $this->createMock(MessageStatusUpdater::class); $this->notificationMailer = $this->createMock(SystemNotificationMailer::class); @@ -72,7 +69,6 @@ protected function setUp(): void $this->campaignEmailBuilder, $this->rateLimitedCampaignMailer, $this->mailSizeChecker, - $this->messagePreparator, $this->messageRepository, $this->messageStatusUpdater, $this->notificationMailer, @@ -92,8 +88,6 @@ protected function setUp(): void $this->subscriber->method('getEmail')->willReturn('test@example.com'); $this->userMessage = $this->createMock(UserMessage::class); - - $this->messagePreparator->method('processMessageLinks')->willReturn($this->precached); } public function testSendMarksSentAndIncrementsCountsOnSuccess(): void diff --git a/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php index df8afd18..ad4cde61 100644 --- a/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Constructor; +use PhpList\Core\Domain\Analytics\Service\LinkTrackService; use PhpList\Core\Domain\Common\Html2Text; use PhpList\Core\Domain\Common\RemotePageFetcher; use PhpList\Core\Domain\Common\TextParser; @@ -28,6 +29,7 @@ class CampaignMailContentBuilderTest extends TestCase private Html2Text&MockObject $html2Text; private TextParser&MockObject $textParser; private MessagePlaceholderProcessor&MockObject $placeholderProcessor; + private LinkTrackService&MockObject $linkTrackService; protected function setUp(): void { @@ -47,6 +49,10 @@ protected function setUp(): void ->onlyMethods(['__invoke']) ->getMock(); $this->placeholderProcessor = $this->createMock(MessagePlaceholderProcessor::class); + $this->linkTrackService = $this->createMock(LinkTrackService::class); + $this->linkTrackService->method('trackLinks')->willReturnCallback( + static fn (string $html, string $text): array => [$html, $text] + ); $this->configProvider ->method('getValue') @@ -67,6 +73,7 @@ private function makeBuilder(): CampaignMailContentBuilder html2Text: $this->html2Text, textParser: $this->textParser, placeholderProcessor: $this->placeholderProcessor, + linkTrackService: $this->linkTrackService, ); } @@ -276,4 +283,53 @@ static function (...$args): string { 'No text content provided and html2text not used when htmlFormatted and template present' ); } + + private function makeSubscriber(): Subscriber&MockObject + { + $subscriber = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->onlyMethods(['getId', 'getEmail']) + ->getMock(); + $subscriber->method('getId')->willReturn(5); + $subscriber->method('getEmail')->willReturn('user@example.com'); + $this->placeholderProcessor + ->method('process') + ->willReturnCallback(static fn (...$args): string => (string) $args[0] . '[expanded]'); + + return $subscriber; + } + + public function testLinksAreTrackedAfterPlaceholdersAreExpanded(): void + { + $subscriber = $this->makeSubscriber(); + $this->linkTrackService = $this->createMock(LinkTrackService::class); + $this->linkTrackService->expects($this->once()) + ->method('trackLinks') + ->with('

Hi

[expanded]', 'Hi[expanded]', 9, $subscriber) + ->willReturn(['

tracked

', 'tracked text']); + + $dto = new MessagePrecacheDto(); + $dto->content = '

Hi

'; + $dto->textContent = 'Hi'; + $dto->htmlFormatted = true; + + [$html, $text] = ($this->makeBuilder())($dto, $subscriber, 9); + + $this->assertStringContainsString('

tracked

', $html); + $this->assertSame('tracked text', $text); + } + + public function testLinksAreNotTrackedInForwardedMessages(): void + { + $subscriber = $this->makeSubscriber(); + $this->linkTrackService = $this->createMock(LinkTrackService::class); + $this->linkTrackService->expects($this->never())->method('trackLinks'); + + $dto = new MessagePrecacheDto(); + $dto->content = '

Hi

'; + $dto->textContent = 'Hi'; + $dto->htmlFormatted = true; + + ($this->makeBuilder())($dto, $subscriber, 9, $this->createMock(Subscriber::class)); + } } diff --git a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php index 08ffa892..f598d636 100644 --- a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php +++ b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php @@ -4,8 +4,6 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; -use PhpList\Core\Domain\Analytics\Service\LinkTrackService; -use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; use PhpList\Core\Domain\Subscription\Model\Subscriber; @@ -20,7 +18,6 @@ class MessageProcessingPreparatorTest extends TestCase { private SubscriberRepository&MockObject $subscriberRepository; private MessageRepository&MockObject $messageRepository; - private LinkTrackService&MockObject $linkTrackService; private OutputInterface&MockObject $output; private MessageProcessingPreparator $preparator; @@ -28,13 +25,11 @@ protected function setUp(): void { $this->subscriberRepository = $this->createMock(SubscriberRepository::class); $this->messageRepository = $this->createMock(MessageRepository::class); - $this->linkTrackService = $this->createMock(LinkTrackService::class); $this->output = $this->createMock(OutputInterface::class); $this->preparator = new MessageProcessingPreparator( subscriberRepository: $this->subscriberRepository, messageRepository: $this->messageRepository, - linkTrackService: $this->linkTrackService, translator: new Translator('en'), ); } @@ -114,53 +109,4 @@ public function testEnsureCampaignsHaveUuidWithCampaigns(): void $this->preparator->ensureCampaignsHaveUuid($this->output); } - - public function testProcessMessageLinksWhenClickTrackingIsDisabled(): void - { - $messageContent = new MessagePrecacheDto(); - $subscriber = $this->createMock(Subscriber::class); - - $this->linkTrackService->expects($this->once()) - ->method('isClickTrackingEnabled') - ->willReturn(false); - $this->messageRepository->expects($this->never())->method('findById'); - $this->linkTrackService->expects($this->never())->method('trackLinks'); - - $result = $this->preparator->processMessageLinks(1, $messageContent, $subscriber); - - $this->assertSame($messageContent, $result); - } - - public function testProcessMessageLinksWhenCampaignIsUnknown(): void - { - $messageContent = new MessagePrecacheDto(); - $subscriber = $this->createMock(Subscriber::class); - - $this->linkTrackService->method('isClickTrackingEnabled')->willReturn(true); - $this->messageRepository->expects($this->once())->method('findById')->with(1)->willReturn(null); - $this->linkTrackService->expects($this->never())->method('trackLinks'); - - $result = $this->preparator->processMessageLinks(1, $messageContent, $subscriber); - - $this->assertSame($messageContent, $result); - } - - public function testProcessMessageLinksReturnsTheContentWithTrackedLinks(): void - { - $messageContent = new MessagePrecacheDto(); - $tracked = new MessagePrecacheDto(); - $subscriber = $this->createMock(Subscriber::class); - $campaign = $this->createMock(Message::class); - - $this->linkTrackService->method('isClickTrackingEnabled')->willReturn(true); - $this->messageRepository->method('findById')->with(1)->willReturn($campaign); - $this->linkTrackService->expects($this->once()) - ->method('trackLinks') - ->with($messageContent, $campaign, $subscriber) - ->willReturn($tracked); - - $result = $this->preparator->processMessageLinks(1, $messageContent, $subscriber); - - $this->assertSame($tracked, $result); - } } From 3efa03d1d909c0ca30c7ba200a541c94bffe022c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 2 Nov 2026 15:50:30 +0400 Subject: [PATCH 22/46] feat: add analytics tracking service for campaign links and integrate with link tracking --- config/services/services.yml | 6 + .../Service/AnalyticsTrackingService.php | 128 ++++++++++++ .../Analytics/Service/LinkClickService.php | 3 +- src/Domain/Analytics/Service/LinkRewriter.php | 71 +++++++ .../Analytics/Service/LinkTrackService.php | 89 ++++----- .../Service/Query/AnalyticsQueryInterface.php | 30 +++ .../Service/Query/GoogleAnalyticsQuery.php | 32 +++ .../Service/Query/MatomoAnalyticsQuery.php | 32 +++ .../Configuration/Model/ConfigOption.php | 1 + .../Model/Dto/MessagePrecacheDto.php | 2 + .../CampaignMailContentBuilder.php | 20 +- .../Service/MessagePrecacheService.php | 17 ++ .../Service/AnalyticsTrackingServiceTest.php | 182 ++++++++++++++++++ .../Service/LinkClickServiceTest.php | 30 +++ .../Service/LinkTrackServiceTest.php | 44 +++++ .../CampaignMailContentBuilderTest.php | 26 ++- 16 files changed, 648 insertions(+), 65 deletions(-) create mode 100644 src/Domain/Analytics/Service/AnalyticsTrackingService.php create mode 100644 src/Domain/Analytics/Service/LinkRewriter.php create mode 100644 src/Domain/Analytics/Service/Query/AnalyticsQueryInterface.php create mode 100644 src/Domain/Analytics/Service/Query/GoogleAnalyticsQuery.php create mode 100644 src/Domain/Analytics/Service/Query/MatomoAnalyticsQuery.php create mode 100644 tests/Unit/Domain/Analytics/Service/AnalyticsTrackingServiceTest.php diff --git a/config/services/services.yml b/config/services/services.yml index ae06bc01..bf40c389 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -61,6 +61,12 @@ services: autoconfigure: true public: true + PhpList\Core\Domain\Analytics\Service\LinkRewriter: + autowire: true + + PhpList\Core\Domain\Analytics\Service\AnalyticsTrackingService: + autowire: true + PhpList\Core\Domain\Analytics\Service\LinkTrackTokenService: autowire: true autoconfigure: true diff --git a/src/Domain/Analytics/Service/AnalyticsTrackingService.php b/src/Domain/Analytics/Service/AnalyticsTrackingService.php new file mode 100644 index 00000000..1525176d --- /dev/null +++ b/src/Domain/Analytics/Service/AnalyticsTrackingService.php @@ -0,0 +1,128 @@ +messageDataLoader)($campaign); + if (empty($messageData['google_track'])) { + return $url; + } + + return $this->addToUrl($url, $isHtml ? self::FORMAT_HTML : self::FORMAT_TEXT, $messageData); + } + + /** + * For a message that is sent without click tracking: adds the parameters to all links. + * + * @param array $messageData the campaign's analytics settings, see MessagePrecacheDto + * @return array{0: string, 1: string} the HTML and the text + */ + public function addToBodies(string $html, string $text, array $messageData): array + { + $html = $this->linkRewriter->rewriteHtml( + $html, + fn (string $url): string => $this->addToUrl($url, self::FORMAT_HTML, $messageData) + ); + $text = $this->linkRewriter->rewriteText( + $text, + fn (string $url): string => $this->addToUrl($url, self::FORMAT_TEXT, $messageData) + ); + + return [$html, $text]; + } + + /** + * Only http and https urls get parameters. + * + * @param array $messageData + */ + public function addToUrl(string $url, string $format, array $messageData): string + { + if (preg_match('/^https?:/i', $url) !== 1) { + return $url; + } + + $query = $this->getQuery(); + // a value the campaign never set gets its default, as phplist3's form would have saved it; a value that + // was set to empty stays empty, which leaves that parameter out + foreach ($query->editableParameters($messageData) as $name => $default) { + if (!array_key_exists($name, $messageData)) { + $messageData[$name] = $default; + } + } + + return $this->withParameters( + $url, + $query->trackingParameters($format, $messageData), + $query->prefix() + ); + } + + /** + * Adds the parameters to the url, taking off tracking parameters that are already on it. Empty parameters are + * not added. A fragment stays at the end. + * + * @param array $trackingParameters + */ + public function withParameters(string $url, array $trackingParameters, string $prefix): string + { + $fragment = ''; + $position = strpos($url, '#'); + if ($position !== false) { + $fragment = substr($url, $position); + $url = substr($url, 0, $position); + } + + if (str_contains($url, $prefix)) { + // the expression can leave a trailing ? or & behind + $pattern = sprintf('/%s.+?=[^&]+(&|$)/', preg_quote($prefix, '/')); + $url = rtrim((string) preg_replace($pattern, '', $url), '?&'); + } + + $trackingCode = http_build_query( + array_filter($trackingParameters, static fn ($value): bool => $value !== null && $value !== '') + ); + if ($trackingCode === '') { + return $url . $fragment; + } + + return $url . (str_contains($url, '?') ? '&' : '?') . $trackingCode . $fragment; + } + + private function getQuery(): AnalyticsQueryInterface + { + return $this->configProvider->getValue(ConfigOption::AnalyticTracker) === 'matomo' + ? new MatomoAnalyticsQuery() + : new GoogleAnalyticsQuery(); + } +} diff --git a/src/Domain/Analytics/Service/LinkClickService.php b/src/Domain/Analytics/Service/LinkClickService.php index 538f2dd3..2eb48dc0 100644 --- a/src/Domain/Analytics/Service/LinkClickService.php +++ b/src/Domain/Analytics/Service/LinkClickService.php @@ -31,6 +31,7 @@ public function __construct( private readonly SubscriberRepository $subscriberRepository, private readonly UserMessageRepository $userMessageRepository, private readonly UserMessageService $userMessageService, + private readonly AnalyticsTrackingService $analyticsTrackingService, ) { } @@ -81,7 +82,7 @@ public function trackClick(string $tid, ?string $signature, array $metadata): ?s $now ); - return $url; + return $this->analyticsTrackingService->addToClickedUrl($url, $message, $token->isHtml()); } private function resolveUrl(LinkTrackForward $forward, Subscriber $subscriber, ?UserMessage $userMessage): ?string diff --git a/src/Domain/Analytics/Service/LinkRewriter.php b/src/Domain/Analytics/Service/LinkRewriter.php new file mode 100644 index 00000000..c0accbea --- /dev/null +++ b/src/Domain/Analytics/Service/LinkRewriter.php @@ -0,0 +1,71 @@ +]*?[\s"\'])?)href=(["\'])(.*?)\2([^>]*>)(.*?)<\/a>/is'; + private const TEXT_URL_PATTERN = '#https?://[^\s<>},]+#i'; + + /** + * Rewrites the href of every anchor. Anchors whose visible text itself contains a URL are left alone, as + * pointing them elsewhere would look like phishing. + * + * @param callable(string): ?string $rewrite gets the decoded href, returns the new url or null to keep the link + */ + public function rewriteHtml(string $html, callable $rewrite): string + { + if ($html === '') { + return $html; + } + + return (string) preg_replace_callback( + self::HTML_ANCHOR_PATTERN, + function (array $match) use ($rewrite): string { + $linkText = strip_tags($match[5]); + if (stripos($linkText, 'http://') !== false || stripos($linkText, 'https://') !== false) { + return $match[0]; + } + + $href = html_entity_decode(trim($match[3]), ENT_QUOTES | ENT_HTML5); + $url = $rewrite($href); + if ($url === null) { + return $match[0]; + } + + return $match[1] . 'href=' . $match[2] . htmlspecialchars($url, ENT_QUOTES) . $match[2] + . $match[4] . $match[5] . ''; + }, + $html + ); + } + + /** + * Rewrites every http(s) URL in the text, leaving a trailing dot (the end of a sentence) where it is. + * + * @param callable(string): ?string $rewrite gets the url, returns the new url or null to keep it + */ + public function rewriteText(string $text, callable $rewrite): string + { + if ($text === '') { + return $text; + } + + return (string) preg_replace_callback( + self::TEXT_URL_PATTERN, + function (array $match) use ($rewrite): string { + $trailing = str_ends_with($match[0], '.') ? '.' : ''; + $link = substr($match[0], 0, strlen($match[0]) - strlen($trailing)); + $url = $rewrite($link); + + return $url === null ? $match[0] : $url . $trailing; + }, + $text + ); + } +} diff --git a/src/Domain/Analytics/Service/LinkTrackService.php b/src/Domain/Analytics/Service/LinkTrackService.php index 5eda1067..7153c40a 100644 --- a/src/Domain/Analytics/Service/LinkTrackService.php +++ b/src/Domain/Analytics/Service/LinkTrackService.php @@ -21,8 +21,6 @@ class LinkTrackService { private const MAX_URL_LENGTH = 255; - private const HTML_ANCHOR_PATTERN = '/(]*?[\s"\'])?)href=(["\'])(.*?)\2([^>]*>)(.*?)<\/a>/is'; - private const TEXT_URL_PATTERN = '#https?://[^\s<>},]+#i'; private const PLACEHOLDER_PATTERN = '/\[[A-Z][A-Z0-9_]*(?::[^\]]*)?\]/'; /** @var array forwards by url hash, kept for the worker's lifetime */ @@ -35,6 +33,8 @@ public function __construct( private readonly MessageRepository $messageRepository, private readonly LinkTrackTokenService $tokenService, private readonly ParameterProvider $paramProvider, + private readonly LinkRewriter $linkRewriter, + private readonly AnalyticsTrackingService $analyticsTrackingService, ) { } @@ -58,18 +58,37 @@ public function isClickTrackingEnabled(): bool * placeholders (the redirect could not expand them), or are shown as text that itself contains a URL (the * rewrite would look like phishing). * + * Without click tracking, the analytics parameters (when given) are added straight onto the links instead, like + * phplist3 does. With click tracking they are added when the link is followed, see LinkClickService. + * + * Forwarded messages are not click tracked, as their receivers are not necessarily subscribers. + * + * @param array|null $analyticsParameters the campaign's analytics settings, null when it has + * no analytics tracking * @return array{0: string, 1: string} the tracked HTML and text */ - public function trackLinks(string $html, string $text, int $campaignId, Subscriber $subscriber): array - { - $campaign = $this->isClickTrackingEnabled() ? $this->messageRepository->findById($campaignId) : null; + public function trackLinks( + string $html, + string $text, + int $campaignId, + Subscriber $subscriber, + ?array $analyticsParameters = null, + bool $forwarded = false, + ): array { + if (!$this->isClickTrackingEnabled()) { + return $analyticsParameters === null + ? [$html, $text] + : $this->analyticsTrackingService->addToBodies($html, $text, $analyticsParameters); + } + + $campaign = $forwarded ? null : $this->messageRepository->findById($campaignId); if ($campaign === null) { return [$html, $text]; } $messageUuid = (string) $campaign->getUuid(); $subscriberUuid = $subscriber->getUuid(); - if (!$this->isClickTrackingEnabled() || $messageUuid === '' || $subscriberUuid === '') { + if ($messageUuid === '' || $subscriberUuid === '') { return [$html, $text]; } @@ -78,8 +97,14 @@ public function trackLinks(string $html, string $text, int $campaignId, Subscrib return $this->createTrackingUrl($url, $type, $messageUuid, $subscriberUuid, $forwardIds); }; - $html = $this->rewriteHtml($html, $trackUrl); - $text = $this->rewriteText($text, $trackUrl); + $html = $this->linkRewriter->rewriteHtml( + $html, + fn (string $href): ?string => $trackUrl($href, LinkTrackToken::TYPE_HTML) + ); + $text = $this->linkRewriter->rewriteText( + $text, + fn (string $url): ?string => $trackUrl($url, LinkTrackToken::TYPE_TEXT) + ); foreach (array_keys($forwardIds) as $forwardId) { $this->linkTrackMlRepository->incrementTotal($campaignId, $forwardId); @@ -88,54 +113,6 @@ public function trackLinks(string $html, string $text, int $campaignId, Subscrib return [$html, $text]; } - /** @param callable(string, string): ?string $trackUrl */ - private function rewriteHtml(string $html, callable $trackUrl): string - { - if ($html === '') { - return $html; - } - - return (string) preg_replace_callback( - self::HTML_ANCHOR_PATTERN, - function (array $match) use ($trackUrl): string { - $linkText = strip_tags($match[5]); - if (stripos($linkText, 'http://') !== false || stripos($linkText, 'https://') !== false) { - return $match[0]; - } - - $href = html_entity_decode(trim($match[3]), ENT_QUOTES | ENT_HTML5); - $url = $trackUrl($href, LinkTrackToken::TYPE_HTML); - if ($url === null) { - return $match[0]; - } - - return $match[1] . 'href=' . $match[2] . htmlspecialchars($url, ENT_QUOTES) . $match[2] - . $match[4] . $match[5] . ''; - }, - $html - ); - } - - /** @param callable(string, string): ?string $trackUrl */ - private function rewriteText(string $text, callable $trackUrl): string - { - if ($text === '') { - return $text; - } - - return (string) preg_replace_callback( - self::TEXT_URL_PATTERN, - function (array $match) use ($trackUrl): string { - $trailing = str_ends_with($match[0], '.') ? '.' : ''; - $link = substr($match[0], 0, strlen($match[0]) - strlen($trailing)); - $url = $trackUrl($link, LinkTrackToken::TYPE_TEXT); - - return $url === null ? $match[0] : $url . $trailing; - }, - $text - ); - } - /** @param array $forwardIds collects the ids of the forwards used in the email */ private function createTrackingUrl( string $url, diff --git a/src/Domain/Analytics/Service/Query/AnalyticsQueryInterface.php b/src/Domain/Analytics/Service/Query/AnalyticsQueryInterface.php new file mode 100644 index 00000000..befaab1a --- /dev/null +++ b/src/Domain/Analytics/Service/Query/AnalyticsQueryInterface.php @@ -0,0 +1,30 @@ + $messageData the campaign's message data + * @return array query parameters as name => value, empty values are not added to links + */ + public function trackingParameters(string $emailFormat, array $messageData): array; + + /** + * The parameters a campaign can set, with the values to use when it has not set them. + * + * @param array $messageData + * @return array + */ + public function editableParameters(array $messageData): array; + + /** Prefix shared by the parameter names, used to take off tracking parameters that are already on a link. */ + public function prefix(): string; +} diff --git a/src/Domain/Analytics/Service/Query/GoogleAnalyticsQuery.php b/src/Domain/Analytics/Service/Query/GoogleAnalyticsQuery.php new file mode 100644 index 00000000..1ad2a4c2 --- /dev/null +++ b/src/Domain/Analytics/Service/Query/GoogleAnalyticsQuery.php @@ -0,0 +1,32 @@ + $messageData['utm_source'] ?? null, + 'utm_medium' => $messageData['utm_medium'] ?? null, + 'utm_campaign' => $messageData['utm_campaign'] ?? null, + 'utm_content' => $emailFormat, + ]; + } + + public function editableParameters(array $messageData): array + { + return [ + 'utm_source' => 'phpList', + 'utm_medium' => 'email', + 'utm_campaign' => (string) ($messageData['subject'] ?? ''), + ]; + } + + public function prefix(): string + { + return 'utm_'; + } +} diff --git a/src/Domain/Analytics/Service/Query/MatomoAnalyticsQuery.php b/src/Domain/Analytics/Service/Query/MatomoAnalyticsQuery.php new file mode 100644 index 00000000..ce975b67 --- /dev/null +++ b/src/Domain/Analytics/Service/Query/MatomoAnalyticsQuery.php @@ -0,0 +1,32 @@ + $messageData['pk_source'] ?? null, + 'pk_medium' => $messageData['pk_medium'] ?? null, + 'pk_campaign' => $messageData['pk_campaign'] ?? null, + 'pk_content' => $emailFormat, + ]; + } + + public function editableParameters(array $messageData): array + { + return [ + 'pk_source' => 'phpList', + 'pk_medium' => 'email', + 'pk_campaign' => (string) ($messageData['subject'] ?? ''), + ]; + } + + public function prefix(): string + { + return 'pk_'; + } +} diff --git a/src/Domain/Configuration/Model/ConfigOption.php b/src/Domain/Configuration/Model/ConfigOption.php index 34a2b306..41ec18dc 100644 --- a/src/Domain/Configuration/Model/ConfigOption.php +++ b/src/Domain/Configuration/Model/ConfigOption.php @@ -22,6 +22,7 @@ enum ConfigOption: string case MessageReplyToAddress = 'message_replyto_address'; case SystemMessageTemplate = 'systemmessagetemplate'; case AlwaysAddGoogleTracking = 'always_add_googletracking'; + case AnalyticTracker = 'analytic_tracker'; case AdminAddress = 'admin_address'; case AdminAddresses = 'admin_addresses'; case DefaultMessageTemplate = 'defaultmessagetemplate'; diff --git a/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php b/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php index 57cbc32b..928a5262 100644 --- a/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php +++ b/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php @@ -24,5 +24,7 @@ class MessagePrecacheDto public ?int $templateId = null; public bool $userSpecificUrl = false; public bool $googleTrack = false; + /** @var array the campaign's analytics settings (utm_*, pk_*) */ + public array $analyticsParameters = []; public array $adminAttributes = []; } diff --git a/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php b/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php index 44ed2eb5..3d752bb2 100644 --- a/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php +++ b/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php @@ -97,7 +97,8 @@ public function __invoke( textMessage: $textMessage, receiver: $receiver, campaignId: $campaignId, - forwardedBy: $forwardedBy, + messagePrecacheDto: $messagePrecacheDto, + forwarded: $forwardedBy !== null, ); $htmlMessage = $this->ensureHtmlFormating(content: $htmlMessage, addDefaultStyle: $addDefaultStyle); @@ -112,8 +113,7 @@ public function __invoke( } /** - * As in phplist3, links are tracked once all placeholders are expanded, but not in forwarded messages as the - * receivers are not necessarily subscribers. + * As in phplist3, links are tracked once all placeholders are expanded. * * @return array{0: string, 1: string} */ @@ -122,13 +122,21 @@ private function trackLinks( string $textMessage, Subscriber $receiver, ?int $campaignId, - ?Subscriber $forwardedBy, + MessagePrecacheDto $messagePrecacheDto, + bool $forwarded, ): array { - if ($campaignId === null || $forwardedBy !== null) { + if ($campaignId === null) { return [$htmlMessage, $textMessage]; } - return $this->linkTrackService->trackLinks($htmlMessage, $textMessage, $campaignId, $receiver); + return $this->linkTrackService->trackLinks( + html: $htmlMessage, + text: $textMessage, + campaignId: $campaignId, + subscriber: $receiver, + analyticsParameters: $messagePrecacheDto->googleTrack ? $messagePrecacheDto->analyticsParameters : null, + forwarded: $forwarded, + ); } private function replaceUserSpecificRemoteContent( diff --git a/src/Domain/Messaging/Service/MessagePrecacheService.php b/src/Domain/Messaging/Service/MessagePrecacheService.php index 59641c5f..86ed5b31 100644 --- a/src/Domain/Messaging/Service/MessagePrecacheService.php +++ b/src/Domain/Messaging/Service/MessagePrecacheService.php @@ -77,6 +77,7 @@ public function precacheMessage( } $messagePrecacheDto->googleTrack = (bool) $loadedMessageData['google_track']; + $messagePrecacheDto->analyticsParameters = $this->extractAnalyticsParameters($loadedMessageData); $this->applyBasicReplacements($messagePrecacheDto, $loadedMessageData); $this->populateAdminAttributes($messagePrecacheDto, $campaign); @@ -257,4 +258,20 @@ private function populateAdminAttributes(MessagePrecacheDto $messagePrecacheDto, $messagePrecacheDto->adminAttributes['LISTOWNER.' . $attr['name']] = $attr['value']; } } + + /** + * The analytics settings of the campaign: the Google (utm_*) and Matomo (pk_*) parameters it has set, and its + * subject, which is their default campaign name. + */ + private function extractAnalyticsParameters(array $loadedMessageData): array + { + $parameters = ['subject' => (string) ($loadedMessageData['subject'] ?? '')]; + foreach ($loadedMessageData as $name => $value) { + if (is_string($name) && preg_match('/^(utm|pk)_/', $name) === 1) { + $parameters[$name] = $value; + } + } + + return $parameters; + } } diff --git a/tests/Unit/Domain/Analytics/Service/AnalyticsTrackingServiceTest.php b/tests/Unit/Domain/Analytics/Service/AnalyticsTrackingServiceTest.php new file mode 100644 index 00000000..db8b2c73 --- /dev/null +++ b/tests/Unit/Domain/Analytics/Service/AnalyticsTrackingServiceTest.php @@ -0,0 +1,182 @@ +configProvider = $this->createMock(ConfigProvider::class); + $this->messageDataLoader = $this->createMock(MessageDataLoader::class); + $this->service = new AnalyticsTrackingService( + $this->configProvider, + $this->messageDataLoader, + new LinkRewriter(), + ); + } + + private function useTracker(?string $tracker): void + { + $this->configProvider->method('getValue')->with(ConfigOption::AnalyticTracker)->willReturn($tracker); + } + + public function testGoogleParametersAreAddedWithTheEmailFormat(): void + { + $this->useTracker('google'); + + $url = $this->service->addToUrl( + 'https://example.com/page', + 'HTML', + ['utm_source' => 'news', 'utm_medium' => 'mail', 'utm_campaign' => 'spring'] + ); + + self::assertSame( + 'https://example.com/page?utm_source=news&utm_medium=mail&utm_campaign=spring&utm_content=HTML', + $url + ); + } + + public function testMatomoParametersAreUsedWhenConfigured(): void + { + $this->useTracker('matomo'); + + $url = $this->service->addToUrl( + 'https://example.com/page?a=1', + 'text', + ['pk_source' => 'news', 'pk_medium' => 'mail', 'pk_campaign' => 'spring', 'utm_source' => 'ignored'] + ); + + self::assertSame( + 'https://example.com/page?a=1&pk_source=news&pk_medium=mail&pk_campaign=spring&pk_content=text', + $url + ); + } + + public function testCampaignWithoutOwnValuesGetsTheDefaults(): void + { + $this->useTracker('google'); + + $url = $this->service->addToUrl('https://example.com/', 'HTML', ['subject' => 'Hello World']); + + self::assertSame( + 'https://example.com/?utm_source=phpList&utm_medium=email&utm_campaign=Hello%20World&utm_content=HTML', + str_replace('+', '%20', $url) + ); + } + + public function testParameterSetToEmptyIsLeftOutInsteadOfGettingItsDefault(): void + { + $this->useTracker('google'); + + $url = $this->service->addToUrl( + 'https://example.com/', + 'HTML', + ['utm_source' => '', 'subject' => 'Hi'] + ); + + self::assertSame('https://example.com/?utm_medium=email&utm_campaign=Hi&utm_content=HTML', $url); + } + + public function testUnknownTrackerFallsBackToGoogle(): void + { + $this->useTracker(null); + + self::assertStringContainsString( + 'utm_content=HTML', + $this->service->addToUrl('https://example.com/', 'HTML', []) + ); + } + + public function testOnlyHttpUrlsGetParameters(): void + { + $this->useTracker('google'); + + foreach (['mailto:a@example.com', 'ftp://example.com/f', '[UNSUBSCRIBEURL]', '#top'] as $url) { + self::assertSame($url, $this->service->addToUrl($url, 'HTML', [])); + } + } + + public function testExistingTrackingParametersAreReplacedAndTheFragmentStaysLast(): void + { + $url = $this->service->withParameters( + 'https://example.com/p?a=1&utm_source=old&utm_medium=old&b=2#section', + ['utm_source' => 'new', 'utm_content' => 'HTML'], + 'utm_' + ); + + self::assertSame('https://example.com/p?a=1&b=2&utm_source=new&utm_content=HTML#section', $url); + } + + public function testEmptyParametersAreLeftOut(): void + { + self::assertSame( + 'https://example.com/?utm_content=text', + $this->service->withParameters( + 'https://example.com/', + ['utm_source' => '', 'utm_medium' => null, 'utm_content' => 'text'], + 'utm_' + ) + ); + self::assertSame( + 'https://example.com/#x', + $this->service->withParameters('https://example.com/#x', ['utm_source' => ''], 'utm_') + ); + } + + public function testBodiesGetParametersOnHtmlLinksAndTextUrls(): void + { + $this->useTracker('google'); + $data = ['utm_source' => 'news', 'utm_medium' => 'mail', 'utm_campaign' => 'c']; + + [$html, $text] = $this->service->addToBodies( + 'A m', + 'Read https://example.com/b. Thanks', + $data + ); + + self::assertStringContainsString( + 'href="https://example.com/a?x=1&y=2&utm_source=news&utm_medium=mail' + . '&utm_campaign=c&utm_content=HTML"', + $html + ); + self::assertStringContainsString('href="mailto:a@example.com"', $html); + self::assertSame( + 'Read https://example.com/b?utm_source=news&utm_medium=mail&utm_campaign=c&utm_content=text. Thanks', + $text + ); + } + + public function testClickedUrlGetsParametersOnlyWhenTheCampaignTracksAnalytics(): void + { + $this->useTracker('google'); + $campaign = $this->createMock(Message::class); + $this->messageDataLoader->method('__invoke')->with($campaign)->willReturnOnConsecutiveCalls( + ['google_track' => false, 'utm_source' => 'news'], + ['google_track' => true, 'utm_source' => 'news', 'utm_medium' => 'm', 'utm_campaign' => 'c'], + ); + + self::assertSame( + 'https://example.com/', + $this->service->addToClickedUrl('https://example.com/', $campaign, true) + ); + self::assertSame( + 'https://example.com/?utm_source=news&utm_medium=m&utm_campaign=c&utm_content=text', + $this->service->addToClickedUrl('https://example.com/', $campaign, false) + ); + } +} diff --git a/tests/Unit/Domain/Analytics/Service/LinkClickServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkClickServiceTest.php index 5b59986d..1f1ada68 100644 --- a/tests/Unit/Domain/Analytics/Service/LinkClickServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/LinkClickServiceTest.php @@ -10,6 +10,7 @@ use PhpList\Core\Domain\Analytics\Repository\LinkTrackForwardRepository; use PhpList\Core\Domain\Analytics\Repository\LinkTrackMlRepository; use PhpList\Core\Domain\Analytics\Repository\LinkTrackUmlClickRepository; +use PhpList\Core\Domain\Analytics\Service\AnalyticsTrackingService; use PhpList\Core\Domain\Analytics\Service\LinkClickService; use PhpList\Core\Domain\Analytics\Service\LinkTrackTokenService; use PhpList\Core\Domain\Analytics\Service\UserMessageService; @@ -31,6 +32,7 @@ class LinkClickServiceTest extends TestCase private SubscriberRepository&MockObject $subscriberRepository; private UserMessageRepository&MockObject $userMessageRepository; private UserMessageService&MockObject $userMessageService; + private AnalyticsTrackingService&MockObject $analyticsTrackingService; private LinkTrackTokenService $tokenService; private LinkClickService $service; private Message&MockObject $message; @@ -45,6 +47,8 @@ protected function setUp(): void $this->subscriberRepository = $this->createMock(SubscriberRepository::class); $this->userMessageRepository = $this->createMock(UserMessageRepository::class); $this->userMessageService = $this->createMock(UserMessageService::class); + $this->analyticsTrackingService = $this->createMock(AnalyticsTrackingService::class); + $this->analyticsTrackingService->method('addToClickedUrl')->willReturnArgument(0); $this->tokenService = new LinkTrackTokenService(new ParameterProvider([]), 'https://example.com/api/v2', 's'); $this->service = new LinkClickService( @@ -56,6 +60,7 @@ protected function setUp(): void $this->subscriberRepository, $this->userMessageRepository, $this->userMessageService, + $this->analyticsTrackingService, ); $this->message = $this->createMock(Message::class); @@ -113,6 +118,31 @@ public function testRedirectsToTheStoredUrlAndCountsTheClick(): void ); } + public function testAnalyticsParametersAreAddedToTheRedirectUrl(): void + { + $this->givenKnownLink(); + $this->givenUserMessage(viewed: true); + + $analytics = $this->createMock(AnalyticsTrackingService::class); + $analytics->expects(self::once()) + ->method('addToClickedUrl') + ->with('https://example.com/a', $this->message, true) + ->willReturn('https://example.com/a?utm_source=phpList'); + $service = new LinkClickService( + $this->tokenService, + $this->forwardRepository, + $this->mlRepository, + $this->umlClickRepository, + $this->messageRepository, + $this->subscriberRepository, + $this->userMessageRepository, + $this->userMessageService, + $analytics, + ); + + self::assertSame('https://example.com/a?utm_source=phpList', $service->trackClick($this->tid(), null, [])); + } + public function testTextClicksAreCountedAsText(): void { $this->givenKnownLink(); diff --git a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php index ace74a03..d6cfc5c8 100644 --- a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php @@ -9,6 +9,8 @@ use PhpList\Core\Domain\Analytics\Repository\LinkTrackForwardRepository; use PhpList\Core\Domain\Analytics\Repository\LinkTrackMlRepository; use PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository; +use PhpList\Core\Domain\Analytics\Service\AnalyticsTrackingService; +use PhpList\Core\Domain\Analytics\Service\LinkRewriter; use PhpList\Core\Domain\Analytics\Service\LinkTrackService; use PhpList\Core\Domain\Analytics\Service\LinkTrackTokenService; use PhpList\Core\Domain\Messaging\Model\Message; @@ -22,6 +24,7 @@ class LinkTrackServiceTest extends TestCase private LinkTrackForwardRepository&MockObject $forwardRepository; private LinkTrackMlRepository&MockObject $mlRepository; private MessageRepository&MockObject $messageRepository; + private AnalyticsTrackingService&MockObject $analyticsTrackingService; private LinkTrackTokenService $tokenService; private Message&MockObject $campaign; private Subscriber $subscriber; @@ -33,6 +36,7 @@ protected function setUp(): void $this->forwardRepository = $this->createMock(LinkTrackForwardRepository::class); $this->mlRepository = $this->createMock(LinkTrackMlRepository::class); $this->messageRepository = $this->createMock(MessageRepository::class); + $this->analyticsTrackingService = $this->createMock(AnalyticsTrackingService::class); $this->tokenService = new LinkTrackTokenService( new ParameterProvider([]), 'https://example.com/api/v2', @@ -76,6 +80,8 @@ private function createService(bool $clickTrack = true): LinkTrackService $this->messageRepository, $this->tokenService, new ParameterProvider(['click_track' => $clickTrack]), + new LinkRewriter(), + $this->analyticsTrackingService, ); } @@ -120,6 +126,8 @@ public function testContentIsReturnedUntouchedWhenCampaignHasNoUuid(): void $repository, $this->tokenService, new ParameterProvider(['click_track' => true]), + new LinkRewriter(), + $this->analyticsTrackingService, ); $html = 'x'; @@ -237,4 +245,40 @@ public function testTotalIsIncrementedOncePerDistinctLinkOfTheEmail(): void self::assertSame([[7, 1], [7, 2]], $incremented); } + + public function testAnalyticsParametersAreAddedStraightToTheLinksWithoutClickTracking(): void + { + $this->analyticsTrackingService->expects(self::once()) + ->method('addToBodies') + ->with('x', 'text', ['utm_source' => 'news']) + ->willReturn(['html with utm', 'text with utm']); + $this->forwardRepository->expects(self::never())->method('findOrCreateByUrl'); + + $result = $this->createService(false)->trackLinks( + 'x', + 'text', + 7, + $this->subscriber, + ['utm_source' => 'news'] + ); + + self::assertSame(['html with utm', 'text with utm'], $result); + } + + public function testAnalyticsParametersAreNotAddedByTheSendWhenClickTrackingIsOn(): void + { + $this->analyticsTrackingService->expects(self::never())->method('addToBodies'); + + $this->createService(true)->trackLinks('x', '', 7, $this->subscriber, []); + } + + public function testForwardedMessagesAreNotClickTracked(): void + { + $html = 'x'; + $this->forwardRepository->expects(self::never())->method('findOrCreateByUrl'); + + $result = $this->createService()->trackLinks($html, '', 7, $this->subscriber, null, true); + + self::assertSame([$html, ''], $result); + } } diff --git a/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php index ad4cde61..d463ac9e 100644 --- a/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php @@ -319,11 +319,14 @@ public function testLinksAreTrackedAfterPlaceholdersAreExpanded(): void $this->assertSame('tracked text', $text); } - public function testLinksAreNotTrackedInForwardedMessages(): void + public function testForwardedMessagesAreMarkedAsForwardedForLinkTracking(): void { $subscriber = $this->makeSubscriber(); $this->linkTrackService = $this->createMock(LinkTrackService::class); - $this->linkTrackService->expects($this->never())->method('trackLinks'); + $this->linkTrackService->expects($this->once()) + ->method('trackLinks') + ->with('

Hi

[expanded]', 'Hi[expanded]', 9, $subscriber, null, true) + ->willReturn(['

Hi

', 'Hi']); $dto = new MessagePrecacheDto(); $dto->content = '

Hi

'; @@ -332,4 +335,23 @@ public function testLinksAreNotTrackedInForwardedMessages(): void ($this->makeBuilder())($dto, $subscriber, 9, $this->createMock(Subscriber::class)); } + + public function testAnalyticsParametersAreHandedOverWhenTheCampaignTracksAnalytics(): void + { + $subscriber = $this->makeSubscriber(); + $this->linkTrackService = $this->createMock(LinkTrackService::class); + $this->linkTrackService->expects($this->once()) + ->method('trackLinks') + ->with('

Hi

[expanded]', 'Hi[expanded]', 9, $subscriber, ['utm_source' => 'news'], false) + ->willReturn(['

Hi

', 'Hi']); + + $dto = new MessagePrecacheDto(); + $dto->content = '

Hi

'; + $dto->textContent = 'Hi'; + $dto->htmlFormatted = true; + $dto->googleTrack = true; + $dto->analyticsParameters = ['utm_source' => 'news']; + + ($this->makeBuilder())($dto, $subscriber, 9); + } } From 0a8e6c375511e395fdbaf5bcfc8510c19591a507 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 2 Nov 2026 16:15:02 +0400 Subject: [PATCH 23/46] feat: add fallback for APP_WEBSITE in parameters.yml to support existing .env files --- config/parameters.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/parameters.yml b/config/parameters.yml index 36860be3..aa00f865 100644 --- a/config/parameters.yml +++ b/config/parameters.yml @@ -23,6 +23,8 @@ parameters: app.rest_api_base_url: '%env(API_BASE_URL)%/api/v2' app.api_base_url: '%env(API_BASE_URL)%' app.frontend_base_url: '%env(FRONT_END_BASE_URL)%' + # falls back to empty so existing .env files without APP_WEBSITE keep working (the value may live in the database) + env(APP_WEBSITE): '' app.website: '%env(APP_WEBSITE)%' app.parallel_use_with_phplist3: '%env(PARALLEL_USE_WITH_PHPLIST3)%' From b7e40ee6401ddf7b7761f6d2bc4da7a77f97d743 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 2 Nov 2026 16:16:54 +0400 Subject: [PATCH 24/46] feat: add restrictive umask when creating files in FileHelper to ensure privacy --- src/Domain/Common/FileHelper.php | 8 +++++++- tests/Unit/Domain/Common/FileHelperTest.php | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Domain/Common/FileHelper.php b/src/Domain/Common/FileHelper.php index 7073abf8..fed3649f 100644 --- a/src/Domain/Common/FileHelper.php +++ b/src/Domain/Common/FileHelper.php @@ -47,7 +47,13 @@ public function writeFileToDirectory(string $directory, string $originalFilename // Exclusive create (O_EXCL) never follows a symlink or reuses an existing entry, // so the file is created and written in a single step without rename. $fullPath = $realDirectory . '/' . $name . bin2hex(random_bytes(6)) . ($ext !== '' ? '.' . $ext : ''); - $handle = fopen($fullPath, 'xb'); + // Restrictive umask so the file is private from creation (like tempnam()), whatever the process umask is + $previousUmask = umask(0077); + try { + $handle = fopen($fullPath, 'xb'); + } finally { + umask($previousUmask); + } if ($handle === false) { return null; } diff --git a/tests/Unit/Domain/Common/FileHelperTest.php b/tests/Unit/Domain/Common/FileHelperTest.php index f9f231d9..4e1f3d7e 100644 --- a/tests/Unit/Domain/Common/FileHelperTest.php +++ b/tests/Unit/Domain/Common/FileHelperTest.php @@ -65,13 +65,19 @@ public function testWriteFileToDirectoryCreatesFileWithExtensionAndContents(): v { $helper = new FileHelper(); - $writtenPath = $helper->writeFileToDirectory($this->tmpDir, 'report.txt', 'payload'); + $previousUmask = umask(0022); + try { + $writtenPath = $helper->writeFileToDirectory($this->tmpDir, 'report.txt', 'payload'); + } finally { + umask($previousUmask); + } $this->assertNotNull($writtenPath); $this->assertStringStartsWith($this->tmpDir . '/', $writtenPath); $this->assertTrue(is_file($writtenPath)); $this->assertStringEndsWith('.txt', basename($writtenPath)); $this->assertSame('payload', file_get_contents($writtenPath)); + $this->assertSame(0600, fileperms($writtenPath) & 0777); } public function testWriteFileToDirectoryLeavesNoOrphanedTempFile(): void From b09f08a13ac2d64bf17d6c2354e48eafe22a570f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 2 Nov 2026 16:19:41 +0400 Subject: [PATCH 25/46] feat: refactor UserTrackValueResolver to use absolute REST API base URL and simplify domain handling --- .../Placeholder/UserTrackValueResolver.php | 8 ++- .../Provider/DefaultConfigProvider.php | 6 ++- .../UserTrackValueResolverTest.php | 52 +++---------------- 3 files changed, 14 insertions(+), 52 deletions(-) diff --git a/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php b/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php index 8a5b626d..f99efa2c 100644 --- a/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php @@ -4,15 +4,12 @@ namespace PhpList\Core\Domain\Configuration\Service\Placeholder; -use PhpList\Core\Domain\Configuration\Model\ConfigOption; -use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Configuration\Model\Dto\PlaceholderContext; use Symfony\Component\DependencyInjection\Attribute\Autowire; final class UserTrackValueResolver implements PlaceholderValueResolverInterface { public function __construct( - private readonly ConfigProvider $config, #[Autowire('%app.rest_api_base_url%')] private readonly string $restApiBaseUrl, ) { } @@ -24,9 +21,10 @@ public function name(): string public function __invoke(PlaceholderContext $ctx): string { - $base = $this->config->getValue(ConfigOption::Domain) ?? $this->restApiBaseUrl; + // The stored domain is scheme-free, which would make the pixel src relative, so always use the absolute base + $base = rtrim($this->restApiBaseUrl, '/'); - if ($ctx->isText() || empty($base)) { + if ($ctx->isText() || $base === '') { return ''; } $uid = $ctx->forwardedBy() ? 'forwarded' : $ctx->getUser()->getUniqueId(); diff --git a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php index 7d8b0f3d..c1aea578 100644 --- a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php @@ -29,6 +29,10 @@ private function init(): void return; } + // the website may contain a port or path, the email domain is the bare host without a leading www. + $host = parse_url('//' . $this->website, PHP_URL_HOST); + $domain = preg_replace('/^www\./i', '', is_string($host) ? $host : $this->website); + $publicSchema = 'http'; $pageRoot = '/api/v2'; @@ -42,7 +46,7 @@ private function init(): void 'category' => 'general', ], 'domain' => [ - 'value' => preg_replace('/^www\./i', '', $this->website), + 'value' => $domain, 'description' => $this->translator->trans('Domain Name of your server (for email)'), 'type' => 'text', 'allowempty' => false, diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php index 19caaada..6c10f0dd 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php @@ -4,24 +4,14 @@ namespace PhpList\Core\Tests\Unit\Domain\Configuration\Service\Placeholder; -use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Model\Dto\PlaceholderContext; use PhpList\Core\Domain\Configuration\Model\OutputFormat; use PhpList\Core\Domain\Configuration\Service\Placeholder\UserTrackValueResolver; -use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Subscription\Model\Subscriber; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; final class UserTrackValueResolverTest extends TestCase { - private ConfigProvider&MockObject $config; - - protected function setUp(): void - { - $this->config = $this->createMock(ConfigProvider::class); - } - private function makeUser(string $uid = 'U-42'): Subscriber { $u = new Subscriber('user@example.com'); @@ -31,24 +21,20 @@ private function makeUser(string $uid = 'U-42'): Subscriber public function testName(): void { - $resolver = new UserTrackValueResolver($this->config, 'https://api.example'); + $resolver = new UserTrackValueResolver('https://api.example'); $this->assertSame('USERTRACK', $resolver->name()); } public function testReturnsEmptyForTextFormat(): void { - $resolver = new UserTrackValueResolver($this->config, 'https://api.example'); + $resolver = new UserTrackValueResolver('https://api.example'); $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); $this->assertSame('', $resolver($ctx)); } - public function testHtmlUsesConfigDomainWhenAvailable(): void + public function testHtmlUsesAbsoluteRestApiBaseUrl(): void { - $this->config->method('getValue') - ->with(ConfigOption::Domain) - ->willReturn('example.com'); - - $resolver = new UserTrackValueResolver($this->config, 'https://api.example'); + $resolver = new UserTrackValueResolver('https://api.example/api/v2/'); $ctx = new PlaceholderContext( user: $this->makeUser('UID-XYZ'), @@ -59,33 +45,7 @@ public function testHtmlUsesConfigDomainWhenAvailable(): void messageId: 99, ); - $result = $resolver($ctx); - - $expected = ''; - // Normalize double quotes for comparison - $this->assertSame($expected, $result); - } - - public function testHtmlFallsBackToRestApiDomainWhenConfigMissing(): void - { - $this->config->method('getValue') - ->with(ConfigOption::Domain) - ->willReturn(null); - - $resolver = new UserTrackValueResolver($this->config, 'https://api.example'); - - $ctx = new PlaceholderContext( - user: $this->makeUser('U1'), - format: OutputFormat::Html, - messagePrecacheDto: null, - locale: 'en', - forwardedBy: null, - messageId: 7, - ); - - $result = $resolver($ctx); - - $expected = ''; - $this->assertSame($expected, $result); + $expected = ''; + $this->assertSame($expected, $resolver($ctx)); } } From f8b558fc293cff7125c52c99f4e4f4fc5bc49fab Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 2 Nov 2026 16:22:19 +0400 Subject: [PATCH 26/46] feat: enhance SubscriberCsvExporter to include blacklisted email checks and optimize data normalization --- .../Repository/UserBlacklistRepository.php | 24 ++++++++++++ .../Service/SubscriberCsvExporter.php | 38 +++++++++++++++---- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/Domain/Subscription/Repository/UserBlacklistRepository.php b/src/Domain/Subscription/Repository/UserBlacklistRepository.php index 9dabc9ff..5651c20c 100644 --- a/src/Domain/Subscription/Repository/UserBlacklistRepository.php +++ b/src/Domain/Subscription/Repository/UserBlacklistRepository.php @@ -58,4 +58,28 @@ public function isEmailBlacklisted(string $email, ?bool $immediate = true): bool ->getQuery() ->getOneOrNullResult() !== null; } + + /** + * Batch variant of isEmailBlacklisted(): returns the given emails (lowercased) that are blacklisted. + * + * @param string[] $emails + * @return string[] + */ + public function findBlacklistedEmails(array $emails): array + { + if ($emails === []) { + return []; + } + + $rows = $this->createQueryBuilder('ub') + ->select('ub.email') + ->where('ub.email IN (:emails)') + ->andWhere('ub.added < :cutoff') + ->setParameter('emails', array_values($emails)) + ->setParameter('cutoff', new DateTimeImmutable()) + ->getQuery() + ->getScalarResult(); + + return array_map(static fn(array $row): string => strtolower($row['email']), $rows); + } } diff --git a/src/Domain/Subscription/Service/SubscriberCsvExporter.php b/src/Domain/Subscription/Service/SubscriberCsvExporter.php index 9429349b..1420fe9e 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvExporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvExporter.php @@ -180,11 +180,16 @@ private function exportSubscribers( 'count' => $subscriberCount ]); + $blacklistedEmails = in_array('blacklisted', $headers, true) + ? $this->findBlacklistedEmails($subscribers) + : []; + foreach ($subscribers as $subscriber) { $row = $this->getSubscriberRow( subscriber: $subscriber, attributeDefinitions: $attributeDefinitions, headers: $headers, + blacklistedEmails: $blacklistedEmails, ); fputcsv($handle, $row); $lastId = $subscriber->getId(); @@ -211,11 +216,16 @@ private function exportSubscribers( * * @param Subscriber $subscriber The subscriber * @param SubscriberAttributeDefinition[] $attributeDefinitions Attribute definitions + * @param string[] $blacklistedEmails Lowercased emails of the batch found in the blacklist table * @return array Row data */ - private function getSubscriberRow(Subscriber $subscriber, array $attributeDefinitions, array $headers): array - { - $row = $this->normalizerSubscriberData($subscriber); + private function getSubscriberRow( + Subscriber $subscriber, + array $attributeDefinitions, + array $headers, + array $blacklistedEmails, + ): array { + $row = $this->normalizerSubscriberData($subscriber, $blacklistedEmails); foreach ($attributeDefinitions as $definition) { $attrValue = $this->attributeManager->getSubscriberAttribute( @@ -256,21 +266,33 @@ private function configureResponse(BinaryFileResponse $response): Response } /** - * re-verify the flag against the blacklist table so stale flags are not exported. + * Re-verifies the flag against the blacklist table with one query per batch, so stale flags are not exported. + * Subscribers whose stored flag is already set are not looked up. + * + * @param Subscriber[] $subscribers + * @return string[] lowercased blacklisted emails */ - private function isBlacklisted(Subscriber $subscriber): bool + private function findBlacklistedEmails(array $subscribers): array { - return $subscriber->isBlacklisted() || $this->blacklistRepository->isEmailBlacklisted($subscriber->getEmail()); + $emails = []; + foreach ($subscribers as $subscriber) { + if (!$subscriber->isBlacklisted()) { + $emails[] = $subscriber->getEmail(); + } + } + + return $this->blacklistRepository->findBlacklistedEmails($emails); } /** @SuppressWarnings("CyclomaticComplexity") */ - private function normalizerSubscriberData(Subscriber $subscriber): array + private function normalizerSubscriberData(Subscriber $subscriber, array $blacklistedEmails): array { return [ 'id' => $subscriber->getId(), 'email' => $subscriber->getEmail(), 'confirmed' => $subscriber->isConfirmed() ? '1' : '0', - 'blacklisted' => $this->isBlacklisted($subscriber) ? '1' : '0', + 'blacklisted' => $subscriber->isBlacklisted() + || in_array(strtolower($subscriber->getEmail()), $blacklistedEmails, true) ? '1' : '0', 'bounceCount' => $subscriber->getBounceCount(), 'createdAt' => $subscriber->getCreatedAt()?->format('Y-m-d H:i:s') ?? '', 'updatedAt' => $subscriber->getUpdatedAt()->format('Y-m-d H:i:s'), From bf29cc591060d18b5469de5c11cceb5f8a03b9a7 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 2 Nov 2026 16:28:27 +0400 Subject: [PATCH 27/46] feat: implement live blacklist status filtering in SubscriberRepository --- .../Repository/SubscriberRepository.php | 26 +++++++++++++++--- .../Repository/SubscriberRepositoryTest.php | 27 +++++++++++++++++++ .../UserTrackValueResolverTest.php | 3 ++- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index 4ee046c2..706e7692 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Domain\Subscription\Repository; +use DateTimeImmutable; use DateTimeInterface; use Doctrine\ORM\QueryBuilder; use InvalidArgumentException; @@ -14,6 +15,7 @@ use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberFilter; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\Subscription; +use PhpList\Core\Domain\Subscription\Model\UserBlacklist; /** * Repository for Subscriber models. @@ -146,9 +148,7 @@ public function getFilteredAfterId(FilterRequestInterface $filter): PaginatedRes } if ($filter->getIsBlacklisted() !== null) { - $queryBuilder - ->andWhere('subscriber.blacklisted = :isBlacklisted') - ->setParameter('isBlacklisted', $filter->getIsBlacklisted()); + $this->applyBlacklistedFilter($queryBuilder, $filter->getIsBlacklisted()); } if ($filter->getFindColumn() && $filter->getFindValue()) { @@ -296,6 +296,26 @@ public function getBounceReportRowsByListId(int $listId): array ->getArrayResult(); } + /** + * Matches the live blacklist status (stored flag or an entry in the blacklist table), the same status the CSV + * export writes, so a stale stored flag does not make the filter and the exported value disagree. + */ + private function applyBlacklistedFilter(QueryBuilder $queryBuilder, bool $isBlacklisted): void + { + $inBlacklistTable = sprintf( + 'EXISTS (SELECT 1 FROM %s ub WHERE ub.email = subscriber.email AND ub.added < :blacklistNow)', + UserBlacklist::class + ); + + $queryBuilder + ->andWhere( + $isBlacklisted + ? '(subscriber.blacklisted = true OR ' . $inBlacklistTable . ')' + : '(subscriber.blacklisted = false AND NOT ' . $inBlacklistTable . ')' + ) + ->setParameter('blacklistNow', new DateTimeImmutable()); + } + public function isEmailBlacklisted(string $email): bool { $queryBuilder = $this->getEntityManager()->createQueryBuilder(); diff --git a/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php b/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php index c835207c..48677399 100644 --- a/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php +++ b/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php @@ -11,6 +11,7 @@ use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberList; use PhpList\Core\Domain\Subscription\Model\Subscription; +use PhpList\Core\Domain\Subscription\Model\UserBlacklist; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository; use PhpList\Core\TestingSupport\Traits\DatabaseTestTrait; @@ -303,6 +304,32 @@ public function testGetSubscribersBySubscribedListIdsReturnsEmptyArrayForEmptyIn self::assertSame([], $this->subscriberRepository->getSubscribersBySubscribedListIds([])); } + public function testGetFilteredAfterIdBlacklistedFilterUsesLiveBlacklistStatus(): void + { + $flagged = (new Subscriber('flagged@example.com'))->setBlacklisted(true); + $stale = new Subscriber('stale@example.com'); + $clean = new Subscriber('clean@example.com'); + foreach ([$flagged, $stale, $clean] as $subscriber) { + $this->entityManager->persist($subscriber); + } + $this->entityManager->persist((new UserBlacklist('stale@example.com'))->setAdded(new DateTime('-1 day'))); + $this->entityManager->flush(); + + $emailsFor = function (bool $isBlacklisted): array { + $result = $this->subscriberRepository->getFilteredAfterId( + new SubscriberFilter(isBlacklisted: $isBlacklisted) + ); + + return array_map( + static fn (Subscriber $subscriber): string => $subscriber->getEmail(), + $result->getItems() + ); + }; + + self::assertEqualsCanonicalizing(['flagged@example.com', 'stale@example.com'], $emailsFor(true)); + self::assertSame(['clean@example.com'], $emailsFor(false)); + } + public function testGetFilteredAfterIdWithoutListFilterReturnsAllSubscribersOnce(): void { $list = (new SubscriberList())->setName('list'); diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php index 6c10f0dd..21992a4a 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php @@ -45,7 +45,8 @@ public function testHtmlUsesAbsoluteRestApiBaseUrl(): void messageId: 99, ); - $expected = ''; + $expected = ''; $this->assertSame($expected, $resolver($ctx)); } } From d4d78c8f9a73e6b47181fa93b3dc76f2bc15c410 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 2 Nov 2026 16:57:38 +0400 Subject: [PATCH 28/46] feat: update SubscriberCsvImporter to ignore CSV blacklisted flag and ensure blacklist status is determined by the repository --- .../Subscription/Model/Dto/ImportSubscriberDto.php | 5 ----- src/Domain/Subscription/Service/ArrayToDtoMapper.php | 2 +- .../Subscription/Service/Manager/SubscriberManager.php | 2 -- .../Subscription/Service/SubscriberCsvImporter.php | 4 ++-- .../Service/SubscriberCsvImportManagerTest.php | 4 ++-- .../Subscription/Service/SubscriberCsvImporterTest.php | 10 +--------- 6 files changed, 6 insertions(+), 21 deletions(-) diff --git a/src/Domain/Subscription/Model/Dto/ImportSubscriberDto.php b/src/Domain/Subscription/Model/Dto/ImportSubscriberDto.php index ee468fa1..c76c3430 100644 --- a/src/Domain/Subscription/Model/Dto/ImportSubscriberDto.php +++ b/src/Domain/Subscription/Model/Dto/ImportSubscriberDto.php @@ -20,9 +20,6 @@ class ImportSubscriberDto #[Assert\Type('bool')] public bool $confirmed; - #[Assert\Type('bool')] - public bool $blacklisted; - #[Assert\Type('bool')] public bool $htmlEmail; @@ -39,7 +36,6 @@ class ImportSubscriberDto public function __construct( string $email, bool $confirmed, - bool $blacklisted, bool $htmlEmail, bool $disabled, ?string $extraData = null, @@ -49,7 +45,6 @@ public function __construct( $this->email = $email; $this->confirmed = $confirmed; $this->sendConfirmation = !$confirmed; - $this->blacklisted = $blacklisted; $this->htmlEmail = $htmlEmail; $this->disabled = $disabled; $this->extraData = $extraData; diff --git a/src/Domain/Subscription/Service/ArrayToDtoMapper.php b/src/Domain/Subscription/Service/ArrayToDtoMapper.php index dcd2f6e8..52e8be47 100644 --- a/src/Domain/Subscription/Service/ArrayToDtoMapper.php +++ b/src/Domain/Subscription/Service/ArrayToDtoMapper.php @@ -9,6 +9,7 @@ class ArrayToDtoMapper { private const FK_HEADER = 'foreignkey'; + // 'blacklisted' stays a known header so the column is ignored instead of becoming an attribute private const KNOWN_HEADERS = [ 'email', 'confirmed', 'blacklisted', 'html_email', 'disabled', 'extra_data', 'foreignkey', ]; @@ -31,7 +32,6 @@ public function map(array $row): ImportSubscriberDto return new ImportSubscriberDto( email: $email, confirmed: filter_var($normalizedRow['confirmed'] ?? false, FILTER_VALIDATE_BOOLEAN), - blacklisted: filter_var($normalizedRow['blacklisted'] ?? false, FILTER_VALIDATE_BOOLEAN), htmlEmail: filter_var($normalizedRow['html_email'] ?? false, FILTER_VALIDATE_BOOLEAN), disabled: filter_var($normalizedRow['disabled'] ?? false, FILTER_VALIDATE_BOOLEAN), extraData: $normalizedRow['extra_data'] ?? null, diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php index 189a2881..6c9e6f6d 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php @@ -110,7 +110,6 @@ public function createFromImport(ImportSubscriberDto $subscriberDto): Subscriber { $subscriber = new Subscriber($subscriberDto->email); $subscriber->setConfirmed($subscriberDto->confirmed); - $subscriber->setBlacklisted($subscriberDto->blacklisted); $subscriber->setHtmlEmail($subscriberDto->htmlEmail); $subscriber->setDisabled($subscriberDto->disabled); $subscriber->setExtraData($subscriberDto->extraData); @@ -127,7 +126,6 @@ public function updateFromImport(Subscriber $existingSubscriber, ImportSubscribe { $existingSubscriber->setEmail($subscriberDto->email); $existingSubscriber->setConfirmed($subscriberDto->confirmed); - $existingSubscriber->setBlacklisted($subscriberDto->blacklisted); $existingSubscriber->setHtmlEmail($subscriberDto->htmlEmail); $existingSubscriber->setDisabled($subscriberDto->disabled); $existingSubscriber->setExtraData($subscriberDto->extraData); diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php index d82b0c4d..18ee26a1 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php @@ -189,10 +189,10 @@ private function processRow( $this->attributeManager->processAttributes($subscriber, $dto->extraAttributes); - // as in phplist3, only the blacklist table decides; a blacklisted flag from the CSV alone does not + // as in phplist3, the CSV blacklisted column is ignored; only the blacklist table decides, and the flag is + // only ever raised, never cleared, by an import $isBlacklisted = $this->blacklistRepository->isEmailBlacklisted($subscriber->getEmail()); if ($isBlacklisted) { - // make sure the flag reflects the blacklist table $subscriber->setBlacklisted(true); $stats['blacklisted']++; } diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php index 9c27c1c9..914cdde8 100644 --- a/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php +++ b/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php @@ -86,7 +86,7 @@ public function testImportFromCsvCreatesNewSubscribers(): void self::assertInstanceOf(Subscriber::class, $subscriber2); self::assertFalse($subscriber2->isConfirmed()); self::assertFalse($subscriber2->hasHtmlEmail()); - self::assertTrue($subscriber2->isBlacklisted()); + self::assertFalse($subscriber2->isBlacklisted()); self::assertTrue($subscriber2->isDisabled()); self::assertSame('More data', $subscriber2->getExtraData()); @@ -130,7 +130,7 @@ public function testImportFromCsvUpdatesExistingSubscribers(): void self::assertInstanceOf(Subscriber::class, $updatedSubscriber); self::assertTrue($updatedSubscriber->isConfirmed()); self::assertTrue($updatedSubscriber->hasHtmlEmail()); - self::assertFalse($updatedSubscriber->isBlacklisted()); + self::assertTrue($updatedSubscriber->isBlacklisted()); self::assertFalse($updatedSubscriber->isDisabled()); self::assertSame('Updated data', $updatedSubscriber->getExtraData()); diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php index d429c5ca..ec46f9c1 100644 --- a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php +++ b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php @@ -104,7 +104,6 @@ public function testImportFromCsvCreatesNewSubscribers(): void $importDto1 = new ImportSubscriberDto( email: 'test@example.com', confirmed: true, - blacklisted: false, htmlEmail: true, disabled: false, ); @@ -115,7 +114,6 @@ public function testImportFromCsvCreatesNewSubscribers(): void $importDto2 = new ImportSubscriberDto( email: 'another@example.com', confirmed: false, - blacklisted: true, htmlEmail: false, disabled: true ); @@ -211,7 +209,6 @@ public function testImportFromCsvUpdatesExistingSubscribers(): void $importDto = new ImportSubscriberDto( email: 'existing@example.com', confirmed: true, - blacklisted: false, htmlEmail: true, disabled: false, ); @@ -281,7 +278,6 @@ public function testImportResolvesByForeignKeyWhenProvidedAndMatches(): void $dto = new ImportSubscriberDto( email: 'user@example.com', confirmed: true, - blacklisted: false, htmlEmail: true, disabled: false, extraData: null, @@ -346,7 +342,6 @@ public function testImportConflictWhenEmailAndForeignKeyReferToDifferentSubscrib $dto = new ImportSubscriberDto( email: 'conflict@example.com', confirmed: true, - blacklisted: false, htmlEmail: true, disabled: false, extraData: null, @@ -406,7 +401,6 @@ public function testImportResolvesByEmailWhenForeignKeyNotFound(): void $dto = new ImportSubscriberDto( email: 'existing@example.com', confirmed: true, - blacklisted: false, htmlEmail: true, disabled: false, extraData: null, @@ -466,7 +460,6 @@ public function testImportCreatesNewWhenNeitherEmailNorForeignKeyFound(): void $dto = new ImportSubscriberDto( email: 'new@example.com', confirmed: false, - blacklisted: false, htmlEmail: true, disabled: false, extraData: null, @@ -520,7 +513,6 @@ public function testImportCountsSubscriberWhoseEmailIsInBlacklistTable(): void $dto = new ImportSubscriberDto( email: 'bl@example.com', confirmed: false, - blacklisted: false, htmlEmail: true, disabled: false, ); @@ -565,7 +557,6 @@ public function testImportIgnoresBlacklistedFlagWhenEmailIsNotInBlacklistTable() $dto = new ImportSubscriberDto( email: 'flag@example.com', confirmed: true, - blacklisted: true, htmlEmail: true, disabled: false, ); @@ -575,6 +566,7 @@ public function testImportIgnoresBlacklistedFlagWhenEmailIsNotInBlacklistTable() $created->method('getId')->willReturn(8); $created->method('getEmail')->willReturn('flag@example.com'); $created->method('isBlacklisted')->willReturn(true); + $created->expects($this->never())->method('setBlacklisted'); $this->subscriberManagerMock->method('createFromImport')->willReturn($created); $this->blacklistRepositoryMock->method('isEmailBlacklisted')->willReturn(false); From 15579b1a739bc143c483728553255dece61f397c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 2 Nov 2026 18:15:46 +0400 Subject: [PATCH 29/46] feat: implement method to remove query parameters with specified prefix from URLs in AnalyticsTrackingService --- .../Service/AnalyticsTrackingService.php | 32 ++++++++++++++++--- .../Service/AnalyticsTrackingServiceTest.php | 16 ++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/Domain/Analytics/Service/AnalyticsTrackingService.php b/src/Domain/Analytics/Service/AnalyticsTrackingService.php index 1525176d..a9e19ecf 100644 --- a/src/Domain/Analytics/Service/AnalyticsTrackingService.php +++ b/src/Domain/Analytics/Service/AnalyticsTrackingService.php @@ -103,11 +103,7 @@ public function withParameters(string $url, array $trackingParameters, string $p $url = substr($url, 0, $position); } - if (str_contains($url, $prefix)) { - // the expression can leave a trailing ? or & behind - $pattern = sprintf('/%s.+?=[^&]+(&|$)/', preg_quote($prefix, '/')); - $url = rtrim((string) preg_replace($pattern, '', $url), '?&'); - } + $url = $this->withoutPrefixedParameters($url, $prefix); $trackingCode = http_build_query( array_filter($trackingParameters, static fn ($value): bool => $value !== null && $value !== '') @@ -119,6 +115,32 @@ public function withParameters(string $url, array $trackingParameters, string $p return $url . (str_contains($url, '?') ? '&' : '?') . $trackingCode . $fragment; } + /** + * Takes off the query parameters whose name starts with the prefix, leaving the path and the other parameters + * as they are. The url is left untouched when nothing is removed. + */ + private function withoutPrefixedParameters(string $url, string $prefix): string + { + $queryPosition = strpos($url, '?'); + if ($prefix === '' || $queryPosition === false) { + return $url; + } + + $base = substr($url, 0, $queryPosition); + $pairs = explode('&', substr($url, $queryPosition + 1)); + $kept = array_filter( + $pairs, + static fn (string $pair): bool => !str_starts_with(urldecode(explode('=', $pair, 2)[0]), $prefix) + ); + if (count($kept) === count($pairs)) { + return $url; + } + + $kept = array_filter($kept, static fn (string $pair): bool => $pair !== ''); + + return $kept === [] ? $base : $base . '?' . implode('&', $kept); + } + private function getQuery(): AnalyticsQueryInterface { return $this->configProvider->getValue(ConfigOption::AnalyticTracker) === 'matomo' diff --git a/tests/Unit/Domain/Analytics/Service/AnalyticsTrackingServiceTest.php b/tests/Unit/Domain/Analytics/Service/AnalyticsTrackingServiceTest.php index db8b2c73..2aa6e120 100644 --- a/tests/Unit/Domain/Analytics/Service/AnalyticsTrackingServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/AnalyticsTrackingServiceTest.php @@ -122,6 +122,22 @@ public function testExistingTrackingParametersAreReplacedAndTheFragmentStaysLast self::assertSame('https://example.com/p?a=1&b=2&utm_source=new&utm_content=HTML#section', $url); } + public function testOnlyQueryParametersStartingWithThePrefixAreRemoved(): void + { + $this->assertSame( + 'https://example.com/utm_page?x_utm_a=1&b=2', + $this->service->withParameters( + 'https://example.com/utm_page?x_utm_a=1&utm_source=old&&b=2&utm_medium=old', + [], + 'utm_' + ) + ); + $this->assertSame( + 'https://example.com/utm_page', + $this->service->withParameters('https://example.com/utm_page?utm_source=old', [], 'utm_') + ); + } + public function testEmptyParametersAreLeftOut(): void { self::assertSame( From 8d8ef6a7cae4c14b3b6e8290e1d6eeea8fe6c0a0 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 2 Nov 2026 18:18:23 +0400 Subject: [PATCH 30/46] feat: enhance LinkRewriter to handle PCRE errors gracefully and add unit tests --- src/Domain/Analytics/Service/LinkRewriter.php | 10 ++-- .../Analytics/Service/LinkRewriterTest.php | 53 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Domain/Analytics/Service/LinkRewriterTest.php diff --git a/src/Domain/Analytics/Service/LinkRewriter.php b/src/Domain/Analytics/Service/LinkRewriter.php index c0accbea..8da1e305 100644 --- a/src/Domain/Analytics/Service/LinkRewriter.php +++ b/src/Domain/Analytics/Service/LinkRewriter.php @@ -24,7 +24,8 @@ public function rewriteHtml(string $html, callable $rewrite): string return $html; } - return (string) preg_replace_callback( + // null means a PCRE error (e.g. the backtrack limit on a large body): keep the original instead of emptying it + return preg_replace_callback( self::HTML_ANCHOR_PATTERN, function (array $match) use ($rewrite): string { $linkText = strip_tags($match[5]); @@ -42,7 +43,7 @@ function (array $match) use ($rewrite): string { . $match[4] . $match[5] . ''; }, $html - ); + ) ?? $html; } /** @@ -56,7 +57,8 @@ public function rewriteText(string $text, callable $rewrite): string return $text; } - return (string) preg_replace_callback( + // null means a PCRE error: keep the original instead of emptying the text + return preg_replace_callback( self::TEXT_URL_PATTERN, function (array $match) use ($rewrite): string { $trailing = str_ends_with($match[0], '.') ? '.' : ''; @@ -66,6 +68,6 @@ function (array $match) use ($rewrite): string { return $url === null ? $match[0] : $url . $trailing; }, $text - ); + ) ?? $text; } } diff --git a/tests/Unit/Domain/Analytics/Service/LinkRewriterTest.php b/tests/Unit/Domain/Analytics/Service/LinkRewriterTest.php new file mode 100644 index 00000000..942f665b --- /dev/null +++ b/tests/Unit/Domain/Analytics/Service/LinkRewriterTest.php @@ -0,0 +1,53 @@ +backtrackLimit = (string) ini_get('pcre.backtrack_limit'); + } + + protected function tearDown(): void + { + ini_set('pcre.backtrack_limit', $this->backtrackLimit); + } + + public function testHtmlIsRewritten(): void + { + $result = (new LinkRewriter())->rewriteHtml( + '

link

', + static fn (string $url): string => $url . '?x=1' + ); + + $this->assertSame('

link

', $result); + } + + public function testHtmlIsKeptWhenThePatternFailsWithAPcreError(): void + { + $html = '' . str_repeat('word ', 200) . ''; + ini_set('pcre.backtrack_limit', '10'); + + $result = (new LinkRewriter())->rewriteHtml($html, static fn (string $url): string => $url . '?x=1'); + + $this->assertSame($html, $result); + } + + public function testTextIsRewritten(): void + { + $result = (new LinkRewriter())->rewriteText( + 'see https://example.com/a.', + static fn (string $url): string => $url . '?x=1' + ); + + $this->assertSame('see https://example.com/a?x=1.', $result); + } +} From 48b8ab87919fd8adab59791c725594ec0c8e6d46 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 3 Nov 2026 12:02:13 +0400 Subject: [PATCH 31/46] feat: implement storeMessageData method in MessageDataManager and add unit tests --- .../Service/Manager/MessageDataManager.php | 51 ++++++++ .../Manager/MessageDataManagerTest.php | 118 ++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 tests/Unit/Domain/Messaging/Service/Manager/MessageDataManagerTest.php diff --git a/src/Domain/Messaging/Service/Manager/MessageDataManager.php b/src/Domain/Messaging/Service/Manager/MessageDataManager.php index b8dab1c2..24b50582 100644 --- a/src/Domain/Messaging/Service/Manager/MessageDataManager.php +++ b/src/Domain/Messaging/Service/Manager/MessageDataManager.php @@ -6,12 +6,31 @@ use Doctrine\ORM\EntityManagerInterface; use HTMLPurifier; +use InvalidArgumentException; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\MessageData; use PhpList\Core\Domain\Messaging\Repository\MessageDataRepository; class MessageDataManager { + /** + * The only settings storeMessageData() accepts. Everything else in messagedata is either kept on the message + * itself (subject, from field, schedule, ...) or maintained by the system (status, sizes, ETA, statistics, + * ...) and must not be overwritten by a caller-supplied value. + */ + public const WRITABLE_SETTINGS = [ + 'google_track', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'pk_source', + 'pk_medium', + 'pk_campaign', + 'notify_start', + 'notify_end', + 'excludelist', + ]; + public function __construct( private readonly MessageDataRepository $messageDataRepository, private readonly EntityManagerInterface $entityManager, @@ -44,6 +63,38 @@ public function setMessageData(Message $campaign, string $name, mixed $value): v $entity->setData($value !== null ? (string) $value : null); } + /** + * Stores a set of campaign settings the way phplist3's storemessage.php does for the campaign form. + * + * Checkboxes that are not submitted when unchecked must be listed in $checkboxes: they are all reset to '0' + * first and then set again by $data when they were submitted. Every entry of $data is stored under its own + * name, booleans as '1' / '0'. + * + * Only the names in WRITABLE_SETTINGS are accepted; nothing is stored when any other name is given. + * The campaign must already have an id, so a new campaign has to be flushed first. + * + * @param array $data setting name => value + * @param string[] $checkboxes names of the checkbox settings that are part of this save + * @throws InvalidArgumentException when a name is not in WRITABLE_SETTINGS + */ + public function storeMessageData(Message $campaign, array $data, array $checkboxes = []): void + { + $notWritable = array_diff([...array_keys($data), ...$checkboxes], self::WRITABLE_SETTINGS); + if ($notWritable !== []) { + throw new InvalidArgumentException( + 'Message data cannot be set for: ' . implode(', ', array_map('strval', $notWritable)) + ); + } + + foreach ($checkboxes as $name) { + $this->setMessageData($campaign, $name, '0'); + } + + foreach ($data as $name => $value) { + $this->setMessageData($campaign, (string) $name, is_bool($value) ? ($value ? '1' : '0') : $value); + } + } + private function normalizeValueByName(string $name, mixed $value) { return match ($name) { diff --git a/tests/Unit/Domain/Messaging/Service/Manager/MessageDataManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/MessageDataManagerTest.php new file mode 100644 index 00000000..c4aa5590 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Manager/MessageDataManagerTest.php @@ -0,0 +1,118 @@ + */ + private array $stored = []; + + protected function setUp(): void + { + $this->repository = $this->createMock(MessageDataRepository::class); + $this->repository->method('findByIdAndName')->willReturnCallback( + fn (int $id, string $name): ?MessageData => $this->stored[$name] ?? null + ); + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->method('persist')->willReturnCallback(function (MessageData $data): void { + $this->stored[$data->getName()] = $data; + }); + + $this->manager = new MessageDataManager( + $this->repository, + $entityManager, + $this->createMock(HTMLPurifier::class) + ); + $this->campaign = $this->createMock(Message::class); + $this->campaign->method('getId')->willReturn(7); + } + + /** @return array */ + private function storedValues(): array + { + return array_map(static fn (MessageData $data): ?string => $data->getData(), $this->stored); + } + + public function testEveryEntryIsStoredUnderItsOwnName(): void + { + $this->manager->storeMessageData($this->campaign, [ + 'utm_source' => 'newsletter', + 'notify_start' => 'a@example.com', + 'excludelist' => [3, 'x', 5], + ]); + + $values = $this->storedValues(); + self::assertSame('newsletter', $values['utm_source']); + self::assertSame('a@example.com', $values['notify_start']); + self::assertSame('SER:' . serialize([0 => 3, 2 => 5]), $values['excludelist']); + self::assertSame(7, $this->stored['utm_source']->getId()); + } + + public function testBooleansAreStoredAsOneAndZero(): void + { + $this->manager->storeMessageData($this->campaign, ['google_track' => true, 'pk_source' => false]); + + self::assertSame(['google_track' => '1', 'pk_source' => '0'], $this->storedValues()); + } + + public function testCheckboxesAreResetBeforeTheSubmittedValuesAreStored(): void + { + $this->manager->storeMessageData( + $this->campaign, + ['google_track' => '1'], + ['google_track', 'pk_source'] + ); + + self::assertSame(['google_track' => '1', 'pk_source' => '0'], $this->storedValues()); + } + + public function testExistingValuesAreOverwritten(): void + { + $this->manager->storeMessageData($this->campaign, ['utm_source' => 'old']); + $this->manager->storeMessageData($this->campaign, ['utm_source' => 'new']); + + self::assertSame(['utm_source' => 'new'], $this->storedValues()); + } + + /** @return array, 1: string[]}> */ + public static function notWritableProvider(): array + { + return [ + 'system setting' => [['status' => 'submitted'], []], + 'message column' => [['subject' => 'x'], []], + 'session name' => [['PHPSESSID' => 'abc'], []], + 'checkbox' => [[], ['resetstats']], + ]; + } + + /** + * @dataProvider notWritableProvider + * @param array $data + * @param string[] $checkboxes + */ + public function testNothingIsStoredWhenAnyNameIsNotWritable(array $data, array $checkboxes): void + { + $this->expectException(InvalidArgumentException::class); + + try { + $this->manager->storeMessageData($this->campaign, ['utm_medium' => 'email'] + $data, $checkboxes); + } finally { + self::assertSame([], $this->storedValues()); + } + } +} From 668d712992fe95f8732d71fec3ec699814f5c4c7 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 3 Nov 2026 13:32:52 +0400 Subject: [PATCH 32/46] feat: update LinkClickService to allow tracking clicks for subscribers without a record of the campaign and adjust related tests --- .../Analytics/Service/LinkClickService.php | 14 +++++--------- .../Analytics/Service/LinkClickServiceTest.php | 18 ++++++++++++------ .../Analytics/Service/LinkTrackServiceTest.php | 4 +++- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/Domain/Analytics/Service/LinkClickService.php b/src/Domain/Analytics/Service/LinkClickService.php index 2eb48dc0..a6d48ab2 100644 --- a/src/Domain/Analytics/Service/LinkClickService.php +++ b/src/Domain/Analytics/Service/LinkClickService.php @@ -10,7 +10,6 @@ use PhpList\Core\Domain\Analytics\Repository\LinkTrackMlRepository; use PhpList\Core\Domain\Analytics\Repository\LinkTrackUmlClickRepository; use PhpList\Core\Domain\Messaging\Model\Message; -use PhpList\Core\Domain\Messaging\Model\UserMessage; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; use PhpList\Core\Domain\Subscription\Model\Subscriber; @@ -59,7 +58,7 @@ public function trackClick(string $tid, ?string $signature, array $metadata): ?s } $userMessage = $this->userMessageRepository->findByUserAndMessage($subscriber, $message); - $url = $this->resolveUrl($forward, $subscriber, $userMessage); + $url = $this->resolveUrl($forward, $subscriber); if ($url === null) { return null; } @@ -69,8 +68,9 @@ public function trackClick(string $tid, ?string $signature, array $metadata): ?s $forwardId = (int) $forward->getId(); $this->mlRepository->recordClick($messageId, $forwardId, $token->isHtml(), $now); - // a click on a link also proves the campaign was opened, but only the first one counts as a view - if ($userMessage !== null && !$userMessage->isViewed()) { + // a click on a link also proves the campaign was opened, but only the first one counts as a view. Like in + // phplist3, a subscriber without a record of receiving the campaign (a test) counts every time. + if ($userMessage === null || !$userMessage->isViewed()) { $this->userMessageService->trackUserMessageView($subscriber->getUniqueId(), $messageId, $metadata); } @@ -85,7 +85,7 @@ public function trackClick(string $tid, ?string $signature, array $metadata): ?s return $this->analyticsTrackingService->addToClickedUrl($url, $message, $token->isHtml()); } - private function resolveUrl(LinkTrackForward $forward, Subscriber $subscriber, ?UserMessage $userMessage): ?string + private function resolveUrl(LinkTrackForward $forward, Subscriber $subscriber): ?string { $url = $forward->getUrl(); if ($url === null) { @@ -93,10 +93,6 @@ private function resolveUrl(LinkTrackForward $forward, Subscriber $subscriber, ? } if ($forward->isPersonalise()) { - // personalised links are only valid for subscribers the campaign was actually sent to - if ($userMessage === null) { - return null; - } // the uid has to go before any fragment, which is never sent to the server $fragmentPos = strpos($url, '#'); $fragment = $fragmentPos === false ? '' : substr($url, $fragmentPos); diff --git a/tests/Unit/Domain/Analytics/Service/LinkClickServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkClickServiceTest.php index 1f1ada68..bc2ed7e0 100644 --- a/tests/Unit/Domain/Analytics/Service/LinkClickServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/LinkClickServiceTest.php @@ -13,6 +13,7 @@ use PhpList\Core\Domain\Analytics\Service\AnalyticsTrackingService; use PhpList\Core\Domain\Analytics\Service\LinkClickService; use PhpList\Core\Domain\Analytics\Service\LinkTrackTokenService; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Analytics\Service\UserMessageService; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\UserMessage; @@ -49,7 +50,12 @@ protected function setUp(): void $this->userMessageService = $this->createMock(UserMessageService::class); $this->analyticsTrackingService = $this->createMock(AnalyticsTrackingService::class); $this->analyticsTrackingService->method('addToClickedUrl')->willReturnArgument(0); - $this->tokenService = new LinkTrackTokenService(new ParameterProvider([]), 'https://example.com/api/v2', 's'); + $this->tokenService = new LinkTrackTokenService( + new ParameterProvider([]), + 'https://example.com/api/v2', + 's', + $this->createMock(ConfigProvider::class) + ); $this->service = new LinkClickService( $this->tokenService, @@ -196,22 +202,22 @@ public function testPersonalisedLinkWithOnlyAFragmentStartsTheQueryBeforeIt(): v ); } - public function testPersonalisedLinkIsRefusedForSubscribersThatNeverGotTheCampaign(): void + public function testPersonalisedLinkWorksForSubscribersWithoutRecordOfTheCampaign(): void { $this->givenKnownLink('https://example.com/p', personalise: true); $this->userMessageRepository->method('findByUserAndMessage')->willReturn(null); - $this->mlRepository->expects(self::never())->method('recordClick'); + $this->mlRepository->expects(self::once())->method('recordClick'); - self::assertNull($this->service->trackClick($this->tid(), null, [])); + self::assertSame('https://example.com/p?uid=uniq-3', $this->service->trackClick($this->tid(), null, [])); } - public function testNonPersonalisedLinkWorksWithoutUserMessage(): void + public function testNonPersonalisedLinkWorksWithoutUserMessageAndCountsEveryClickAsView(): void { $this->givenKnownLink(); $this->userMessageRepository->method('findByUserAndMessage')->willReturn(null); - $this->userMessageService->expects(self::never())->method('trackUserMessageView'); + $this->userMessageService->expects(self::once())->method('trackUserMessageView'); self::assertSame('https://example.com/a', $this->service->trackClick($this->tid(), null, [])); } diff --git a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php index d6cfc5c8..c523afa4 100644 --- a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php @@ -13,6 +13,7 @@ use PhpList\Core\Domain\Analytics\Service\LinkRewriter; use PhpList\Core\Domain\Analytics\Service\LinkTrackService; use PhpList\Core\Domain\Analytics\Service\LinkTrackTokenService; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Subscription\Model\Subscriber; @@ -40,7 +41,8 @@ protected function setUp(): void $this->tokenService = new LinkTrackTokenService( new ParameterProvider([]), 'https://example.com/api/v2', - 'secret' + 'secret', + $this->createMock(ConfigProvider::class) ); $this->campaign = $this->createMock(Message::class); From d10550adde1e08595df253e03569498b02d7fddd Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 3 Nov 2026 13:52:04 +0400 Subject: [PATCH 33/46] feat: update UserMessageService to count views even when no campaign record exists and adjust related tests --- src/Domain/Analytics/Service/UserMessageService.php | 7 ++----- .../Domain/Analytics/Service/UserMessageServiceTest.php | 7 ++++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Domain/Analytics/Service/UserMessageService.php b/src/Domain/Analytics/Service/UserMessageService.php index c5a1cc5b..7f4694e0 100644 --- a/src/Domain/Analytics/Service/UserMessageService.php +++ b/src/Domain/Analytics/Service/UserMessageService.php @@ -29,12 +29,9 @@ public function trackUserMessageView(string $uid, int $messageId, array $metadat return; } + // like phplist3, a view is counted even without a record of the campaign being sent to the subscriber $userMessage = $this->userMessageRepository->findByUserAndMessage($subscriber, $message); - if ($userMessage === null) { - return; - } - - $userMessage->setViewedNow(); + $userMessage?->setViewedNow(); $message->getMetadata()->incrementViews(); $data = []; diff --git a/tests/Unit/Domain/Analytics/Service/UserMessageServiceTest.php b/tests/Unit/Domain/Analytics/Service/UserMessageServiceTest.php index 47da1520..61d8b8cf 100644 --- a/tests/Unit/Domain/Analytics/Service/UserMessageServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/UserMessageServiceTest.php @@ -79,9 +79,10 @@ public function testReturnsEarlyWhenMessageNotFound(): void $this->subject->trackUserMessageView('sub-uid', 123, []); } - public function testReturnsEarlyWhenUserMessageNotFound(): void + public function testViewIsCountedEvenWhenTheCampaignWasNeverSentToTheSubscriber(): void { $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getId')->willReturn(5); $message = $this->getMockBuilder(Message::class) ->disableOriginalConstructor() ->getMock(); @@ -97,8 +98,8 @@ public function testReturnsEarlyWhenUserMessageNotFound(): void ->with($subscriber, $message) ->willReturn(null); - $metadata->expects(self::never())->method('incrementViews'); - $this->entityManager->expects(self::never())->method('persist'); + $metadata->expects(self::once())->method('incrementViews'); + $this->entityManager->expects(self::once())->method('persist'); $this->subject->trackUserMessageView('sub-uid', 321, []); } From e6030caaecdc10d6e809771ee2411702cafecdaf Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 3 Nov 2026 14:26:12 +0400 Subject: [PATCH 34/46] feat: enhance LinkTrackTokenService to support parsing phplist3 tokens and add related unit tests --- .../Service/LinkTrackTokenService.php | 66 ++++++++++++++ .../Configuration/Model/ConfigOption.php | 1 + .../Service/LinkTrackTokenServiceTest.php | 85 ++++++++++++++++++- 3 files changed, 149 insertions(+), 3 deletions(-) diff --git a/src/Domain/Analytics/Service/LinkTrackTokenService.php b/src/Domain/Analytics/Service/LinkTrackTokenService.php index 796432ea..8e3f8616 100644 --- a/src/Domain/Analytics/Service/LinkTrackTokenService.php +++ b/src/Domain/Analytics/Service/LinkTrackTokenService.php @@ -6,6 +6,8 @@ use PhpList\Core\Core\ParameterProvider; use PhpList\Core\Domain\Analytics\Model\Dto\LinkTrackToken; +use PhpList\Core\Domain\Configuration\Model\ConfigOption; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use Symfony\Component\DependencyInjection\Attribute\Autowire; /** @@ -16,6 +18,11 @@ * random, so a token cannot be guessed. With the `click_track_sign_with_hmac` parameter enabled the token is * additionally signed (`hm`) with the application secret, so a request can be rejected before touching the * database. Keep it off if the secret may be rotated, as that would invalidate the links of sent campaigns. + * + * Tokens of links in campaigns sent by phplist3 can be parsed as well, so that those links keep working when + * lt.php is served by this application. Their HTML tokens are masked with the `xormask` setting, which is shared + * through the config table; their text tokens are the three uuids packed as binary. phplist3 signs its links with + * a hash of the whole url, which cannot be checked here, so with signing enabled they are refused. */ class LinkTrackTokenService { @@ -25,6 +32,7 @@ public function __construct( private readonly ParameterProvider $parameterProvider, #[Autowire('%app.rest_api_base_url%')] private readonly string $restApiBaseUrl, #[Autowire('%app.secret%')] private readonly string $secret, + private readonly ConfigProvider $configProvider, ) { } @@ -52,6 +60,11 @@ public function parse(string $tid, ?string $signature = null): ?LinkTrackToken return null; } + return $this->parseOwnFormat($tid) ?? $this->parsePhplist3Format($tid); + } + + private function parseOwnFormat(string $tid): ?LinkTrackToken + { $decoded = base64_decode(strtr($tid, '-_', '+/'), true); if ($decoded === false) { return null; @@ -68,6 +81,59 @@ public function parse(string $tid, ?string $signature = null): ?LinkTrackToken return new LinkTrackToken($parts[0], $parts[1], $parts[2], $parts[3]); } + /** The tokens of phplist3's lt.php, see its sendemaillib.php. */ + private function parsePhplist3Format(string $tid): ?LinkTrackToken + { + // a query string turns the + of the base64 into a space + $tid = str_replace(' ', '+', $tid); + + if (strlen($tid) === 64) { + $track = $this->unpackPhplist3TextToken($tid); + } else { + $track = $this->unmaskPhplist3HtmlToken($tid); + } + + $uuid = '([a-f0-9]{8}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12})'; + $pattern = '/^(H|T)\|' . $uuid . '\|' . $uuid . '\|' . $uuid . '$/'; + if ($track === null || !preg_match($pattern, $track, $matches)) { + return null; + } + + return new LinkTrackToken($matches[1], $matches[2], $matches[3], $matches[4]); + } + + /** Text links carry the forward, message and subscriber uuids as 48 binary bytes. */ + private function unpackPhplist3TextToken(string $tid): ?string + { + $binary = base64_decode($tid, true); + if ($binary === false || strlen($binary) !== 48) { + return null; + } + + $hex = bin2hex($binary); + $uuids = []; + foreach ([0, 32, 64] as $offset) { + // the version nibble of the uuid was replaced by random data, uuids are version 4 + $uuids[] = substr($hex, $offset, 8) . '-' . substr($hex, $offset + 8, 4) + . '-4' . substr($hex, $offset + 13, 3) . '-' . substr($hex, $offset + 16, 4) + . '-' . substr($hex, $offset + 20, 12); + } + + return 'T|' . implode('|', $uuids); + } + + /** HTML links carry `H|forward|message|subscriber` XOR-ed with the repeated xormask setting. */ + private function unmaskPhplist3HtmlToken(string $tid): ?string + { + $xorMask = $this->configProvider->getValue(ConfigOption::XorMask); + $masked = base64_decode($tid); + if ($xorMask === null || $xorMask === '' || $masked === '') { + return null; + } + + return $masked ^ str_repeat($xorMask, 20); + } + private function hasValidSignature(string $tid, ?string $signature): bool { if (!$this->isSigningEnabled()) { diff --git a/src/Domain/Configuration/Model/ConfigOption.php b/src/Domain/Configuration/Model/ConfigOption.php index 41ec18dc..dda10f8f 100644 --- a/src/Domain/Configuration/Model/ConfigOption.php +++ b/src/Domain/Configuration/Model/ConfigOption.php @@ -47,4 +47,5 @@ enum ConfigOption: string case AutoCreateAttributes = 'autocreate_attributes'; case RemoteProcessingSecret = 'remote_processing_secret'; case NotifyAdminLogin = 'notify_admin_login'; + case XorMask = 'xormask'; } diff --git a/tests/Unit/Domain/Analytics/Service/LinkTrackTokenServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkTrackTokenServiceTest.php index e97f1fc5..9e2c8def 100644 --- a/tests/Unit/Domain/Analytics/Service/LinkTrackTokenServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/LinkTrackTokenServiceTest.php @@ -7,19 +7,53 @@ use PhpList\Core\Core\ParameterProvider; use PhpList\Core\Domain\Analytics\Model\Dto\LinkTrackToken; use PhpList\Core\Domain\Analytics\Service\LinkTrackTokenService; +use PhpList\Core\Domain\Configuration\Model\ConfigOption; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PHPUnit\Framework\TestCase; class LinkTrackTokenServiceTest extends TestCase { - private function createService(bool $sign = false, string $secret = 'secret'): LinkTrackTokenService - { + private const XOR_MASK = '0123456789abcdef0123456789abcdef'; + private const FORWARD_UUID = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'; + private const MESSAGE_UUID = '11111111-2222-4333-a444-555555555555'; + private const SUBSCRIBER_UUID = '99999999-8888-4777-b666-444444444444'; + + private function createService( + bool $sign = false, + string $secret = 'secret', + ?string $xorMask = self::XOR_MASK + ): LinkTrackTokenService { + $configProvider = $this->createMock(ConfigProvider::class); + $configProvider->method('getValue')->with(ConfigOption::XorMask)->willReturn($xorMask); + return new LinkTrackTokenService( new ParameterProvider(['click_track_sign_with_hmac' => $sign]), 'https://example.com/api/v2/', - $secret + $secret, + $configProvider ); } + /** What phplist3's sendemaillib.php puts in the link of an HTML email. */ + private function phplist3HtmlToken(): string + { + $track = 'H|' . self::FORWARD_UUID . '|' . self::MESSAGE_UUID . '|' . self::SUBSCRIBER_UUID; + $masked = base64_encode($track ^ str_repeat(self::XOR_MASK, 20)); + + return preg_replace('/=$/', '', preg_replace('/=$/', '', $masked)); + } + + /** What phplist3's sendemaillib.php puts in the link of a text email. */ + private function phplist3TextToken(): string + { + $masked = self::FORWARD_UUID . self::MESSAGE_UUID . self::SUBSCRIBER_UUID; + foreach ([14, 36 + 14, 72 + 14] as $position) { + $masked[$position] = 'c'; + } + + return str_replace('=', '', base64_encode(hex2bin(str_replace('-', '', $masked)))); + } + /** @return array */ private function queryOf(string $url): array { @@ -92,4 +126,49 @@ public function testUnsignedUrlsHaveNoSignature(): void self::assertArrayNotHasKey('hm', $query); } + + public function testParsesTheHtmlTokensOfPhplist3(): void + { + $token = $this->createService()->parse($this->phplist3HtmlToken()); + + self::assertNotNull($token); + self::assertTrue($token->isHtml()); + self::assertSame(self::FORWARD_UUID, $token->forwardUuid); + self::assertSame(self::MESSAGE_UUID, $token->messageUuid); + self::assertSame(self::SUBSCRIBER_UUID, $token->subscriberUuid); + } + + public function testParsesTheHtmlTokensOfPhplist3AfterAQueryStringTurnedPlusIntoSpace(): void + { + $tid = str_replace('+', ' ', $this->phplist3HtmlToken()); + + self::assertNotNull($this->createService()->parse($tid)); + } + + public function testParsesTheTextTokensOfPhplist3(): void + { + $token = $this->createService()->parse($this->phplist3TextToken()); + + self::assertNotNull($token); + self::assertSame('T', $token->type); + self::assertSame(self::FORWARD_UUID, $token->forwardUuid); + self::assertSame(self::MESSAGE_UUID, $token->messageUuid); + self::assertSame(self::SUBSCRIBER_UUID, $token->subscriberUuid); + } + + public function testPhplist3HtmlTokensNeedTheSameXorMask(): void + { + $tid = $this->phplist3HtmlToken(); + + self::assertNull($this->createService(xorMask: 'fedcba9876543210fedcba9876543210')->parse($tid)); + self::assertNull($this->createService(xorMask: null)->parse($tid)); + } + + public function testPhplist3TokensCannotBeVerifiedWhenSigningIsEnabled(): void + { + $service = $this->createService(sign: true); + + self::assertNull($service->parse($this->phplist3HtmlToken(), 'whatever')); + self::assertNull($service->parse($this->phplist3TextToken(), 'whatever')); + } } From 201a392d6b263ccbdfc66160d1601bde3754fb08 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 5 Nov 2026 19:46:52 +0400 Subject: [PATCH 35/46] chore: update to PHP 8.2 --- .github/workflows/ci.yml | 2 +- .github/workflows/core-docs.yml | 2 +- .github/workflows/i18n-validate.yml | 2 +- composer.json | 2 +- src/Domain/Messaging/Service/SendRateLimiter.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86596523..986afd9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: strategy: fail-fast: false matrix: - php-versions: ['8.1'] + php-versions: ['8.2'] steps: - name: Checkout uses: actions/checkout@v3 diff --git a/.github/workflows/core-docs.yml b/.github/workflows/core-docs.yml index dc3a5bdf..c9de9426 100644 --- a/.github/workflows/core-docs.yml +++ b/.github/workflows/core-docs.yml @@ -10,7 +10,7 @@ jobs: - name: Setup PHP, with composer and extensions uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 extensions: mbstring, dom, fileinfo, mysql - name: Get composer cache directory id: composer-cache diff --git a/.github/workflows/i18n-validate.yml b/.github/workflows/i18n-validate.yml index 4e49efa8..b9f88131 100644 --- a/.github/workflows/i18n-validate.yml +++ b/.github/workflows/i18n-validate.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.1'] + php: ['8.2'] steps: - name: Checkout diff --git a/composer.json b/composer.json index 5c6f9e05..3f2f2db7 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "source": "https://github.com/phpList/core" }, "require": { - "php": "^8.1", + "php": "^8.2", "symfony/framework-bundle": "^6.4", "symfony/dependency-injection": "^6.4", "symfony/config": "^6.4", diff --git a/src/Domain/Messaging/Service/SendRateLimiter.php b/src/Domain/Messaging/Service/SendRateLimiter.php index 2590e721..40617bd8 100644 --- a/src/Domain/Messaging/Service/SendRateLimiter.php +++ b/src/Domain/Messaging/Service/SendRateLimiter.php @@ -46,7 +46,7 @@ private function initializeLimits(): void $ispMinPeriod = $isp->minBatchPeriod ?? 0; $cfgThrottle = $this->mailqueueThrottle ?? 0; - $ispMinThrottle = (int)($isp->minThrottle ?? 0); + $ispMinThrottle = (int)($isp->raw['minthrottle'] ?? 0); if ($cfgBatch <= 0) { $this->batchSize = $ispMax !== null ? max(0, $ispMax) : 0; From c209891eefe0f83d856af4b5e499e819fa90f491 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 5 Nov 2026 20:48:45 +0400 Subject: [PATCH 36/46] feat: optimize runForMySql method to skip creating messenger_messages table if it already exists --- .../Traits/DatabaseTestTrait.php | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/TestingSupport/Traits/DatabaseTestTrait.php b/src/TestingSupport/Traits/DatabaseTestTrait.php index d645894c..c231fdc3 100644 --- a/src/TestingSupport/Traits/DatabaseTestTrait.php +++ b/src/TestingSupport/Traits/DatabaseTestTrait.php @@ -93,14 +93,22 @@ protected function loadSchema(): void private function runForMySql($metadata, $schemaTool): void { - try { - $schemaTool->createSchema($metadata); - } catch (ToolsException $e) { - $missing = $this->filterMissingTables($metadata); + $missing = $this->filterMissingTables($metadata); - if ($missing !== []) { - $schemaTool->createSchema($missing); + if ($missing === []) { + return; + } + + $connection = $this->entityManager->getConnection(); + $messengerTableExists = $this->tableExistsIgnoringSchemaFilter('messenger_messages'); + + // Symfony's Doctrine messenger schema listener appends CREATE TABLE messenger_messages to every + // schema it builds, so skip it when the table is already there (e.g. from a previous run). + foreach ($schemaTool->getCreateSchemaSql($missing) as $sql) { + if ($messengerTableExists && str_starts_with($sql, 'CREATE TABLE messenger_messages')) { + continue; } + $connection->executeStatement($sql); } } From 9fc83f23cc12979169bf6319debfe22737c0b23f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 5 Nov 2026 22:03:03 +0400 Subject: [PATCH 37/46] feat: new logo --- public/favicon-48.png | Bin 2842 -> 31758 bytes public/favicon.ico | Bin 831 -> 44045 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/public/favicon-48.png b/public/favicon-48.png index 215a9efe830022152fb78d175a4e1aec9e3e8397..83e65adb07eb983cbd0d75969bec13761b8b44f5 100644 GIT binary patch literal 31758 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 diff --git a/public/favicon.ico b/public/favicon.ico index 5a82bbab7b184b906b72b9d20f93f59318552641..e219715e3d66750ff0289abd8df924d1934252c3 100644 GIT binary patch literal 44045 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 delta 811 zcmV+`1JwMD*aE)>k!B-*00eVFNmK|32nc)#WQYI&010qNS#tmY3labT3lag+-G2N4 z0016zMObuGZ)S9NVRB^vL1b@YWgtdra%FdKa%*!SLsK;_F{>X)>i_@%6?8>dbVG7w zVRUJ4ZXi@?ZDjy5FETMNF*D4bXPp250+vZcK~y-6ZIeAnn{g0-#-I4D5p5EZP!q)= z$)Yv^8^0i_RXgN;QA6WUi?s-fUF_mwJ17*vCQ}EYAlgAIq9zSUwJMEK3sHm6EJ}?Q zU1~~oNvK0R_;l(^q8{9GcfW_b|Nl;g8*F`J!|ZXpU0RK{KRrD?ukcpkuHX=+8=D&6 z+*B`FG#b6r+R`$AbGxYMJU2I&03_Gz^_PM}*!Ft8k8XsAhllM}tMzX(1b{g?IlSM{ zz^tq+0-(ucqQ~uil>(ogot4$s*Z;W+D-;UO&dzda=@SEi0ChSY0kEW`gcIZAeE^US z0H;4r-#%_{uQ)k5K`a)Ngr9VDAUiu7oliSqG#XJ^S&3kOFo<|Ojzl7H_xSkOkLBfM zvr46m*VWZ=Z*Pw^H8oNPwY9Ze{J6;C;$qIt&9SAqS<1=fa!yW8CLjpH%l!O&0-)7u z<-xZD+U<6!u&b+!7Z<P)~Hk}rCfJ+ zH#axG&|oldY-}t60BCJ(^(G4}EiUmvQxgGjb#;}0Mx!yMySBDAhIhih!r`z10IdY(NTLf-*8PSXx?oDT?B@-|tU3CfQ9wTU%S^k&zLXMx*IZ plgaWTlq%J(;1H(m_w3is`5)qzO Date: Mon, 9 Nov 2026 16:14:41 +0400 Subject: [PATCH 38/46] feat: implement access level checks for administrators and update resource ownership logic --- src/Domain/Identity/Model/AccessLevel.php | 12 ++++ src/Domain/Identity/Model/Administrator.php | 5 +- .../Identity/Service/PermissionChecker.php | 56 +++++++++++-------- .../Repository/MessageRepository.php | 5 +- .../Repository/SubscriberListRepository.php | 7 ++- .../Service/PermissionCheckerTest.php | 50 +++++++++++++++++ .../Repository/MessageRepositoryTest.php | 4 +- .../SubscriberListRepositoryTest.php | 18 ++++++ 8 files changed, 125 insertions(+), 32 deletions(-) create mode 100644 src/Domain/Identity/Model/AccessLevel.php diff --git a/src/Domain/Identity/Model/AccessLevel.php b/src/Domain/Identity/Model/AccessLevel.php new file mode 100644 index 00000000..e706a64d --- /dev/null +++ b/src/Domain/Identity/Model/AccessLevel.php @@ -0,0 +1,12 @@ +getId() === null) { + $owner = $resource->getOwner(); + if ($this->getId() === null || $owner === null) { return false; } - return $resource->getOwner()->getId() === $this->getId(); + return $owner->getId() === $this->getId(); } #[ORM\PreUpdate] diff --git a/src/Domain/Identity/Service/PermissionChecker.php b/src/Domain/Identity/Service/PermissionChecker.php index dffbd626..30c12d5d 100644 --- a/src/Domain/Identity/Service/PermissionChecker.php +++ b/src/Domain/Identity/Service/PermissionChecker.php @@ -6,6 +6,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface; +use PhpList\Core\Domain\Identity\Model\AccessLevel; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\Core\Domain\Messaging\Model\ListMessage; @@ -26,42 +27,36 @@ class PermissionChecker Message::class => SubscriberList::class ]; - public function canManage(Administrator $actor, DomainModel $resource): bool + /** + * @param class-string $resourceClass + */ + public function accessLevel(Administrator $actor, string $resourceClass): AccessLevel { if ($actor->isSuperUser()) { - return true; + return AccessLevel::All; } - $required = $this->resolveRequiredPrivilegeForClass(get_class($resource)); + $required = $this->resolveRequiredPrivilegeForClass($resourceClass); if ($required !== null && !$actor->getPrivileges()->has($required)) { - return false; - } - - if ($resource instanceof OwnableInterface) { - return $actor->owns($resource); + return AccessLevel::None; } - $notRestricted = true; - foreach (self::OWNERSHIP_MAP as $resourceClass => $relatedClass) { - if ($resource instanceof $resourceClass) { - $related = $this->resolveRelatedEntity($resource, $relatedClass); - $notRestricted = $this->checkRelatedResources($related, $actor); - } - } + return AccessLevel::Owner; + } - return $notRestricted; + public function canManage(Administrator $actor, DomainModel $resource): bool + { + return match ($this->accessLevel($actor, get_class($resource))) { + AccessLevel::All => true, + AccessLevel::None => false, + AccessLevel::Owner => $this->ownsResource($actor, $resource), + }; } /** @param class-string $resourceClass */ public function canCreate(Administrator $actor, string $resourceClass): bool { - if ($actor->isSuperUser()) { - return true; - } - - $required = $this->resolveRequiredPrivilegeForClass($resourceClass); - - return $required === null || $actor->getPrivileges()->has($required); + return $this->accessLevel($actor, $resourceClass) !== AccessLevel::None; } public function canList(Administrator $actor, string $resourceClass): bool @@ -84,6 +79,21 @@ public function canDelete(Administrator $actor, DomainModel $resource): bool return $this->canManage($actor, $resource); } + private function ownsResource(Administrator $actor, DomainModel $resource): bool + { + if ($resource instanceof OwnableInterface) { + return $actor->owns($resource); + } + + foreach (self::OWNERSHIP_MAP as $resourceClass => $relatedClass) { + if ($resource instanceof $resourceClass) { + return $this->checkRelatedResources($this->resolveRelatedEntity($resource, $relatedClass), $actor); + } + } + + return true; + } + /** @param class-string $resourceClass */ private function resolveRequiredPrivilegeForClass(string $resourceClass): ?PrivilegeFlag { diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php index 84f4da7d..f8c1735d 100644 --- a/src/Domain/Messaging/Repository/MessageRepository.php +++ b/src/Domain/Messaging/Repository/MessageRepository.php @@ -63,9 +63,8 @@ public function getFilteredAfterId(FilterRequestInterface $filter): PaginatedRes $queryBuilder = $this->createQueryBuilder('m'); if ($filter instanceof MessageFilter && $filter->getOwner() !== null) { - // Legacy/imported messages have no owner recorded - treat them as shared rather - // than invisible, instead of excluding them outright via a strict owner match. - $queryBuilder->andWhere('(m.owner IS NULL OR IDENTITY(m.owner) = :ownerId)') + // Same as phplist3: non-superusers only see the campaigns they own. + $queryBuilder->andWhere('IDENTITY(m.owner) = :ownerId') ->setParameter('ownerId', $filter->getOwner()->getId()); } diff --git a/src/Domain/Subscription/Repository/SubscriberListRepository.php b/src/Domain/Subscription/Repository/SubscriberListRepository.php index 270ab7f6..d3da1835 100644 --- a/src/Domain/Subscription/Repository/SubscriberListRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberListRepository.php @@ -83,6 +83,11 @@ public function getFilteredAfterId(FilterRequestInterface $filter): PaginatedRes $queryBuilder = $this->createQueryBuilder('l'); + // Same as phplist3: when an owner is given (non-superuser), only the lists they own are visible. + if ($filter->getOwner() !== null) { + $queryBuilder->where('l.owner = :admin')->setParameter('admin', $filter->getOwner()); + } + $countQb = clone $queryBuilder; $total = (int) $countQb ->select('COUNT(DISTINCT l.id)') @@ -91,8 +96,6 @@ public function getFilteredAfterId(FilterRequestInterface $filter): PaginatedRes /** @var list $items */ $items = $queryBuilder - ->where($queryBuilder->expr()->orX('l.owner = :admin', 'l.public = true')) - ->setParameter('admin', $filter->getOwner()) ->andWhere('l.id > :id') ->setParameter('id', $filter->getLastId()) ->setMaxResults($filter->getLimit()) diff --git a/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php b/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php index 7f2d0d67..5fcdb751 100644 --- a/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php +++ b/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Tests\Integration\Domain\Identity\Service; use PhpList\Core\Domain\Configuration\Model\Config; +use PhpList\Core\Domain\Identity\Model\AccessLevel; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\Core\Domain\Identity\Model\Privileges; @@ -89,4 +90,53 @@ public function testNonSuperUserCanCreateResourceWithNoRequiredPrivilege(): void $this->assertTrue($this->checker->canCreate($admin, Config::class)); } + + public function testAccessLevelIsAllForSuperUser(): void + { + $admin = (new Administrator())->setSuperUser(true); + + self::assertSame(AccessLevel::All, $this->checker->accessLevel($admin, SubscriberList::class)); + } + + public function testAccessLevelIsNoneWithoutRequiredPrivilege(): void + { + $admin = (new Administrator())->setPrivileges(new Privileges()); + + self::assertSame(AccessLevel::None, $this->checker->accessLevel($admin, Message::class)); + } + + public function testAccessLevelIsOwnerWithRequiredPrivilege(): void + { + $admin = (new Administrator())->setPrivileges((new Privileges())->grant(PrivilegeFlag::Campaigns)); + + self::assertSame(AccessLevel::Owner, $this->checker->accessLevel($admin, Message::class)); + } + + public function testCannotManageListWithoutPrivilegeEvenIfOwner(): void + { + $admin = $this->adminWithId(1, new Privileges()); + $list = (new SubscriberList())->setOwner($admin); + + self::assertFalse($this->checker->canManage($admin, $list)); + } + + public function testCanManageOwnedListButNotForeignOrOwnerlessList(): void + { + $admin = $this->adminWithId(1, (new Privileges())->grant(PrivilegeFlag::Subscribers)); + $other = $this->adminWithId(2, new Privileges()); + + self::assertTrue($this->checker->canManage($admin, (new SubscriberList())->setOwner($admin))); + self::assertFalse($this->checker->canManage($admin, (new SubscriberList())->setOwner($other))); + self::assertFalse($this->checker->canManage($admin, new SubscriberList())); + } + + private function adminWithId(int $id, Privileges $privileges): Administrator + { + $admin = (new Administrator())->setPrivileges($privileges); + $property = new \ReflectionProperty(Administrator::class, 'id'); + $property->setAccessible(true); + $property->setValue($admin, $id); + + return $admin; + } } diff --git a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php index a496f42c..dbcd34c2 100644 --- a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php +++ b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php @@ -152,7 +152,7 @@ private function persistMessage( return $message; } - public function testGetFilteredAfterIdIncludesOwnerlessMessagesForAnyAdmin(): void + public function testGetFilteredAfterIdExcludesOwnerlessAndForeignMessagesForOwnerFilter(): void { $admin = (new Administrator())->setLoginName('owner-test-admin'); $otherAdmin = (new Administrator())->setLoginName('other-admin'); @@ -173,7 +173,7 @@ public function testGetFilteredAfterIdIncludesOwnerlessMessagesForAnyAdmin(): vo static fn (Message $message) => $message->getContent()->getSubject(), $result->getItems() ); - self::assertContains('Legacy unowned campaign', $subjects); + self::assertNotContains('Legacy unowned campaign', $subjects); self::assertContains('My own campaign', $subjects); self::assertNotContains("Someone else's campaign", $subjects); } diff --git a/tests/Integration/Domain/Messaging/Repository/SubscriberListRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/SubscriberListRepositoryTest.php index 93e99a81..f6b5ae97 100644 --- a/tests/Integration/Domain/Messaging/Repository/SubscriberListRepositoryTest.php +++ b/tests/Integration/Domain/Messaging/Repository/SubscriberListRepositoryTest.php @@ -6,6 +6,7 @@ use DateTime; use Doctrine\ORM\Tools\SchemaTool; +use PhpList\Core\Domain\Messaging\Model\Filter\SubscriberListFilter; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberList; @@ -109,6 +110,23 @@ public function testSavePersistsAndFlushesModel() self::assertSame($model, $this->subscriberListRepository->find($model->getId())); } + public function testGetFilteredAfterIdReturnsOnlyOwnedListsForOwnerFilter() + { + $this->loadFixtures([SubscriberListFixture::class, AdministratorFixture::class]); + + $owner = $this->administratorRepository->find(1); + $filter = (new SubscriberListFilter())->setOwner($owner)->setLimit(100); + + $result = $this->subscriberListRepository->getFilteredAfterId($filter); + + self::assertNotEmpty($result->getItems()); + foreach ($result->getItems() as $list) { + self::assertInstanceOf(SubscriberList::class, $list); + self::assertSame($owner->getId(), $list->getOwner()->getId()); + } + self::assertSame(count($result->getItems()), $result->getTotal()); + } + public function testFindByOwnerFindsSubscriberListWithTheGivenOwner() { $this->loadFixtures([SubscriberListFixture::class, AdministratorFixture::class]); From 9bf47aabb5aa1af15234e72c2ba8a7b6b0c0f75c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 10 Nov 2026 14:04:14 +0400 Subject: [PATCH 39/46] feat: add admin notification system for queued campaigns and attachment repository status --- config/services/services.yml | 5 ++ .../Identity/Model/Dto/AdminNotification.php | 26 ++++++++ .../AdminNotificationCollector.php | 31 +++++++++ .../AdminNotificationProviderInterface.php | 16 +++++ ...tachmentRepositoryNotificationProvider.php | 42 +++++++++++++ .../QueuedCampaignsNotificationProvider.php | 47 ++++++++++++++ .../Repository/MessageRepository.php | 16 +++++ .../AdminNotificationCollectorTest.php | 30 +++++++++ ...mentRepositoryNotificationProviderTest.php | 48 ++++++++++++++ ...ueuedCampaignsNotificationProviderTest.php | 63 +++++++++++++++++++ 10 files changed, 324 insertions(+) create mode 100644 src/Domain/Identity/Model/Dto/AdminNotification.php create mode 100644 src/Domain/Identity/Service/Notification/AdminNotificationCollector.php create mode 100644 src/Domain/Identity/Service/Notification/AdminNotificationProviderInterface.php create mode 100644 src/Domain/Identity/Service/Notification/AttachmentRepositoryNotificationProvider.php create mode 100644 src/Domain/Identity/Service/Notification/QueuedCampaignsNotificationProvider.php create mode 100644 tests/Unit/Domain/Identity/Service/Notification/AdminNotificationCollectorTest.php create mode 100644 tests/Unit/Domain/Identity/Service/Notification/AttachmentRepositoryNotificationProviderTest.php create mode 100644 tests/Unit/Domain/Identity/Service/Notification/QueuedCampaignsNotificationProviderTest.php diff --git a/config/services/services.yml b/config/services/services.yml index bf40c389..5224f0ce 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -264,6 +264,11 @@ services: autoconfigure: true public: true + PhpList\Core\Domain\Identity\Service\Notification\: + resource: '../../src/Domain/Identity/Service/Notification/*.php' + autowire: true + autoconfigure: true + PhpList\Core\Domain\Identity\Service\AdminNotifier: autowire: true autoconfigure: true diff --git a/src/Domain/Identity/Model/Dto/AdminNotification.php b/src/Domain/Identity/Model/Dto/AdminNotification.php new file mode 100644 index 00000000..c2b80360 --- /dev/null +++ b/src/Domain/Identity/Model/Dto/AdminNotification.php @@ -0,0 +1,26 @@ + $context machine readable details, e.g. counts + */ + public function __construct( + public readonly string $code, + public readonly string $level, + public readonly string $message, + public readonly array $context = [], + ) { + } +} diff --git a/src/Domain/Identity/Service/Notification/AdminNotificationCollector.php b/src/Domain/Identity/Service/Notification/AdminNotificationCollector.php new file mode 100644 index 00000000..776fa0b9 --- /dev/null +++ b/src/Domain/Identity/Service/Notification/AdminNotificationCollector.php @@ -0,0 +1,31 @@ + $providers */ + public function __construct( + #[AutowireIterator('phplist.admin_notification_provider')] private readonly iterable $providers, + ) { + } + + /** @return AdminNotification[] */ + public function collect(Administrator $administrator): array + { + $notifications = []; + foreach ($this->providers as $provider) { + foreach ($provider->getNotifications($administrator) as $notification) { + $notifications[] = $notification; + } + } + + return $notifications; + } +} diff --git a/src/Domain/Identity/Service/Notification/AdminNotificationProviderInterface.php b/src/Domain/Identity/Service/Notification/AdminNotificationProviderInterface.php new file mode 100644 index 00000000..b2d7474c --- /dev/null +++ b/src/Domain/Identity/Service/Notification/AdminNotificationProviderInterface.php @@ -0,0 +1,16 @@ +isSuperUser()) { + return []; + } + + if (is_dir($this->attachmentRepositoryPath) && is_writable($this->attachmentRepositoryPath)) { + return []; + } + + return [ + new AdminNotification( + code: 'attachment_repository_not_writable', + level: AdminNotification::LEVEL_WARNING, + message: $this->translator->trans('The attachment repository does not exist or is not writable'), + ), + ]; + } +} diff --git a/src/Domain/Identity/Service/Notification/QueuedCampaignsNotificationProvider.php b/src/Domain/Identity/Service/Notification/QueuedCampaignsNotificationProvider.php new file mode 100644 index 00000000..639c72f5 --- /dev/null +++ b/src/Domain/Identity/Service/Notification/QueuedCampaignsNotificationProvider.php @@ -0,0 +1,47 @@ +isSuperUser()) { + return []; + } + + $count = $this->messageRepository->countQueued(new DateTimeImmutable()); + if ($count === 0) { + return []; + } + + return [ + new AdminNotification( + code: 'campaigns_in_queue', + level: AdminNotification::LEVEL_INFO, + message: $this->translator->trans( + 'You have %count% campaign(s) in the queue, ready for processing', + ['%count%' => $count] + ), + context: ['count' => $count], + ), + ]; + } +} diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php index f8c1735d..ec86673a 100644 --- a/src/Domain/Messaging/Repository/MessageRepository.php +++ b/src/Domain/Messaging/Repository/MessageRepository.php @@ -136,6 +136,22 @@ public function incrementBounceCount(int $messageId): void ->execute(); } + /** Campaigns that are submitted or in process and whose embargo has passed. */ + public function countQueued(DateTimeImmutable $now): int + { + return (int) $this->createQueryBuilder('m') + ->select('COUNT(m.id)') + ->where('m.metadata.status IN (:statuses)') + ->andWhere('m.schedule.embargo IS NULL OR m.schedule.embargo <= :now') + ->setParameter('statuses', [ + Message\MessageStatus::Submitted->value, + Message\MessageStatus::InProcess->value, + ]) + ->setParameter('now', $now) + ->getQuery() + ->getSingleScalarResult(); + } + /** @return Message[] */ public function getByStatusAndEmbargo(Message\MessageStatus $status, DateTimeImmutable $embargo): array { diff --git a/tests/Unit/Domain/Identity/Service/Notification/AdminNotificationCollectorTest.php b/tests/Unit/Domain/Identity/Service/Notification/AdminNotificationCollectorTest.php new file mode 100644 index 00000000..baa80464 --- /dev/null +++ b/tests/Unit/Domain/Identity/Service/Notification/AdminNotificationCollectorTest.php @@ -0,0 +1,30 @@ +createMock(AdminNotificationProviderInterface::class); + $p1->method('getNotifications')->willReturn([$a]); + $p2 = $this->createMock(AdminNotificationProviderInterface::class); + $p2->method('getNotifications')->willReturn([]); + $p3 = $this->createMock(AdminNotificationProviderInterface::class); + $p3->method('getNotifications')->willReturn([$b]); + + $result = (new AdminNotificationCollector([$p1, $p2, $p3]))->collect($this->createMock(Administrator::class)); + + self::assertSame([$a, $b], $result); + } +} diff --git a/tests/Unit/Domain/Identity/Service/Notification/AttachmentRepositoryNotificationProviderTest.php b/tests/Unit/Domain/Identity/Service/Notification/AttachmentRepositoryNotificationProviderTest.php new file mode 100644 index 00000000..3ab3bbdd --- /dev/null +++ b/tests/Unit/Domain/Identity/Service/Notification/AttachmentRepositoryNotificationProviderTest.php @@ -0,0 +1,48 @@ +createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + + return new AttachmentRepositoryNotificationProvider($translator, $path); + } + + private function admin(bool $super): Administrator + { + $admin = $this->createMock(Administrator::class); + $admin->method('isSuperUser')->willReturn($super); + + return $admin; + } + + public function testWritableDirectoryProducesNothing(): void + { + self::assertSame([], $this->provider(sys_get_temp_dir())->getNotifications($this->admin(true))); + } + + public function testMissingDirectoryProducesWarning(): void + { + $result = $this->provider('/nonexistent/attachments')->getNotifications($this->admin(true)); + + self::assertCount(1, $result); + self::assertSame('attachment_repository_not_writable', $result[0]->code); + self::assertSame('warning', $result[0]->level); + } + + public function testNonSuperUserGetsNothing(): void + { + self::assertSame([], $this->provider('/nonexistent')->getNotifications($this->admin(false))); + } +} diff --git a/tests/Unit/Domain/Identity/Service/Notification/QueuedCampaignsNotificationProviderTest.php b/tests/Unit/Domain/Identity/Service/Notification/QueuedCampaignsNotificationProviderTest.php new file mode 100644 index 00000000..22ec8f37 --- /dev/null +++ b/tests/Unit/Domain/Identity/Service/Notification/QueuedCampaignsNotificationProviderTest.php @@ -0,0 +1,63 @@ +repository = $this->createMock(MessageRepository::class); + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback( + fn (string $id, array $params = []) => strtr($id, $params) + ); + $this->subject = new QueuedCampaignsNotificationProvider($this->repository, $translator); + } + + private function admin(bool $super): Administrator + { + $admin = $this->createMock(Administrator::class); + $admin->method('isSuperUser')->willReturn($super); + + return $admin; + } + + public function testNonSuperUserGetsNothing(): void + { + $this->repository->expects(self::never())->method('countQueued'); + + self::assertSame([], $this->subject->getNotifications($this->admin(false))); + } + + public function testEmptyQueueGetsNothing(): void + { + $this->repository->method('countQueued')->willReturn(0); + + self::assertSame([], $this->subject->getNotifications($this->admin(true))); + } + + public function testQueuedCampaignsProduceInfoNotification(): void + { + $this->repository->method('countQueued')->willReturn(3); + + $result = $this->subject->getNotifications($this->admin(true)); + + self::assertCount(1, $result); + self::assertSame('campaigns_in_queue', $result[0]->code); + self::assertSame('info', $result[0]->level); + self::assertSame(['count' => 3], $result[0]->context); + self::assertStringContainsString('3 campaign(s)', $result[0]->message); + } +} From 4d4b6d477c0020dcb65667a76b217ed39c94fac6 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 11 Nov 2026 13:03:04 +0400 Subject: [PATCH 40/46] feat: add PendingMigrationsNotificationProvider to notify superusers of pending database migrations --- .../PendingMigrationsNotificationProvider.php | 60 +++++++++++++ ...dingMigrationsNotificationProviderTest.php | 90 +++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 src/Domain/Identity/Service/Notification/PendingMigrationsNotificationProvider.php create mode 100644 tests/Unit/Domain/Identity/Service/Notification/PendingMigrationsNotificationProviderTest.php diff --git a/src/Domain/Identity/Service/Notification/PendingMigrationsNotificationProvider.php b/src/Domain/Identity/Service/Notification/PendingMigrationsNotificationProvider.php new file mode 100644 index 00000000..e92e6d1c --- /dev/null +++ b/src/Domain/Identity/Service/Notification/PendingMigrationsNotificationProvider.php @@ -0,0 +1,60 @@ +isSuperUser()) { + return []; + } + + try { + $count = count($this->dependencyFactory->getMigrationStatusCalculator()->getNewMigrations()); + } catch (Throwable $e) { + $this->logger->warning('Unable to determine pending migrations', ['exception' => $e]); + + return []; + } + + if ($count === 0) { + return []; + } + + return [ + new AdminNotification( + code: 'pending_migrations', + level: AdminNotification::LEVEL_WARNING, + message: $this->translator->trans( + '%count% database migration(s) have not been run. ' + . 'Run "bin/console doctrine:migrations:migrate" to update the database', + ['%count%' => $count] + ), + context: ['count' => $count], + ), + ]; + } +} diff --git a/tests/Unit/Domain/Identity/Service/Notification/PendingMigrationsNotificationProviderTest.php b/tests/Unit/Domain/Identity/Service/Notification/PendingMigrationsNotificationProviderTest.php new file mode 100644 index 00000000..ba7d2581 --- /dev/null +++ b/tests/Unit/Domain/Identity/Service/Notification/PendingMigrationsNotificationProviderTest.php @@ -0,0 +1,90 @@ +createMock(DependencyFactory::class); + if ($calculator instanceof RuntimeException) { + $factory->method('getMigrationStatusCalculator')->willThrowException($calculator); + } else { + $factory->method('getMigrationStatusCalculator')->willReturn($calculator); + } + + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + + return new PendingMigrationsNotificationProvider( + $factory, + $translator, + $this->createMock(LoggerInterface::class) + ); + } + + private function calculator(int $pending): MigrationStatusCalculator + { + $migrations = []; + for ($i = 1; $i <= $pending; $i++) { + $migrations[] = new AvailableMigration( + new Version('Version' . $i), + $this->createMock(AbstractMigration::class) + ); + } + $calculator = $this->createMock(MigrationStatusCalculator::class); + $calculator->method('getNewMigrations')->willReturn(new AvailableMigrationsList($migrations)); + + return $calculator; + } + + private function admin(bool $super): Administrator + { + $admin = $this->createMock(Administrator::class); + $admin->method('isSuperUser')->willReturn($super); + + return $admin; + } + + public function testNoPendingMigrationsProducesNothing(): void + { + self::assertSame([], $this->provider($this->calculator(0))->getNotifications($this->admin(true))); + } + + public function testPendingMigrationsProduceWarning(): void + { + $result = $this->provider($this->calculator(2))->getNotifications($this->admin(true)); + + self::assertCount(1, $result); + self::assertSame('pending_migrations', $result[0]->code); + self::assertSame('warning', $result[0]->level); + self::assertSame(['count' => 2], $result[0]->context); + } + + public function testNonSuperUserGetsNothing(): void + { + self::assertSame([], $this->provider($this->calculator(2))->getNotifications($this->admin(false))); + } + + public function testFailureIsSwallowed(): void + { + $provider = $this->provider(new RuntimeException('db down')); + + self::assertSame([], $provider->getNotifications($this->admin(true))); + } +} From e0448d5adfebfb27eee7804f556d8d7b467843dc Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 11 Nov 2026 13:13:21 +0400 Subject: [PATCH 41/46] feat: add SkippedMigrationRecorder to track skipped migrations in Doctrine --- config/services.yml | 4 ++ .../Doctrine/SkippedMigrationRecorder.php | 43 ++++++++++++ .../Doctrine/SkippedMigrationRecorderTest.php | 67 +++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 src/Core/Doctrine/SkippedMigrationRecorder.php create mode 100644 tests/Unit/Core/Doctrine/SkippedMigrationRecorderTest.php diff --git a/config/services.yml b/config/services.yml index bfb06823..c24bd36f 100644 --- a/config/services.yml +++ b/config/services.yml @@ -55,6 +55,10 @@ services: arguments: $tablePrefix: '%database.prefix%' + PhpList\Core\Core\Doctrine\SkippedMigrationRecorder: + autowire: true + autoconfigure: true + PhpList\Core\Core\Doctrine\SearchIndexDoctrineListener: arguments: $enabled: '%elasticsearch.enabled%' diff --git a/src/Core/Doctrine/SkippedMigrationRecorder.php b/src/Core/Doctrine/SkippedMigrationRecorder.php new file mode 100644 index 00000000..7efadcf5 --- /dev/null +++ b/src/Core/Doctrine/SkippedMigrationRecorder.php @@ -0,0 +1,43 @@ +getPlan(); + $result = $plan->getResult(); + + if ($result === null + || $plan->getDirection() !== Direction::UP + || $args->getMigratorConfiguration()->isDryRun() + ) { + return; + } + + $this->dependencyFactory->getMetadataStorage()->complete($result); + } +} diff --git a/tests/Unit/Core/Doctrine/SkippedMigrationRecorderTest.php b/tests/Unit/Core/Doctrine/SkippedMigrationRecorderTest.php new file mode 100644 index 00000000..b9b49f0d --- /dev/null +++ b/tests/Unit/Core/Doctrine/SkippedMigrationRecorderTest.php @@ -0,0 +1,67 @@ +createMock(AbstractMigration::class), $direction); + if ($withResult) { + $plan->markAsExecuted(new ExecutionResult($version, $direction, new DateTimeImmutable())); + } + + return new MigrationsVersionEventArgs( + $this->createMock(Connection::class), + $plan, + (new MigratorConfiguration())->setDryRun($dryRun) + ); + } + + private function recorder(int $expectedCalls): SkippedMigrationRecorder + { + $storage = $this->createMock(MetadataStorage::class); + $storage->expects(self::exactly($expectedCalls))->method('complete'); + $factory = $this->createMock(DependencyFactory::class); + $factory->method('getMetadataStorage')->willReturn($storage); + + return new SkippedMigrationRecorder($factory); + } + + public function testRecordsSkippedUpMigration(): void + { + $this->recorder(1)->onMigrationsVersionSkipped($this->args(Direction::UP, false)); + } + + public function testDoesNotRecordOnDryRun(): void + { + $this->recorder(0)->onMigrationsVersionSkipped($this->args(Direction::UP, true)); + } + + public function testDoesNotRecordSkippedDownMigration(): void + { + $this->recorder(0)->onMigrationsVersionSkipped($this->args(Direction::DOWN, false)); + } + + public function testDoesNothingWithoutResult(): void + { + $this->recorder(0)->onMigrationsVersionSkipped($this->args(Direction::UP, false, false)); + } +} From f4bfebd4748b6ed0606ee5804ed6fff090c8d165 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 11 Nov 2026 13:30:25 +0400 Subject: [PATCH 42/46] feat: add DevModeNotification and MailerDisabledNotification --- ...tachmentRepositoryNotificationProvider.php | 2 +- .../DevModeNotificationProvider.php | 56 +++++++++++++++++ .../MailerDisabledNotificationProvider.php | 46 ++++++++++++++ .../QueuedCampaignsNotificationProvider.php | 2 +- .../DevModeNotificationProviderTest.php | 57 ++++++++++++++++++ ...MailerDisabledNotificationProviderTest.php | 60 +++++++++++++++++++ ...dingMigrationsNotificationProviderTest.php | 5 +- 7 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 src/Domain/Identity/Service/Notification/DevModeNotificationProvider.php create mode 100644 src/Domain/Identity/Service/Notification/MailerDisabledNotificationProvider.php create mode 100644 tests/Unit/Domain/Identity/Service/Notification/DevModeNotificationProviderTest.php create mode 100644 tests/Unit/Domain/Identity/Service/Notification/MailerDisabledNotificationProviderTest.php diff --git a/src/Domain/Identity/Service/Notification/AttachmentRepositoryNotificationProvider.php b/src/Domain/Identity/Service/Notification/AttachmentRepositoryNotificationProvider.php index 94aa95d6..dbe951a6 100644 --- a/src/Domain/Identity/Service/Notification/AttachmentRepositoryNotificationProvider.php +++ b/src/Domain/Identity/Service/Notification/AttachmentRepositoryNotificationProvider.php @@ -10,7 +10,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; /** - * phplist3 (index.php): warns when the attachment repository is missing or not writable. + * warns when the attachment repository is missing or not writable. * Only superusers can act on this, so only they get it. */ class AttachmentRepositoryNotificationProvider implements AdminNotificationProviderInterface diff --git a/src/Domain/Identity/Service/Notification/DevModeNotificationProvider.php b/src/Domain/Identity/Service/Notification/DevModeNotificationProvider.php new file mode 100644 index 00000000..882a23b7 --- /dev/null +++ b/src/Domain/Identity/Service/Notification/DevModeNotificationProvider.php @@ -0,0 +1,56 @@ +devVersion = filter_var($devVersion, FILTER_VALIDATE_BOOLEAN); + } + + public function getNotifications(Administrator $administrator): array + { + if (!$this->devVersion || !$administrator->isSuperUser()) { + return []; + } + + if ($this->devEmail === '') { + return [ + new AdminNotification( + code: 'dev_mode_without_email', + level: AdminNotification::LEVEL_INFO, + message: $this->translator->trans('Running DEV version, but developer email is not set'), + ), + ]; + } + + return [ + new AdminNotification( + code: 'dev_mode', + level: AdminNotification::LEVEL_INFO, + message: $this->translator->trans( + 'Running DEV version. All emails will be sent to %email%', + ['%email%' => $this->devEmail] + ), + context: ['email' => $this->devEmail], + ), + ]; + } +} diff --git a/src/Domain/Identity/Service/Notification/MailerDisabledNotificationProvider.php b/src/Domain/Identity/Service/Notification/MailerDisabledNotificationProvider.php new file mode 100644 index 00000000..8109d4e4 --- /dev/null +++ b/src/Domain/Identity/Service/Notification/MailerDisabledNotificationProvider.php @@ -0,0 +1,46 @@ +isSuperUser() || !$this->usesNullTransport()) { + return []; + } + + return [ + new AdminNotification( + code: 'mailer_disabled', + level: AdminNotification::LEVEL_WARNING, + message: $this->translator->trans( + 'The mailer uses the "null" transport, no emails will be sent. Check MAILER_DSN.' + ), + ), + ]; + } + + private function usesNullTransport(): bool + { + return preg_match('#^\s*null(\+[a-z0-9]+)?://#i', $this->mailerDsn) === 1; + } +} diff --git a/src/Domain/Identity/Service/Notification/QueuedCampaignsNotificationProvider.php b/src/Domain/Identity/Service/Notification/QueuedCampaignsNotificationProvider.php index 639c72f5..3c8ffcff 100644 --- a/src/Domain/Identity/Service/Notification/QueuedCampaignsNotificationProvider.php +++ b/src/Domain/Identity/Service/Notification/QueuedCampaignsNotificationProvider.php @@ -11,7 +11,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; /** - * phplist3 (index.php): superusers are told how many campaigns are waiting in the queue. + * superusers are told how many campaigns are waiting in the queue. */ class QueuedCampaignsNotificationProvider implements AdminNotificationProviderInterface { diff --git a/tests/Unit/Domain/Identity/Service/Notification/DevModeNotificationProviderTest.php b/tests/Unit/Domain/Identity/Service/Notification/DevModeNotificationProviderTest.php new file mode 100644 index 00000000..d4f70e3a --- /dev/null +++ b/tests/Unit/Domain/Identity/Service/Notification/DevModeNotificationProviderTest.php @@ -0,0 +1,57 @@ +createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + + return new DevModeNotificationProvider($translator, $dev, $email); + } + + private function admin(bool $super): Administrator + { + $admin = $this->createMock(Administrator::class); + $admin->method('isSuperUser')->willReturn($super); + + return $admin; + } + + public function testDisabledProducesNothing(): void + { + self::assertSame([], $this->provider('0', 'dev@example.com')->getNotifications($this->admin(true))); + self::assertSame([], $this->provider(false, 'dev@example.com')->getNotifications($this->admin(true))); + } + + public function testEnabledShowsRedirectEmail(): void + { + $result = $this->provider('1', 'dev@example.com')->getNotifications($this->admin(true)); + + self::assertCount(1, $result); + self::assertSame('dev_mode', $result[0]->code); + self::assertSame('info', $result[0]->level); + self::assertSame(['email' => 'dev@example.com'], $result[0]->context); + } + + public function testEnabledWithoutEmail(): void + { + $result = $this->provider(true, '')->getNotifications($this->admin(true)); + + self::assertSame('dev_mode_without_email', $result[0]->code); + } + + public function testNonSuperUserGetsNothing(): void + { + self::assertSame([], $this->provider('1', 'dev@example.com')->getNotifications($this->admin(false))); + } +} diff --git a/tests/Unit/Domain/Identity/Service/Notification/MailerDisabledNotificationProviderTest.php b/tests/Unit/Domain/Identity/Service/Notification/MailerDisabledNotificationProviderTest.php new file mode 100644 index 00000000..436054d0 --- /dev/null +++ b/tests/Unit/Domain/Identity/Service/Notification/MailerDisabledNotificationProviderTest.php @@ -0,0 +1,60 @@ +createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + + return new MailerDisabledNotificationProvider($translator, $dsn); + } + + private function admin(bool $super): Administrator + { + $admin = $this->createMock(Administrator::class); + $admin->method('isSuperUser')->willReturn($super); + + return $admin; + } + + /** @dataProvider nullDsns */ + public function testNullTransportProducesWarning(string $dsn): void + { + $result = $this->provider($dsn)->getNotifications($this->admin(true)); + + self::assertCount(1, $result); + self::assertSame('mailer_disabled', $result[0]->code); + self::assertSame('warning', $result[0]->level); + } + + public static function nullDsns(): array + { + return [['null://null'], ['NULL://default'], ['null+smtp://x']]; + } + + /** @dataProvider realDsns */ + public function testRealTransportProducesNothing(string $dsn): void + { + self::assertSame([], $this->provider($dsn)->getNotifications($this->admin(true))); + } + + public static function realDsns(): array + { + return [['smtp://user:null@host:25'], ['sendgrid://KEY@default'], ['native://default'], ['']]; + } + + public function testNonSuperUserGetsNothing(): void + { + self::assertSame([], $this->provider('null://null')->getNotifications($this->admin(false))); + } +} diff --git a/tests/Unit/Domain/Identity/Service/Notification/PendingMigrationsNotificationProviderTest.php b/tests/Unit/Domain/Identity/Service/Notification/PendingMigrationsNotificationProviderTest.php index ba7d2581..cb53d0de 100644 --- a/tests/Unit/Domain/Identity/Service/Notification/PendingMigrationsNotificationProviderTest.php +++ b/tests/Unit/Domain/Identity/Service/Notification/PendingMigrationsNotificationProviderTest.php @@ -19,8 +19,9 @@ class PendingMigrationsNotificationProviderTest extends TestCase { - private function provider(MigrationStatusCalculator|RuntimeException $calculator): PendingMigrationsNotificationProvider - { + private function provider( + MigrationStatusCalculator|RuntimeException $calculator + ): PendingMigrationsNotificationProvider { $factory = $this->createMock(DependencyFactory::class); if ($calculator instanceof RuntimeException) { $factory->method('getMigrationStatusCalculator')->willThrowException($calculator); From 15ac4457f6851be06354401f7f1cc7a4a416770c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 11 Nov 2026 14:18:53 +0400 Subject: [PATCH 43/46] chore: remove todo lists are filtered by owner in repo --- src/Domain/Messaging/Service/Manager/MessageDataManager.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Domain/Messaging/Service/Manager/MessageDataManager.php b/src/Domain/Messaging/Service/Manager/MessageDataManager.php index 24b50582..c46af070 100644 --- a/src/Domain/Messaging/Service/Manager/MessageDataManager.php +++ b/src/Domain/Messaging/Service/Manager/MessageDataManager.php @@ -53,8 +53,6 @@ public function setMessageData(Message $campaign, string $name, mixed $value): v $value = $this->normalizeValueByName($name, $value); - // todo: targetLists (not only here) should be with $GLOBALS['subselect'] filter for access control - if (is_array($value) || is_object($value)) { $value = 'SER:' . serialize($value); } From c4c7edce4522af9fd004d3b5b942c72904d7bdd0 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 11 Nov 2026 16:22:01 +0400 Subject: [PATCH 44/46] feat: update email header handling to use 'Received' in case of http request sending --- config/parameters.yml | 2 +- config/services/services.yml | 4 + .../InjectedByHeaderSubscriber.php | 33 +++--- .../Service/Builder/ForwardEmailBuilder.php | 14 --- .../Builder/HttpReceivedStampBuilder.php | 9 +- .../Service/ForwardContentService.php | 7 +- .../Service/MessageForwardService.php | 4 +- .../InjectedByHeaderSubscriberTest.php | 102 ++++++++---------- .../Builder/ForwardEmailBuilderTest.php | 11 -- .../Builder/HttpReceivedStampBuilderTest.php | 10 +- .../Service/ForwardContentServiceTest.php | 15 +++ .../Service/MessageForwardServiceTest.php | 1 + 12 files changed, 98 insertions(+), 114 deletions(-) diff --git a/config/parameters.yml b/config/parameters.yml index aa00f865..d902b639 100644 --- a/config/parameters.yml +++ b/config/parameters.yml @@ -20,7 +20,7 @@ parameters: app.dev_email: '%env(APP_DEV_EMAIL)%' app.powered_by_phplist: '%env(APP_POWERED_BY_PHPLIST)%' app.preference_page_show_private_lists: '%env(PREFERENCEPAGE_SHOW_PRIVATE_LISTS)%' - app.rest_api_base_url: '%env(API_BASE_URL)%/api/v2' + app.rest_api_base_url: '%env(API_BASE_URL)%api/v2' app.api_base_url: '%env(API_BASE_URL)%' app.frontend_base_url: '%env(FRONT_END_BASE_URL)%' # falls back to empty so existing .env files without APP_WEBSITE keep working (the value may live in the database) diff --git a/config/services/services.yml b/config/services/services.yml index 5224f0ce..5b19db15 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -26,6 +26,10 @@ services: autowire: true autoconfigure: true + PhpList\Core\Domain\Messaging\EventSubscriber\InjectedByHeaderSubscriber: + autowire: true + autoconfigure: true + PhpList\Core\Domain\Messaging\Service\ForwardContentService: autowire: true autoconfigure: true diff --git a/src/Domain/Messaging/EventSubscriber/InjectedByHeaderSubscriber.php b/src/Domain/Messaging/EventSubscriber/InjectedByHeaderSubscriber.php index e74abff1..96b664e8 100644 --- a/src/Domain/Messaging/EventSubscriber/InjectedByHeaderSubscriber.php +++ b/src/Domain/Messaging/EventSubscriber/InjectedByHeaderSubscriber.php @@ -4,14 +4,19 @@ namespace PhpList\Core\Domain\Messaging\EventSubscriber; +use PhpList\Core\Domain\Messaging\Service\Builder\HttpReceivedStampBuilder; use Symfony\Component\EventDispatcher\EventSubscriberInterface; -use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Mailer\Event\MessageEvent; use Symfony\Component\Mime\Email; +/** + * When the email is generated from a web request, add a "Received" line to identify the origin + * (same as phplist3's PHPlistMailer::add_timestamp()). Mails sent from the CLI or by async + * workers have no request, so they are not stamped. + */ class InjectedByHeaderSubscriber implements EventSubscriberInterface { - public function __construct(private readonly RequestStack $requestStack) + public function __construct(private readonly HttpReceivedStampBuilder $stampBuilder) { } @@ -22,31 +27,19 @@ public static function getSubscribedEvents(): array public function onMessage(MessageEvent $event): void { - $request = $this->requestStack->getCurrentRequest(); - if (!$request) { - return; - } - - // todo: add custom header only to messages sent individually not from campaigns - // when the email is generated from a webpage (quite possible :-) add a "received line" to identify the origin $msg = $event->getMessage(); if (!$msg instanceof Email) { return; } - // Only when triggered by HTTP request context (not CLI workers) - if (PHP_SAPI === 'cli') { + $stamp = $this->stampBuilder->buildStamp(); + if ($stamp === null) { return; } - $ip = $request->getClientIp() ?? 'unknown'; - $host = gethostname() ?: 'unknown-host'; - - $timestamp = $request->server->get('REQUEST_TIME') ?? time(); - - $msg->getHeaders()->addTextHeader( - 'X-phpList-Injected-By', - sprintf('from [%s] by %s with HTTP; %s', $ip, $host, $timestamp) - ); + // phplist3 keeps a single stamp per mail: replace instead of stacking + $headers = $msg->getHeaders(); + $headers->remove('Received'); + $headers->addTextHeader('Received', preg_replace('/[\r\n]+/', ' ', $stamp)); } } diff --git a/src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php b/src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php index 778e62af..b31d2685 100644 --- a/src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php +++ b/src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php @@ -43,7 +43,6 @@ public function __construct( PdfGenerator $pdfGenerator, AttachmentAdder $attachmentAdder, TranslatorInterface $translator, - private readonly HttpReceivedStampBuilder $httpReceivedStampBuilder, string $googleSenderId, bool $useAmazonSes, bool $usePrecedenceHeader, @@ -129,17 +128,4 @@ public function buildForwardEmail( return [$email, $sentAs]; } - - public function applyCampaignHeaders(Email $email, Subscriber $subscriber): Email - { - $email = parent::applyCampaignHeaders($email, $subscriber); - - $receivedLine = $this->httpReceivedStampBuilder->buildStamp(); - if ($receivedLine !== null) { - $receivedLine = preg_replace('/[\r\n]+/', ' ', $receivedLine); - $email->getHeaders()->addTextHeader('Received', $receivedLine); - } - - return $email; - } } diff --git a/src/Domain/Messaging/Service/Builder/HttpReceivedStampBuilder.php b/src/Domain/Messaging/Service/Builder/HttpReceivedStampBuilder.php index fe06efca..2662f502 100644 --- a/src/Domain/Messaging/Service/Builder/HttpReceivedStampBuilder.php +++ b/src/Domain/Messaging/Service/Builder/HttpReceivedStampBuilder.php @@ -5,15 +5,12 @@ namespace PhpList\Core\Domain\Messaging\Service\Builder; use DateTimeImmutable; -use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\RequestStack; class HttpReceivedStampBuilder { - public function __construct( - private readonly RequestStack $requestStack, - #[Autowire('%app.rest_api_base_url%')] private readonly string $restApiBaseUrl, - ) { + public function __construct(private readonly RequestStack $requestStack) + { } public function buildStamp(): ?string @@ -40,7 +37,7 @@ public function buildStamp(): ?string $requestTime = $request->server->get('REQUEST_TIME') ?? time(); $date = (new DateTimeImmutable('@' . $requestTime))->format(\DATE_RFC2822); - return sprintf('from %s by %s with HTTP; %s', $from, $this->restApiBaseUrl, $date); + return sprintf('from %s by %s with HTTP; %s', $from, $request->getHost(), $date); } private function getHostByAddr(string $ipAddress): ?string diff --git a/src/Domain/Messaging/Service/ForwardContentService.php b/src/Domain/Messaging/Service/ForwardContentService.php index 48320a21..01d27388 100644 --- a/src/Domain/Messaging/Service/ForwardContentService.php +++ b/src/Domain/Messaging/Service/ForwardContentService.php @@ -13,6 +13,7 @@ use PhpList\Core\Domain\Messaging\Service\Builder\ForwardEmailBuilder; use PhpList\Core\Domain\Subscription\Model\Subscriber; use Psr\SimpleCache\CacheInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Mime\Email; class ForwardContentService @@ -20,6 +21,8 @@ class ForwardContentService public function __construct( private readonly CacheInterface $cache, private readonly ForwardEmailBuilder $forwardEmailBuilder, + private readonly MessagePrecacheService $precacheService, + #[Autowire('%messaging.forward_alternative_content%')] private readonly bool $forwardAlternativeContent = false, ) { } @@ -32,7 +35,9 @@ public function getContents( string $friendEmail, MessageForwardDto $forwardDto ): array { - $messagePrecacheDto = $this->cache->get(sprintf('messaging.message.base.%d.%d.%d', $campaign->getId(), 1, 0)); + $messagePrecacheDto = $this->cache->get( + $this->precacheService->getCacheKey($campaign->getId(), $this->forwardAlternativeContent) + ); if ($messagePrecacheDto === null) { throw new MessageCacheMissingException(); diff --git a/src/Domain/Messaging/Service/MessageForwardService.php b/src/Domain/Messaging/Service/MessageForwardService.php index c27af684..1b3aa54f 100644 --- a/src/Domain/Messaging/Service/MessageForwardService.php +++ b/src/Domain/Messaging/Service/MessageForwardService.php @@ -14,6 +14,7 @@ use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; +use Symfony\Component\DependencyInjection\Attribute\Autowire; class MessageForwardService { @@ -26,6 +27,7 @@ public function __construct( private readonly MessagePrecacheService $precacheService, private readonly AdminNotifier $adminNotifier, private readonly ForwardingStatsService $forwardingStatsService, + #[Autowire('%messaging.forward_alternative_content%')] private readonly bool $forwardAlternativeContent = false, ) { } @@ -54,7 +56,7 @@ public function forward(MessageForwardDto $messageForwardDto, Message $campaign) continue; } - if (!$this->precacheService->precacheMessage($campaign, $loadedMessageData, true)) { + if (!$this->precacheService->precacheMessage($campaign, $loadedMessageData, $this->forwardAlternativeContent)) { $forwardingRecipientResult = $this->handleFailure( campaign: $campaign, forwardingSubscriber: $forwardingSubscriber, diff --git a/tests/Unit/Domain/Messaging/EventSubscriber/InjectedByHeaderSubscriberTest.php b/tests/Unit/Domain/Messaging/EventSubscriber/InjectedByHeaderSubscriberTest.php index 78358a92..2bd88e2e 100644 --- a/tests/Unit/Domain/Messaging/EventSubscriber/InjectedByHeaderSubscriberTest.php +++ b/tests/Unit/Domain/Messaging/EventSubscriber/InjectedByHeaderSubscriberTest.php @@ -5,9 +5,9 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\EventSubscriber; use PhpList\Core\Domain\Messaging\EventSubscriber\InjectedByHeaderSubscriber; +use PhpList\Core\Domain\Messaging\Service\Builder\HttpReceivedStampBuilder; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\Event\MessageEvent; use Symfony\Component\Mime\Address; @@ -16,74 +16,64 @@ class InjectedByHeaderSubscriberTest extends TestCase { - public function testNoHeaderWhenNoCurrentRequest(): void + private HttpReceivedStampBuilder&MockObject $stampBuilder; + + protected function setUp(): void { - $requestStack = new RequestStack(); - $subscriber = new InjectedByHeaderSubscriber($requestStack); + $this->stampBuilder = $this->createMock(HttpReceivedStampBuilder::class); + } - $email = (new Email()) - ->from('from@example.com') - ->to('to@example.com') - ->subject('Subject') - ->text('Body'); + private function email(): Email + { + return (new Email())->from('from@example.com')->to('to@example.com')->subject('S')->text('B'); + } - $event = new MessageEvent($email, Envelope::create($email), 'test'); + public function testNoHeaderWhenNoStamp(): void + { + $this->stampBuilder->method('buildStamp')->willReturn(null); + $email = $this->email(); - $subscriber->onMessage($event); + (new InjectedByHeaderSubscriber($this->stampBuilder)) + ->onMessage(new MessageEvent($email, Envelope::create($email), 'test')); - $this->assertFalse( - $email->getHeaders()->has('X-phpList-Injected-By'), - 'Header must not be added when there is no current Request.' - ); + self::assertFalse($email->getHeaders()->has('Received')); } - public function testNoHeaderWhenMessageIsNotEmail(): void + public function testIgnoresNonEmailMessages(): void { - $requestStack = new RequestStack(); - // Push a Request to ensure the early return is due to non-Email message, not missing request - $requestStack->push(new Request(server: ['REQUEST_TIME' => time()])); - - $subscriber = new InjectedByHeaderSubscriber($requestStack); - - $raw = new RawMessage('raw'); - // Create an arbitrary envelope; it does not need to match the message class + $this->stampBuilder->expects(self::never())->method('buildStamp'); $envelope = new Envelope(new Address('from@example.com'), [new Address('to@example.com')]); - $event = new MessageEvent($raw, $envelope, 'test'); - // RawMessage has no headers; the subscriber should return early - $subscriber->onMessage($event); - // sanity check to use the variable - $this->assertSame('raw', $raw->toString()); - // Nothing to assert on headers (RawMessage has none), but the lack of exceptions is a success + (new InjectedByHeaderSubscriber($this->stampBuilder)) + ->onMessage(new MessageEvent(new RawMessage('raw'), $envelope, 'test')); + $this->addToAssertionCount(1); } - public function testNoHeaderWhenRunningInCliEvenWithRequestAndEmail(): void + public function testAddsReceivedHeader(): void { - // In PHPUnit, PHP_SAPI is typically "cli"; ensure we have a Request to pass other guards - $request = new Request(server: [ - 'REQUEST_TIME' => time(), - 'REMOTE_ADDR' => '127.0.0.1', - ]); - $requestStack = new RequestStack(); - $requestStack->push($request); - - $subscriber = new InjectedByHeaderSubscriber($requestStack); - - $email = (new Email()) - ->from('from@example.com') - ->to('to@example.com') - ->subject('Subject') - ->text('Body'); - - $event = new MessageEvent($email, Envelope::create($email), 'test'); - - $subscriber->onMessage($event); - - // Because tests run under CLI SAPI, the header must not be added - $this->assertFalse( - $email->getHeaders()->has('X-phpList-Injected-By'), - 'Header must not be added when running under CLI.' + $this->stampBuilder->method('buildStamp')->willReturn('from [1.2.3.4] by host with HTTP; date'); + $email = $this->email(); + + (new InjectedByHeaderSubscriber($this->stampBuilder)) + ->onMessage(new MessageEvent($email, Envelope::create($email), 'test')); + + self::assertSame( + 'from [1.2.3.4] by host with HTTP; date', + $email->getHeaders()->get('Received')->getBodyAsString() ); } + + public function testReplacesExistingReceivedHeader(): void + { + $this->stampBuilder->method('buildStamp')->willReturn('new'); + $email = $this->email(); + $email->getHeaders()->addTextHeader('Received', 'old'); + + (new InjectedByHeaderSubscriber($this->stampBuilder)) + ->onMessage(new MessageEvent($email, Envelope::create($email), 'test')); + + self::assertCount(1, iterator_to_array($email->getHeaders()->all('Received'))); + self::assertSame('new', $email->getHeaders()->get('Received')->getBodyAsString()); + } } diff --git a/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php index 2ca82e74..69c598d7 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php @@ -15,7 +15,6 @@ use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Service\AttachmentAdder; use PhpList\Core\Domain\Messaging\Service\Builder\ForwardEmailBuilder; -use PhpList\Core\Domain\Messaging\Service\Builder\HttpReceivedStampBuilder; use PhpList\Core\Domain\Messaging\Service\Constructor\CampaignMailContentBuilder; use PhpList\Core\Domain\Messaging\Service\TemplateImageEmbedder; use PhpList\Core\Domain\Subscription\Model\Subscriber; @@ -42,7 +41,6 @@ class ForwardEmailBuilderTest extends TestCase private PdfGenerator&MockObject $pdfGenerator; private AttachmentAdder&MockObject $attachmentAdder; private TranslatorInterface&MockObject $translator; - private HttpReceivedStampBuilder&MockObject $httpReceivedStampBuilder; protected function setUp(): void { @@ -61,7 +59,6 @@ protected function setUp(): void $this->pdfGenerator = $this->createMock(PdfGenerator::class); $this->attachmentAdder = $this->createMock(AttachmentAdder::class); $this->translator = $this->createMock(TranslatorInterface::class); - $this->httpReceivedStampBuilder = $this->createMock(HttpReceivedStampBuilder::class); // Defaults for config values used in headers $this->configProvider->method('getValue')->willReturnMap([ @@ -97,7 +94,6 @@ private function makeBuilder( pdfGenerator: $this->pdfGenerator, attachmentAdder: $this->attachmentAdder, translator: $this->translator, - httpReceivedStampBuilder: $this->httpReceivedStampBuilder, googleSenderId: $googleSenderId, useAmazonSes: $useAmazonSes, usePrecedenceHeader: $usePrecedenceHeader, @@ -139,10 +135,6 @@ public function testBuildsForwardEmailWithSubjectPrefixHeadersAndReplyTo(): void ->with($this->isInstanceOf(Email::class), 99, OutputFormat::Html, true) ->willReturn(true); - $this->httpReceivedStampBuilder - ->method('buildStamp') - ->willReturn('from host [127.0.0.1] by example.org with HTTP; Wed, 01 Jan 2025 00:00:00 +0000'); - $builder = $this->makeBuilder(devVersion: true, devEmail: 'dev@example.com'); [$email, $sentAs] = $builder->buildForwardEmail( messageId: 99, @@ -164,9 +156,6 @@ public function testBuildsForwardEmailWithSubjectPrefixHeadersAndReplyTo(): void $this->assertSame($fromEmail, $email->getReplyTo()[0]->getAddress()); $this->assertSame($fromName, $email->getReplyTo()[0]->getName()); - // Received header present - $this->assertNotNull($email->getHeaders()->get('Received')); - // Dev mode reroutes recipient $this->assertSame('dev@example.com', $email->getTo()[0]->getAddress()); } diff --git a/tests/Unit/Domain/Messaging/Service/Builder/HttpReceivedStampBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/HttpReceivedStampBuilderTest.php index aeb0d73c..155b87d9 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/HttpReceivedStampBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/HttpReceivedStampBuilderTest.php @@ -14,7 +14,7 @@ class HttpReceivedStampBuilderTest extends TestCase public function testReturnsNullWhenNoRequest(): void { $stack = new RequestStack(); - $builder = new HttpReceivedStampBuilder($stack, 'api.example.test'); + $builder = new HttpReceivedStampBuilder($stack); self::assertNull($builder->buildStamp()); } @@ -26,7 +26,7 @@ public function testReturnsNullWhenNoClientIp(): void // Do not set REMOTE_ADDR to simulate missing client IP $stack->push($request); - $builder = new HttpReceivedStampBuilder($stack, 'api.example.test'); + $builder = new HttpReceivedStampBuilder($stack); self::assertNull($builder->buildStamp()); } @@ -40,9 +40,10 @@ public function testBuildsStampWithRemoteHostAndFixedTime(): void $request->server->set('REMOTE_HOST', 'client.example.org'); // Fix the request time for deterministic output (Unix epoch start) $request->server->set('REQUEST_TIME', 0); + $request->headers->set('Host', 'api.example.test'); $stack->push($request); - $builder = new HttpReceivedStampBuilder($stack, 'api.example.test'); + $builder = new HttpReceivedStampBuilder($stack); $stamp = $builder->buildStamp(); @@ -61,9 +62,10 @@ public function testBuildsStampWithIpOnlyNoReverseDns(): void // Ensure no REMOTE_HOST so builder attempts reverse DNS, which should fail and fallback to IP only $request->server->remove('REMOTE_HOST'); $request->server->set('REQUEST_TIME', 0); + $request->headers->set('Host', 'api.example.test'); $stack->push($request); - $builder = new HttpReceivedStampBuilder($stack, 'api.example.test'); + $builder = new HttpReceivedStampBuilder($stack); $stamp = $builder->buildStamp(); diff --git a/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php b/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php index 65bcc57e..c038f7ed 100644 --- a/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php @@ -11,6 +11,7 @@ use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Service\ForwardContentService; +use PhpList\Core\Domain\Messaging\Service\MessagePrecacheService; use PhpList\Core\Domain\Messaging\Service\Builder\ForwardEmailBuilder; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PHPUnit\Framework\MockObject\MockObject; @@ -22,11 +23,21 @@ class ForwardContentServiceTest extends TestCase { private CacheInterface&MockObject $cache; private ForwardEmailBuilder&MockObject $builder; + private MessagePrecacheService&MockObject $precacheService; protected function setUp(): void { $this->cache = $this->createMock(CacheInterface::class); $this->builder = $this->createMock(ForwardEmailBuilder::class); + $this->precacheService = $this->createMock(MessagePrecacheService::class); + $this->precacheService->method('getCacheKey')->willReturnCallback( + fn (int $id, bool $forward = false, bool $test = false) => sprintf( + 'messaging.message.base.%d.%d.%d', + $id, + (int) $forward, + (int) $test + ) + ); } public function testThrowsWhenCacheMissing(): void @@ -34,6 +45,8 @@ public function testThrowsWhenCacheMissing(): void $service = new ForwardContentService( cache: $this->cache, forwardEmailBuilder: $this->builder, + precacheService: $this->precacheService, + forwardAlternativeContent: true, ); $campaign = $this->createMock(Message::class); @@ -67,6 +80,8 @@ public function testDelegatesCachedContentToBuilderWithoutClickTracking(): void $service = new ForwardContentService( cache: $this->cache, forwardEmailBuilder: $this->builder, + precacheService: $this->precacheService, + forwardAlternativeContent: true, ); $campaign = $this->createMock(Message::class); diff --git a/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php b/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php index 6eeab890..0cd69c49 100644 --- a/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php @@ -58,6 +58,7 @@ private function createService(): MessageForwardService precacheService: $this->precache, adminNotifier: $this->notifier, forwardingStatsService: $this->stats, + forwardAlternativeContent: true, ); } From e91399887701577fb610fbf5668912bd5654f068 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 11 Nov 2026 16:26:03 +0400 Subject: [PATCH 45/46] feat: enhance placeholder replacement for website and domain in configuration values --- resources/translations/messages.en.xlf | 212 +++++++++--------- .../Service/Provider/ConfigProvider.php | 20 +- .../Service/Provider/ConfigProviderTest.php | 18 ++ 3 files changed, 142 insertions(+), 108 deletions(-) diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf index 03d7912e..f3f356e3 100644 --- a/resources/translations/messages.en.xlf +++ b/resources/translations/messages.en.xlf @@ -394,7 +394,7 @@ 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! + Thank you for subscribing! Please confirm your subscription by clicking the link below: @@ -404,7 +404,7 @@ If you did not request this subscription, please ignore this email. <p>Thank you for subscribing!</p><p>Please confirm your subscription by clicking the link below:</p><p><a href="%confirmation_link%">Confirm Subscription</a></p><p>If you did not request this subscription, please ignore this email.</p> - Thank you for subscribing!

Please confirm your subscription by clicking the link below:

Confirm Subscription

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

]]>
+ Thank you for subscribing!

Please confirm your subscription by clicking the link below:

Confirm Subscription

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

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

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

Please use the following token to reset your password:

Reset Password

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

Thank you.

]]>
+ Password Reset Request!

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

Please use the following token to reset your password:

Reset Password

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

Thank you.

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

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

]]>
+ Thanks, you have been added to our newsletter

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

]]>
Text to display when subscription with an AJAX request was successful - __Text to display when subscription with an AJAX request was successful + Text to display when subscription with an AJAX request was successful Subject of the message subscribers receive when they sign up - __Subject of the message subscribers receive when they sign up + Subject of the message subscribers receive when they sign up Message subscribers receive when they sign up - __Message subscribers receive when they sign up + Message subscribers receive when they sign up Goodbye from our Newsletter - __Goodbye from our Newsletter + Goodbye from our Newsletter Subject of the message subscribers receive when they unsubscribe - __Subject of the message subscribers receive when they unsubscribe + Subject of the message subscribers receive when they unsubscribe Message subscribers receive when they unsubscribe - __Message subscribers receive when they unsubscribe + Message subscribers receive when they unsubscribe Welcome to our Newsletter - __Welcome to our Newsletter + Welcome to our Newsletter Subject of the message subscribers receive after confirming their email address - __Subject of the message subscribers receive after confirming their email address + Subject of the message subscribers receive after confirming their email address Message subscribers receive after confirming their email address - __Message subscribers receive after confirming their email address + Message subscribers receive after confirming their email address [notify] Change of List-Membership details - __[notify] Change of List-Membership details + [notify] Change of List-Membership details Subject of the message subscribers receive when they have changed their details - __Subject of the message subscribers receive when they have changed their details + Subject of the message subscribers receive when they have changed their details Message subscribers receive when they have changed their details - __Message subscribers receive when they have changed their details + Message subscribers receive when they have changed their details Part of the message that is sent to their new email address when subscribers change their information, and the email address has changed - __Part of the message that is sent to their new email address when subscribers change their information, and the email address has changed + Part of the message that is sent to their new email address when subscribers change their information, and the email address has changed Part of the message that is sent to their old email address when subscribers change their information, and the email address has changed - __Part of the message that is sent to their old email address when subscribers change their information, and the email address has changed + Part of the message that is sent to their old email address when subscribers change their information, and the email address has changed Your personal location - __Your personal location + Your personal location Subject of message when subscribers request their personal location - __Subject of message when subscribers request their personal location + Subject of message when subscribers request their personal location Default footer for sending a campaign - __Default footer for sending a campaign + Default footer for sending a campaign Footer used when a message has been forwarded - __Footer used when a message has been forwarded + Footer used when a message has been forwarded Message to send when they request their personal location - __Message to send when they request their personal location + Message to send when they request their personal location String to always append to remote URL when using send-a-webpage - __String to always append to remote URL when using send-a-webpage + String to always append to remote URL when using send-a-webpage Width for Wordwrap of Text messages - __Width for Wordwrap of Text messages + Width for Wordwrap of Text messages CSS for HTML messages without a template - __CSS for HTML messages without a template + CSS for HTML messages without a template Domains that only accept text emails, one per line - __Domains that only accept text emails, one per line + Domains that only accept text emails, one per line last time TLDs were fetched - __last time TLDs were fetched + last time TLDs were fetched Top level domains - __Top level domains + Top level domains Header of public pages. - __Header of public pages. + Header of public pages. Footer of public pages - __Footer of public pages + Footer of public pages Please confirm your subscription - __Please confirm your subscription + Please confirm your subscription No user details changed - __No user details changed + No user details changed %field% = %new% *changed* from %old% - __%field% = %new% *changed* from %old% + %field% = %new% *changed* from %old% Subscribed to %list% - __Subscribed to %list% + Subscribed to %list% Subscriber marked unconfirmed for invalid email address - __Subscriber marked unconfirmed for invalid email address + Subscriber marked unconfirmed for invalid email address Marked unconfirmed while sending campaign %message_id% - __Marked unconfirmed while sending campaign %message_id% + Marked unconfirmed while sending campaign %message_id% Update by %admin% - __Update by %admin% + Update by %admin% (no data) - __(no data) + (no data) %attribute% = %new_value% changed from %old_value% - __%attribute% = %new_value% + %attribute% = %new_value% changed from %old_value% No data changed - __No data changed + No data changed Campaign not found or not in submitted status - __Campaign not found or not in submitted status + Campaign not found or not in submitted status Conflict: email and foreign key refer to different subscribers. - __Conflict: email and foreign key refer to different subscribers. + Conflict: email and foreign key refer to different subscribers. Invalid email: %email% - __Invalid email: %email% + Invalid email: %email% Value must be an AttributeTypeEnum or string. - __Value must be an AttributeTypeEnum or string. + Value must be an AttributeTypeEnum or string. Campaign started - __Campaign started + Campaign started phplist has started sending the campaign with subject %s - __phplist has started sending the campaign with subject %s + phplist has started sending the campaign with subject %s phplist has started sending the campaign with subject %subject% - __phplist has started sending the campaign with subject %subject% + phplist has started sending the campaign with subject %subject% Unsubscribe - __Unsubscribe + Unsubscribe This link - __This link + This link Confirm - __Confirm + Confirm Update preferences - __Update preferences + Update preferences Sorry, you are not subscribed to any of our newsletters with this email address. - __Sorry, you are not subscribed to any of our newsletters with this email address. + Sorry, you are not subscribed to any of our newsletters with this email address. This message contains attachments that can be viewed with a webbrowser - __This message contains attachments that can be viewed with a webbrowser + This message contains attachments that can be viewed with a webbrowser Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit% - __Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit% + Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit% Add us to your address book - __Add us to your address book + Add us to your address book phpList system error - __phpList system error + phpList system error Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be copied to the repository. Check for permissions. - __Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be copied to the repository. Check for permissions. + Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be copied to the repository. Check for permissions. failed to open attachment (%remoteFile%) to add to campaign %campaignId% - __failed to open attachment (%remoteFile%) to add to campaign %campaignId% + failed to open attachment (%remoteFile%) to add to campaign %campaignId% Attachment %remoteFile% does not exist - __Attachment %remoteFile% does not exist + Attachment %remoteFile% does not exist Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be found in the repository. - __Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be found in the repository. + Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be found in the repository. Location - __Location + Location Fwd - __Fwd + Fwd (test) - __(test) + (test) Message Forwarded - __Message Forwarded + Message Forwarded %subscriber% tried forwarding message %campaignId% to %email% but failed - __%subscriber% tried forwarding message %campaignId% to %email% but failed + %subscriber% tried forwarding message %campaignId% to %email% but failed %subscriber% has forwarded message %campaignId% to %email% - __%subscriber% has forwarded message %campaignId% to %email% + %subscriber% has forwarded message %campaignId% to %email% Error precaching campaign message: %error% - __Error precaching campaign message: %error% + Error precaching campaign message: %error% phplist system error - __phplist system error + phplist system error Failed to send to: %subscriber_id% - __Failed to send to: %subscriber_id% + Failed to send to: %subscriber_id% Invalid email, marking unconfirmed: %subscriber_id% - __Invalid email, marking unconfirmed: %subscriber_id% + Invalid email, marking unconfirmed: %subscriber_id% diff --git a/src/Domain/Configuration/Service/Provider/ConfigProvider.php b/src/Domain/Configuration/Service/Provider/ConfigProvider.php index 2890a86d..ed182c0b 100644 --- a/src/Domain/Configuration/Service/Provider/ConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/ConfigProvider.php @@ -65,11 +65,27 @@ public function getValue(ConfigOption $key): ?string $this->cache->set($cacheKey, $value, $this->ttlSeconds); } - if ($value !== null) { + if ($value === null && $this->defaultConfigs->has($key)) { + $value = (string) $this->defaultConfigs->get($key)['value']; + } + + return $value === null ? null : $this->replaceBuiltInPlaceholders($value, $key); + } + + private function replaceBuiltInPlaceholders(string $value, ConfigOption $key): string + { + if ($key === ConfigOption::Website || $key === ConfigOption::Domain) { return $value; } - return $this->defaultConfigs->has($key) ? (string) $this->defaultConfigs->get($key)['value'] : null; + if (stripos($value, '[WEBSITE]') !== false) { + $value = str_ireplace('[WEBSITE]', $this->getValue(ConfigOption::Website) ?? '', $value); + } + if (stripos($value, '[DOMAIN]') !== false) { + $value = str_ireplace('[DOMAIN]', $this->getValue(ConfigOption::Domain) ?? '', $value); + } + + return $value; } public function getValueWithNamespace(ConfigOption|string $key): ?string diff --git a/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php b/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php index bd7eee08..d51d33f9 100644 --- a/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php +++ b/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php @@ -248,4 +248,22 @@ public function testGetValueWithNamespaceSupportsStringNamespacedKey(): void $this->assertSame('PARENT', $this->provider->getValueWithNamespace('subscribemessage:1')); } + + public function testGetValueReplacesWebsiteAndDomainPlaceholders(): void + { + $this->cache->method('get')->willReturn(null); + $this->repo->method('findValueByItem')->willReturnCallback( + fn (string $item) => match ($item) { + ConfigOption::SubscribeUrl->value => 'https://[WEBSITE]/lists/?p=subscribe&d=[DOMAIN]', + ConfigOption::Website->value => 'lists.example.org', + ConfigOption::Domain->value => 'example.org', + default => null, + } + ); + + self::assertSame( + 'https://lists.example.org/lists/?p=subscribe&d=example.org', + $this->provider->getValue(ConfigOption::SubscribeUrl) + ); + } } From ef20e9cc9ea8460ff62c678dd2f48bf1c7fcd8f9 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 11 Nov 2026 16:38:32 +0400 Subject: [PATCH 46/46] todos --- .../EventSubscriber/InjectedByHeaderSubscriber.php | 1 - src/Domain/Messaging/Service/MessageForwardService.php | 7 ++++++- .../Service/Manager/SubscriberAttributeManager.php | 7 ++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Domain/Messaging/EventSubscriber/InjectedByHeaderSubscriber.php b/src/Domain/Messaging/EventSubscriber/InjectedByHeaderSubscriber.php index 96b664e8..d3286b99 100644 --- a/src/Domain/Messaging/EventSubscriber/InjectedByHeaderSubscriber.php +++ b/src/Domain/Messaging/EventSubscriber/InjectedByHeaderSubscriber.php @@ -37,7 +37,6 @@ public function onMessage(MessageEvent $event): void return; } - // phplist3 keeps a single stamp per mail: replace instead of stacking $headers = $msg->getHeaders(); $headers->remove('Received'); $headers->addTextHeader('Received', preg_replace('/[\r\n]+/', ' ', $stamp)); diff --git a/src/Domain/Messaging/Service/MessageForwardService.php b/src/Domain/Messaging/Service/MessageForwardService.php index 1b3aa54f..ec4062cb 100644 --- a/src/Domain/Messaging/Service/MessageForwardService.php +++ b/src/Domain/Messaging/Service/MessageForwardService.php @@ -56,7 +56,12 @@ public function forward(MessageForwardDto $messageForwardDto, Message $campaign) continue; } - if (!$this->precacheService->precacheMessage($campaign, $loadedMessageData, $this->forwardAlternativeContent)) { + $messagePrecached = $this->precacheService->precacheMessage( + $campaign, + $loadedMessageData, + $this->forwardAlternativeContent + ); + if (!$messagePrecached) { $forwardingRecipientResult = $this->handleFailure( campaign: $campaign, forwardingSubscriber: $forwardingSubscriber, diff --git a/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php b/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php index 8eb1d363..d0d847dc 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php @@ -43,7 +43,12 @@ public function createOrUpdate( SubscriberAttributeDefinition $definition, ?string $value = null ): SubscriberAttributeValue { - // todo: clarify which attributes can be created/updated + // todo: phplist3 saves the value as given (empty allowed) and checks required fields at form level; + // here a missing value without a default throws. Check whether that is intended. + // todo: phplist3 maps a select attribute's value to its option (adding the option when missing); + // here the value is stored as given without checking it against the options. + // Like phplist3's saveUserAttribute(): only attributes that have a definition get a value. Built-in + // subscriber fields (email, password, ...) are columns on the subscriber, not attribute values. $subscriberAttribute = $this->attributeRepository ->findOneBySubscriberAndAttribute($subscriber, $definition);