Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,3 @@ reviews:
# code_guidelines:
# filePatterns:
# - ".github/AGENT.md"

checks:
docstring_coverage:
enabled: false
4 changes: 3 additions & 1 deletion config/PhpCodeSniffer/ruleset.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@

<!-- Commenting -->
<rule ref="Generic.Commenting.Fixme"/>
<rule ref="Generic.Commenting.Todo"/>
<rule ref="Generic.Commenting.Todo">
<severity>0</severity>
</rule>
<rule ref="PEAR.Commenting.InlineComment"/>
<rule ref="Squiz.Commenting.DocCommentAlignment"/>
<rule ref="Squiz.Commenting.EmptyCatchComment"/>
Expand Down
2 changes: 2 additions & 0 deletions config/parameters.yml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,5 @@ parameters:
env(MAILQUEUE_THROTTLE): '5'
messaging.max_process_time: '%%env(MESSAGING_MAX_PROCESS_TIME)%%'
env(MESSAGING_MAX_PROCESS_TIME): '600'
messaging.max_mail_size: '%%env(MAX_MAILSIZE)%%'
env(MAX_MAILSIZE): '209715200'
5 changes: 5 additions & 0 deletions config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,8 @@ services:
lazy: true
tags:
- { name: 'doctrine.dbal.schema_filter', connection: 'default' }

PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler:
autowire: true
arguments:
$maxMailSize: '%messaging.max_mail_size%'
4 changes: 4 additions & 0 deletions config/services/managers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,7 @@ services:
PhpList\Core\Domain\Messaging\Service\Manager\SendProcessManager:
autowire: true
autoconfigure: true

PhpList\Core\Domain\Messaging\Service\Manager\MessageDataManager:
autowire: true
autoconfigure: true
5 changes: 5 additions & 0 deletions config/services/repositories.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,8 @@ services:
parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
arguments:
- PhpList\Core\Domain\Messaging\Model\SendProcess

PhpList\Core\Domain\Messaging\Repository\MessageDataRepository:
parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
arguments:
- PhpList\Core\Domain\Messaging\Model\MessageData
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace PhpList\Core\Domain\Messaging\Exception;

use RuntimeException;

class MessageSizeLimitExceededException extends RuntimeException
{
public function __construct(
private readonly int $actualSize,
private readonly int $maxSize
) {
parent::__construct(sprintf(
'Message too large (%d bytes exceeds limit of %d bytes)',
$actualSize,
$maxSize
));
}

public function getActualSize(): int
{
return $this->actualSize;
}

public function getMaxSize(): int
{
return $this->maxSize;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace PhpList\Core\Domain\Messaging\MessageHandler;

use Doctrine\ORM\EntityManagerInterface;
use PhpList\Core\Domain\Messaging\Exception\MessageSizeLimitExceededException;
use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage;
use PhpList\Core\Domain\Messaging\Message\SyncCampaignProcessorMessage;
use PhpList\Core\Domain\Messaging\Model\Message;
Expand All @@ -14,14 +15,18 @@
use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository;
use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler;
use PhpList\Core\Domain\Messaging\Service\Manager\MessageDataManager;
use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter;
use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator;
use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer;
use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager;
use PhpList\Core\Domain\Subscription\Model\Subscriber;
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Email;
use Symfony\Contracts\Translation\TranslatorInterface;
use Throwable;

Expand All @@ -32,42 +37,26 @@
#[AsMessageHandler]
class CampaignProcessorMessageHandler
{
private RateLimitedCampaignMailer $mailer;
private EntityManagerInterface $entityManager;
private SubscriberProvider $subscriberProvider;
private MessageProcessingPreparator $messagePreparator;
private LoggerInterface $logger;
private UserMessageRepository $userMessageRepository;
private MaxProcessTimeLimiter $timeLimiter;
private RequeueHandler $requeueHandler;
private TranslatorInterface $translator;
private SubscriberHistoryManager $subscriberHistoryManager;
private MessageRepository $messageRepository;
private ?int $maxMailSize;

public function __construct(
RateLimitedCampaignMailer $mailer,
EntityManagerInterface $entityManager,
SubscriberProvider $subscriberProvider,
MessageProcessingPreparator $messagePreparator,
LoggerInterface $logger,
UserMessageRepository $userMessageRepository,
MaxProcessTimeLimiter $timeLimiter,
RequeueHandler $requeueHandler,
TranslatorInterface $translator,
SubscriberHistoryManager $subscriberHistoryManager,
MessageRepository $messageRepository,
private readonly RateLimitedCampaignMailer $mailer,
private readonly EntityManagerInterface $entityManager,
private readonly SubscriberProvider $subscriberProvider,
private readonly MessageProcessingPreparator $messagePreparator,
private readonly LoggerInterface $logger,
private readonly CacheInterface $cache,
private readonly UserMessageRepository $userMessageRepository,
private readonly MaxProcessTimeLimiter $timeLimiter,
private readonly RequeueHandler $requeueHandler,
private readonly TranslatorInterface $translator,
private readonly SubscriberHistoryManager $subscriberHistoryManager,
private readonly MessageRepository $messageRepository,
private readonly EventLogManager $eventLogManager,
private readonly MessageDataManager $messageDataManager,
?int $maxMailSize = null,
) {
$this->mailer = $mailer;
$this->entityManager = $entityManager;
$this->subscriberProvider = $subscriberProvider;
$this->messagePreparator = $messagePreparator;
$this->logger = $logger;
$this->userMessageRepository = $userMessageRepository;
$this->timeLimiter = $timeLimiter;
$this->requeueHandler = $requeueHandler;
$this->translator = $translator;
$this->subscriberHistoryManager = $subscriberHistoryManager;
$this->messageRepository = $messageRepository;
$this->maxMailSize = $maxMailSize ?? 0;
}

public function __invoke(CampaignProcessorMessage|SyncCampaignProcessorMessage $message): void
Expand Down Expand Up @@ -161,12 +150,20 @@ private function handleInvalidEmail(UserMessage $userMessage, Subscriber $subscr

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

try {
$email = $this->mailer->composeEmail($processed, $subscriber);
$this->mailer->send($email);
$this->checkMessageSizeOrSuspendCampaign($campaign, $email, $subscriber->hasHtmlEmail());
$this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent);
} catch (MessageSizeLimitExceededException $e) {
// stop after the first message if size is exceeded
$this->updateMessageStatus($campaign, MessageStatus::Suspended);
$this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent);

throw $e;
} catch (Throwable $e) {
$this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent);
$this->logger->error($e->getMessage(), [
Expand All @@ -178,4 +175,57 @@ private function handleEmailSending(mixed $campaign, Subscriber $subscriber, Use
]));
}
}

private function checkMessageSizeOrSuspendCampaign(
Message $campaign,
Email $email,
bool $hasHtmlEmail
): void {
if ($this->maxMailSize <= 0) {
return;
}
$sizeName = $hasHtmlEmail ? 'htmlsize' : 'textsize';
$cacheKey = sprintf('messaging.size.%d.%s', $campaign->getId(), $sizeName);
if (!$this->cache->has($cacheKey)) {
$size = $this->calculateEmailSize($email);
$this->messageDataManager->setMessageData($campaign, $sizeName, $size);
$this->cache->set($cacheKey, $size);
}

$size = $this->cache->get($cacheKey);
if ($size <= $this->maxMailSize) {
return;
}

$this->logger->warning(sprintf(
'Message too large (%d is over %d), suspending campaign %d',
$size,
$this->maxMailSize,
$campaign->getId()
));

$this->eventLogManager->log('send', sprintf(
'Message too large (%d is over %d), suspending',
$size,
$this->maxMailSize
));

$this->eventLogManager->log('send', sprintf(
'Campaign %d suspended. Message too large',
$campaign->getId()
));

throw new MessageSizeLimitExceededException($size, $this->maxMailSize);
}

private function calculateEmailSize(Email $email): int
{
$size = 0;

foreach ($email->toIterable() as $line) {
$size += strlen($line);
}
// todo: setMessageData($messageid, $sizename, $mail->mailsize);
return $size;
}
}
6 changes: 6 additions & 0 deletions src/Domain/Messaging/Repository/MessageDataRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
use PhpList\Core\Domain\Messaging\Model\MessageData;

class MessageDataRepository extends AbstractRepository implements PaginatableRepositoryInterface
{
use CursorPaginationTrait;

public function findByIdAndName(int $messageId, string $name): ?MessageData
{
return $this->findOneBy(['id' => $messageId, 'name' => $name]);
}
}
113 changes: 113 additions & 0 deletions src/Domain/Messaging/Service/Manager/MessageDataManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

namespace PhpList\Core\Domain\Messaging\Service\Manager;

use Doctrine\ORM\EntityManagerInterface;
use PhpList\Core\Domain\Messaging\Model\Message;
use PhpList\Core\Domain\Messaging\Model\MessageData;
use PhpList\Core\Domain\Messaging\Repository\MessageDataRepository;

class MessageDataManager
{
public function __construct(
private readonly MessageDataRepository $messageDataRepository,
private readonly EntityManagerInterface $entityManager,
) {
}

/**
* Mirrors the legacy setMessageData behavior with safe sanitization and persistence.
*
* @param Message $campaign
* @param string $name
* @param mixed $value
*/
public function setMessageData(Message $campaign, string $name, mixed $value): void
{
if ($name === 'PHPSESSID' || $name === session_name()) {
return;
}

$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);
// }
// }
// }
// }

if (is_array($value) || is_object($value)) {
$value = 'SER:' . serialize($value);
}

$entity = $this->getOrCreateMessageDataEntity($campaign, $name);
$entity->setData($value !== null ? (string) $value : null);
}

/**
* Remove potentially harmful JavaScript from HTML content.
*
* This is a conservative cleaner: removes <script> blocks, javascript: URLs,
* and inline event handlers (on*) attributes.
*/
private function disableJavascript(string $html): string
{
// Remove script tags and their content
$clean = preg_replace('#<script\b[^>]*>.*?</script>#is', '', $html) ?? $html;

// Remove on*="..." event handler attributes
$clean = preg_replace('/\s+on[a-zA-Z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $clean) ?? $clean;

// Neutralize javascript: and data: URIs in href/src/style
$clean = preg_replace('/\b(href|src)\s*=\s*("|\')\s*(javascript:|data:)[^\2]*\2/i', '$1="#"', $clean) ?? $clean;
return preg_replace('/\bstyle\s*=\s*("|\')[^\1]*\1/i', '', $clean) ?? $clean;
}

private function normalizeValueByName(string $name, mixed $value)
{
return match ($name) {
'subject', 'campaigntitle' => is_string($value) ? strip_tags($value) : $value,
'message' => is_string($value) ? $this->disableJavascript($value) : $value,
'excludelist' => is_array($value) ? array_filter($value, fn ($val) => is_numeric($val)) : $value,
'footer' => is_string($value) ? preg_replace('/<!--.*?-->/', '', $value) : $value,
default => $value,
};
}

private function getOrCreateMessageDataEntity(Message $campaign, string $name)
{
$entity = $this->messageDataRepository->findByIdAndName($campaign->getId(), $name);
if (!$entity instanceof MessageData) {
$entity = (new MessageData())
->setId($campaign->getId())
->setName($name);
$this->entityManager->persist($entity);
}

return $entity;
}
}
Loading
Loading