From 3ebedfbfce05a0a3a5b48baedbeaf7e4cafe1c38 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 26 Oct 2026 13:32:10 +0400 Subject: [PATCH 01/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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