From a72d2e920ef18618a36a58714cd06d916b01a30b Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 11 Dec 2025 10:40:57 +0400 Subject: [PATCH 01/37] UserPersonalizer in CampaignProcessorMessageHandler --- .../CampaignProcessorMessageHandler.php | 4 ++++ .../Service/MessageProcessingPreparator.php | 7 +------ .../Service/RateLimitedCampaignMailer.php | 21 +++++++++++-------- .../CampaignProcessorMessageHandlerTest.php | 16 ++++++++++++++ .../MessageProcessingPreparatorTest.php | 10 --------- 5 files changed, 33 insertions(+), 25 deletions(-) diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php index 12d45c30..f5154563 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Domain\Messaging\MessageHandler; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; use PhpList\Core\Domain\Messaging\Exception\MessageSizeLimitExceededException; use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage; use PhpList\Core\Domain\Messaging\Message\SyncCampaignProcessorMessage; @@ -56,6 +57,7 @@ public function __construct( private readonly EventLogManager $eventLogManager, private readonly MessageDataManager $messageDataManager, private readonly MessagePrecacheService $precacheService, + private readonly UserPersonalizer $userPersonalizer, ?int $maxMailSize = null, ) { $this->maxMailSize = $maxMailSize ?? 0; @@ -159,6 +161,8 @@ private function handleEmailSending( Message\MessageContent $precachedContent, ): void { $processed = $this->messagePreparator->processMessageLinks($campaign->getId(), $precachedContent, $subscriber); + $processed->setText($this->userPersonalizer->personalize($processed->getText(), $subscriber->getEmail())); + $processed->setFooter($this->userPersonalizer->personalize($processed->getFooter(), $subscriber->getEmail())); try { $email = $this->mailer->composeEmail($campaign, $subscriber, $processed); diff --git a/src/Domain/Messaging/Service/MessageProcessingPreparator.php b/src/Domain/Messaging/Service/MessageProcessingPreparator.php index aee1d9e7..549a439e 100644 --- a/src/Domain/Messaging/Service/MessageProcessingPreparator.php +++ b/src/Domain/Messaging/Service/MessageProcessingPreparator.php @@ -5,8 +5,6 @@ namespace PhpList\Core\Domain\Messaging\Service; use PhpList\Core\Domain\Analytics\Service\LinkTrackService; -use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; -use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Subscription\Model\Subscriber; @@ -24,7 +22,6 @@ public function __construct( private readonly MessageRepository $messageRepository, private readonly LinkTrackService $linkTrackService, private readonly TranslatorInterface $translator, - private readonly UserPersonalizer $userPersonalizer, ) { } @@ -78,16 +75,14 @@ public function processMessageLinks( $htmlText = $content->getText(); $footer = $content->getFooter(); - // todo: check other configured data that should be used in mail formatting/creation + // todo: check if getTextMessage should replace links as well if ($htmlText !== null) { $htmlText = $this->replaceLinks($savedLinks, $htmlText); - $htmlText = $this->userPersonalizer->personalize($htmlText, $subscriber->getEmail()); $content->setText($htmlText); } if ($footer !== null) { $footer = $this->replaceLinks($savedLinks, $footer); - $footer = $this->userPersonalizer->personalize($footer, $subscriber->getEmail()); $content->setFooter($footer); } diff --git a/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php b/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php index de2b73c1..97027d16 100644 --- a/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php +++ b/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php @@ -20,23 +20,26 @@ public function __construct(MailerInterface $mailer, SendRateLimiter $limiter) $this->limiter = $limiter; } - public function composeEmail(Message $processed, Subscriber $subscriber, Message\MessageContent $content): Email - { + public function composeEmail( + Message $message, + Subscriber $subscriber, + Message\MessageContent $processedContent, + ): Email { $email = new Email(); - if ($processed->getOptions()->getFromField() !== '') { - $email->from($processed->getOptions()->getFromField()); + if ($message->getOptions()->getFromField() !== '') { + $email->from($message->getOptions()->getFromField()); } - if ($processed->getOptions()->getReplyTo() !== '') { - $email->replyTo($processed->getOptions()->getReplyTo()); + if ($message->getOptions()->getReplyTo() !== '') { + $email->replyTo($message->getOptions()->getReplyTo()); } return $email ->to($subscriber->getEmail()) - ->subject($content->getSubject()) + ->subject($processedContent->getSubject()) // todo: check HTML2Text functionality - ->text($content->getTextMessage()) - ->html($content->getText()); + ->text($processedContent->getTextMessage()) + ->html($processedContent->getText()); } /** diff --git a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php index a565f558..0e61d5dd 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php @@ -7,6 +7,7 @@ use Doctrine\ORM\EntityManagerInterface; use Exception; use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; +use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage; use PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler; use PhpList\Core\Domain\Messaging\Model\Message; @@ -57,10 +58,18 @@ protected function setUp(): void $requeueHandler = $this->createMock(RequeueHandler::class); $this->translator = $this->createMock(Translator::class); $this->precacheService = $this->createMock(MessagePrecacheService::class); + $userPersonalizer = $this->createMock(UserPersonalizer::class); $timeLimiter->method('start'); $timeLimiter->method('shouldStop')->willReturn(false); + // Ensure personalization returns original text so assertions on replaced links remain valid + $userPersonalizer + ->method('personalize') + ->willReturnCallback(function (string $text) { + return $text; + }); + $this->handler = new CampaignProcessorMessageHandler( mailer: $this->mailer, entityManager: $this->entityManager, @@ -77,6 +86,7 @@ protected function setUp(): void eventLogManager: $this->createMock(EventLogManager::class), messageDataManager: $this->createMock(MessageDataManager::class), precacheService: $this->precacheService, + userPersonalizer: $userPersonalizer, maxMailSize: 0, ); } @@ -166,6 +176,8 @@ public function testInvokeWithValidSubscriberEmail(): void { $campaign = $this->createMock(Message::class); $content = $this->createContentMock(); + $content->method('getText')->willReturn('

Test HTML message

'); + $content->method('getFooter')->willReturn('

Test footer message

'); $campaign->method('getContent')->willReturn($content); $metadata = $this->createMock(MessageMetadata::class); $campaign->method('getMetadata')->willReturn($metadata); @@ -225,6 +237,8 @@ public function testInvokeWithMailerException(): void { $campaign = $this->createMock(Message::class); $content = $this->createContentMock(); + $content->method('getText')->willReturn('

Test HTML message

'); + $content->method('getFooter')->willReturn('

Test footer message

'); $metadata = $this->createMock(MessageMetadata::class); $campaign->method('getContent')->willReturn($content); $campaign->method('getMetadata')->willReturn($metadata); @@ -278,6 +292,8 @@ public function testInvokeWithMultipleSubscribers(): void { $campaign = $this->createCampaignMock(); $content = $this->createContentMock(); + $content->method('getText')->willReturn('

Test HTML message

'); + $content->method('getFooter')->willReturn('

Test footer message

'); $metadata = $this->createMock(MessageMetadata::class); $campaign->method('getMetadata')->willReturn($metadata); $campaign->method('getId')->willReturn(1); diff --git a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php index b7530895..9ba29c46 100644 --- a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php +++ b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php @@ -6,7 +6,6 @@ use PhpList\Core\Domain\Analytics\Model\LinkTrack; use PhpList\Core\Domain\Analytics\Service\LinkTrackService; -use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; @@ -23,7 +22,6 @@ class MessageProcessingPreparatorTest extends TestCase private SubscriberRepository&MockObject $subscriberRepository; private MessageRepository&MockObject $messageRepository; private LinkTrackService&MockObject $linkTrackService; - private UserPersonalizer&MockObject $userPersonalizer; private OutputInterface&MockObject $output; private MessageProcessingPreparator $preparator; @@ -32,13 +30,6 @@ protected function setUp(): void $this->subscriberRepository = $this->createMock(SubscriberRepository::class); $this->messageRepository = $this->createMock(MessageRepository::class); $this->linkTrackService = $this->createMock(LinkTrackService::class); - $this->userPersonalizer = $this->createMock(UserPersonalizer::class); - // Ensure personalization returns original text so assertions on replaced links remain valid - $this->userPersonalizer - ->method('personalize') - ->willReturnCallback(function (string $text) { - return $text; - }); $this->output = $this->createMock(OutputInterface::class); $this->preparator = new MessageProcessingPreparator( @@ -46,7 +37,6 @@ protected function setUp(): void messageRepository: $this->messageRepository, linkTrackService: $this->linkTrackService, translator: new Translator('en'), - userPersonalizer: $this->userPersonalizer, ); } From d40dedd67a6ff04416c355afee1da3bde0ef8e6e Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 11 Dec 2025 13:22:35 +0400 Subject: [PATCH 02/37] HtmlToText --- config/parameters.yml.dist | 4 + src/Domain/Messaging/Service/HtmlToText.php | 85 +++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/Domain/Messaging/Service/HtmlToText.php diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index 41c9a20b..bc73fb60 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -89,3 +89,7 @@ parameters: env(MESSAGING_MAX_PROCESS_TIME): '600' messaging.max_mail_size: '%%env(MAX_MAILSIZE)%%' env(MAX_MAILSIZE): '209715200' + messaging.default_message_age: '%%env(DEFAULT_MESSAGEAGE)%%' + env(DEFAULT_MESSAGEAGE): '691200' + messaging.use_manual_text_part : '%%env(USE_MANUAL_TEXT_PART)%%' + env(USE_MANUAL_TEXT_PART): 0 diff --git a/src/Domain/Messaging/Service/HtmlToText.php b/src/Domain/Messaging/Service/HtmlToText.php new file mode 100644 index 00000000..a6775288 --- /dev/null +++ b/src/Domain/Messaging/Service/HtmlToText.php @@ -0,0 +1,85 @@ +]*>(.*?)<\/script\s*>/is", '', $text); + $text = preg_replace("/]*>(.*?)<\/style\s*>/is", '', $text); + + $text = preg_replace( + "/]*href=([\"\'])(.*)\\1[^>]*>(.*)<\/a>/Umis", + "[URLTEXT]\\3[ENDURLTEXT][LINK]\\2[ENDLINK]\n", + $text + ); + $text = preg_replace("/(.*?)<\/b\s*>/is", '*\\1*', $text); + $text = preg_replace("/(.*?)<\/h[\d]\s*>/is", "**\\1**\n", $text); + $text = preg_replace("/(.*?)<\/i\s*>/is", '/\\1/', $text); + $text = preg_replace("/<\/tr\s*?>/i", "<\/tr>\n\n", $text); + $text = preg_replace("/<\/p\s*?>/i", "<\/p>\n\n", $text); + $text = preg_replace('/]*?>/i', "
\n", $text); + $text = preg_replace("/]*?\/>/i", "\n", $text); + $text = preg_replace('/ $fullmatch) { + $linktext = $links[1][$matchindex]; + $linkurl = $links[2][$matchindex]; + // check if the text linked is a repetition of the URL + if (trim($linktext) == trim($linkurl) || + 'https://'.trim($linktext) == trim($linkurl) || + 'http://'.trim($linktext) == trim($linkurl) + ) { + $linkreplace = $linkurl; + } else { + //# if link is an anchor only, take it out + if (strpos($linkurl, '#') === 0) { + $linkreplace = $linktext; + } else { + $linkreplace = $linktext.' <'.$linkurl.'>'; + } + } + $text = str_replace($fullmatch, $linkreplace, $text); + } + $text = preg_replace( + "/]*>(.*?)<\/a>/is", + '[URLTEXT]\\2[ENDURLTEXT][LINK]\\1[ENDLINK]', + $text, + 500 + ); + + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + $text = preg_replace('/###NL###/', "\n", $text); + $text = preg_replace("/\n /", "\n", $text); + $text = preg_replace("/\t/", ' ', $text); + + // reduce whitespace + while (preg_match('/ /', $text)) { + $text = preg_replace('/ /', ' ', $text); + } + while (preg_match("/\n\s*\n\s*\n/", $text)) { + $text = preg_replace("/\n\s*\n\s*\n/", "\n\n", $text); + } + $ww = $this->configProvider->getValue(ConfigOption::WordWrap) ?? self::WORD_WRAP; + + return wordwrap($text, $ww); + } +} From 613a1960322572647345acec00a2c387f4349480 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 11 Dec 2025 13:26:57 +0400 Subject: [PATCH 03/37] MessageDataLoader --- .../Configuration/Model/ConfigOption.php | 9 + .../Repository/MessageDataRepository.php | 6 + .../Messaging/Service/MessageDataLoader.php | 201 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 src/Domain/Messaging/Service/MessageDataLoader.php diff --git a/src/Domain/Configuration/Model/ConfigOption.php b/src/Domain/Configuration/Model/ConfigOption.php index 86b9286e..fa2066a8 100644 --- a/src/Domain/Configuration/Model/ConfigOption.php +++ b/src/Domain/Configuration/Model/ConfigOption.php @@ -15,4 +15,13 @@ enum ConfigOption: string case SubscribeUrl = 'subscribeurl'; case Domain = 'domain'; case Website = 'website'; + case MessageFromAddress = 'message_from_address'; + case AlwaysAddGoogleTracking = 'always_add_googletracking'; + case AdminAddress = 'admin_address'; + case DefaultMessageTemplate = 'defaultmessagetemplate'; + case MessageFooter = 'messagefooter'; + case ForwardFooter = 'forwardfooter'; + case NotifyStartDefault = 'notifystart_default'; + case NotifyEndDefault = 'notifyend_default'; + case WordWrap = 'wordwrap'; } diff --git a/src/Domain/Messaging/Repository/MessageDataRepository.php b/src/Domain/Messaging/Repository/MessageDataRepository.php index 51b27a04..1ef70d39 100644 --- a/src/Domain/Messaging/Repository/MessageDataRepository.php +++ b/src/Domain/Messaging/Repository/MessageDataRepository.php @@ -17,4 +17,10 @@ public function findByIdAndName(int $messageId, string $name): ?MessageData { return $this->findOneBy(['id' => $messageId, 'name' => $name]); } + + /** @return MessageData[] */ + public function getForMessage(int $messageId): array + { + return $this->findBy(['id' => $messageId]); + } } diff --git a/src/Domain/Messaging/Service/MessageDataLoader.php b/src/Domain/Messaging/Service/MessageDataLoader.php new file mode 100644 index 00000000..0f899d28 --- /dev/null +++ b/src/Domain/Messaging/Service/MessageDataLoader.php @@ -0,0 +1,201 @@ +configProvider->getValue(ConfigOption::MessageFromAddress) + ?? $this->configProvider->getValue(ConfigOption::AdminAddress); + + $finishSending = time() + $this->defaultMessageAge; + + $messageData = [ + 'template' => $this->configProvider->getValue(ConfigOption::DefaultMessageTemplate), + 'sendformat' => 'HTML', + 'message' => '', + 'forwardmessage' => '', + 'textmessage' => '', + 'rsstemplate' => '', + 'embargo' => [ + 'year' => date('Y'), + 'month' => date('m'), + 'day' => date('d'), + 'hour' => date('H'), + 'minute' => date('i'), + ], + 'repeatinterval' => 0, + 'repeatuntil' => [ + 'year' => date('Y'), + 'month' => date('m'), + 'day' => date('d'), + 'hour' => date('H'), + 'minute' => date('i'), + ], + 'requeueinterval' => 0, + 'requeueuntil' => [ + 'year' => date('Y'), + 'month' => date('m'), + 'day' => date('d'), + 'hour' => date('H'), + 'minute' => date('i'), + ], + 'finishsending' => [ + 'year' => date('Y', $finishSending), + 'month' => date('m', $finishSending), + 'day' => date('d', $finishSending), + 'hour' => date('H', $finishSending), + 'minute' => date('i', $finishSending), + ], + 'fromfield' => '', + 'subject' => '', + 'forwardsubject' => '', + 'footer' => $this->configProvider->getValue(ConfigOption::MessageFooter), + 'forwardfooter' => $this->configProvider->getValue(ConfigOption::ForwardFooter), + 'status' => '', + 'tofield' => '', + 'replyto' => '', + 'targetlist' => [], + 'criteria_match' => '', + 'sendurl' => '', + 'sendmethod' => 'inputhere', + 'testtarget' => '', + 'notify_start' => $this->configProvider->getValue(ConfigOption::NotifyStartDefault), + 'notify_end' => $this->configProvider->getValue(ConfigOption::NotifyEndDefault), + 'google_track' => filter_var( + value: $this->configProvider->getValue(ConfigOption::AlwaysAddGoogleTracking), + filter: FILTER_VALIDATE_BOOL + ), + 'excludelist' => [], + 'sentastest' => 0, + ]; + // todo: set correct values from entity + $nonEmptyFields = array_filter( + get_object_vars($message), + fn($v) => $v !== null && $v !== '', + ); + foreach ($nonEmptyFields as $key => $val) { + $messageData[$key] = $val; + } + + $messageData['subject'] = $messageData['subject'] === '(no title)' ? '(no subject)' : $messageData['subject']; + + $storedMessageData = $this->messageDataRepository->getForMessage($message->getId()); + foreach ($storedMessageData as $storedMessageDatum) { + if (str_starts_with($storedMessageDatum->getData(), 'SER:')) { + $unserialized = unserialize(substr($storedMessageDatum->getData(), 4)); + $data = array_walk_recursive($unserialized, 'stripslashes'); + } else { + $data = stripslashes($storedMessageDatum->getData()); + } + if (!in_array($storedMessageDatum->getName(), ['astext', 'ashtml', 'astextandhtml', 'aspdf', 'astextandpdf'])) + { + //# don't overwrite counters in the message table from the data table + $messageData[stripslashes($storedMessageDatum->getName())] = $data; + } + } + + foreach (array('embargo', 'repeatuntil', 'requeueuntil') as $dateField) { + if (!is_array($messageData[$dateField])) { + $messageData[$dateField] = [ + 'year' => date('Y'), + 'month' => date('m'), + 'day' => date('d'), + 'hour' => date('H'), + 'minute' => date('i'), + ]; + } + } + + foreach($message->getListMessages() as $listMessage) { + $messageData['targetlist'][$listMessage->getListId()] = 1; + } + + //# backwards, check that the content has a url and use it to fill the sendurl + if (empty($messageData['sendurl'])) { + //# can't do "ungreedy matching, in case the URL has placeholders, but this can potentially throw problems + if (!empty($messageData['message']) && preg_match('/\[URL:(.*)\]/i', $messageData['message'], $regs)) { + $messageData['sendurl'] = $regs[1]; + } + } + if (empty($messageData['sendurl']) && !empty($messageData['message'])) { + // if there's a message and no url, make sure to show the editor, and not the URL input + $messageData['sendmethod'] = 'inputhere'; + } + + //## parse the from field into it's components - email and name + if (preg_match('/([^ ]+@[^ ]+)/', $messageData['fromfield'], $regs)) { + // if there is an email in the from, rewrite it as "name " + $messageData['fromname'] = str_replace($regs[0], '', $messageData['fromfield']); + $messageData['fromemail'] = $regs[0]; + // if the email has < and > take them out here + $messageData['fromemail'] = str_replace('<', '', $messageData['fromemail']); + $messageData['fromemail'] = str_replace('>', '', $messageData['fromemail']); + // make sure there are no quotes around the name + $messageData['fromname'] = str_replace('"', '', ltrim(rtrim($messageData['fromname']))); + } elseif (strpos($messageData['fromfield'], ' ')) { + // if there is a space, we need to add the email + $messageData['fromname'] = $messageData['fromfield']; + // $cached[$messageid]["fromemail"] = "listmaster@$domain"; + $messageData['fromemail'] = $defaultFrom; + } else { + $messageData['fromemail'] = $defaultFrom; + $messageData['fromname'] = $messageData['fromfield']; + } + // disallow an email address in the name + if (preg_match('/([^ ]+@[^ ]+)/', $messageData['fromname'], $regs)) { + $messageData['fromname'] = str_replace($regs[0], '', $messageData['fromname']); + } + // clean up + $messageData['fromemail'] = str_replace(',', '', $messageData['fromemail']); + $messageData['fromname'] = str_replace(',', '', $messageData['fromname']); + + $messageData['fromname'] = trim($messageData['fromname']); + + // erase double spacing + while (strpos($messageData['fromname'], ' ')) { + $messageData['fromname'] = str_replace(' ', ' ', $messageData['fromname']); + } + + //# if the name ends up being empty, copy the email + if (empty($messageData['fromname'])) { + $messageData['fromname'] = $messageData['fromemail']; + } + + if (isset($messageData['targetlist']['unselect'])) { + unset($messageData['targetlist']['unselect']); + } + if (isset($messageData['excludelist']['unselect'])) { + unset($messageData['excludelist']['unselect']); + } + + if (empty($messageData['campaigntitle'])) { + if ($messageData['subject'] != '(no subject)') { + $messageData['campaigntitle'] = $messageData['subject']; + } else { + $messageData['campaigntitle'] = '(no title)'; + } + } + //# copy subject to title + if ($messageData['campaigntitle'] == '(no title)' && $messageData['subject'] != '(no subject)') { + $messageData['campaigntitle'] = $messageData['subject']; + } + + return $messageData; + } +} From 759d8e062c2be4930c14ce6cae9ff6362155fd5e Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 11 Dec 2025 13:28:20 +0400 Subject: [PATCH 04/37] TextParser --- src/Domain/Messaging/Service/TextParser.php | 73 +++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/Domain/Messaging/Service/TextParser.php diff --git a/src/Domain/Messaging/Service/TextParser.php b/src/Domain/Messaging/Service/TextParser.php new file mode 100644 index 00000000..da0a3c83 --- /dev/null +++ b/src/Domain/Messaging/Service/TextParser.php @@ -0,0 +1,73 @@ +\\1', $text); + $link_pattern = "/(.*)(.*?)<\s*\/a\s*>(.*)/is"; + + $i = 0; + while (preg_match($link_pattern, $text, $matches)) { + $url = $matches[2]; + $rest = $matches[3]; + if (!preg_match('/^(http:)|(mailto:)|(ftp:)|(https:)/i', $url)) { + // avoid this + // + $url = preg_replace('/:/', '', $url); + } + $link[$i] = ''.$matches[4].''; + $text = $matches[1]."%%$i%%".$matches[5]; + ++$i; + } + + $text = preg_replace("/(www\.[a-zA-Z0-9\.\/#~:?+=&%@!_\\-]+)/i", 'http://\\1', $text); //make www. -> http://www. + $text = preg_replace("/(https?:\/\/)http?:\/\//i", '\\1', $text); //take out duplicate schema + $text = preg_replace("/(ftp:\/\/)http?:\/\//i", '\\1', $text); //take out duplicate schema + $text = preg_replace("/(https?:\/\/)(?!www)([a-zA-Z0-9\.\/#~:?+=&%@!_\\-]+)/i", + '\\2', + $text); //eg-- http://kernel.org -> http://kernel.org + + $text = preg_replace("/(https?:\/\/)(www\.)([a-zA-Z0-9\.\/#~:?+=&%@!\\-_]+)/i", + '\\2\\3', + $text); //eg -- http://www.google.com -> www.google.com + + // take off a possible last full stop and move it outside + $text = preg_replace("/(.*)\.<\/a>/i", + '\\2.', $text); + + for ($j = 0; $j < $i; ++$j) { + $replacement = $link[$j]; + $text = preg_replace("/\%\%$j\%\%/", $replacement, $text); + } + + // hmm, regular expression choke on some characters in the text + // first replace all the brackets with placeholders. + // we cannot use htmlspecialchars or addslashes, because some are needed + + $text = str_replace("\(", '', $text); + $text = str_replace("\)", '', $text); + $text = preg_replace('/\$/', '', $text); + + // @@@ to be xhtml compabible we'd have to close the

as well + // so for now, just make it two br/s, which will be done by replacing + // \n with
+ // $paragraph = '

'; + $br = '
'; + $text = preg_replace("/\r/", '', $text); + $text = preg_replace("/\n/", "$br\n", $text); + + // reverse our previous placeholders + $text = str_replace('', '(', $text); + $text = str_replace('', ')', $text); + return str_replace('', '$', $text); + } +} From d94f825ce115eaae5ddd75f654551f15a88c44dd Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 13 Dec 2025 18:39:49 +0400 Subject: [PATCH 05/37] RemotePageFetcher --- composer.json | 3 +- .../Service => Common}/HtmlToText.php | 2 +- src/Domain/Common/HtmlUrlRewriter.php | 190 +++++++++++++++++ src/Domain/Common/RemotePageFetcher.php | 121 +++++++++++ .../Service => Common}/TextParser.php | 2 +- .../Configuration/Model/ConfigOption.php | 1 + .../Repository/UrlCacheRepository.php | 18 ++ .../CampaignProcessorMessageHandler.php | 5 + .../Repository/TemplateRepository.php | 6 + .../Service/MessagePrecacheService.php | 193 +++++++++++++++++- 10 files changed, 535 insertions(+), 6 deletions(-) rename src/Domain/{Messaging/Service => Common}/HtmlToText.php (98%) create mode 100644 src/Domain/Common/HtmlUrlRewriter.php create mode 100644 src/Domain/Common/RemotePageFetcher.php rename src/Domain/{Messaging/Service => Common}/TextParser.php (98%) diff --git a/composer.json b/composer.json index e696c132..ca914980 100644 --- a/composer.json +++ b/composer.json @@ -79,7 +79,8 @@ "ext-imap": "*", "tatevikgr/rss-feed": "dev-main", "ext-pdo": "*", - "ezyang/htmlpurifier": "^4.19" + "ezyang/htmlpurifier": "^4.19", + "ext-libxml": "*" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/src/Domain/Messaging/Service/HtmlToText.php b/src/Domain/Common/HtmlToText.php similarity index 98% rename from src/Domain/Messaging/Service/HtmlToText.php rename to src/Domain/Common/HtmlToText.php index a6775288..d426ce41 100644 --- a/src/Domain/Messaging/Service/HtmlToText.php +++ b/src/Domain/Common/HtmlToText.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Service; +namespace PhpList\Core\Domain\Common; use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; diff --git a/src/Domain/Common/HtmlUrlRewriter.php b/src/Domain/Common/HtmlUrlRewriter.php new file mode 100644 index 00000000..bba0b46e --- /dev/null +++ b/src/Domain/Common/HtmlUrlRewriter.php @@ -0,0 +1,190 @@ +

' . $html . '
'; + $dom->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + + $xpath = new DOMXPath($dom); + + // Attributes to rewrite + $attrMap = [ + '//*[@src]' => 'src', + '//*[@href]' => 'href', + '//*[@action]' => 'action', + '//*[@background]' => 'background', + ]; + + foreach ($attrMap as $query => $attr) { + foreach ($xpath->query($query) as $node) { + /** @var DOMElement $node */ + $val = $node->getAttribute($attr); + $node->setAttribute($attr, $this->absolutizeUrl($val, $baseUrl)); + } + } + + // srcset needs special handling (multiple candidates) + foreach ($xpath->query('//*[@srcset]') as $node) { + /** @var DOMElement $node */ + $node->setAttribute('srcset', $this->rewriteSrcset($node->getAttribute('srcset'), $baseUrl)); + } + + // 2) Rewrite inline +
X
+ '; + + $base = 'https://ex.am/dir/level/page.html'; + $out = $this->rewriter->addAbsoluteResources($html, $base); + + $this->assertMatchesRegularExpression( + '~url\((["\']?)https://ex\.am/dir/img/bg\.png\1\)~', + $out + ); + + $this->assertMatchesRegularExpression( + '~@import\s+(?:url\()?(["\']?)https://ex\.am/css/reset\.css\1\)?~', + $out + ); + + $this->assertMatchesRegularExpression( + '~@import\s+(?:url\()?(["\']?)https://ex\.am/dir/level/css/theme\.css\1\)?~', + $out + ); + + $this->assertMatchesRegularExpression( + '~url\((["\']?)https://ex\.am/dir/level/icons/ico\.svg\1\)~', + $out + ); + } + + public function testAbsolutizeUrlDirectlyCoversDotSegmentsAndPort(): void + { + $base = 'http://example.com:8080/a/b/c/'; + + $this->assertSame( + 'http://example.com:8080/a/b/img.png', + $this->rewriter->absolutizeUrl('../img.png', $base) + ); + + $this->assertSame( + 'http://example.com:8080/a/b/c/d/e.png?x=1#top', + $this->rewriter->absolutizeUrl('d/./e.png?x=1#top', $base) + ); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php b/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php new file mode 100644 index 00000000..de7f09c9 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php @@ -0,0 +1,169 @@ +html2Text = $this->getMockBuilder(Html2Text::class) + ->disableOriginalConstructor() + ->onlyMethods(['__invoke']) + ->getMock(); + $this->configProvider = $this->createMock(ConfigProvider::class); + $this->templateRepository = $this->createMock(TemplateRepository::class); + $this->templateImageManager = $this->getMockBuilder(TemplateImageManager::class) + ->disableOriginalConstructor() + ->onlyMethods(['parseLogoPlaceholders']) + ->getMock(); + } + + private function createConstructor(bool $poweredByPhplist = false): SystemMailConstructor + { + // Defaults needed by constructor + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::PoweredByText, 'Powered by phpList'], + [ConfigOption::SystemMessageTemplate, null], + ]); + + return new SystemMailConstructor( + html2Text: $this->html2Text, + configProvider: $this->configProvider, + templateRepository: $this->templateRepository, + templateImageManager: $this->templateImageManager, + poweredByPhplist: $poweredByPhplist, + ); + } + + public function testPlainTextWithoutTemplateLinkifiedAndNl2br(): void + { + $constructor = $this->createConstructor(); + + // Html2Text is not used when source is plain text + $this->html2Text->expects($this->never())->method('__invoke'); + + [$html, $text] = $constructor('Line1' . "\n" . 'Visit http://example.com', 'Subject'); + + $this->assertSame("Line1\nVisit http://example.com", $text); + $this->assertStringContainsString('Line1assertStringContainsString('http://example.com', $html); + } + + public function testHtmlSourceWithoutTemplateUsesHtml2Text(): void + { + $constructor = $this->createConstructor(); + + $this->html2Text->expects($this->once()) + ->method('__invoke') + ->with('

Hello

') + ->willReturn('Hello'); + + [$html, $text] = $constructor('

Hello

', 'Subject'); + + $this->assertSame('

Hello

', $html); + $this->assertSame('Hello', $text); + } + + public function testTemplateWithSignaturePlaceholderUsesPoweredByImageWhenFlagFalse(): void + { + // Configure template usage + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::PoweredByText, 'Powered'], + [ConfigOption::SystemMessageTemplate, '10'], + [ConfigOption::PoweredByImage, ''], + ]); + + $template = new Template('sys-template'); + $template->setContent('[SUBJECT]: [CONTENT] [SIGNATURE]'); + $template->setText("SUBJ: [SUBJECT]\n[BODY]\n[CONTENT]\n[SIGNATURE]"); + + $this->templateRepository->method('findOneById')->with(10)->willReturn($template); + + $this->templateImageManager->expects($this->once()) + ->method('parseLogoPlaceholders') + ->with($this->callback(fn ($html) => is_string($html))) + ->willReturnArgument(0); + + // Plain text input so Html2Text is called only for powered by text when building text part + $this->html2Text->expects($this->once()) + ->method('__invoke') + ->with('Powered') + ->willReturn('Powered'); + + $constructor = new SystemMailConstructor( + html2Text: $this->html2Text, + configProvider: $this->configProvider, + templateRepository: $this->templateRepository, + templateImageManager: $this->templateImageManager, + poweredByPhplist: false, + ); + + [$html, $text] = $constructor('Body', 'Subject'); + + // HTML should contain processed powered-by image (src rewritten to powerphplist.png) in place of [SIGNATURE] + $this->assertStringContainsString('Subject: Body', $html); + $this->assertStringContainsString('src="powerphplist.png"', $html); + + // Text should include powered by text substituted into [SIGNATURE] + $this->assertStringContainsString("SUBJ: Subject\n[BODY]\nBody\nPowered", $text); + } + + public function testTemplateWithoutSignatureAppendsPoweredByTextAndBeforeBodyEndWhenHtml(): void + { + // Configure template usage with poweredByPhplist=true (use text snippet instead of image) + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::PoweredByText, 'PB'], + [ConfigOption::SystemMessageTemplate, '11'], + ]); + + $template = new Template('sys-template'); + $template->setContent('[CONTENT]'); + $template->setText('[CONTENT]'); + $this->templateRepository->method('findOneById')->with(11)->willReturn($template); + + $this->templateImageManager->method('parseLogoPlaceholders')->willReturnCallback(static fn ($h) => $h); + + // Html2Text is called twice: once for the HTML message -> text, and once for powered-by text + $this->html2Text->expects($this->exactly(2)) + ->method('__invoke') + ->withConsecutive( + ['Hello World'], + ['PB'] + ) + ->willReturnOnConsecutiveCalls('Hello World', 'PB'); + + $constructor = new SystemMailConstructor( + html2Text: $this->html2Text, + configProvider: $this->configProvider, + templateRepository: $this->templateRepository, + templateImageManager: $this->templateImageManager, + poweredByPhplist: true, + ); + + [$html, $text] = $constructor('Hello World', 'Sub'); + + // HTML path: since poweredByPhplist=true, raw PoweredByText should be inserted before + $this->assertStringContainsString('Hello World', $html); + $this->assertMatchesRegularExpression('~PB\s*$~', $html); + + // TEXT path: PoweredByText (converted) appended with two newlines since no [SIGNATURE] + $this->assertSame("Hello World\n\nPB", $text); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/TemplateImageEmbedderTest.php b/tests/Unit/Domain/Messaging/Service/TemplateImageEmbedderTest.php new file mode 100644 index 00000000..9190467a --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/TemplateImageEmbedderTest.php @@ -0,0 +1,239 @@ +configProvider = $this->createMock(ConfigProvider::class); + $this->configManager = $this->createMock(ConfigManager::class); + $this->externalImageService = $this->createMock(ExternalImageService::class); + $this->templateImageRepository = $this->createMock(TemplateImageRepository::class); + + // Create a temporary document root for filesystem-related tests + $this->documentRoot = sys_get_temp_dir() . '/tpl_img_embedder_' . bin2hex(random_bytes(6)); + mkdir($this->documentRoot, 0777, true); + + // Reasonable defaults for options used in code + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::SystemMessageTemplate, '0'], + [ConfigOption::Website, 'https://example.com'], + [ConfigOption::UploadImageRoot, $this->documentRoot . '/upload/'], + [ConfigOption::PageRoot, '/'], + ]); + } + + protected function tearDown(): void + { + // best-effort cleanup + if (is_dir($this->documentRoot)) { + $this->recursiveRemove($this->documentRoot); + } + } + + private function recursiveRemove(string $path): void + { + if (!is_dir($path)) { + unlink($path); + return; + } + foreach (scandir($path) ?: [] as $file) { + if ($file === '.' || $file === '..') { + continue; + } + $full = $path . DIRECTORY_SEPARATOR . $file; + if (is_dir($full)) { + $this->recursiveRemove($full); + } else { + unlink($full); + } + } + rmdir($path); + } + + private function createEmbedder( + bool $embedExternal = false, + bool $embedUploaded = false, + ?string $uploadImagesDir = null, + string $editorImagesDir = 'images' + ): TemplateImageEmbedder { + return new TemplateImageEmbedder( + configProvider: $this->configProvider, + configManager: $this->configManager, + externalImageService: $this->externalImageService, + templateImageRepository: $this->templateImageRepository, + documentRoot: $this->documentRoot, + editorImagesDir: $editorImagesDir, + embedExternalImages: $embedExternal, + embedUploadedImages: $embedUploaded, + uploadImagesDir: $uploadImagesDir, + ); + } + + public function testExternalImagesEmbeddedAndSameHostLeftAlone(): void + { + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::SystemMessageTemplate, '0'], + [ConfigOption::Website, 'https://example.com'], + [ConfigOption::UploadImageRoot, $this->documentRoot . '/upload/'], + [ConfigOption::PageRoot, '/'], + ]); + + $html = '

and ' + . '

'; + + $this->externalImageService->expects($this->exactly(2)) + ->method('cache') + ->withConsecutive( + ['https://cdn.other.org/pic.jpg', 111], + ['https://example.com/local.jpg', 111] + ) + ->willReturnOnConsecutiveCalls(true, false); + + $jpegBase64 = base64_encode('JPEGDATA'); + $this->externalImageService->expects($this->once()) + ->method('getFromCache') + ->with('https://cdn.other.org/pic.jpg', 111) + ->willReturn($jpegBase64); + + $embedder = $this->createEmbedder(embedExternal: true); + $out = $embedder($html, 111); + + $this->assertStringContainsString('cid:', $out); + $this->assertStringContainsString('https://example.com/local.jpg', $out, 'Same-host URL should remain'); + $this->assertCount(1, $embedder->attachment); + $att = $embedder->attachment[0]; + $this->assertSame('base64', $att[3]); + $this->assertSame('image/jpeg', $att[4]); + } + + public function testTemplateImagesAreEmbeddedIncludingPoweredBySpecialCase(): void + { + // Template id used + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::SystemMessageTemplate, '42'], + [ConfigOption::Website, 'https://example.com'], + [ConfigOption::UploadImageRoot, $this->documentRoot . '/upload/'], + [ConfigOption::PageRoot, '/'], + ]); + + $html = '
'; + + // For normal image, repository called with templateId 42 + $tplImg1 = $this->createMock(TemplateImage::class); + $tplImg1->method('getData')->willReturn(base64_encode('IMG1')); + + // For powerphplist.png, templateId should be 0 per implementation + $tplImg2 = $this->createMock(TemplateImage::class); + $tplImg2->method('getData')->willReturn(base64_encode('IMG2')); + + $this->templateImageRepository->method('findByTemplateIdAndFilename') + ->willReturnCallback(function (int $tplId, string $filename) use ($tplImg1, $tplImg2) { + if ($filename === '/assets/logo.jpg') { + // In current implementation, first pass checks templateId as provided + return $tplImg1; + } + if ($filename === 'powerphplist.png') { + return $tplImg2; + } + return null; + }); + + $embedder = $this->createEmbedder(); + $out = $embedder($html, 7); + + // Both images should be replaced with cid references + $this->assertSame(2, substr_count($out, 'cid:')); + $this->assertStringNotContainsString('/assets/logo.jpg', $out); + $this->assertStringNotContainsString('powerphplist.png"', $out, 'basename is replaced by cid'); + $this->assertCount(2, $embedder->attachment); + } + + public function testFilesystemUploadedImagesAreEmbeddedAndConfigIsUpdated(): void + { + // Prepare upload dir structure and file + $uploadDir = $this->documentRoot . '/uploads'; + mkdir($uploadDir . '/image', 0777, true); + $filePath = $uploadDir . '/image/pic.png'; + file_put_contents($filePath, 'PNGDATA'); + + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::SystemMessageTemplate, '0'], + [ConfigOption::Website, 'https://example.com'], + [ConfigOption::UploadImageRoot, $this->documentRoot . '/upload/'], + [ConfigOption::PageRoot, '/'], + ]); + + // Expect configManager->create called when a path with non-null config is used + $this->configManager->expects($this->atLeastOnce()) + ->method('create'); + + $html = '

'; + + $embedder = $this->createEmbedder(embedUploaded: true, uploadImagesDir: 'uploads'); + $out = $embedder($html, 22); + + $this->assertStringContainsString('cid:', $out); + $this->assertCount(1, $embedder->attachment); + $att = $embedder->attachment[0]; + $this->assertSame('image/png', $att[4]); + } + + public function testNoOpWhenFlagsOffAndNoTemplateMatch(): void + { + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::SystemMessageTemplate, '0'], + [ConfigOption::Website, 'https://example.com'], + [ConfigOption::UploadImageRoot, $this->documentRoot . '/upload/'], + [ConfigOption::PageRoot, '/'], + ]); + + // Neither external nor uploaded embedding enabled; repository returns null + $this->templateImageRepository->method('findByTemplateIdAndFilename')->willReturn(null); + + $html = ''; + $embedder = $this->createEmbedder(); + $out = $embedder($html, 1); + + $this->assertSame($html, $out); + $this->assertSame([], $embedder->attachment); + } + + public function testUnknownExtensionIsIgnored(): void + { + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::SystemMessageTemplate, 0], + [ConfigOption::Website, 'https://example.com'], + [ConfigOption::UploadImageRoot, $this->documentRoot . '/upload/'], + [ConfigOption::PageRoot, '/'], + ]); + + $html = ''; + $embedder = $this->createEmbedder(embedExternal: true, embedUploaded: true); + $out = $embedder($html, 5); + + // .svg is not in allowed extensions → untouched, no attachments + $this->assertSame($html, $out); + $this->assertSame([], $embedder->attachment); + } +} From 7f21c58fb7f388005b73b8b07afffbb1e792672b Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 29 Dec 2025 12:22:13 +0400 Subject: [PATCH 30/37] MailSizeChecker --- config/services/messenger.yml | 2 - config/services/services.yml | 6 + .../CampaignProcessorMessageHandler.php | 66 +------ .../Messaging/Service/MailSizeChecker.php | 78 ++++++++ .../CampaignProcessorMessageHandlerTest.php | 14 +- .../Messaging/Service/MailSizeCheckerTest.php | 171 ++++++++++++++++++ 6 files changed, 264 insertions(+), 73 deletions(-) create mode 100644 src/Domain/Messaging/Service/MailSizeChecker.php create mode 100644 tests/Unit/Domain/Messaging/Service/MailSizeCheckerTest.php diff --git a/config/services/messenger.yml b/config/services/messenger.yml index 110129d5..3c8f27bb 100644 --- a/config/services/messenger.yml +++ b/config/services/messenger.yml @@ -31,8 +31,6 @@ services: PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler: autowire: true - arguments: - $maxMailSize: '%messaging.max_mail_size%' PhpList\Core\Domain\Subscription\MessageHandler\DynamicTableMessageHandler: autowire: true diff --git a/config/services/services.yml b/config/services/services.yml index 65ede6b7..cf298621 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -133,3 +133,9 @@ services: arguments: [ '@cache.app' ] Psr\SimpleCache\CacheInterface: '@cache.app.simple' + + PhpList\Core\Domain\Messaging\Service\MailSizeChecker: + autowire: true + autoconfigure: true + arguments: + $maxMailSize: '%messaging.max_mail_size%' diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php index 54a61983..35b4c841 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php @@ -23,13 +23,12 @@ use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; use PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder; use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; -use PhpList\Core\Domain\Messaging\Service\Manager\MessageDataManager; +use PhpList\Core\Domain\Messaging\Service\MailSizeChecker; use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; 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\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; @@ -39,7 +38,6 @@ use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Mime\Address; -use Symfony\Component\Mime\Email; use Symfony\Contracts\Translation\TranslatorInterface; use Throwable; @@ -50,8 +48,6 @@ #[AsMessageHandler] class CampaignProcessorMessageHandler { - private ?int $maxMailSize; - public function __construct( private readonly MailerInterface $mailer, private readonly RateLimitedCampaignMailer $rateLimitedCampaignMailer, @@ -66,16 +62,13 @@ public function __construct( private readonly TranslatorInterface $translator, private readonly SubscriberHistoryManager $subscriberHistoryManager, private readonly MessageRepository $messageRepository, - private readonly EventLogManager $eventLogManager, - private readonly MessageDataManager $messageDataManager, private readonly MessagePrecacheService $precacheService, private readonly UserPersonalizer $userPersonalizer, private readonly MessageDataLoader $messageDataLoader, private readonly EmailBuilder $emailBuilder, + private readonly MailSizeChecker $mailSizeChecker, private readonly string $messageEnvelope, - ?int $maxMailSize = null, ) { - $this->maxMailSize = $maxMailSize ?? 0; } public function __invoke(CampaignProcessorMessage|SyncCampaignProcessorMessage $message): void @@ -215,7 +208,7 @@ private function handleEmailSending( try { $email = $this->rateLimitedCampaignMailer->composeEmail($campaign, $subscriber, $processed); $this->mailer->send($email); - $this->checkMessageSizeOrSuspendCampaign($campaign, $email, $subscriber->hasHtmlEmail()); + ($this->mailSizeChecker)($campaign, $email, $subscriber->hasHtmlEmail()); $this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent); } catch (MessageSizeLimitExceededException $e) { // stop after the first message if size is exceeded @@ -235,59 +228,6 @@ private function handleEmailSending( } } - 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); - } - - return $size; - } - private function handleAdminNotifications(Message $campaign, array $loadedMessageData, int $messageId): void { if (!empty($loadedMessageData['notify_start']) && !isset($loadedMessageData['start_notified'])) { diff --git a/src/Domain/Messaging/Service/MailSizeChecker.php b/src/Domain/Messaging/Service/MailSizeChecker.php new file mode 100644 index 00000000..9d8c9a92 --- /dev/null +++ b/src/Domain/Messaging/Service/MailSizeChecker.php @@ -0,0 +1,78 @@ +maxMailSize = $maxMailSize ?? 0; + } + + public function __invoke(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); + } + + return $size; + } +} diff --git a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php index 86a98c5f..9fa4b4f6 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php @@ -6,10 +6,10 @@ use Doctrine\ORM\EntityManagerInterface; use Exception; -use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage; use PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler; +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\Messaging\Model\Message\MessageMetadata; @@ -18,7 +18,7 @@ use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; use PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder; use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; -use PhpList\Core\Domain\Messaging\Service\Manager\MessageDataManager; +use PhpList\Core\Domain\Messaging\Service\MailSizeChecker; use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; use PhpList\Core\Domain\Messaging\Service\MessageDataLoader; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; @@ -91,14 +91,12 @@ protected function setUp(): void translator: $this->translator, subscriberHistoryManager: $this->createMock(SubscriberHistoryManager::class), messageRepository: $this->messageRepository, - eventLogManager: $this->createMock(EventLogManager::class), - messageDataManager: $this->createMock(MessageDataManager::class), precacheService: $this->precacheService, userPersonalizer: $userPersonalizer, messageDataLoader: $this->createMock(MessageDataLoader::class), emailBuilder: $this->createMock(EmailBuilder::class), + mailSizeChecker: $this->createMock(MailSizeChecker::class), messageEnvelope: 'messageEnvelope', - maxMailSize: 0, ); } @@ -196,7 +194,7 @@ public function testInvokeWithInvalidSubscriberEmail(): void public function testInvokeWithValidSubscriberEmail(): void { $campaign = $this->createMock(Message::class); - $precached = new \PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto(); + $precached = new MessagePrecacheDto(); $precached->subject = 'Test Subject'; $precached->content = '

Test HTML message

'; $precached->textContent = 'Test text message'; @@ -267,7 +265,7 @@ public function testInvokeWithValidSubscriberEmail(): void public function testInvokeWithMailerException(): void { $campaign = $this->createMock(Message::class); - $precached = new \PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto(); + $precached = new MessagePrecacheDto(); $precached->subject = 'Test Subject'; $precached->content = '

Test HTML message

'; $precached->textContent = 'Test text message'; @@ -326,7 +324,7 @@ public function testInvokeWithMailerException(): void public function testInvokeWithMultipleSubscribers(): void { $campaign = $this->createCampaignMock(); - $precached = new \PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto(); + $precached = new MessagePrecacheDto(); $precached->subject = 'Test Subject'; $precached->content = '

Test HTML message

'; $precached->textContent = 'Test text message'; diff --git a/tests/Unit/Domain/Messaging/Service/MailSizeCheckerTest.php b/tests/Unit/Domain/Messaging/Service/MailSizeCheckerTest.php new file mode 100644 index 00000000..e8ba227e --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/MailSizeCheckerTest.php @@ -0,0 +1,171 @@ +eventLogManager = $this->createMock(EventLogManager::class); + $this->messageDataManager = $this->createMock(MessageDataManager::class); + $this->cache = $this->createMock(CacheInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + } + + private function createMessageWithId(int $id): Message + { + $message = $this->getMockBuilder(Message::class) + ->disableOriginalConstructor() + ->onlyMethods(['getId']) + ->getMock(); + + $message->method('getId')->willReturn($id); + + return $message; + } + + private function createEmail(): Email + { + return (new Email()) + ->from('no-reply@example.com') + ->to('user@example.com') + ->subject('Subject') + ->text('Body'); + } + + public function testDisabledMaxMailSizeDoesNothingAndSkipsCache(): void + { + $checker = new MailSizeChecker( + eventLogManager: $this->eventLogManager, + messageDataManager: $this->messageDataManager, + cache: $this->cache, + logger: $this->logger, + maxMailSize: 0, + ); + + $this->cache->expects($this->never())->method('has'); + $this->messageDataManager->expects($this->never())->method('setMessageData'); + $this->eventLogManager->expects($this->never())->method('log'); + $this->logger->expects($this->never())->method('warning'); + + $checker->__invoke($this->createMessageWithId(1), $this->createEmail(), true); + // No exceptions + $this->addToAssertionCount(1); + } + + public function testCacheMissCalculatesAndStoresAndDoesNotThrow(): void + { + // very large to avoid throwing regardless of calculated size + $checker = new MailSizeChecker( + eventLogManager: $this->eventLogManager, + messageDataManager: $this->messageDataManager, + cache: $this->cache, + logger: $this->logger, + maxMailSize: 10_000_000, + ); + + $message = $this->createMessageWithId(42); + + $this->cache->expects($this->once()) + ->method('has') + ->with($this->callback(fn (string $key) => str_contains($key, 'messaging.size.42.htmlsize'))) + ->willReturn(false); + + $this->messageDataManager->expects($this->once()) + ->method('setMessageData') + ->with($message, 'htmlsize', $this->callback(fn ($v) => is_int($v) && $v > 0)); + + $this->cache->expects($this->once()) + ->method('set') + ->with( + $this->callback(fn (string $key) => str_contains($key, 'messaging.size.42.htmlsize')), + $this->callback(fn ($v) => is_int($v) && $v > 0) + ); + + // After setting, get() will be called; return a small size to keep below limit + $this->cache->expects($this->once()) + ->method('get') + ->with($this->callback(fn (string $key) => str_contains($key, 'messaging.size.42.htmlsize'))) + ->willReturn(100); + + $checker->__invoke($message, $this->createEmail(), true); + $this->addToAssertionCount(1); + } + + public function testThrowsWhenCachedSizeExceedsLimitAndLogsAndEvents(): void + { + $checker = new MailSizeChecker( + eventLogManager: $this->eventLogManager, + messageDataManager: $this->messageDataManager, + cache: $this->cache, + logger: $this->logger, + maxMailSize: 500, + ); + + $message = $this->createMessageWithId(7); + + // Simulate cache hit with a large size + $this->cache->method('has')->willReturn(true); + $this->cache->method('get')->willReturn(1_000); + + $this->logger->expects($this->once()) + ->method('warning') + ->with($this->callback( + fn (string $msg) => str_contains($msg, 'Message too large') && str_contains($msg, '7') + )); + + $this->eventLogManager->expects($this->exactly(2)) + ->method('log') + ->with( + 'send', + $this->callback( + fn (string $msg) => + str_contains($msg, 'Message too large') || str_contains($msg, 'Campaign 7 suspended') + ) + ); + + $this->expectException(MessageSizeLimitExceededException::class); + $checker->__invoke($message, $this->createEmail(), false); + } + + public function testReturnsWhenCachedSizeWithinLimit(): void + { + $checker = new MailSizeChecker( + eventLogManager: $this->eventLogManager, + messageDataManager: $this->messageDataManager, + cache: $this->cache, + logger: $this->logger, + maxMailSize: 10_000, + ); + + $message = $this->createMessageWithId(99); + + $this->cache->method('has')->willReturn(true); + // well below the limit + $this->cache->method('get')->willReturn(123); + + $this->logger->expects($this->never())->method('warning'); + $this->eventLogManager->expects($this->never())->method('log'); + + $checker->__invoke($message, $this->createEmail(), true); + $this->addToAssertionCount(1); + } +} From 8342b4a708eebff05e8be36992df2b494f2f836d Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Thu, 22 Jan 2026 15:19:49 +0400 Subject: [PATCH 31/37] Feat/email building with attachments (#375) New Features PDF generation for messages, per-subscriber remote-content fetching, tracking-pixel user tracking, and richer attachment handling with downloadable copies. Improvements Unified email builder flow with consistent composition and multi-format output (HTML/Text/PDF); expanded, context-aware placeholder personalization (many URL/list resolvers); improved remote-content precaching and output formatting; new configurable parameters and translations. --------- Co-authored-by: Tatevik --- composer.json | 3 +- config/PHPMD/rules.xml | 2 +- config/parameters.yml.dist | 16 + config/services/builders.yml | 40 +- config/services/managers.yml | 102 +---- config/services/messenger.yml | 31 +- config/services/parameters.yml | 5 + config/services/providers.yml | 9 +- config/services/repositories.yml | 15 + config/services/resolvers.yml | 24 ++ config/services/services.yml | 92 ++++- resources/translations/messages.en.xlf | 60 +++ src/Domain/Common/FileHelper.php | 61 +++ src/Domain/Common/IspRestrictionsProvider.php | 3 +- src/Domain/Common/OnceCacheGuard.php | 28 ++ src/Domain/Common/PdfGenerator.php | 25 ++ src/Domain/Common/RemotePageFetcher.php | 2 +- .../Configuration/Model/ConfigOption.php | 8 + .../Model/Dto/PlaceholderContext.php | 47 +++ .../Configuration/Model/OutputFormat.php | 14 + .../Service/LegacyUrlBuilder.php | 12 +- .../Service/MessagePlaceholderProcessor.php | 126 ++++++ .../Placeholder/BlacklistUrlValueResolver.php | 39 ++ .../Placeholder/BlacklistValueResolver.php | 45 +++ .../ConfirmationUrlValueResolver.php | 36 ++ .../Placeholder/ContactUrlValueResolver.php | 30 ++ .../Placeholder/ContactValueResolver.php | 42 ++ .../Placeholder/FooterValueResolver.php | 33 ++ .../ForwardMessageIdValueResolver.php | 72 ++++ .../Placeholder/ForwardUrlValueResolver.php | 42 ++ .../Placeholder/ForwardValueResolver.php | 50 +++ .../Placeholder/JumpoffUrlValueResolver.php | 13 + .../Placeholder/JumpoffValueResolver.php | 39 ++ .../Placeholder/ListsValueResolver.php | 48 +++ .../PatternValueResolverInterface.php | 13 + .../PlaceholderValueResolverInterface.php | 13 + .../PreferencesUrlValueResolver.php | 41 ++ .../Placeholder/PreferencesValueResolver.php | 50 +++ .../Placeholder/SignatureValueResolver.php | 40 ++ .../Placeholder/SubscribeUrlValueResolver.php | 32 ++ .../Placeholder/SubscribeValueResolver.php | 43 ++ ...SupportingPlaceholderResolverInterface.php | 13 + .../UnsubscribeUrlValueResolver.php | 39 ++ .../Placeholder/UnsubscribeValueResolver.php | 49 +++ .../UserDataSupportingResolver.php | 53 +++ .../Placeholder/UserTrackValueResolver.php | 41 ++ .../Service/PlaceholderResolver.php | 101 ++++- .../Service/UserPersonalizer.php | 57 ++- .../Command/ImportDefaultsCommand.php | 2 +- .../AdminAttributeDefinitionManager.php | 4 +- .../{ => Manager}/AdminAttributeManager.php | 4 +- .../{ => Manager}/AdministratorManager.php | 2 +- .../Service/{ => Manager}/PasswordManager.php | 6 +- .../Service/{ => Manager}/SessionManager.php | 4 +- .../Exception/AttachmentCopyException.php | 12 + .../Exception/AttachmentException.php | 15 + .../Exception/RemotePageFetchException.php | 15 + .../Exception/SubscriberNotFoundException.php | 12 + .../AsyncEmailMessageHandler.php | 5 +- .../CampaignProcessorMessageHandler.php | 87 +++- .../PasswordResetMessageHandler.php | 15 +- .../SubscriberConfirmationMessageHandler.php | 15 +- ...SubscriptionConfirmationMessageHandler.php | 51 +-- .../Model/Dto/MessagePrecacheDto.php | 2 +- src/Domain/Messaging/Model/Message.php | 13 +- .../Messaging/Model/Message/MessageFormat.php | 76 ++-- .../Repository/AttachmentRepository.php | 23 ++ .../Messaging/Service/AttachmentAdder.php | 248 ++++++++++++ .../Service/Builder/BaseEmailBuilder.php | 154 +++++++ .../Service/Builder/EmailBuilder.php | 341 +++++++++------- .../Service/Builder/MessageFormatBuilder.php | 1 - .../Service/Builder/SystemEmailBuilder.php | 110 +++++ .../CampaignMailContentBuilder.php | 159 ++++++++ .../MailContentBuilderInterface.php | 19 + .../SystemMailContentBuilder.php} | 15 +- src/Domain/Messaging/Service/EmailService.php | 18 +- .../Service/MessagePrecacheService.php | 47 +-- .../Service/RateLimitedCampaignMailer.php | 36 +- .../Repository/SubscriberListRepository.php | 38 ++ .../Repository/SubscriberRepository.php | 10 + .../Service/SubscriberCsvImporter.php | 37 +- .../Messaging/Fixtures/MessageFixture.php | 63 ++- .../Service/WebklexImapClientFactoryTest.php | 24 +- tests/Unit/Domain/Common/FileHelperTest.php | 88 ++++ .../Unit/Domain/Common/OnceCacheGuardTest.php | 78 ++++ tests/Unit/Domain/Common/PdfGeneratorTest.php | 46 +++ .../MessagePlaceholderProcessorTest.php | 190 +++++++++ .../BlacklistUrlValueResolverTest.php | 95 +++++ .../BlacklistValueResolverTest.php | 97 +++++ .../ConfirmationUrlValueResolverTest.php | 104 +++++ .../ContactUrlValueResolverTest.php | 103 +++++ .../Placeholder/ContactValueResolverTest.php | 116 ++++++ .../Placeholder/FooterValueResolverTest.php | 80 ++++ .../ForwardMessageIdValueResolverTest.php | 146 +++++++ .../ForwardUrlValueResolverTest.php | 124 ++++++ .../Placeholder/ForwardValueResolverTest.php | 145 +++++++ .../JumpoffUrlValueResolverTest.php | 73 ++++ .../Placeholder/JumpoffValueResolverTest.php | 93 +++++ .../Placeholder/ListsValueResolverTest.php | 114 ++++++ .../PreferencesUrlValueResolverTest.php | 82 ++++ .../PreferencesValueResolverTest.php | 110 +++++ .../SignatureValueResolverTest.php | 96 +++++ .../SubscribeUrlValueResolverTest.php | 74 ++++ .../SubscribeValueResolverTest.php | 86 ++++ .../UnsubscribeUrlValueResolverTest.php | 95 +++++ .../UnsubscribeValueResolverTest.php | 147 +++++++ .../UserDataSupportingResolverTest.php | 103 +++++ .../UserTrackValueResolverTest.php | 92 +++++ .../Service/PlaceholderResolverTest.php | 25 +- .../Service/UserPersonalizerTest.php | 55 ++- .../AdminAttributeDefinitionManagerTest.php | 4 +- .../Service/AdminAttributeManagerTest.php | 4 +- .../Service/AdministratorManagerTest.php | 2 +- .../Identity/Service/PasswordManagerTest.php | 10 +- .../Identity/Service/SessionManagerTest.php | 2 +- .../CampaignProcessorMessageHandlerTest.php | 85 ++-- ...criptionConfirmationMessageHandlerTest.php | 26 +- .../Messaging/Service/AttachmentAdderTest.php | 234 +++++++++++ .../Service/Builder/EmailBuilderTest.php | 382 ++++++++++++++---- .../Builder/MessageFormatBuilderTest.php | 1 - .../Builder/SystemEmailBuilderTest.php | 205 ++++++++++ .../CampaignMailContentBuilderTest.php | 265 ++++++++++++ .../Service/RateLimitedCampaignMailerTest.php | 104 ----- .../Service/SystemMailConstructorTest.php | 31 +- 124 files changed, 6604 insertions(+), 890 deletions(-) create mode 100644 src/Domain/Common/FileHelper.php create mode 100644 src/Domain/Common/OnceCacheGuard.php create mode 100644 src/Domain/Common/PdfGenerator.php create mode 100644 src/Domain/Configuration/Model/Dto/PlaceholderContext.php create mode 100644 src/Domain/Configuration/Model/OutputFormat.php create mode 100644 src/Domain/Configuration/Service/MessagePlaceholderProcessor.php create mode 100644 src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/ContactUrlValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/ContactValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/JumpoffValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/PatternValueResolverInterface.php create mode 100644 src/Domain/Configuration/Service/Placeholder/PlaceholderValueResolverInterface.php create mode 100644 src/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/SubscribeValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/SupportingPlaceholderResolverInterface.php create mode 100644 src/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/UserDataSupportingResolver.php create mode 100644 src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php rename src/Domain/Identity/Service/{ => Manager}/AdminAttributeDefinitionManager.php (98%) rename src/Domain/Identity/Service/{ => Manager}/AdminAttributeManager.php (97%) rename src/Domain/Identity/Service/{ => Manager}/AdministratorManager.php (97%) rename src/Domain/Identity/Service/{ => Manager}/PasswordManager.php (98%) rename src/Domain/Identity/Service/{ => Manager}/SessionManager.php (97%) create mode 100644 src/Domain/Messaging/Exception/AttachmentCopyException.php create mode 100644 src/Domain/Messaging/Exception/AttachmentException.php create mode 100644 src/Domain/Messaging/Exception/RemotePageFetchException.php create mode 100644 src/Domain/Messaging/Exception/SubscriberNotFoundException.php create mode 100644 src/Domain/Messaging/Service/AttachmentAdder.php create mode 100644 src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php create mode 100644 src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php create mode 100644 src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php create mode 100644 src/Domain/Messaging/Service/Constructor/MailContentBuilderInterface.php rename src/Domain/Messaging/Service/{SystemMailConstructor.php => Constructor/SystemMailContentBuilder.php} (87%) create mode 100644 tests/Unit/Domain/Common/FileHelperTest.php create mode 100644 tests/Unit/Domain/Common/OnceCacheGuardTest.php create mode 100644 tests/Unit/Domain/Common/PdfGeneratorTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/ContactUrlValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/ContactValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/FooterValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/ForwardValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/ListsValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/SignatureValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/UserDataSupportingResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/AttachmentAdderTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php diff --git a/composer.json b/composer.json index 92a29565..aba1a887 100644 --- a/composer.json +++ b/composer.json @@ -83,7 +83,8 @@ "ext-libxml": "*", "ext-gd": "*", "ext-curl": "*", - "ext-fileinfo": "*" + "ext-fileinfo": "*", + "setasign/fpdf": "^1.8" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/config/PHPMD/rules.xml b/config/PHPMD/rules.xml index 40900dd9..9738d792 100644 --- a/config/PHPMD/rules.xml +++ b/config/PHPMD/rules.xml @@ -46,7 +46,7 @@ - + diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index 92b33bed..8d3af216 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -31,6 +31,10 @@ parameters: env(APP_DEV_EMAIL): 'dev@dev.com' app.powered_by_phplist: '%%env(APP_POWERED_BY_PHPLIST)%%' env(APP_POWERED_BY_PHPLIST): '0' + app.preference_page_show_private_lists: '%%env(PREFERENCEPAGE_SHOW_PRIVATE_LISTS)%%' + env(PREFERENCEPAGE_SHOW_PRIVATE_LISTS): '0' + app.rest_api_domain: '%%env(REST_API_DOMAIN)%%' + env(REST_API_DOMAIN): 'https://example.com/api/v2' # Email configuration app.mailer_from: '%%env(MAILER_FROM)%%' @@ -105,6 +109,8 @@ parameters: env(GOOGLE_SENDERID): '' messaging.use_amazon_ses: '%%env(USE_AMAZONSES)%%' env(USE_AMAZONSES): '0' + messaging.use_precedence_header: '%%env(USE_PRECEDENCE_HEADER)%%' + env(USE_PRECEDENCE_HEADER): '0' messaging.embed_external_images: '%%env(EMBEDEXTERNALIMAGES)%%' env(EMBEDEXTERNALIMAGES): '0' messaging.embed_uploaded_images: '%%env(EMBEDUPLOADIMAGES)%%' @@ -115,6 +121,12 @@ parameters: env(EXTERNALIMAGE_TIMEOUT): '30' messaging.external_image_max_size: '%%env(EXTERNALIMAGE_MAXSIZE)%%' env(EXTERNALIMAGE_MAXSIZE): '204800' + messaging.forward_alternative_content: '%%env(FORWARD_ALTERNATIVE_CONTENT)%%' + env(FORWARD_ALTERNATIVE_CONTENT): '0' + messaging.email_text_credits: '%%env(EMAILTEXTCREDITS)%%' + env(EMAILTEXTCREDITS): '0' + messaging.always_add_user_track: '%%env(ALWAYS_ADD_USERTRACK)%%' + env(ALWAYS_ADD_USERTRACK): '1' phplist.upload_images_dir: '%%env(PHPLIST_UPLOADIMAGES_DIR)%%' env(PHPLIST_UPLOADIMAGES_DIR): 'images' @@ -122,3 +134,7 @@ parameters: env(FCKIMAGES_DIR): 'uploadimages' phplist.public_schema: '%%env(PUBLIC_SCHEMA)%%' env(PUBLIC_SCHEMA): 'https' + phplist.attachment_download_url: '%%env(PHPLIST_ATTACHMENT_DOWNLOAD_URL)%%' + env(PHPLIST_ATTACHMENT_DOWNLOAD_URL): 'https://example.com/download/' + phplist.attachment_repository_path: '%%env(PHPLIST_ATTACHMENT_REPOSITORY_PATH)%%' + env(PHPLIST_ATTACHMENT_REPOSITORY_PATH): '/tmp' diff --git a/config/services/builders.yml b/config/services/builders.yml index 10a994a4..8a386408 100644 --- a/config/services/builders.yml +++ b/config/services/builders.yml @@ -4,22 +4,30 @@ services: autoconfigure: true public: false - PhpList\Core\Domain\Messaging\Service\Builder\MessageBuilder: - autowire: true - autoconfigure: true + PhpList\Core\Domain\: + resource: '../../src/Domain/*/Service/Builder/*' - PhpList\Core\Domain\Messaging\Service\Builder\MessageFormatBuilder: - autowire: true - autoconfigure: true + # Concrete mail constructors + PhpList\Core\Domain\Messaging\Service\Constructor\SystemMailContentBuilder: ~ + PhpList\Core\Domain\Messaging\Service\Constructor\CampaignMailContentBuilder: ~ - PhpList\Core\Domain\Messaging\Service\Builder\MessageScheduleBuilder: - autowire: true - autoconfigure: true + # Two EmailBuilder services with different constructors injected + Core.EmailBuilder.system: + class: PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder + arguments: + $mailConstructor: '@PhpList\Core\Domain\Messaging\Service\Constructor\SystemMailContentBuilder' + $googleSenderId: '%messaging.google_sender_id%' + $useAmazonSes: '%messaging.use_amazon_ses%' + $usePrecedenceHeader: '%messaging.use_precedence_header%' + $devVersion: '%app.dev_version%' + $devEmail: '%app.dev_email%' - PhpList\Core\Domain\Messaging\Service\Builder\MessageContentBuilder: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Builder\MessageOptionsBuilder: - autowire: true - autoconfigure: true + Core.EmailBuilder.campaign: + class: PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder + arguments: + $mailConstructor: '@PhpList\Core\Domain\Messaging\Service\Constructor\CampaignMailContentBuilder' + $googleSenderId: '%messaging.google_sender_id%' + $useAmazonSes: '%messaging.use_amazon_ses%' + $usePrecedenceHeader: '%messaging.use_precedence_header%' + $devVersion: '%app.dev_version%' + $devEmail: '%app.dev_email%' diff --git a/config/services/managers.yml b/config/services/managers.yml index 75475459..83059bc9 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -4,53 +4,11 @@ services: autoconfigure: true public: false - PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager: - autowire: true - autoconfigure: true + PhpList\Core\Domain\: + resource: '../../src/Domain/*/Service/Manager/*' + exclude: '../../src/Domain/*/Service/Manager/Builder/*' - PhpList\Core\Domain\Identity\Service\SessionManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Identity\Service\AdministratorManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Identity\Service\AdminAttributeManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Identity\Service\PasswordManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\DynamicListAttrManager: - autowire: true - autoconfigure: true + PhpList\Core\Bounce\Service\Manager\BounceManager: ~ Doctrine\DBAL\Schema\AbstractSchemaManager: factory: ['@doctrine.dbal.default_connection', 'createSchemaManager'] @@ -62,55 +20,3 @@ services: arguments: $dbPrefix: '%database_prefix%' $dynamicListTablePrefix: '%list_table_prefix%' - - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\MessageManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\BounceRegexManager: - autowire: true - autoconfigure: true - - PhpList\Core\Bounce\Service\Manager\BounceManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\SendProcessManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\MessageDataManager: - autowire: true - autoconfigure: true diff --git a/config/services/messenger.yml b/config/services/messenger.yml index 3c8f27bb..eb4b5d7a 100644 --- a/config/services/messenger.yml +++ b/config/services/messenger.yml @@ -5,34 +5,15 @@ services: resource: '../../src/Domain/Messaging/MessageHandler' tags: [ 'messenger.message_handler' ] - PhpList\Core\Domain\Messaging\MessageHandler\SubscriberConfirmationMessageHandler: + # Register Subscription message handlers (e.g., DynamicTableMessageHandler) + PhpList\Core\Domain\Subscription\MessageHandler\: autowire: true - autoconfigure: true - tags: [ 'messenger.message_handler' ] - arguments: - $confirmationUrl: '%app.confirmation_url%' - - PhpList\Core\Domain\Messaging\MessageHandler\AsyncEmailMessageHandler: - autowire: true - autoconfigure: true - tags: [ 'messenger.message_handler' ] - - PhpList\Core\Domain\Messaging\MessageHandler\PasswordResetMessageHandler: - autowire: true - autoconfigure: true - tags: [ 'messenger.message_handler' ] - arguments: - $passwordResetUrl: '%app.password_reset_url%' - - PhpList\Core\Domain\Messaging\MessageHandler\SubscriptionConfirmationMessageHandler: - autowire: true - autoconfigure: true + resource: '../../src/Domain/Subscription/MessageHandler' tags: [ 'messenger.message_handler' ] PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler: autowire: true - - PhpList\Core\Domain\Subscription\MessageHandler\DynamicTableMessageHandler: - autowire: true autoconfigure: true - tags: [ 'messenger.message_handler' ] + arguments: + $campaignEmailBuilder: '@Core.EmailBuilder.campaign' + $systemEmailBuilder: '@Core.EmailBuilder.system' diff --git a/config/services/parameters.yml b/config/services/parameters.yml index ebf1d99b..18aa6ccf 100644 --- a/config/services/parameters.yml +++ b/config/services/parameters.yml @@ -1,4 +1,9 @@ parameters: + # Flattened parameters for direct DI usage (Symfony does not support dot access into arrays) + app.config.message_from_address: 'news@example.com' + app.config.default_message_age: 15768000 + + # Keep original grouped array for legacy/config-provider usage app.config: message_from_address: 'news@example.com' admin_address: 'admin@example.com' diff --git a/config/services/providers.yml b/config/services/providers.yml index b7b66be8..481b23a3 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -7,12 +7,6 @@ services: arguments: $config: '%app.config%' - PhpList\Core\Domain\Common\IspRestrictionsProvider: - autowire: true - autoconfigure: true - arguments: - $confPath: '%app.phplist_isp_conf_path%' - PhpList\Core\Domain\Subscription\Service\Provider\CheckboxGroupValueProvider: autowire: true PhpList\Core\Domain\Subscription\Service\Provider\SelectOrRadioValueProvider: @@ -30,3 +24,6 @@ services: PhpList\Core\Domain\Subscription\Service\Provider\SubscriberAttributeChangeSetProvider: autowire: true + + PhpList\Core\Domain\Common\IspRestrictionsProvider: + autowire: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml index ea1f0001..37b31c18 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -22,6 +22,11 @@ services: arguments: - PhpList\Core\Domain\Configuration\Model\EventLog + PhpList\Core\Domain\Configuration\Repository\UrlCacheRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Configuration\Model\UrlCache + PhpList\Core\Domain\Identity\Repository\AdministratorRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository @@ -145,3 +150,13 @@ services: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Messaging\Model\MessageData + + PhpList\Core\Domain\Messaging\Repository\AttachmentRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\Attachment + + PhpList\Core\Domain\Messaging\Repository\MessageAttachmentRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\MessageAttachment diff --git a/config/services/resolvers.yml b/config/services/resolvers.yml index 99c08356..6dfab328 100644 --- a/config/services/resolvers.yml +++ b/config/services/resolvers.yml @@ -13,3 +13,27 @@ services: PhpList\Core\Bounce\Service\BounceActionResolver: arguments: - !tagged_iterator { tag: 'phplist.bounce_action_handler' } + + PhpList\Core\Domain\Configuration\Service\Placeholder\UnsubscribeUrlValueResolver: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\Placeholder\ConfirmationUrlValueResolver: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\Placeholder\PreferencesUrlValueResolver: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\Placeholder\SubscribeUrlValueResolver: + autowire: true + autoconfigure: true + + _instanceof: + PhpList\Core\Domain\Configuration\Service\Placeholder\PlaceholderValueResolverInterface: + tags: ['phplist.placeholder_resolver'] + PhpList\Core\Domain\Configuration\Service\Placeholder\PatternValueResolverInterface: + tags: [ 'phplist.pattern_resolver' ] + PhpList\Core\Domain\Configuration\Service\Placeholder\SupportingPlaceholderResolverInterface: + tags: [ 'phplist.supporting_placeholder_resolver' ] diff --git a/config/services/services.yml b/config/services/services.yml index cf298621..c07ee0bb 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -1,4 +1,9 @@ services: + _defaults: + autowire: true + autoconfigure: true + public: false + PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter: autowire: true autoconfigure: true @@ -12,9 +17,6 @@ services: PhpList\Core\Domain\Messaging\Service\EmailService: autowire: true autoconfigure: true - arguments: - $defaultFromEmail: '%app.mailer_from%' - $bounceEmail: '%imap_bounce.email%' PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService: autowire: true @@ -43,6 +45,59 @@ services: autowire: true autoconfigure: true + PhpList\Core\Domain\Common\OnceCacheGuard: + autowire: true + autoconfigure: true + + # Html to Text converter used by mail constructors + PhpList\Core\Domain\Common\Html2Text: + autowire: true + autoconfigure: true + + # Rewrites relative asset URLs in fetched HTML to absolute ones + PhpList\Core\Domain\Common\HtmlUrlRewriter: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Common\PdfGenerator: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\AttachmentAdder: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\UserPersonalizer: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Common\FileHelper: + autowire: true + autoconfigure: true + + # External image caching/downloading helper used by TemplateImageEmbedder + PhpList\Core\Domain\Common\ExternalImageService: + autowire: true + autoconfigure: true + arguments: + $tempDir: '%kernel.cache_dir%' + # Use literal defaults if parameters are not defined in this environment + $externalImageMaxAge: 0 + $externalImageMaxSize: 204800 + $externalImageTimeout: 30 + + # Embed images from templates and filesystem into HTML emails + PhpList\Core\Domain\Messaging\Service\TemplateImageEmbedder: + autowire: true + autoconfigure: true + arguments: + $documentRoot: '%kernel.project_dir%/public' + # Reuse upload_images_dir for editorImagesDir if a dedicated parameter is absent + $editorImagesDir: '%phplist.upload_images_dir%' + $embedExternalImages: '%messaging.embed_external_images%' + $embedUploadedImages: '%messaging.embed_uploaded_images%' + $uploadImagesDir: '%phplist.upload_images_dir%' + PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer: autowire: true autoconfigure: true @@ -120,9 +175,13 @@ services: autoconfigure: true public: true - PhpList\Core\Domain\Configuration\Service\UserPersonalizer: + PhpList\Core\Domain\Configuration\Service\MessagePlaceholderProcessor: autowire: true autoconfigure: true + arguments: + $placeholderResolvers: !tagged_iterator phplist.placeholder_resolver + $patternResolvers: !tagged_iterator phplist.pattern_resolver + $supportingResolvers: !tagged_iterator phplist.supporting_placeholder_resolver PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder: autowire: true @@ -139,3 +198,28 @@ services: autoconfigure: true arguments: $maxMailSize: '%messaging.max_mail_size%' + + # Loads and normalises message data for campaigns + PhpList\Core\Domain\Messaging\Service\MessageDataLoader: + autowire: true + autoconfigure: true + arguments: + $defaultMessageAge: '%app.config.default_message_age%' + + # Common helpers required by precache/message building + PhpList\Core\Domain\Common\TextParser: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Common\RemotePageFetcher: + autowire: true + autoconfigure: true + + # Pre-caches base message content (HTML/Text/template) for campaigns + PhpList\Core\Domain\Messaging\Service\MessagePrecacheService: + autowire: true + autoconfigure: true + arguments: + $useManualTextPart: '%messaging.use_manual_text_part%' + $uploadImageDir: '%phplist.upload_images_dir%' + $publicSchema: '%phplist.public_schema%' diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf index 40a24785..fee9ed2f 100644 --- a/resources/translations/messages.en.xlf +++ b/resources/translations/messages.en.xlf @@ -750,6 +750,66 @@ Thank you. phplist has started sending the campaign with subject %subject% __phplist has started sending the campaign with subject %subject% + + Unsubscribe + __Unsubscribe + + + This link + __This link + + + Confirm + __Confirm + + + Update preferences + __Update preferences + + + Sorry, you are not subscribed to any of our newsletters with this email address. + __Sorry, you are not subscribed to any of our newsletters with this email address. + + + This message contains attachments that can be viewed with a webbrowser + __This message contains attachments that can be viewed with a webbrowser + + + Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit% + __Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit% + + + Add us to your address book + __Add us to your address book + + + phpList system error + __phpList system error + + + Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be copied to the repository. Check for permissions. + __Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be copied to the repository. Check for permissions. + + + failed to open attachment (%remoteFile%) to add to campaign %campaignId% + __failed to open attachment (%remoteFile%) to add to campaign %campaignId% + + + Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit% + __Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit% + + + Attachment %remoteFile% does not exist + __Attachment %remoteFile% does not exist + + + Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be found in the repository. + __Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be found in the repository. + + + Location + __Location + diff --git a/src/Domain/Common/FileHelper.php b/src/Domain/Common/FileHelper.php new file mode 100644 index 00000000..5dab8b05 --- /dev/null +++ b/src/Domain/Common/FileHelper.php @@ -0,0 +1,61 @@ +cache->has($key)) { + return false; + } + // mark as seen + $this->cache->set($key, true, $ttlSeconds); + + return true; + } +} diff --git a/src/Domain/Common/PdfGenerator.php b/src/Domain/Common/PdfGenerator.php new file mode 100644 index 00000000..1f25840f --- /dev/null +++ b/src/Domain/Common/PdfGenerator.php @@ -0,0 +1,25 @@ +SetCompression(false); + } + $pdf->SetCreator('phpList'); + $pdf->AddPage(); + $pdf->SetFont('Arial', '', 12); + $pdf->Write(6, $text); + + return $pdf->Output('', 'S'); + } +} diff --git a/src/Domain/Common/RemotePageFetcher.php b/src/Domain/Common/RemotePageFetcher.php index 0d37b76e..40cecc5b 100644 --- a/src/Domain/Common/RemotePageFetcher.php +++ b/src/Domain/Common/RemotePageFetcher.php @@ -75,7 +75,7 @@ public function __invoke(string $url, array $userData): string ]); } - return $content; + return $content ?? ''; } private function fetchUrlDirect(string $url): string diff --git a/src/Domain/Configuration/Model/ConfigOption.php b/src/Domain/Configuration/Model/ConfigOption.php index 9222a24b..5ce47d1c 100644 --- a/src/Domain/Configuration/Model/ConfigOption.php +++ b/src/Domain/Configuration/Model/ConfigOption.php @@ -10,9 +10,12 @@ enum ConfigOption: string case SubscribeMessage = 'subscribemessage'; case SubscribeEmailSubject = 'subscribesubject'; case UnsubscribeUrl = 'unsubscribeurl'; + case BlacklistUrl = 'blacklisturl'; + case ForwardUrl = 'forwardurl'; 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'; @@ -33,4 +36,9 @@ enum ConfigOption: string case PoweredByText = 'PoweredByText'; case UploadImageRoot = 'uploadimageroot'; case PageRoot = 'pageroot'; + case OrganisationName = 'organisation_name'; + case VCardUrl = 'vcardurl'; + case HtmlEmailStyle = 'html_email_style'; + case AlwaysSendTextDomains = 'alwayssendtextto'; + case ReportAddress = 'report_address'; } diff --git a/src/Domain/Configuration/Model/Dto/PlaceholderContext.php b/src/Domain/Configuration/Model/Dto/PlaceholderContext.php new file mode 100644 index 00000000..e040a19c --- /dev/null +++ b/src/Domain/Configuration/Model/Dto/PlaceholderContext.php @@ -0,0 +1,47 @@ +format === OutputFormat::Html; + } + + public function isText(): bool + { + return $this->format === OutputFormat::Text; + } + + public function forwardedBy(): ?string + { + return $this->forwardedBy; + } + + public function messageId(): ?int + { + return $this->messageId; + } + + public function getUser(): Subscriber + { + return $this->user; + } +} diff --git a/src/Domain/Configuration/Model/OutputFormat.php b/src/Domain/Configuration/Model/OutputFormat.php new file mode 100644 index 00000000..32884855 --- /dev/null +++ b/src/Domain/Configuration/Model/OutputFormat.php @@ -0,0 +1,14 @@ +withQueryParam($baseUrl, 'uid', $uid); + } + + public function withEmail(string $baseUrl, string $email): string + { + return $this->withQueryParam($baseUrl, 'email', $email); + } + + private function withQueryParam(string $baseUrl, string $paramName, string $paramValue): string { $parts = parse_url($baseUrl) ?: []; $query = []; if (!empty($parts['query'])) { parse_str($parts['query'], $query); } - $query['uid'] = $uid; + $query[$paramName] = $paramValue; $parts['query'] = http_build_query($query); diff --git a/src/Domain/Configuration/Service/MessagePlaceholderProcessor.php b/src/Domain/Configuration/Service/MessagePlaceholderProcessor.php new file mode 100644 index 00000000..7b0edcda --- /dev/null +++ b/src/Domain/Configuration/Service/MessagePlaceholderProcessor.php @@ -0,0 +1,126 @@ + */ + private readonly iterable $placeholderResolvers, + /** @var iterable */ + private readonly iterable $patternResolvers, + /** @var iterable */ + private readonly iterable $supportingResolvers, + #[Autowire('%messaging.always_add_user_track%')] private readonly bool $alwaysAddUserTrack, + ) { + } + + public function process( + string $value, + Subscriber $user, + OutputFormat $format, + MessagePrecacheDto $messagePrecacheDto, + ?int $campaignId = null, + ?string $forwardedBy = null, + ): string { + $value = $this->ensureStandardPlaceholders($value, $format); + + $resolver = new PlaceholderResolver(); + $resolver->register('EMAIL', fn(PlaceholderContext $ctx) => $ctx->user->getEmail()); + $resolver->register('FORWARDEDBY', fn(PlaceholderContext $ctx) => $ctx->forwardedBy()); + $resolver->register('MESSAGEID', fn(PlaceholderContext $ctx) => $ctx->messageId()); + $resolver->register('FORWARDFORM', fn(PlaceholderContext $ctx) => ''); + $resolver->register('USERID', fn(PlaceholderContext $ctx) => $ctx->user->getUniqueId()); + $resolver->register( + name: 'WEBSITE', + resolver: fn(PlaceholderContext $ctx) => $this->config->getValue(ConfigOption::Website) ?? '' + ); + $resolver->register( + name: 'DOMAIN', + resolver: fn(PlaceholderContext $ctx) => $this->config->getValue(ConfigOption::Domain) ?? '' + ); + $resolver->register( + name: 'ORGANIZATION_NAME', + resolver: fn(PlaceholderContext $ctx) => $this->config->getValue(ConfigOption::OrganisationName) ?? '' + ); + + foreach ($this->placeholderResolvers as $placeholderResolver) { + $resolver->register($placeholderResolver->name(), $placeholderResolver); + } + + foreach ($this->patternResolvers as $patternResolver) { + $resolver->registerPattern($patternResolver->pattern(), $patternResolver); + } + + foreach ($this->supportingResolvers as $supportingResolver) { + $resolver->registerSupporting($supportingResolver); + } + + $userAttributes = $this->attributesRepository->getForSubscriber($user); + foreach ($userAttributes as $userAttribute) { + $resolver->register( + name: strtoupper($userAttribute->getAttributeDefinition()->getName()), + resolver: fn(PlaceholderContext $ctx) => $this->attributeValueResolver->resolve($userAttribute) + ); + } + + return $resolver->resolve( + value: $value, + context: new PlaceholderContext( + user: $user, + format: $format, + messagePrecacheDto: $messagePrecacheDto, + forwardedBy: $forwardedBy, + messageId: $campaignId, + ) + ); + } + + private function ensureStandardPlaceholders(string $value, OutputFormat $format): string + { + if (!str_contains($value, '[FOOTER]')) { + $sep = $format === OutputFormat::Html ? '
' : "\n\n"; + $value = $this->appendContent($value, $sep . '[FOOTER]'); + } + + if (!str_contains($value, '[SIGNATURE]')) { + $sep = $format === OutputFormat::Html ? ' ' : "\n"; + $value = $this->appendContent($value, $sep . '[SIGNATURE]'); + } + + if ($this->alwaysAddUserTrack && $format === OutputFormat::Html && !str_contains($value, '[USERTRACK]')) { + $value = $this->appendContent($value, ' ' . '[USERTRACK]'); + } + + return $value; + } + + private function appendContent(string $message, string $append): string + { + if (preg_match('##i', $message)) { + $message = preg_replace('##i', $append . '', $message); + } else { + $message .= $append; + } + + return $message; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php b/src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php new file mode 100644 index 00000000..6d9afe9e --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php @@ -0,0 +1,39 @@ +config->getValue(ConfigOption::BlacklistUrl); + if (empty($base)) { + return ''; + } + $url = $this->urlBuilder->withEmail($base, $ctx->getUser()->getEmail()); + + if ($ctx->isHtml()) { + return htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + return $url; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php b/src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php new file mode 100644 index 00000000..c879afc3 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php @@ -0,0 +1,45 @@ +config->getValue(ConfigOption::BlacklistUrl); + if (empty($base)) { + return ''; + } + $url = $this->urlBuilder->withEmail($base, $ctx->getUser()->getEmail()); + + if ($ctx->isHtml()) { + $label = $this->translator->trans('Unsubscribe'); + $safeLabel = htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $safeUrl = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return '' . $safeLabel . ''; + } + + return $url; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php new file mode 100644 index 00000000..930b3d6c --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php @@ -0,0 +1,36 @@ +config->getValue(ConfigOption::ConfirmationUrl); + if (empty($url)) { + return ''; + } + $sep = !str_contains($url, '?') ? '?' : '&'; + + if ($ctx->isHtml()) { + return sprintf('%s%suid=%s', $url, htmlspecialchars($sep), $ctx->getUser()->getUniqueId()); + } + + return sprintf('%s%suid=%s', $url, $sep, $ctx->getUser()->getUniqueId()); + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/ContactUrlValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ContactUrlValueResolver.php new file mode 100644 index 00000000..8fbe5dec --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ContactUrlValueResolver.php @@ -0,0 +1,30 @@ +isText()) { + return $this->config->getValue(ConfigOption::VCardUrl) ?? ''; + } + + return htmlspecialchars($this->config->getValue(ConfigOption::VCardUrl) ?? ''); + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/ContactValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ContactValueResolver.php new file mode 100644 index 00000000..12b07fba --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ContactValueResolver.php @@ -0,0 +1,42 @@ +config->getValue(ConfigOption::VCardUrl); + if (empty($url)) { + return ''; + } + $label = $this->translator->trans('Add us to your address book'); + + if ($ctx->isHtml()) { + $href = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $text = htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return sprintf('%s', $href, $text); + } + + return $label !== '' ? ($label . ': ' . $url) : $url; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php b/src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php new file mode 100644 index 00000000..fd1e9d4b --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php @@ -0,0 +1,33 @@ +forwardAlternativeContent && $ctx->messagePrecacheDto) { + return stripslashes($ctx->messagePrecacheDto->footer); + } + + return $this->config->getValue(ConfigOption::ForwardFooter) ?? ''; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolver.php new file mode 100644 index 00000000..3b8082c7 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolver.php @@ -0,0 +1,72 @@ +translator->trans('This link'); + + if (str_contains($newForward, ':')) { + [$forwardMessage, $label] = explode(':', $newForward, 2); + } else { + $forwardMessage = $newForward; + } + + $forwardMessage = trim($forwardMessage); + if ($forwardMessage === '') { + return ''; + } + + $messageId = (int) $forwardMessage; + + $url = $this->config->getValue(ConfigOption::ForwardUrl); + if (empty($url)) { + return ''; + } + + $sep = !str_contains($url, '?') ? '?' : '&'; + $uid = $ctx->getUser()->getUniqueId(); + + if ($ctx->isHtml()) { + $forwardUrl = sprintf( + '%s%suid=%s&mid=%d', + htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + htmlspecialchars($sep, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + $uid, + $messageId + ); + + return sprintf( + '%s', + $forwardUrl, + htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + ); + } + + $forwardUrl = sprintf('%s%suid=%s&mid=%d', $url, $sep, $uid, $messageId); + + return $label . ' ' . $forwardUrl; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php new file mode 100644 index 00000000..4eb628be --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php @@ -0,0 +1,42 @@ +config->getValue(ConfigOption::ForwardUrl); + if (empty($url)) { + return ''; + } + $sep = !str_contains($url, '?') ? '?' : '&'; + + if ($ctx->isHtml()) { + return sprintf( + '%s%suid=%s&mid=%d', + htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + htmlspecialchars($sep), + $ctx->getUser()->getUniqueId(), + $ctx->messageId(), + ); + } + + return sprintf('%s%suid=%s&mid=%d', $url, $sep, $ctx->getUser()->getUniqueId(), $ctx->messageId()); + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php new file mode 100644 index 00000000..9de07e46 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php @@ -0,0 +1,50 @@ +config->getValue(ConfigOption::ForwardUrl); + if (empty($url)) { + return ''; + } + $sep = !str_contains($url, '?') ? '?' : '&'; + + if ($ctx->isHtml()) { + $label = $this->translator->trans('This link'); + + return '' + . htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . ' '; + } + + return sprintf('%s%suid=%s&mid=%d ', $url, $sep, $ctx->getUser()->getUniqueId(), $ctx->messageId()); + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolver.php b/src/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolver.php new file mode 100644 index 00000000..44780d6b --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolver.php @@ -0,0 +1,13 @@ +config->getValue(ConfigOption::UnsubscribeUrl); + if (empty($base)) { + return ''; + } + $url = $this->urlBuilder->withUid($base, $ctx->getUser()->getUniqueId()); + + if ($ctx->isHtml()) { + return ''; + } + + return $url . '&jo=1'; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php new file mode 100644 index 00000000..5a77deb4 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php @@ -0,0 +1,48 @@ +subscriberListRepository->getActiveListNamesForSubscriber( + subscriber: $ctx->getUser(), + showPrivate: $this->showPrivateLists + ); + + if ($names === []) { + return $this->translator + ->trans('Sorry, you are not subscribed to any of our newsletters with this email address.'); + } + + $separator = $ctx->isHtml() ? '
' : "\n"; + + if ($ctx->isHtml()) { + $names = array_map( + static fn(string $name) => htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + $names + ); + } + + return implode($separator, $names); + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/PatternValueResolverInterface.php b/src/Domain/Configuration/Service/Placeholder/PatternValueResolverInterface.php new file mode 100644 index 00000000..ad170cde --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/PatternValueResolverInterface.php @@ -0,0 +1,13 @@ +config->getValue(ConfigOption::PreferencesUrl); + if (empty($url)) { + return ''; + } + $sep = !str_contains($url, '?') ? '?' : '&'; + + if ($ctx->isHtml()) { + return sprintf( + '%s%suid=%s', + htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + htmlspecialchars($sep), + $ctx->getUser()->getUniqueId(), + ); + } + + return sprintf('%s%suid=%s', $url, $sep, $ctx->getUser()->getUniqueId()); + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php b/src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php new file mode 100644 index 00000000..574fddf0 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php @@ -0,0 +1,50 @@ +config->getValue(ConfigOption::PreferencesUrl); + if (empty($url)) { + return ''; + } + $sep = !str_contains($url, '?') ? '?' : '&'; + + if ($ctx->isHtml()) { + $label = $this->translator->trans('This link'); + $safeLabel = htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $safeUrl = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return '' + . $safeLabel + . ' '; + } + + return sprintf('%s%suid=%s', $url, $sep, $ctx->getUser()->getUniqueId()); + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php b/src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php new file mode 100644 index 00000000..ebd90daa --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php @@ -0,0 +1,40 @@ +isHtml()) { + if ($this->emailTextCredits) { + return $this->config->getValue(ConfigOption::PoweredByText) ?? ''; + } + + return preg_replace( + '/src=".*power-phplist.png"/', + 'src="powerphplist.png"', + $this->config->getValue(ConfigOption::PoweredByImage) ?? '' + ); + } + + return "\n\n-- powered by phpList, www.phplist.com --\n\n"; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolver.php b/src/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolver.php new file mode 100644 index 00000000..9ab3c151 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolver.php @@ -0,0 +1,32 @@ +config->getValue(ConfigOption::SubscribeUrl) ?? ''; + + if ($ctx->isHtml()) { + return htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + return $url; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/SubscribeValueResolver.php b/src/Domain/Configuration/Service/Placeholder/SubscribeValueResolver.php new file mode 100644 index 00000000..b34ec777 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/SubscribeValueResolver.php @@ -0,0 +1,43 @@ +config->getValue(ConfigOption::SubscribeUrl); + + if (empty($url)) { + return ''; + } + + if ($ctx->isHtml()) { + $label = $this->translator->trans('This link'); + $safeLabel = htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $safeUrl = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return '' . $safeLabel . ''; + } + + return $url; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/SupportingPlaceholderResolverInterface.php b/src/Domain/Configuration/Service/Placeholder/SupportingPlaceholderResolverInterface.php new file mode 100644 index 00000000..72004acd --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/SupportingPlaceholderResolverInterface.php @@ -0,0 +1,13 @@ +config->getValue(ConfigOption::UnsubscribeUrl); + if (empty($base)) { + return ''; + } + $url = $this->urlBuilder->withUid($base, $ctx->getUser()->getUniqueId()); + + if ($ctx->isHtml()) { + return htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + return $url; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php b/src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php new file mode 100644 index 00000000..12cd359c --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php @@ -0,0 +1,49 @@ +config->getValue(ConfigOption::UnsubscribeUrl); + if ($ctx->forwardedBy()) { + //0013076: Problem found during testing: message part must be parsed correctly as well. + $base = $this->config->getValue(ConfigOption::BlacklistUrl); + } + if (empty($base)) { + return ''; + } + $url = $this->urlBuilder->withUid($base, $ctx->getUser()->getUniqueId()); + + if ($ctx->isHtml()) { + $label = $this->translator->trans('Unsubscribe'); + $safeLabel = htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $safeUrl = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return '' . $safeLabel . ''; + } + + return $url; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/UserDataSupportingResolver.php b/src/Domain/Configuration/Service/Placeholder/UserDataSupportingResolver.php new file mode 100644 index 00000000..86e97b7d --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/UserDataSupportingResolver.php @@ -0,0 +1,53 @@ +supportedKeys); + } + + public function resolve(string $key, PlaceholderContext $ctx): ?string + { + $canon = strtoupper($key); + $data = $this->subscriberRepository->getDataById($ctx->getUser()->getId()); + + foreach ($data as $k => $value) { + if (strtoupper((string) $k) !== $canon) { + continue; + } + if ($value === null || $value === '') { + return null; + } + return is_scalar($value) ? (string) $value : null; + } + return null; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php b/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php new file mode 100644 index 00000000..2181881f --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php @@ -0,0 +1,41 @@ +config->getValue(ConfigOption::Domain) ?? $this->restApiDomain; + + if ($ctx->isText() || empty($base)) { + return ''; + } + + return ''; + } +} diff --git a/src/Domain/Configuration/Service/PlaceholderResolver.php b/src/Domain/Configuration/Service/PlaceholderResolver.php index 3a0a3464..61760c22 100644 --- a/src/Domain/Configuration/Service/PlaceholderResolver.php +++ b/src/Domain/Configuration/Service/PlaceholderResolver.php @@ -4,30 +4,101 @@ namespace PhpList\Core\Domain\Configuration\Service; +use PhpList\Core\Domain\Configuration\Model\Dto\PlaceholderContext; +use PhpList\Core\Domain\Configuration\Service\Placeholder\SupportingPlaceholderResolverInterface; + class PlaceholderResolver { - /** @var array */ - private array $providers = []; + /** @var array */ + private array $resolvers = []; + + /** @var array */ + private array $patternResolvers = []; + + /** @var SupportingPlaceholderResolverInterface[] */ + private array $supportingResolvers = []; + + public function register(string $name, callable $resolver): void + { + $name = $this->normalizePlaceholderKey($name); + $this->resolvers[strtoupper($name)] = $resolver; + } - public function register(string $token, callable $provider): void + public function registerPattern(string $pattern, callable $resolver): void { - // tokens like [UNSUBSCRIBEURL] (case-insensitive) - $this->providers[strtoupper($token)] = $provider; + $this->patternResolvers[] = ['pattern' => $pattern, 'resolver' => $resolver]; } - public function resolve(?string $input): ?string + public function registerSupporting(SupportingPlaceholderResolverInterface $resolver): void { - if ($input === null || $input === '') { - return $input; + $this->supportingResolvers[] = $resolver; + } + + public function resolve(string $value, PlaceholderContext $context): string + { + if (!str_contains($value, '[')) { + return $value; + } + + foreach ($this->patternResolvers as $resolver) { + $value = preg_replace_callback( + $resolver['pattern'], + fn(array $match) => (string) ($resolver['resolver'])($context, $match), + $value + ); } - // Replace [TOKEN] (case-insensitive) - return preg_replace_callback('/\[(\w+)\]/i', function ($map) { - $key = strtoupper($map[1]); - if (!isset($this->providers[$key])) { - return $map[0]; + return preg_replace_callback( + '/\[([^\]%%]+)(?:%%([^\]]+))?\]/i', + fn(array $matches) => $this->resolveSinglePlaceholder($matches, $context), + $value + ); + } + + private function normalizePlaceholderKey(string $rawKey): string + { + $key = trim($rawKey); + $key = html_entity_decode($key, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $key = str_ireplace("\xC2\xA0", ' ', $key); + $key = str_ireplace(' ', ' ', $key); + + return preg_replace('/\s+/u', ' ', $key) ?? $key; + } + + private function resolveSinglePlaceholder(array $matches, PlaceholderContext $context): string + { + $rawKey = $matches[1]; + $default = $matches[2] ?? null; + + $keyNormalized = $this->normalizePlaceholderKey($rawKey); + $canon = strtoupper($this->normalizePlaceholderKey($rawKey)); + + // 1) Exact resolver (system placeholders) + if (isset($this->resolvers[$canon])) { + $resolved = (string) ($this->resolvers[$canon])($context); + + if ($default !== null && $resolved === '') { + return $default; + } + return $resolved; + } + + // 2) Supporting resolvers (userdata, attributes, etc.) + foreach ($this->supportingResolvers as $resolver) { + if (!$resolver->supports($keyNormalized, $context) && !$resolver->supports($canon, $context)) { + continue; } - return (string) ($this->providers[$key])(); - }, $input); + + $resolved = $resolver->resolve($keyNormalized, $context); + $resolved = $resolved ?? ''; + + if ($default !== null && $resolved === '') { + return $default; + } + return $resolved; + } + + // 3) if there is a %%default, use it; otherwise keep placeholder unchanged + return $default ?? $matches[0]; } } diff --git a/src/Domain/Configuration/Service/UserPersonalizer.php b/src/Domain/Configuration/Service/UserPersonalizer.php index 7aedf1d8..6edd90f9 100644 --- a/src/Domain/Configuration/Service/UserPersonalizer.php +++ b/src/Domain/Configuration/Service/UserPersonalizer.php @@ -5,6 +5,12 @@ namespace PhpList\Core\Domain\Configuration\Service; 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\ConfirmationUrlValueResolver; +use PhpList\Core\Domain\Configuration\Service\Placeholder\PreferencesUrlValueResolver; +use PhpList\Core\Domain\Configuration\Service\Placeholder\SubscribeUrlValueResolver; +use PhpList\Core\Domain\Configuration\Service\Placeholder\UnsubscribeUrlValueResolver; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; @@ -12,18 +18,19 @@ class UserPersonalizer { - private const PHP_SPACE = ' '; - public function __construct( private readonly ConfigProvider $config, - private readonly LegacyUrlBuilder $urlBuilder, private readonly SubscriberRepository $subscriberRepository, private readonly SubscriberAttributeValueRepository $attributesRepository, - private readonly AttributeValueResolver $attributeValueResolver + private readonly AttributeValueResolver $attributeValueResolver, + private readonly UnsubscribeUrlValueResolver $unsubscribeUrlValueResolver, + private readonly ConfirmationUrlValueResolver $confirmationUrlValueResolver, + private readonly PreferencesUrlValueResolver $preferencesUrlValueResolver, + private readonly SubscribeUrlValueResolver $subscribeUrlValueResolver, ) { } - public function personalize(string $value, string $email): string + public function personalize(string $value, string $email, OutputFormat $format): string { $user = $this->subscriberRepository->findOneByEmail($email); if (!$user) { @@ -31,39 +38,31 @@ public function personalize(string $value, string $email): string } $resolver = new PlaceholderResolver(); - $resolver->register('EMAIL', fn() => $user->getEmail()); - - $resolver->register('UNSUBSCRIBEURL', function () use ($user) { - $base = $this->config->getValue(ConfigOption::UnsubscribeUrl) ?? ''; - return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE; - }); - - $resolver->register('CONFIRMATIONURL', function () use ($user) { - $base = $this->config->getValue(ConfigOption::ConfirmationUrl) ?? ''; - return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE; - }); - $resolver->register('PREFERENCESURL', function () use ($user) { - $base = $this->config->getValue(ConfigOption::PreferencesUrl) ?? ''; - return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE; - }); - + $resolver->register('EMAIL', fn(PlaceholderContext $ctx) => $ctx->user->getEmail()); + $resolver->register($this->unsubscribeUrlValueResolver->name(), $this->unsubscribeUrlValueResolver); + $resolver->register($this->confirmationUrlValueResolver->name(), $this->confirmationUrlValueResolver); + $resolver->register($this->preferencesUrlValueResolver->name(), $this->preferencesUrlValueResolver); + $resolver->register($this->subscribeUrlValueResolver->name(), $this->subscribeUrlValueResolver); $resolver->register( - 'SUBSCRIBEURL', - fn() => ($this->config->getValue(ConfigOption::SubscribeUrl) ?? '') . self::PHP_SPACE + 'DOMAIN', + fn(PlaceholderContext $ctx) => $this->config->getValue(ConfigOption::Domain) ?? '' + ); + $resolver->register( + 'WEBSITE', + fn(PlaceholderContext $ctx) => $this->config->getValue(ConfigOption::Website) ?? '' ); - $resolver->register('DOMAIN', fn() => $this->config->getValue(ConfigOption::Domain) ?? ''); - $resolver->register('WEBSITE', fn() => $this->config->getValue(ConfigOption::Website) ?? ''); $userAttributes = $this->attributesRepository->getForSubscriber($user); foreach ($userAttributes as $userAttribute) { $resolver->register( strtoupper($userAttribute->getAttributeDefinition()->getName()), - fn() => $this->attributeValueResolver->resolve($userAttribute) + fn(PlaceholderContext $ctx) => $this->attributeValueResolver->resolve($userAttribute) ); } - $out = $resolver->resolve($value); - - return (string) $out; + return $resolver->resolve( + value: $value, + context: new PlaceholderContext(user: $user, format: $format) + ); } } diff --git a/src/Domain/Identity/Command/ImportDefaultsCommand.php b/src/Domain/Identity/Command/ImportDefaultsCommand.php index cf36e765..47ac4295 100644 --- a/src/Domain/Identity/Command/ImportDefaultsCommand.php +++ b/src/Domain/Identity/Command/ImportDefaultsCommand.php @@ -8,7 +8,7 @@ use PhpList\Core\Domain\Identity\Model\Dto\CreateAdministratorDto; use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; -use PhpList\Core\Domain\Identity\Service\AdministratorManager; +use PhpList\Core\Domain\Identity\Service\Manager\AdministratorManager; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; diff --git a/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php b/src/Domain/Identity/Service/Manager/AdminAttributeDefinitionManager.php similarity index 98% rename from src/Domain/Identity/Service/AdminAttributeDefinitionManager.php rename to src/Domain/Identity/Service/Manager/AdminAttributeDefinitionManager.php index 50a58bd0..93684b0a 100644 --- a/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php +++ b/src/Domain/Identity/Service/Manager/AdminAttributeDefinitionManager.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Identity\Service; +namespace PhpList\Core\Domain\Identity\Service\Manager; +use PhpList\Core\Domain\Identity\Exception\AttributeDefinitionCreationException; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; use PhpList\Core\Domain\Identity\Model\Dto\AdminAttributeDefinitionDto; use PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository; -use PhpList\Core\Domain\Identity\Exception\AttributeDefinitionCreationException; use PhpList\Core\Domain\Identity\Validator\AttributeTypeValidator; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/src/Domain/Identity/Service/AdminAttributeManager.php b/src/Domain/Identity/Service/Manager/AdminAttributeManager.php similarity index 97% rename from src/Domain/Identity/Service/AdminAttributeManager.php rename to src/Domain/Identity/Service/Manager/AdminAttributeManager.php index 53fa330e..ab123bce 100644 --- a/src/Domain/Identity/Service/AdminAttributeManager.php +++ b/src/Domain/Identity/Service/Manager/AdminAttributeManager.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Identity\Service; +namespace PhpList\Core\Domain\Identity\Service\Manager; +use PhpList\Core\Domain\Identity\Exception\AdminAttributeCreationException; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; use PhpList\Core\Domain\Identity\Model\AdminAttributeValue; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Repository\AdminAttributeValueRepository; -use PhpList\Core\Domain\Identity\Exception\AdminAttributeCreationException; class AdminAttributeManager { diff --git a/src/Domain/Identity/Service/AdministratorManager.php b/src/Domain/Identity/Service/Manager/AdministratorManager.php similarity index 97% rename from src/Domain/Identity/Service/AdministratorManager.php rename to src/Domain/Identity/Service/Manager/AdministratorManager.php index 814557a1..940eaa42 100644 --- a/src/Domain/Identity/Service/AdministratorManager.php +++ b/src/Domain/Identity/Service/Manager/AdministratorManager.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Identity\Service; +namespace PhpList\Core\Domain\Identity\Service\Manager; use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Identity\Model\Administrator; diff --git a/src/Domain/Identity/Service/PasswordManager.php b/src/Domain/Identity/Service/Manager/PasswordManager.php similarity index 98% rename from src/Domain/Identity/Service/PasswordManager.php rename to src/Domain/Identity/Service/Manager/PasswordManager.php index 35d5d1ff..01f9bb7d 100644 --- a/src/Domain/Identity/Service/PasswordManager.php +++ b/src/Domain/Identity/Service/Manager/PasswordManager.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Identity\Service; +namespace PhpList\Core\Domain\Identity\Service\Manager; use DateTime; -use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest; use PhpList\Core\Domain\Identity\Model\Administrator; -use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; +use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; +use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; use PhpList\Core\Domain\Messaging\Message\PasswordResetMessage; use PhpList\Core\Security\HashGenerator; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; diff --git a/src/Domain/Identity/Service/SessionManager.php b/src/Domain/Identity/Service/Manager/SessionManager.php similarity index 97% rename from src/Domain/Identity/Service/SessionManager.php rename to src/Domain/Identity/Service/Manager/SessionManager.php index cfeaa706..6e10007d 100644 --- a/src/Domain/Identity/Service/SessionManager.php +++ b/src/Domain/Identity/Service/Manager/SessionManager.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Identity\Service; +namespace PhpList\Core\Domain\Identity\Service\Manager; -use Symfony\Contracts\Translation\TranslatorInterface; use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; +use Symfony\Contracts\Translation\TranslatorInterface; class SessionManager { diff --git a/src/Domain/Messaging/Exception/AttachmentCopyException.php b/src/Domain/Messaging/Exception/AttachmentCopyException.php new file mode 100644 index 00000000..dd56d73d --- /dev/null +++ b/src/Domain/Messaging/Exception/AttachmentCopyException.php @@ -0,0 +1,12 @@ +emailService = $emailService; } /** diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php index 35b4c841..36490e84 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php @@ -8,7 +8,9 @@ use DateTimeImmutable; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; -use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; +use PhpList\Core\Domain\Configuration\Model\ConfigOption; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; +use PhpList\Core\Domain\Messaging\Exception\AttachmentCopyException; use PhpList\Core\Domain\Messaging\Exception\MessageCacheMissingException; use PhpList\Core\Domain\Messaging\Exception\MessageSizeLimitExceededException; use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage; @@ -22,6 +24,7 @@ use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; use PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder; +use PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder; use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; use PhpList\Core\Domain\Messaging\Service\MailSizeChecker; use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; @@ -34,6 +37,7 @@ use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; @@ -63,11 +67,12 @@ public function __construct( private readonly SubscriberHistoryManager $subscriberHistoryManager, private readonly MessageRepository $messageRepository, private readonly MessagePrecacheService $precacheService, - private readonly UserPersonalizer $userPersonalizer, private readonly MessageDataLoader $messageDataLoader, - private readonly EmailBuilder $emailBuilder, + private readonly SystemEmailBuilder $systemEmailBuilder, + private readonly EmailBuilder $campaignEmailBuilder, private readonly MailSizeChecker $mailSizeChecker, - private readonly string $messageEnvelope, + private readonly ConfigProvider $configProvider, + #[Autowire('%imap_bounce.email%')] private readonly string $bounceEmail, ) { } @@ -194,27 +199,60 @@ private function handleEmailSending( UserMessage $userMessage, MessagePrecacheDto $precachedContent, ): void { + // todo: check at which point link tracking should be applied (maybe after constructing ful text?) $processed = $this->messagePreparator->processMessageLinks( - $campaign->getId(), - $precachedContent, - $subscriber + campaignId: $campaign->getId(), + cachedMessageDto: $precachedContent, + subscriber: $subscriber ); - $processed->textContent = $this->userPersonalizer->personalize( - $processed->textContent, - $subscriber->getEmail(), - ); - $processed->footer = $this->userPersonalizer->personalize($processed->footer, $subscriber->getEmail()); try { - $email = $this->rateLimitedCampaignMailer->composeEmail($campaign, $subscriber, $processed); - $this->mailer->send($email); + $result = $this->campaignEmailBuilder->buildPhplistEmail( + messageId: $campaign->getId(), + data: $processed, + skipBlacklistCheck: false, + inBlast: true, + htmlPref: $subscriber->hasHtmlEmail(), + ); + if ($result === null) { + return; + } + [$email, $sentAs] = $result; + $email = $this->campaignEmailBuilder->applyCampaignHeaders(email: $email, subscriber: $subscriber); + + $this->rateLimitedCampaignMailer->send($email); ($this->mailSizeChecker)($campaign, $email, $subscriber->hasHtmlEmail()); $this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent); + $campaign->incrementSentCount($sentAs); } 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 (AttachmentCopyException $e) { + // stop after the first message if size is exceeded + $this->updateMessageStatus($campaign, MessageStatus::Suspended); + $this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent); + + $data = new MessagePrecacheDto(); + $data->to = $this->configProvider->getValue(ConfigOption::ReportAddress); + $data->subject = $this->translator->trans('phpList system error'); + $data->content = $this->translator->trans($e->getMessage()); + + $email = $this->systemEmailBuilder->buildPhplistEmail( + messageId: $campaign->getId(), + data: $data, + inBlast: false, + htmlPref: true, + ); + + $envelope = new Envelope( + sender: new Address($this->bounceEmail, 'PHPList'), + recipients: [new Address($email->getTo()[0]->getAddress())], + ); + $this->mailer->send(message: $email, envelope: $envelope); + throw $e; } catch (Throwable $e) { $this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent); @@ -233,15 +271,19 @@ private function handleAdminNotifications(Message $campaign, array $loadedMessag if (!empty($loadedMessageData['notify_start']) && !isset($loadedMessageData['start_notified'])) { $notifications = explode(',', $loadedMessageData['notify_start']); foreach ($notifications as $notification) { - $email = $this->emailBuilder->buildPhplistEmail( + $data = new MessagePrecacheDto(); + $data->to = $notification; + $data->subject = $this->translator->trans('Campaign started'); + $data->content = $this->translator->trans( + 'phplist has started sending the campaign with subject %subject%', + ['%subject%' => $loadedMessageData['subject']] + ); + + $email = $this->systemEmailBuilder->buildPhplistEmail( messageId: $campaign->getId(), - to: $notification, - subject: $this->translator->trans('Campaign started'), - message: $this->translator->trans( - 'phplist has started sending the campaign with subject %subject%', - ['%subject%' => $loadedMessageData['subject']] - ), + data: $data, inBlast: false, + htmlPref: true, ); if (!$email) { @@ -250,7 +292,7 @@ private function handleAdminNotifications(Message $campaign, array $loadedMessag // todo: check if from name should be from config $envelope = new Envelope( - sender: new Address($this->messageEnvelope, 'PHPList'), + sender: new Address($this->bounceEmail, 'PHPList'), recipients: [new Address($email->getTo()[0]->getAddress())], ); $this->mailer->send(message: $email, envelope: $envelope); @@ -301,6 +343,7 @@ private function processSubscribersForCampaign(Message $campaign, array $subscri if ($messagePrecacheDto === null) { throw new MessageCacheMissingException(); } + // todo: maybe catch exception and return false to stop early? $this->handleEmailSending($campaign, $subscriber, $userMessage, $messagePrecacheDto); } diff --git a/src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php b/src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php index 7d2a3096..a364d013 100644 --- a/src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php @@ -6,6 +6,7 @@ use PhpList\Core\Domain\Messaging\Message\PasswordResetMessage; use PhpList\Core\Domain\Messaging\Service\EmailService; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Mime\Email; use Symfony\Contracts\Translation\TranslatorInterface; @@ -13,15 +14,11 @@ #[AsMessageHandler] class PasswordResetMessageHandler { - private EmailService $emailService; - private TranslatorInterface $translator; - private string $passwordResetUrl; - - public function __construct(EmailService $emailService, TranslatorInterface $translator, string $passwordResetUrl) - { - $this->emailService = $emailService; - $this->translator = $translator; - $this->passwordResetUrl = $passwordResetUrl; + public function __construct( + private readonly EmailService $emailService, + private readonly TranslatorInterface $translator, + #[Autowire('%app.password_reset_url%')] private readonly string $passwordResetUrl + ) { } /** diff --git a/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php b/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php index 69ec42cb..d07519a8 100644 --- a/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php @@ -6,6 +6,7 @@ use PhpList\Core\Domain\Messaging\Message\SubscriberConfirmationMessage; use PhpList\Core\Domain\Messaging\Service\EmailService; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Mime\Email; use Symfony\Contracts\Translation\TranslatorInterface; @@ -16,15 +17,11 @@ #[AsMessageHandler] class SubscriberConfirmationMessageHandler { - private EmailService $emailService; - private TranslatorInterface $translator; - private string $confirmationUrl; - - public function __construct(EmailService $emailService, TranslatorInterface $translator, string $confirmationUrl) - { - $this->emailService = $emailService; - $this->translator = $translator; - $this->confirmationUrl = $confirmationUrl; + public function __construct( + private readonly EmailService $emailService, + private readonly TranslatorInterface $translator, + #[Autowire('%app.confirmation_url%')]private readonly string $confirmationUrl + ) { } /** diff --git a/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php b/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php index 6ecb965b..e859c401 100644 --- a/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Domain\Messaging\MessageHandler; use PhpList\Core\Domain\Configuration\Model\ConfigOption; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; use PhpList\Core\Domain\Messaging\Message\SubscriptionConfirmationMessage; @@ -20,24 +21,13 @@ #[AsMessageHandler] class SubscriptionConfirmationMessageHandler { - private EmailService $emailService; - private ConfigProvider $configProvider; - private LoggerInterface $logger; - private UserPersonalizer $userPersonalizer; - private SubscriberListRepository $subscriberListRepository; - public function __construct( - EmailService $emailService, - ConfigProvider $configProvider, - LoggerInterface $logger, - UserPersonalizer $userPersonalizer, - SubscriberListRepository $subscriberListRepository, + private readonly EmailService $emailService, + private readonly ConfigProvider $configProvider, + private readonly LoggerInterface $logger, + private readonly UserPersonalizer $userPersonalizer, + private readonly SubscriberListRepository $subscriberListRepository, ) { - $this->emailService = $emailService; - $this->configProvider = $configProvider; - $this->logger = $logger; - $this->userPersonalizer = $userPersonalizer; - $this->subscriberListRepository = $subscriberListRepository; } /** @@ -47,14 +37,25 @@ public function __invoke(SubscriptionConfirmationMessage $message): void { $subject = $this->configProvider->getValue(ConfigOption::SubscribeEmailSubject); $textContent = $this->configProvider->getValue(ConfigOption::SubscribeMessage); - $personalizedTextContent = $this->userPersonalizer->personalize($textContent, $message->getUniqueId()); + + if (empty($subject) || empty($textContent)) { + $this->logger->error('Subscription email configuration is missing. Email not sent.'); + return; + } + + $personalizedTextContent = $this->userPersonalizer->personalize( + value: $textContent, + email: $message->getEmail(), + format: OutputFormat::Text, + ); + $listOfLists = $this->getListNames($message->getListIds()); - $replacedTextContent = str_replace('[LISTS]', $listOfLists, $personalizedTextContent); + $personalizedTextContent = str_replace('[LISTS]', $listOfLists, $personalizedTextContent); $email = (new Email()) ->to($message->getEmail()) ->subject($subject) - ->text($replacedTextContent); + ->text($personalizedTextContent); $this->emailService->sendEmail($email); @@ -63,14 +64,14 @@ public function __invoke(SubscriptionConfirmationMessage $message): void private function getListNames(array $listIds): string { - $listNames = []; - foreach ($listIds as $id) { - $list = $this->subscriberListRepository->find($id); - if ($list) { - $listNames[] = $list->getName(); + $names = []; + foreach ($listIds as $listId) { + $list = $this->subscriberListRepository->find($listId); + if ($list !== null) { + $names[] = $list->getName(); } } - return implode(', ', $listNames); + return implode(', ', $names); } } diff --git a/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php b/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php index 10fdaf93..9a25e76f 100644 --- a/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php +++ b/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php @@ -13,7 +13,7 @@ class MessagePrecacheDto public ?string $fromEmail = null; public ?string $to = null; public string $subject = ''; - public ?string $content = null; + public string $content = ''; public string $textContent = ''; public string $footer = ''; public ?string $textFooter = null; diff --git a/src/Domain/Messaging/Model/Message.php b/src/Domain/Messaging/Model/Message.php index 90f5036c..00b41897 100644 --- a/src/Domain/Messaging/Model/Message.php +++ b/src/Domain/Messaging/Model/Message.php @@ -5,7 +5,6 @@ namespace PhpList\Core\Domain\Messaging\Model; use DateTime; -use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -13,6 +12,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\Identity; use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat; @@ -194,4 +194,15 @@ public function getListMessages(): Collection { return $this->listMessages; } + + public function incrementSentCount(OutputFormat $sentAs): void + { + match ($sentAs) { + OutputFormat::Html => $this->format->incrementAsHtml(), + OutputFormat::Text => $this->format->incrementAsText(), + OutputFormat::Pdf => $this->format->incrementAsPdf(), + OutputFormat::TextAndHtml => $this->format->incrementAsTextAndHtml(), + OutputFormat::TextAndPdf => $this->format->incrementAsTextAndPdf(), + }; + } } diff --git a/src/Domain/Messaging/Model/Message/MessageFormat.php b/src/Domain/Messaging/Model/Message/MessageFormat.php index 5deedefb..2efcfcc8 100644 --- a/src/Domain/Messaging/Model/Message/MessageFormat.php +++ b/src/Domain/Messaging/Model/Message/MessageFormat.php @@ -5,7 +5,6 @@ namespace PhpList\Core\Domain\Messaging\Model\Message; use Doctrine\ORM\Mapping as ORM; -use InvalidArgumentException; use PhpList\Core\Domain\Common\Model\Interfaces\EmbeddableInterface; #[ORM\Embeddable] @@ -17,20 +16,20 @@ class MessageFormat implements EmbeddableInterface #[ORM\Column(name: 'sendformat', type: 'string', length: 20, nullable: true)] private ?string $sendFormat = null; - #[ORM\Column(name: 'astext', type: 'boolean')] - private bool $asText = false; + #[ORM\Column(name: 'astext', type: 'integer')] + private int $asText = 0; - #[ORM\Column(name: 'ashtml', type: 'boolean')] - private bool $asHtml = false; + #[ORM\Column(name: 'ashtml', type: 'integer')] + private int $asHtml = 0; - #[ORM\Column(name: 'aspdf', type: 'boolean')] - private bool $asPdf = false; + #[ORM\Column(name: 'aspdf', type: 'integer')] + private int $asPdf = 0; - #[ORM\Column(name: 'astextandhtml', type: 'boolean')] - private bool $asTextAndHtml = false; + #[ORM\Column(name: 'astextandhtml', type: 'integer')] + private int $asTextAndHtml = 0; - #[ORM\Column(name: 'astextandpdf', type: 'boolean')] - private bool $asTextAndPdf = false; + #[ORM\Column(name: 'astextandpdf', type: 'integer')] + private int $asTextAndPdf = 0; public const FORMAT_TEXT = 'text'; public const FORMAT_HTML = 'html'; @@ -39,12 +38,9 @@ class MessageFormat implements EmbeddableInterface public function __construct( bool $htmlFormatted, ?string $sendFormat, - array $formatOptions = [] ) { $this->htmlFormatted = $htmlFormatted; $this->sendFormat = $sendFormat; - - $this->setFormatOptions($formatOptions); } public function isHtmlFormatted(): bool @@ -69,31 +65,56 @@ public function setSendFormat(?string $sendFormat): self return $this; } - public function isAsText(): bool + public function getAsText(): int { return $this->asText; } - public function isAsHtml(): bool + public function getAsHtml(): int { return $this->asHtml; } - public function isAsTextAndHtml(): bool + public function getAsTextAndHtml(): int { return $this->asTextAndHtml; } - public function isAsPdf(): bool + public function getAsPdf(): int { return $this->asPdf; } - public function isAsTextAndPdf(): bool + public function getAsTextAndPdf(): int { return $this->asTextAndPdf; } + public function incrementAsText(): void + { + $this->asText++; + } + + public function incrementAsHtml(): void + { + $this->asHtml++; + } + + public function incrementAsTextAndHtml(): void + { + $this->asTextAndHtml++; + } + + public function incrementAsPdf(): void + { + $this->asPdf++; + } + + public function incrementAsTextAndPdf(): void + { + $this->asTextAndPdf++; + } + public function getFormatOptions(): array { return array_values(array_filter([ @@ -102,21 +123,4 @@ public function getFormatOptions(): array $this->asPdf ? self::FORMAT_PDF : null, ])); } - - public function setFormatOptions(array $formatOptions): self - { - foreach ($formatOptions as $option) { - match ($option) { - self::FORMAT_TEXT => $this->asText = true, - self::FORMAT_HTML => $this->asHtml = true, - self::FORMAT_PDF => $this->asPdf = true, - default => throw new InvalidArgumentException('Invalid format option: ' . $option) - }; - } - - $this->asTextAndHtml = $this->asText && $this->asHtml; - $this->asTextAndPdf = $this->asText && $this->asPdf; - - return $this; - } } diff --git a/src/Domain/Messaging/Repository/AttachmentRepository.php b/src/Domain/Messaging/Repository/AttachmentRepository.php index 393c8cb1..368ccee0 100644 --- a/src/Domain/Messaging/Repository/AttachmentRepository.php +++ b/src/Domain/Messaging/Repository/AttachmentRepository.php @@ -7,8 +7,31 @@ 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\Attachment; +use PhpList\Core\Domain\Messaging\Model\MessageAttachment; class AttachmentRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + /** + * @return Attachment[] + */ + public function findAttachmentsForMessage(int $messageId): array + { + return $this->getEntityManager() + ->createQueryBuilder() + ->select('a') + ->from(Attachment::class, 'a') + ->innerJoin( + MessageAttachment::class, + 'ma', + 'WITH', + 'ma.attachmentId = a.id' + ) + ->where('ma.messageId = :messageId') + ->setParameter('messageId', $messageId) + ->getQuery() + ->getResult(); + } } diff --git a/src/Domain/Messaging/Service/AttachmentAdder.php b/src/Domain/Messaging/Service/AttachmentAdder.php new file mode 100644 index 00000000..30fb3ea9 --- /dev/null +++ b/src/Domain/Messaging/Service/AttachmentAdder.php @@ -0,0 +1,248 @@ +attachmentRepository->findAttachmentsForMessage($campaignId); + + if (empty($attachments)) { + return true; + } + + if ($format === OutputFormat::Text) { + $this->prependTextAttachmentNotice($email); + } + + $totalSize = 0; + $memoryLimit = $this->getMemoryLimit(); + + foreach ($attachments as $att) { + $totalSize += $att->getSize(); + if (!$this->hasMemoryForAttachment($totalSize, $memoryLimit, $campaignId)) { + return false; + } + + switch ($format) { + case OutputFormat::Html: + if (!$this->handleHtmlAttachment($email, $att, $campaignId)) { + return false; + } + break; + + case OutputFormat::Text: + $userEmail = $email->getTo()[0]->getAddress(); + // todo: add endpoint in rest-api project + $viewUrl = $this->attachmentDownloadUrl . '/?id=' . $att->getId() . '&uid=' . $userEmail; + + $email->text( + $email->getTextBody() + . $att->getDescription() . "\n" + . $this->translator->trans('Location') . ': ' . $viewUrl . "\n\n" + ); + break; + } + } + + return true; + } + + private function getMemoryLimit(): int + { + $val = ini_get('memory_limit'); + sscanf($val, '%f%c', $number, $unit); + + return (int)($number * match (strtolower($unit ?? '')) { + 'g' => 1024 ** 3, + 'm' => 1024 ** 2, + 'k' => 1024, + default => 1, + }); + } + + private function prependTextAttachmentNotice(Email $email): void + { + $pre = $this->translator->trans('This message contains attachments that can be viewed with a webbrowser'); + $email->text($email->getTextBody() . $pre . ":\n"); + } + + private function hasMemoryForAttachment(?int $totalSize, int $memoryLimit, int $campaignId): bool + { + // the 3 is roughly the size increase to encode the string + if ($memoryLimit > 0 && (3 * $totalSize) > $memoryLimit) { + $this->eventLogManager->log( + '', + $this->translator->trans( + 'Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%', + [ + '%campaignId%' => $campaignId, + '%totalSize%' => $totalSize, + '%memLimit%' => $memoryLimit + ] + ) + ); + + return false; + } + + return true; + } + + private function handleHtmlAttachment(Email $email, Attachment $att, int $campaignId): bool + { + $key = 'attaching_fail:' . sha1($campaignId . '|' . $att->getRemoteFile()); + if ($this->attachFromRepository($email, $att)) { + return true; + } + + if ($this->fileHelper->isValidFile($att->getRemoteFile())) { + return $this->handleLocalAttachment($email, $att, $campaignId, $key); + } + + $this->handleMissingAttachment($att, $campaignId, $key); + + return false; + } + + private function attachFromRepository(Email $email, Attachment $att): bool + { + $attachmentPath = $this->attachmentRepositoryPath . '/' . $att->getFilename(); + + if (!$this->fileHelper->isValidFile($attachmentPath)) { + return false; + } + + $contents = $this->fileHelper->readFileContents($attachmentPath); + if ($contents === null) { + return false; + } + + $email->attach($contents, basename($att->getRemoteFile()), $att->getMimeType()); + + return true; + } + + private function handleLocalAttachment(Email $email, Attachment $att, int $campaignId, string $key): bool + { + $remoteFile = $att->getRemoteFile(); + $contents = $this->fileHelper->readFileContents($remoteFile); + + if ($contents === null) { + $this->eventLogManager->log( + page: '', + entry: $this->translator->trans( + 'failed to open attachment (%remoteFile%) to add to campaign %campaignId%', + [ + '%remoteFile%' => $remoteFile, + '%campaignId%' => $campaignId, + ] + ) + ); + + return false; + } + + $email->attach($contents, basename($remoteFile), $att->getMimeType()); + $this->copyAttachmentToRepository($att, $contents, $campaignId, $key); + + return true; + } + + private function copyAttachmentToRepository(Attachment $att, string $contents, int $campaignId, string $key): void + { + $remoteFile = $att->getRemoteFile(); + if ($remoteFile === null) { + return; + } + + $relativeName = $this->fileHelper->writeFileToDirectory( + directory: $this->attachmentRepositoryPath, + originalFilename: $remoteFile, + contents: $contents + ); + + if ($relativeName === null) { + $this->handleCopyFailure($remoteFile, $campaignId, $key); + return; + } + + $att->setFilename($relativeName); + } + + private function handleCopyFailure(string $remoteFile, int $campaignId, string $key): void + { + if ($this->onceCacheGuard->firstTime($key, 3600)) { + $this->eventLogManager->log( + page: '', + entry: 'Unable to make a copy of attachment ' . $remoteFile . ' in repository' + ); + + $errorMessage = $this->translator->trans( + 'Error, when trying to send campaign %campaignId% the attachment (%remoteFile%)' + . ' could not be copied to the repository. Check for permissions.', + [ + '%campaignId%' => $campaignId, + '%remoteFile%' => $remoteFile, + ] + ); + + throw new AttachmentCopyException($errorMessage); + } + + // Not the first time => silently allow send to continue + } + + private function handleMissingAttachment(Attachment $att, int $campaignId, string $key): void + { + $remoteFile = $att->getRemoteFile(); + + if ($this->onceCacheGuard->firstTime($key, 3600)) { + $this->eventLogManager->log( + page: '', + entry: $this->translator->trans( + 'Attachment %remoteFile% does not exist', + [ + '%remoteFile%' => $remoteFile, + ] + ) + ); + + $errorMessage = $this->translator->trans( + 'Error, when trying to send campaign %campaignId% the attachment (%remoteFile%)' + . ' could not be found in the repository.', + [ + '%campaignId%' => $campaignId, + '%remoteFile%' => $remoteFile, + ] + ); + + throw new AttachmentCopyException($errorMessage); + } + } +} diff --git a/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php b/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php new file mode 100644 index 00000000..a674cf30 --- /dev/null +++ b/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php @@ -0,0 +1,154 @@ +eventLogManager->log('', sprintf('Error: empty To: in message with subject %s to send', $subject)); + + return false; + } + if (!$subject || trim($subject) === '') { + $this->eventLogManager->log('', sprintf('Error: empty Subject: in message to send to %s', $to)); + + return false; + } + if (preg_match("/\n/", $to)) { + $this->eventLogManager->log('', 'Error: invalid recipient, containing newlines, email blocked'); + + return false; + } + if (preg_match("/\n/", $subject)) { + $this->eventLogManager->log('', 'Error: invalid subject, containing newlines, email blocked'); + + return false; + } + + return true; + } + + protected function passesBlacklistCheck(string $to, ?bool $skipBlacklistCheck): bool + { + + if (!$skipBlacklistCheck && $this->blacklistRepository->isEmailBlacklisted($to)) { + $this->eventLogManager->log('', sprintf('Error, %s is blacklisted, not sending', $to)); + $subscriber = $this->subscriberRepository->findOneByEmail($to); + if (!$subscriber) { + $this->logger->error('Error: subscriber not found', ['email' => $to]); + + return false; + } + $subscriber->setBlacklisted(true); + + $this->subscriberHistoryManager->addHistory( + subscriber: $subscriber, + message: 'Marked Blacklisted', + details: 'Found user in blacklist while trying to send an email, marked black listed', + ); + + return false; + } + + return true; + } + + protected function resolveDestinationEmail(?string $to): string + { + $destinationEmail = $to; + + if ($this->devVersion) { + if (!$this->devEmail) { + throw new DevEmailNotConfiguredException(); + } + $destinationEmail = $this->devEmail; + } + + return $destinationEmail; + } + + protected function createBaseEmail( + int $messageId, + string $originalTo, + ?string $fromEmail, + ?string $fromName, + ?string $subject, + ?bool $inBlast + ) : Email { + $email = (new Email()); + $destinationEmail = $this->resolveDestinationEmail($originalTo); + + $email->getHeaders()->addTextHeader('X-MessageID', (string)$messageId); + $email->getHeaders()->addTextHeader('X-ListMember', $destinationEmail); + if ($this->googleSenderId !== '') { + $email->getHeaders()->addTextHeader('Feedback-ID', sprintf('%s:%s', $messageId, $this->googleSenderId)); + } + + if (!$this->useAmazonSes && $this->usePrecedenceHeader) { + $email->getHeaders()->addTextHeader('Precedence', 'bulk'); + } + + if ($inBlast) { + $email->getHeaders()->addTextHeader('X-Blast', '1'); + } + + $removeUrl = $this->configProvider->getValue(ConfigOption::UnsubscribeUrl); + $sep = !str_contains($removeUrl, '?') ? '?' : '&'; + $email->getHeaders()->addTextHeader( + 'List-Unsubscribe', + sprintf( + '<%s%s%s>', + $removeUrl, + $sep, + http_build_query([ + 'email' => $destinationEmail, + 'jo' => 1, + ]) + ) + ); + + if ($this->devEmail && $destinationEmail === $this->devEmail && $originalTo !== $this->devEmail) { + $email->getHeaders()->addMailboxHeader( + 'X-Originally-To', + new Address($originalTo) + ); + } + + $email->to($destinationEmail); + $email->from(new Address($fromEmail, $fromName ?? '')); + $email->subject($subject); + + return $email; + } +} diff --git a/src/Domain/Messaging/Service/Builder/EmailBuilder.php b/src/Domain/Messaging/Service/Builder/EmailBuilder.php index 87bb9c8f..3c27e583 100644 --- a/src/Domain/Messaging/Service/Builder/EmailBuilder.php +++ b/src/Domain/Messaging/Service/Builder/EmailBuilder.php @@ -4,211 +4,264 @@ namespace PhpList\Core\Domain\Messaging\Service\Builder; +use PhpList\Core\Domain\Common\PdfGenerator; use PhpList\Core\Domain\Configuration\Model\ConfigOption; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; +use PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder; use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; -use PhpList\Core\Domain\Messaging\Exception\DevEmailNotConfiguredException; -use PhpList\Core\Domain\Messaging\Service\SystemMailConstructor; +use PhpList\Core\Domain\Messaging\Exception\AttachmentException; +use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; +use PhpList\Core\Domain\Messaging\Service\AttachmentAdder; +use PhpList\Core\Domain\Messaging\Service\Constructor\MailContentBuilderInterface; use PhpList\Core\Domain\Messaging\Service\TemplateImageEmbedder; +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\SubscriberHistoryManager; use Psr\Log\LoggerInterface; +use Symfony\Component\Mailer\Exception\LogicException; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; +use Symfony\Contracts\Translation\TranslatorInterface; -/** @SuppressWarnings("ExcessiveParameterList") */ -class EmailBuilder +/** @SuppressWarnings("ExcessiveParameterList") @SuppressWarnings("PHPMD.CouplingBetweenObjects") */ +class EmailBuilder extends BaseEmailBuilder { public function __construct( - private readonly ConfigProvider $configProvider, - private readonly EventLogManager $eventLogManager, - private readonly UserBlacklistRepository $blacklistRepository, - private readonly SubscriberHistoryManager $subscriberHistoryManager, - private readonly SubscriberRepository $subscriberRepository, - private readonly SystemMailConstructor $systemMailConstructor, + ConfigProvider $configProvider, + EventLogManager $eventLogManager, + UserBlacklistRepository $blacklistRepository, + SubscriberHistoryManager $subscriberHistoryManager, + SubscriberRepository $subscriberRepository, + LoggerInterface $logger, + private readonly MailContentBuilderInterface $mailConstructor, private readonly TemplateImageEmbedder $templateImageEmbedder, - private readonly LoggerInterface $logger, - private readonly string $googleSenderId, - private readonly bool $useAmazonSes, - private readonly bool $usePrecedenceHeader, - private readonly bool $devVersion = true, - private readonly ?string $devEmail = null, + private readonly LegacyUrlBuilder $urlBuilder, + private readonly PdfGenerator $pdfGenerator, + private readonly AttachmentAdder $attachmentAdder, + private readonly TranslatorInterface $translator, + string $googleSenderId, + bool $useAmazonSes, + bool $usePrecedenceHeader, + bool $devVersion = true, + ?string $devEmail = null, ) { + parent::__construct( + configProvider: $configProvider, + eventLogManager: $eventLogManager, + blacklistRepository: $blacklistRepository, + subscriberHistoryManager: $subscriberHistoryManager, + subscriberRepository: $subscriberRepository, + logger: $logger, + googleSenderId: $googleSenderId, + useAmazonSes: $useAmazonSes, + usePrecedenceHeader: $usePrecedenceHeader, + devVersion: $devVersion, + devEmail: $devEmail, + ); } public function buildPhplistEmail( int $messageId, - ?string $to = null, - ?string $subject = null, - ?string $message = null, + MessagePrecacheDto $data, ?bool $skipBlacklistCheck = false, ?bool $inBlast = true, - ): ?Email { - if (!$this->validateRecipientAndSubject($to, $subject)) { + ?bool $htmlPref = false, + ?bool $isTestMail = false, + ): ?array { + if (!$this->validateRecipientAndSubject(to: $data->to, subject: $data->subject)) { return null; } - if (!$this->passesBlacklistCheck($to, $skipBlacklistCheck)) { + if (!$this->passesBlacklistCheck(to: $data->to, skipBlacklistCheck: $skipBlacklistCheck)) { return null; } - $fromEmail = $this->configProvider->getValue(ConfigOption::MessageFromAddress); - $fromName = $this->configProvider->getValue(ConfigOption::MessageFromName); -// $messageReplyToAddress = $this->configProvider->getValue(ConfigOption::MessageReplyToAddress); -// $replyTo = $messageReplyToAddress ?: $fromEmail; - - [$destinationEmail, $message] = $this->resolveDestinationEmailAndMessage($to, $message); + $fromEmail = $data->fromEmail; + $fromName = $data->fromName; + $subject = (!$isTestMail ? '' : $this->translator->trans('(test)') . ' ') . $data->subject; - [$htmlMessage, $textMessage] = ($this->systemMailConstructor)($message, $subject); + [$htmlMessage, $textMessage] = ($this->mailConstructor)(messagePrecacheDto: $data); $email = $this->createBaseEmail( - $messageId, - $destinationEmail, - $fromEmail, - $fromName, - $subject, - $inBlast + messageId: $messageId, + originalTo: $data->to, + fromEmail: $fromEmail, + fromName: $fromName, + subject: $subject, + inBlast: $inBlast ); - $this->applyContentAndFormatting($email, $htmlMessage, $textMessage, $messageId); + if (!empty($data->replyToEmail)) { + $email->addReplyTo(new Address($data->replyToEmail, $data->replyToName)); + } elseif ($isTestMail) { + $testReplyAddress = $this->configProvider->getValue(ConfigOption::AdminAddress); + if (!empty($testReplyAddress)) { + $email->addReplyTo(new Address($testReplyAddress, '')); + } + } - return $email; + $sentAs = $this->applyContentAndFormatting( + email: $email, + htmlMessage: $htmlMessage, + textMessage: $textMessage, + messageId: $messageId, + data: $data, + htmlPref: $htmlPref, + ); + + return [$email, $sentAs]; } - private function validateRecipientAndSubject(?string $to, ?string $subject): bool + public function applyCampaignHeaders(Email $email, Subscriber $subscriber): Email { - if (!$to) { - $this->eventLogManager->log('', sprintf('Error: empty To: in message with subject %s to send', $subject)); + $preferencesUrl = $this->configProvider->getValue(ConfigOption::PreferencesUrl) ?? ''; + $unsubscribeUrl = $this->configProvider->getValue(ConfigOption::UnsubscribeUrl) ?? ''; + $subscribeUrl = $this->configProvider->getValue(ConfigOption::SubscribeUrl) ?? ''; + $adminAddress = $this->configProvider->getValue(ConfigOption::UnsubscribeUrl) ?? ''; - return false; - } - if (!$subject) { - $this->eventLogManager->log('', sprintf('Error: empty Subject: in message to send to %s', $to)); - - return false; - } - if (preg_match("/\n/", $to)) { - $this->eventLogManager->log('', 'Error: invalid recipient, containing newlines, email blocked'); + $email->getHeaders()->addTextHeader( + 'List-Help', + '<' . $this->urlBuilder->withUid($preferencesUrl, $subscriber->getUniqueId()) . '>' + ); + $email->getHeaders()->addTextHeader( + 'List-Unsubscribe', + '<' . $this->urlBuilder->withUid($unsubscribeUrl, $subscriber->getUniqueId()) . '&jo=1>' + ); + $email->getHeaders()->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'); + $email->getHeaders()->addTextHeader('List-Subscribe', '<'. $subscribeUrl . '>'); + $email->getHeaders()->addTextHeader('List-Owner', ''); - return false; - } - if (preg_match("/\n/", $subject)) { - $this->eventLogManager->log('', 'Error: invalid subject, containing newlines, email blocked'); + return $email; + } - return false; + public function applyContentAndFormatting( + Email $email, + ?string $htmlMessage, + ?string $textMessage, + int $messageId, + MessagePrecacheDto $data, + bool $htmlPref = false + ): OutputFormat { + $htmlPref = $this->shouldPreferHtml($htmlMessage, $htmlPref, $email); + $normalizedFormat = $this->normalizeSendFormat($data->sendFormat); + + // so what do we actually send? + switch ($normalizedFormat) { + case 'pdf': + $sentAs = $this->applyPdfFormat($email, $textMessage, $messageId, $htmlPref); + break; + case 'text_and_pdf': + $sentAs = $this->applyTextAndPdfFormat($email, $textMessage, $messageId, $htmlPref); + break; + case 'text': + $sentAs = OutputFormat::Text; + $email->text($textMessage); + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Text)) { + throw new AttachmentException(); + } + break; + default: + if ($htmlPref && $htmlMessage) { + $sentAs = OutputFormat::TextAndHtml; + $htmlMessage = ($this->templateImageEmbedder)(html: $htmlMessage, messageId: $messageId); + $email->html($htmlMessage); + $email->text($textMessage); + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Html)) { + throw new AttachmentException(); + } + } else { + $sentAs = OutputFormat::Text; + $email->text($textMessage); + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Text)) { + throw new AttachmentException(); + } + } + break; } - return true; + return $sentAs; } - private function passesBlacklistCheck(?string $to, ?bool $skipBlacklistCheck): bool + private function shouldPreferHtml(?string $htmlMessage, bool $htmlPref, Email $email): bool { + if (empty($email->getTo())) { + throw new LogicException('No recipients specified'); + } + // If we have HTML content, default to preferring HTML + $htmlPref = $htmlPref || (is_string($htmlMessage) && trim($htmlMessage) !== ''); - if (!$skipBlacklistCheck && $this->blacklistRepository->isEmailBlacklisted($to)) { - $this->eventLogManager->log('', sprintf('Error, %s is blacklisted, not sending', $to)); - $subscriber = $this->subscriberRepository->findOneByEmail($to); - if (!$subscriber) { - $this->logger->error('Error: subscriber not found', ['email' => $to]); - - return false; - } - $subscriber->setBlacklisted(true); - - $this->subscriberHistoryManager->addHistory( - subscriber: $subscriber, - message: 'Marked Blacklisted', - details: 'Found user in blacklist while trying to send an email, marked black listed', - ); + // Domain-based text-only override + $domain = substr(strrchr($email->getTo()[0]->getAddress(), '@'), 1); + $textDomains = explode("\n", trim($this->configProvider->getValue(ConfigOption::AlwaysSendTextDomains) ?? '')); + if (in_array($domain, $textDomains, true)) { return false; } - return true; + return $htmlPref; } - private function resolveDestinationEmailAndMessage(?string $to, ?string $message): array + private function normalizeSendFormat(?string $sendFormat): string { - $destinationEmail = $to; + $format = strtolower(trim((string) $sendFormat)); + + return match ($format) { + 'pdf' => 'pdf', + 'text and pdf' => 'text_and_pdf', + 'text' => 'text', + // the default is for these too: 'both', 'html', 'text and html', + default => 'html_and_text', + }; + } - if ($this->devVersion) { - if (!$this->devEmail) { - throw new DevEmailNotConfiguredException(); + private function applyPdfFormat(Email $email, ?string $textMessage, int $messageId, bool $htmlPref): OutputFormat + { + // send a PDF file to users who want html and text to everyone else + if ($htmlPref) { + $sentAs = OutputFormat::Pdf; + $pdfBytes = $this->pdfGenerator->createPdfBytes($textMessage); + $email->attach($pdfBytes, 'message.pdf', 'application/pdf'); + + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Html)) { + throw new AttachmentException(); + } + } else { + $sentAs = OutputFormat::Text; + $email->text($textMessage); + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Text)) { + throw new AttachmentException(); } - $destinationEmail = $this->devEmail; } - return [$destinationEmail, $message]; + return $sentAs; } - private function createBaseEmail( + private function applyTextAndPdfFormat( + Email $email, + ?string $textMessage, int $messageId, - mixed $destinationEmail, - ?string $fromEmail, - ?string $fromName, - ?string $subject, - ?bool $inBlast - ) : Email { - $email = (new Email()); - - $email->getHeaders()->addTextHeader('X-MessageID', (string)$messageId); - $email->getHeaders()->addTextHeader('X-ListMember', $destinationEmail); - if ($this->googleSenderId !== '') { - $email->getHeaders()->addTextHeader('Feedback-ID', sprintf('%s:%s', $messageId, $this->googleSenderId)); - } - - if (!$this->useAmazonSes && $this->usePrecedenceHeader) { - $email->getHeaders()->addTextHeader('Precedence', 'bulk'); - } - - if ($inBlast) { - $email->getHeaders()->addTextHeader('X-Blast', '1'); - } - - $removeUrl = $this->configProvider->getValue(ConfigOption::UnsubscribeUrl); - $sep = !str_contains($removeUrl, '?') ? '?' : '&'; - $email->getHeaders()->addTextHeader( - 'List-Unsubscribe', - sprintf( - '<%s%s%s>', - $removeUrl, - $sep, - http_build_query([ - 'email' => $destinationEmail, - 'jo' => 1, - ]) - ) - ); - - - if ($this->devEmail && $destinationEmail !== $this->devEmail) { - $email->getHeaders()->addMailboxHeader( - 'X-Originally-To', - new Address($destinationEmail) - ); - } - - $email->to($destinationEmail); - $email->from(new Address($fromEmail, $fromName)); - $email->subject($subject); - - return $email; - } - - private function applyContentAndFormatting(Email $email, $htmlMessage, $textMessage, int $messageId): void - { - // Word wrapping disabled here to avoid reliance on config provider during content assembly + bool $htmlPref + ): OutputFormat { + // send a PDF file to users who want html and text to everyone else + if ($htmlPref) { + $sentAs = OutputFormat::TextAndPdf; + $pdfBytes = $this->pdfGenerator->createPdfBytes($textMessage); + $email->attach($pdfBytes, 'message.pdf', 'application/pdf'); + $email->text($textMessage); - if (!empty($htmlMessage)) { - // Embed/transform images and use the returned HTML content - $htmlMessage = ($this->templateImageEmbedder)(html: $htmlMessage, messageId: $messageId); - $email->html($htmlMessage); + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Html)) { + throw new AttachmentException(); + } + } else { + $sentAs = OutputFormat::Text; $email->text($textMessage); - //# In the above phpMailer strips all tags, which removes the links - // which are wrapped in < and > by HTML2text - //# so add it again + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Text)) { + throw new AttachmentException(); + } } - // Ensure text body is always set - $email->text($textMessage); + + return $sentAs; } } diff --git a/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php b/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php index c6b05fc2..2bd5cf7f 100644 --- a/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php +++ b/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php @@ -19,7 +19,6 @@ public function build(object $dto): MessageFormat return new MessageFormat( htmlFormatted: $dto->htmlFormated, sendFormat: $dto->sendFormat, - formatOptions: $dto->formatOptions ); } } diff --git a/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php b/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php new file mode 100644 index 00000000..f5d52a69 --- /dev/null +++ b/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php @@ -0,0 +1,110 @@ +validateRecipientAndSubject(to: $data->to, subject: $data->subject)) { + return null; + } + + if (!$this->passesBlacklistCheck(to: $data->to, skipBlacklistCheck: $skipBlacklistCheck)) { + return null; + } + + $fromEmail = $this->configProvider->getValue(ConfigOption::MessageFromAddress); + $fromName = $this->configProvider->getValue(ConfigOption::MessageFromName); +// $messageReplyToAddress = $this->configProvider->getValue(ConfigOption::MessageReplyToAddress); +// $replyTo = $messageReplyToAddress ?: $fromEmail; + + [$htmlMessage, $textMessage] = ($this->mailConstructor)(messagePrecacheDto: $data); + + $email = $this->createBaseEmail( + messageId: $messageId, + originalTo: $data->to, + fromEmail: $fromEmail, + fromName: $fromName, + subject: $data->subject, + inBlast: $inBlast + ); + + $this->applyContentAndFormatting( + email: $email, + htmlMessage: $htmlMessage, + textMessage: $textMessage, + messageId: $messageId, + ); + + return $email; + } + + protected function applyContentAndFormatting( + Email $email, + ?string $htmlMessage, + ?string $textMessage, + int $messageId, + ): void { + // Word wrapping disabled here to avoid reliance on config provider during content assembly + if (!empty($htmlMessage)) { + // Embed/transform images and use the returned HTML content + $htmlMessage = ($this->templateImageEmbedder)(html: $htmlMessage, messageId: $messageId); + $email->html($htmlMessage); + //# In the above phpMailer strips all tags, which removes the links + // which are wrapped in < and > by HTML2text so add it again + } + // Ensure text body is always set + $email->text($textMessage); + } +} diff --git a/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php b/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php new file mode 100644 index 00000000..25bdf075 --- /dev/null +++ b/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php @@ -0,0 +1,159 @@ +subscriberRepository->findOneByEmail($messagePrecacheDto->to); + if (!$subscriber) { + throw new SubscriberNotFoundException( + sprintf('Subscriber with email %s not found', $messagePrecacheDto->to) + ); + } + $addDefaultStyle = false; + + if ($messagePrecacheDto->userSpecificUrl) { + $userData = $this->subscriberRepository->getDataById($subscriber->getId()); + $this->replaceUserSpecificRemoteContent($messagePrecacheDto, $subscriber, $userData); + } + + $content = $messagePrecacheDto->content; + $hasText = !empty($messagePrecacheDto->textContent); + if ($messagePrecacheDto->htmlFormatted) { + $textContent = $hasText ? $messagePrecacheDto->textContent : ($this->html2Text)($content); + $htmlContent = $content; + } else { + $textContent = $hasText ? $messagePrecacheDto->textContent : $content; + $htmlContent = ($this->textParser)($content); + } + + if ($messagePrecacheDto->template) { + // template used: use only the content of the body element if it is present + if (preg_match('|(.+)|is', $htmlContent, $matches)) { + $htmlContent = $matches[1]; + } + $htmlMessage = str_replace('[CONTENT]', $htmlContent, $messagePrecacheDto->template); + } else { + $htmlMessage = $htmlContent; + $addDefaultStyle = true; + } + if ($messagePrecacheDto->templateText) { + $textMessage = str_replace('[CONTENT]', $textContent, $messagePrecacheDto->templateText); + } else { + $textMessage = $textContent; + } + + $textMessage = $this->placeholderProcessor->process( + value: $textMessage, + user: $subscriber, + format: OutputFormat::Text, + messagePrecacheDto: $messagePrecacheDto, + campaignId: $campaignId, + ); + + $htmlMessage = $this->placeholderProcessor->process( + value: $htmlMessage, + user: $subscriber, + format: OutputFormat::Html, + messagePrecacheDto: $messagePrecacheDto, + campaignId: $campaignId, + ); + + $htmlMessage = $this->ensureHtmlFormating(content: $htmlMessage, addDefaultStyle: $addDefaultStyle); + // todo: add link CLICKTRACK to $htmlMessage + + return [$htmlMessage, $textMessage]; + } + + private function replaceUserSpecificRemoteContent( + MessagePrecacheDto $messagePrecacheDto, + Subscriber $subscriber, + array $userData + ): void { + if (!preg_match_all('/\[URL:([^\s]+)]/i', $messagePrecacheDto->content, $matches, PREG_SET_ORDER)) { + return; + } + + $content = $messagePrecacheDto->content; + foreach ($matches as $match) { + $token = $match[0]; + $rawUrl = $match[1]; + + $url = preg_match('/^https?:\/\//i', $rawUrl) ? $rawUrl : 'https://' . $rawUrl; + + $remoteContent = ($this->remotePageFetcher)($url, $userData); + + if ($remoteContent === '') { + $this->eventLogManager->log( + '', + sprintf('Error fetching URL: %s to send to %s', $rawUrl, $subscriber->getEmail()) + ); + + throw new RemotePageFetchException(); + } + + $content = str_replace($token, '' . $remoteContent, $content); + } + + $messagePrecacheDto->content = $content; + $messagePrecacheDto->htmlFormatted = strip_tags($content) !== $content; + } + + private function ensureHtmlFormating(string $content, bool $addDefaultStyle): string + { + if (!preg_match('##ims', $content)) { + $content = '' . $content . ''; + } + if (!preg_match('##ims', $content)) { + $defaultStyle = $this->configProvider->getValue(ConfigOption::HtmlEmailStyle); + + if (!$addDefaultStyle) { + $defaultStyle = ''; + } + $content = ' + + + ' . $defaultStyle . '' . $content; + } + if (!preg_match('##ims', $content)) { + $content = '' . $content . ''; + } + + //# remove trailing code after + $content = preg_replace('#.*#msi', '', $content); + + //# the editor sometimes places

and

around the URL + $content = str_ireplace('

', '', $content); + } +} diff --git a/src/Domain/Messaging/Service/Constructor/MailContentBuilderInterface.php b/src/Domain/Messaging/Service/Constructor/MailContentBuilderInterface.php new file mode 100644 index 00000000..024b86d7 --- /dev/null +++ b/src/Domain/Messaging/Service/Constructor/MailContentBuilderInterface.php @@ -0,0 +1,19 @@ +poweredByText = $configProvider->getValue(ConfigOption::PoweredByText); } - public function __invoke($message, string $subject = ''): array + public function __invoke(MessagePrecacheDto $messagePrecacheDto, ?int $campaignId = null): array { - [$htmlMessage, $textMessage] = $this->buildMessageBodies($message); + [$htmlMessage, $textMessage] = $this->buildMessageBodies($messagePrecacheDto->content); $htmlContent = $htmlMessage; $textContent = $textMessage; @@ -38,7 +39,7 @@ public function __invoke($message, string $subject = ''): array $htmlTemplate = stripslashes($template->getContent()); $textTemplate = stripslashes($template->getText()); $htmlContent = str_replace('[CONTENT]', $htmlMessage, $htmlTemplate); - $htmlContent = str_replace('[SUBJECT]', $subject, $htmlContent); + $htmlContent = str_replace('[SUBJECT]', $messagePrecacheDto->subject, $htmlContent); $htmlContent = str_replace('[FOOTER]', '', $htmlContent); if (!$this->poweredByPhplist) { $phpListPowered = preg_replace( @@ -58,7 +59,7 @@ public function __invoke($message, string $subject = ''): array } $htmlContent = $this->templateImageManager->parseLogoPlaceholders($htmlContent); $textContent = str_replace('[CONTENT]', $textMessage, $textTemplate); - $textContent = str_replace('[SUBJECT]', $subject, $textContent); + $textContent = str_replace('[SUBJECT]', $messagePrecacheDto->subject, $textContent); $textContent = str_replace('[FOOTER]', '', $textContent); $phpListPowered = trim(($this->html2Text)($this->poweredByText)); if (str_contains($textContent, '[SIGNATURE]')) { @@ -72,7 +73,7 @@ public function __invoke($message, string $subject = ''): array return [$htmlContent, $textContent]; } - private function buildMessageBodies($message): array + private function buildMessageBodies(string $message): array { $hasHTML = strip_tags($message) !== $message; diff --git a/src/Domain/Messaging/Service/EmailService.php b/src/Domain/Messaging/Service/EmailService.php index 2a45b0fd..bced6a8b 100644 --- a/src/Domain/Messaging/Service/EmailService.php +++ b/src/Domain/Messaging/Service/EmailService.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Domain\Messaging\Service; use PhpList\Core\Domain\Messaging\Message\AsyncEmailMessage; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Messenger\MessageBusInterface; @@ -13,21 +14,12 @@ class EmailService { - private MailerInterface $mailer; - private MessageBusInterface $messageBus; - private string $defaultFromEmail; - private string $bounceEmail; - public function __construct( - MailerInterface $mailer, - MessageBusInterface $messageBus, - string $defaultFromEmail, - string $bounceEmail, + private readonly MailerInterface $mailer, + private readonly MessageBusInterface $messageBus, + #[Autowire('%app.mailer_from%')] private readonly string $defaultFromEmail, + #[Autowire('%imap_bounce.email%')] private readonly string $bounceEmail, ) { - $this->mailer = $mailer; - $this->messageBus = $messageBus; - $this->defaultFromEmail = $defaultFromEmail; - $this->bounceEmail = $bounceEmail; } public function sendEmail( diff --git a/src/Domain/Messaging/Service/MessagePrecacheService.php b/src/Domain/Messaging/Service/MessagePrecacheService.php index 2a0f6b28..90b847a5 100644 --- a/src/Domain/Messaging/Service/MessagePrecacheService.php +++ b/src/Domain/Messaging/Service/MessagePrecacheService.php @@ -68,10 +68,8 @@ public function precacheMessage(Message $campaign, $loadedMessageData, ?bool $fo //# but that has quite some impact on speed. So check if that's the case and apply $messagePrecacheDto->userSpecificUrl = preg_match('/\[.+\]/', $loadedMessageData['sendurl']); - if (!$messagePrecacheDto->userSpecificUrl) { - if (!$this->applyRemoteContentIfPresent($messagePrecacheDto, $loadedMessageData)) { - return false; - } + if (!$this->applyRemoteContentIfPresent($messagePrecacheDto, $loadedMessageData)) { + return false; } $messagePrecacheDto->googleTrack = $loadedMessageData['google_track']; @@ -181,27 +179,30 @@ private function applyTemplate(MessagePrecacheDto $messagePrecacheDto, $loadedMe private function applyRemoteContentIfPresent(MessagePrecacheDto $messagePrecacheDto, $loadedMessageData): bool { - if (preg_match('/\[URL:([^\s]+)\]/i', $messagePrecacheDto->content, $regs)) { - $remoteContent = ($this->remotePageFetcher)($regs[1], []); - - if ($remoteContent) { - $messagePrecacheDto->content = str_replace($regs[0], $remoteContent, $messagePrecacheDto->content); - $messagePrecacheDto->htmlFormatted = $this->isHtml($remoteContent); - - //# 17086 - disregard any template settings when we have a valid remote URL - $messagePrecacheDto->template = null; - $messagePrecacheDto->templateText = null; - $messagePrecacheDto->templateId = null; - } else { - $this->eventLogManager->log( - page: 'unknown page', - entry: 'Error fetching URL: '.$loadedMessageData['sendurl'].' cannot proceed', - ); - - return false; - } + if ($messagePrecacheDto->userSpecificUrl + || !preg_match('/\[URL:([^\s]+)\]/i', $messagePrecacheDto->content, $regs) + ) { + return true; + } + + $remoteContent = ($this->remotePageFetcher)($regs[1], []); + if (!$remoteContent) { + $this->eventLogManager->log( + page: 'unknown page', + entry: 'Error fetching URL: ' . $loadedMessageData['sendurl'] . ' cannot proceed', + ); + + return false; } + $messagePrecacheDto->content = str_replace($regs[0], $remoteContent, $messagePrecacheDto->content); + $messagePrecacheDto->htmlFormatted = $this->isHtml($remoteContent); + + //# 17086 - disregard any template settings when we have a valid remote URL + $messagePrecacheDto->template = null; + $messagePrecacheDto->templateText = null; + $messagePrecacheDto->templateId = null; + return true; } diff --git a/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php b/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php index fe1c64eb..fb3ec64d 100644 --- a/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php +++ b/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php @@ -4,44 +4,16 @@ namespace PhpList\Core\Domain\Messaging\Service; -use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; -use PhpList\Core\Domain\Messaging\Model\Message; -use PhpList\Core\Domain\Subscription\Model\Subscriber; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; class RateLimitedCampaignMailer { - private MailerInterface $mailer; - private SendRateLimiter $limiter; - public function __construct(MailerInterface $mailer, SendRateLimiter $limiter) - { - $this->mailer = $mailer; - $this->limiter = $limiter; - } - - public function composeEmail( - Message $message, - Subscriber $subscriber, - MessagePrecacheDto $messagePrecacheDto, - ): Email { - $email = new Email(); - if ($message->getOptions()->getFromField() !== '') { - $email->from($message->getOptions()->getFromField()); - } - - if ($message->getOptions()->getReplyTo() !== '') { - $email->replyTo($message->getOptions()->getReplyTo()); - } - - $html = $messagePrecacheDto->content . $messagePrecacheDto->htmlFooter; - - return $email - ->to($subscriber->getEmail()) - ->subject($messagePrecacheDto->subject) - ->text($messagePrecacheDto->textContent) - ->html($html); + public function __construct( + private readonly MailerInterface $mailer, + private readonly SendRateLimiter $limiter, + ) { } /** diff --git a/src/Domain/Subscription/Repository/SubscriberListRepository.php b/src/Domain/Subscription/Repository/SubscriberListRepository.php index d8910751..0a1a60cd 100644 --- a/src/Domain/Subscription/Repository/SubscriberListRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberListRepository.php @@ -9,6 +9,7 @@ use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberList; /** @@ -53,4 +54,41 @@ public function getAllActive(): array ->getQuery() ->getResult(); } + + public function getListNames(array $listIds): array + { + if ($listIds === []) { + return []; + } + + $lists = $this->createQueryBuilder('l') + ->select('l.name') + ->where('l.id IN (:ids)') + ->setParameter('ids', $listIds) + ->getQuery() + ->getScalarResult(); + + return array_column($lists, 'name'); + } + + /** + * Returns the names of lists the given subscriber is subscribed to. + * If $showPrivate is false, only active/public lists are included. + */ + public function getActiveListNamesForSubscriber(Subscriber $subscriber, bool $showPrivate): array + { + $queryBuilder = $this->createQueryBuilder('l') + ->select('l.name') + ->innerJoin('l.subscriptions', 's') + ->where('IDENTITY(s.subscriber) = :subscriberId') + ->setParameter('subscriberId', $subscriber->getId()); + + if (!$showPrivate) { + $queryBuilder->andWhere('l.active = true'); + } + + $rows = $queryBuilder->getQuery()->getScalarResult(); + + return array_column($rows, 'name'); + } } diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index af776a75..4da427c6 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -205,4 +205,14 @@ public function decrementBounceCount(Subscriber $subscriber): void ->getQuery() ->execute(); } + + public function getDataById(int $subscriberId): array + { + return $this->createQueryBuilder('s') + ->select('s') + ->where('s.id = :subscriberId') + ->setParameter('subscriberId', $subscriberId) + ->getQuery() + ->getArrayResult()[0]; + } } diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php index f962c9ab..f9db822a 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php @@ -34,36 +34,17 @@ */ class SubscriberCsvImporter { - private SubscriberManager $subscriberManager; - private SubscriberAttributeManager $attributeManager; - private SubscriptionManager $subscriptionManager; - private SubscriberRepository $subscriberRepository; - private CsvToDtoImporter $csvToDtoImporter; - private EntityManagerInterface $entityManager; - private TranslatorInterface $translator; - private MessageBusInterface $messageBus; - private SubscriberHistoryManager $subscriberHistoryManager; - public function __construct( - SubscriberManager $subscriberManager, - SubscriberAttributeManager $attributeManager, - SubscriptionManager $subscriptionManager, - SubscriberRepository $subscriberRepository, - CsvToDtoImporter $csvToDtoImporter, - EntityManagerInterface $entityManager, - TranslatorInterface $translator, - MessageBusInterface $messageBus, - SubscriberHistoryManager $subscriberHistoryManager, + private readonly SubscriberManager $subscriberManager, + private readonly SubscriberAttributeManager $attributeManager, + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriberRepository $subscriberRepository, + private readonly CsvToDtoImporter $csvToDtoImporter, + private readonly EntityManagerInterface $entityManager, + private readonly TranslatorInterface $translator, + private readonly MessageBusInterface $messageBus, + private readonly SubscriberHistoryManager $subscriberHistoryManager, ) { - $this->subscriberManager = $subscriberManager; - $this->attributeManager = $attributeManager; - $this->subscriptionManager = $subscriptionManager; - $this->subscriberRepository = $subscriberRepository; - $this->csvToDtoImporter = $csvToDtoImporter; - $this->entityManager = $entityManager; - $this->translator = $translator; - $this->messageBus = $messageBus; - $this->subscriberHistoryManager = $subscriberHistoryManager; } /** diff --git a/tests/Integration/Domain/Messaging/Fixtures/MessageFixture.php b/tests/Integration/Domain/Messaging/Fixtures/MessageFixture.php index 52930d14..548becf9 100644 --- a/tests/Integration/Domain/Messaging/Fixtures/MessageFixture.php +++ b/tests/Integration/Domain/Messaging/Fixtures/MessageFixture.php @@ -49,53 +49,48 @@ public function load(ObjectManager $manager): void $template = $templateRepository->find($row['template']); $format = new MessageFormat( - (bool)$row['htmlformatted'], - $row['sendformat'], - array_keys(array_filter([ - MessageFormat::FORMAT_TEXT => $row['astext'], - MessageFormat::FORMAT_HTML => $row['ashtml'], - MessageFormat::FORMAT_PDF => $row['aspdf'], - ])) + htmlFormatted: (bool)$row['htmlformatted'], + sendFormat: $row['sendformat'], ); $schedule = new MessageSchedule( - (int)$row['repeatinterval'], - new DateTime($row['repeatuntil']), - (int)$row['requeueinterval'], - new DateTime($row['requeueuntil']), - new DateTime($row['embargo']), + repeatInterval: (int)$row['repeatinterval'], + repeatUntil: new DateTime($row['repeatuntil']), + requeueInterval: (int)$row['requeueinterval'], + requeueUntil: new DateTime($row['requeueuntil']), + embargo: new DateTime($row['embargo']), ); $metadata = new MessageMetadata( - $row['status'], - (int)$row['bouncecount'], - new DateTime($row['entered']), - new DateTime($row['sent']), - new DateTime($row['sendstart']), + status: $row['status'], + bounceCount: (int)$row['bouncecount'], + entered: new DateTime($row['entered']), + sent: new DateTime($row['sent']), + sendStart: new DateTime($row['sendstart']), ); $metadata->setProcessed((bool) $row['processed']); - $metadata->setViews($row['viewed']); + $metadata->setViews((int) $row['viewed']); $content = new MessageContent( - $row['subject'], - $row['message'], - $row['textmessage'], - $row['footer'] + subject: $row['subject'], + text: $row['message'], + textMessage: $row['textmessage'], + footer: $row['footer'] ); $options = new MessageOptions( - $row['fromfield'], - $row['tofield'], - $row['replyto'], - $row['userselection'], - $row['rsstemplate'], + fromField: $row['fromfield'], + toField: $row['tofield'], + replyTo: $row['replyto'], + userSelection: $row['userselection'], + rssTemplate: $row['rsstemplate'], ); $message = new Message( - $format, - $schedule, - $metadata, - $content, - $options, - $admin, - $template, + format: $format, + schedule: $schedule, + metadata: $metadata, + content: $content, + options: $options, + owner: $admin, + template: $template, ); $this->setSubjectId($message, (int)$row['id']); $this->setSubjectProperty($message, 'uuid', $row['uuid']); diff --git a/tests/Unit/Bounce/Service/WebklexImapClientFactoryTest.php b/tests/Unit/Bounce/Service/WebklexImapClientFactoryTest.php index 25ad57dc..c2b536cd 100644 --- a/tests/Unit/Bounce/Service/WebklexImapClientFactoryTest.php +++ b/tests/Unit/Bounce/Service/WebklexImapClientFactoryTest.php @@ -37,16 +37,20 @@ public function testMakeForMailboxBuildsClientWithConfiguredParams(): void $this->manager ->expects($this->once()) ->method('make') - ->with($this->callback(function (array $cfg) { - $this->assertSame('imap.example.com', $cfg['host']); - $this->assertSame(993, $cfg['port']); - $this->assertSame('ssl', $cfg['encryption']); - $this->assertTrue($cfg['validate_cert']); - $this->assertSame('user', $cfg['username']); - $this->assertSame('pass', $cfg['password']); - $this->assertSame('imap', $cfg['protocol']); - return true; - })) + ->with( + $this->callback( + function (array $cfg) { + $this->assertSame('imap.example.com', $cfg['host']); + $this->assertSame(993, $cfg['port']); + $this->assertSame('ssl', $cfg['encryption']); + $this->assertTrue($cfg['validate_cert']); + $this->assertSame('user', $cfg['username']); + $this->assertSame('pass', $cfg['password']); + $this->assertSame('imap', $cfg['protocol']); + return true; + } + ) + ) ->willReturn($client); $out = $factory->makeForMailbox(); diff --git a/tests/Unit/Domain/Common/FileHelperTest.php b/tests/Unit/Domain/Common/FileHelperTest.php new file mode 100644 index 00000000..9f652623 --- /dev/null +++ b/tests/Unit/Domain/Common/FileHelperTest.php @@ -0,0 +1,88 @@ +tmpDir = sys_get_temp_dir() . '/phplist-filehelper-' . bin2hex(random_bytes(6)); + mkdir($this->tmpDir, 0777, true); + } + + protected function tearDown(): void + { + if (is_dir($this->tmpDir)) { + foreach (glob($this->tmpDir . '/*') ?: [] as $path) { + if (is_file($path) || is_link($path)) { + unlink($path); + } + } + rmdir($this->tmpDir); + } + } + + public function testIsValidFile(): void + { + $helper = new FileHelper(); + + $nonExisting = $this->tmpDir . '/missing.txt'; + $this->assertFalse($helper->isValidFile($nonExisting), 'Non-existing path must be invalid'); + + $empty = $this->tmpDir . '/empty.txt'; + touch($empty); + $this->assertFalse($helper->isValidFile($empty), 'Empty file must be invalid'); + + $nonEmpty = $this->tmpDir . '/data.txt'; + file_put_contents($nonEmpty, 'abc'); + $this->assertTrue($helper->isValidFile($nonEmpty), 'Non-empty file must be valid'); + + $this->assertFalse($helper->isValidFile($this->tmpDir), 'Directory must be invalid'); + } + + public function testReadFileContents(): void + { + $helper = new FileHelper(); + + $file = $this->tmpDir . '/readme.txt'; + $content = 'Hello, world!'; + file_put_contents($file, $content); + + $this->assertSame($content, $helper->readFileContents($file)); + + // Attempting to read a directory should return null + $this->assertNull($helper->readFileContents($this->tmpDir)); + } + + public function testWriteFileToDirectoryCreatesFileWithExtensionAndContents(): void + { + $helper = new FileHelper(); + + $writtenPath = $helper->writeFileToDirectory($this->tmpDir, 'report.txt', 'payload'); + + $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)); + } + + public function testWriteFileToDirectoryUsesDefaultNameWhenMissing(): void + { + $helper = new FileHelper(); + + $writtenPath = $helper->writeFileToDirectory($this->tmpDir, '', 'x'); + + $this->assertNotNull($writtenPath); + $this->assertTrue(is_file($writtenPath)); + $this->assertSame('x', file_get_contents($writtenPath)); + $this->assertStringStartsWith('file', basename($writtenPath)); + } +} diff --git a/tests/Unit/Domain/Common/OnceCacheGuardTest.php b/tests/Unit/Domain/Common/OnceCacheGuardTest.php new file mode 100644 index 00000000..50afa0f9 --- /dev/null +++ b/tests/Unit/Domain/Common/OnceCacheGuardTest.php @@ -0,0 +1,78 @@ +cache = $this->createMock(CacheInterface::class); + } + + public function testFirstTimeReturnsTrueAndSetsKeyWithTtl(): void + { + $key = 'once:key:123'; + $ttl = 60; + + $this->cache->expects($this->once()) + ->method('has') + ->with($key) + ->willReturn(false); + + $this->cache->expects($this->once()) + ->method('set') + ->with($key, true, $ttl) + ->willReturn(true); + + $guard = new OnceCacheGuard($this->cache); + + $this->assertTrue($guard->firstTime($key, $ttl)); + } + + public function testFirstTimeReturnsFalseWhenKeyAlreadyPresent(): void + { + $key = 'once:key:present'; + + $this->cache->expects($this->once()) + ->method('has') + ->with($key) + ->willReturn(true); + + $this->cache->expects($this->never()) + ->method('set'); + + $guard = new OnceCacheGuard($this->cache); + + $this->assertFalse($guard->firstTime($key, 10)); + } + + public function testFirstTimeIgnoresSetFailureAndStillReturnsTrueOnFirstCall(): void + { + $key = 'once:key:set-fails'; + $ttl = 5; + + $this->cache->expects($this->once()) + ->method('has') + ->with($key) + ->willReturn(false); + + // Even if underlying cache set returns false, guard should return true. + $this->cache->expects($this->once()) + ->method('set') + ->with($key, true, $ttl) + ->willReturn(false); + + $guard = new OnceCacheGuard($this->cache); + + $this->assertTrue($guard->firstTime($key, $ttl)); + } +} diff --git a/tests/Unit/Domain/Common/PdfGeneratorTest.php b/tests/Unit/Domain/Common/PdfGeneratorTest.php new file mode 100644 index 00000000..78df55c8 --- /dev/null +++ b/tests/Unit/Domain/Common/PdfGeneratorTest.php @@ -0,0 +1,46 @@ +createPdfBytes($text); + + $this->assertIsString($pdfBytes); + $this->assertNotSame('', $pdfBytes); + + // Must start with a valid PDF header + $this->assertStringStartsWith('%PDF-', $pdfBytes); + + // Should contain EOF marker somewhere near the end + $this->assertNotFalse(strpos($pdfBytes, '%%EOF')); + + // Should be reasonably sized for a minimal 1-page PDF + $this->assertGreaterThan(100, strlen($pdfBytes)); + } + + public function testCreatePdfBytesContainsCreatorMetadataAndSomeText(): void + { + $generator = new PdfGenerator(); + $text = 'Sample text for pdfList PDF'; + + $pdfBytes = $generator->createPdfBytes($text); + + // FPDF stores the Creator metadata; value set to 'phpList' in PdfGenerator + $this->assertNotFalse(strpos($pdfBytes, 'phpList')); + + // The plain text often appears within a text object; ensure at least a fragment is present + $fragment = 'Sample text'; + $this->assertNotFalse(strpos($pdfBytes, $fragment)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php b/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php new file mode 100644 index 00000000..0b7c9b0d --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php @@ -0,0 +1,190 @@ +config = $this->createMock(ConfigProvider::class); + $this->attrRepo = $this->createMock(SubscriberAttributeValueRepository::class); + $this->attrResolver = $this->createMock(AttributeValueResolver::class); + $this->attrRepo->method('getForSubscriber')->willReturn([]); + } + + private function makeUser(string $email = 'user@example.com', string $uid = 'UID123'): Subscriber + { + $u = new Subscriber(); + $u->setEmail($email); + $u->setUniqueId($uid); + return $u; + } + + public function testEnsuresStandardPlaceholdersAndUsertrackInHtmlOnly(): void + { + $user = $this->makeUser(); + $dto = new MessagePrecacheDto(); + + // alwaysAddUserTrack = true + $processor = new MessagePlaceholderProcessor( + config: $this->config, + attributesRepository: $this->attrRepo, + attributeValueResolver: $this->attrResolver, + placeholderResolvers: [], + patternResolvers: [], + supportingResolvers: [], + alwaysAddUserTrack: true, + ); + + $html = 'Hello'; + $processedHtml = $processor->process( + value: $html, + user: $user, + format: OutputFormat::Html, + messagePrecacheDto: $dto, + campaignId: 42, + forwardedBy: null, + ); + + // FOOTER and SIGNATURE must be inserted before , USERTRACK appended for Html when flag enabled + $this->assertStringContainsString('
[FOOTER] [SIGNATURE] [USERTRACK]', $processedHtml); + + // In Text, FOOTER and SIGNATURE are appended with newlines, no USERTRACK even if flag enabled + $text = 'Hi'; + $processedText = $processor->process( + value: $text, + user: $user, + format: OutputFormat::Text, + messagePrecacheDto: $dto, + ); + + $this->assertStringEndsWith("\n\n[FOOTER]\n[SIGNATURE]", $processedText); + $this->assertStringNotContainsString('[USERTRACK]', $processedText); + } + + public function testBuiltInResolversReplaceEmailUserIdAndConfigValues(): void + { + $user = $this->makeUser('alice@example.com', 'U-999'); + $dto = new MessagePrecacheDto(); + + $this->config->method('getValue')->willReturnCallback( + function (ConfigOption $opt): ?string { + return match ($opt) { + ConfigOption::Website => 'https://site.example', + ConfigOption::Domain => 'example.com', + ConfigOption::OrganisationName => 'ACME Inc', + default => null, + }; + } + ); + + $processor = new MessagePlaceholderProcessor( + config: $this->config, + attributesRepository: $this->attrRepo, + attributeValueResolver: $this->attrResolver, + placeholderResolvers: [], + patternResolvers: [], + supportingResolvers: [], + alwaysAddUserTrack: false, + ); + + $content = 'Hi [EMAIL], id=[USERID], web=[WEBSITE], dom=[DOMAIN], org=[ORGANIZATION_NAME].'; + $out = $processor->process( + value: $content, + user: $user, + format: OutputFormat::Text, + messagePrecacheDto: $dto, + campaignId: 101, + forwardedBy: 'bob@example.com', + ); + + $this->assertStringContainsString('Hi alice@example.com,', $out); + $this->assertStringContainsString('id=U-999,', $out); + $this->assertStringContainsString('web=https://site.example,', $out); + $this->assertStringContainsString('dom=example.com,', $out); + $this->assertStringContainsString('org=ACME Inc.', $out); + } + + public function testCustomResolversFromIterablesAreApplied(): void + { + $user = $this->makeUser(); + $dto = new MessagePrecacheDto(); + + // Placeholder by name: [CUSTOM] + $customPlaceholder = new class implements PlaceholderValueResolverInterface { + public function name(): string + { + return 'CUSTOM'; + } + public function __invoke(PlaceholderContext $ctx): string + { + return 'XVAL'; + } + }; + + // Pattern resolver: [UPPER:text] + $pattern = new class implements PatternValueResolverInterface { + public function pattern(): string + { + return '/\[UPPER:([^\]]+)]/i'; + } + public function __invoke(PlaceholderContext $ctx, array $matches): string + { + return strtoupper($matches[1]); + } + }; + + // Supporting resolver: for key SUPPORT + $supporting = new class implements SupportingPlaceholderResolverInterface { + public function supports(string $key, PlaceholderContext $ctx): bool + { + return strtoupper($key) === 'SUPPORT'; + } + public function resolve(string $key, PlaceholderContext $ctx): ?string + { + return 'SVAL'; + } + }; + + $processor = new MessagePlaceholderProcessor( + config: $this->config, + attributesRepository: $this->attrRepo, + attributeValueResolver: $this->attrResolver, + placeholderResolvers: [$customPlaceholder], + patternResolvers: [$pattern], + supportingResolvers: [$supporting], + alwaysAddUserTrack: false, + ); + + $content = 'A [CUSTOM] B [UPPER:abc] C [SUPPORT]'; + $out = $processor->process( + value: $content, + user: $user, + format: OutputFormat::Text, + messagePrecacheDto: $dto, + ); + + $this->assertStringContainsString('A XVAL B ABC C SVAL', $out); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolverTest.php new file mode 100644 index 00000000..49eb567b --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolverTest.php @@ -0,0 +1,95 @@ +config = $this->createMock(ConfigProvider::class); + $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); + } + + private function makeUser(string $email = 'user@example.com'): Subscriber + { + $u = new Subscriber(); + $u->setEmail($email); + $u->setUniqueId('UID-123'); + return $u; + } + + public function testName(): void + { + $resolver = new BlacklistUrlValueResolver($this->config, $this->urlBuilder); + $this->assertSame('BLACKLISTURL', $resolver->name()); + } + + public function testInvokedForHtmlEscapesUrl(): void + { + $this->config->method('getValue') + ->with(ConfigOption::BlacklistUrl) + ->willReturn('https://example.com/blacklist.php'); + + $expectedRaw = 'https://example.com/blacklist.php?a=1&b=2&email=user%40example.com'; + $this->urlBuilder->expects($this->once()) + ->method('withEmail') + ->with('https://example.com/blacklist.php', 'user@example.com') + ->willReturn($expectedRaw); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 1, + ); + + $resolver = new BlacklistUrlValueResolver($this->config, $this->urlBuilder); + $result = $resolver($ctx); + + // In HTML, ampersands must be escaped + $this->assertSame( + 'https://example.com/blacklist.php?a=1&b=2&email=user%40example.com', + $result + ); + } + + public function testInvokedForTextReturnsPlainUrl(): void + { + $this->config->method('getValue') + ->with(ConfigOption::BlacklistUrl) + ->willReturn('https://example.com/blacklist.php'); + + $expectedRaw = 'https://example.com/blacklist.php?email=user%40example.com'; + $this->urlBuilder->expects($this->once()) + ->method('withEmail') + ->with('https://example.com/blacklist.php', 'user@example.com') + ->willReturn($expectedRaw); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Text, + ); + + $resolver = new BlacklistUrlValueResolver($this->config, $this->urlBuilder); + $result = $resolver($ctx); + + $this->assertSame($expectedRaw, $result); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistValueResolverTest.php new file mode 100644 index 00000000..7607270b --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/BlacklistValueResolverTest.php @@ -0,0 +1,97 @@ +config = $this->createMock(ConfigProvider::class); + $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); + $this->translator = $this->createMock(TranslatorInterface::class); + } + + private function makeUser(string $email = 'user@example.com'): Subscriber + { + $u = new Subscriber(); + $u->setEmail($email); + $u->setUniqueId('UID-1'); + return $u; + } + + public function testName(): void + { + $resolver = new BlacklistValueResolver($this->config, $this->urlBuilder, $this->translator); + $this->assertSame('BLACKLIST', $resolver->name()); + } + + public function testHtmlReturnsAnchorWithTranslatedEscapedLabelAndUrl(): void + { + $this->config->method('getValue') + ->with(ConfigOption::BlacklistUrl) + ->willReturn('https://example.com/blacklist.php'); + + $rawUrl = 'https://example.com/blacklist.php?email=user%40example.com&x=1'; + $this->urlBuilder->expects($this->once()) + ->method('withEmail') + ->with('https://example.com/blacklist.php', 'user@example.com') + ->willReturn($rawUrl); + + // Translator returns a label with characters that require escaping + $this->translator->method('trans') + ->with('Unsubscribe') + ->willReturn('Unsubscribe & more "now" <>'); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Html, + ); + + $resolver = new BlacklistValueResolver($this->config, $this->urlBuilder, $this->translator); + $result = $resolver($ctx); + + $expectedHref = htmlspecialchars($rawUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $expectedLabel = htmlspecialchars('Unsubscribe & more "now" <>', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $expected = '' . $expectedLabel . ''; + + $this->assertSame($expected, $result); + } + + public function testTextReturnsPlainUrl(): void + { + $this->config->method('getValue') + ->with(ConfigOption::BlacklistUrl) + ->willReturn('https://example.com/blacklist.php'); + + $rawUrl = 'https://example.com/blacklist.php?email=user%40example.com'; + $this->urlBuilder->expects($this->once()) + ->method('withEmail') + ->with('https://example.com/blacklist.php', 'user@example.com') + ->willReturn($rawUrl); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Text, + ); + + $resolver = new BlacklistValueResolver($this->config, $this->urlBuilder, $this->translator); + $this->assertSame($rawUrl, $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolverTest.php new file mode 100644 index 00000000..e9278a42 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolverTest.php @@ -0,0 +1,104 @@ +config = $this->createMock(ConfigProvider::class); + } + + private function makeUser(string $uid = 'UID-1'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new ConfirmationUrlValueResolver($this->config); + $this->assertSame('CONFIRMATIONURL', $resolver->name()); + } + + public function testHtmlWhenBaseHasNoQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ConfirmationUrl) + ->willReturn('https://example.com/confirm.php'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-42'), + format: OutputFormat::Html, + ); + + $resolver = new ConfirmationUrlValueResolver($this->config); + $result = $resolver($ctx); + + $this->assertSame('https://example.com/confirm.php?uid=U-42', $result); + } + + public function testHtmlWhenBaseHasExistingQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ConfirmationUrl) + ->willReturn('https://example.com/confirm.php?a=1'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('UIDX'), + format: OutputFormat::Html, + ); + + $resolver = new ConfirmationUrlValueResolver($this->config); + $result = $resolver($ctx); + + $this->assertSame('https://example.com/confirm.php?a=1&uid=UIDX', $result); + // Ensure it decodes to the right raw URL + $this->assertSame('https://example.com/confirm.php?a=1&uid=UIDX', html_entity_decode($result)); + } + + public function testTextWhenBaseHasNoQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ConfirmationUrl) + ->willReturn('https://example.com/confirm.php'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-7'), + format: OutputFormat::Text, + ); + + $resolver = new ConfirmationUrlValueResolver($this->config); + $this->assertSame('https://example.com/confirm.php?uid=U-7', $resolver($ctx)); + } + + public function testTextWhenBaseHasExistingQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ConfirmationUrl) + ->willReturn('https://example.com/confirm.php?x=9'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('UU-1'), + format: OutputFormat::Text, + ); + + $resolver = new ConfirmationUrlValueResolver($this->config); + $this->assertSame('https://example.com/confirm.php?x=9&uid=UU-1', $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ContactUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ContactUrlValueResolverTest.php new file mode 100644 index 00000000..07801cec --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ContactUrlValueResolverTest.php @@ -0,0 +1,103 @@ +config = $this->createMock(ConfigProvider::class); + } + + private function makeUser(): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId('UID-1'); + return $u; + } + + public function testName(): void + { + $resolver = new ContactUrlValueResolver($this->config); + $this->assertSame('CONTACTURL', $resolver->name()); + } + + public function testHtmlEscapesUrl(): void + { + $raw = 'https://example.com/vcard.php?a=1&b=2&x="\''; + $this->config->method('getValue') + ->with(ConfigOption::VCardUrl) + ->willReturn($raw); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Html, + ); + + $resolver = new ContactUrlValueResolver($this->config); + $result = $resolver($ctx); + + // Match implementation defaults of htmlspecialchars + $this->assertSame(htmlspecialchars($raw), $result); + } + + public function testTextReturnsPlainUrl(): void + { + $raw = 'https://example.com/vcard.php?a=1&b=2'; + $this->config->method('getValue') + ->with(ConfigOption::VCardUrl) + ->willReturn($raw); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Text, + ); + + $resolver = new ContactUrlValueResolver($this->config); + $this->assertSame($raw, $resolver($ctx)); + } + + public function testReturnsEmptyStringWhenConfigNullForHtml(): void + { + $this->config->method('getValue') + ->with(ConfigOption::VCardUrl) + ->willReturn(null); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Html, + ); + + $resolver = new ContactUrlValueResolver($this->config); + $this->assertSame('', $resolver($ctx)); + } + + public function testReturnsEmptyStringWhenConfigNullForText(): void + { + $this->config->method('getValue') + ->with(ConfigOption::VCardUrl) + ->willReturn(null); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Text, + ); + + $resolver = new ContactUrlValueResolver($this->config); + $this->assertSame('', $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ContactValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ContactValueResolverTest.php new file mode 100644 index 00000000..e11a1365 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ContactValueResolverTest.php @@ -0,0 +1,116 @@ +config = $this->createMock(ConfigProvider::class); + $this->translator = $this->createMock(TranslatorInterface::class); + } + + private function makeUser(): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId('UID-C'); + return $u; + } + + public function testPatternMatchesBothContactForms(): void + { + $resolver = new ContactValueResolver($this->config, $this->translator); + + $pattern = $resolver->pattern(); + $this->assertSame(1, preg_match($pattern, '[CONTACT]')); + $this->assertSame(1, preg_match($pattern, '[Contact:123]')); + } + + public function testHtmlReturnsAnchorWithEscapedUrlAndLabel(): void + { + $rawUrl = 'https://example.com/vcard.php?a=1&b=2&x="\''; + $this->config->method('getValue') + ->with(ConfigOption::VCardUrl) + ->willReturn($rawUrl); + + $this->translator->method('trans') + ->with('Add us to your address book') + ->willReturn('Add & keep in "book" <>'); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Html, + ); + + $resolver = new ContactValueResolver($this->config, $this->translator); + + // simulate regex matches (index 1 is optional number, can be missing) + $matches = ['[CONTACT]', null]; + + $result = $resolver($ctx, $matches); + + $expectedHref = htmlspecialchars($rawUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $expectedText = htmlspecialchars('Add & keep in "book" <>', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $expected = sprintf('%s', $expectedHref, $expectedText); + + $this->assertSame($expected, $result); + } + + public function testTextReturnsLabelColonUrlWhenLabelNonEmpty(): void + { + $this->config->method('getValue') + ->with(ConfigOption::VCardUrl) + ->willReturn('https://example.com/vcard.php'); + + $this->translator->method('trans') + ->with('Add us to your address book') + ->willReturn('Add us to your address book'); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Text, + ); + + $resolver = new ContactValueResolver($this->config, $this->translator); + $out = $resolver($ctx, ['[CONTACT]']); + + $this->assertSame('Add us to your address book: https://example.com/vcard.php', $out); + } + + public function testTextReturnsJustUrlWhenLabelEmpty(): void + { + $this->config->method('getValue') + ->with(ConfigOption::VCardUrl) + ->willReturn('https://example.com/vcard.php?x=1'); + + $this->translator->method('trans') + ->with('Add us to your address book') + ->willReturn(''); + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Text, + ); + + $resolver = new ContactValueResolver($this->config, $this->translator); + $out = $resolver($ctx, ['[CONTACT:9]', '9']); + + $this->assertSame('https://example.com/vcard.php?x=1', $out); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/FooterValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/FooterValueResolverTest.php new file mode 100644 index 00000000..73a464c1 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/FooterValueResolverTest.php @@ -0,0 +1,80 @@ +config = $this->createMock(ConfigProvider::class); + } + + private function makeUser(): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId('UID-FTR'); + return $u; + } + + public function testName(): void + { + $resolver = new FooterValueResolver($this->config, false); + $this->assertSame('FOOTER', $resolver->name()); + } + + public function testReturnsConfigForwardFooterByDefault(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ForwardFooter) + ->willReturn('Default footer'); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + + $resolver = new FooterValueResolver($this->config, false); + $this->assertSame('Default footer', $resolver($ctx)); + } + + public function testReturnsEmptyStringWhenConfigNull(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ForwardFooter) + ->willReturn(null); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + + $resolver = new FooterValueResolver($this->config, false); + $this->assertSame('', $resolver($ctx)); + } + + public function testReturnsDtoFooterWhenForwardAlternativeContentEnabledAndDtoPresent(): void + { + $dto = new MessagePrecacheDto(); + // with backslashes + $dto->footer = 'A\\B\\C'; + + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Html, + messagePrecacheDto: $dto, + ); + + // When alternative content flag is on, config should be ignored and dto footer used (with stripslashes) + $resolver = new FooterValueResolver($this->config, true); + $this->assertSame('ABC', $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolverTest.php new file mode 100644 index 00000000..7fda2280 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolverTest.php @@ -0,0 +1,146 @@ +config = $this->createMock(ConfigProvider::class); + $this->translator = $this->createMock(TranslatorInterface::class); + } + + private function makeUser(string $uid = 'U-FWD'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testPatternMatchesBothForms(): void + { + $resolver = new ForwardMessageIdValueResolver($this->config, $this->translator); + $pattern = $resolver->pattern(); + + $this->assertSame(1, preg_match($pattern, '[FORWARD:123]')); + $this->assertSame(1, preg_match($pattern, '[FORWARD:123:Share]')); + } + + public function testHtmlWithDefaultTranslatedLabelAndNoQuery(): void + { + $this->config + ->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php'); + $this->translator + ->method('trans') + ->with('This link') + ->willReturn('Click & go'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-99'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + ); + + $matches = ['[FORWARD:77]', '77']; + + $resolver = new ForwardMessageIdValueResolver($this->config, $this->translator); + $out = $resolver($ctx, $matches); + + $this->assertSame( + '' + . htmlspecialchars('Click & go', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . '', + $out + ); + } + + public function testHtmlWithCustomLabelAndExistingQuery(): void + { + $this->config + ->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php?a=1'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-A'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + ); + + $matches = ['[FORWARD:15:Share & enjoy]', '15:Share & enjoy']; + + $resolver = new ForwardMessageIdValueResolver($this->config, $this->translator); + $out = $resolver($ctx, $matches); + + $expectedHref = 'https://example.com/forward.php?a=1&uid=U-A&mid=15'; + $expectedLabel = htmlspecialchars('Share & enjoy', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $this->assertSame('' . $expectedLabel . '', $out); + } + + public function testTextWithDefaultTranslatedLabel(): void + { + $this->config + ->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php'); + $this->translator + ->method('trans') + ->with('This link') + ->willReturn('Open'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-TX'), format: OutputFormat::Text); + $matches = ['[FORWARD:3]', '3']; + + $resolver = new ForwardMessageIdValueResolver($this->config, $this->translator); + $out = $resolver($ctx, $matches); + + $this->assertSame('Open https://example.com/forward.php?uid=U-TX&mid=3', $out); + } + + public function testTextWithCustomLabelAndExistingQuery(): void + { + $this->config + ->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php?x=9'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-XY'), format: OutputFormat::Text); + $matches = ['[FORWARD:44:Share it]', '44:Share it']; + + $resolver = new ForwardMessageIdValueResolver($this->config, $this->translator); + $out = $resolver($ctx, $matches); + + $this->assertSame('Share it https://example.com/forward.php?x=9&uid=U-XY&mid=44', $out); + } + + public function testEmptyOrWhitespaceIdReturnsEmptyString(): void + { + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + $matches = ['[FORWARD: ]', ' ']; + + $resolver = new ForwardMessageIdValueResolver($this->config, $this->translator); + $this->assertSame('', $resolver($ctx, $matches)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolverTest.php new file mode 100644 index 00000000..dac64444 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolverTest.php @@ -0,0 +1,124 @@ +config = $this->createMock(ConfigProvider::class); + } + + private function makeUser(string $uid = 'U1'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new ForwardUrlValueResolver($this->config); + $this->assertSame('FORWARDURL', $resolver->name()); + } + + public function testHtmlWhenBaseHasNoQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('UID-42'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 5, + ); + + $resolver = new ForwardUrlValueResolver($this->config); + $out = $resolver($ctx); + + $this->assertSame('https://example.com/forward.php?uid=UID-42&mid=5', $out); + } + + public function testHtmlWhenBaseHasExistingQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php?a=1'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-7'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 15, + ); + + $resolver = new ForwardUrlValueResolver($this->config); + $out = $resolver($ctx); + + $this->assertSame('https://example.com/forward.php?a=1&uid=U-7&mid=15', $out); + // Raw decode should match with & between params + $this->assertSame('https://example.com/forward.php?a=1&uid=U-7&mid=15', html_entity_decode($out)); + } + + public function testTextWhenBaseHasNoQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-T'), + format: OutputFormat::Text, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 2, + ); + + $resolver = new ForwardUrlValueResolver($this->config); + $out = $resolver($ctx); + + $this->assertSame('https://example.com/forward.php?uid=U-T&mid=2', $out); + } + + public function testTextWhenBaseHasExistingQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php?x=9'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-Z'), + format: OutputFormat::Text, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 88, + ); + + $resolver = new ForwardUrlValueResolver($this->config); + $out = $resolver($ctx); + + $this->assertSame('https://example.com/forward.php?x=9&uid=U-Z&mid=88', $out); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardValueResolverTest.php new file mode 100644 index 00000000..944cf1ef --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ForwardValueResolverTest.php @@ -0,0 +1,145 @@ +config = $this->createMock(ConfigProvider::class); + $this->translator = $this->createMock(TranslatorInterface::class); + } + + private function makeUser(string $uid = 'UID-F'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new ForwardValueResolver($this->config, $this->translator); + $this->assertSame('FORWARD', $resolver->name()); + } + + public function testHtmlReturnsLinkWithEscapedHrefAndLabelNoQuery(): void + { + $this->config + ->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php'); + $this->translator + ->method('trans') + ->with('This link') + ->willReturn('Click & share "now" <>'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-1'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 77, + ); + + $resolver = new ForwardValueResolver($this->config, $this->translator); + $out = $resolver($ctx); + + $expectedHref = 'https://example.com/forward.php?uid=U-1&mid=77'; + $expectedLabel = htmlspecialchars('Click & share "now" <>', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $this->assertSame('' . $expectedLabel . ' ', $out); + } + + public function testHtmlReturnsLinkWithEscapedHrefAndLabelWithExistingQuery(): void + { + $this->config + ->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php?a=1'); + $this->translator + ->method('trans') + ->with('This link') + ->willReturn('This <&>'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-2'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 5, + ); + + $resolver = new ForwardValueResolver($this->config, $this->translator); + $out = $resolver($ctx); + + $expectedHref = 'https://example.com/forward.php?a=1&uid=U-2&mid=5'; + $expectedLabel = htmlspecialchars('This <&>', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $this->assertSame('' . $expectedLabel . ' ', $out); + $this->assertSame( + 'https://example.com/forward.php?a=1&uid=U-2&mid=5', + html_entity_decode($expectedHref) + ); + } + + public function testTextReturnsRawUrlWithTrailingSpace(): void + { + $this->config + ->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-3'), + format: OutputFormat::Text, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 9, + ); + + $resolver = new ForwardValueResolver($this->config, $this->translator); + $out = $resolver($ctx); + + $this->assertSame('https://example.com/forward.php?uid=U-3&mid=9 ', $out); + } + + public function testTextWithExistingQueryHasAmpersandAndTrailingSpace(): void + { + $this->config + ->method('getValue') + ->with(ConfigOption::ForwardUrl) + ->willReturn('https://example.com/forward.php?a=1'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('U-4'), + format: OutputFormat::Text, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + messageId: 11, + ); + + $resolver = new ForwardValueResolver($this->config, $this->translator); + $out = $resolver($ctx); + + $this->assertSame('https://example.com/forward.php?a=1&uid=U-4&mid=11 ', $out); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolverTest.php new file mode 100644 index 00000000..842e6f8e --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolverTest.php @@ -0,0 +1,73 @@ +config = $this->createMock(ConfigProvider::class); + $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); + } + + private function makeUser(string $uid = 'UID-JOU'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new JumpoffUrlValueResolver($this->config, $this->urlBuilder); + $this->assertSame('JUMPOFFURL', $resolver->name()); + } + + public function testHtmlReturnsEmptyString(): void + { + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn('https://example.com/unsub.php'); + + $this->urlBuilder->expects($this->once()) + ->method('withUid') + ->with('https://example.com/unsub.php', 'UH-1') + ->willReturn('https://example.com/unsub.php?uid=UH-1'); + + $ctx = new PlaceholderContext(user: $this->makeUser('UH-1'), format: OutputFormat::Html); + $resolver = new JumpoffUrlValueResolver($this->config, $this->urlBuilder); + $this->assertSame('', $resolver($ctx)); + } + + public function testTextReturnsPlainUrlWithJoParam(): void + { + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn('https://example.com/unsub.php?a=1'); + + $this->urlBuilder->expects($this->once()) + ->method('withUid') + ->with('https://example.com/unsub.php?a=1', 'U-T1') + ->willReturn('https://example.com/unsub.php?a=1&uid=U-T1'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-T1'), format: OutputFormat::Text); + $resolver = new JumpoffUrlValueResolver($this->config, $this->urlBuilder); + $this->assertSame('https://example.com/unsub.php?a=1&uid=U-T1&jo=1', $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffValueResolverTest.php new file mode 100644 index 00000000..521bd06b --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/JumpoffValueResolverTest.php @@ -0,0 +1,93 @@ +config = $this->createMock(ConfigProvider::class); + $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); + } + + private function makeUser(string $uid = 'UID-JO'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new JumpoffValueResolver($this->config, $this->urlBuilder); + $this->assertSame('JUMPOFF', $resolver->name()); + } + + public function testHtmlReturnsEmptyStringButBuildsUrlWithUid(): void + { + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn('https://example.com/unsubscribe.php'); + + // Even though HTML returns empty string, implementation builds URL first + $this->urlBuilder->expects($this->once()) + ->method('withUid') + ->with('https://example.com/unsubscribe.php', 'UID-H') + ->willReturn('https://example.com/unsubscribe.php?uid=UID-H'); + + $ctx = new PlaceholderContext(user: $this->makeUser('UID-H'), format: OutputFormat::Html); + $resolver = new JumpoffValueResolver($this->config, $this->urlBuilder); + + $this->assertSame('', $resolver($ctx)); + } + + public function testTextReturnsPlainUrlWithUidAndJoParamWhenNoExistingQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn('https://example.com/unsubscribe.php'); + + $this->urlBuilder->expects($this->once()) + ->method('withUid') + ->with('https://example.com/unsubscribe.php', 'U-1') + ->willReturn('https://example.com/unsubscribe.php?uid=U-1'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-1'), format: OutputFormat::Text); + $resolver = new JumpoffValueResolver($this->config, $this->urlBuilder); + + $this->assertSame('https://example.com/unsubscribe.php?uid=U-1&jo=1', $resolver($ctx)); + } + + public function testTextReturnsPlainUrlWithUidAndJoParamWhenExistingQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn('https://example.com/unsubscribe.php?a=1'); + + $this->urlBuilder->expects($this->once()) + ->method('withUid') + ->with('https://example.com/unsubscribe.php?a=1', 'U-2') + ->willReturn('https://example.com/unsubscribe.php?a=1&uid=U-2'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-2'), format: OutputFormat::Text); + $resolver = new JumpoffValueResolver($this->config, $this->urlBuilder); + + $this->assertSame('https://example.com/unsubscribe.php?a=1&uid=U-2&jo=1', $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/ListsValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/ListsValueResolverTest.php new file mode 100644 index 00000000..df0f2224 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/ListsValueResolverTest.php @@ -0,0 +1,114 @@ +repo = $this->createMock(SubscriberListRepository::class); + $this->translator = $this->createMock(TranslatorInterface::class); + } + + private function makeUser(): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId('UID-L'); + return $u; + } + + public function testName(): void + { + $resolver = new ListsValueResolver($this->repo, $this->translator, false); + $this->assertSame('LISTS', $resolver->name()); + } + + public function testReturnsTranslatedMessageWhenNoLists(): void + { + $this->repo->expects($this->once()) + ->method('getActiveListNamesForSubscriber') + ->with($this->isInstanceOf(Subscriber::class), false) + ->willReturn([]); + + $this->translator->method('trans') + ->with('Sorry, you are not subscribed to any of our newsletters with this email address.') + ->willReturn('No subscriptions'); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + $resolver = new ListsValueResolver($this->repo, $this->translator, false); + + $this->assertSame('No subscriptions', $resolver($ctx)); + } + + public function testHtmlEscapesNamesAndJoinsWithBr(): void + { + $names = ['News & Updates', 'Special ', "Quotes ' \" "]; + + $this->repo->expects($this->once()) + ->method('getActiveListNamesForSubscriber') + ->with($this->isInstanceOf(Subscriber::class), false) + ->willReturn($names); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + $resolver = new ListsValueResolver($this->repo, $this->translator, false); + + $out = $resolver($ctx); + + $expected = implode( + '
', + array_map( + static fn(string $n) => htmlspecialchars($n, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + $names + ) + ); + + $this->assertSame($expected, $out); + } + + public function testTextJoinsWithNewlinesWithoutEscaping(): void + { + $names = ['General', 'Dev & QA', 'Sales ']; + + $this->repo->expects($this->once()) + ->method('getActiveListNamesForSubscriber') + ->with($this->isInstanceOf(Subscriber::class), false) + ->willReturn($names); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + $resolver = new ListsValueResolver($this->repo, $this->translator, false); + + $out = $resolver($ctx); + + $this->assertSame(implode("\n", $names), $out); + } + + public function testRespectsShowPrivateFlagTrue(): void + { + $names = ['Private List']; + + $this->repo->expects($this->once()) + ->method('getActiveListNamesForSubscriber') + ->with($this->isInstanceOf(Subscriber::class), true) + ->willReturn($names); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + $resolver = new ListsValueResolver($this->repo, $this->translator, true); + + $this->assertSame('Private List', $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolverTest.php new file mode 100644 index 00000000..24fc705d --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolverTest.php @@ -0,0 +1,82 @@ +config = $this->createMock(ConfigProvider::class); + } + + private function makeUser(): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId('UID-PREF'); + return $u; + } + + public function testName(): void + { + $resolver = new PreferencesUrlValueResolver($this->config); + $this->assertSame('PREFERENCESURL', $resolver->name()); + } + + public function testTextUrlWithUidAppended(): void + { + $raw = 'https://example.com/prefs.php'; + $this->config->method('getValue') + ->with(ConfigOption::PreferencesUrl) + ->willReturn($raw); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + + $resolver = new PreferencesUrlValueResolver($this->config); + $this->assertSame($raw . '?uid=UID-PREF', $resolver($ctx)); + } + + public function testTextUrlUsesAmpersandWhenQueryPresent(): void + { + $raw = 'https://example.com/prefs.php?a=1'; + $this->config->method('getValue') + ->with(ConfigOption::PreferencesUrl) + ->willReturn($raw); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + + $resolver = new PreferencesUrlValueResolver($this->config); + $this->assertSame($raw . '&uid=UID-PREF', $resolver($ctx)); + } + + public function testHtmlEscapesUrlAndAppendsUid(): void + { + $raw = 'https://e.com/prefs.php?a=1&x="\''; + $this->config->method('getValue') + ->with(ConfigOption::PreferencesUrl) + ->willReturn($raw); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + + $resolver = new PreferencesUrlValueResolver($this->config); + $result = $resolver($ctx); + + $this->assertSame( + sprintf('%s%suid=%s', htmlspecialchars($raw), htmlspecialchars('&'), 'UID-PREF'), + $result + ); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesValueResolverTest.php new file mode 100644 index 00000000..f59649d8 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/PreferencesValueResolverTest.php @@ -0,0 +1,110 @@ +config = $this->createMock(ConfigProvider::class); + $this->translator = $this->createMock(TranslatorInterface::class); + } + + private function makeUser(string $uid = 'UID-PREV'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new PreferencesValueResolver($this->config, $this->translator); + $this->assertSame('PREFERENCES', $resolver->name()); + } + + public function testHtmlReturnsAnchorWithEscapedHrefAndLabelNoQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::PreferencesUrl) + ->willReturn('https://example.com/prefs.php'); + $this->translator->method('trans') + ->with('This link') + ->willReturn('Click & manage "prefs" <>'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-1'), format: OutputFormat::Html); + + $resolver = new PreferencesValueResolver($this->config, $this->translator); + $out = $resolver($ctx); + + $expectedHref = htmlspecialchars('https://example.com/prefs.php', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . htmlspecialchars('?') + . 'uid=U-1'; + $expectedLabel = htmlspecialchars('Click & manage "prefs" <>', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + $this->assertSame('' . $expectedLabel . ' ', $out); + } + + public function testHtmlReturnsAnchorWithAmpersandWhenQueryPresent(): void + { + $this->config->method('getValue') + ->with(ConfigOption::PreferencesUrl) + ->willReturn('https://example.com/prefs.php?a=1'); + $this->translator->method('trans') + ->with('This link') + ->willReturn('Go to prefs'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-2'), format: OutputFormat::Html); + + $resolver = new PreferencesValueResolver($this->config, $this->translator); + $out = $resolver($ctx); + + $expectedHref = htmlspecialchars('https://example.com/prefs.php?a=1', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . htmlspecialchars('&') + . 'uid=U-2'; + $expectedLabel = htmlspecialchars('Go to prefs', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + $this->assertSame('' . $expectedLabel . ' ', $out); + $this->assertSame('https://example.com/prefs.php?a=1&uid=U-2', $expectedHref); + } + + public function testTextReturnsUrlWithUidNoQuery(): void + { + $this->config->method('getValue') + ->with(ConfigOption::PreferencesUrl) + ->willReturn('https://example.com/prefs.php'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-3'), format: OutputFormat::Text); + $resolver = new PreferencesValueResolver($this->config, $this->translator); + + $this->assertSame('https://example.com/prefs.php?uid=U-3', $resolver($ctx)); + } + + public function testTextReturnsUrlWithUidWhenQueryPresent(): void + { + $this->config->method('getValue') + ->with(ConfigOption::PreferencesUrl) + ->willReturn('https://example.com/prefs.php?a=1'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-4'), format: OutputFormat::Text); + $resolver = new PreferencesValueResolver($this->config, $this->translator); + + $this->assertSame('https://example.com/prefs.php?a=1&uid=U-4', $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/SignatureValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/SignatureValueResolverTest.php new file mode 100644 index 00000000..03928966 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/SignatureValueResolverTest.php @@ -0,0 +1,96 @@ +config = $this->createMock(ConfigProvider::class); + } + + private function makeUser(): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId('UID-SIG'); + return $u; + } + + public function testName(): void + { + $resolver = new SignatureValueResolver($this->config); + $this->assertSame('SIGNATURE', $resolver->name()); + } + + public function testHtmlReturnsPoweredByTextWhenTextCreditsTrue(): void + { + $this->config->method('getValue') + ->with(ConfigOption::PoweredByText) + ->willReturn('Powered by phpList'); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + $resolver = new SignatureValueResolver($this->config, true); + + $this->assertSame('Powered by phpList', $resolver($ctx)); + } + + public function testHtmlReturnsEmptyWhenPoweredByTextNullAndTextCreditsTrue(): void + { + $this->config->method('getValue') + ->with(ConfigOption::PoweredByText) + ->willReturn(null); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + $resolver = new SignatureValueResolver($this->config, true); + + $this->assertSame('', $resolver($ctx)); + } + + public function testHtmlReplacesImageSrcWhenTextCreditsFalse(): void + { + $html = ''; + $this->config->method('getValue') + ->with(ConfigOption::PoweredByImage) + ->willReturn($html); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + $resolver = new SignatureValueResolver($this->config, false); + + $out = $resolver($ctx); + $this->assertStringContainsString('src="powerphplist.png"', $out); + } + + public function testHtmlReturnsEmptyWhenPoweredByImageNull(): void + { + $this->config->method('getValue') + ->with(ConfigOption::PoweredByImage) + ->willReturn(null); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + $resolver = new SignatureValueResolver($this->config, false); + + $this->assertSame('', $resolver($ctx)); + } + + public function testTextReturnsFixedSignature(): void + { + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + $resolver = new SignatureValueResolver($this->config, false); + + $this->assertSame("\n\n-- powered by phpList, www.phplist.com --\n\n", $resolver($ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolverTest.php new file mode 100644 index 00000000..530a1dc5 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolverTest.php @@ -0,0 +1,74 @@ +config = $this->createMock(ConfigProvider::class); + } + + private function makeUser(): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId('UID-SUB'); + return $u; + } + + public function testName(): void + { + $resolver = new SubscribeUrlValueResolver($this->config); + $this->assertSame('SUBSCRIBEURL', $resolver->name()); + } + + public function testHtmlEscapesUrl(): void + { + $raw = 'https://example.com/sub.php?a=1&b=2&x="\''; + $this->config->method('getValue') + ->with(ConfigOption::SubscribeUrl) + ->willReturn($raw); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + + $resolver = new SubscribeUrlValueResolver($this->config); + $this->assertSame(htmlspecialchars($raw, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), $resolver($ctx)); + } + + public function testTextReturnsPlainUrl(): void + { + $raw = 'https://example.com/sub.php?a=1&b=2'; + $this->config->method('getValue') + ->with(ConfigOption::SubscribeUrl) + ->willReturn($raw); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + $resolver = new SubscribeUrlValueResolver($this->config); + $this->assertSame($raw, $resolver($ctx)); + } + + public function testReturnsEmptyStringWhenConfigNull(): void + { + $this->config->method('getValue') + ->with(ConfigOption::SubscribeUrl) + ->willReturn(null); + + $resolver = new SubscribeUrlValueResolver($this->config); + $this->assertSame('', $resolver(new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html))); + $this->assertSame('', $resolver(new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text))); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeValueResolverTest.php new file mode 100644 index 00000000..b37774e1 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/SubscribeValueResolverTest.php @@ -0,0 +1,86 @@ +config = $this->createMock(ConfigProvider::class); + $this->translator = $this->createMock(TranslatorInterface::class); + } + + private function makeUser(): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId('UID-SV'); + return $u; + } + + public function testName(): void + { + $resolver = new SubscribeValueResolver($this->config, $this->translator); + $this->assertSame('SUBSCRIBE', $resolver->name()); + } + + public function testHtmlReturnsAnchorWithEscapedHrefAndLabel(): void + { + $rawUrl = 'https://example.com/sub.php?a=1&x="\''; + $this->config->method('getValue') + ->with(ConfigOption::SubscribeUrl) + ->willReturn($rawUrl); + + $this->translator->method('trans') + ->with('This link') + ->willReturn('Click & join "now" <>'); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + + $resolver = new SubscribeValueResolver($this->config, $this->translator); + $out = $resolver($ctx); + + $expectedHref = htmlspecialchars($rawUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $expectedLabel = htmlspecialchars('Click & join "now" <>', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $this->assertSame('' . $expectedLabel . '', $out); + } + + public function testTextReturnsPlainUrl(): void + { + $raw = 'https://example.com/sub.php?a=1&b=2'; + $this->config->method('getValue') + ->with(ConfigOption::SubscribeUrl) + ->willReturn($raw); + + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + $resolver = new SubscribeValueResolver($this->config, $this->translator); + $this->assertSame($raw, $resolver($ctx)); + } + + public function testReturnsEmptyStringWhenConfigNull(): void + { + $this->config->method('getValue') + ->with(ConfigOption::SubscribeUrl) + ->willReturn(null); + + $resolver = new SubscribeValueResolver($this->config, $this->translator); + + $this->assertSame('', $resolver(new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html))); + $this->assertSame('', $resolver(new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text))); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolverTest.php new file mode 100644 index 00000000..4ca95e8c --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolverTest.php @@ -0,0 +1,95 @@ +config = $this->createMock(ConfigProvider::class); + $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); + } + + private function makeUser(string $uid = 'UID-UNSUB'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new UnsubscribeUrlValueResolver($this->config, $this->urlBuilder); + $this->assertSame('UNSUBSCRIBEURL', $resolver->name()); + } + + public function testHtmlEscapesBuiltUrl(): void + { + $base = 'https://example.com/unsub.php?a=1&x='; + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn($base); + + $built = $base . '&uid=UID-UNSUB'; + + $this->urlBuilder + ->expects($this->once()) + ->method('withUid') + ->with($base, 'UID-UNSUB') + ->willReturn($built); + + $ctx = new PlaceholderContext(user: $this->makeUser('UID-UNSUB'), format: OutputFormat::Html); + + $resolver = new UnsubscribeUrlValueResolver($this->config, $this->urlBuilder); + $result = $resolver($ctx); + + $this->assertSame(htmlspecialchars($built, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), $result); + } + + public function testTextReturnsBuiltUrl(): void + { + $base = 'https://example.com/unsub.php?a=1'; + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn($base); + + $this->urlBuilder + ->expects($this->once()) + ->method('withUid') + ->with($base, 'U-TXT') + ->willReturn('https://example.com/unsub.php?a=1&uid=U-TXT'); + + $ctx = new PlaceholderContext(user: $this->makeUser('U-TXT'), format: OutputFormat::Text); + + $resolver = new UnsubscribeUrlValueResolver($this->config, $this->urlBuilder); + $this->assertSame('https://example.com/unsub.php?a=1&uid=U-TXT', $resolver($ctx)); + } + + public function testReturnsEmptyStringWhenConfigNullOrEmpty(): void + { + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn(null); + + $resolver = new UnsubscribeUrlValueResolver($this->config, $this->urlBuilder); + + $this->assertSame('', $resolver(new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html))); + $this->assertSame('', $resolver(new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text))); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolverTest.php new file mode 100644 index 00000000..8c33334a --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolverTest.php @@ -0,0 +1,147 @@ +config = $this->createMock(ConfigProvider::class); + $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); + $this->translator = $this->createMock(TranslatorInterface::class); + } + + private function makeUser(string $uid = 'UID-U'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new UnsubscribeValueResolver($this->config, $this->urlBuilder, $this->translator); + $this->assertSame('UNSUBSCRIBE', $resolver->name()); + } + + public function testHtmlReturnsAnchorWithEscapedHrefAndLabel(): void + { + $base = 'https://example.com/unsub.php?a=1&x='; + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn($base); + + $built = $base . '&uid=UID-H'; + $this->urlBuilder + ->expects($this->once()) + ->method('withUid') + ->with($base, 'UID-H') + ->willReturn($built); + + $this->translator + ->method('trans') + ->with('Unsubscribe') + ->willReturn('Unsubscribe & confirm "now" <>'); + + $ctx = new PlaceholderContext(user: $this->makeUser('UID-H'), format: OutputFormat::Html); + + $resolver = new UnsubscribeValueResolver($this->config, $this->urlBuilder, $this->translator); + $out = $resolver($ctx); + + $expectedHref = htmlspecialchars($built, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $expectedLabel = htmlspecialchars('Unsubscribe & confirm "now" <>', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + $this->assertSame('' . $expectedLabel . '', $out); + } + + public function testTextReturnsBuiltUrl(): void + { + $base = 'https://example.com/unsub.php?a=1'; + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn($base); + + $this->urlBuilder + ->expects($this->once()) + ->method('withUid') + ->with($base, 'UID-TXT') + ->willReturn('https://example.com/unsub.php?a=1&uid=UID-TXT'); + + $ctx = new PlaceholderContext(user: $this->makeUser('UID-TXT'), format: OutputFormat::Text); + + $resolver = new UnsubscribeValueResolver($this->config, $this->urlBuilder, $this->translator); + $this->assertSame('https://example.com/unsub.php?a=1&uid=UID-TXT', $resolver($ctx)); + } + + public function testForwardedByUsesBlacklistUrl(): void + { + $unsubscribeBase = 'https://example.com/unsub.php'; + $blacklistBase = 'https://example.com/black.php'; + + $this->config->method('getValue') + ->willReturnMap( + [ + [ConfigOption::UnsubscribeUrl, $unsubscribeBase], + [ConfigOption::BlacklistUrl, $blacklistBase], + ] + ); + + $this->urlBuilder + ->expects($this->once()) + ->method('withUid') + ->with($blacklistBase, 'UID-FWD') + ->willReturn($blacklistBase . '?uid=UID-FWD'); + + $this->translator + ->method('trans') + ->with('Unsubscribe') + ->willReturn('Unsubscribe'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('UID-FWD'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: 'someone@example.com', + ); + + $resolver = new UnsubscribeValueResolver($this->config, $this->urlBuilder, $this->translator); + $out = $resolver($ctx); + + $this->assertStringContainsString( + 'href="' + . htmlspecialchars($blacklistBase . '?uid=UID-FWD', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . '"', + $out + ); + } + + public function testReturnsEmptyStringWhenBaseMissing(): void + { + $this->config->method('getValue') + ->with(ConfigOption::UnsubscribeUrl) + ->willReturn(null); + + $resolver = new UnsubscribeValueResolver($this->config, $this->urlBuilder, $this->translator); + $this->assertSame('', $resolver(new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text))); + $this->assertSame('', $resolver(new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html))); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/UserDataSupportingResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/UserDataSupportingResolverTest.php new file mode 100644 index 00000000..8a757cd4 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/UserDataSupportingResolverTest.php @@ -0,0 +1,103 @@ +repo = $this->createMock(SubscriberRepository::class); + } + + private function makeCtx(Subscriber $user = null): PlaceholderContext + { + $u = $user ?? (function () { + $s = new Subscriber(); + $s->setEmail('user@example.com'); + $s->setUniqueId('UID-X'); + // Ensure the entity has a non-null ID for repository lookup + $rp = new \ReflectionProperty(Subscriber::class, 'id'); + $rp->setAccessible(true); + $rp->setValue($s, 42); + return $s; + })(); + + return new PlaceholderContext($u, OutputFormat::Text); + } + + public function testSupportsIsCaseInsensitiveForKnownKeys(): void + { + $resolver = new UserDataSupportingResolver($this->repo); + + $ctx = $this->makeCtx(); + $this->assertTrue($resolver->supports('confirmed', $ctx)); + $this->assertTrue($resolver->supports('CONFIRMED', $ctx)); + $this->assertTrue($resolver->supports('UniqId', $ctx)); + $this->assertFalse($resolver->supports('UNKNOWN_KEY', $ctx)); + } + + public function testResolveReturnsScalarStringForMatchingKey(): void + { + $resolver = new UserDataSupportingResolver($this->repo); + $ctx = $this->makeCtx(); + + $this->repo->expects($this->once()) + ->method('getDataById') + ->with($ctx->getUser()->getId()) + ->willReturn( + [ + 'confirmed' => true, + 'uniqid' => 'ABC123', + ] + ); + + $this->assertSame('ABC123', $resolver->resolve('uniqid', $ctx)); + } + + public function testResolveReturnsNullWhenValueNullOrEmpty(): void + { + $resolver = new UserDataSupportingResolver($this->repo); + $ctx = $this->makeCtx(); + + $this->repo->method('getDataById') + ->with($ctx->getUser()->getId()) + ->willReturn( + [ + 'uuid' => null, + 'foreignkey' => '', + ] + ); + + $this->assertNull($resolver->resolve('uuid', $ctx)); + $this->assertNull($resolver->resolve('foreignkey', $ctx)); + } + + public function testResolveReturnsNullWhenKeyAbsent(): void + { + $resolver = new UserDataSupportingResolver($this->repo); + $ctx = $this->makeCtx(); + + $this->repo->method('getDataById') + ->with($ctx->getUser()->getId()) + ->willReturn( + [ + 'confirmed' => 1, + 'uniqid' => 'Z', + ] + ); + + $this->assertNull($resolver->resolve('rssfrequency', $ctx)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php new file mode 100644 index 00000000..f68ce777 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/UserTrackValueResolverTest.php @@ -0,0 +1,92 @@ +config = $this->createMock(ConfigProvider::class); + } + + private function makeUser(string $uid = 'U-42'): Subscriber + { + $u = new Subscriber(); + $u->setEmail('user@example.com'); + $u->setUniqueId($uid); + return $u; + } + + public function testName(): void + { + $resolver = new UserTrackValueResolver($this->config, 'https://api.example'); + $this->assertSame('USERTRACK', $resolver->name()); + } + + public function testReturnsEmptyForTextFormat(): void + { + $resolver = new UserTrackValueResolver($this->config, 'https://api.example'); + $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + $this->assertSame('', $resolver($ctx)); + } + + public function testHtmlUsesConfigDomainWhenAvailable(): void + { + $this->config->method('getValue') + ->with(ConfigOption::Domain) + ->willReturn('example.com'); + + $resolver = new UserTrackValueResolver($this->config, 'https://api.example'); + + $ctx = new PlaceholderContext( + user: $this->makeUser('UID-XYZ'), + format: OutputFormat::Html, + messagePrecacheDto: null, + locale: 'en', + forwardedBy: null, + 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); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php b/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php index e2a1d719..36bb5626 100644 --- a/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Configuration\Service; +use PhpList\Core\Domain\Configuration\Model\Dto\PlaceholderContext; use PhpList\Core\Domain\Configuration\Service\PlaceholderResolver; use PHPUnit\Framework\TestCase; @@ -12,31 +13,33 @@ */ final class PlaceholderResolverTest extends TestCase { - public function testNullAndEmptyAreReturnedAsIs(): void + public function testEmptyAreReturnedAsIs(): void { $resolver = new PlaceholderResolver(); + $placeholderContext = $this->createMock(PlaceholderContext::class); - $this->assertNull($resolver->resolve(null)); - $this->assertSame('', $resolver->resolve('')); + $this->assertSame('', $resolver->resolve('', $placeholderContext)); } public function testUnregisteredTokensRemainUnchanged(): void { $resolver = new PlaceholderResolver(); + $placeholderContext = $this->createMock(PlaceholderContext::class); $input = 'Hello [NAME], click [UNSUBSCRIBEURL] to opt out.'; - $this->assertSame($input, $resolver->resolve($input)); + $this->assertSame($input, $resolver->resolve($input, $placeholderContext)); } public function testCaseInsensitiveTokenResolution(): void { $resolver = new PlaceholderResolver(); $resolver->register('unsubscribeurl', fn () => 'https://u.example/u/123'); + $placeholderContext = $this->createMock(PlaceholderContext::class); $input = 'Click [UnSubscribeUrl]'; $expect = 'Click https://u.example/u/123'; - $this->assertSame($expect, $resolver->resolve($input)); + $this->assertSame($expect, $resolver->resolve($input, $placeholderContext)); } public function testMultipleDifferentTokensAreResolved(): void @@ -44,16 +47,18 @@ public function testMultipleDifferentTokensAreResolved(): void $resolver = new PlaceholderResolver(); $resolver->register('NAME', fn () => 'Ada'); $resolver->register('EMAIL', fn () => 'ada@example.com'); + $placeholderContext = $this->createMock(PlaceholderContext::class); $input = 'Hi [NAME] <[email]>'; $expect = 'Hi Ada '; - $this->assertSame($expect, $resolver->resolve($input)); + $this->assertSame($expect, $resolver->resolve($input, $placeholderContext)); } public function testAdjacentAndRepeatedTokens(): void { $resolver = new PlaceholderResolver(); + $placeholderContext = $this->createMock(PlaceholderContext::class); $count = 0; $resolver->register('X', function () use (&$count) { @@ -64,7 +69,7 @@ public function testAdjacentAndRepeatedTokens(): void $input = 'Start [x][X]-[x] End'; $expect = 'Start VV-V End'; - $this->assertSame($expect, $resolver->resolve($input)); + $this->assertSame($expect, $resolver->resolve($input, $placeholderContext)); $this->assertSame(3, $count); } @@ -72,21 +77,23 @@ public function testDigitsAndUnderscoresInToken(): void { $resolver = new PlaceholderResolver(); $resolver->register('USER_2', fn () => 'Bob#2'); + $placeholderContext = $this->createMock(PlaceholderContext::class); $input = 'Hello [user_2]!'; $expect = 'Hello Bob#2!'; - $this->assertSame($expect, $resolver->resolve($input)); + $this->assertSame($expect, $resolver->resolve($input, $placeholderContext)); } public function testUnknownTokensArePreservedVerbatim(): void { $resolver = new PlaceholderResolver(); $resolver->register('KNOWN', fn () => 'K'); + $placeholderContext = $this->createMock(PlaceholderContext::class); $input = 'A[UNKNOWN]B[KNOWN]C'; $expect = 'A[UNKNOWN]BKC'; - $this->assertSame($expect, $resolver->resolve($input)); + $this->assertSame($expect, $resolver->resolve($input, $placeholderContext)); } } diff --git a/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php b/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php index 0c7f7dfd..5a83a739 100644 --- a/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php +++ b/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php @@ -5,8 +5,13 @@ namespace PhpList\Core\Tests\Unit\Domain\Configuration\Service; use PhpList\Core\Domain\Configuration\Model\ConfigOption; -use PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; +use PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder; +use PhpList\Core\Domain\Configuration\Service\Placeholder\ConfirmationUrlValueResolver; +use PhpList\Core\Domain\Configuration\Service\Placeholder\PreferencesUrlValueResolver; +use PhpList\Core\Domain\Configuration\Service\Placeholder\SubscribeUrlValueResolver; +use PhpList\Core\Domain\Configuration\Service\Placeholder\UnsubscribeUrlValueResolver; use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; @@ -20,7 +25,6 @@ final class UserPersonalizerTest extends TestCase { private ConfigProvider&MockObject $config; - private LegacyUrlBuilder&MockObject $urlBuilder; private SubscriberRepository&MockObject $subRepo; private SubscriberAttributeValueRepository&MockObject $attrRepo; private AttributeValueResolver&MockObject $attrResolver; @@ -29,17 +33,22 @@ final class UserPersonalizerTest extends TestCase protected function setUp(): void { $this->config = $this->createMock(ConfigProvider::class); - $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); $this->subRepo = $this->createMock(SubscriberRepository::class); $this->attrRepo = $this->createMock(SubscriberAttributeValueRepository::class); $this->attrResolver = $this->createMock(AttributeValueResolver::class); $this->personalizer = new UserPersonalizer( - $this->config, - $this->urlBuilder, - $this->subRepo, - $this->attrRepo, - $this->attrResolver + config: $this->config, + subscriberRepository: $this->subRepo, + attributesRepository: $this->attrRepo, + attributeValueResolver: $this->attrResolver, + unsubscribeUrlValueResolver: new UnsubscribeUrlValueResolver( + config: $this->config, + urlBuilder: new LegacyUrlBuilder() + ), + confirmationUrlValueResolver: new ConfirmationUrlValueResolver($this->config), + preferencesUrlValueResolver: new PreferencesUrlValueResolver($this->config), + subscribeUrlValueResolver: new SubscribeUrlValueResolver($this->config), ); } @@ -51,7 +60,7 @@ public function testReturnsOriginalWhenSubscriberNotFound(): void ->with('nobody@example.com') ->willReturn(null); - $result = $this->personalizer->personalize('Hello [EMAIL]', 'nobody@example.com'); + $result = $this->personalizer->personalize('Hello [EMAIL]', 'nobody@example.com', OutputFormat::Text); $this->assertSame('Hello [EMAIL]', $result); } @@ -84,11 +93,6 @@ public function testBuiltInPlaceholdersAreResolved(): void }; }); - // LegacyUrlBuilder glue behavior - $this->urlBuilder - ->method('withUid') - ->willReturnCallback(fn(string $base, string $u) => $base . '?uid=' . $u); - $this->attrRepo ->expects($this->once()) ->method('getForSubscriber') @@ -97,21 +101,20 @@ public function testBuiltInPlaceholdersAreResolved(): void $input = 'Email: [EMAIL] Unsub: [UNSUBSCRIBEURL] - Conf: [confirmationurl] + Conf: [CONFIRMATIONURL] Prefs: [PREFERENCESURL] Sub: [SUBSCRIBEURL] Domain: [DOMAIN] Website: [WEBSITE]'; - $result = $this->personalizer->personalize($input, $email); + $result = $this->personalizer->personalize($input, $email, OutputFormat::Text); $this->assertStringContainsString('Email: ada@example.com', $result); - // trailing space is expected after URL placeholders - $this->assertStringContainsString('Unsub: https://u.example/unsub?uid=U123 ', $result); - $this->assertStringContainsString('Conf: https://u.example/confirm?uid=U123 ', $result); - $this->assertStringContainsString('Prefs: https://u.example/prefs?uid=U123 ', $result); - $this->assertStringContainsString('Sub: https://u.example/subscribe ', $result); + $this->assertStringContainsString('Unsub: https://u.example/unsub?uid=U123', $result); + $this->assertStringContainsString('Conf: https://u.example/confirm?uid=U123', $result); + $this->assertStringContainsString('Prefs: https://u.example/prefs?uid=U123', $result); + $this->assertStringContainsString('Sub: https://u.example/subscribe', $result); $this->assertStringContainsString('Domain: example.org', $result); $this->assertStringContainsString('Website: site.example.org', $result); } @@ -141,8 +144,6 @@ public function testDynamicUserAttributesAreResolvedCaseInsensitive(): void [ConfigOption::Website, 'site.example.org'], ]); - $this->urlBuilder->method('withUid')->willReturnCallback(fn(string $b, string $u) => $b . '?uid=' . $u); - // Build a fake attribute value entity with definition NAME => "Full Name" $attrDefinition = $this->createMock(SubscriberAttributeDefinition::class); $attrDefinition->method('getName')->willReturn('Full_Name2'); @@ -163,7 +164,7 @@ public function testDynamicUserAttributesAreResolvedCaseInsensitive(): void ->willReturn('Bob #2'); $input = 'Hello [full_name2], your email is [email].'; - $result = $this->personalizer->personalize($input, $email); + $result = $this->personalizer->personalize($input, $email, OutputFormat::Text); $this->assertSame('Hello Bob #2, your email is bob@example.com.', $result); } @@ -188,8 +189,6 @@ public function testMultipleOccurrencesAndAdjacency(): void [ConfigOption::Website, 'w.x.tld'], ]); - $this->urlBuilder->method('withUid')->willReturnCallback(fn(string $b, string $u) => $b . '?uid=' . $u); - // Two attributes: FOO & BAR $defFoo = $this->createMock(SubscriberAttributeDefinition::class); $defFoo->method('getName')->willReturn('FOO'); @@ -211,8 +210,8 @@ public function testMultipleOccurrencesAndAdjacency(): void ]); $input = '[foo][BAR]-[email]-[UNSUBSCRIBEURL]'; - $out = $this->personalizer->personalize($input, $email); + $out = $this->personalizer->personalize($input, $email, OutputFormat::Text); - $this->assertSame('FVALBVAL-eve@example.com-https://x/unsub?uid=UID42 ', $out); + $this->assertSame('FVALBVAL-eve@example.com-https://x/unsub?uid=UID42', $out); } } diff --git a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php index edf16d37..1a7a885e 100644 --- a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php @@ -4,11 +4,11 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Service; +use PhpList\Core\Domain\Identity\Exception\AttributeDefinitionCreationException; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; use PhpList\Core\Domain\Identity\Model\Dto\AdminAttributeDefinitionDto; use PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository; -use PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager; -use PhpList\Core\Domain\Identity\Exception\AttributeDefinitionCreationException; +use PhpList\Core\Domain\Identity\Service\Manager\AdminAttributeDefinitionManager; use PhpList\Core\Domain\Identity\Validator\AttributeTypeValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php b/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php index d0ea805c..697798b7 100644 --- a/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php @@ -4,12 +4,12 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Service; +use PhpList\Core\Domain\Identity\Exception\AdminAttributeCreationException; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; use PhpList\Core\Domain\Identity\Model\AdminAttributeValue; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Repository\AdminAttributeValueRepository; -use PhpList\Core\Domain\Identity\Service\AdminAttributeManager; -use PhpList\Core\Domain\Identity\Exception\AdminAttributeCreationException; +use PhpList\Core\Domain\Identity\Service\Manager\AdminAttributeManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php b/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php index 8a61b4fd..0a56460f 100644 --- a/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php @@ -8,7 +8,7 @@ use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Model\Dto\CreateAdministratorDto; use PhpList\Core\Domain\Identity\Model\Dto\UpdateAdministratorDto; -use PhpList\Core\Domain\Identity\Service\AdministratorManager; +use PhpList\Core\Domain\Identity\Service\Manager\AdministratorManager; use PhpList\Core\Security\HashGenerator; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php index b9b53039..fd7aae53 100644 --- a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php @@ -5,18 +5,18 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Service; use DateTime; -use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest; use PhpList\Core\Domain\Identity\Model\Administrator; -use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; +use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; -use PhpList\Core\Domain\Identity\Service\PasswordManager; +use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; +use PhpList\Core\Domain\Identity\Service\Manager\PasswordManager; use PhpList\Core\Domain\Messaging\Message\PasswordResetMessage; use PhpList\Core\Security\HashGenerator; -use Symfony\Component\Messenger\Envelope; -use Symfony\Component\Messenger\MessageBusInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Contracts\Translation\TranslatorInterface; class PasswordManagerTest extends TestCase diff --git a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php index da620f12..e655f4a5 100644 --- a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php @@ -8,7 +8,7 @@ use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; -use PhpList\Core\Domain\Identity\Service\SessionManager; +use PhpList\Core\Domain\Identity\Service\Manager\SessionManager; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php index 9fa4b4f6..dd562504 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php @@ -6,7 +6,8 @@ use Doctrine\ORM\EntityManagerInterface; use Exception; -use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage; use PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler; use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; @@ -17,6 +18,7 @@ use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; use PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder; +use PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder; use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; use PhpList\Core\Domain\Messaging\Service\MailSizeChecker; use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; @@ -31,6 +33,7 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; +use ReflectionClass; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; use Symfony\Component\Translation\Translator; @@ -65,18 +68,10 @@ protected function setUp(): void $this->precacheService = $this->createMock(MessagePrecacheService::class); $this->cache = $this->createMock(CacheInterface::class); $this->symfonyMailer = $this->createMock(MailerInterface::class); - $userPersonalizer = $this->createMock(UserPersonalizer::class); $timeLimiter->method('start'); $timeLimiter->method('shouldStop')->willReturn(false); - // Ensure personalization returns original text so assertions on replaced links remain valid - $userPersonalizer - ->method('personalize') - ->willReturnCallback(function (string $text) { - return $text; - }); - $this->handler = new CampaignProcessorMessageHandler( mailer: $this->symfonyMailer, rateLimitedCampaignMailer: $this->mailer, @@ -92,11 +87,12 @@ protected function setUp(): void subscriberHistoryManager: $this->createMock(SubscriberHistoryManager::class), messageRepository: $this->messageRepository, precacheService: $this->precacheService, - userPersonalizer: $userPersonalizer, messageDataLoader: $this->createMock(MessageDataLoader::class), - emailBuilder: $this->createMock(EmailBuilder::class), + systemEmailBuilder: $this->createMock(SystemEmailBuilder::class), + campaignEmailBuilder: $this->createMock(EmailBuilder::class), mailSizeChecker: $this->createMock(MailSizeChecker::class), - messageEnvelope: 'messageEnvelope', + configProvider: $this->createMock(ConfigProvider::class), + bounceEmail: 'bounce@email.com', ); } @@ -229,29 +225,25 @@ public function testInvokeWithValidSubscriberEmail(): void ->with(1, $precached, $subscriber) ->willReturn($precached); - $this->mailer->expects($this->once()) - ->method('composeEmail') - ->with( - $this->identicalTo($campaign), - $this->identicalTo($subscriber), - $this->identicalTo($precached) - ) - ->willReturnCallback(function ($camp, $sub, $proc) use ($campaign, $subscriber, $precached) { - $this->assertSame($campaign, $camp); - $this->assertSame($subscriber, $sub); - $this->assertSame($precached, $proc); + // campaign emails are built via campaignEmailBuilder and sent via RateLimitedCampaignMailer + $campaignEmailBuilder = (new ReflectionClass($this->handler)) + ->getProperty('campaignEmailBuilder'); + /** @var EmailBuilder|MockObject $campaignBuilderMock */ + $campaignBuilderMock = $campaignEmailBuilder->getValue($this->handler); - return (new Email()) + $campaignBuilderMock->expects($this->once()) + ->method('buildPhplistEmail') + ->willReturn([ + (new Email()) ->from('news@example.com') ->to('test@example.com') ->subject('Test Subject') ->text('Test text message') - ->html('

Test HTML message

'); - }); + ->html('

Test HTML message

'), + OutputFormat::Html + ]); - $this->symfonyMailer->expects($this->once()) - ->method('send') - ->with($this->isInstanceOf(Email::class)); + $this->mailer->expects($this->any())->method('send'); $metadata->expects($this->atLeastOnce()) ->method('setStatus'); @@ -300,8 +292,21 @@ public function testInvokeWithMailerException(): void ->with(123, $precached, $subscriber) ->willReturn($precached); + // Build email and throw on rate-limited sender + $campaignEmailBuilder = (new ReflectionClass($this->handler)) + ->getProperty('campaignEmailBuilder'); + + /** @var EmailBuilder|MockObject $campaignBuilderMock */ + $campaignBuilderMock = $campaignEmailBuilder->getValue($this->handler); + $campaignBuilderMock->expects($this->once()) + ->method('buildPhplistEmail') + ->willReturn([ + (new Email())->to('test@example.com')->subject('Test Subject')->text('x'), + OutputFormat::Text + ]); + $exception = new Exception('Test exception'); - $this->symfonyMailer->expects($this->once()) + $this->mailer->expects($this->once()) ->method('send') ->willThrowException($exception); @@ -369,7 +374,25 @@ public function testInvokeWithMultipleSubscribers(): void ) ->willReturnOnConsecutiveCalls($precached, $precached); - $this->symfonyMailer->expects($this->exactly(2)) + // Configure builder to return emails for first two subscribers + $campaignEmailBuilder = (new ReflectionClass($this->handler)) + ->getProperty('campaignEmailBuilder'); + /** @var EmailBuilder|MockObject $campaignBuilderMock */ + $campaignBuilderMock = $campaignEmailBuilder->getValue($this->handler); + $campaignBuilderMock->expects($this->exactly(2)) + ->method('buildPhplistEmail') + ->willReturnOnConsecutiveCalls( + [ + (new Email())->to('test1@example.com')->subject('Test Subject')->text('x'), + OutputFormat::Text + ], + [ + (new Email())->to('test2@example.com')->subject('Test Subject')->text('x'), + OutputFormat::Text + ], + ); + + $this->mailer->expects($this->exactly(2)) ->method('send'); $metadata->expects($this->atLeastOnce()) diff --git a/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php index 6288c5f4..2a4e1ed4 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php @@ -4,13 +4,13 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\MessageHandler; +use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; use PhpList\Core\Domain\Messaging\Message\SubscriptionConfirmationMessage; use PhpList\Core\Domain\Subscription\Model\SubscriberList; use PHPUnit\Framework\TestCase; use PhpList\Core\Domain\Messaging\MessageHandler\SubscriptionConfirmationMessageHandler; use PhpList\Core\Domain\Messaging\Service\EmailService; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; -use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use Psr\Log\LoggerInterface; @@ -26,14 +26,14 @@ public function testSendsEmailWithPersonalizedContentAndListNames(): void $emailService = $this->createMock(EmailService::class); $configProvider = $this->createMock(ConfigProvider::class); $logger = $this->createMock(LoggerInterface::class); - $personalizer = $this->createMock(UserPersonalizer::class); + $userPersonalizer = $this->createMock(UserPersonalizer::class); $listRepo = $this->createMock(SubscriberListRepository::class); $handler = new SubscriptionConfirmationMessageHandler( emailService: $emailService, configProvider: $configProvider, logger: $logger, - userPersonalizer: $personalizer, + userPersonalizer: $userPersonalizer, subscriberListRepository: $listRepo ); $configProvider @@ -44,11 +44,15 @@ public function testSendsEmailWithPersonalizedContentAndListNames(): void [ConfigOption::SubscribeMessage, 'Hi {{name}}, you subscribed to: [LISTS]'], ]); - $message = new SubscriptionConfirmationMessage('alice@example.com', 'user-123', [10, 11]); + $message = new SubscriptionConfirmationMessage( + email: 'alice@example.com', + uniqueId: 'user-123', + listIds: [10, 11], + ); - $personalizer->expects($this->once()) + $userPersonalizer->expects($this->once()) ->method('personalize') - ->with('Hi {{name}}, you subscribed to: [LISTS]', 'user-123') + ->with('Hi {{name}}, you subscribed to: [LISTS]', 'alice@example.com') ->willReturn('Hi Alice, you subscribed to: [LISTS]'); $listA = $this->createMock(SubscriberList::class); @@ -95,14 +99,14 @@ public function testHandlesMissingListsGracefullyAndEmptyJoin(): void $emailService = $this->createMock(EmailService::class); $configProvider = $this->createMock(ConfigProvider::class); $logger = $this->createMock(LoggerInterface::class); - $personalizer = $this->createMock(UserPersonalizer::class); + $userPersonalizer = $this->createMock(UserPersonalizer::class); $listRepo = $this->createMock(SubscriberListRepository::class); $handler = new SubscriptionConfirmationMessageHandler( emailService: $emailService, configProvider: $configProvider, logger: $logger, - userPersonalizer: $personalizer, + userPersonalizer: $userPersonalizer, subscriberListRepository: $listRepo ); @@ -117,11 +121,11 @@ public function testHandlesMissingListsGracefullyAndEmptyJoin(): void $message->method('getUniqueId')->willReturn('user-456'); $message->method('getListIds')->willReturn([42]); - $personalizer->method('personalize') - ->with('Lists: [LISTS]', 'user-456') + $userPersonalizer->method('personalize') + ->with('Lists: [LISTS]', 'bob@example.com') ->willReturn('Lists: [LISTS]'); - $listRepo->method('find')->with(42)->willReturn(null); + $listRepo->method('getListNames')->with([42])->willReturn([]); $emailService->expects($this->once()) ->method('sendEmail') diff --git a/tests/Unit/Domain/Messaging/Service/AttachmentAdderTest.php b/tests/Unit/Domain/Messaging/Service/AttachmentAdderTest.php new file mode 100644 index 00000000..9356eb3d --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/AttachmentAdderTest.php @@ -0,0 +1,234 @@ +createMock(CacheInterface::class); + // default: firstTime returns true once per unique key + $cache->method('has')->willReturn(false); + $cache->method('set')->willReturn(true); + $onceCacheGuard = new OnceCacheGuard($cache); + + return new AttachmentAdder( + attachmentRepository: $this->attachmentRepository, + translator: $this->translator, + eventLogManager: $this->eventLogManager, + onceCacheGuard: $onceCacheGuard, + fileHelper: $this->fileHelper, + attachmentDownloadUrl: $downloadUrl, + attachmentRepositoryPath: '/repo', + ); + } + + protected function setUp(): void + { + $this->attachmentRepository = $this->createMock(AttachmentRepository::class); + $this->translator = $this->createMock(TranslatorInterface::class); + $this->eventLogManager = $this->createMock(EventLogManager::class); + $this->fileHelper = $this->createMock(FileHelper::class); + + // default translator: return the message id itself for easier asserts + $this->translator + ->method('trans') + ->willReturnCallback(static fn(string $id, array $params = []) => $id); + } + + public function testAddReturnsTrueWhenNoAttachments(): void + { + $this->attachmentRepository + ->method('findAttachmentsForMessage') + ->willReturn([]); + + $adder = $this->makeAdder(); + $email = (new Email())->to(new Address('user@example.com')); + + $this->assertTrue($adder->add($email, 123, OutputFormat::Text)); + $this->assertSame('', (string)$email->getTextBody()); + } + + public function testTextModePrependsNoticeAndLinks(): void + { + $att = $this->createMock(Attachment::class); + $att->method('getId')->willReturn(42); + $att->method('getDescription')->willReturn('Doc description'); + $this->attachmentRepository->method('findAttachmentsForMessage')->willReturn([$att]); + + $email = (new Email())->to(new Address('user@example.com')); + $adder = $this->makeAdder(downloadUrl: 'https://dl.example'); + + $ok = $adder->add($email, 10, OutputFormat::Text); + $this->assertTrue($ok); + + $body = (string)$email->getTextBody(); + $this->assertStringContainsString( + 'This message contains attachments that can be viewed with a webbrowser', + $body + ); + $this->assertStringContainsString('Doc description', $body); + $this->assertStringContainsString('Location', $body); + $this->assertStringContainsString('https://dl.example/?id=42&uid=user@example.com', $body); + } + + public function testHtmlUsesRepositoryFileIfExists(): void + { + $att = $this->createMock(Attachment::class); + $att->method('getFilename')->willReturn('stored/file.pdf'); + $att->method('getRemoteFile')->willReturn('/originals/file.pdf'); + $att->method('getMimeType')->willReturn('application/pdf'); + $att->method('getSize')->willReturn(10); + + $this->attachmentRepository + ->method('findAttachmentsForMessage') + ->willReturn([$att]); + + // repository path file exists and can be read + $this->fileHelper + ->method('isValidFile') + ->willReturnCallback( + function (string $path): bool { + return $path === '/repo/stored/file.pdf'; + } + ); + $this->fileHelper + ->method('readFileContents') + ->willReturnCallback( + function (string $path): ?string { + return $path === '/repo/stored/file.pdf' ? 'PDF-DATA' : null; + } + ); + + $email = (new Email())->to(new Address('user@example.com')); + $adder = $this->makeAdder(); + + $ok = $adder->add($email, 77, OutputFormat::Html); + $this->assertTrue($ok); + + $attachments = $email->getAttachments(); + $this->assertCount(1, $attachments); + $this->assertSame('file.pdf', $attachments[0]->getFilename()); + } + + public function testHtmlLocalFileUnreadableLogsAndReturnsFalse(): void + { + $att = $this->createMock(Attachment::class); + $att->method('getFilename')->willReturn(null); + $att->method('getRemoteFile')->willReturn('/local/missing.txt'); + $att->method('getMimeType')->willReturn('text/plain'); + $att->method('getSize')->willReturn(10); + + $this->attachmentRepository->method('findAttachmentsForMessage')->willReturn([$att]); + + // Not in repository; local path considered valid file, but cannot be read + $this->fileHelper->method('isValidFile')->willReturn(true); + $this->fileHelper->method('readFileContents')->willReturn(null); + + $this->eventLogManager->expects($this->once())->method('log'); + + $email = (new Email())->to(new Address('user@example.com')); + $adder = $this->makeAdder(); + + $ok = $adder->add($email, 501, OutputFormat::Html); + $this->assertFalse($ok); + $this->assertCount(0, $email->getAttachments()); + } + + public function testCopyFailureThrowsOnFirstTime(): void + { + $att = $this->createMock(Attachment::class); + $att->method('getFilename')->willReturn(null); + $att->method('getRemoteFile')->willReturn('/local/ok.pdf'); + $att->method('getMimeType')->willReturn('application/pdf'); + $att->method('getSize')->willReturn(10); + + $this->attachmentRepository + ->method('findAttachmentsForMessage') + ->willReturn([$att]); + + // Repository path should not exist, local file should be readable + $this->fileHelper + ->method('isValidFile') + ->willReturnCallback( + function (string $path): bool { + if ($path === '/repo/') { + // repository lookup should fail + return false; + } + return $path === '/local/ok.pdf'; + } + ); + $this->fileHelper + ->method('readFileContents') + ->willReturnCallback( + function (string $path): ?string { + return $path === '/local/ok.pdf' ? 'PDF' : null; + } + ); + // copy to repository fails + $this->fileHelper + ->method('writeFileToDirectory') + ->willReturn(null); + + $this->eventLogManager + ->expects($this->once()) + ->method('log'); + + $email = (new Email())->to(new Address('user@example.com')); + $adder = $this->makeAdder(); + + $this->expectException(AttachmentCopyException::class); + $adder->add($email, 321, OutputFormat::Html); + } + + public function testMissingAttachmentThrowsOnFirstTime(): void + { + $att = $this->createMock(Attachment::class); + $att->method('getFilename')->willReturn(null); + $att->method('getRemoteFile')->willReturn('/local/not-exist.bin'); + $att->method('getMimeType')->willReturn('application/octet-stream'); + $att->method('getSize')->willReturn(5); + + $this->attachmentRepository + ->method('findAttachmentsForMessage') + ->willReturn([$att]); + + // Not in repository; local path invalid -> missing + $this->fileHelper + ->method('isValidFile') + ->willReturn(false); + + $this->eventLogManager + ->expects($this->once()) + ->method('log'); + + $email = (new Email())->to(new Address('user@example.com')); + $adder = $this->makeAdder(); + + $this->expectException(AttachmentCopyException::class); + $adder->add($email, 999, OutputFormat::Html); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php index 1c7e809e..900ada89 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php @@ -5,19 +5,27 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder; use PhpList\Core\Domain\Configuration\Model\ConfigOption; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; +use PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder; use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; +use PhpList\Core\Domain\Messaging\Exception\AttachmentException; +use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; +use PhpList\Core\Domain\Messaging\Service\AttachmentAdder; use PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder; -use PhpList\Core\Domain\Messaging\Service\SystemMailConstructor; +use PhpList\Core\Domain\Messaging\Service\Constructor\MailContentBuilderInterface; use PhpList\Core\Domain\Messaging\Service\TemplateImageEmbedder; 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\SubscriberHistoryManager; +use PhpList\Core\Domain\Common\PdfGenerator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; +use Symfony\Contracts\Translation\TranslatorInterface; class EmailBuilderTest extends TestCase { @@ -26,9 +34,13 @@ class EmailBuilderTest extends TestCase private UserBlacklistRepository&MockObject $blacklistRepository; private SubscriberHistoryManager&MockObject $subscriberHistoryManager; private SubscriberRepository&MockObject $subscriberRepository; - private SystemMailConstructor&MockObject $systemMailConstructor; - private TemplateImageEmbedder&MockObject $templateImageEmbedder; private LoggerInterface&MockObject $logger; + private MailContentBuilderInterface&MockObject $mailConstructor; + private TemplateImageEmbedder&MockObject $templateImageEmbedder; + private LegacyUrlBuilder&MockObject $urlBuilder; + private PdfGenerator&MockObject $pdfGenerator; + private AttachmentAdder&MockObject $attachmentAdder; + private TranslatorInterface&MockObject $translator; protected function setUp(): void { @@ -37,41 +49,52 @@ protected function setUp(): void $this->blacklistRepository = $this->createMock(UserBlacklistRepository::class); $this->subscriberHistoryManager = $this->createMock(SubscriberHistoryManager::class); $this->subscriberRepository = $this->createMock(SubscriberRepository::class); - $this->systemMailConstructor = $this->getMockBuilder(SystemMailConstructor::class) - ->disableOriginalConstructor() + $this->logger = $this->createMock(LoggerInterface::class); + $this->mailConstructor = $this->getMockBuilder(MailContentBuilderInterface::class) ->onlyMethods(['__invoke']) ->getMock(); $this->templateImageEmbedder = $this->getMockBuilder(TemplateImageEmbedder::class) ->disableOriginalConstructor() ->onlyMethods(['__invoke']) ->getMock(); - $this->logger = $this->createMock(LoggerInterface::class); + $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); + $this->pdfGenerator = $this->createMock(PdfGenerator::class); + $this->attachmentAdder = $this->createMock(AttachmentAdder::class); + $this->translator = $this->createMock(TranslatorInterface::class); + + $this->configProvider->method('getValue')->willReturnMap( + [ + [ConfigOption::MessageFromAddress, 'from@example.com'], + [ConfigOption::MessageFromName, 'From Name'], + [ConfigOption::UnsubscribeUrl, 'https://example.com/unsubscribe'], + [ConfigOption::PreferencesUrl, 'https://example.com/prefs'], + [ConfigOption::SubscribeUrl, 'https://example.com/subscribe'], + [ConfigOption::AdminAddress, 'admin@example.com'], + [ConfigOption::AlwaysSendTextDomains, ''], + ] + ); } - private function createBuilder( - string $googleSenderId = 'gsender', + private function makeBuilder( + string $googleSenderId = 'g-123', bool $useAmazonSes = false, bool $usePrecedenceHeader = true, bool $devVersion = true, ?string $devEmail = 'dev@example.com', ): EmailBuilder { - // Default config values used by EmailBuilder - $this->configProvider->method('getValue')->willReturnMap([ - [ConfigOption::MessageFromAddress, 'from@example.com'], - [ConfigOption::MessageFromName, 'From Name'], - [ConfigOption::UnsubscribeUrl, 'https://example.com/unsubscribe'], - [ConfigOption::WordWrap, 0], - ]); - return new EmailBuilder( configProvider: $this->configProvider, eventLogManager: $this->eventLogManager, blacklistRepository: $this->blacklistRepository, subscriberHistoryManager: $this->subscriberHistoryManager, subscriberRepository: $this->subscriberRepository, - systemMailConstructor: $this->systemMailConstructor, - templateImageEmbedder: $this->templateImageEmbedder, logger: $this->logger, + mailConstructor: $this->mailConstructor, + templateImageEmbedder: $this->templateImageEmbedder, + urlBuilder: $this->urlBuilder, + pdfGenerator: $this->pdfGenerator, + attachmentAdder: $this->attachmentAdder, + translator: $this->translator, googleSenderId: $googleSenderId, useAmazonSes: $useAmazonSes, usePrecedenceHeader: $usePrecedenceHeader, @@ -83,22 +106,31 @@ private function createBuilder( public function testReturnsNullWhenMissingRecipient(): void { $this->eventLogManager->expects($this->once())->method('log'); + $dto = new MessagePrecacheDto(); + $dto->to = null; + $dto->subject = 'Subj'; + $dto->content = 'Body'; + $dto->fromEmail = 'from@example.com'; - $builder = $this->createBuilder(); - $result = $builder->buildPhplistEmail(messageId: 123, to: null, subject: 'Subj', message: 'Body'); + $builder = $this->makeBuilder(); + $result = $builder->buildPhplistEmail(messageId: 1, data: $dto); $this->assertNull($result); } public function testReturnsNullWhenMissingSubject(): void { $this->eventLogManager->expects($this->once())->method('log'); + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->content = 'Body'; + $dto->fromEmail = 'from@example.com'; - $builder = $this->createBuilder(); - $result = $builder->buildPhplistEmail(messageId: 123, to: 'user@example.com', subject: null, message: 'Body'); + $builder = $this->makeBuilder(); + $result = $builder->buildPhplistEmail(messageId: 1, data: $dto); $this->assertNull($result); } - public function testReturnsNullWhenBlacklistedAndHistoryUpdated(): void + public function testBlacklistReturnsNullAndMarksHistory(): void { $this->blacklistRepository->method('isEmailBlacklisted')->willReturn(true); @@ -106,89 +138,283 @@ public function testReturnsNullWhenBlacklistedAndHistoryUpdated(): void ->disableOriginalConstructor() ->onlyMethods(['setBlacklisted']) ->getMock(); - $subscriber->expects($this->once())->method('setBlacklisted')->with(true); + $subscriber + ->expects($this->once()) + ->method('setBlacklisted') + ->with(true); + $this->subscriberRepository + ->method('findOneByEmail') + ->with('user@example.com') + ->willReturn($subscriber); + $this->subscriberHistoryManager + ->expects($this->once()) + ->method('addHistory'); - $this->subscriberRepository->method('findOneByEmail')->with('user@example.com')->willReturn($subscriber); - $this->subscriberHistoryManager->expects($this->once())->method('addHistory'); + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->subject = 'Hello'; + $dto->content = 'B'; + $dto->fromEmail = 'from@example.com'; - $builder = $this->createBuilder(); - $result = $builder->buildPhplistEmail(messageId: 55, to: 'user@example.com', subject: 'Hi', message: 'Body'); + $builder = $this->makeBuilder(); + $result = $builder->buildPhplistEmail(messageId: 5, data: $dto); $this->assertNull($result); } - public function testBuildsEmailWithExpectedHeadersAndBodiesInDevMode(): void + public function testBuildsHtmlPreferredWithAttachments(): void { - $this->blacklistRepository->method('isEmailBlacklisted')->willReturn(false); + $this->blacklistRepository + ->method('isEmailBlacklisted') + ->willReturn(false); + $dto = new MessagePrecacheDto(); + $dto->to = 'real@example.com'; + $dto->subject = 'Subject'; + $dto->content = 'TEXT'; + $dto->fromEmail = 'from@example.com'; + $dto->fromName = 'From Name'; - // SystemMailConstructor returns both html and text bodies - $this->systemMailConstructor->expects($this->once()) + $this->mailConstructor + ->expects($this->once()) ->method('__invoke') - ->with('Body', 'Subject') + ->with($dto) ->willReturn(['

HTML

', 'TEXT']); - - // TemplateImageEmbedder invoked when HTML present - $this->templateImageEmbedder->expects($this->once()) + $this->templateImageEmbedder + ->expects($this->once()) ->method('__invoke') ->with(html: '

HTML

', messageId: 777) ->willReturn('

HTML

'); + $this->attachmentAdder + ->expects($this->once()) + ->method('add') + ->with($this->isInstanceOf(Email::class), 777, OutputFormat::Html) + ->willReturn(true); - $builder = $this->createBuilder( - googleSenderId: 'g-123', - useAmazonSes: false, - usePrecedenceHeader: true, - devVersion: true, - devEmail: 'dev@example.com' - ); - - $email = $builder->buildPhplistEmail( + $builder = $this->makeBuilder(devVersion: true, devEmail: 'dev@example.com'); + [$email, $sentAs] = $builder->buildPhplistEmail( messageId: 777, - to: 'real@example.com', - subject: 'Subject', - message: 'Body', + data: $dto, skipBlacklistCheck: false, - inBlast: true + inBlast: true, + htmlPref: false, ); - $this->assertNotNull($email); + $this->assertSame(OutputFormat::TextAndHtml, $sentAs); + $this->assertSame('TEXT', $email->getTextBody()); + $this->assertSame('

HTML

', $email->getHtmlBody()); - // Recipient is redirected to dev email in dev mode - $this->assertCount(1, $email->getTo()); - $this->assertInstanceOf(Address::class, $email->getTo()[0]); + // Recipient redirected in dev mode $this->assertSame('dev@example.com', $email->getTo()[0]->getAddress()); - $this->assertSame('', $email->getTo()[0]->getName()); + $this->assertSame('real@example.com', $email->getHeaders()->get('X-Originally-To')->getBodyAsString()); + } - // Basic headers - $headers = $email->getHeaders(); - $this->assertTrue($headers->has('X-MessageID')); - $this->assertSame('777', $headers->get('X-MessageID')->getBodyAsString()); + public function testPrefersTextWhenNoHtmlContent(): void + { + $this->configProvider + ->method('getValue') + ->willReturnMap([ + [ConfigOption::MessageFromAddress, 'from@example.com'], + [ConfigOption::MessageFromName, 'From Name'], + [ConfigOption::UnsubscribeUrl, 'https://example.com/unsubscribe'], + [ConfigOption::PreferencesUrl, 'https://example.com/prefs'], + [ConfigOption::SubscribeUrl, 'https://example.com/subscribe'], + [ConfigOption::AdminAddress, 'admin@example.com'], + [ConfigOption::AlwaysSendTextDomains, ''], + ]); + + $this->blacklistRepository->method('isEmailBlacklisted')->willReturn(false); + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->subject = 'Subject'; + $dto->content = 'TEXT'; + $dto->fromEmail = 'from@example.com'; + + // No HTML content provided -> should choose text-only + $this->mailConstructor + ->method('__invoke') + ->willReturn([null, 'TEXT']); + $this->attachmentAdder + ->expects($this->once()) + ->method('add') + ->with($this->isInstanceOf(Email::class), 9, OutputFormat::Text) + ->willReturn(true); + + $builder = $this->makeBuilder(devVersion: false, devEmail: null); + [$email, $sentAs] = $builder->buildPhplistEmail(messageId: 9, data: $dto, htmlPref: true); + + $this->assertSame(OutputFormat::Text, $sentAs); + $this->assertSame('TEXT', $email->getTextBody()); + $this->assertNull($email->getHtmlBody()); + $this->assertSame('user@example.com', $email->getTo()[0]->getAddress()); + } + + public function testPdfFormatWhenHtmlPreferred(): void + { + $this->blacklistRepository + ->method('isEmailBlacklisted') + ->willReturn(false); + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->subject = 'Subject'; + $dto->content = 'TEXT'; + $dto->fromEmail = 'from@example.com'; + $dto->sendFormat = 'pdf'; - $this->assertTrue($headers->has('X-ListMember')); - $this->assertSame('dev@example.com', $headers->get('X-ListMember')->getBodyAsString()); + $this->mailConstructor + ->method('__invoke') + ->willReturn(['H', 'TEXT']); + $this->pdfGenerator + ->expects($this->once()) + ->method('createPdfBytes') + ->with('TEXT') + ->willReturn('%PDF%'); + $this->attachmentAdder + ->expects($this->once()) + ->method('add') + ->with($this->isInstanceOf(Email::class), 42, OutputFormat::Html) + ->willReturn(true); + + $builder = $this->makeBuilder(devVersion: false, devEmail: null); + [$email, $sentAs] = $builder->buildPhplistEmail(messageId: 42, data: $dto, htmlPref: true); + + $this->assertSame(OutputFormat::Pdf, $sentAs); + $this->assertCount(1, $email->getAttachments()); + } + + public function testTextAndPdfFormatWhenNotHtmlPreferred(): void + { + $this->blacklistRepository + ->method('isEmailBlacklisted') + ->willReturn(false); + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->subject = 'Subject'; + $dto->content = 'TEXT'; + $dto->fromEmail = 'from@example.com'; + $dto->sendFormat = 'text and pdf'; + + $this->mailConstructor + ->method('__invoke') + ->willReturn([null, 'TEXT']); + $this->attachmentAdder + ->expects($this->once()) + ->method('add') + ->with($this->isInstanceOf(Email::class), 43, OutputFormat::Text) + ->willReturn(true); + $this->pdfGenerator + ->expects($this->never()) + ->method('createPdfBytes'); + + $builder = $this->makeBuilder(devVersion: false, devEmail: null); + [$email, $sentAs] = $builder->buildPhplistEmail(messageId: 43, data: $dto, htmlPref: false); - $this->assertTrue($headers->has('Feedback-ID')); - $this->assertSame('777:g-123', $headers->get('Feedback-ID')->getBodyAsString()); + $this->assertSame(OutputFormat::Text, $sentAs); + $this->assertSame('TEXT', $email->getTextBody()); + $this->assertCount(0, $email->getAttachments()); + } - // Precedence: bulk when not using Amazon SES and enabled - $this->assertTrue($headers->has('Precedence')); - $this->assertSame('bulk', $headers->get('Precedence')->getBodyAsString()); + public function testReplyToExplicitAndTestMailFallback(): void + { + $this->blacklistRepository + ->method('isEmailBlacklisted') + ->willReturn(false); + + // explicit reply-to + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->subject = 'Subject'; + $dto->content = 'TEXT'; + $dto->fromEmail = 'from@example.com'; + $dto->replyToEmail = 'reply@example.com'; + $dto->replyToName = 'Rep'; + $this->mailConstructor + ->method('__invoke') + ->willReturn([null, 'TEXT']); + $this->attachmentAdder + ->method('add') + ->willReturn(true); - // X-Blast for campaign blasts - $this->assertTrue($headers->has('X-Blast')); - $this->assertSame('1', $headers->get('X-Blast')->getBodyAsString()); + $builder = $this->makeBuilder(devVersion: false, devEmail: null); + [$email] = $builder->buildPhplistEmail(messageId: 50, data: $dto); + $this->assertSame('reply@example.com', $email->getReplyTo()[0]->getAddress()); - // List-Unsubscribe includes the email - $this->assertTrue($headers->has('List-Unsubscribe')); - $this->assertStringContainsString( - 'email=dev%40example.com', + // no reply-to, but test mail -> uses AdminAddress + $dto2 = new MessagePrecacheDto(); + $dto2->to = 'user@example.com'; + $dto2->subject = 'Subject'; + $dto2->content = 'TEXT'; + $dto2->fromEmail = 'from@example.com'; + $this->mailConstructor + ->method('__invoke') + ->willReturn([null, 'TEXT']); + + $this->translator + ->method('trans') + ->with('(test)') + ->willReturn('(test)'); + + [$email2] = $builder->buildPhplistEmail(messageId: 51, data: $dto2, isTestMail: true); + $this->assertSame('admin@example.com', $email2->getReplyTo()[0]->getAddress()); + $this->assertStringStartsWith('(test) ', $email2->getSubject()); + } + + public function testApplyCampaignHeaders(): void + { + $subscriber = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->onlyMethods(['getUniqueId']) + ->getMock(); + $subscriber + ->method('getUniqueId') + ->willReturn('abc123'); + + $this->urlBuilder + ->method('withUid') + ->willReturnCallback( + function (string $url, string $uid): string { + return $url . '?uid=' . $uid; + } + ); + + $builder = $this->makeBuilder(); + $email = (new Email())->to(new Address('user@example.com')); + $email = $builder->applyCampaignHeaders($email, $subscriber); + + $headers = $email->getHeaders(); + $this->assertSame('', $headers->get('List-Help')->getBodyAsString()); + $this->assertSame( + '', $headers->get('List-Unsubscribe')->getBodyAsString() ); + $this->assertSame('List-Unsubscribe=One-Click', $headers->get('List-Unsubscribe-Post')->getBodyAsString()); + $this->assertSame('', $headers->get('List-Subscribe')->getBodyAsString()); + // In implementation, adminAddress uses UnsubscribeUrl option (likely a bug); we assert the behavior as-is + $this->assertSame('', $headers->get('List-Owner')->getBodyAsString()); + } - // In dev mode with redirected recipient, no "X-Originally to" header is set per current implementation - $this->assertFalse($headers->has('X-Originally to')); + public function testAttachmentAdderFailureThrows(): void + { + $this->blacklistRepository + ->method('isEmailBlacklisted') + ->willReturn(false); + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->subject = 'Subject'; + $dto->content = 'TEXT'; + $dto->fromEmail = 'from@example.com'; - // Bodies - $this->assertSame('

HTML

', $email->getHtmlBody()); - $this->assertSame('TEXT', $email->getTextBody()); - $this->assertSame('Subject', $email->getSubject()); + $this->mailConstructor + ->method('__invoke') + ->willReturn(['H', 'TEXT']); + $this->templateImageEmbedder + ->method('__invoke') + ->willReturn('H'); + $this->attachmentAdder + ->method('add') + ->willReturn(false); + + $builder = $this->makeBuilder(devVersion: false, devEmail: null); + + $this->expectException(AttachmentException::class); + $builder->buildPhplistEmail(messageId: 60, data: $dto, htmlPref: true); } } diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php index 17d93eae..ed4645ed 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php @@ -25,7 +25,6 @@ public function testBuildsMessageFormatSuccessfully(): void $this->assertSame(true, $messageFormat->isHtmlFormatted()); $this->assertSame('html', $messageFormat->getSendFormat()); - $this->assertEqualsCanonicalizing(['html', 'text'], $messageFormat->getFormatOptions()); } public function testThrowsExceptionOnInvalidDto(): void diff --git a/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php new file mode 100644 index 00000000..d1b891c2 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php @@ -0,0 +1,205 @@ +configProvider = $this->createMock(ConfigProvider::class); + $this->eventLogManager = $this->createMock(EventLogManager::class); + $this->blacklistRepository = $this->createMock(UserBlacklistRepository::class); + $this->subscriberHistoryManager = $this->createMock(SubscriberHistoryManager::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->mailConstructor = $this->getMockBuilder(MailContentBuilderInterface::class) + ->onlyMethods(['__invoke']) + ->getMock(); + $this->templateImageEmbedder = $this->getMockBuilder(TemplateImageEmbedder::class) + ->disableOriginalConstructor() + ->onlyMethods(['__invoke']) + ->getMock(); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->configProvider->method('getValue')->willReturnMap( + [ + [ConfigOption::MessageFromAddress, 'from@example.com'], + [ConfigOption::MessageFromName, 'From Name'], + [ConfigOption::UnsubscribeUrl, 'https://example.com/unsubscribe'], + ] + ); + } + + private function makeBuilder( + string $googleSenderId = 'g-123', + bool $useAmazonSes = false, + bool $usePrecedenceHeader = true, + bool $devVersion = true, + ?string $devEmail = 'dev@example.com', + ): SystemEmailBuilder { + return new SystemEmailBuilder( + configProvider: $this->configProvider, + eventLogManager: $this->eventLogManager, + blacklistRepository: $this->blacklistRepository, + subscriberHistoryManager: $this->subscriberHistoryManager, + subscriberRepository: $this->subscriberRepository, + mailConstructor: $this->mailConstructor, + templateImageEmbedder: $this->templateImageEmbedder, + logger: $this->logger, + googleSenderId: $googleSenderId, + useAmazonSes: $useAmazonSes, + usePrecedenceHeader: $usePrecedenceHeader, + devVersion: $devVersion, + devEmail: $devEmail, + ); + } + + public function testReturnsNullWhenMissingRecipient(): void + { + $this->eventLogManager->expects($this->once())->method('log'); + $dto = new MessagePrecacheDto(); + $dto->to = null; + $dto->subject = 'Subj'; + $dto->content = 'Body'; + + $builder = $this->makeBuilder(); + $result = $builder->buildPhplistEmail(messageId: 1, data: $dto); + $this->assertNull($result); + } + + public function testReturnsNullWhenMissingSubject(): void + { + $this->eventLogManager->expects($this->once())->method('log'); + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->content = 'Body'; + + $builder = $this->makeBuilder(); + $result = $builder->buildPhplistEmail(messageId: 1, data: $dto); + $this->assertNull($result); + } + + public function testReturnsNullWhenBlacklistedAndHistoryUpdated(): void + { + $this->blacklistRepository->method('isEmailBlacklisted')->willReturn(true); + + $subscriber = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->onlyMethods(['setBlacklisted']) + ->getMock(); + $subscriber->expects($this->once()) + ->method('setBlacklisted') + ->with(true); + $this->subscriberRepository + ->method('findOneByEmail') + ->with('user@example.com') + ->willReturn($subscriber); + $this->subscriberHistoryManager + ->expects($this->once()) + ->method('addHistory'); + + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->subject = 'Hello'; + $dto->content = 'B'; + + $builder = $this->makeBuilder(); + $result = $builder->buildPhplistEmail(messageId: 5, data: $dto); + $this->assertNull($result); + } + + public function testBuildsEmailWithExpectedHeadersAndBodiesInDevMode(): void + { + $this->blacklistRepository + ->method('isEmailBlacklisted') + ->willReturn(false); + $dto = new MessagePrecacheDto(); + $dto->to = 'real@example.com'; + $dto->subject = 'Subject'; + $dto->content = 'TEXT'; + + $this->mailConstructor->expects($this->once()) + ->method('__invoke') + ->with($dto) + ->willReturn(['

HTML

', 'TEXT']); + + $this->templateImageEmbedder->expects($this->once()) + ->method('__invoke') + ->with(html: '

HTML

', messageId: 777) + ->willReturn('

HTML

'); + + $builder = $this->makeBuilder( + googleSenderId: 'g-123', + useAmazonSes: false, + usePrecedenceHeader: true, + devVersion: true, + devEmail: 'dev@example.com' + ); + + $email = $builder->buildPhplistEmail( + messageId: 777, + data: $dto, + skipBlacklistCheck: false, + inBlast: true + ); + + $this->assertNotNull($email); + + // Recipient is redirected to dev email in dev mode + $this->assertCount(1, $email->getTo()); + $this->assertInstanceOf(Address::class, $email->getTo()[0]); + $this->assertSame('dev@example.com', $email->getTo()[0]->getAddress()); + + // Headers + $headers = $email->getHeaders(); + $this->assertSame('777', $headers->get('X-MessageID')->getBodyAsString()); + $this->assertSame('dev@example.com', $headers->get('X-ListMember')->getBodyAsString()); + $this->assertSame('777:g-123', $headers->get('Feedback-ID')->getBodyAsString()); + $this->assertSame('bulk', $headers->get('Precedence')->getBodyAsString()); + $this->assertSame('1', $headers->get('X-Blast')->getBodyAsString()); + + $this->assertTrue($headers->has('X-Originally-To')); + $this->assertSame('real@example.com', $headers->get('X-Originally-To')->getBodyAsString()); + + $this->assertTrue($headers->has('List-Unsubscribe')); + $this->assertStringContainsString( + 'email=dev%40example.com', + $headers->get('List-Unsubscribe')->getBodyAsString() + ); + + // From and subject + $this->assertSame('from@example.com', $email->getFrom()[0]->getAddress()); + $this->assertSame('From Name', $email->getFrom()[0]->getName()); + $this->assertSame('Subject', $email->getSubject()); + + // Bodies + $this->assertSame('TEXT', $email->getTextBody()); + $this->assertSame('

HTML

', $email->getHtmlBody()); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php new file mode 100644 index 00000000..84ace526 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Constructor/CampaignMailContentBuilderTest.php @@ -0,0 +1,265 @@ +subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->remotePageFetcher = $this->getMockBuilder(RemotePageFetcher::class) + ->disableOriginalConstructor() + ->onlyMethods(['__invoke']) + ->getMock(); + $this->eventLogManager = $this->createMock(EventLogManager::class); + $this->configProvider = $this->createMock(ConfigProvider::class); + $this->html2Text = $this->getMockBuilder(Html2Text::class) + ->disableOriginalConstructor() + ->onlyMethods(['__invoke']) + ->getMock(); + $this->textParser = $this->getMockBuilder(TextParser::class) + ->disableOriginalConstructor() + ->onlyMethods(['__invoke']) + ->getMock(); + $this->placeholderProcessor = $this->createMock(MessagePlaceholderProcessor::class); + + $this->configProvider + ->method('getValue') + ->willReturnMap( + [ + [ConfigOption::HtmlEmailStyle, ''], + ] + ); + } + + private function makeBuilder(): CampaignMailContentBuilder + { + return new CampaignMailContentBuilder( + subscriberRepository: $this->subscriberRepository, + remotePageFetcher: $this->remotePageFetcher, + eventLogManager: $this->eventLogManager, + configProvider: $this->configProvider, + html2Text: $this->html2Text, + textParser: $this->textParser, + placeholderProcessor: $this->placeholderProcessor, + ); + } + + public function testThrowsWhenSubscriberNotFound(): void + { + $dto = new MessagePrecacheDto(); + $dto->to = 'missing@example.com'; + $dto->content = 'Hello'; + + $this->subscriberRepository->method('findOneByEmail')->willReturn(null); + + $builder = $this->makeBuilder(); + $this->expectException(SubscriberNotFoundException::class); + $builder($dto, 10); + } + + public function testBuildsHtmlFormattedGeneratesTextViaHtml2Text(): void + { + $subscriber = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->onlyMethods(['getId', 'getEmail']) + ->getMock(); + $subscriber->method('getId')->willReturn(123); + $subscriber->method('getEmail')->willReturn('user@example.com'); + + $this->subscriberRepository + ->method('findOneByEmail') + ->willReturn($subscriber); + $this->placeholderProcessor + ->method('process') + ->willReturnCallback( + static function (...$args): string { + return (string) $args[0]; + } + ); + + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->content = 'Hi'; + $dto->htmlFormatted = true; + + $this->html2Text->expects($this->once()) + ->method('__invoke') + ->with('Hi') + ->willReturn('Hi'); + + $builder = $this->makeBuilder(); + [$html, $text] = $builder($dto, 5); + + $this->assertSame('Hi', $text); + $this->assertStringContainsString('Hi', $html); + $this->assertStringContainsString('assertStringContainsString('', $html); + $this->assertStringContainsString( + '/*default-style*/', + $html, + 'Default style should be added when no template is used' + ); + } + + public function testBuildsFromPlainTextUsingTextParser(): void + { + $subscriber = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->onlyMethods(['getId', 'getEmail']) + ->getMock(); + $subscriber->method('getId')->willReturn(22); + $subscriber->method('getEmail')->willReturn('user@example.com'); + $this->subscriberRepository + ->method('findOneByEmail') + ->willReturn($subscriber); + $this->placeholderProcessor + ->method('process') + ->willReturnCallback( + static function (...$args): string { + return (string) $args[0]; + } + ); + + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->content = 'Hello world'; + $dto->htmlFormatted = false; + + $this->textParser->expects($this->once()) + ->method('__invoke') + ->with('Hello world') + ->willReturn('

Hello world

'); + + $builder = $this->makeBuilder(); + [$html, $text] = $builder($dto, 7); + + $this->assertSame('Hello world', $text); + $this->assertStringContainsString('

Hello world

', $html); + $this->assertStringContainsString('/*default-style*/', $html); + } + + public function testUserSpecificUrlReplacementAndExceptionOnEmpty(): void + { + $subscriber = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->onlyMethods(['getId', 'getEmail']) + ->getMock(); + $subscriber->method('getId')->willReturn(55); + $subscriber->method('getEmail')->willReturn('user@example.com'); + $this->subscriberRepository + ->method('findOneByEmail') + ->willReturn($subscriber); + $this->subscriberRepository + ->method('getDataById') + ->with(55) + ->willReturn(['id' => 55]); + + // Success path replacement + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->content = 'Intro [URL:example.com/path] End'; + $dto->userSpecificUrl = true; + + $this->remotePageFetcher + ->expects($this->exactly(2)) + ->method('__invoke') + ->withConsecutive( + ['https://example.com/path', ['id' => 55]], + ['https://example.com/empty', ['id' => 55]], + ) + ->willReturnOnConsecutiveCalls('
REMOTE
', ''); + $this->placeholderProcessor + ->method('process') + ->willReturnCallback( + static function (...$args): string { + return (string) $args[0]; + } + ); + + $builder = $this->makeBuilder(); + [$html] = $builder($dto, 11); + $this->assertStringContainsString('
REMOTE
', $html); + + // Failure path (empty content) should log and throw + $dto2 = new MessagePrecacheDto(); + $dto2->to = 'user@example.com'; + $dto2->content = 'Again [URL:example.com/empty] test'; + $dto2->userSpecificUrl = true; + + $this->eventLogManager + ->expects($this->once()) + ->method('log'); + + $this->expectException(RemotePageFetchException::class); + $builder($dto2, 12); + } + + public function testTemplatePreventsDefaultStyleInjection(): void + { + $subscriber = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->onlyMethods(['getId', 'getEmail']) + ->getMock(); + $subscriber->method('getId')->willReturn(77); + $subscriber->method('getEmail')->willReturn('user@example.com'); + $this->subscriberRepository + ->method('findOneByEmail') + ->willReturn($subscriber); + + $this->placeholderProcessor + ->method('process') + ->willReturnCallback( + static function (...$args): string { + return (string) $args[0]; + } + ); + + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->content = '

Inner

'; + $dto->htmlFormatted = true; + $dto->template = 'TBEFORE[CONTENT]AFTER'; + + $builder = $this->makeBuilder(); + [$html, $text] = $builder($dto, 2); + + $this->assertStringContainsString('BEFORE

Inner

AFTER', $html); + $this->assertStringNotContainsString( + '/*default-style*/', + $html, + 'Default style must not be added when template provided' + ); + $this->assertSame( + '', + $text, + 'No text content provided and html2text not used when htmlFormatted and template present' + ); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php b/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php index 7a92a420..d60b38e1 100644 --- a/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php +++ b/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php @@ -4,19 +4,10 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; -use PhpList\Core\Domain\Messaging\Model\Message; -use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; -use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; -use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat; -use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata; -use PhpList\Core\Domain\Messaging\Model\Message\MessageOptions; -use PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule; use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer; use PhpList\Core\Domain\Messaging\Service\SendRateLimiter; -use PhpList\Core\Domain\Subscription\Model\Subscriber; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use ReflectionProperty; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; @@ -34,61 +25,6 @@ protected function setUp(): void $this->sut = new RateLimitedCampaignMailer($this->mailer, $this->limiter); } - public function testComposeEmailSetsHeadersAndBody(): void - { - $message = $this->buildMessage( - subject: 'Subject', - textBody: 'Plain text', - htmlBody: '

HTML

', - from: 'from@example.com', - replyTo: 'reply@example.com' - ); - - $subscriber = new Subscriber(); - $this->setSubscriberEmail($subscriber, 'user@example.com'); - - $precached = new MessagePrecacheDto(); - $precached->subject = 'Subject'; - $precached->textContent = 'Plain text'; - $precached->content = '

HTML

'; - - $email = $this->sut->composeEmail($message, $subscriber, $precached); - - $this->assertInstanceOf(Email::class, $email); - $this->assertSame('user@example.com', $email->getTo()[0]->getAddress()); - $this->assertSame('Subject', $email->getSubject()); - $this->assertSame('from@example.com', $email->getFrom()[0]->getAddress()); - $this->assertSame('reply@example.com', $email->getReplyTo()[0]->getAddress()); - $this->assertSame('Plain text', $email->getTextBody()); - $this->assertSame('

HTML

', $email->getHtmlBody()); - } - - public function testComposeEmailWithoutOptionalHeaders(): void - { - $message = $this->buildMessage( - subject: 'No headers', - textBody: 'text', - htmlBody: 'h', - from: '', - replyTo: '' - ); - - $subscriber = new Subscriber(); - $this->setSubscriberEmail($subscriber, 'user2@example.com'); - - $precached = new MessagePrecacheDto(); - $precached->subject = 'No headers'; - $precached->textContent = 'text'; - $precached->content = 'h'; - - $email = $this->sut->composeEmail($message, $subscriber, $precached); - - $this->assertSame('user2@example.com', $email->getTo()[0]->getAddress()); - $this->assertSame('No headers', $email->getSubject()); - $this->assertSame([], $email->getFrom()); - $this->assertSame([], $email->getReplyTo()); - } - public function testSendUsesLimiterAroundMailer(): void { $email = (new Email())->to('someone@example.com'); @@ -102,44 +38,4 @@ public function testSendUsesLimiterAroundMailer(): void $this->sut->send($email); } - - private function buildMessage( - string $subject, - string $textBody, - string $htmlBody, - string $from, - string $replyTo - ): Message { - $content = new MessageContent( - subject: $subject, - text: $htmlBody, - textMessage: $textBody, - footer: null, - ); - $format = new MessageFormat( - htmlFormatted: true, - sendFormat: MessageFormat::FORMAT_HTML, - formatOptions: [MessageFormat::FORMAT_HTML] - ); - $schedule = new MessageSchedule( - repeatInterval: 0, - repeatUntil: null, - requeueInterval: 0, - requeueUntil: null, - embargo: null - ); - $metadata = new MessageMetadata(); - $options = new MessageOptions(fromField: $from, toField: '', replyTo: $replyTo); - - return new Message($format, $schedule, $metadata, $content, $options, null, null); - } - - /** - * Subscriber has no public setter for email, so we use reflection. - */ - private function setSubscriberEmail(Subscriber $subscriber, string $email): void - { - $ref = new ReflectionProperty($subscriber, 'email'); - $ref->setValue($subscriber, $email); - } } diff --git a/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php b/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php index de7f09c9..8f7ee7f7 100644 --- a/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php +++ b/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php @@ -7,10 +7,11 @@ use PhpList\Core\Domain\Common\Html2Text; use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; +use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Model\Template; use PhpList\Core\Domain\Messaging\Repository\TemplateRepository; +use PhpList\Core\Domain\Messaging\Service\Constructor\SystemMailContentBuilder; use PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager; -use PhpList\Core\Domain\Messaging\Service\SystemMailConstructor; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -35,7 +36,7 @@ protected function setUp(): void ->getMock(); } - private function createConstructor(bool $poweredByPhplist = false): SystemMailConstructor + private function createConstructor(bool $poweredByPhplist = false): SystemMailContentBuilder { // Defaults needed by constructor $this->configProvider->method('getValue')->willReturnMap([ @@ -43,7 +44,7 @@ private function createConstructor(bool $poweredByPhplist = false): SystemMailCo [ConfigOption::SystemMessageTemplate, null], ]); - return new SystemMailConstructor( + return new SystemMailContentBuilder( html2Text: $this->html2Text, configProvider: $this->configProvider, templateRepository: $this->templateRepository, @@ -58,8 +59,11 @@ public function testPlainTextWithoutTemplateLinkifiedAndNl2br(): void // Html2Text is not used when source is plain text $this->html2Text->expects($this->never())->method('__invoke'); + $dto = new MessagePrecacheDto(); + $dto->subject = 'Subject'; + $dto->content = 'Line1' . "\n" . 'Visit http://example.com'; - [$html, $text] = $constructor('Line1' . "\n" . 'Visit http://example.com', 'Subject'); + [$html, $text] = $constructor($dto); $this->assertSame("Line1\nVisit http://example.com", $text); $this->assertStringContainsString('Line1method('__invoke') ->with('

Hello

') ->willReturn('Hello'); + $dto = new MessagePrecacheDto(); + $dto->subject = 'Subject'; + $dto->content = '

Hello

'; - [$html, $text] = $constructor('

Hello

', 'Subject'); + [$html, $text] = $constructor($dto); $this->assertSame('

Hello

', $html); $this->assertSame('Hello', $text); @@ -107,15 +114,18 @@ public function testTemplateWithSignaturePlaceholderUsesPoweredByImageWhenFlagFa ->with('Powered') ->willReturn('Powered'); - $constructor = new SystemMailConstructor( + $constructor = new SystemMailContentBuilder( html2Text: $this->html2Text, configProvider: $this->configProvider, templateRepository: $this->templateRepository, templateImageManager: $this->templateImageManager, poweredByPhplist: false, ); + $dto = new MessagePrecacheDto(); + $dto->subject = 'Subject'; + $dto->content = 'Body'; - [$html, $text] = $constructor('Body', 'Subject'); + [$html, $text] = $constructor($dto); // HTML should contain processed powered-by image (src rewritten to powerphplist.png) in place of [SIGNATURE] $this->assertStringContainsString('Subject: Body', $html); @@ -149,15 +159,18 @@ public function testTemplateWithoutSignatureAppendsPoweredByTextAndBeforeBodyEnd ) ->willReturnOnConsecutiveCalls('Hello World', 'PB'); - $constructor = new SystemMailConstructor( + $constructor = new SystemMailContentBuilder( html2Text: $this->html2Text, configProvider: $this->configProvider, templateRepository: $this->templateRepository, templateImageManager: $this->templateImageManager, poweredByPhplist: true, ); + $dto = new MessagePrecacheDto(); + $dto->subject = 'Sub'; + $dto->content = 'Hello World'; - [$html, $text] = $constructor('Hello World', 'Sub'); + [$html, $text] = $constructor($dto); // HTML path: since poweredByPhplist=true, raw PoweredByText should be inserted before $this->assertStringContainsString('Hello World', $html); From 39712ed98337e6c7e143f1741258b7083097f836 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Tue, 3 Feb 2026 15:22:06 +0400 Subject: [PATCH 32/37] Feat: email forwarding (#377) - Message forwarding: send campaigns to friends (optional personal note), per-user limits, admin notifications on success/failure, and forwarding statistics; forwarded messages prefixed "Fwd". - Admin-copy emails: configurable toggle to send admin copies and select recipients. --------- Co-authored-by: Tatevik --- composer.json | 3 +- config/parameters.yml.dist | 19 +- config/services/builders.yml | 8 +- config/services/messenger.yml | 2 - resources/translations/messages.en.xlf | 26 +- src/Core/Version.php | 10 + .../Analytics/Service/LinkTrackService.php | 1 + src/Domain/Common/ExternalImageService.php | 5 +- .../Configuration/Model/ConfigOption.php | 4 + .../Model/Dto/PlaceholderContext.php | 4 +- .../Service/MessagePlaceholderProcessor.php | 37 ++- .../ConfirmationUrlValueResolver.php | 5 +- .../Placeholder/FooterValueResolver.php | 4 + .../Placeholder/ForwardUrlValueResolver.php | 5 +- .../Placeholder/ForwardValueResolver.php | 5 +- .../Placeholder/JumpoffValueResolver.php | 3 +- .../PreferencesUrlValueResolver.php | 5 +- .../Placeholder/PreferencesValueResolver.php | 5 +- .../UnsubscribeUrlValueResolver.php | 3 +- .../Placeholder/UnsubscribeValueResolver.php | 3 +- .../Placeholder/UserTrackValueResolver.php | 3 +- .../Service/Provider/ConfigProvider.php | 14 +- .../Identity/Service/AdminCopyEmailSender.php | 81 +++++++ src/Domain/Identity/Service/AdminNotifier.php | 62 +++++ .../ForwardLimitExceededException.php | 15 ++ .../Exception/MessageNotReceivedException.php | 15 ++ .../CampaignProcessorMessageHandler.php | 12 +- .../Messaging/Model/Dto/MessageForwardDto.php | 56 +++++ .../UserMessageForwardRepository.php | 22 ++ .../Messaging/Service/AttachmentAdder.php | 6 +- .../Service/Builder/BaseEmailBuilder.php | 28 ++- .../Service/Builder/EmailBuilder.php | 75 +++--- .../Service/Builder/ForwardEmailBuilder.php | 135 +++++++++++ .../Builder/HttpReceivedStampBuilder.php | 66 ++++++ .../Service/Builder/SystemEmailBuilder.php | 77 +++++- .../CampaignMailContentBuilder.php | 39 ++- .../MailContentBuilderInterface.php | 19 -- .../Constructor/SystemMailContentBuilder.php | 4 +- .../Service/ForwardContentService.php | 58 +++++ .../Service/ForwardDeliveryService.php | 58 +++++ .../Messaging/Service/ForwardingGuard.php | 53 +++++ .../Service/ForwardingStatsService.php | 87 +++++++ .../Manager/UserMessageForwardManager.php | 34 +++ .../Service/MessageForwardService.php | 85 +++++++ .../Service/MessagePrecacheService.php | 1 - .../AttributeNotAllowedException.php | 19 ++ .../Model/SubscriberAttributeDefinition.php | 14 ++ .../SubscriberAttributeValueRepository.php | 15 ++ .../Manager/SubscriberAttributeManager.php | 36 +-- .../MessagePlaceholderProcessorTest.php | 24 +- .../Placeholder/FooterValueResolverTest.php | 89 +++++-- .../UnsubscribeValueResolverTest.php | 8 +- .../Service/AdminCopyEmailSenderTest.php | 173 ++++++++++++++ .../Identity/Service/AdminNotifierTest.php | 167 +++++++++++++ .../CampaignProcessorMessageHandlerTest.php | 6 +- .../Service/Builder/EmailBuilderTest.php | 30 ++- .../Builder/ForwardEmailBuilderTest.php | 223 ++++++++++++++++++ .../Builder/HttpReceivedStampBuilderTest.php | 75 ++++++ .../Builder/SystemEmailBuilderTest.php | 18 +- .../Service/ForwardContentServiceTest.php | 136 +++++++++++ .../Service/ForwardDeliveryServiceTest.php | 114 +++++++++ .../Messaging/Service/ForwardingGuardTest.php | 140 +++++++++++ .../Service/ForwardingStatsServiceTest.php | 119 ++++++++++ .../Manager/UserMessageForwardManagerTest.php | 69 ++++++ .../Service/MessageForwardServiceTest.php | 205 ++++++++++++++++ .../SubscriberAttributeManagerTest.php | 13 +- 66 files changed, 2731 insertions(+), 224 deletions(-) create mode 100644 src/Core/Version.php create mode 100644 src/Domain/Identity/Service/AdminCopyEmailSender.php create mode 100644 src/Domain/Identity/Service/AdminNotifier.php create mode 100644 src/Domain/Messaging/Exception/ForwardLimitExceededException.php create mode 100644 src/Domain/Messaging/Exception/MessageNotReceivedException.php create mode 100644 src/Domain/Messaging/Model/Dto/MessageForwardDto.php create mode 100644 src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php create mode 100644 src/Domain/Messaging/Service/Builder/HttpReceivedStampBuilder.php delete mode 100644 src/Domain/Messaging/Service/Constructor/MailContentBuilderInterface.php create mode 100644 src/Domain/Messaging/Service/ForwardContentService.php create mode 100644 src/Domain/Messaging/Service/ForwardDeliveryService.php create mode 100644 src/Domain/Messaging/Service/ForwardingGuard.php create mode 100644 src/Domain/Messaging/Service/ForwardingStatsService.php create mode 100644 src/Domain/Messaging/Service/Manager/UserMessageForwardManager.php create mode 100644 src/Domain/Messaging/Service/MessageForwardService.php create mode 100644 src/Domain/Subscription/Exception/AttributeNotAllowedException.php create mode 100644 tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php create mode 100644 tests/Unit/Domain/Identity/Service/AdminNotifierTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Builder/HttpReceivedStampBuilderTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Manager/UserMessageForwardManagerTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php diff --git a/composer.json b/composer.json index aba1a887..cdcb9934 100644 --- a/composer.json +++ b/composer.json @@ -84,7 +84,8 @@ "ext-gd": "*", "ext-curl": "*", "ext-fileinfo": "*", - "setasign/fpdf": "^1.8" + "setasign/fpdf": "^1.8", + "phpdocumentor/reflection-docblock": "^5.2" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index 8d3af216..628f1e45 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -34,7 +34,7 @@ parameters: app.preference_page_show_private_lists: '%%env(PREFERENCEPAGE_SHOW_PRIVATE_LISTS)%%' env(PREFERENCEPAGE_SHOW_PRIVATE_LISTS): '0' app.rest_api_domain: '%%env(REST_API_DOMAIN)%%' - env(REST_API_DOMAIN): 'https://example.com/api/v2' + env(REST_API_DOMAIN): 'example.com' # Email configuration app.mailer_from: '%%env(MAILER_FROM)%%' @@ -81,6 +81,8 @@ parameters: # A secret key that's used to generate certain security-related tokens secret: '%%env(PHPLIST_SECRET)%%' env(PHPLIST_SECRET): %1$s + phplist.verify_ssl: '%%env(VERIFY_SSL)%%' + env(VERIFY_SSL): '1' graylog_host: 'graylog.example.com' graylog_port: 12201 @@ -127,6 +129,19 @@ parameters: env(EMAILTEXTCREDITS): '0' messaging.always_add_user_track: '%%env(ALWAYS_ADD_USERTRACK)%%' env(ALWAYS_ADD_USERTRACK): '1' + messaging.send_list_admin_copy: '%%env(SEND_LISTADMIN_COPY)%%' + env(SEND_LISTADMIN_COPY): '0' + + phplist.forward_email_period: '%%env(FORWARD_EMAIL_PERIOD)%%' + env(FORWARD_EMAIL_PERIOD): '1 minute' + phplist.forward_email_count: '%%env(FORWARD_EMAIL_COUNT)%%' + env(FORWARD_EMAIL_COUNT): '1' + phplist.forward_personal_note_size: '%%env(FORWARD_PERSONAL_NOTE_SIZE)%%' + env(FORWARD_PERSONAL_NOTE_SIZE): '0' + phplist.forward_friend_count_attribute: '%%env(FORWARD_FRIEND_COUNT_ATTRIBUTE)%%' + env(FORWARD_FRIEND_COUNT_ATTRIBUTE): '' + phplist.keep_forwarded_attributes: '%%env(KEEPFORWARDERATTRIBUTES)%%' + env(KEEPFORWARDERATTRIBUTES): '0' phplist.upload_images_dir: '%%env(PHPLIST_UPLOADIMAGES_DIR)%%' env(PHPLIST_UPLOADIMAGES_DIR): 'images' @@ -138,3 +153,5 @@ parameters: env(PHPLIST_ATTACHMENT_DOWNLOAD_URL): 'https://example.com/download/' phplist.attachment_repository_path: '%%env(PHPLIST_ATTACHMENT_REPOSITORY_PATH)%%' env(PHPLIST_ATTACHMENT_REPOSITORY_PATH): '/tmp' + phplist.max_avatar_size: '%%env(MAX_AVATAR_SIZE)%%' + env(MAX_AVATAR_SIZE): '100000' diff --git a/config/services/builders.yml b/config/services/builders.yml index 8a386408..1b3b3814 100644 --- a/config/services/builders.yml +++ b/config/services/builders.yml @@ -12,20 +12,16 @@ services: PhpList\Core\Domain\Messaging\Service\Constructor\CampaignMailContentBuilder: ~ # Two EmailBuilder services with different constructors injected - Core.EmailBuilder.system: - class: PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder + PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder: arguments: - $mailConstructor: '@PhpList\Core\Domain\Messaging\Service\Constructor\SystemMailContentBuilder' $googleSenderId: '%messaging.google_sender_id%' $useAmazonSes: '%messaging.use_amazon_ses%' $usePrecedenceHeader: '%messaging.use_precedence_header%' $devVersion: '%app.dev_version%' $devEmail: '%app.dev_email%' - Core.EmailBuilder.campaign: - class: PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder + PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder: arguments: - $mailConstructor: '@PhpList\Core\Domain\Messaging\Service\Constructor\CampaignMailContentBuilder' $googleSenderId: '%messaging.google_sender_id%' $useAmazonSes: '%messaging.use_amazon_ses%' $usePrecedenceHeader: '%messaging.use_precedence_header%' diff --git a/config/services/messenger.yml b/config/services/messenger.yml index eb4b5d7a..1508e592 100644 --- a/config/services/messenger.yml +++ b/config/services/messenger.yml @@ -15,5 +15,3 @@ services: autowire: true autoconfigure: true arguments: - $campaignEmailBuilder: '@Core.EmailBuilder.campaign' - $systemEmailBuilder: '@Core.EmailBuilder.system' diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf index fee9ed2f..090b4f5d 100644 --- a/resources/translations/messages.en.xlf +++ b/resources/translations/messages.en.xlf @@ -774,7 +774,7 @@ Thank you. This message contains attachments that can be viewed with a webbrowser __This message contains attachments that can be viewed with a webbrowser - + Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit% __Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit% @@ -794,10 +794,6 @@ Thank you. failed to open attachment (%remoteFile%) to add to campaign %campaignId% __failed to open attachment (%remoteFile%) to add to campaign %campaignId% - - Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit% - __Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit% - Attachment %remoteFile% does not exist __Attachment %remoteFile% does not exist @@ -810,6 +806,26 @@ Thank you. Location __Location + + Fwd + __Fwd + + + (test) + __(test) + + + Message Forwarded + __Message Forwarded + + + %subscriber% tried forwarding message %campaignId% to %email% but failed + __%subscriber% tried forwarding message %campaignId% to %email% but failed + + + %subscriber% has forwarded message %campaignId% to %email% + __%subscriber% has forwarded message %campaignId% to %email% + diff --git a/src/Core/Version.php b/src/Core/Version.php new file mode 100644 index 00000000..0303b97f --- /dev/null +++ b/src/Core/Version.php @@ -0,0 +1,10 @@ +isExtractAndSaveLinksApplicable()) { return []; } diff --git a/src/Domain/Common/ExternalImageService.php b/src/Domain/Common/ExternalImageService.php index c2ef3e17..4af3eba6 100644 --- a/src/Domain/Common/ExternalImageService.php +++ b/src/Domain/Common/ExternalImageService.php @@ -7,7 +7,7 @@ use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use Psr\Log\LoggerInterface; -use Throwable; +use Symfony\Component\DependencyInjection\Attribute\Autowire; class ExternalImageService { @@ -19,6 +19,7 @@ public function __construct( private readonly string $tempDir, private readonly int $externalImageMaxAge, private readonly int $externalImageMaxSize, + #[Autowire('%phplist.verify_ssl%')] private readonly bool $verifySsl = true, private readonly ?int $externalImageTimeout = 30, ) { $this->externalCacheDir = $this->tempDir . '/external_cache'; @@ -125,7 +126,7 @@ private function downloadUsingCurl(string $filename): ?string curl_setopt($cURLHandle, CURLOPT_TIMEOUT, $this->externalImageTimeout); curl_setopt($cURLHandle, CURLOPT_FOLLOWLOCATION, true); curl_setopt($cURLHandle, CURLOPT_MAXREDIRS, 10); - curl_setopt($cURLHandle, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($cURLHandle, CURLOPT_SSL_VERIFYPEER, $this->verifySsl); curl_setopt($cURLHandle, CURLOPT_FAILONERROR, true); $cacheFileContent = curl_exec($cURLHandle); diff --git a/src/Domain/Configuration/Model/ConfigOption.php b/src/Domain/Configuration/Model/ConfigOption.php index 5ce47d1c..1c9fd37e 100644 --- a/src/Domain/Configuration/Model/ConfigOption.php +++ b/src/Domain/Configuration/Model/ConfigOption.php @@ -24,6 +24,7 @@ enum ConfigOption: string case SystemMessageTemplate = 'systemmessagetemplate'; case AlwaysAddGoogleTracking = 'always_add_googletracking'; case AdminAddress = 'admin_address'; + case AdminAddresses = 'admin_addresses'; case DefaultMessageTemplate = 'defaultmessagetemplate'; case MessageFooter = 'messagefooter'; case ForwardFooter = 'forwardfooter'; @@ -41,4 +42,7 @@ enum ConfigOption: string case HtmlEmailStyle = 'html_email_style'; case AlwaysSendTextDomains = 'alwayssendtextto'; case ReportAddress = 'report_address'; + case SendAdminCopies = 'send_admin_copies'; + case DontSaveUserPassword = 'dontsave_userpassword'; + case AutoCreateAttributes = 'autocreate_attributes'; } diff --git a/src/Domain/Configuration/Model/Dto/PlaceholderContext.php b/src/Domain/Configuration/Model/Dto/PlaceholderContext.php index e040a19c..b93c107b 100644 --- a/src/Domain/Configuration/Model/Dto/PlaceholderContext.php +++ b/src/Domain/Configuration/Model/Dto/PlaceholderContext.php @@ -15,7 +15,7 @@ public function __construct( public readonly OutputFormat $format, public readonly ?MessagePrecacheDto $messagePrecacheDto = null, public readonly string $locale = 'en', - private readonly ?string $forwardedBy = null, + private readonly ?Subscriber $forwardedBy = null, private readonly ?int $messageId = null, ) { } @@ -30,7 +30,7 @@ public function isText(): bool return $this->format === OutputFormat::Text; } - public function forwardedBy(): ?string + public function forwardedBy(): ?Subscriber { return $this->forwardedBy; } diff --git a/src/Domain/Configuration/Service/MessagePlaceholderProcessor.php b/src/Domain/Configuration/Service/MessagePlaceholderProcessor.php index 7b0edcda..ba216a1a 100644 --- a/src/Domain/Configuration/Service/MessagePlaceholderProcessor.php +++ b/src/Domain/Configuration/Service/MessagePlaceholderProcessor.php @@ -30,25 +30,29 @@ public function __construct( /** @var iterable */ private readonly iterable $supportingResolvers, #[Autowire('%messaging.always_add_user_track%')] private readonly bool $alwaysAddUserTrack, + #[Autowire('%phplist.keep_forwarded_attributes%')] private readonly bool $keepForwardedAttributes, ) { } public function process( string $value, - Subscriber $user, + Subscriber $receiver, OutputFormat $format, MessagePrecacheDto $messagePrecacheDto, ?int $campaignId = null, - ?string $forwardedBy = null, + ?Subscriber $forwardedBy = null, ): string { $value = $this->ensureStandardPlaceholders($value, $format); $resolver = new PlaceholderResolver(); $resolver->register('EMAIL', fn(PlaceholderContext $ctx) => $ctx->user->getEmail()); - $resolver->register('FORWARDEDBY', fn(PlaceholderContext $ctx) => $ctx->forwardedBy()); + $resolver->register('FORWARDEDBY', fn(PlaceholderContext $ctx) => $ctx->forwardedBy()?->getEmail() ?? ''); $resolver->register('MESSAGEID', fn(PlaceholderContext $ctx) => $ctx->messageId()); $resolver->register('FORWARDFORM', fn(PlaceholderContext $ctx) => ''); - $resolver->register('USERID', fn(PlaceholderContext $ctx) => $ctx->user->getUniqueId()); + $resolver->register( + name: 'USERID', + resolver: fn(PlaceholderContext $ctx) => $ctx->forwardedBy() ? 'forwarded' : $ctx->user->getUniqueId() + ); $resolver->register( name: 'WEBSITE', resolver: fn(PlaceholderContext $ctx) => $this->config->getValue(ConfigOption::Website) ?? '' @@ -74,18 +78,12 @@ public function process( $resolver->registerSupporting($supportingResolver); } - $userAttributes = $this->attributesRepository->getForSubscriber($user); - foreach ($userAttributes as $userAttribute) { - $resolver->register( - name: strtoupper($userAttribute->getAttributeDefinition()->getName()), - resolver: fn(PlaceholderContext $ctx) => $this->attributeValueResolver->resolve($userAttribute) - ); - } + $this->registerAttributeResolvers($resolver, $receiver, $forwardedBy); return $resolver->resolve( value: $value, context: new PlaceholderContext( - user: $user, + user: $receiver, format: $format, messagePrecacheDto: $messagePrecacheDto, forwardedBy: $forwardedBy, @@ -123,4 +121,19 @@ private function appendContent(string $message, string $append): string return $message; } + + private function registerAttributeResolvers( + PlaceholderResolver $resolver, + Subscriber $receiver, + ?Subscriber $forwardedBy + ): void { + $userForAttributes = ($forwardedBy && $this->keepForwardedAttributes) ? $forwardedBy : $receiver; + $userAttributes = $this->attributesRepository->getForSubscriber($userForAttributes); + foreach ($userAttributes as $userAttribute) { + $resolver->register( + name: strtoupper($userAttribute->getAttributeDefinition()->getName()), + resolver: fn(PlaceholderContext $ctx) => $this->attributeValueResolver->resolve($userAttribute) + ); + } + } } diff --git a/src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php index 930b3d6c..36b5ae41 100644 --- a/src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php @@ -26,11 +26,12 @@ public function __invoke(PlaceholderContext $ctx): string return ''; } $sep = !str_contains($url, '?') ? '?' : '&'; + $uid = $ctx->forwardedBy() ? 'forwarded' : $ctx->getUser()->getUniqueId(); if ($ctx->isHtml()) { - return sprintf('%s%suid=%s', $url, htmlspecialchars($sep), $ctx->getUser()->getUniqueId()); + return sprintf('%s%suid=%s', $url, htmlspecialchars($sep), $uid); } - return sprintf('%s%suid=%s', $url, $sep, $ctx->getUser()->getUniqueId()); + return sprintf('%s%suid=%s', $url, $sep, $uid); } } diff --git a/src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php b/src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php index fd1e9d4b..506581dd 100644 --- a/src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php @@ -23,6 +23,10 @@ public function name(): string public function __invoke(PlaceholderContext $ctx): string { + if ($ctx->forwardedBy() === null && $ctx->messagePrecacheDto) { + return $ctx->isText() ? $ctx->messagePrecacheDto->textFooter : $ctx->messagePrecacheDto->htmlFooter; + } + //0013076: different content when forwarding 'to a friend' if ($this->forwardAlternativeContent && $ctx->messagePrecacheDto) { return stripslashes($ctx->messagePrecacheDto->footer); diff --git a/src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php index 4eb628be..0ca9ac1e 100644 --- a/src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php @@ -26,17 +26,18 @@ public function __invoke(PlaceholderContext $ctx): string return ''; } $sep = !str_contains($url, '?') ? '?' : '&'; + $uid = $ctx->forwardedBy() ? 'forwarded' : $ctx->getUser()->getUniqueId(); if ($ctx->isHtml()) { return sprintf( '%s%suid=%s&mid=%d', htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), htmlspecialchars($sep), - $ctx->getUser()->getUniqueId(), + $uid, $ctx->messageId(), ); } - return sprintf('%s%suid=%s&mid=%d', $url, $sep, $ctx->getUser()->getUniqueId(), $ctx->messageId()); + return sprintf('%s%suid=%s&mid=%d', $url, $sep, $uid, $ctx->messageId()); } } diff --git a/src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php index 9de07e46..cc0b8b12 100644 --- a/src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php @@ -29,6 +29,7 @@ public function __invoke(PlaceholderContext $ctx): string return ''; } $sep = !str_contains($url, '?') ? '?' : '&'; + $uid = $ctx->forwardedBy() ? 'forwarded' : $ctx->getUser()->getUniqueId(); if ($ctx->isHtml()) { $label = $this->translator->trans('This link'); @@ -37,7 +38,7 @@ public function __invoke(PlaceholderContext $ctx): string . htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($sep) . 'uid=' - . $ctx->getUser()->getUniqueId() + . $uid . '&mid=' . $ctx->messageId() . '">' @@ -45,6 +46,6 @@ public function __invoke(PlaceholderContext $ctx): string . ' '; } - return sprintf('%s%suid=%s&mid=%d ', $url, $sep, $ctx->getUser()->getUniqueId(), $ctx->messageId()); + return sprintf('%s%suid=%s&mid=%d ', $url, $sep, $uid, $ctx->messageId()); } } diff --git a/src/Domain/Configuration/Service/Placeholder/JumpoffValueResolver.php b/src/Domain/Configuration/Service/Placeholder/JumpoffValueResolver.php index 47601ce8..1152e463 100644 --- a/src/Domain/Configuration/Service/Placeholder/JumpoffValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/JumpoffValueResolver.php @@ -28,7 +28,8 @@ public function __invoke(PlaceholderContext $ctx): string if (empty($base)) { return ''; } - $url = $this->urlBuilder->withUid($base, $ctx->getUser()->getUniqueId()); + $uid = $ctx->forwardedBy() ? 'forwarded' : $ctx->getUser()->getUniqueId(); + $url = $this->urlBuilder->withUid($base, $uid); if ($ctx->isHtml()) { return ''; diff --git a/src/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolver.php b/src/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolver.php index 3818dfb4..3600834f 100644 --- a/src/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolver.php @@ -26,16 +26,17 @@ public function __invoke(PlaceholderContext $ctx): string return ''; } $sep = !str_contains($url, '?') ? '?' : '&'; + $uid = $ctx->forwardedBy() ? 'forwarded' : $ctx->getUser()->getUniqueId(); if ($ctx->isHtml()) { return sprintf( '%s%suid=%s', htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), htmlspecialchars($sep), - $ctx->getUser()->getUniqueId(), + $uid, ); } - return sprintf('%s%suid=%s', $url, $sep, $ctx->getUser()->getUniqueId()); + return sprintf('%s%suid=%s', $url, $sep, $uid); } } diff --git a/src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php b/src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php index 574fddf0..bf82c2fd 100644 --- a/src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php @@ -29,6 +29,7 @@ public function __invoke(PlaceholderContext $ctx): string return ''; } $sep = !str_contains($url, '?') ? '?' : '&'; + $uid = $ctx->forwardedBy() ? 'forwarded' : $ctx->getUser()->getUniqueId(); if ($ctx->isHtml()) { $label = $this->translator->trans('This link'); @@ -39,12 +40,12 @@ public function __invoke(PlaceholderContext $ctx): string . $safeUrl . htmlspecialchars($sep) . 'uid=' - . $ctx->getUser()->getUniqueId() + . $uid . '">' . $safeLabel . ' '; } - return sprintf('%s%suid=%s', $url, $sep, $ctx->getUser()->getUniqueId()); + return sprintf('%s%suid=%s', $url, $sep, $uid); } } diff --git a/src/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolver.php b/src/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolver.php index 58822c4a..fd008d93 100644 --- a/src/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolver.php @@ -28,7 +28,8 @@ public function __invoke(PlaceholderContext $ctx): string if (empty($base)) { return ''; } - $url = $this->urlBuilder->withUid($base, $ctx->getUser()->getUniqueId()); + $uid = $ctx->forwardedBy() ? 'forwarded' : $ctx->getUser()->getUniqueId(); + $url = $this->urlBuilder->withUid($base, $uid); if ($ctx->isHtml()) { return htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); diff --git a/src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php b/src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php index 12cd359c..933d2c10 100644 --- a/src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php @@ -34,7 +34,8 @@ public function __invoke(PlaceholderContext $ctx): string if (empty($base)) { return ''; } - $url = $this->urlBuilder->withUid($base, $ctx->getUser()->getUniqueId()); + $uid = $ctx->forwardedBy() ? 'forwarded' : $ctx->getUser()->getUniqueId(); + $url = $this->urlBuilder->withUid($base, $uid); if ($ctx->isHtml()) { $label = $this->translator->trans('Unsubscribe'); diff --git a/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php b/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php index 2181881f..4983fc28 100644 --- a/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php @@ -29,11 +29,12 @@ public function __invoke(PlaceholderContext $ctx): string if ($ctx->isText() || empty($base)) { return ''; } + $uid = $ctx->forwardedBy() ? 'forwarded' : $ctx->getUser()->getUniqueId(); return ''; diff --git a/src/Domain/Configuration/Service/Provider/ConfigProvider.php b/src/Domain/Configuration/Service/Provider/ConfigProvider.php index 9dd24b08..7ae13f99 100644 --- a/src/Domain/Configuration/Service/Provider/ConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/ConfigProvider.php @@ -13,6 +13,7 @@ class ConfigProvider { private array $booleanValues = [ ConfigOption::MaintenanceMode, + ConfigOption::SendAdminCopies, ]; public function __construct( @@ -28,16 +29,23 @@ public function __construct( */ public function isEnabled(ConfigOption $key): bool { - if (!in_array($key, $this->booleanValues)) { + if (!in_array($key, $this->booleanValues, true)) { throw new InvalidArgumentException('Invalid boolean value key'); } $config = $this->configRepository->findOneBy(['item' => $key->value]); if ($config !== null) { - return $config->getValue() === '1'; + return filter_var($config->getValue(), FILTER_VALIDATE_BOOLEAN); } - return $this->defaultConfigs->has($key->value) && $this->defaultConfigs->get($key->value)['value'] === '1'; + if ($this->defaultConfigs->has($key->value)) { + return filter_var( + $this->defaultConfigs->get($key->value)['value'], + FILTER_VALIDATE_BOOLEAN + ); + } + + return false; } /** diff --git a/src/Domain/Identity/Service/AdminCopyEmailSender.php b/src/Domain/Identity/Service/AdminCopyEmailSender.php new file mode 100644 index 00000000..c029afcc --- /dev/null +++ b/src/Domain/Identity/Service/AdminCopyEmailSender.php @@ -0,0 +1,81 @@ +configProvider->isEnabled(ConfigOption::SendAdminCopies)) { + return; + } + + $mails = $this->resolveRecipientEmails($lists); + + foreach ($mails as $adminMail) { + $data = new MessagePrecacheDto(); + $data->to = $adminMail; + $data->subject = $this->installationName . ' ' . $subject; + $data->content = $message; + + $email = $this->systemEmailBuilder->buildSystemEmail(data: $data); + + $envelope = new Envelope( + sender: new Address($this->bounceEmail, 'PHPList'), + recipients: [new Address($adminMail)], + ); + $this->mailer->send(message: $email, envelope: $envelope); + } + } + + private function resolveRecipientEmails(array $lists): array + { + $emails = []; + if ($this->sendListAdminCopy) { + $emails = array_filter(array_map( + static fn ($list) => $list->getOwner()?->getEmail(), + $lists + )); + } + + if (count($emails) === 0) { + $adminMail = $this->configProvider->getValue(ConfigOption::AdminAddress); + $adminMailsString = $this->configProvider->getValue(ConfigOption::AdminAddresses); + + $emails = $adminMailsString + ? array_map('trim', explode(',', $adminMailsString)) + : []; + + if ($adminMail !== null && $adminMail !== '') { + $emails[] = trim($adminMail); + } + } + + $emails = array_filter($emails, fn($email) => is_string($email) && $email !== ''); + + return array_values(array_unique($emails)); + } +} diff --git a/src/Domain/Identity/Service/AdminNotifier.php b/src/Domain/Identity/Service/AdminNotifier.php new file mode 100644 index 00000000..1071cfba --- /dev/null +++ b/src/Domain/Identity/Service/AdminNotifier.php @@ -0,0 +1,62 @@ +adminCopyEmailSender)( + subject: $this->translator->trans('Message Forwarded'), + message: $this->translator->trans( + '%subscriber% tried forwarding message %campaignId% to %email% but failed', + [ + '%subscriber%' => $forwardingSubscriber->getEmail(), + '%campaignId%' => $campaign->getId(), + '%email%' => $friendEmail, + ] + ), + lists: $lists + ); + + $this->eventLogManager->log('forward', 'Error loading message ' . $campaign->getId().' in cache'); + } + + public function notifyForwardSucceeded( + Message $campaign, + Subscriber $forwardingSubscriber, + string $friendEmail, + array $lists + ): void { + ($this->adminCopyEmailSender)( + subject: $this->translator->trans('Message Forwarded'), + message: $this->translator->trans( + '%subscriber% has forwarded message %campaignId% to %email%', + [ + '%subscriber%' => $forwardingSubscriber->getEmail(), + '%campaignId%' => $campaign->getId(), + '%email%' => $friendEmail, + ] + ), + lists: $lists + ); + } +} diff --git a/src/Domain/Messaging/Exception/ForwardLimitExceededException.php b/src/Domain/Messaging/Exception/ForwardLimitExceededException.php new file mode 100644 index 00000000..75792c7f --- /dev/null +++ b/src/Domain/Messaging/Exception/ForwardLimitExceededException.php @@ -0,0 +1,15 @@ +campaignEmailBuilder->buildPhplistEmail( + $result = $this->campaignEmailBuilder->buildCampaignEmail( messageId: $campaign->getId(), data: $processed, skipBlacklistCheck: false, @@ -218,7 +218,7 @@ private function handleEmailSending( return; } [$email, $sentAs] = $result; - $email = $this->campaignEmailBuilder->applyCampaignHeaders(email: $email, subscriber: $subscriber); + $this->campaignEmailBuilder->applyCampaignHeaders(email: $email, subscriber: $subscriber); $this->rateLimitedCampaignMailer->send($email); ($this->mailSizeChecker)($campaign, $email, $subscriber->hasHtmlEmail()); @@ -240,11 +240,9 @@ private function handleEmailSending( $data->subject = $this->translator->trans('phpList system error'); $data->content = $this->translator->trans($e->getMessage()); - $email = $this->systemEmailBuilder->buildPhplistEmail( + $email = $this->systemEmailBuilder->buildCampaignEmail( messageId: $campaign->getId(), data: $data, - inBlast: false, - htmlPref: true, ); $envelope = new Envelope( @@ -279,11 +277,9 @@ private function handleAdminNotifications(Message $campaign, array $loadedMessag ['%subject%' => $loadedMessageData['subject']] ); - $email = $this->systemEmailBuilder->buildPhplistEmail( + $email = $this->systemEmailBuilder->buildCampaignEmail( messageId: $campaign->getId(), data: $data, - inBlast: false, - htmlPref: true, ); if (!$email) { diff --git a/src/Domain/Messaging/Model/Dto/MessageForwardDto.php b/src/Domain/Messaging/Model/Dto/MessageForwardDto.php new file mode 100644 index 00000000..e41bb167 --- /dev/null +++ b/src/Domain/Messaging/Model/Dto/MessageForwardDto.php @@ -0,0 +1,56 @@ +emails; + } + + public function getUid(): string + { + return $this->uid; + } + + public function getCutoff(): DateTimeInterface + { + return $this->cutoff; + } + + public function getFromName(): string + { + return $this->fromName; + } + + public function getFromEmail(): string + { + return $this->fromEmail; + } + + public function getNote(): ?string + { + return $this->note; + } +} diff --git a/src/Domain/Messaging/Repository/UserMessageForwardRepository.php b/src/Domain/Messaging/Repository/UserMessageForwardRepository.php index e2a48b46..65c4d193 100644 --- a/src/Domain/Messaging/Repository/UserMessageForwardRepository.php +++ b/src/Domain/Messaging/Repository/UserMessageForwardRepository.php @@ -4,9 +4,12 @@ namespace PhpList\Core\Domain\Messaging\Repository; +use DateTimeInterface; 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\UserMessageForward; +use PhpList\Core\Domain\Subscription\Model\Subscriber; class UserMessageForwardRepository extends AbstractRepository implements PaginatableRepositoryInterface { @@ -21,4 +24,23 @@ public function getCountByMessageId(int $messageId): int ->getQuery() ->getSingleScalarResult(); } + + public function getCountByUserSince(Subscriber $user, DateTimeInterface $cutoff): int + { + return (int) $this->createQueryBuilder('umf') + ->select('COUNT(umf.id)') + ->where('umf.user = :userId') + ->andWhere('umf.status = :status') + ->andWhere('umf.time >= :cutoff') + ->setParameter('userId', $user->getId()) + ->setParameter('status', 'sent') + ->setParameter('cutoff', $cutoff) + ->getQuery() + ->getSingleScalarResult(); + } + + public function findByEmailAndMessage(string $email, int $messageId): ?UserMessageForward + { + return $this->findOneBy(['forward' => $email, 'messageId' => $messageId]); + } } diff --git a/src/Domain/Messaging/Service/AttachmentAdder.php b/src/Domain/Messaging/Service/AttachmentAdder.php index 30fb3ea9..ad239ca9 100644 --- a/src/Domain/Messaging/Service/AttachmentAdder.php +++ b/src/Domain/Messaging/Service/AttachmentAdder.php @@ -28,7 +28,7 @@ public function __construct( ) { } - public function add(Email $email, int $campaignId, OutputFormat $format): bool + public function add(Email $email, int $campaignId, OutputFormat $format, bool $forwarded = false): bool { $attachments = $this->attachmentRepository->findAttachmentsForMessage($campaignId); @@ -57,9 +57,9 @@ public function add(Email $email, int $campaignId, OutputFormat $format): bool break; case OutputFormat::Text: - $userEmail = $email->getTo()[0]->getAddress(); + $hash = $forwarded ? 'forwarded' : $email->getTo()[0]->getAddress(); // todo: add endpoint in rest-api project - $viewUrl = $this->attachmentDownloadUrl . '/?id=' . $att->getId() . '&uid=' . $userEmail; + $viewUrl = $this->attachmentDownloadUrl . '/?id=' . $att->getId() . '&uid=' . $hash; $email->text( $email->getTextBody() diff --git a/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php b/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php index a674cf30..98fc705d 100644 --- a/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php +++ b/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php @@ -16,7 +16,7 @@ use Symfony\Component\Mime\Email; /** @SuppressWarnings("ExcessiveParameterList") */ -class BaseEmailBuilder +abstract class BaseEmailBuilder { public function __construct( protected readonly ConfigProvider $configProvider, @@ -99,16 +99,28 @@ protected function resolveDestinationEmail(?string $to): string } protected function createBaseEmail( - int $messageId, - string $originalTo, + string $to, ?string $fromEmail, ?string $fromName, ?string $subject, - ?bool $inBlast ) : Email { $email = (new Email()); - $destinationEmail = $this->resolveDestinationEmail($originalTo); + $destinationEmail = $this->resolveDestinationEmail($to); + + $email->to($destinationEmail); + $email->from(new Address($fromEmail, $fromName ?? '')); + $email->subject($subject); + + return $email; + } + protected function addBaseCampaignHeaders( + Email $email, + int $messageId, + string $originalTo, + string $destinationEmail, + ?bool $inBlast + ): void { $email->getHeaders()->addTextHeader('X-MessageID', (string)$messageId); $email->getHeaders()->addTextHeader('X-ListMember', $destinationEmail); if ($this->googleSenderId !== '') { @@ -144,11 +156,5 @@ protected function createBaseEmail( new Address($originalTo) ); } - - $email->to($destinationEmail); - $email->from(new Address($fromEmail, $fromName ?? '')); - $email->subject($subject); - - return $email; } } diff --git a/src/Domain/Messaging/Service/Builder/EmailBuilder.php b/src/Domain/Messaging/Service/Builder/EmailBuilder.php index 3c27e583..58a624e7 100644 --- a/src/Domain/Messaging/Service/Builder/EmailBuilder.php +++ b/src/Domain/Messaging/Service/Builder/EmailBuilder.php @@ -13,7 +13,7 @@ use PhpList\Core\Domain\Messaging\Exception\AttachmentException; use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Service\AttachmentAdder; -use PhpList\Core\Domain\Messaging\Service\Constructor\MailContentBuilderInterface; +use PhpList\Core\Domain\Messaging\Service\Constructor\CampaignMailContentBuilder; use PhpList\Core\Domain\Messaging\Service\TemplateImageEmbedder; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; @@ -25,7 +25,10 @@ use Symfony\Component\Mime\Email; use Symfony\Contracts\Translation\TranslatorInterface; -/** @SuppressWarnings("ExcessiveParameterList") @SuppressWarnings("PHPMD.CouplingBetweenObjects") */ +/** + * @SuppressWarnings("ExcessiveParameterList") + * @SuppressWarnings("PHPMD.CouplingBetweenObjects") + */ class EmailBuilder extends BaseEmailBuilder { public function __construct( @@ -35,12 +38,12 @@ public function __construct( SubscriberHistoryManager $subscriberHistoryManager, SubscriberRepository $subscriberRepository, LoggerInterface $logger, - private readonly MailContentBuilderInterface $mailConstructor, - private readonly TemplateImageEmbedder $templateImageEmbedder, - private readonly LegacyUrlBuilder $urlBuilder, - private readonly PdfGenerator $pdfGenerator, - private readonly AttachmentAdder $attachmentAdder, - private readonly TranslatorInterface $translator, + protected readonly CampaignMailContentBuilder $mailContentBuilder, + protected readonly TemplateImageEmbedder $templateImageEmbedder, + protected readonly LegacyUrlBuilder $urlBuilder, + protected readonly PdfGenerator $pdfGenerator, + protected readonly AttachmentAdder $attachmentAdder, + protected readonly TranslatorInterface $translator, string $googleSenderId, bool $useAmazonSes, bool $usePrecedenceHeader, @@ -62,7 +65,8 @@ public function __construct( ); } - public function buildPhplistEmail( + /** @return array{Email, OutputFormat}|null */ + public function buildCampaignEmail( int $messageId, MessagePrecacheDto $data, ?bool $skipBlacklistCheck = false, @@ -82,15 +86,18 @@ public function buildPhplistEmail( $fromName = $data->fromName; $subject = (!$isTestMail ? '' : $this->translator->trans('(test)') . ' ') . $data->subject; - [$htmlMessage, $textMessage] = ($this->mailConstructor)(messagePrecacheDto: $data); - $email = $this->createBaseEmail( - messageId: $messageId, - originalTo: $data->to, + to: $data->to, fromEmail: $fromEmail, fromName: $fromName, subject: $subject, - inBlast: $inBlast + ); + $this->addBaseCampaignHeaders( + email: $email, + messageId: $messageId, + originalTo: $data->to, + destinationEmail: $email->getTo()[0]->getAddress(), + inBlast: $inBlast, ); if (!empty($data->replyToEmail)) { @@ -102,6 +109,7 @@ public function buildPhplistEmail( } } + [$htmlMessage, $textMessage] = ($this->mailContentBuilder)(messagePrecacheDto: $data, campaignId: $messageId); $sentAs = $this->applyContentAndFormatting( email: $email, htmlMessage: $htmlMessage, @@ -142,7 +150,8 @@ public function applyContentAndFormatting( ?string $textMessage, int $messageId, MessagePrecacheDto $data, - bool $htmlPref = false + bool $htmlPref = false, + bool $forwarded = false, ): OutputFormat { $htmlPref = $this->shouldPreferHtml($htmlMessage, $htmlPref, $email); $normalizedFormat = $this->normalizeSendFormat($data->sendFormat); @@ -150,15 +159,15 @@ public function applyContentAndFormatting( // so what do we actually send? switch ($normalizedFormat) { case 'pdf': - $sentAs = $this->applyPdfFormat($email, $textMessage, $messageId, $htmlPref); + $sentAs = $this->applyPdfFormat($email, $textMessage, $messageId, $htmlPref, $forwarded); break; case 'text_and_pdf': - $sentAs = $this->applyTextAndPdfFormat($email, $textMessage, $messageId, $htmlPref); + $sentAs = $this->applyTextAndPdfFormat($email, $textMessage, $messageId, $htmlPref, $forwarded); break; case 'text': $sentAs = OutputFormat::Text; $email->text($textMessage); - if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Text)) { + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Text, $forwarded)) { throw new AttachmentException(); } break; @@ -168,13 +177,13 @@ public function applyContentAndFormatting( $htmlMessage = ($this->templateImageEmbedder)(html: $htmlMessage, messageId: $messageId); $email->html($htmlMessage); $email->text($textMessage); - if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Html)) { + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Html, $forwarded)) { throw new AttachmentException(); } } else { $sentAs = OutputFormat::Text; $email->text($textMessage); - if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Text)) { + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Text, $forwarded)) { throw new AttachmentException(); } } @@ -184,7 +193,7 @@ public function applyContentAndFormatting( return $sentAs; } - private function shouldPreferHtml(?string $htmlMessage, bool $htmlPref, Email $email): bool + protected function shouldPreferHtml(?string $htmlMessage, bool $htmlPref, Email $email): bool { if (empty($email->getTo())) { throw new LogicException('No recipients specified'); @@ -203,7 +212,7 @@ private function shouldPreferHtml(?string $htmlMessage, bool $htmlPref, Email $e return $htmlPref; } - private function normalizeSendFormat(?string $sendFormat): string + protected function normalizeSendFormat(?string $sendFormat): string { $format = strtolower(trim((string) $sendFormat)); @@ -216,21 +225,26 @@ private function normalizeSendFormat(?string $sendFormat): string }; } - private function applyPdfFormat(Email $email, ?string $textMessage, int $messageId, bool $htmlPref): OutputFormat - { + protected function applyPdfFormat( + Email $email, + ?string $textMessage, + int $messageId, + bool $htmlPref, + bool $forwarded + ): OutputFormat { // send a PDF file to users who want html and text to everyone else if ($htmlPref) { $sentAs = OutputFormat::Pdf; $pdfBytes = $this->pdfGenerator->createPdfBytes($textMessage); $email->attach($pdfBytes, 'message.pdf', 'application/pdf'); - if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Html)) { + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Html, $forwarded)) { throw new AttachmentException(); } } else { $sentAs = OutputFormat::Text; $email->text($textMessage); - if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Text)) { + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Text, $forwarded)) { throw new AttachmentException(); } } @@ -238,11 +252,12 @@ private function applyPdfFormat(Email $email, ?string $textMessage, int $message return $sentAs; } - private function applyTextAndPdfFormat( + protected function applyTextAndPdfFormat( Email $email, ?string $textMessage, int $messageId, - bool $htmlPref + bool $htmlPref, + bool $forwarded, ): OutputFormat { // send a PDF file to users who want html and text to everyone else if ($htmlPref) { @@ -251,13 +266,13 @@ private function applyTextAndPdfFormat( $email->attach($pdfBytes, 'message.pdf', 'application/pdf'); $email->text($textMessage); - if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Html)) { + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Html, $forwarded)) { throw new AttachmentException(); } } else { $sentAs = OutputFormat::Text; $email->text($textMessage); - if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Text)) { + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Text, $forwarded)) { throw new AttachmentException(); } } diff --git a/src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php b/src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php new file mode 100644 index 00000000..7d660531 --- /dev/null +++ b/src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php @@ -0,0 +1,135 @@ +validateRecipientAndSubject(to: $friendEmail, subject: $data->subject)) { + return null; + } + + if (!$this->passesBlacklistCheck(to: $friendEmail, skipBlacklistCheck: false)) { + return null; + } + + $subject = $this->translator->trans('Fwd') . ': ' . stripslashes($data->subject); + + [$htmlMessage, $textMessage] = ($this->mailContentBuilder)( + messagePrecacheDto: $data, + campaignId: $messageId, + forwardedBy: $forwardedBy, + forwardedPersonalNote: $forwardedPersonalNote, + ); + + $email = $this->createBaseEmail( + to: $friendEmail, + fromEmail: $fromEmail, + fromName: $fromName, + subject: $subject, + ); + + $this->applyCampaignHeaders($email, $forwardedBy); + + $email->addReplyTo(new Address($fromEmail, $fromName)); + + $sentAs = $this->applyContentAndFormatting( + email: $email, + htmlMessage: $htmlMessage, + textMessage: $textMessage, + messageId: $messageId, + data: $data, + htmlPref: $htmlPref, + forwarded: true, + ); + + return [$email, $sentAs]; + } + + public function applyCampaignHeaders(Email $email, Subscriber $subscriber): Email + { + $email = parent::applyCampaignHeaders($email, $subscriber); + + $receivedLine = $this->httpReceivedStampBuilder->buildStamp(); + if ($receivedLine !== null) { + $email->getHeaders()->addTextHeader('Received', $receivedLine); + } + + return $email; + } +} diff --git a/src/Domain/Messaging/Service/Builder/HttpReceivedStampBuilder.php b/src/Domain/Messaging/Service/Builder/HttpReceivedStampBuilder.php new file mode 100644 index 00000000..8c62a2af --- /dev/null +++ b/src/Domain/Messaging/Service/Builder/HttpReceivedStampBuilder.php @@ -0,0 +1,66 @@ +requestStack->getCurrentRequest(); + if (!$request) { + return null; + } + + $ipAddress = $request->getClientIp(); + if (!$ipAddress) { + return null; + } + + $remoteHost = $request->server->get('REMOTE_HOST'); + $ipDomain = $remoteHost ?: $this->getHostByAddr($ipAddress); + + if ($ipDomain && $ipDomain !== $ipAddress) { + $from = sprintf('%s [%s]', $ipDomain, $ipAddress); + } else { + $from = sprintf('[%s]', $ipAddress); + } + + $requestTime = $request->server->get('REQUEST_TIME') ?? time(); + $date = (new DateTimeImmutable('@' . $requestTime))->format(\DATE_RFC2822); + + return sprintf('from %s by %s with HTTP; %s', $from, $this->hostname, $date); + } + + private function getHostByAddr(string $ipAddress): ?string + { + $previousHandler = set_error_handler(static fn(): bool => true); + + try { + $host = gethostbyaddr($ipAddress); + } finally { + if ($previousHandler !== null) { + set_error_handler($previousHandler); + } else { + restore_error_handler(); + } + } + + if ($host === false || $host === $ipAddress) { + return null; + } + + return $host; + } +} diff --git a/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php b/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php index f5d52a69..d58700f3 100644 --- a/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php +++ b/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php @@ -4,11 +4,12 @@ namespace PhpList\Core\Domain\Messaging\Service\Builder; +use PhpList\Core\Core\Version; use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; -use PhpList\Core\Domain\Messaging\Service\Constructor\MailContentBuilderInterface; +use PhpList\Core\Domain\Messaging\Service\Constructor\SystemMailContentBuilder; use PhpList\Core\Domain\Messaging\Service\TemplateImageEmbedder; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository; @@ -25,7 +26,7 @@ public function __construct( UserBlacklistRepository $blacklistRepository, SubscriberHistoryManager $subscriberHistoryManager, SubscriberRepository $subscriberRepository, - protected readonly MailContentBuilderInterface $mailConstructor, + protected readonly SystemMailContentBuilder $mailConstructor, protected readonly TemplateImageEmbedder $templateImageEmbedder, LoggerInterface $logger, string $googleSenderId, @@ -49,12 +50,10 @@ public function __construct( ); } - public function buildPhplistEmail( + public function buildCampaignEmail( int $messageId, MessagePrecacheDto $data, ?bool $skipBlacklistCheck = false, - ?bool $inBlast = true, - ?bool $htmlPref = false, ): ?Email { if (!$this->validateRecipientAndSubject(to: $data->to, subject: $data->subject)) { return null; @@ -66,20 +65,26 @@ public function buildPhplistEmail( $fromEmail = $this->configProvider->getValue(ConfigOption::MessageFromAddress); $fromName = $this->configProvider->getValue(ConfigOption::MessageFromName); -// $messageReplyToAddress = $this->configProvider->getValue(ConfigOption::MessageReplyToAddress); -// $replyTo = $messageReplyToAddress ?: $fromEmail; - - [$htmlMessage, $textMessage] = ($this->mailConstructor)(messagePrecacheDto: $data); + $messageReplyToAddress = $this->configProvider->getValue(ConfigOption::MessageReplyToAddress); + $replyTo = $messageReplyToAddress ?: $fromEmail; $email = $this->createBaseEmail( - messageId: $messageId, - originalTo: $data->to, + to: $data->to, fromEmail: $fromEmail, fromName: $fromName, subject: $data->subject, - inBlast: $inBlast ); + $email->replyTo($replyTo); + $this->addBaseCampaignHeaders( + email: $email, + messageId: $messageId, + originalTo: $data->to, + destinationEmail: $email->getTo()[0]->getAddress(), + inBlast: false, + ); + + [$htmlMessage, $textMessage] = ($this->mailConstructor)(messagePrecacheDto: $data); $this->applyContentAndFormatting( email: $email, htmlMessage: $htmlMessage, @@ -90,6 +95,54 @@ public function buildPhplistEmail( return $email; } + public function buildSystemEmail( + MessagePrecacheDto $data, + ?bool $skipBlacklistCheck = false, + ): ?Email { + if (!$this->validateRecipientAndSubject(to: $data->to, subject: $data->subject)) { + return null; + } + + if (!$this->passesBlacklistCheck(to: $data->to, skipBlacklistCheck: $skipBlacklistCheck)) { + return null; + } + + $fromEmail = $this->configProvider->getValue(ConfigOption::MessageFromAddress); + $fromName = $this->configProvider->getValue(ConfigOption::MessageFromName); + $messageReplyToAddress = $this->configProvider->getValue(ConfigOption::MessageReplyToAddress); + $replyTo = $messageReplyToAddress ?: $fromEmail; + + $email = $this->createBaseEmail( + to: $data->to, + fromEmail: $fromEmail, + fromName: $fromName, + subject: $data->subject, + ); + $email->replyTo($replyTo); + + $this->addSystemHeaders(email: $email, originalTo: $data->to,); + + [$htmlMessage, $textMessage] = ($this->mailConstructor)(messagePrecacheDto: $data); + $email->text($textMessage); + $email->html($htmlMessage); + + return $email; + } + + protected function addSystemHeaders(Email $email, string $originalTo): void + { + $email->getHeaders()->addTextHeader( + 'X-Mailer', + sprintf('phplist version %s (www.phplist.com)', Version::VERSION) + ); + + $email->getHeaders()->addTextHeader('X-MessageID', 'systemmessage'); + + if ($originalTo !== '') { + $email->getHeaders()->addTextHeader('X-User', $originalTo); + } + } + protected function applyContentAndFormatting( Email $email, ?string $htmlMessage, diff --git a/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php b/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php index 25bdf075..c166b112 100644 --- a/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php +++ b/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php @@ -17,8 +17,9 @@ use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Messaging\Exception\SubscriberNotFoundException; +use Symfony\Component\DependencyInjection\Attribute\Autowire; -class CampaignMailContentBuilder implements MailContentBuilderInterface +class CampaignMailContentBuilder { public function __construct( private readonly SubscriberRepository $subscriberRepository, @@ -28,11 +29,16 @@ public function __construct( private readonly Html2Text $html2Text, private readonly TextParser $textParser, private readonly MessagePlaceholderProcessor $placeholderProcessor, + #[Autowire('%phplist.forward_personal_note_size%')] private readonly int $forwardPersonalNoteSize = 0, ) { } - public function __invoke(MessagePrecacheDto $messagePrecacheDto, ?int $campaignId = null,): array - { + public function __invoke( + MessagePrecacheDto $messagePrecacheDto, + ?int $campaignId = null, + ?Subscriber $forwardedBy = null, + ?string $forwardedPersonalNote = null, + ): array { $subscriber = $this->subscriberRepository->findOneByEmail($messagePrecacheDto->to); if (!$subscriber) { throw new SubscriberNotFoundException( @@ -74,23 +80,31 @@ public function __invoke(MessagePrecacheDto $messagePrecacheDto, ?int $campaignI $textMessage = $this->placeholderProcessor->process( value: $textMessage, - user: $subscriber, + receiver: $subscriber, format: OutputFormat::Text, messagePrecacheDto: $messagePrecacheDto, campaignId: $campaignId, + forwardedBy: $forwardedBy, ); $htmlMessage = $this->placeholderProcessor->process( value: $htmlMessage, - user: $subscriber, + receiver: $subscriber, format: OutputFormat::Html, messagePrecacheDto: $messagePrecacheDto, campaignId: $campaignId, + forwardedBy: $forwardedBy, ); $htmlMessage = $this->ensureHtmlFormating(content: $htmlMessage, addDefaultStyle: $addDefaultStyle); // todo: add link CLICKTRACK to $htmlMessage + [$htmlMessage, $textMessage] = $this->ensureNoteInCaseOfForwarded( + htmlMessage: $htmlMessage, + textMessage: $textMessage, + note: $forwardedPersonalNote, + ); + return [$htmlMessage, $textMessage]; } @@ -156,4 +170,19 @@ private function ensureHtmlFormating(string $content, bool $addDefaultStyle): st return str_ireplace('

', '', $content); } + + private function ensureNoteInCaseOfForwarded(string $htmlMessage, string $textMessage, ?string $note): array + { + if ($note === null || $note === '') { + return [$htmlMessage, $textMessage]; + } + $escapedNote = htmlspecialchars($note, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + //0011996: forward to friend - personal message + if (($this->forwardPersonalNoteSize * 2) > strlen($note)) { + $htmlMessage = nl2br($escapedNote) . '
' . $htmlMessage; + $textMessage = $note . "\n" . $textMessage; + } + + return [$htmlMessage, $textMessage]; + } } diff --git a/src/Domain/Messaging/Service/Constructor/MailContentBuilderInterface.php b/src/Domain/Messaging/Service/Constructor/MailContentBuilderInterface.php deleted file mode 100644 index 024b86d7..00000000 --- a/src/Domain/Messaging/Service/Constructor/MailContentBuilderInterface.php +++ /dev/null @@ -1,19 +0,0 @@ -poweredByText = $configProvider->getValue(ConfigOption::PoweredByText); } - public function __invoke(MessagePrecacheDto $messagePrecacheDto, ?int $campaignId = null): array + public function __invoke(MessagePrecacheDto $messagePrecacheDto): array { [$htmlMessage, $textMessage] = $this->buildMessageBodies($messagePrecacheDto->content); diff --git a/src/Domain/Messaging/Service/ForwardContentService.php b/src/Domain/Messaging/Service/ForwardContentService.php new file mode 100644 index 00000000..14574749 --- /dev/null +++ b/src/Domain/Messaging/Service/ForwardContentService.php @@ -0,0 +1,58 @@ +cache->get(sprintf('messaging.message.base.%d.%d', $campaign->getId(), 1)); + + if ($messagePrecacheDto === null) { + 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 + ); + + return $this->forwardEmailBuilder->buildForwardEmail( + messageId: $campaign->getId(), + friendEmail: $friendEmail, + forwardedBy: $forwardingSubscriber, + data: $processed, + htmlPref: $forwardingSubscriber->hasHtmlEmail(), + fromName: $forwardDto->getFromName(), + fromEmail: $forwardDto->getFromEmail(), + forwardedPersonalNote: $forwardDto->getNote() + ); + } +} diff --git a/src/Domain/Messaging/Service/ForwardDeliveryService.php b/src/Domain/Messaging/Service/ForwardDeliveryService.php new file mode 100644 index 00000000..9bec169e --- /dev/null +++ b/src/Domain/Messaging/Service/ForwardDeliveryService.php @@ -0,0 +1,58 @@ +getTo())) { + throw new LogicException('No recipient specified'); + } + + $envelope = new Envelope( + sender: new Address($this->bounceEmail, 'PHPList'), + recipients: [new Address($friendEmail->getTo()[0]->getAddress())], + ); + $this->mailer->send(message: $friendEmail, envelope: $envelope); + } + + public function markSent(Message $campaign, Subscriber $subscriber, string $friendEmail): void + { + $this->messageForwardManager->create( + subscriber: $subscriber, + campaign: $campaign, + friendEmail: $friendEmail, + status: 'sent' + ); + } + + public function markFailed(Message $campaign, Subscriber $subscriber, string $friendEmail): void + { + $this->messageForwardManager->create( + subscriber: $subscriber, + campaign: $campaign, + friendEmail: $friendEmail, + status: 'failed' + ); + } +} diff --git a/src/Domain/Messaging/Service/ForwardingGuard.php b/src/Domain/Messaging/Service/ForwardingGuard.php new file mode 100644 index 00000000..7c2a54cd --- /dev/null +++ b/src/Domain/Messaging/Service/ForwardingGuard.php @@ -0,0 +1,53 @@ +subscriberRepository->findOneByUniqueId($uid); + if ($subscriber === null) { + throw new MessageNotReceivedException(); + } + $receivedMessage = $this->userMessageRepository->findOneByUserAndMessage($subscriber, $campaign); + + if ($receivedMessage === null) { + throw new MessageNotReceivedException(); + } + + $forwardPeriodCount = $this->forwardRepository->getCountByUserSince($subscriber, $cutoff); + if ($forwardPeriodCount >= $this->forwardMessageCount) { + throw new ForwardLimitExceededException(); + } + + return $subscriber; + } + + public function hasAlreadyBeenSent(string $friendEmail, Message $campaign): bool + { + $existing = $this->forwardRepository->findByEmailAndMessage($friendEmail, $campaign->getId()); + + return $existing !== null && $existing->getStatus() === 'sent'; + } +} diff --git a/src/Domain/Messaging/Service/ForwardingStatsService.php b/src/Domain/Messaging/Service/ForwardingStatsService.php new file mode 100644 index 00000000..0e7ed518 --- /dev/null +++ b/src/Domain/Messaging/Service/ForwardingStatsService.php @@ -0,0 +1,87 @@ +forwardFriendCountAttribute = $forwardFriendCountAttr !== '' ? $forwardFriendCountAttr : null; + } + + public function incrementFriendsCount(Subscriber $subscriber): void + { + if ($this->forwardFriendCountAttribute === null) { + return; + } + + $subscriberId = $subscriber->getId(); + + if ($this->friendCount === null || $this->friendCountSubscriberId !== $subscriberId) { + $this->friendCount = $this->loadFriendsCount($subscriber); + $this->friendCountSubscriberId = $subscriberId; + } + + $this->friendCount++; + } + + public function updateFriendsCount(Subscriber $subscriber): void + { + if ($this->forwardFriendCountAttribute === null) { + return; + } + + $subscriberId = $subscriber->getId(); + + // No cached value or cache belongs to a different subscriber → nothing to update + if ($this->friendCount === null || $this->friendCountSubscriberId !== $subscriberId) { + return; + } + + $this->subscriberAttributeManager->createOrUpdateByName( + subscriber: $subscriber, + attributeName: $this->forwardFriendCountAttribute, + value: (string) $this->friendCount + ); + + // reset typed properties + $this->friendCount = null; + $this->friendCountSubscriberId = null; + } + + private function loadFriendsCount(Subscriber $subscriber): int + { + if ($this->forwardFriendCountAttribute === null) { + return 0; + } + + $attribute = $this->subscriberAttributeValueRepo->findOneBySubscriberAndAttributeName( + subscriber: $subscriber, + attributeName: $this->forwardFriendCountAttribute + ); + + return (int) ($attribute?->getValue() ?? 0); + } +} diff --git a/src/Domain/Messaging/Service/Manager/UserMessageForwardManager.php b/src/Domain/Messaging/Service/Manager/UserMessageForwardManager.php new file mode 100644 index 00000000..0a352013 --- /dev/null +++ b/src/Domain/Messaging/Service/Manager/UserMessageForwardManager.php @@ -0,0 +1,34 @@ +setMessageId($campaign->getId()) + ->setUserId($subscriber->getId()) + ->setForward($friendEmail) + ->setStatus($status); + + $this->entityManager->persist($forward); + return $forward; + } +} diff --git a/src/Domain/Messaging/Service/MessageForwardService.php b/src/Domain/Messaging/Service/MessageForwardService.php new file mode 100644 index 00000000..5698147b --- /dev/null +++ b/src/Domain/Messaging/Service/MessageForwardService.php @@ -0,0 +1,85 @@ +messageDataLoader)($campaign); + $forwardingSubscriber = $this->guard->assertCanForward( + uid: $messageForwardDto->getUid(), + campaign: $campaign, + cutoff: $messageForwardDto->getCutoff(), + ); + $messageLists = $this->subscriberListRepository->getListsByMessage($campaign); + + foreach ($messageForwardDto->getEmails() as $friendEmail) { + if ($this->guard->hasAlreadyBeenSent(friendEmail: $friendEmail, campaign: $campaign)) { + continue; + } + + if (!$this->precacheService->precacheMessage($campaign, $loadedMessageData, true)) { + $this->adminNotifier->notifyForwardFailed( + campaign: $campaign, + forwardingSubscriber: $forwardingSubscriber, + friendEmail: $friendEmail, + lists: $messageLists + ); + $this->forwardDeliveryService->markFailed($campaign, $forwardingSubscriber, $friendEmail); + continue; + } + + $result = $this->forwardContentService->getContents( + campaign: $campaign, + forwardingSubscriber: $forwardingSubscriber, + friendEmail: $friendEmail, + forwardDto: $messageForwardDto, + ); + + if ($result === null) { + $this->adminNotifier->notifyForwardFailed( + campaign: $campaign, + forwardingSubscriber: $forwardingSubscriber, + friendEmail: $friendEmail, + lists: $messageLists + ); + $this->forwardDeliveryService->markFailed($campaign, $forwardingSubscriber, $friendEmail); + continue; + } + + [$email, $sentAs] = $result; + $this->forwardDeliveryService->send($email); + $this->adminNotifier->notifyForwardSucceeded( + campaign: $campaign, + forwardingSubscriber: $forwardingSubscriber, + friendEmail: $friendEmail, + lists: $messageLists + ); + $this->forwardDeliveryService->markSent($campaign, $forwardingSubscriber, $friendEmail); + $campaign->incrementSentCount($sentAs); + $this->forwardingStatsService->incrementFriendsCount($forwardingSubscriber); + } + + $this->forwardingStatsService->updateFriendsCount($forwardingSubscriber); + } +} diff --git a/src/Domain/Messaging/Service/MessagePrecacheService.php b/src/Domain/Messaging/Service/MessagePrecacheService.php index 90b847a5..5d866e90 100644 --- a/src/Domain/Messaging/Service/MessagePrecacheService.php +++ b/src/Domain/Messaging/Service/MessagePrecacheService.php @@ -43,7 +43,6 @@ public function __construct( /** * Retrieve the base (unpersonalized) message content for a campaign from cache, * or cache it on first access. Handle [URL:] token fetch and basic placeholder replacements. - * */ public function precacheMessage(Message $campaign, $loadedMessageData, ?bool $forwardContent = false): bool { diff --git a/src/Domain/Subscription/Exception/AttributeNotAllowedException.php b/src/Domain/Subscription/Exception/AttributeNotAllowedException.php new file mode 100644 index 00000000..019877bd --- /dev/null +++ b/src/Domain/Subscription/Exception/AttributeNotAllowedException.php @@ -0,0 +1,19 @@ + $this->id, + 'name' => $this->name, + 'type' => $this->type, + 'listOrder' => $this->listOrder, + 'defaultValue' => $this->defaultValue, + 'required' => $this->required, + 'tableName' => $this->tableName, + 'options' => $this->options, + ]; + } + public function getId(): ?int { return $this->id; diff --git a/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php b/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php index 49f15ab2..0f40d648 100644 --- a/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php @@ -90,4 +90,19 @@ public function existsByAttributeAndValue(string $tableName, int $optionId): boo return $row !== null; } + + public function findOneBySubscriberAndAttributeName( + Subscriber $subscriber, + string $attributeName + ): ?SubscriberAttributeValue { + return $this->createQueryBuilder('sa') + ->join('sa.subscriber', 's') + ->join('sa.attributeDefinition', 'ad') + ->where('s = :subscriber') + ->andWhere('ad.name = :attributeName') + ->setParameter('subscriber', $subscriber) + ->setParameter('attributeName', $attributeName) + ->getQuery() + ->getOneOrNullResult(); + } } diff --git a/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php b/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php index 4426fe70..8eb1d363 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Domain\Subscription\Service\Manager; use Doctrine\ORM\EntityManagerInterface; +use InvalidArgumentException; use PhpList\Core\Domain\Subscription\Exception\SubscriberAttributeCreationException; use PhpList\Core\Domain\Subscription\Model\Dto\ChangeSetDto; use PhpList\Core\Domain\Subscription\Model\Subscriber; @@ -16,21 +17,25 @@ class SubscriberAttributeManager { - private SubscriberAttributeValueRepository $attributeRepository; - private SubscriberAttributeDefinitionRepository $attrDefinitionRepository; - private EntityManagerInterface $entityManager; - private TranslatorInterface $translator; - public function __construct( - SubscriberAttributeValueRepository $attributeRepository, - SubscriberAttributeDefinitionRepository $attrDefinitionRepository, - EntityManagerInterface $entityManager, - TranslatorInterface $translator, + private readonly SubscriberAttributeValueRepository $attributeRepository, + private readonly SubscriberAttributeDefinitionRepository $attrDefinitionRepository, + private readonly EntityManagerInterface $entityManager, + private readonly TranslatorInterface $translator, ) { - $this->attributeRepository = $attributeRepository; - $this->attrDefinitionRepository = $attrDefinitionRepository; - $this->entityManager = $entityManager; - $this->translator = $translator; + } + + public function createOrUpdateByName( + Subscriber $subscriber, + string $attributeName, + ?string $value = null + ): SubscriberAttributeValue { + $definition = $this->attrDefinitionRepository->findOneByName($attributeName); + if (!$definition) { + throw new InvalidArgumentException('Attribute definition not found for name: ' . $attributeName); + } + + return $this->createOrUpdate($subscriber, $definition, $value); } public function createOrUpdate( @@ -59,7 +64,10 @@ public function createOrUpdate( public function getSubscriberAttribute(int $subscriberId, int $attributeDefinitionId): ?SubscriberAttributeValue { - return $this->attributeRepository->findOneBySubscriberIdAndAttributeId($subscriberId, $attributeDefinitionId); + return $this->attributeRepository->findOneBySubscriberIdAndAttributeId( + subscriberId: $subscriberId, + attributeDefinitionId: $attributeDefinitionId + ); } public function delete(SubscriberAttributeValue $attribute): void diff --git a/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php b/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php index 0b7c9b0d..2e67097f 100644 --- a/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php +++ b/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php @@ -35,10 +35,10 @@ protected function setUp(): void private function makeUser(string $email = 'user@example.com', string $uid = 'UID123'): Subscriber { - $u = new Subscriber(); - $u->setEmail($email); - $u->setUniqueId($uid); - return $u; + $user = new Subscriber(); + $user->setEmail($email); + $user->setUniqueId($uid); + return $user; } public function testEnsuresStandardPlaceholdersAndUsertrackInHtmlOnly(): void @@ -55,12 +55,13 @@ public function testEnsuresStandardPlaceholdersAndUsertrackInHtmlOnly(): void patternResolvers: [], supportingResolvers: [], alwaysAddUserTrack: true, + keepForwardedAttributes: false ); $html = 'Hello'; $processedHtml = $processor->process( value: $html, - user: $user, + receiver: $user, format: OutputFormat::Html, messagePrecacheDto: $dto, campaignId: 42, @@ -74,7 +75,7 @@ public function testEnsuresStandardPlaceholdersAndUsertrackInHtmlOnly(): void $text = 'Hi'; $processedText = $processor->process( value: $text, - user: $user, + receiver: $user, format: OutputFormat::Text, messagePrecacheDto: $dto, ); @@ -86,6 +87,7 @@ public function testEnsuresStandardPlaceholdersAndUsertrackInHtmlOnly(): void public function testBuiltInResolversReplaceEmailUserIdAndConfigValues(): void { $user = $this->makeUser('alice@example.com', 'U-999'); + $forwardedBy = $this->makeUser('bob@example.com', 'U-991'); $dto = new MessagePrecacheDto(); $this->config->method('getValue')->willReturnCallback( @@ -107,20 +109,21 @@ function (ConfigOption $opt): ?string { patternResolvers: [], supportingResolvers: [], alwaysAddUserTrack: false, + keepForwardedAttributes: false ); $content = 'Hi [EMAIL], id=[USERID], web=[WEBSITE], dom=[DOMAIN], org=[ORGANIZATION_NAME].'; $out = $processor->process( value: $content, - user: $user, + receiver: $user, format: OutputFormat::Text, messagePrecacheDto: $dto, campaignId: 101, - forwardedBy: 'bob@example.com', + forwardedBy: $forwardedBy, ); $this->assertStringContainsString('Hi alice@example.com,', $out); - $this->assertStringContainsString('id=U-999,', $out); + $this->assertStringContainsString('id=forwarded,', $out); $this->assertStringContainsString('web=https://site.example,', $out); $this->assertStringContainsString('dom=example.com,', $out); $this->assertStringContainsString('org=ACME Inc.', $out); @@ -175,12 +178,13 @@ public function resolve(string $key, PlaceholderContext $ctx): ?string patternResolvers: [$pattern], supportingResolvers: [$supporting], alwaysAddUserTrack: false, + keepForwardedAttributes: false ); $content = 'A [CUSTOM] B [UPPER:abc] C [SUPPORT]'; $out = $processor->process( value: $content, - user: $user, + receiver: $user, format: OutputFormat::Text, messagePrecacheDto: $dto, ); diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/FooterValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/FooterValueResolverTest.php index 73a464c1..1508cf3d 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/FooterValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/FooterValueResolverTest.php @@ -23,58 +23,103 @@ protected function setUp(): void $this->config = $this->createMock(ConfigProvider::class); } - private function makeUser(): Subscriber + private function makeUser(string $email = 'user@example.com', string $uid = 'UID-1'): Subscriber { $u = new Subscriber(); - $u->setEmail('user@example.com'); - $u->setUniqueId('UID-FTR'); + $u->setEmail($email); + $u->setUniqueId($uid); return $u; } + private function makeDto( + string $textFooter = 'TEXT_FOOT', + string $htmlFooter = 'HTML_FOOT', + string $footer = '' + ): MessagePrecacheDto { + $dto = new MessagePrecacheDto(); + $dto->textFooter = $textFooter; + $dto->htmlFooter = $htmlFooter; + $dto->footer = $footer; + return $dto; + } + public function testName(): void { $resolver = new FooterValueResolver($this->config, false); $this->assertSame('FOOTER', $resolver->name()); } - public function testReturnsConfigForwardFooterByDefault(): void + public function testReturnsDtoFooterWhenNotForwardedText(): void { - $this->config->method('getValue') - ->with(ConfigOption::ForwardFooter) - ->willReturn('Default footer'); + $resolver = new FooterValueResolver($this->config, false); + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Text, + messagePrecacheDto: $this->makeDto('TF', 'HF') + ); - $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Text); + $this->assertSame('TF', $resolver($ctx)); + } + public function testReturnsDtoFooterWhenNotForwardedHtml(): void + { $resolver = new FooterValueResolver($this->config, false); - $this->assertSame('Default footer', $resolver($ctx)); + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Html, + messagePrecacheDto: $this->makeDto('TF', 'HF') + ); + + $this->assertSame('HF', $resolver($ctx)); } - public function testReturnsEmptyStringWhenConfigNull(): void + public function testForwardedAlternativeUsesStripslashesFooter(): void + { + // footer contains escaped quotes/backslashes, should be unescaped by stripslashes + $raw = "It\\'s \\\"fine\\\" \\ path"; + $dto = $this->makeDto('TF', 'HF', $raw); + + $resolver = new FooterValueResolver($this->config, true); + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Text, + messagePrecacheDto: $dto, + forwardedBy: (new Subscriber())->setEmail('fwd@example.com'), + ); + $this->assertSame(stripslashes($raw), $resolver($ctx)); + } + + public function testForwardedUsesConfigForwardFooterWhenFlagFalse(): void { $this->config->method('getValue') ->with(ConfigOption::ForwardFooter) - ->willReturn(null); - - $ctx = new PlaceholderContext(user: $this->makeUser(), format: OutputFormat::Html); + ->willReturn('Forward footer set by config'); $resolver = new FooterValueResolver($this->config, false); - $this->assertSame('', $resolver($ctx)); + $ctx = new PlaceholderContext( + user: $this->makeUser(), + format: OutputFormat::Html, + messagePrecacheDto: $this->makeDto('TF', 'HF', 'Alt'), + forwardedBy: (new Subscriber())->setEmail('fwd@example.com'), + ); + + $this->assertSame('Forward footer set by config', $resolver($ctx)); } - public function testReturnsDtoFooterWhenForwardAlternativeContentEnabledAndDtoPresent(): void + public function testForwardedFallsBackToEmptyWhenConfigNull(): void { - $dto = new MessagePrecacheDto(); - // with backslashes - $dto->footer = 'A\\B\\C'; + $this->config->method('getValue') + ->with(ConfigOption::ForwardFooter) + ->willReturn(null); + $resolver = new FooterValueResolver($this->config, false); $ctx = new PlaceholderContext( user: $this->makeUser(), format: OutputFormat::Html, - messagePrecacheDto: $dto, + messagePrecacheDto: $this->makeDto('TF', 'HF', 'Alt'), + forwardedBy: (new Subscriber())->setEmail('fwd@example.com'), ); - // When alternative content flag is on, config should be ignored and dto footer used (with stripslashes) - $resolver = new FooterValueResolver($this->config, true); - $this->assertSame('ABC', $resolver($ctx)); + $this->assertSame('', $resolver($ctx)); } } diff --git a/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolverTest.php b/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolverTest.php index 8c33334a..3ae672eb 100644 --- a/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolverTest.php @@ -107,8 +107,8 @@ public function testForwardedByUsesBlacklistUrl(): void $this->urlBuilder ->expects($this->once()) ->method('withUid') - ->with($blacklistBase, 'UID-FWD') - ->willReturn($blacklistBase . '?uid=UID-FWD'); + ->with($blacklistBase, 'forwarded') + ->willReturn($blacklistBase . '?uid=forwarded'); $this->translator ->method('trans') @@ -120,7 +120,7 @@ public function testForwardedByUsesBlacklistUrl(): void format: OutputFormat::Html, messagePrecacheDto: null, locale: 'en', - forwardedBy: 'someone@example.com', + forwardedBy: (new Subscriber())->setEmail('someone@example.com'), ); $resolver = new UnsubscribeValueResolver($this->config, $this->urlBuilder, $this->translator); @@ -128,7 +128,7 @@ public function testForwardedByUsesBlacklistUrl(): void $this->assertStringContainsString( 'href="' - . htmlspecialchars($blacklistBase . '?uid=UID-FWD', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . htmlspecialchars($blacklistBase . '?uid=forwarded', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '"', $out ); diff --git a/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php b/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php new file mode 100644 index 00000000..de5fb3d9 --- /dev/null +++ b/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php @@ -0,0 +1,173 @@ +createMock(ConfigProvider::class); + $configProvider->expects(self::once()) + ->method('isEnabled') + ->with(ConfigOption::SendAdminCopies) + ->willReturn(false); + + $systemEmailBuilder = $this->createMock(SystemEmailBuilder::class); + $systemEmailBuilder->expects(self::never())->method('buildSystemEmail'); + + $mailer = $this->createMock(MailerInterface::class); + $mailer->expects(self::never())->method('send'); + + $sender = new AdminCopyEmailSender( + configProvider: $configProvider, + systemEmailBuilder: $systemEmailBuilder, + mailer: $mailer, + sendListAdminCopy: true, + bounceEmail: 'bounce@example.com', + ); + + $sender->__invoke('Subject', 'Message body'); + } + + public function testSendsToListOwnersWhenFlagEnabled(): void + { + $configProvider = $this->createMock(ConfigProvider::class); + $configProvider->method('isEnabled') + ->with(ConfigOption::SendAdminCopies) + ->willReturn(true); + + $emails = ['owner1@example.com', 'owner2@example.com']; + + $systemEmailBuilder = $this->createMock(SystemEmailBuilder::class); + // Expect called exactly for unique owner emails + $systemEmailBuilder->expects(self::exactly(count($emails))) + ->method('buildSystemEmail') + ->with(self::callback(function (MessagePrecacheDto $data): bool { + return $data->to !== null + && str_starts_with($data->subject, 'phpList ') + && $data->content === 'Hello Admin'; + })) + ->willReturn(new Email()); + + $mailer = $this->createMock(MailerInterface::class); + + $bounce = 'bounces@phplist.test'; + $invocationIndex = 0; + $mailer->expects(self::exactly(count($emails))) + ->method('send') + ->with( + self::isInstanceOf(Email::class), + self::callback(function (Envelope $envelope) use ($emails, &$invocationIndex, $bounce): bool { + // Verify bounce/sender address + $sender = $envelope->getSender(); + $recipient = $envelope->getRecipients()[0] ?? null; + $expectedRecipient = $emails[$invocationIndex++] ?? null; + + return $sender !== null + && $sender->getAddress() === $bounce + && $recipient !== null + && $recipient->getAddress() === $expectedRecipient; + }) + ); + + // Build lists with owners, including duplicates and a null owner + $list1 = $this->createListWithOwner('owner1@example.com'); + $list2 = $this->createListWithOwner('owner2@example.com'); + // no owner + $list3 = new SubscriberList(); + // duplicate owner to test de-dup + $list4 = $this->createListWithOwner('owner1@example.com'); + + $sender = new AdminCopyEmailSender( + configProvider: $configProvider, + systemEmailBuilder: $systemEmailBuilder, + mailer: $mailer, + sendListAdminCopy: true, + bounceEmail: $bounce, + ); + + $sender->__invoke('Test Subject', 'Hello Admin', [$list1, $list2, $list3, $list4]); + } + + public function testFallsBackToAdminAddressesWhenNoOwnersOrFlagFalse(): void + { + $configProvider = $this->createMock(ConfigProvider::class); + $configProvider->method('isEnabled') + ->with(ConfigOption::SendAdminCopies) + ->willReturn(true); + + $configProvider->expects(self::exactly(2)) + ->method('getValue') + ->withConsecutive([ConfigOption::AdminAddress], [ConfigOption::AdminAddresses]) + ->willReturnOnConsecutiveCalls( + 'single@example.com', + ' admin1@example.com, , admin2@example.com ,admin1@example.com ' + ); + + $expectedRecipients = ['admin1@example.com', 'admin2@example.com', 'single@example.com']; + + $systemEmailBuilder = $this->createMock(SystemEmailBuilder::class); + $systemEmailBuilder->expects(self::exactly(count($expectedRecipients))) + ->method('buildSystemEmail') + ->with(self::callback(function (MessagePrecacheDto $data): bool { + return $data->to !== null && str_starts_with($data->subject, 'phpList '); + })) + ->willReturn(new Email()); + + $mailer = $this->createMock(MailerInterface::class); + $bounce = 'bounce@domain.test'; + $i = 0; + $mailer->expects(self::exactly(count($expectedRecipients))) + ->method('send') + ->with( + self::isInstanceOf(Email::class), + self::callback(function (Envelope $envelope) use ($expectedRecipients, &$i, $bounce): bool { + $sender = $envelope->getSender(); + $recipient = $envelope->getRecipients()[0] ?? null; + $expected = $expectedRecipients[$i++] ?? null; + return $sender !== null + && $sender->getAddress() === $bounce + && $recipient !== null + && $recipient->getAddress() === $expected; + }) + ); + + $sender = new AdminCopyEmailSender( + configProvider: $configProvider, + systemEmailBuilder: $systemEmailBuilder, + mailer: $mailer, + // ensure fallback path regardless of list owners + sendListAdminCopy: false, + bounceEmail: $bounce, + ); + + // Even if lists have owners, flag=false should ignore them and use AdminAddress(es) + $listWithOwner = $this->createListWithOwner('ignored@example.com'); + $sender->__invoke('System Update', 'Body', [$listWithOwner]); + } + + private function createListWithOwner(string $email): SubscriberList + { + $admin = new Administrator(); + $admin->setEmail($email); + + $list = new SubscriberList(); + $list->setOwner($admin); + + return $list; + } +} diff --git a/tests/Unit/Domain/Identity/Service/AdminNotifierTest.php b/tests/Unit/Domain/Identity/Service/AdminNotifierTest.php new file mode 100644 index 00000000..74d6bdd0 --- /dev/null +++ b/tests/Unit/Domain/Identity/Service/AdminNotifierTest.php @@ -0,0 +1,167 @@ +adminCopyEmailSender = $this->createMock(AdminCopyEmailSender::class); + $this->translator = $this->createMock(TranslatorInterface::class); + $this->eventLogManager = $this->createMock(EventLogManager::class); + } + + public function testNotifyForwardFailedSendsAdminCopyAndLogs(): void + { + $campaign = $this->createMock(Message::class); + $campaign->method('getId')->willReturn(42); + + $subscriber = new Subscriber(); + $subscriber->setEmail('john@example.com'); + + $friendEmail = 'friend@example.com'; + $lists = [new SubscriberList()]; + + $expectedSubject = 'Message Forwarded'; + $expectedMessage = sprintf( + '%s tried forwarding message %d to %s but failed', + $subscriber->getEmail(), + 42, + $friendEmail + ); + + // Translator expectations: first for subject, then for message with placeholders + $this->translator + ->expects(self::exactly(2)) + ->method('trans') + ->withConsecutive( + [$this->equalTo('Message Forwarded')], + [ + $this->equalTo('%subscriber% tried forwarding message %campaignId% to %email% but failed'), + $this->callback(function (array $params) use ($subscriber, $friendEmail): bool { + return ($params['%subscriber%'] ?? null) === $subscriber->getEmail() + && ($params['%campaignId%'] ?? null) === 42 + && ($params['%email%'] ?? null) === $friendEmail; + }) + ] + ) + ->willReturnOnConsecutiveCalls( + $expectedSubject, + $expectedMessage + ); + + // Admin copy sender should be invoked with translated subject and message and same lists + $this->adminCopyEmailSender + ->expects(self::once()) + ->method('__invoke') + ->with( + $this->equalTo($expectedSubject), + $this->equalTo($expectedMessage), + $this->identicalTo($lists) + ); + + // EventLogManager should log only on failure + $this->eventLogManager + ->expects(self::once()) + ->method('log') + ->with( + $this->equalTo('forward'), + $this->equalTo('Error loading message 42 in cache') + ); + + $notifier = new AdminNotifier( + adminCopyEmailSender: $this->adminCopyEmailSender, + translator: $this->translator, + eventLogManager: $this->eventLogManager, + ); + + $notifier->notifyForwardFailed( + campaign: $campaign, + forwardingSubscriber: $subscriber, + friendEmail: $friendEmail, + lists: $lists + ); + } + + public function testNotifyForwardSucceededSendsAdminCopyWithoutLogging(): void + { + $campaign = $this->createMock(Message::class); + $campaign->method('getId')->willReturn(777); + + $subscriber = new Subscriber(); + $subscriber->setEmail('alice@example.com'); + + $friendEmail = 'bob@example.net'; + $lists = [new SubscriberList(), new SubscriberList()]; + + $expectedSubject = 'Message Forwarded'; + $expectedMessage = sprintf( + '%s has forwarded message %d to %s', + $subscriber->getEmail(), + 777, + $friendEmail + ); + + $this->translator + ->expects(self::exactly(2)) + ->method('trans') + ->withConsecutive( + [$this->equalTo('Message Forwarded')], + [ + $this->equalTo('%subscriber% has forwarded message %campaignId% to %email%'), + $this->callback(function (array $params) use ($subscriber, $friendEmail): bool { + return ($params['%subscriber%'] ?? null) === $subscriber->getEmail() + && ($params['%campaignId%'] ?? null) === 777 + && ($params['%email%'] ?? null) === $friendEmail; + }) + ] + ) + ->willReturnOnConsecutiveCalls( + $expectedSubject, + $expectedMessage + ); + + $this->adminCopyEmailSender + ->expects(self::once()) + ->method('__invoke') + ->with( + $this->equalTo($expectedSubject), + $this->equalTo($expectedMessage), + $this->identicalTo($lists) + ); + + $this->eventLogManager + ->expects(self::never()) + ->method('log'); + + $notifier = new AdminNotifier( + adminCopyEmailSender: $this->adminCopyEmailSender, + translator: $this->translator, + eventLogManager: $this->eventLogManager, + ); + + $notifier->notifyForwardSucceeded( + campaign: $campaign, + forwardingSubscriber: $subscriber, + friendEmail: $friendEmail, + lists: $lists + ); + } +} diff --git a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php index dd562504..0f943cdb 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php @@ -232,7 +232,7 @@ public function testInvokeWithValidSubscriberEmail(): void $campaignBuilderMock = $campaignEmailBuilder->getValue($this->handler); $campaignBuilderMock->expects($this->once()) - ->method('buildPhplistEmail') + ->method('buildCampaignEmail') ->willReturn([ (new Email()) ->from('news@example.com') @@ -299,7 +299,7 @@ public function testInvokeWithMailerException(): void /** @var EmailBuilder|MockObject $campaignBuilderMock */ $campaignBuilderMock = $campaignEmailBuilder->getValue($this->handler); $campaignBuilderMock->expects($this->once()) - ->method('buildPhplistEmail') + ->method('buildCampaignEmail') ->willReturn([ (new Email())->to('test@example.com')->subject('Test Subject')->text('x'), OutputFormat::Text @@ -380,7 +380,7 @@ public function testInvokeWithMultipleSubscribers(): void /** @var EmailBuilder|MockObject $campaignBuilderMock */ $campaignBuilderMock = $campaignEmailBuilder->getValue($this->handler); $campaignBuilderMock->expects($this->exactly(2)) - ->method('buildPhplistEmail') + ->method('buildCampaignEmail') ->willReturnOnConsecutiveCalls( [ (new Email())->to('test1@example.com')->subject('Test Subject')->text('x'), diff --git a/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php index 900ada89..26a6cf67 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php @@ -13,7 +13,7 @@ use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Service\AttachmentAdder; use PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder; -use PhpList\Core\Domain\Messaging\Service\Constructor\MailContentBuilderInterface; +use PhpList\Core\Domain\Messaging\Service\Constructor\CampaignMailContentBuilder; use PhpList\Core\Domain\Messaging\Service\TemplateImageEmbedder; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; @@ -35,7 +35,7 @@ class EmailBuilderTest extends TestCase private SubscriberHistoryManager&MockObject $subscriberHistoryManager; private SubscriberRepository&MockObject $subscriberRepository; private LoggerInterface&MockObject $logger; - private MailContentBuilderInterface&MockObject $mailConstructor; + private CampaignMailContentBuilder&MockObject $mailConstructor; private TemplateImageEmbedder&MockObject $templateImageEmbedder; private LegacyUrlBuilder&MockObject $urlBuilder; private PdfGenerator&MockObject $pdfGenerator; @@ -50,9 +50,7 @@ protected function setUp(): void $this->subscriberHistoryManager = $this->createMock(SubscriberHistoryManager::class); $this->subscriberRepository = $this->createMock(SubscriberRepository::class); $this->logger = $this->createMock(LoggerInterface::class); - $this->mailConstructor = $this->getMockBuilder(MailContentBuilderInterface::class) - ->onlyMethods(['__invoke']) - ->getMock(); + $this->mailConstructor = $this->createMock(CampaignMailContentBuilder::class); $this->templateImageEmbedder = $this->getMockBuilder(TemplateImageEmbedder::class) ->disableOriginalConstructor() ->onlyMethods(['__invoke']) @@ -89,7 +87,7 @@ private function makeBuilder( subscriberHistoryManager: $this->subscriberHistoryManager, subscriberRepository: $this->subscriberRepository, logger: $this->logger, - mailConstructor: $this->mailConstructor, + mailContentBuilder: $this->mailConstructor, templateImageEmbedder: $this->templateImageEmbedder, urlBuilder: $this->urlBuilder, pdfGenerator: $this->pdfGenerator, @@ -113,7 +111,7 @@ public function testReturnsNullWhenMissingRecipient(): void $dto->fromEmail = 'from@example.com'; $builder = $this->makeBuilder(); - $result = $builder->buildPhplistEmail(messageId: 1, data: $dto); + $result = $builder->buildCampaignEmail(messageId: 1, data: $dto); $this->assertNull($result); } @@ -126,7 +124,7 @@ public function testReturnsNullWhenMissingSubject(): void $dto->fromEmail = 'from@example.com'; $builder = $this->makeBuilder(); - $result = $builder->buildPhplistEmail(messageId: 1, data: $dto); + $result = $builder->buildCampaignEmail(messageId: 1, data: $dto); $this->assertNull($result); } @@ -157,7 +155,7 @@ public function testBlacklistReturnsNullAndMarksHistory(): void $dto->fromEmail = 'from@example.com'; $builder = $this->makeBuilder(); - $result = $builder->buildPhplistEmail(messageId: 5, data: $dto); + $result = $builder->buildCampaignEmail(messageId: 5, data: $dto); $this->assertNull($result); } @@ -190,7 +188,7 @@ public function testBuildsHtmlPreferredWithAttachments(): void ->willReturn(true); $builder = $this->makeBuilder(devVersion: true, devEmail: 'dev@example.com'); - [$email, $sentAs] = $builder->buildPhplistEmail( + [$email, $sentAs] = $builder->buildCampaignEmail( messageId: 777, data: $dto, skipBlacklistCheck: false, @@ -239,7 +237,7 @@ public function testPrefersTextWhenNoHtmlContent(): void ->willReturn(true); $builder = $this->makeBuilder(devVersion: false, devEmail: null); - [$email, $sentAs] = $builder->buildPhplistEmail(messageId: 9, data: $dto, htmlPref: true); + [$email, $sentAs] = $builder->buildCampaignEmail(messageId: 9, data: $dto, htmlPref: true); $this->assertSame(OutputFormat::Text, $sentAs); $this->assertSame('TEXT', $email->getTextBody()); @@ -274,7 +272,7 @@ public function testPdfFormatWhenHtmlPreferred(): void ->willReturn(true); $builder = $this->makeBuilder(devVersion: false, devEmail: null); - [$email, $sentAs] = $builder->buildPhplistEmail(messageId: 42, data: $dto, htmlPref: true); + [$email, $sentAs] = $builder->buildCampaignEmail(messageId: 42, data: $dto, htmlPref: true); $this->assertSame(OutputFormat::Pdf, $sentAs); $this->assertCount(1, $email->getAttachments()); @@ -305,7 +303,7 @@ public function testTextAndPdfFormatWhenNotHtmlPreferred(): void ->method('createPdfBytes'); $builder = $this->makeBuilder(devVersion: false, devEmail: null); - [$email, $sentAs] = $builder->buildPhplistEmail(messageId: 43, data: $dto, htmlPref: false); + [$email, $sentAs] = $builder->buildCampaignEmail(messageId: 43, data: $dto, htmlPref: false); $this->assertSame(OutputFormat::Text, $sentAs); $this->assertSame('TEXT', $email->getTextBody()); @@ -334,7 +332,7 @@ public function testReplyToExplicitAndTestMailFallback(): void ->willReturn(true); $builder = $this->makeBuilder(devVersion: false, devEmail: null); - [$email] = $builder->buildPhplistEmail(messageId: 50, data: $dto); + [$email] = $builder->buildCampaignEmail(messageId: 50, data: $dto); $this->assertSame('reply@example.com', $email->getReplyTo()[0]->getAddress()); // no reply-to, but test mail -> uses AdminAddress @@ -352,7 +350,7 @@ public function testReplyToExplicitAndTestMailFallback(): void ->with('(test)') ->willReturn('(test)'); - [$email2] = $builder->buildPhplistEmail(messageId: 51, data: $dto2, isTestMail: true); + [$email2] = $builder->buildCampaignEmail(messageId: 51, data: $dto2, isTestMail: true); $this->assertSame('admin@example.com', $email2->getReplyTo()[0]->getAddress()); $this->assertStringStartsWith('(test) ', $email2->getSubject()); } @@ -415,6 +413,6 @@ public function testAttachmentAdderFailureThrows(): void $builder = $this->makeBuilder(devVersion: false, devEmail: null); $this->expectException(AttachmentException::class); - $builder->buildPhplistEmail(messageId: 60, data: $dto, htmlPref: true); + $builder->buildCampaignEmail(messageId: 60, data: $dto, htmlPref: true); } } diff --git a/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php new file mode 100644 index 00000000..30920240 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php @@ -0,0 +1,223 @@ +configProvider = $this->createMock(ConfigProvider::class); + $this->eventLogManager = $this->createMock(EventLogManager::class); + $this->blacklistRepository = $this->createMock(UserBlacklistRepository::class); + $this->subscriberHistoryManager = $this->createMock(SubscriberHistoryManager::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->mailConstructor = $this->createMock(CampaignMailContentBuilder::class); + $this->templateImageEmbedder = $this->getMockBuilder(TemplateImageEmbedder::class) + ->disableOriginalConstructor() + ->onlyMethods(['__invoke']) + ->getMock(); + $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); + $this->pdfGenerator = $this->createMock(PdfGenerator::class); + $this->attachmentAdder = $this->createMock(AttachmentAdder::class); + $this->translator = $this->createMock(TranslatorInterface::class); + $this->httpReceivedStampBuilder = $this->createMock(HttpReceivedStampBuilder::class); + + // Defaults for config values used in headers + $this->configProvider->method('getValue')->willReturnMap([ + [ConfigOption::PreferencesUrl, 'https://example.com/prefs'], + [ConfigOption::UnsubscribeUrl, 'https://example.com/unsub'], + [ConfigOption::SubscribeUrl, 'https://example.com/subscribe'], + [ConfigOption::AdminAddress, 'admin@example.com'], + [ConfigOption::AlwaysSendTextDomains, ''], + ]); + + $this->urlBuilder->method('withUid')->willReturnCallback( + static fn(string $url, ?string $uid): string => $url . (str_contains($url, '?') ? '&' : '?') . 'uid=' . $uid + ); + } + + private function makeBuilder( + string $googleSenderId = 'g-123', + bool $useAmazonSes = false, + bool $usePrecedenceHeader = true, + bool $devVersion = true, + ?string $devEmail = 'dev@example.com', + ): ForwardEmailBuilder { + return new ForwardEmailBuilder( + configProvider: $this->configProvider, + eventLogManager: $this->eventLogManager, + blacklistRepository: $this->blacklistRepository, + subscriberHistoryManager: $this->subscriberHistoryManager, + subscriberRepository: $this->subscriberRepository, + logger: $this->logger, + mailContentBuilder: $this->mailConstructor, + templateImageEmbedder: $this->templateImageEmbedder, + urlBuilder: $this->urlBuilder, + pdfGenerator: $this->pdfGenerator, + attachmentAdder: $this->attachmentAdder, + translator: $this->translator, + httpReceivedStampBuilder: $this->httpReceivedStampBuilder, + googleSenderId: $googleSenderId, + useAmazonSes: $useAmazonSes, + usePrecedenceHeader: $usePrecedenceHeader, + devVersion: $devVersion, + devEmail: $devEmail, + ); + } + + public function testBuildsForwardEmailWithSubjectPrefixHeadersAndReplyTo(): void + { + $this->blacklistRepository->method('isEmailBlacklisted')->willReturn(false); + + $dto = new MessagePrecacheDto(); + // will be stripped of backslashes by stripslashes + $dto->subject = 'Hello \\"World\\"'; + $dto->content = 'Body text'; + $dto->sendFormat = null; + + $friendEmail = 'friend@example.com'; + $fromEmail = 'from@example.com'; + $fromName = 'From Name'; + + $this->translator->method('trans')->with('Fwd')->willReturn('Fwd'); + + $this->mailConstructor + ->expects(self::once()) + ->method('__invoke') + ->willReturn(['

HTML

', 'TEXT']); + + $this->templateImageEmbedder + ->expects(self::once()) + ->method('__invoke') + ->with(html: '

HTML

', messageId: 99) + ->willReturn('

HTML

'); + + $this->attachmentAdder + ->expects(self::once()) + ->method('add') + ->with($this->isInstanceOf(Email::class), 99, OutputFormat::Html, true) + ->willReturn(true); + + $this->httpReceivedStampBuilder + ->method('buildStamp') + ->willReturn('from host [127.0.0.1] by example.org with HTTP; Wed, 01 Jan 2025 00:00:00 +0000'); + + $builder = $this->makeBuilder(devVersion: true, devEmail: 'dev@example.com'); + [$email, $sentAs] = $builder->buildForwardEmail( + messageId: 99, + friendEmail: $friendEmail, + forwardedBy: new Subscriber(), + data: $dto, + htmlPref: true, + fromName: $fromName, + fromEmail: $fromEmail, + forwardedPersonalNote: 'See this', + ); + + $this->assertSame(OutputFormat::TextAndHtml, $sentAs); + + // Subject prefixed and stripslashes applied + $this->assertSame('Fwd: Hello "World"', $email->getSubject()); + + // Reply-To set + $this->assertSame($fromEmail, $email->getReplyTo()[0]->getAddress()); + $this->assertSame($fromName, $email->getReplyTo()[0]->getName()); + + // Received header present + $this->assertNotNull($email->getHeaders()->get('Received')); + + // Dev mode reroutes recipient + $this->assertSame('dev@example.com', $email->getTo()[0]->getAddress()); + } + + public function testReturnsNullWhenEmptySubjectAndLogs(): void + { + $dto = new MessagePrecacheDto(); + $dto->subject = ''; + $friend = 'friend@example.com'; + + $this->eventLogManager->expects(self::once())->method('log'); + + $builder = $this->makeBuilder(); + $result = $builder->buildForwardEmail( + messageId: 1, + friendEmail: $friend, + forwardedBy: new Subscriber(), + data: $dto, + htmlPref: false, + fromName: 'X', + fromEmail: 'x@example.com', + ); + + self::assertNull($result); + } + + public function testBlacklistReturnsNullAndMarksHistory(): void + { + $this->blacklistRepository->method('isEmailBlacklisted')->willReturn(true); + + $subscriber = $this->getMockBuilder(Subscriber::class) + ->disableOriginalConstructor() + ->onlyMethods(['setBlacklisted']) + ->getMock(); + $subscriber->expects(self::once())->method('setBlacklisted')->with(true); + + $this->subscriberRepository->method('findOneByEmail')->with('friend@example.com')->willReturn($subscriber); + $this->subscriberHistoryManager->expects(self::once())->method('addHistory'); + + $dto = new MessagePrecacheDto(); + $dto->subject = 'S'; + + $builder = $this->makeBuilder(); + $result = $builder->buildForwardEmail( + messageId: 2, + friendEmail: 'friend@example.com', + forwardedBy: new Subscriber(), + data: $dto, + htmlPref: false, + fromName: 'From', + fromEmail: 'from@example.com', + ); + + self::assertNull($result); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Builder/HttpReceivedStampBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/HttpReceivedStampBuilderTest.php new file mode 100644 index 00000000..aeb0d73c --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Builder/HttpReceivedStampBuilderTest.php @@ -0,0 +1,75 @@ +buildStamp()); + } + + public function testReturnsNullWhenNoClientIp(): void + { + $stack = new RequestStack(); + $request = new Request(); + // Do not set REMOTE_ADDR to simulate missing client IP + $stack->push($request); + + $builder = new HttpReceivedStampBuilder($stack, 'api.example.test'); + + self::assertNull($builder->buildStamp()); + } + + public function testBuildsStampWithRemoteHostAndFixedTime(): void + { + $stack = new RequestStack(); + $request = new Request(); + // Set client IP and remote host explicitly + $request->server->set('REMOTE_ADDR', '203.0.113.5'); + $request->server->set('REMOTE_HOST', 'client.example.org'); + // Fix the request time for deterministic output (Unix epoch start) + $request->server->set('REQUEST_TIME', 0); + $stack->push($request); + + $builder = new HttpReceivedStampBuilder($stack, 'api.example.test'); + + $stamp = $builder->buildStamp(); + + self::assertSame( + 'from client.example.org [203.0.113.5] by api.example.test with HTTP; Thu, 01 Jan 1970 00:00:00 +0000', + $stamp + ); + } + + public function testBuildsStampWithIpOnlyNoReverseDns(): void + { + $stack = new RequestStack(); + $request = new Request(); + // Use a TEST-NET IP which should not resolve via gethostbyaddr + $request->server->set('REMOTE_ADDR', '203.0.113.55'); + // Ensure no REMOTE_HOST so builder attempts reverse DNS, which should fail and fallback to IP only + $request->server->remove('REMOTE_HOST'); + $request->server->set('REQUEST_TIME', 0); + $stack->push($request); + + $builder = new HttpReceivedStampBuilder($stack, 'api.example.test'); + + $stamp = $builder->buildStamp(); + + self::assertSame( + 'from [203.0.113.55] by api.example.test with HTTP; Thu, 01 Jan 1970 00:00:00 +0000', + $stamp + ); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php index d1b891c2..849449cb 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php @@ -9,7 +9,7 @@ use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder; -use PhpList\Core\Domain\Messaging\Service\Constructor\MailContentBuilderInterface; +use PhpList\Core\Domain\Messaging\Service\Constructor\SystemMailContentBuilder; use PhpList\Core\Domain\Messaging\Service\TemplateImageEmbedder; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; @@ -27,7 +27,7 @@ class SystemEmailBuilderTest extends TestCase private UserBlacklistRepository&MockObject $blacklistRepository; private SubscriberHistoryManager&MockObject $subscriberHistoryManager; private SubscriberRepository&MockObject $subscriberRepository; - private MailContentBuilderInterface&MockObject $mailConstructor; + private SystemMailContentBuilder&MockObject $mailConstructor; private TemplateImageEmbedder&MockObject $templateImageEmbedder; private LoggerInterface&MockObject $logger; @@ -38,9 +38,7 @@ protected function setUp(): void $this->blacklistRepository = $this->createMock(UserBlacklistRepository::class); $this->subscriberHistoryManager = $this->createMock(SubscriberHistoryManager::class); $this->subscriberRepository = $this->createMock(SubscriberRepository::class); - $this->mailConstructor = $this->getMockBuilder(MailContentBuilderInterface::class) - ->onlyMethods(['__invoke']) - ->getMock(); + $this->mailConstructor = $this->createMock(SystemMailContentBuilder::class); $this->templateImageEmbedder = $this->getMockBuilder(TemplateImageEmbedder::class) ->disableOriginalConstructor() ->onlyMethods(['__invoke']) @@ -89,7 +87,7 @@ public function testReturnsNullWhenMissingRecipient(): void $dto->content = 'Body'; $builder = $this->makeBuilder(); - $result = $builder->buildPhplistEmail(messageId: 1, data: $dto); + $result = $builder->buildCampaignEmail(messageId: 1, data: $dto); $this->assertNull($result); } @@ -101,7 +99,7 @@ public function testReturnsNullWhenMissingSubject(): void $dto->content = 'Body'; $builder = $this->makeBuilder(); - $result = $builder->buildPhplistEmail(messageId: 1, data: $dto); + $result = $builder->buildCampaignEmail(messageId: 1, data: $dto); $this->assertNull($result); } @@ -130,7 +128,7 @@ public function testReturnsNullWhenBlacklistedAndHistoryUpdated(): void $dto->content = 'B'; $builder = $this->makeBuilder(); - $result = $builder->buildPhplistEmail(messageId: 5, data: $dto); + $result = $builder->buildCampaignEmail(messageId: 5, data: $dto); $this->assertNull($result); } @@ -162,11 +160,10 @@ public function testBuildsEmailWithExpectedHeadersAndBodiesInDevMode(): void devEmail: 'dev@example.com' ); - $email = $builder->buildPhplistEmail( + $email = $builder->buildCampaignEmail( messageId: 777, data: $dto, skipBlacklistCheck: false, - inBlast: true ); $this->assertNotNull($email); @@ -182,7 +179,6 @@ public function testBuildsEmailWithExpectedHeadersAndBodiesInDevMode(): void $this->assertSame('dev@example.com', $headers->get('X-ListMember')->getBodyAsString()); $this->assertSame('777:g-123', $headers->get('Feedback-ID')->getBodyAsString()); $this->assertSame('bulk', $headers->get('Precedence')->getBodyAsString()); - $this->assertSame('1', $headers->get('X-Blast')->getBodyAsString()); $this->assertTrue($headers->has('X-Originally-To')); $this->assertSame('real@example.com', $headers->get('X-Originally-To')->getBodyAsString()); diff --git a/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php b/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php new file mode 100644 index 00000000..f5ff4a9c --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php @@ -0,0 +1,136 @@ +cache = $this->createMock(CacheInterface::class); + $this->preparator = $this->createMock(MessageProcessingPreparator::class); + $this->builder = $this->createMock(ForwardEmailBuilder::class); + } + + public function testThrowsWhenCacheMissing(): void + { + $service = new ForwardContentService( + cache: $this->cache, + messagePreparator: $this->preparator, + forwardEmailBuilder: $this->builder, + ); + + $campaign = $this->createMock(Message::class); + $campaign->method('getId')->willReturn(10); + $subscriber = new Subscriber(); + + $this->cache + ->expects(self::once()) + ->method('get') + ->with('messaging.message.base.10.1') + ->willReturn(null); + + $this->expectException(MessageCacheMissingException::class); + + $service->getContents( + campaign: $campaign, + forwardingSubscriber: $subscriber, + friendEmail: 'friend@example.com', + forwardDto: new MessageForwardDto( + [], + 'uuid', + new DateTime(), + 'from@example.com', + 'From', + null + ) + ); + } + + public function testProcessesLinksAndDelegatesToBuilder(): void + { + $service = new ForwardContentService( + cache: $this->cache, + messagePreparator: $this->preparator, + forwardEmailBuilder: $this->builder, + ); + + $campaign = $this->createMock(Message::class); + $campaign->method('getId')->willReturn(42); + $subscriber = new Subscriber(); + $subscriber->setHtmlEmail(true); + + $cached = new MessagePrecacheDto(); + $processed = new MessagePrecacheDto(); + + $this->cache + ->expects(self::once()) + ->method('get') + ->with('messaging.message.base.42.1') + ->willReturn($cached); + + $this->preparator + ->expects(self::once()) + ->method('processMessageLinks') + ->with( + campaignId: 42, + cachedMessageDto: $cached, + subscriber: $subscriber + ) + ->willReturn($processed); + + $expectedEmail = new Email(); + $this->builder + ->expects(self::once()) + ->method('buildForwardEmail') + ->with( + messageId: 42, + friendEmail: 'f@example.com', + forwardedBy: $subscriber, + data: $processed, + htmlPref: true, + fromName: 'From Name', + fromEmail: 'from@example.com', + forwardedPersonalNote: 'note' + ) + ->willReturn([$expectedEmail, OutputFormat::Text]); + + $result = $service->getContents( + campaign: $campaign, + forwardingSubscriber: $subscriber, + friendEmail: 'f@example.com', + forwardDto: new MessageForwardDto( + ['f@example.com'], + 'uuid', + new DateTime(), + 'From Name', + 'from@example.com', + 'note' + ) + ); + + self::assertIsArray($result); + self::assertSame($expectedEmail, $result[0]); + self::assertSame(OutputFormat::Text, $result[1]); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php b/tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php new file mode 100644 index 00000000..5bbdfc0b --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php @@ -0,0 +1,114 @@ +mailer = $this->createMock(MailerInterface::class); + $this->forwardManager = $this->createMock(UserMessageForwardManager::class); + } + + public function testSendUsesBounceEnvelopeAndRecipient(): void + { + $service = new ForwardDeliveryService( + mailer: $this->mailer, + messageForwardManager: $this->forwardManager, + bounceEmail: 'bounce@example.test', + ); + + $email = (new Email())->to('friend@example.test'); + + $this->mailer->expects(self::once()) + ->method('send') + ->with( + self::identicalTo($email), + self::callback(function (Envelope $envelope): bool { + // Check that sender is the bounce address and recipient matches TO + return $envelope->getSender()->getAddress() === 'bounce@example.test' + && $envelope->getRecipients()[0]->getAddress() === 'friend@example.test'; + }) + ); + + $service->send($email); + } + + public function testSendThrowsWhenNoRecipient(): void + { + $service = new ForwardDeliveryService( + mailer: $this->mailer, + messageForwardManager: $this->forwardManager, + bounceEmail: 'bounce@example.test', + ); + // no recipients + $email = new Email(); + + $this->expectException(LogicException::class); + $service->send($email); + } + + public function testMarkSentDelegatesToManager(): void + { + $service = new ForwardDeliveryService( + mailer: $this->mailer, + messageForwardManager: $this->forwardManager, + bounceEmail: 'bounce@example.test', + ); + + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + $friendEmail = 'friend@example.test'; + + $this->forwardManager->expects(self::once()) + ->method('create') + ->with( + subscriber: self::identicalTo($subscriber), + campaign: self::identicalTo($campaign), + friendEmail: $friendEmail, + status: 'sent' + ); + + $service->markSent($campaign, $subscriber, $friendEmail); + } + + public function testMarkFailedDelegatesToManager(): void + { + $service = new ForwardDeliveryService( + mailer: $this->mailer, + messageForwardManager: $this->forwardManager, + bounceEmail: 'bounce@example.test', + ); + + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + $friendEmail = 'friend@example.test'; + + $this->forwardManager->expects(self::once()) + ->method('create') + ->with( + subscriber: self::identicalTo($subscriber), + campaign: self::identicalTo($campaign), + friendEmail: $friendEmail, + status: 'failed' + ); + + $service->markFailed($campaign, $subscriber, $friendEmail); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php b/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php new file mode 100644 index 00000000..279c0286 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php @@ -0,0 +1,140 @@ +subscriberRepo = $this->createMock(SubscriberRepository::class); + $this->userMessageRepo = $this->createMock(UserMessageRepository::class); + $this->forwardRepo = $this->createMock(UserMessageForwardRepository::class); + } + + public function testAssertCanForwardReturnsSubscriber(): void + { + $guard = new ForwardingGuard( + subscriberRepository: $this->subscriberRepo, + userMessageRepository: $this->userMessageRepo, + forwardRepository: $this->forwardRepo, + forwardMessageCount: 2, + ); + + $uid = 'abc'; + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + + $this->subscriberRepo->method('findOneByUniqueId')->with($uid)->willReturn($subscriber); + $this->userMessageRepo->method('findOneByUserAndMessage')->willReturn( + $this->createMock(UserMessage::class) + ); + $this->forwardRepo->method('getCountByUserSince')->willReturn(1); + + $result = $guard->assertCanForward($uid, $campaign, new DateTimeImmutable()); + self::assertSame($subscriber, $result); + } + + public function testAssertCanForwardThrowsWhenSubscriberMissing(): void + { + $guard = new ForwardingGuard( + subscriberRepository: $this->subscriberRepo, + userMessageRepository: $this->userMessageRepo, + forwardRepository: $this->forwardRepo, + forwardMessageCount: 2, + ); + + $this->subscriberRepo->method('findOneByUniqueId')->willReturn(null); + + $this->expectException(MessageNotReceivedException::class); + $guard->assertCanForward('uid', $this->createMock(Message::class), new DateTimeImmutable()); + } + + public function testAssertCanForwardThrowsWhenMessageNotReceived(): void + { + $guard = new ForwardingGuard( + subscriberRepository: $this->subscriberRepo, + userMessageRepository: $this->userMessageRepo, + forwardRepository: $this->forwardRepo, + forwardMessageCount: 2, + ); + + $this->subscriberRepo->method('findOneByUniqueId')->willReturn(new Subscriber()); + $this->userMessageRepo->method('findOneByUserAndMessage')->willReturn(null); + + $this->expectException(MessageNotReceivedException::class); + $guard->assertCanForward('uid', $this->createMock(Message::class), new DateTimeImmutable()); + } + + public function testAssertCanForwardThrowsWhenLimitExceeded(): void + { + $guard = new ForwardingGuard( + subscriberRepository: $this->subscriberRepo, + userMessageRepository: $this->userMessageRepo, + forwardRepository: $this->forwardRepo, + forwardMessageCount: 2, + ); + + $this->subscriberRepo->method('findOneByUniqueId')->willReturn(new Subscriber()); + $this->userMessageRepo->method('findOneByUserAndMessage')->willReturn($this->createMock(UserMessage::class)); + $this->forwardRepo->method('getCountByUserSince')->willReturn(2); + + $this->expectException(ForwardLimitExceededException::class); + $guard->assertCanForward('uid', $this->createMock(Message::class), new DateTimeImmutable()); + } + + public function testHasAlreadyBeenSentTrue(): void + { + $guard = new ForwardingGuard( + subscriberRepository: $this->subscriberRepo, + userMessageRepository: $this->userMessageRepo, + forwardRepository: $this->forwardRepo, + forwardMessageCount: 10, + ); + + $campaign = $this->createMock(Message::class); + $campaign->method('getId')->willReturn(7); + + $forward = (new UserMessageForward())->setStatus('sent'); + + $this->forwardRepo->method('findByEmailAndMessage')->with('friend@x.tld', 7)->willReturn($forward); + + self::assertTrue($guard->hasAlreadyBeenSent('friend@x.tld', $campaign)); + } + + public function testHasAlreadyBeenSentFalseWhenNone(): void + { + $guard = new ForwardingGuard( + subscriberRepository: $this->subscriberRepo, + userMessageRepository: $this->userMessageRepo, + forwardRepository: $this->forwardRepo, + forwardMessageCount: 10, + ); + + $campaign = $this->createMock(Message::class); + $campaign->method('getId')->willReturn(8); + + $this->forwardRepo->method('findByEmailAndMessage')->willReturn(null); + + self::assertFalse($guard->hasAlreadyBeenSent('f@x.tld', $campaign)); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php b/tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php new file mode 100644 index 00000000..cc8f34bc --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php @@ -0,0 +1,119 @@ +valueRepo = $this->createMock(SubscriberAttributeValueRepository::class); + $this->attrManager = $this->createMock(SubscriberAttributeManager::class); + } + + public function testNoAttributeConfiguredDoesNothing(): void + { + $service = new ForwardingStatsService( + subscriberAttributeValueRepo: $this->valueRepo, + subscriberAttributeManager: $this->attrManager, + // becomes null internally + forwardFriendCountAttr: '' + ); + + $subscriber = $this->createMock(Subscriber::class); + + // No repository or manager calls expected + $this->valueRepo->expects(self::never())->method(self::anything()); + $this->attrManager->expects(self::never())->method(self::anything()); + + $service->incrementFriendsCount($subscriber); + $service->updateFriendsCount($subscriber); + // reached without interactions + self::assertTrue(true); + } + + public function testIncrementThenUpdatePersistsAndResets(): void + { + $service = new ForwardingStatsService( + subscriberAttributeValueRepo: $this->valueRepo, + subscriberAttributeManager: $this->attrManager, + forwardFriendCountAttr: 'FriendsForwarded' + ); + + $subscriber = $this->createConfiguredMock(Subscriber::class, ['getId' => 123]); + + // Simulate existing attribute value of 3 + $existing = $this->getMockBuilder(SubscriberAttributeValue::class) + ->disableOriginalConstructor() + ->onlyMethods(['getValue']) + ->getMock(); + $existing->method('getValue')->willReturn('3'); + + $this->valueRepo->expects(self::once()) + ->method('findOneBySubscriberAndAttributeName') + ->with(subscriber: self::identicalTo($subscriber), attributeName: 'FriendsForwarded') + ->willReturn($existing); + + // After two increments (3 -> 4 -> 5), update should persist '5' + $this->attrManager->expects(self::once()) + ->method('createOrUpdateByName') + ->with( + subscriber: self::identicalTo($subscriber), + attributeName: 'FriendsForwarded', + value: '5' + ); + + $service->incrementFriendsCount($subscriber); + $service->incrementFriendsCount($subscriber); + $service->updateFriendsCount($subscriber); + + // Second update attempt should be a no-op due to cache reset + $this->attrManager->expects(self::never())->method('createOrUpdateByName'); + $service->updateFriendsCount($subscriber); + self::assertTrue(true); + } + + public function testCacheIsolationBySubscriber(): void + { + $service = new ForwardingStatsService( + subscriberAttributeValueRepo: $this->valueRepo, + subscriberAttributeManager: $this->attrManager, + forwardFriendCountAttr: 'FriendsForwarded' + ); + + $subscriberA = $this->createConfiguredMock(Subscriber::class, ['getId' => 1]); + $subscriberB = $this->createConfiguredMock(Subscriber::class, ['getId' => 2]); + + // Initial load for A returns 0 + $this->valueRepo->expects(self::once()) + ->method('findOneBySubscriberAndAttributeName') + ->with(subscriber: self::identicalTo($subscriberA), attributeName: 'FriendsForwarded') + ->willReturn(null); + // cache for A becomes 1 + $service->incrementFriendsCount($subscriberA); + + // Expect exactly one persistence call overall (for A only) + $this->attrManager->expects(self::once()) + ->method('createOrUpdateByName') + ->with( + subscriber: self::identicalTo($subscriberA), + attributeName: 'FriendsForwarded', + value: '1' + ); + // Calling update for B must be a no-op (cache belongs to A) + $service->updateFriendsCount($subscriberB); + $service->updateFriendsCount($subscriberA); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Manager/UserMessageForwardManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/UserMessageForwardManagerTest.php new file mode 100644 index 00000000..edf754c6 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Manager/UserMessageForwardManagerTest.php @@ -0,0 +1,69 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->manager = new UserMessageForwardManager($this->entityManager); + } + + public function testCreatePersistsAndReturnsForwardWithExpectedFields(): void + { + $subscriber = $this->createMock(Subscriber::class); + $message = $this->createMock(Message::class); + + $subscriber->method('getId')->willReturn(42); + $message->method('getId')->willReturn(7); + + $expectedFriendEmail = 'friend@example.test'; + + $persisted = null; + + $this->entityManager->expects($this->once()) + ->method('persist') + ->with( + $this->callback(function (UserMessageForward $fwd) use (&$persisted, $expectedFriendEmail) { + $persisted = $fwd; + return $fwd->getUserId() === 42 + && $fwd->getMessageId() === 7 + && $fwd->getForward() === $expectedFriendEmail + && $fwd->getStatus() === $this->expectedStatus + && $fwd->getCreatedAt() !== null; + }) + ); + + $this->entityManager->expects($this->never()) + ->method('flush'); + + $result = $this->manager->create( + subscriber: $subscriber, + campaign: $message, + friendEmail: $expectedFriendEmail, + status: $this->expectedStatus + ); + + $this->assertInstanceOf(UserMessageForward::class, $result); + $this->assertSame($persisted, $result, 'Returned entity should be the same instance that was persisted'); + $this->assertSame(42, $result->getUserId()); + $this->assertSame(7, $result->getMessageId()); + $this->assertSame($expectedFriendEmail, $result->getForward()); + $this->assertSame($this->expectedStatus, $result->getStatus()); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php b/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php new file mode 100644 index 00000000..fe795ae6 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php @@ -0,0 +1,205 @@ +guard = $this->createMock(ForwardingGuard::class); + $this->delivery = $this->createMock(ForwardDeliveryService::class); + $this->loader = $this->createMock(MessageDataLoader::class); + $this->listRepo = $this->createMock(SubscriberListRepository::class); + $this->contentService = $this->createMock(ForwardContentService::class); + $this->precache = $this->createMock(MessagePrecacheService::class); + $this->notifier = $this->createMock(AdminNotifier::class); + $this->stats = $this->createMock(ForwardingStatsService::class); + } + + private function createService(): MessageForwardService + { + return new MessageForwardService( + guard: $this->guard, + forwardDeliveryService: $this->delivery, + messageDataLoader: $this->loader, + subscriberListRepository: $this->listRepo, + forwardContentService: $this->contentService, + precacheService: $this->precache, + adminNotifier: $this->notifier, + forwardingStatsService: $this->stats, + ); + } + + private function createDto(array $emails): MessageForwardDto + { + return new MessageForwardDto( + emails: $emails, + uid: 'uid-123', + cutoff: new DateTimeImmutable('-1 day'), + fromName: 'Alice', + fromEmail: 'alice@example.test' + ); + } + + public function testSkipsAlreadySentAndStillUpdatesStats(): void + { + $service = $this->createService(); + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + + $this->loader->expects(self::once()) + ->method('__invoke') + ->with(self::identicalTo($campaign)) + ->willReturn(['loaded' => true]); + + $this->guard->expects(self::once()) + ->method('assertCanForward') + ->willReturn($subscriber); + + $this->listRepo->expects(self::once()) + ->method('getListsByMessage') + ->with(self::identicalTo($campaign)) + ->willReturn([]); + + $this->guard->expects(self::exactly(2)) + ->method('hasAlreadyBeenSent') + ->willReturn(true); + + $this->precache->expects(self::never())->method('precacheMessage'); + $this->contentService->expects(self::never())->method('getContents'); + $this->delivery->expects(self::never())->method('send'); + $this->delivery->expects(self::never())->method('markSent'); + $this->delivery->expects(self::never())->method('markFailed'); + $this->notifier->expects(self::never())->method('notifyForwardSucceeded'); + $this->notifier->expects(self::never())->method('notifyForwardFailed'); + $this->stats->expects(self::never())->method('incrementFriendsCount'); + $this->stats->expects(self::once()) + ->method('updateFriendsCount') + ->with(self::identicalTo($subscriber)); + + $service->forward($this->createDto(['a@x.tld', 'b@x.tld']), $campaign); + } + + public function testPrecacheFailureNotifiesAndMarksFailed(): void + { + $service = $this->createService(); + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + + $this->loader->method('__invoke')->willReturn(['ok' => true]); + $this->guard->method('assertCanForward')->willReturn($subscriber); + $this->listRepo->method('getListsByMessage')->willReturn(['L1']); + $this->guard->method('hasAlreadyBeenSent')->willReturn(false); + + $this->precache->expects(self::once()) + ->method('precacheMessage') + ->with(self::identicalTo($campaign), ['ok' => true], true) + ->willReturn(false); + + $this->notifier->expects(self::once())->method('notifyForwardFailed'); + $this->delivery->expects(self::once())->method('markFailed'); + $this->contentService->expects(self::never())->method('getContents'); + $this->delivery->expects(self::never())->method('send'); + $this->stats->expects(self::never())->method('incrementFriendsCount'); + $this->stats->expects(self::once()) + ->method('updateFriendsCount') + ->with(self::identicalTo($subscriber)); + + $service->forward($this->createDto(['friend@example.test']), $campaign); + } + + public function testContentNullTriggersFailureFlow(): void + { + $service = $this->createService(); + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + + $this->loader->method('__invoke')->willReturn(['ok' => true]); + $this->guard->method('assertCanForward')->willReturn($subscriber); + $this->listRepo->method('getListsByMessage')->willReturn([]); + $this->guard->method('hasAlreadyBeenSent')->willReturn(false); + $this->precache->method('precacheMessage')->willReturn(true); + + $this->contentService->expects(self::once()) + ->method('getContents') + ->willReturn(null); + + $this->notifier->expects(self::once())->method('notifyForwardFailed'); + $this->delivery->expects(self::once())->method('markFailed'); + $this->delivery->expects(self::never())->method('send'); + $this->stats->expects(self::never())->method('incrementFriendsCount'); + $this->stats->expects(self::once()) + ->method('updateFriendsCount') + ->with(self::identicalTo($subscriber)); + + $service->forward($this->createDto(['f@x.tld']), $campaign); + } + + public function testSuccessfulFlowSendsAndUpdatesEverything(): void + { + $service = $this->createService(); + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + + $this->loader->method('__invoke')->willReturn(['ok' => true]); + $this->guard->method('assertCanForward')->willReturn($subscriber); + $this->listRepo->method('getListsByMessage')->willReturn([]); + $this->guard->method('hasAlreadyBeenSent')->willReturn(false); + $this->precache->method('precacheMessage')->willReturn(true); + + $email1 = (new Email())->to('x1@example.test'); + $email2 = (new Email())->to('x2@example.test'); + + $this->contentService->expects(self::exactly(2)) + ->method('getContents') + ->willReturnOnConsecutiveCalls([$email1, OutputFormat::Html], [$email2, OutputFormat::Text]); + + $this->delivery->expects(self::exactly(2))->method('send'); + $this->notifier->expects(self::exactly(2))->method('notifyForwardSucceeded'); + $this->delivery->expects(self::exactly(2))->method('markSent'); + + // Campaign should increment sent count for both sentAs values + $campaign->expects(self::exactly(2)) + ->method('incrementSentCount') + ->with(self::logicalOr(OutputFormat::Html, OutputFormat::Text)); + + // Stats increment per friend, then update once at the end + $this->stats->expects(self::exactly(2)) + ->method('incrementFriendsCount') + ->with(self::identicalTo($subscriber)); + $this->stats->expects(self::once()) + ->method('updateFriendsCount') + ->with(self::identicalTo($subscriber)); + + $service->forward($this->createDto(['x1@example.test', 'x2@example.test']), $campaign); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php index 332b3a7c..4d3e7e3a 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php @@ -5,12 +5,15 @@ namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service\Manager; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Subscription\Exception\SubscriberAttributeCreationException; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository; +use PhpList\Core\Domain\Subscription\Service\Manager\DynamicListAttrManager; +use PhpList\Core\Domain\Subscription\Service\Manager\DynamicListAttrTablesManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; use PHPUnit\Framework\TestCase; use Symfony\Component\Translation\Translator; @@ -40,7 +43,7 @@ public function testCreateNewSubscriberAttribute(): void attributeRepository: $subscriberAttrRepo, attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class), entityManager: $entityManager, - translator: new Translator('en') + translator: new Translator('en'), ); $attribute = $manager->createOrUpdate($subscriber, $definition, 'US'); @@ -71,7 +74,7 @@ public function testUpdateExistingSubscriberAttribute(): void attributeRepository: $subscriberAttrRepo, attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class), entityManager: $entityManager, - translator: new Translator('en') + translator: new Translator('en'), ); $result = $manager->createOrUpdate($subscriber, $definition, 'Updated'); @@ -92,7 +95,7 @@ public function testCreateFailsWhenValueAndDefaultAreNull(): void attributeRepository: $subscriberAttrRepo, attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class), entityManager: $entityManager, - translator: new Translator('en') + translator: new Translator('en'), ); $this->expectException(SubscriberAttributeCreationException::class); @@ -116,7 +119,7 @@ public function testGetSubscriberAttribute(): void attributeRepository: $subscriberAttrRepo, attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class), entityManager: $entityManager, - translator: new Translator('en') + translator: new Translator('en'), ); $result = $manager->getSubscriberAttribute(5, 10); @@ -137,7 +140,7 @@ public function testDeleteSubscriberAttribute(): void attributeRepository: $subscriberAttrRepo, attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class), entityManager: $entityManager, - translator: new Translator('en') + translator: new Translator('en'), ); $manager->delete($attribute); From 88e25f5512e45fd12fefd6fbc5dcfc964fbf68fa Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 3 Feb 2026 17:00:17 +0400 Subject: [PATCH 33/37] Cutoff from forward_email_period config --- .../Messaging/Model/Dto/MessageForwardDto.php | 8 -------- src/Domain/Messaging/Service/ForwardingGuard.php | 15 ++++++++++++--- .../Messaging/Service/MessageForwardService.php | 1 - .../Service/ForwardContentServiceTest.php | 2 -- .../Messaging/Service/ForwardingGuardTest.php | 14 ++++++++++---- .../Service/MessageForwardServiceTest.php | 1 - 6 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/Domain/Messaging/Model/Dto/MessageForwardDto.php b/src/Domain/Messaging/Model/Dto/MessageForwardDto.php index e41bb167..b39f06a5 100644 --- a/src/Domain/Messaging/Model/Dto/MessageForwardDto.php +++ b/src/Domain/Messaging/Model/Dto/MessageForwardDto.php @@ -4,8 +4,6 @@ namespace PhpList\Core\Domain\Messaging\Model\Dto; -use DateTimeInterface; - class MessageForwardDto { /** @@ -14,7 +12,6 @@ class MessageForwardDto public function __construct( private readonly array $emails, private readonly string $uid, - private readonly DateTimeInterface $cutoff, private readonly string $fromName, private readonly string $fromEmail, private readonly ?string $note = null @@ -34,11 +31,6 @@ public function getUid(): string return $this->uid; } - public function getCutoff(): DateTimeInterface - { - return $this->cutoff; - } - public function getFromName(): string { return $this->fromName; diff --git a/src/Domain/Messaging/Service/ForwardingGuard.php b/src/Domain/Messaging/Service/ForwardingGuard.php index 7c2a54cd..565f3cce 100644 --- a/src/Domain/Messaging/Service/ForwardingGuard.php +++ b/src/Domain/Messaging/Service/ForwardingGuard.php @@ -4,7 +4,7 @@ namespace PhpList\Core\Domain\Messaging\Service; -use DateTimeInterface; +use DateTimeImmutable; use PhpList\Core\Domain\Messaging\Exception\ForwardLimitExceededException; use PhpList\Core\Domain\Messaging\Exception\MessageNotReceivedException; use PhpList\Core\Domain\Messaging\Model\Message; @@ -21,10 +21,11 @@ public function __construct( private readonly UserMessageRepository $userMessageRepository, private readonly UserMessageForwardRepository $forwardRepository, #[Autowire('%phplist.forward_email_count%')] private readonly int $forwardMessageCount, + #[Autowire('%phplist.forward_email_period%')] private readonly string $forwardEmailPeriod, ) { } - public function assertCanForward(string $uid, Message $campaign, DateTimeInterface $cutoff): Subscriber + public function assertCanForward(string $uid, Message $campaign): Subscriber { $subscriber = $this->subscriberRepository->findOneByUniqueId($uid); if ($subscriber === null) { @@ -36,7 +37,10 @@ public function assertCanForward(string $uid, Message $campaign, DateTimeInterfa throw new MessageNotReceivedException(); } - $forwardPeriodCount = $this->forwardRepository->getCountByUserSince($subscriber, $cutoff); + $forwardPeriodCount = $this->forwardRepository->getCountByUserSince( + user: $subscriber, + cutoff: $this->getForwardCutoffDate(), + ); if ($forwardPeriodCount >= $this->forwardMessageCount) { throw new ForwardLimitExceededException(); } @@ -50,4 +54,9 @@ public function hasAlreadyBeenSent(string $friendEmail, Message $campaign): bool return $existing !== null && $existing->getStatus() === 'sent'; } + + private function getForwardCutoffDate(): DateTimeImmutable + { + return new DateTimeImmutable('-' . $this->forwardEmailPeriod); + } } diff --git a/src/Domain/Messaging/Service/MessageForwardService.php b/src/Domain/Messaging/Service/MessageForwardService.php index 5698147b..bc07d8db 100644 --- a/src/Domain/Messaging/Service/MessageForwardService.php +++ b/src/Domain/Messaging/Service/MessageForwardService.php @@ -29,7 +29,6 @@ public function forward(MessageForwardDto $messageForwardDto, Message $campaign) $forwardingSubscriber = $this->guard->assertCanForward( uid: $messageForwardDto->getUid(), campaign: $campaign, - cutoff: $messageForwardDto->getCutoff(), ); $messageLists = $this->subscriberListRepository->getListsByMessage($campaign); diff --git a/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php b/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php index f5ff4a9c..1fa1b037 100644 --- a/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php @@ -59,7 +59,6 @@ public function testThrowsWhenCacheMissing(): void forwardDto: new MessageForwardDto( [], 'uuid', - new DateTime(), 'from@example.com', 'From', null @@ -122,7 +121,6 @@ public function testProcessesLinksAndDelegatesToBuilder(): void forwardDto: new MessageForwardDto( ['f@example.com'], 'uuid', - new DateTime(), 'From Name', 'from@example.com', 'note' diff --git a/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php b/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php index 279c0286..8ac266f8 100644 --- a/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php +++ b/tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php @@ -38,6 +38,7 @@ public function testAssertCanForwardReturnsSubscriber(): void userMessageRepository: $this->userMessageRepo, forwardRepository: $this->forwardRepo, forwardMessageCount: 2, + forwardEmailPeriod: '1 day', ); $uid = 'abc'; @@ -50,7 +51,7 @@ public function testAssertCanForwardReturnsSubscriber(): void ); $this->forwardRepo->method('getCountByUserSince')->willReturn(1); - $result = $guard->assertCanForward($uid, $campaign, new DateTimeImmutable()); + $result = $guard->assertCanForward($uid, $campaign); self::assertSame($subscriber, $result); } @@ -61,12 +62,13 @@ public function testAssertCanForwardThrowsWhenSubscriberMissing(): void userMessageRepository: $this->userMessageRepo, forwardRepository: $this->forwardRepo, forwardMessageCount: 2, + forwardEmailPeriod: '1 day', ); $this->subscriberRepo->method('findOneByUniqueId')->willReturn(null); $this->expectException(MessageNotReceivedException::class); - $guard->assertCanForward('uid', $this->createMock(Message::class), new DateTimeImmutable()); + $guard->assertCanForward('uid', $this->createMock(Message::class)); } public function testAssertCanForwardThrowsWhenMessageNotReceived(): void @@ -76,13 +78,14 @@ public function testAssertCanForwardThrowsWhenMessageNotReceived(): void userMessageRepository: $this->userMessageRepo, forwardRepository: $this->forwardRepo, forwardMessageCount: 2, + forwardEmailPeriod: '1 day', ); $this->subscriberRepo->method('findOneByUniqueId')->willReturn(new Subscriber()); $this->userMessageRepo->method('findOneByUserAndMessage')->willReturn(null); $this->expectException(MessageNotReceivedException::class); - $guard->assertCanForward('uid', $this->createMock(Message::class), new DateTimeImmutable()); + $guard->assertCanForward('uid', $this->createMock(Message::class)); } public function testAssertCanForwardThrowsWhenLimitExceeded(): void @@ -92,6 +95,7 @@ public function testAssertCanForwardThrowsWhenLimitExceeded(): void userMessageRepository: $this->userMessageRepo, forwardRepository: $this->forwardRepo, forwardMessageCount: 2, + forwardEmailPeriod: '1 day', ); $this->subscriberRepo->method('findOneByUniqueId')->willReturn(new Subscriber()); @@ -99,7 +103,7 @@ public function testAssertCanForwardThrowsWhenLimitExceeded(): void $this->forwardRepo->method('getCountByUserSince')->willReturn(2); $this->expectException(ForwardLimitExceededException::class); - $guard->assertCanForward('uid', $this->createMock(Message::class), new DateTimeImmutable()); + $guard->assertCanForward('uid', $this->createMock(Message::class)); } public function testHasAlreadyBeenSentTrue(): void @@ -109,6 +113,7 @@ public function testHasAlreadyBeenSentTrue(): void userMessageRepository: $this->userMessageRepo, forwardRepository: $this->forwardRepo, forwardMessageCount: 10, + forwardEmailPeriod: '1 day', ); $campaign = $this->createMock(Message::class); @@ -128,6 +133,7 @@ public function testHasAlreadyBeenSentFalseWhenNone(): void userMessageRepository: $this->userMessageRepo, forwardRepository: $this->forwardRepo, forwardMessageCount: 10, + forwardEmailPeriod: '1 day', ); $campaign = $this->createMock(Message::class); diff --git a/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php b/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php index fe795ae6..12827f45 100644 --- a/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php @@ -64,7 +64,6 @@ private function createDto(array $emails): MessageForwardDto return new MessageForwardDto( emails: $emails, uid: 'uid-123', - cutoff: new DateTimeImmutable('-1 day'), fromName: 'Alice', fromEmail: 'alice@example.test' ); From 6f81e7b2b78441005f88de05037c2ba6c32d0659 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 3 Feb 2026 17:12:41 +0400 Subject: [PATCH 34/37] ForwardingResult --- config/PHPMD/rules.xml | 2 +- .../Messaging/Command/ProcessQueueCommand.php | 3 - .../Exception/EmailBlacklistedException.php | 15 ++ .../InvalidRecipientOrSubjectException.php | 15 ++ .../MessageCacheMissingException.php | 2 +- .../Model/Dto/ForwardingRecipientResult.php | 15 ++ .../Messaging/Model/Dto/ForwardingResult.php | 20 ++ .../Service/Builder/ForwardEmailBuilder.php | 14 +- .../Service/ForwardContentService.php | 4 +- .../Service/MessageForwardService.php | 85 +++++++-- .../Builder/ForwardEmailBuilderTest.php | 13 +- .../Service/MessageForwardServiceTest.php | 178 +++++++++++++++--- 12 files changed, 310 insertions(+), 56 deletions(-) create mode 100644 src/Domain/Messaging/Exception/EmailBlacklistedException.php create mode 100644 src/Domain/Messaging/Exception/InvalidRecipientOrSubjectException.php create mode 100644 src/Domain/Messaging/Model/Dto/ForwardingRecipientResult.php create mode 100644 src/Domain/Messaging/Model/Dto/ForwardingResult.php diff --git a/config/PHPMD/rules.xml b/config/PHPMD/rules.xml index 9738d792..a2f21165 100644 --- a/config/PHPMD/rules.xml +++ b/config/PHPMD/rules.xml @@ -33,7 +33,7 @@ - + diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php index ec2d1af8..245bc57a 100644 --- a/src/Domain/Messaging/Command/ProcessQueueCommand.php +++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php @@ -21,9 +21,6 @@ use Symfony\Contracts\Translation\TranslatorInterface; use Throwable; -/** - * @SuppressWarnings("PHPMD.CouplingBetweenObjects") - */ #[AsCommand( name: 'phplist:process-queue', description: 'Processes the email campaign queue.' diff --git a/src/Domain/Messaging/Exception/EmailBlacklistedException.php b/src/Domain/Messaging/Exception/EmailBlacklistedException.php new file mode 100644 index 00000000..e555cdc2 --- /dev/null +++ b/src/Domain/Messaging/Exception/EmailBlacklistedException.php @@ -0,0 +1,15 @@ +validateRecipientAndSubject(to: $friendEmail, subject: $data->subject)) { - return null; + throw new InvalidRecipientOrSubjectException(); } if (!$this->passesBlacklistCheck(to: $friendEmail, skipBlacklistCheck: false)) { - return null; + throw new EmailBlacklistedException(); } $subject = $this->translator->trans('Fwd') . ': ' . stripslashes($data->subject); diff --git a/src/Domain/Messaging/Service/ForwardContentService.php b/src/Domain/Messaging/Service/ForwardContentService.php index 14574749..cb1e505b 100644 --- a/src/Domain/Messaging/Service/ForwardContentService.php +++ b/src/Domain/Messaging/Service/ForwardContentService.php @@ -22,7 +22,7 @@ public function __construct( ) { } - /** @return array{Email, OutputFormat}|null + /** @return array{Email, OutputFormat} * @throws MessageCacheMissingException */ public function getContents( @@ -30,7 +30,7 @@ public function getContents( Subscriber $forwardingSubscriber, string $friendEmail, MessageForwardDto $forwardDto - ): ?array { + ): array { $messagePrecacheDto = $this->cache->get(sprintf('messaging.message.base.%d.%d', $campaign->getId(), 1)); if ($messagePrecacheDto === null) { diff --git a/src/Domain/Messaging/Service/MessageForwardService.php b/src/Domain/Messaging/Service/MessageForwardService.php index bc07d8db..7e086a0f 100644 --- a/src/Domain/Messaging/Service/MessageForwardService.php +++ b/src/Domain/Messaging/Service/MessageForwardService.php @@ -5,8 +5,14 @@ namespace PhpList\Core\Domain\Messaging\Service; use PhpList\Core\Domain\Identity\Service\AdminNotifier; +use PhpList\Core\Domain\Messaging\Exception\EmailBlacklistedException; +use PhpList\Core\Domain\Messaging\Exception\InvalidRecipientOrSubjectException; +use PhpList\Core\Domain\Messaging\Exception\MessageCacheMissingException; use PhpList\Core\Domain\Messaging\Model\Dto\MessageForwardDto; +use PhpList\Core\Domain\Messaging\Model\Dto\ForwardingRecipientResult; +use PhpList\Core\Domain\Messaging\Model\Dto\ForwardingResult; use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; class MessageForwardService @@ -23,8 +29,14 @@ public function __construct( ) { } - public function forward(MessageForwardDto $messageForwardDto, Message $campaign): void + public function forward(MessageForwardDto $messageForwardDto, Message $campaign): ForwardingResult { + $recipientsResults = []; + $totalRequested = count($messageForwardDto->getEmails()); + $totalSent = 0; + $totalFailed = 0; + $totalAlreadySent = 0; + $loadedMessageData = ($this->messageDataLoader)($campaign); $forwardingSubscriber = $this->guard->assertCanForward( uid: $messageForwardDto->getUid(), @@ -34,35 +46,45 @@ public function forward(MessageForwardDto $messageForwardDto, Message $campaign) foreach ($messageForwardDto->getEmails() as $friendEmail) { if ($this->guard->hasAlreadyBeenSent(friendEmail: $friendEmail, campaign: $campaign)) { + $totalAlreadySent++; + $recipientsResults[] = new ForwardingRecipientResult( + email: $friendEmail, + status: 'already_sent', + ); continue; } if (!$this->precacheService->precacheMessage($campaign, $loadedMessageData, true)) { - $this->adminNotifier->notifyForwardFailed( + $forwardingRecipientResult = $this->handleFailure( campaign: $campaign, forwardingSubscriber: $forwardingSubscriber, friendEmail: $friendEmail, - lists: $messageLists + messageLists: $messageLists, ); - $this->forwardDeliveryService->markFailed($campaign, $forwardingSubscriber, $friendEmail); + $forwardingRecipientResult->reason = 'precache_failed'; + $recipientsResults[] = $forwardingRecipientResult; + $totalFailed++; continue; } - $result = $this->forwardContentService->getContents( - campaign: $campaign, - forwardingSubscriber: $forwardingSubscriber, - friendEmail: $friendEmail, - forwardDto: $messageForwardDto, - ); - - if ($result === null) { - $this->adminNotifier->notifyForwardFailed( + try { + $result = $this->forwardContentService->getContents( + campaign: $campaign, + forwardingSubscriber: $forwardingSubscriber, + friendEmail: $friendEmail, + forwardDto: $messageForwardDto, + ); + } catch (EmailBlacklistedException | MessageCacheMissingException | InvalidRecipientOrSubjectException $e) { + $forwardingRecipientResult = $this->handleFailure( campaign: $campaign, forwardingSubscriber: $forwardingSubscriber, friendEmail: $friendEmail, - lists: $messageLists + messageLists: $messageLists, ); - $this->forwardDeliveryService->markFailed($campaign, $forwardingSubscriber, $friendEmail); + + $forwardingRecipientResult->reason = $e->getMessage(); + $recipientsResults[] = $forwardingRecipientResult; + $totalFailed++; continue; } @@ -77,8 +99,41 @@ public function forward(MessageForwardDto $messageForwardDto, Message $campaign) $this->forwardDeliveryService->markSent($campaign, $forwardingSubscriber, $friendEmail); $campaign->incrementSentCount($sentAs); $this->forwardingStatsService->incrementFriendsCount($forwardingSubscriber); + $totalSent++; + $recipientsResults[] = new ForwardingRecipientResult( + email: $friendEmail, + status: 'sent', + ); } $this->forwardingStatsService->updateFriendsCount($forwardingSubscriber); + + return new ForwardingResult( + totalRequested: $totalRequested, + totalSent: $totalSent, + totalFailed: $totalFailed, + totalAlreadySent: $totalAlreadySent, + recipients: $recipientsResults, + ); + } + + private function handleFailure( + Message $campaign, + Subscriber $forwardingSubscriber, + string $friendEmail, + array $messageLists + ): ForwardingRecipientResult { + $this->adminNotifier->notifyForwardFailed( + campaign: $campaign, + forwardingSubscriber: $forwardingSubscriber, + friendEmail: $friendEmail, + lists: $messageLists + ); + $this->forwardDeliveryService->markFailed($campaign, $forwardingSubscriber, $friendEmail); + + return new ForwardingRecipientResult( + email: $friendEmail, + status: 'failed', + ); } } diff --git a/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php index 30920240..ddb0de83 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php @@ -10,6 +10,8 @@ use PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder; use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; +use PhpList\Core\Domain\Messaging\Exception\EmailBlacklistedException; +use PhpList\Core\Domain\Messaging\Exception\InvalidRecipientOrSubjectException; use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Service\AttachmentAdder; use PhpList\Core\Domain\Messaging\Service\Builder\ForwardEmailBuilder; @@ -177,8 +179,11 @@ public function testReturnsNullWhenEmptySubjectAndLogs(): void $this->eventLogManager->expects(self::once())->method('log'); + $this->expectException(InvalidRecipientOrSubjectException::class); + $this->expectExceptionMessage('Invalid recipient or subject.'); + $builder = $this->makeBuilder(); - $result = $builder->buildForwardEmail( + $builder->buildForwardEmail( messageId: 1, friendEmail: $friend, forwardedBy: new Subscriber(), @@ -187,8 +192,6 @@ public function testReturnsNullWhenEmptySubjectAndLogs(): void fromName: 'X', fromEmail: 'x@example.com', ); - - self::assertNull($result); } public function testBlacklistReturnsNullAndMarksHistory(): void @@ -206,6 +209,8 @@ public function testBlacklistReturnsNullAndMarksHistory(): void $dto = new MessagePrecacheDto(); $dto->subject = 'S'; + $this->expectException(EmailBlacklistedException::class); + $this->expectExceptionMessage('Email address is blacklisted.'); $builder = $this->makeBuilder(); $result = $builder->buildForwardEmail( @@ -217,7 +222,5 @@ public function testBlacklistReturnsNullAndMarksHistory(): void fromName: 'From', fromEmail: 'from@example.com', ); - - self::assertNull($result); } } diff --git a/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php b/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php index 12827f45..6e7c059a 100644 --- a/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php @@ -4,8 +4,10 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; -use DateTimeImmutable; use PhpList\Core\Domain\Identity\Service\AdminNotifier; +use PhpList\Core\Domain\Messaging\Exception\EmailBlacklistedException; +use PhpList\Core\Domain\Messaging\Exception\InvalidRecipientOrSubjectException; +use PhpList\Core\Domain\Messaging\Exception\MessageCacheMissingException; use PhpList\Core\Domain\Messaging\Model\Dto\MessageForwardDto; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Configuration\Model\OutputFormat; @@ -136,7 +138,7 @@ public function testPrecacheFailureNotifiesAndMarksFailed(): void $service->forward($this->createDto(['friend@example.test']), $campaign); } - public function testContentNullTriggersFailureFlow(): void + public function testSuccessfulFlowSendsAndUpdatesEverything(): void { $service = $this->createService(); $campaign = $this->createMock(Message::class); @@ -148,22 +150,67 @@ public function testContentNullTriggersFailureFlow(): void $this->guard->method('hasAlreadyBeenSent')->willReturn(false); $this->precache->method('precacheMessage')->willReturn(true); - $this->contentService->expects(self::once()) + $email1 = (new Email())->to('x1@example.test'); + $email2 = (new Email())->to('x2@example.test'); + + $this->contentService->expects(self::exactly(2)) ->method('getContents') - ->willReturn(null); + ->willReturnOnConsecutiveCalls([$email1, OutputFormat::Html], [$email2, OutputFormat::Text]); - $this->notifier->expects(self::once())->method('notifyForwardFailed'); - $this->delivery->expects(self::once())->method('markFailed'); - $this->delivery->expects(self::never())->method('send'); - $this->stats->expects(self::never())->method('incrementFriendsCount'); + $this->delivery->expects(self::exactly(2))->method('send'); + $this->notifier->expects(self::exactly(2))->method('notifyForwardSucceeded'); + $this->delivery->expects(self::exactly(2))->method('markSent'); + + // Campaign should increment sent count for both sentAs values + $campaign->expects(self::exactly(2)) + ->method('incrementSentCount') + ->with(self::logicalOr(OutputFormat::Html, OutputFormat::Text)); + + // Stats increment per friend, then update once at the end + $this->stats->expects(self::exactly(2)) + ->method('incrementFriendsCount') + ->with(self::identicalTo($subscriber)); $this->stats->expects(self::once()) ->method('updateFriendsCount') ->with(self::identicalTo($subscriber)); - $service->forward($this->createDto(['f@x.tld']), $campaign); + $service->forward($this->createDto(['x1@example.test', 'x2@example.test']), $campaign); + } + + public function testGetContentsThrowsEmailBlacklistedIsHandledAsFailureAndReportedInResult(): void + { + $service = $this->createService(); + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + + $this->loader->method('__invoke')->willReturn(['ok' => true]); + $this->guard->method('assertCanForward')->willReturn($subscriber); + $this->listRepo->method('getListsByMessage')->willReturn(['L1']); + $this->guard->method('hasAlreadyBeenSent')->willReturn(false); + $this->precache->method('precacheMessage')->willReturn(true); + + $this->contentService->method('getContents')->willThrowException(new EmailBlacklistedException()); + + $this->notifier->expects(self::once())->method('notifyForwardFailed'); + $this->delivery->expects(self::once())->method('markFailed'); + $this->delivery->expects(self::never())->method('send'); + $this->delivery->expects(self::never())->method('markSent'); + $this->stats->expects(self::never())->method('incrementFriendsCount'); + $this->stats->expects(self::once())->method('updateFriendsCount'); + + $result = $service->forward($this->createDto(['friend1@example.test']), $campaign); + + self::assertSame(1, $result->totalRequested); + self::assertSame(0, $result->totalSent); + self::assertSame(1, $result->totalFailed); + self::assertSame(0, $result->totalAlreadySent); + self::assertCount(1, $result->recipients); + self::assertSame('friend1@example.test', $result->recipients[0]->email); + self::assertSame('failed', $result->recipients[0]->status); + self::assertSame('Email address is blacklisted.', $result->recipients[0]->reason); } - public function testSuccessfulFlowSendsAndUpdatesEverything(): void + public function testGetContentsThrowsInvalidRecipientIsHandledAsFailureAndReportedInResult(): void { $service = $this->createService(); $campaign = $this->createMock(Message::class); @@ -175,30 +222,111 @@ public function testSuccessfulFlowSendsAndUpdatesEverything(): void $this->guard->method('hasAlreadyBeenSent')->willReturn(false); $this->precache->method('precacheMessage')->willReturn(true); - $email1 = (new Email())->to('x1@example.test'); - $email2 = (new Email())->to('x2@example.test'); + $this->contentService->method('getContents')->willThrowException(new InvalidRecipientOrSubjectException()); - $this->contentService->expects(self::exactly(2)) - ->method('getContents') - ->willReturnOnConsecutiveCalls([$email1, OutputFormat::Html], [$email2, OutputFormat::Text]); + $this->notifier->expects(self::once())->method('notifyForwardFailed'); + $this->delivery->expects(self::once())->method('markFailed'); + $this->delivery->expects(self::never())->method('send'); + $this->delivery->expects(self::never())->method('markSent'); + $this->stats->expects(self::never())->method('incrementFriendsCount'); + $this->stats->expects(self::once())->method('updateFriendsCount'); - $this->delivery->expects(self::exactly(2))->method('send'); - $this->notifier->expects(self::exactly(2))->method('notifyForwardSucceeded'); - $this->delivery->expects(self::exactly(2))->method('markSent'); + $result = $service->forward($this->createDto(['friend2@example.test']), $campaign); - // Campaign should increment sent count for both sentAs values - $campaign->expects(self::exactly(2)) - ->method('incrementSentCount') - ->with(self::logicalOr(OutputFormat::Html, OutputFormat::Text)); + self::assertSame(1, $result->totalRequested); + self::assertSame(0, $result->totalSent); + self::assertSame(1, $result->totalFailed); + self::assertSame(0, $result->totalAlreadySent); + self::assertSame('Invalid recipient or subject.', $result->recipients[0]->reason); + self::assertSame('failed', $result->recipients[0]->status); + } - // Stats increment per friend, then update once at the end - $this->stats->expects(self::exactly(2)) + public function testPrecacheFailureAlsoReflectedInForwardingResult(): void + { + $service = $this->createService(); + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + + $this->loader->method('__invoke')->willReturn(['data' => true]); + $this->guard->method('assertCanForward')->willReturn($subscriber); + $this->listRepo->method('getListsByMessage')->willReturn(['LZ']); + $this->guard->method('hasAlreadyBeenSent')->willReturn(false); + + $this->precache->method('precacheMessage')->willReturn(false); + + $this->notifier->expects(self::once())->method('notifyForwardFailed'); + $this->delivery->expects(self::once())->method('markFailed'); + $this->contentService->expects(self::never())->method('getContents'); + + $result = $service->forward($this->createDto(['friend3@example.test']), $campaign); + + self::assertSame(1, $result->totalRequested); + self::assertSame(0, $result->totalSent); + self::assertSame(1, $result->totalFailed); + self::assertSame(0, $result->totalAlreadySent); + self::assertSame('precache_failed', $result->recipients[0]->reason); + self::assertSame('failed', $result->recipients[0]->status); + } + + public function testMixedScenarioAggregatesResultsAndSideEffects(): void + { + $service = $this->createService(); + $campaign = $this->createMock(Message::class); + $subscriber = new Subscriber(); + + $this->loader->method('__invoke')->willReturn(['ok' => 1]); + $this->guard->method('assertCanForward')->willReturn($subscriber); + $this->listRepo->method('getListsByMessage')->willReturn(['L1', 'L2']); + + // e1 already sent, others not + $this->guard->expects(self::exactly(4)) + ->method('hasAlreadyBeenSent') + ->willReturnOnConsecutiveCalls(true, false, false, false); + + // precache called for e2, e3, e4 + $this->precache->expects(self::exactly(3)) + ->method('precacheMessage') + ->willReturnOnConsecutiveCalls(false, true, true); + + // e3 success, e4 throws + $email3 = (new Email())->to('e3@example.test'); + $this->contentService->expects(self::exactly(2)) + ->method('getContents') + ->willReturnOnConsecutiveCalls( + [$email3, OutputFormat::Html], + self::throwException(new MessageCacheMissingException()) + ); + + // side-effects + $this->delivery->expects(self::once())->method('send'); + $this->delivery->expects(self::once())->method('markSent'); + $this->delivery->expects(self::exactly(2))->method('markFailed'); + $this->notifier->expects(self::once())->method('notifyForwardSucceeded'); + $this->notifier->expects(self::exactly(2))->method('notifyForwardFailed'); + $campaign->expects(self::once()) + ->method('incrementSentCount') + ->with(OutputFormat::Html); + $this->stats->expects(self::once()) ->method('incrementFriendsCount') ->with(self::identicalTo($subscriber)); $this->stats->expects(self::once()) ->method('updateFriendsCount') ->with(self::identicalTo($subscriber)); - $service->forward($this->createDto(['x1@example.test', 'x2@example.test']), $campaign); + $dto = $this->createDto(['e1@example.test', 'e2@example.test', 'e3@example.test', 'e4@example.test']); + $result = $service->forward($dto, $campaign); + + self::assertSame(4, $result->totalRequested); + self::assertSame(1, $result->totalSent); + self::assertSame(2, $result->totalFailed); + self::assertSame(1, $result->totalAlreadySent); + + self::assertCount(4, $result->recipients); + self::assertSame('already_sent', $result->recipients[0]->status); + self::assertSame('failed', $result->recipients[1]->status); + self::assertSame('precache_failed', $result->recipients[1]->reason); + self::assertSame('sent', $result->recipients[2]->status); + self::assertSame('failed', $result->recipients[3]->status); + self::assertSame('Message cache is missing or expired.', $result->recipients[3]->reason); } } From 2adf65cffcf6b34a79042cffafaf143db02e62b9 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 3 Feb 2026 21:21:36 +0400 Subject: [PATCH 35/37] Remove MessageFormat consts --- config/services/services.yml | 28 +++++++++++++++++++ .../Messaging/Model/Message/MessageFormat.php | 13 --------- .../Repository/MessageRepositoryTest.php | 9 +++--- .../Service/SubscriberDeletionServiceTest.php | 3 +- .../Domain/Messaging/Model/MessageTest.php | 3 +- 5 files changed, 37 insertions(+), 19 deletions(-) diff --git a/config/services/services.yml b/config/services/services.yml index c07ee0bb..93134c38 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -18,6 +18,26 @@ services: autowire: true autoconfigure: true + PhpList\Core\Domain\Messaging\Service\MessageForwardService: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\ForwardingGuard: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\ForwardContentService: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\ForwardDeliveryService: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\ForwardingStatsService: + autowire: true + autoconfigure: true + PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService: autowire: true autoconfigure: true @@ -175,6 +195,14 @@ services: autoconfigure: true public: true + PhpList\Core\Domain\Identity\Service\AdminNotifier: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Identity\Service\AdminCopyEmailSender: + autowire: true + autoconfigure: true + PhpList\Core\Domain\Configuration\Service\MessagePlaceholderProcessor: autowire: true autoconfigure: true diff --git a/src/Domain/Messaging/Model/Message/MessageFormat.php b/src/Domain/Messaging/Model/Message/MessageFormat.php index 2efcfcc8..1f857c37 100644 --- a/src/Domain/Messaging/Model/Message/MessageFormat.php +++ b/src/Domain/Messaging/Model/Message/MessageFormat.php @@ -31,10 +31,6 @@ class MessageFormat implements EmbeddableInterface #[ORM\Column(name: 'astextandpdf', type: 'integer')] private int $asTextAndPdf = 0; - public const FORMAT_TEXT = 'text'; - public const FORMAT_HTML = 'html'; - public const FORMAT_PDF = 'pdf'; - public function __construct( bool $htmlFormatted, ?string $sendFormat, @@ -114,13 +110,4 @@ public function incrementAsTextAndPdf(): void { $this->asTextAndPdf++; } - - public function getFormatOptions(): array - { - return array_values(array_filter([ - $this->asText ? self::FORMAT_TEXT : null, - $this->asHtml ? self::FORMAT_HTML : null, - $this->asPdf ? self::FORMAT_PDF : null, - ])); - } } diff --git a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php index 388fa391..29793766 100644 --- a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php +++ b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php @@ -6,6 +6,7 @@ use DateTime; use Doctrine\ORM\Tools\SchemaTool; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; @@ -75,7 +76,7 @@ public function testGetByOwnerIdReturnsOnlyOwnedMessages(): void $this->entityManager->persist($admin2); $msg1 = new Message( - new MessageFormat(true, MessageFormat::FORMAT_TEXT), + new MessageFormat(true, OutputFormat::Text->value), new MessageSchedule(1, null, 3, null, null), new MessageMetadata(Message\MessageStatus::Sent), new MessageContent('Owned by Admin 1!'), @@ -84,7 +85,7 @@ public function testGetByOwnerIdReturnsOnlyOwnedMessages(): void ); $msg2 = new Message( - new MessageFormat(true, MessageFormat::FORMAT_TEXT), + new MessageFormat(true, OutputFormat::Text->value), new MessageSchedule(1, null, 3, null, null), new MessageMetadata(null), new MessageContent('Owned by Admin 2!'), @@ -93,7 +94,7 @@ public function testGetByOwnerIdReturnsOnlyOwnedMessages(): void ); $msg3 = new Message( - new MessageFormat(true, MessageFormat::FORMAT_TEXT), + new MessageFormat(true, OutputFormat::Text->value), new MessageSchedule(1, null, 3, null, null), new MessageMetadata(null), new MessageContent('Hello world!'), @@ -118,7 +119,7 @@ public function testMessageTimestampsAreSetOnPersist(): void $expectedDate = new DateTime(); $message = new Message( - new MessageFormat(true, MessageFormat::FORMAT_TEXT), + new MessageFormat(true, OutputFormat::Text->value), new MessageSchedule(1, null, 3, null, null), new MessageMetadata(null), new MessageContent('Hello world!'), diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php index 8fb8e31c..785d3547 100644 --- a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php +++ b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php @@ -10,6 +10,7 @@ use Exception; use PhpList\Core\Domain\Analytics\Model\LinkTrackUmlClick; use PhpList\Core\Domain\Analytics\Model\UserMessageView; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; @@ -56,7 +57,7 @@ public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError(): $this->entityManager->persist($admin); $msg = new Message( - format: new MessageFormat(true, MessageFormat::FORMAT_TEXT), + format: new MessageFormat(true, OutputFormat::Text->value), schedule: new MessageSchedule(1, null, 3, null, null), metadata: new MessageMetadata(Message\MessageStatus::Sent), content: new MessageContent('Owned by Admin 1!'), diff --git a/tests/Unit/Domain/Messaging/Model/MessageTest.php b/tests/Unit/Domain/Messaging/Model/MessageTest.php index 0201f08b..8abf952b 100644 --- a/tests/Unit/Domain/Messaging/Model/MessageTest.php +++ b/tests/Unit/Domain/Messaging/Model/MessageTest.php @@ -8,6 +8,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Common\Model\Interfaces\Identity; use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; @@ -29,7 +30,7 @@ class MessageTest extends TestCase protected function setUp(): void { - $this->format = new MessageFormat(true, MessageFormat::FORMAT_TEXT); + $this->format = new MessageFormat(true, OutputFormat::Text->value); $this->schedule = new MessageSchedule(1, new DateTime(), 2, new DateTime(), null); $this->metadata = new MessageMetadata(); $this->content = new MessageContent('This is the body'); From 7adf1f68b5264a0a0c46edaef14aac035ff38b63 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 3 Feb 2026 22:22:13 +0400 Subject: [PATCH 36/37] Testing bundle --- composer.json | 8 +++--- config/services/builders.yml | 8 ++++++ src/Composer/ModuleFinder.php | 26 ++++++++++++------ .../PhpListCoreExtension.php | 26 ++++++++++++++++++ .../Configuration/Model/ConfigOption.php | 2 ++ .../Service/Provider/ConfigProvider.php | 6 ++--- .../Provider/DefaultConfigProvider.php | 11 ++++---- .../Repository/UserBlacklistRepository.php | 3 ++- src/PhpListCoreBundle.php | 11 ++++++++ tests/Unit/Composer/ModuleFinderTest.php | 6 ++--- .../Service/Provider/ConfigProviderTest.php | 10 +++---- .../Provider/DefaultConfigProviderTest.php | 27 +++++++------------ 12 files changed, 98 insertions(+), 46 deletions(-) create mode 100644 src/DependencyInjection/PhpListCoreExtension.php create mode 100644 src/PhpListCoreBundle.php diff --git a/composer.json b/composer.json index cdcb9934..0a39fd1b 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "phplist/core", "description": "The core module of phpList, the world's most popular open source newsletter manager", - "type": "phplist-module", + "type": "symfony-bundle", "keywords": [ "phplist", "email", @@ -46,6 +46,7 @@ }, "require": { "php": "^8.1", + "symfony/framework-bundle": "^6.4", "symfony/dependency-injection": "^6.4", "symfony/config": "^6.4", "symfony/yaml": "^6.4", @@ -98,7 +99,6 @@ "symfony/test-pack": "^1.1", "symfony/process": "^6.4", "composer/composer": "^2.7", - "symfony/framework-bundle": "^6.4", "symfony/http-kernel": "^6.4", "symfony/http-foundation": "^6.4", "symfony/routing": "^6.4", @@ -158,8 +158,8 @@ "Doctrine\\Bundle\\DoctrineBundle\\DoctrineBundle", "Doctrine\\Bundle\\MigrationsBundle\\DoctrineMigrationsBundle", "PhpList\\Core\\EmptyStartPageBundle\\EmptyStartPageBundle", - "FOS\\RestBundle\\FOSRestBundle", - "TatevikGr\\RssFeedBundle\\RssFeedBundle" + "PhpList\\Core\\EmptyStartPageBundle\\PhpListCoreBundle", + "FOS\\RestBundle\\FOSRestBundle" ], "routes": { "homepage": { diff --git a/config/services/builders.yml b/config/services/builders.yml index 1b3b3814..c57ac009 100644 --- a/config/services/builders.yml +++ b/config/services/builders.yml @@ -27,3 +27,11 @@ services: $usePrecedenceHeader: '%messaging.use_precedence_header%' $devVersion: '%app.dev_version%' $devEmail: '%app.dev_email%' + + PhpList\Core\Domain\Messaging\Service\Builder\ForwardEmailBuilder: + arguments: + $googleSenderId: '%messaging.google_sender_id%' + $useAmazonSes: '%messaging.use_amazon_ses%' + $usePrecedenceHeader: '%messaging.use_precedence_header%' + $devVersion: '%app.dev_version%' + $devEmail: '%app.dev_email%' diff --git a/src/Composer/ModuleFinder.php b/src/Composer/ModuleFinder.php index 110006e4..2e69c447 100644 --- a/src/Composer/ModuleFinder.php +++ b/src/Composer/ModuleFinder.php @@ -35,10 +35,14 @@ public function injectPackageRepository(PackageRepository $repository): void } /** - * Finds the bundles class in all installed modules. + * Finds the bundle classes declared by all installed packages (including the root package). * - * @return string[][] class names of the bundles of all installed phpList modules: - * ['module package name' => ['bundle class name 1', 'bundle class name 2']] + * We intentionally scan all packages, not only those with a specific type, because the root + * package or other dependencies can also declare bundles via the "extra.phplist/core.bundles" + * section in their composer.json. + * + * @return string[][] class names of the bundles grouped by package name: + * ['package name' => ['Bundle\Class\Name1', 'Bundle\Class\Name2']] * * @throws InvalidArgumentException */ @@ -47,7 +51,8 @@ public function findBundleClasses(): array /** @var string[][] $bundleSets */ $bundleSets = []; - $modules = $this->packageRepository->findModules(); + // Look at ALL packages (including the root), as they may declare bundles + $modules = $this->packageRepository->findAll(); foreach ($modules as $module) { $extra = $module->getExtra(); $this->validateBundlesSectionInExtra($extra); @@ -131,10 +136,14 @@ public function createBundleConfigurationYaml(): string } /** - * Finds the routes in all installed modules. + * Finds the routes declared by all installed packages (including the root package). * - * @return array[] class names of the routes of all installed phpList modules: - * ['route name' => [route configuration] + * We intentionally scan all packages, not only those with a specific type, because the root + * package or other dependencies can also declare routes via the "extra.phplist/core.routes" + * section in their composer.json. + * + * @return array[] routes keyed by prefixed route name: + * ['vendor/package.route_name' => [route configuration]] * * @throws InvalidArgumentException */ @@ -143,7 +152,8 @@ public function findRoutes(): array /** @var array[] $routes */ $routes = []; - $modules = $this->packageRepository->findModules(); + // Look at ALL packages (including the root), as they may declare routes + $modules = $this->packageRepository->findAll(); foreach ($modules as $module) { $extra = $module->getExtra(); $this->validateRoutesSectionInExtra($extra); diff --git a/src/DependencyInjection/PhpListCoreExtension.php b/src/DependencyInjection/PhpListCoreExtension.php new file mode 100644 index 00000000..e04ad940 --- /dev/null +++ b/src/DependencyInjection/PhpListCoreExtension.php @@ -0,0 +1,26 @@ +load($file); + } + } + } +} diff --git a/src/Domain/Configuration/Model/ConfigOption.php b/src/Domain/Configuration/Model/ConfigOption.php index 1c9fd37e..1c4a3376 100644 --- a/src/Domain/Configuration/Model/ConfigOption.php +++ b/src/Domain/Configuration/Model/ConfigOption.php @@ -45,4 +45,6 @@ enum ConfigOption: string case SendAdminCopies = 'send_admin_copies'; case DontSaveUserPassword = 'dontsave_userpassword'; case AutoCreateAttributes = 'autocreate_attributes'; + case RemoteProcessingSecret = 'remote_processing_secret'; + case NotifyAdminLogin = 'notify_admin_login'; } diff --git a/src/Domain/Configuration/Service/Provider/ConfigProvider.php b/src/Domain/Configuration/Service/Provider/ConfigProvider.php index 7ae13f99..0124af9c 100644 --- a/src/Domain/Configuration/Service/Provider/ConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/ConfigProvider.php @@ -38,9 +38,9 @@ public function isEnabled(ConfigOption $key): bool return filter_var($config->getValue(), FILTER_VALIDATE_BOOLEAN); } - if ($this->defaultConfigs->has($key->value)) { + if ($this->defaultConfigs->has($key)) { return filter_var( - $this->defaultConfigs->get($key->value)['value'], + $this->defaultConfigs->get($key)['value'], FILTER_VALIDATE_BOOLEAN ); } @@ -68,7 +68,7 @@ public function getValue(ConfigOption $key): ?string return $value; } - return $this->defaultConfigs->has($key->value) ? $this->defaultConfigs->get($key->value)['value'] : null; + return $this->defaultConfigs->has($key) ? (string) $this->defaultConfigs->get($key)['value'] : null; } public function getValueWithNamespace(ConfigOption $key): ?string diff --git a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php index 9686bab6..51b95ea1 100644 --- a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Domain\Configuration\Service\Provider; +use PhpList\Core\Domain\Configuration\Model\ConfigOption; use Symfony\Contracts\Translation\TranslatorInterface; // phpcs:disable Generic.Files.LineLength @@ -577,24 +578,24 @@ private function init(): void /** * Get a single default config item by key * - * @param string $key + * @param ConfigOption $key * @param mixed|null $default * @return mixed */ - public function get(string $key, mixed $default = null): mixed + public function get(ConfigOption $key, mixed $default = null): mixed { $this->init(); - return $this->defaults[$key] ?? $default; + return $this->defaults[$key->value] ?? $default; } /** * Check if a config key exists */ - public function has(string $key): bool + public function has(ConfigOption $key): bool { $this->init(); - return isset($this->defaults[$key]); + return isset($this->defaults[$key->value]); } } diff --git a/src/Domain/Subscription/Repository/UserBlacklistRepository.php b/src/Domain/Subscription/Repository/UserBlacklistRepository.php index 1a556eb7..9dabc9ff 100644 --- a/src/Domain/Subscription/Repository/UserBlacklistRepository.php +++ b/src/Domain/Subscription/Repository/UserBlacklistRepository.php @@ -10,13 +10,14 @@ use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Subscription\Model\UserBlacklist; use PhpList\Core\Domain\Subscription\Model\UserBlacklistData; +use Symfony\Component\DependencyInjection\Attribute\Autowire; class UserBlacklistRepository extends AbstractRepository { public function __construct( EntityManagerInterface $entityManager, ClassMetadata $class, - private readonly int $blacklistGraceTime, + #[Autowire('%messaging.blacklist_grace_time%')] private readonly int $blacklistGraceTime = 5, ) { parent::__construct($entityManager, $class); } diff --git a/src/PhpListCoreBundle.php b/src/PhpListCoreBundle.php new file mode 100644 index 00000000..a5149d1e --- /dev/null +++ b/src/PhpListCoreBundle.php @@ -0,0 +1,11 @@ +packageRepository - ->method('findModules') + ->method('findAll') ->willReturn($modules); $this->expectException(\InvalidArgumentException::class); @@ -133,7 +133,7 @@ public function testFindBundleClassesForModulesWithBundlesReturnsBundleClassName array $expectedBundles ): void { $this->packageRepository - ->method('findModules') + ->method('findAll') ->willReturn($modules); $result = $this->subject->findBundleClasses(); @@ -158,7 +158,7 @@ public function testCreateBundleConfigurationYamlForNoModulesReturnsCommentOnly( public function testCreateBundleConfigurationYamlReturnsYamlForBundles(array $modules, array $bundles): void { $this->packageRepository - ->method('findModules') + ->method('findAll') ->willReturn($modules); $result = $this->subject->createBundleConfigurationYaml(); diff --git a/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php b/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php index 12e36ed9..c5744908 100644 --- a/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php +++ b/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php @@ -119,13 +119,13 @@ public function testIsEnabledFallsBackToDefaultsWhenRepoMissing(): void $this->defaults ->expects($this->once()) ->method('has') - ->with($key->value) + ->with($key) ->willReturn(true); $this->defaults ->expects($this->once()) ->method('get') - ->with($key->value) + ->with($key) ->willReturn(['value' => '1']); $this->assertTrue($this->provider->isEnabled($key)); @@ -210,13 +210,13 @@ public function testGetValueFallsBackToDefaultConfigsWhenNoCacheAndNoRepo(): voi $this->defaults ->expects($this->once()) ->method('has') - ->with($key->value) + ->with($key) ->willReturn(true); $this->defaults ->expects($this->once()) ->method('get') - ->with($key->value) + ->with($key) ->willReturn(['value' => 'DEF']); $this->assertSame('DEF', $this->provider->getValue($key)); @@ -231,7 +231,7 @@ public function testGetValueReturnsNullWhenNoCacheNoRepoNoDefault(): void $this->repo->expects($this->once())->method('findValueByItem')->with($key->value)->willReturn(null); $this->cache->expects($this->once())->method('set')->with($cacheKey, null, 300); - $this->defaults->expects($this->once())->method('has')->with($key->value)->willReturn(false); + $this->defaults->expects($this->once())->method('has')->with($key)->willReturn(false); $this->defaults->expects($this->never())->method('get'); $this->assertNull($this->provider->getValue($key)); diff --git a/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php b/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php index ae5b96cb..1d79369a 100644 --- a/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php +++ b/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Configuration\Service\Provider; +use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Service\Provider\DefaultConfigProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -26,12 +27,12 @@ protected function setUp(): void public function testHasReturnsTrueForKnownKey(): void { - $this->assertTrue($this->provider->has('admin_address')); + $this->assertTrue($this->provider->has(ConfigOption::AdminAddress)); } public function testGetReturnsArrayShapeForKnownKey(): void { - $item = $this->provider->get('admin_address'); + $item = $this->provider->get(ConfigOption::AdminAddress); $this->assertIsArray($item); $this->assertArrayHasKey('value', $item); @@ -45,15 +46,9 @@ public function testGetReturnsArrayShapeForKnownKey(): void $this->assertStringContainsString('[DOMAIN]', (string) $item['value']); } - public function testGetReturnsProvidedDefaultWhenUnknownKey(): void - { - $fallback = ['value' => 'X', 'type' => 'text']; - $this->assertSame($fallback, $this->provider->get('does_not_exist', $fallback)); - } - public function testRemoteProcessingSecretIsRandomHexOfExpectedLength(): void { - $item = $this->provider->get('remote_processing_secret'); + $item = $this->provider->get(ConfigOption::RemoteProcessingSecret); $this->assertIsArray($item); $this->assertArrayHasKey('value', $item); @@ -64,7 +59,7 @@ public function testRemoteProcessingSecretIsRandomHexOfExpectedLength(): void public function testSubscribeUrlDefaultsToHttpAndApiV2Path(): void { - $item = $this->provider->get('subscribeurl'); + $item = $this->provider->get(ConfigOption::SubscribeUrl); $this->assertIsArray($item); $url = (string) $item['value']; @@ -75,7 +70,7 @@ public function testSubscribeUrlDefaultsToHttpAndApiV2Path(): void public function testUnsubscribeUrlDefaults(): void { - $item = $this->provider->get('unsubscribeurl'); + $item = $this->provider->get(ConfigOption::UnsubscribeUrl); $url = (string) $item['value']; $this->assertStringStartsWith('http://', $url); @@ -88,7 +83,7 @@ public function testTranslatorIsUsedOnlyOnFirstInit(): void ->expects($this->atLeastOnce()) ->method('trans') ->willReturnArgument(0); - $this->provider->get('admin_address'); + $this->provider->get(ConfigOption::AdminAddress); // Subsequent calls should not trigger init again $translator = $this->createMock(TranslatorInterface::class); @@ -100,8 +95,8 @@ public function testTranslatorIsUsedOnlyOnFirstInit(): void $prop = $reflection->getProperty('translator'); $prop->setValue($this->provider, $translator); - $this->provider->get('unsubscribeurl'); - $this->provider->has('pageheader'); + $this->provider->get(ConfigOption::UnsubscribeUrl); + $this->provider->has(ConfigOption::BlacklistUrl); } public function testKnownKeysHaveReasonableTypes(): void @@ -110,8 +105,6 @@ public function testKnownKeysHaveReasonableTypes(): void 'admin_address' => 'email', 'organisation_name' => 'text', 'organisation_logo' => 'image', - 'date_format' => 'text', - 'rc_notification' => 'boolean', 'notify_admin_login' => 'boolean', 'message_from_address' => 'email', 'message_from_name' => 'text', @@ -119,7 +112,7 @@ public function testKnownKeysHaveReasonableTypes(): void ]; foreach ($keys as $key => $type) { - $item = $this->provider->get($key); + $item = $this->provider->get(ConfigOption::from($key)); $this->assertIsArray($item, 'Item should be an array. Key: ' . $key); $this->assertSame($type, $item['type'] ?? null, $key .': should have type ' . $type); } From 0a9b9a4d3ede956c1172f0b0474827cc49eb54de Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 4 Feb 2026 13:32:02 +0400 Subject: [PATCH 37/37] After review 3 --- config/services/messenger.yml | 1 - src/Domain/Common/FileHelper.php | 3 + src/Domain/Common/RemotePageFetcher.php | 1 - .../Identity/Service/AdminCopyEmailSender.php | 6 ++ .../Service/Builder/BaseEmailBuilder.php | 2 +- .../Service/Builder/EmailBuilder.php | 2 +- .../Service/Builder/ForwardEmailBuilder.php | 1 + .../Service/Builder/SystemEmailBuilder.php | 2 +- .../Service/MessagePrecacheService.php | 2 +- .../Repository/SubscriberRepository.php | 2 +- src/Migrations/Version20260204094237.php | 63 +++++++++++++++++++ .../Service/AdminCopyEmailSenderTest.php | 4 ++ .../Service/Builder/EmailBuilderTest.php | 2 +- 13 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 src/Migrations/Version20260204094237.php diff --git a/config/services/messenger.yml b/config/services/messenger.yml index 1508e592..6ae953d4 100644 --- a/config/services/messenger.yml +++ b/config/services/messenger.yml @@ -14,4 +14,3 @@ services: PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler: autowire: true autoconfigure: true - arguments: diff --git a/src/Domain/Common/FileHelper.php b/src/Domain/Common/FileHelper.php index 5dab8b05..b8995b35 100644 --- a/src/Domain/Common/FileHelper.php +++ b/src/Domain/Common/FileHelper.php @@ -33,6 +33,9 @@ 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 { $pathInfo = pathinfo($originalFilename); diff --git a/src/Domain/Common/RemotePageFetcher.php b/src/Domain/Common/RemotePageFetcher.php index 40cecc5b..30ac316e 100644 --- a/src/Domain/Common/RemotePageFetcher.php +++ b/src/Domain/Common/RemotePageFetcher.php @@ -118,7 +118,6 @@ private function prepareUrl(string $url, array $userData): string $url = str_replace('[' . $key . ']', rawurlencode($val), $url); } } - $url = mb_convert_encoding($url, 'ISO-8859-1', 'UTF-8'); return $this->expandUrl($url); } diff --git a/src/Domain/Identity/Service/AdminCopyEmailSender.php b/src/Domain/Identity/Service/AdminCopyEmailSender.php index c029afcc..a1de0b0c 100644 --- a/src/Domain/Identity/Service/AdminCopyEmailSender.php +++ b/src/Domain/Identity/Service/AdminCopyEmailSender.php @@ -9,6 +9,7 @@ use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder; use PhpList\Core\Domain\Subscription\Model\SubscriberList; +use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\MailerInterface; @@ -20,6 +21,7 @@ public function __construct( private readonly ConfigProvider $configProvider, private readonly SystemEmailBuilder $systemEmailBuilder, private readonly MailerInterface $mailer, + private readonly LoggerInterface $logger, #[Autowire('%messaging.send_list_admin_copy%')] private readonly bool $sendListAdminCopy, #[Autowire('%imap_bounce.email%')] private readonly string $bounceEmail, private readonly string $installationName = 'phpList', @@ -42,6 +44,10 @@ public function __invoke(string $subject, string $message, array $lists = []): v $data->content = $message; $email = $this->systemEmailBuilder->buildSystemEmail(data: $data); + if ($email === null) { + $this->logger->warning('Failed to build admin copy email for recipient ' . $adminMail); + continue; + } $envelope = new Envelope( sender: new Address($this->bounceEmail, 'PHPList'), diff --git a/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php b/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php index 98fc705d..79bffa9a 100644 --- a/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php +++ b/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php @@ -136,7 +136,7 @@ protected function addBaseCampaignHeaders( } $removeUrl = $this->configProvider->getValue(ConfigOption::UnsubscribeUrl); - $sep = !str_contains($removeUrl, '?') ? '?' : '&'; + $sep = !str_contains($removeUrl ?? '#', '?') ? '?' : '&'; $email->getHeaders()->addTextHeader( 'List-Unsubscribe', sprintf( diff --git a/src/Domain/Messaging/Service/Builder/EmailBuilder.php b/src/Domain/Messaging/Service/Builder/EmailBuilder.php index 58a624e7..d0cf8039 100644 --- a/src/Domain/Messaging/Service/Builder/EmailBuilder.php +++ b/src/Domain/Messaging/Service/Builder/EmailBuilder.php @@ -127,7 +127,7 @@ public function applyCampaignHeaders(Email $email, Subscriber $subscriber): Emai $preferencesUrl = $this->configProvider->getValue(ConfigOption::PreferencesUrl) ?? ''; $unsubscribeUrl = $this->configProvider->getValue(ConfigOption::UnsubscribeUrl) ?? ''; $subscribeUrl = $this->configProvider->getValue(ConfigOption::SubscribeUrl) ?? ''; - $adminAddress = $this->configProvider->getValue(ConfigOption::UnsubscribeUrl) ?? ''; + $adminAddress = $this->configProvider->getValue(ConfigOption::AdminAddress) ?? ''; $email->getHeaders()->addTextHeader( 'List-Help', diff --git a/src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php b/src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php index 7f76c637..7a0c35b5 100644 --- a/src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php +++ b/src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php @@ -133,6 +133,7 @@ public function applyCampaignHeaders(Email $email, Subscriber $subscriber): Emai $receivedLine = $this->httpReceivedStampBuilder->buildStamp(); if ($receivedLine !== null) { + $receivedLine = preg_replace('/[\r\n]+/', ' ', $receivedLine); $email->getHeaders()->addTextHeader('Received', $receivedLine); } diff --git a/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php b/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php index d58700f3..855c5950 100644 --- a/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php +++ b/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php @@ -158,6 +158,6 @@ protected function applyContentAndFormatting( // which are wrapped in < and > by HTML2text so add it again } // Ensure text body is always set - $email->text($textMessage); + $email->text($textMessage ?? ''); } } diff --git a/src/Domain/Messaging/Service/MessagePrecacheService.php b/src/Domain/Messaging/Service/MessagePrecacheService.php index 5d866e90..4b7e784e 100644 --- a/src/Domain/Messaging/Service/MessagePrecacheService.php +++ b/src/Domain/Messaging/Service/MessagePrecacheService.php @@ -49,7 +49,7 @@ public function precacheMessage(Message $campaign, $loadedMessageData, ?bool $fo $cacheKey = sprintf('messaging.message.base.%d.%d', $campaign->getId(), (int) $forwardContent); $cached = $this->cache->get($cacheKey); if ($cached !== null) { - return $cached; + return true; } $domain = $this->configProvider->getValue(ConfigOption::Domain); diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index 4da427c6..dd2a8ac2 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -213,6 +213,6 @@ public function getDataById(int $subscriberId): array ->where('s.id = :subscriberId') ->setParameter('subscriberId', $subscriberId) ->getQuery() - ->getArrayResult()[0]; + ->getArrayResult()[0] ?? []; } } diff --git a/src/Migrations/Version20260204094237.php b/src/Migrations/Version20260204094237.php new file mode 100644 index 00000000..8ca27fd9 --- /dev/null +++ b/src/Migrations/Version20260204094237.php @@ -0,0 +1,63 @@ +connection->getDatabasePlatform(); + $this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf( + 'Unsupported platform for this migration: %s', + get_class($platform) + )); + + $this->addSql('ALTER TABLE phplist_linktrack_forward ALTER url TYPE VARCHAR(255)'); + + $this->addSql('ALTER TABLE phplist_message ALTER astext TYPE INT USING astext::integer'); + $this->addSql('ALTER TABLE phplist_message ALTER ashtml TYPE INT USING ashtml::integer'); + $this->addSql('ALTER TABLE phplist_message ALTER aspdf TYPE INT USING aspdf::integer'); + $this->addSql('ALTER TABLE phplist_message ALTER astextandhtml TYPE INT USING astextandhtml::integer'); + $this->addSql('ALTER TABLE phplist_message ALTER astextandpdf TYPE INT USING astextandpdf::integer'); + + $this->addSql('ALTER TABLE phplist_urlcache ALTER url TYPE VARCHAR(255)'); + } + + public function down(Schema $schema): void + { + $platform = $this->connection->getDatabasePlatform(); + $this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf( + 'Unsupported platform for this migration: %s', + get_class($platform) + )); + + $this->addSql('ALTER TABLE phplist_linktrack_forward ALTER url TYPE VARCHAR(2083)'); + + $this->addSql('ALTER TABLE phplist_message ALTER astext TYPE BOOLEAN USING (astext::integer <> 0)'); + $this->addSql('ALTER TABLE phplist_message ALTER ashtml TYPE BOOLEAN USING (ashtml::integer <> 0)'); + $this->addSql('ALTER TABLE phplist_message ALTER aspdf TYPE BOOLEAN USING (aspdf::integer <> 0)'); + $this->addSql('ALTER TABLE phplist_message ALTER astextandhtml TYPE BOOLEAN USING (astextandhtml::integer <> 0)'); + $this->addSql('ALTER TABLE phplist_message ALTER astextandpdf TYPE BOOLEAN USING (astextandpdf::integer <> 0)'); + + $this->addSql('ALTER TABLE phplist_urlcache ALTER url TYPE VARCHAR(2083)'); + } +} diff --git a/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php b/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php index de5fb3d9..00760fa7 100644 --- a/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php +++ b/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php @@ -12,6 +12,7 @@ use PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder; use PhpList\Core\Domain\Subscription\Model\SubscriberList; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; @@ -36,6 +37,7 @@ public function testDoesNothingWhenSendAdminCopiesDisabled(): void configProvider: $configProvider, systemEmailBuilder: $systemEmailBuilder, mailer: $mailer, + logger: $this->createMock(LoggerInterface::class), sendListAdminCopy: true, bounceEmail: 'bounce@example.com', ); @@ -96,6 +98,7 @@ public function testSendsToListOwnersWhenFlagEnabled(): void configProvider: $configProvider, systemEmailBuilder: $systemEmailBuilder, mailer: $mailer, + logger: $this->createMock(LoggerInterface::class), sendListAdminCopy: true, bounceEmail: $bounce, ); @@ -150,6 +153,7 @@ public function testFallsBackToAdminAddressesWhenNoOwnersOrFlagFalse(): void configProvider: $configProvider, systemEmailBuilder: $systemEmailBuilder, mailer: $mailer, + logger: $this->createMock(LoggerInterface::class), // ensure fallback path regardless of list owners sendListAdminCopy: false, bounceEmail: $bounce, diff --git a/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php index 26a6cf67..fb4b8740 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php @@ -386,7 +386,7 @@ function (string $url, string $uid): string { $this->assertSame('List-Unsubscribe=One-Click', $headers->get('List-Unsubscribe-Post')->getBodyAsString()); $this->assertSame('', $headers->get('List-Subscribe')->getBodyAsString()); // In implementation, adminAddress uses UnsubscribeUrl option (likely a bug); we assert the behavior as-is - $this->assertSame('', $headers->get('List-Owner')->getBodyAsString()); + $this->assertSame('', $headers->get('List-Owner')->getBodyAsString()); } public function testAttachmentAdderFailureThrows(): void