Skip to content

Commit fbd4922

Browse files
committed
MessageDataManager
1 parent 0800347 commit fbd4922

File tree

13 files changed

+216
-71
lines changed

13 files changed

+216
-71
lines changed

config/PhpCodeSniffer/ruleset.xml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@
3030

3131
<!-- Commenting -->
3232
<rule ref="Generic.Commenting.Fixme"/>
33-
<rule ref="Generic.Commenting.Todo"/>
33+
<rule ref="Generic.Commenting.Todo">
34+
<severity>0</severity>
35+
</rule>
3436
<rule ref="PEAR.Commenting.InlineComment"/>
3537
<rule ref="Squiz.Commenting.DocCommentAlignment"/>
3638
<rule ref="Squiz.Commenting.EmptyCatchComment"/>

config/services.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,6 @@ services:
5252
- { name: 'doctrine.dbal.schema_filter', connection: 'default' }
5353

5454
PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler:
55+
autowire: true
5556
arguments:
5657
$maxMailSize: '%messaging.max_mail_size%'

config/services/managers.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,7 @@ services:
110110
PhpList\Core\Domain\Messaging\Service\Manager\SendProcessManager:
111111
autowire: true
112112
autoconfigure: true
113+
114+
PhpList\Core\Domain\Messaging\Service\Manager\MessageDataManager:
115+
autowire: true
116+
autoconfigure: true

config/services/repositories.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,8 @@ services:
140140
parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
141141
arguments:
142142
- PhpList\Core\Domain\Messaging\Model\SendProcess
143+
144+
PhpList\Core\Domain\Messaging\Repository\MessageDataRepository:
145+
parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
146+
arguments:
147+
- PhpList\Core\Domain\Messaging\Model\MessageData

src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php

Lines changed: 28 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
1616
use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository;
1717
use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler;
18+
use PhpList\Core\Domain\Messaging\Service\Manager\MessageDataManager;
1819
use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter;
1920
use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator;
2021
use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer;
@@ -23,6 +24,7 @@
2324
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
2425
use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider;
2526
use Psr\Log\LoggerInterface;
27+
use Psr\SimpleCache\CacheInterface;
2628
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
2729
use Symfony\Component\Mime\Email;
2830
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -35,47 +37,25 @@
3537
#[AsMessageHandler]
3638
class CampaignProcessorMessageHandler
3739
{
38-
private RateLimitedCampaignMailer $mailer;
39-
private EntityManagerInterface $entityManager;
40-
private SubscriberProvider $subscriberProvider;
41-
private MessageProcessingPreparator $messagePreparator;
42-
private LoggerInterface $logger;
43-
private UserMessageRepository $userMessageRepository;
44-
private MaxProcessTimeLimiter $timeLimiter;
45-
private RequeueHandler $requeueHandler;
46-
private TranslatorInterface $translator;
47-
private SubscriberHistoryManager $subscriberHistoryManager;
48-
private MessageRepository $messageRepository;
49-
private EventLogManager $eventLogManager;
5040
private ?int $maxMailSize;
5141

5242
public function __construct(
53-
RateLimitedCampaignMailer $mailer,
54-
EntityManagerInterface $entityManager,
55-
SubscriberProvider $subscriberProvider,
56-
MessageProcessingPreparator $messagePreparator,
57-
LoggerInterface $logger,
58-
UserMessageRepository $userMessageRepository,
59-
MaxProcessTimeLimiter $timeLimiter,
60-
RequeueHandler $requeueHandler,
61-
TranslatorInterface $translator,
62-
SubscriberHistoryManager $subscriberHistoryManager,
63-
MessageRepository $messageRepository,
64-
EventLogManager $eventLogManager,
43+
private readonly RateLimitedCampaignMailer $mailer,
44+
private readonly EntityManagerInterface $entityManager,
45+
private readonly SubscriberProvider $subscriberProvider,
46+
private readonly MessageProcessingPreparator $messagePreparator,
47+
private readonly LoggerInterface $logger,
48+
private readonly CacheInterface $cache,
49+
private readonly UserMessageRepository $userMessageRepository,
50+
private readonly MaxProcessTimeLimiter $timeLimiter,
51+
private readonly RequeueHandler $requeueHandler,
52+
private readonly TranslatorInterface $translator,
53+
private readonly SubscriberHistoryManager $subscriberHistoryManager,
54+
private readonly MessageRepository $messageRepository,
55+
private readonly EventLogManager $eventLogManager,
56+
private readonly MessageDataManager $messageDataManager,
6557
?int $maxMailSize = null,
6658
) {
67-
$this->mailer = $mailer;
68-
$this->entityManager = $entityManager;
69-
$this->subscriberProvider = $subscriberProvider;
70-
$this->messagePreparator = $messagePreparator;
71-
$this->logger = $logger;
72-
$this->userMessageRepository = $userMessageRepository;
73-
$this->timeLimiter = $timeLimiter;
74-
$this->requeueHandler = $requeueHandler;
75-
$this->translator = $translator;
76-
$this->subscriberHistoryManager = $subscriberHistoryManager;
77-
$this->messageRepository = $messageRepository;
78-
$this->eventLogManager = $eventLogManager;
7959
$this->maxMailSize = $maxMailSize ?? 0;
8060
}
8161

@@ -170,12 +150,13 @@ private function handleInvalidEmail(UserMessage $userMessage, Subscriber $subscr
170150

171151
private function handleEmailSending(mixed $campaign, Subscriber $subscriber, UserMessage $userMessage): void
172152
{
173-
$processed = $this->messagePreparator->processMessageLinks($campaign, $subscriber->getId());
153+
$processed = $this->messagePreparator->processMessageLinks($campaign, $subscriber);
154+
// todo: precacheMessage
174155

175156
try {
176157
$email = $this->mailer->composeEmail($processed, $subscriber);
177158
$this->mailer->send($email);
178-
$this->checkMessageSizeOrSuspendCampaign($campaign, $email);
159+
$this->checkMessageSizeOrSuspendCampaign($campaign, $email, $subscriber->hasHtmlEmail());
179160
$this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent);
180161
} catch (MessageSizeLimitExceededException $e) {
181162
$this->updateMessageStatus($campaign, MessageStatus::Suspended);
@@ -194,12 +175,20 @@ private function handleEmailSending(mixed $campaign, Subscriber $subscriber, Use
194175
private function checkMessageSizeOrSuspendCampaign(
195176
Message $campaign,
196177
Email $email,
178+
bool $hasHtmlEmail
197179
): void {
198180
if ($this->maxMailSize <= 0) {
199181
return;
200182
}
183+
$sizeName = $hasHtmlEmail ? 'htmlsize' : 'textsize';
184+
$cacheKey = sprintf('messaging.size.%d.%s', $campaign->getId(), $sizeName);
185+
if (!$this->cache->has($cacheKey)) {
186+
$size = $this->calculateEmailSize($email);
187+
$this->messageDataManager->setMessageData($campaign, $sizeName, $size);
188+
$this->cache->set($cacheKey, $size);
189+
}
201190

202-
$size = $this->calculateEmailSize($email);
191+
$size = $this->cache->get($cacheKey);
203192
if ($size <= $this->maxMailSize) {
204193
return;
205194
}

src/Domain/Messaging/Repository/MessageDataRepository.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@
77
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
88
use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
99
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
10+
use PhpList\Core\Domain\Messaging\Model\MessageData;
1011

1112
class MessageDataRepository extends AbstractRepository implements PaginatableRepositoryInterface
1213
{
1314
use CursorPaginationTrait;
15+
16+
public function findByIdAndName(int $messageId, string $name): ?MessageData
17+
{
18+
return $this->findOneBy(['id' => $messageId, 'name' => $name]);
19+
}
1420
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\Core\Domain\Messaging\Service\Manager;
6+
7+
use PhpList\Core\Domain\Messaging\Model\Message;
8+
use PhpList\Core\Domain\Messaging\Model\MessageData;
9+
use PhpList\Core\Domain\Messaging\Repository\MessageDataRepository;
10+
11+
class MessageDataManager
12+
{
13+
public function __construct(
14+
private readonly MessageDataRepository $messageDataRepository,
15+
) {
16+
}
17+
18+
/**
19+
* Mirrors the legacy setMessageData behavior with safe sanitization and persistence.
20+
*
21+
* @param Message $campaign
22+
* @param string $name
23+
* @param mixed $value
24+
*/
25+
public function setMessageData(Message $campaign, string $name, mixed $value): void
26+
{
27+
if ($name === 'PHPSESSID' || $name === session_name()) {
28+
return;
29+
}
30+
31+
$value = $this->normalizeValueByName($name, $value);
32+
33+
// todo: remove this once we have a proper way to handle targetlists
34+
// if ($name === 'targetlist' && is_array($value)) {
35+
// $this->listMessageRepository->removeAllListAssociationsForMessage($campaign);
36+
//
37+
// if (!empty($value['all']) || !empty($value['allactive'])) {
38+
// // todo: should be with $GLOBALS['subselect'] filter for access control
39+
// foreach ($this->subscriberListRepository->getAllActive() as $list) {
40+
// $listMessage = (new ListMessage())
41+
// ->setMessage($campaign)
42+
// ->setList($list);
43+
// $this->listMessageRepository->persist($listMessage);
44+
// }
45+
// // once we used "all" to set all, unset it, to avoid confusion trying to unselect lists
46+
// unset($value['all']);
47+
// } else {
48+
// foreach ($value as $listId => $val) {
49+
// // see #16940 - ignore a list called "unselect" which is there to allow unselecting all
50+
// if ($listId !== 'unselect') {
51+
// $list = $this->subscriberListRepository->find($listId);
52+
// $listMessage = (new ListMessage())
53+
// ->setMessage($campaign)
54+
// ->setList($list);
55+
// $this->listMessageRepository->persist($listMessage);
56+
// }
57+
// }
58+
// }
59+
// }
60+
61+
if (is_array($value) || is_object($value)) {
62+
$value = 'SER:' . serialize($value);
63+
}
64+
65+
$entity = $this->getOrCreateMessageDataEntity($campaign, $name);
66+
$entity->setData($value !== null ? (string) $value : null);
67+
}
68+
69+
/**
70+
* Remove potentially harmful JavaScript from HTML content.
71+
*
72+
* This is a conservative cleaner: removes <script> blocks, javascript: URLs,
73+
* and inline event handlers (on*) attributes.
74+
*/
75+
private function disableJavascript(string $html): string
76+
{
77+
// Remove script tags and their content
78+
$clean = preg_replace('#<script\b[^>]*>.*?</script>#is', '', $html) ?? $html;
79+
80+
// Remove on*="..." event handler attributes
81+
$clean = preg_replace('/\s+on[a-zA-Z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $clean) ?? $clean;
82+
83+
// Neutralize javascript: and data: URIs in href/src/style
84+
$clean = preg_replace('/\b(href|src)\s*=\s*("|\')\s*(javascript:|data:)[^\2]*\2/i', '$1="#"', $clean) ?? $clean;
85+
return preg_replace('/\bstyle\s*=\s*("|\')[^\1]*\1/i', '', $clean) ?? $clean;
86+
}
87+
88+
private function normalizeValueByName(string $name, mixed $value)
89+
{
90+
return match ($name) {
91+
'subject', 'campaigntitle' => is_string($value) ? strip_tags($value) : $value,
92+
'message' => is_string($value) ? $this->disableJavascript($value) : $value,
93+
'excludelist' => is_array($value) ? array_filter($value, fn ($val) => is_numeric($val)) : $value,
94+
'footer' => preg_replace('/<!--.*-->/', '', $value),
95+
default => $value,
96+
};
97+
}
98+
99+
private function getOrCreateMessageDataEntity(Message $campaign, string $name)
100+
{
101+
$entity = $this->messageDataRepository->findByIdAndName($campaign->getId(), $name);
102+
if (!$entity instanceof MessageData) {
103+
$entity = (new MessageData())
104+
->setId($campaign->getId())
105+
->setName($name);
106+
$this->messageDataRepository->persist($entity);
107+
}
108+
109+
return $entity;
110+
}
111+
}

src/Domain/Messaging/Service/MessageProcessingPreparator.php

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,26 @@
55
namespace PhpList\Core\Domain\Messaging\Service;
66

77
use PhpList\Core\Domain\Analytics\Service\LinkTrackService;
8+
use PhpList\Core\Domain\Configuration\Service\UserPersonalizer;
89
use PhpList\Core\Domain\Messaging\Model\Message;
910
use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
11+
use PhpList\Core\Domain\Subscription\Model\Subscriber;
1012
use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
1113
use Symfony\Component\Console\Output\OutputInterface;
1214
use Symfony\Contracts\Translation\TranslatorInterface;
1315

1416
class MessageProcessingPreparator
1517
{
16-
// phpcs:ignore Generic.Commenting.Todo
1718
// @todo: create functionality to track
18-
public const LINT_TRACK_ENDPOINT = '/api/v2/link-track';
19-
private SubscriberRepository $subscriberRepository;
20-
private MessageRepository $messageRepository;
21-
private LinkTrackService $linkTrackService;
22-
private TranslatorInterface $translator;
19+
public const LINK_TRACK_ENDPOINT = '/api/v2/link-track';
2320

2421
public function __construct(
25-
SubscriberRepository $subscriberRepository,
26-
MessageRepository $messageRepository,
27-
LinkTrackService $linkTrackService,
28-
TranslatorInterface $translator,
22+
private readonly SubscriberRepository $subscriberRepository,
23+
private readonly MessageRepository $messageRepository,
24+
private readonly LinkTrackService $linkTrackService,
25+
private readonly TranslatorInterface $translator,
26+
private readonly UserPersonalizer $userPersonalizer,
2927
) {
30-
$this->subscriberRepository = $subscriberRepository;
31-
$this->messageRepository = $messageRepository;
32-
$this->linkTrackService = $linkTrackService;
33-
$this->translator = $translator;
3428
}
3529

3630
public function ensureSubscribersHaveUuid(OutputInterface $output): void
@@ -66,13 +60,13 @@ public function ensureCampaignsHaveUuid(OutputInterface $output): void
6660
/**
6761
* Process message content to extract URLs and replace them with link track URLs
6862
*/
69-
public function processMessageLinks(Message $message, int $userId): Message
63+
public function processMessageLinks(Message $message, Subscriber $subscriber): Message
7064
{
7165
if (!$this->linkTrackService->isExtractAndSaveLinksApplicable()) {
7266
return $message;
7367
}
7468

75-
$savedLinks = $this->linkTrackService->extractAndSaveLinks($message, $userId);
69+
$savedLinks = $this->linkTrackService->extractAndSaveLinks($message, $subscriber->getId());
7670

7771
if (empty($savedLinks)) {
7872
return $message;
@@ -81,14 +75,16 @@ public function processMessageLinks(Message $message, int $userId): Message
8175
$content = $message->getContent();
8276
$htmlText = $content->getText();
8377
$footer = $content->getFooter();
84-
78+
// todo: check other configured data that should be used in mail formatting/creation
8579
if ($htmlText !== null) {
8680
$htmlText = $this->replaceLinks($savedLinks, $htmlText);
81+
$htmlText = $this->userPersonalizer->personalize($htmlText, $subscriber->getEmail());
8782
$content->setText($htmlText);
8883
}
8984

9085
if ($footer !== null) {
9186
$footer = $this->replaceLinks($savedLinks, $footer);
87+
$footer = $this->userPersonalizer->personalize($footer, $subscriber->getEmail());
9288
$content->setFooter($footer);
9389
}
9490

@@ -99,7 +95,7 @@ private function replaceLinks(array $savedLinks, string $htmlText): string
9995
{
10096
foreach ($savedLinks as $linkTrack) {
10197
$originalUrl = $linkTrack->getUrl();
102-
$trackUrl = '/' . self::LINT_TRACK_ENDPOINT . '?id=' . $linkTrack->getId();
98+
$trackUrl = '/' . self::LINK_TRACK_ENDPOINT . '?id=' . $linkTrack->getId();
10399
$htmlText = str_replace('href="' . $originalUrl . '"', 'href="' . $trackUrl . '"', $htmlText);
104100
}
105101

src/Domain/Messaging/Service/RateLimitedCampaignMailer.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public function composeEmail(Message $processed, Subscriber $subscriber): Email
3434
return $email
3535
->to($subscriber->getEmail())
3636
->subject($processed->getContent()->getSubject())
37+
// todo: check HTML2Text functionality
3738
->text($processed->getContent()->getTextMessage())
3839
->html($processed->getContent()->getText());
3940
}

src/Domain/Subscription/Repository/SubscriberListRepository.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,12 @@ public function getListsByMessage(Message $message): array
4545
->getQuery()
4646
->getResult();
4747
}
48+
49+
public function getAllActive(): array
50+
{
51+
return $this->createQueryBuilder('l')
52+
->where('l.active = true')
53+
->getQuery()
54+
->getResult();
55+
}
4856
}

0 commit comments

Comments
 (0)