Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config/parameters.yml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ parameters:
env(APP_DEV_EMAIL): '[email protected]'
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'

# Email configuration
app.mailer_from: '%%env(MAILER_FROM)%%'
Expand Down
2 changes: 2 additions & 0 deletions config/services/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ services:
PhpList\Core\Domain\Configuration\Service\UserPersonalizer:
autowire: true
autoconfigure: true
arguments:
$preferencePageShowPrivateLists: '%app.preference_page_show_private_lists%'
Comment on lines 123 to +127
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the UserPersonalizer class file
find . -name "*.php" -type f | xargs grep -l "class UserPersonalizer" | head -5

Repository: phpList/core

Length of output: 178


🏁 Script executed:

#!/bin/bash
# Check the UserPersonalizer constructor and parameter usage
ast-grep --pattern 'class UserPersonalizer {
  $$$
  public function __construct($$$) {
    $$$
  }
  $$$
}'

Repository: phpList/core

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Also search for parameters.yml.dist
find . -name "parameters.yml*" -type f

Repository: phpList/core

Length of output: 117


🏁 Script executed:

#!/bin/bash
# Read the UserPersonalizer class to check constructor and parameter handling
cat -n ./src/Domain/Configuration/Service/UserPersonalizer.php

Repository: phpList/core

Length of output: 6367


🏁 Script executed:

#!/bin/bash
# Read parameters.yml.dist to check the parameter definition
cat -n ./config/parameters.yml.dist | grep -A 5 -B 5 "preference_page_show_private_lists"

Repository: phpList/core

Length of output: 595


String-to-boolean conversion will cause incorrect behavior.

The parameter $preferencePageShowPrivateLists is type-hinted as bool (line 28 of UserPersonalizer.php), but the services configuration passes a string value from parameters.yml.dist. In PHP, (bool)'0' evaluates to true (non-empty string), not false. This means the feature will behave opposite to the intended default—always showing private lists when it should hide them.

Use an integer instead of a string in parameters.yml.dist:

env(PREFERENCEPAGE_SHOW_PRIVATE_LISTS): 0

Or apply explicit casting in services.yml using !php/const or a cast operator.

🤖 Prompt for AI Agents
In config/services/services.yml around lines 123 to 127, the
$preferencePageShowPrivateLists argument is receiving a string parameter which
will convert to true in PHP even for "0"; change the wiring so a boolean is
passed: either update parameters.yml.dist to supply an integer/env var (e.g. use
env(...) with 0/1) or explicitly cast/resolve the parameter to a boolean in
services.yml (use a PHP cast or a proper YAML PHP constant resolution) so the
UserPersonalizer receives a real bool not a non-empty string.


PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder:
autowire: true
Expand Down
11 changes: 11 additions & 0 deletions src/Domain/Configuration/Model/OutputFormat.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace PhpList\Core\Domain\Configuration\Model;

enum OutputFormat: string
{
case Html = 'html';
case Text = 'text';
}
76 changes: 68 additions & 8 deletions src/Domain/Configuration/Service/UserPersonalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
namespace PhpList\Core\Domain\Configuration\Service;

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\Subscription\Repository\SubscriberAttributeValueRepository;
use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository;
use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
use PhpList\Core\Domain\Subscription\Service\Resolver\AttributeValueResolver;
use Symfony\Contracts\Translation\TranslatorInterface;

class UserPersonalizer
{
Expand All @@ -19,11 +22,14 @@ public function __construct(
private readonly LegacyUrlBuilder $urlBuilder,
private readonly SubscriberRepository $subscriberRepository,
private readonly SubscriberAttributeValueRepository $attributesRepository,
private readonly AttributeValueResolver $attributeValueResolver
private readonly AttributeValueResolver $attributeValueResolver,
private readonly SubscriberListRepository $subscriberListRepository,
private readonly TranslatorInterface $translator,
private readonly bool $preferencePageShowPrivateLists = false
) {
}

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) {
Expand All @@ -33,18 +39,49 @@ public function personalize(string $value, string $email): string
$resolver = new PlaceholderResolver();
$resolver->register('EMAIL', fn() => $user->getEmail());

$resolver->register('UNSUBSCRIBEURL', function () use ($user) {
$resolver->register('UNSUBSCRIBEURL', function () use ($user, $format) {
$base = $this->config->getValue(ConfigOption::UnsubscribeUrl) ?? '';
return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE;
$url = $this->urlBuilder->withUid($base, $user->getUniqueId());

if ($format === OutputFormat::Html) {
$label = $this->translator->trans('Unsubscribe');
$safeUrl = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$safeLabel = htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

return '<a href="' . $safeUrl . '">' . $safeLabel . '</a>' . self::PHP_SPACE;
}

return $url . self::PHP_SPACE;
});

$resolver->register('CONFIRMATIONURL', function () use ($user) {
$resolver->register('CONFIRMATIONURL', function () use ($user, $format) {
$base = $this->config->getValue(ConfigOption::ConfirmationUrl) ?? '';
return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE;
$url = $this->urlBuilder->withUid($base, $user->getUniqueId());

if ($format === OutputFormat::Html) {
$label = $this->translator->trans('Confirm');
$safeUrl = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$safeLabel = htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

return '<a href="' . $safeUrl . '">' . $safeLabel . '</a>' . self::PHP_SPACE;
}

return $url . self::PHP_SPACE;
});
$resolver->register('PREFERENCESURL', function () use ($user) {

$resolver->register('PREFERENCESURL', function () use ($user, $format) {
$base = $this->config->getValue(ConfigOption::PreferencesUrl) ?? '';
return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE;
$url = $this->urlBuilder->withUid($base, $user->getUniqueId());

if ($format === OutputFormat::Html) {
$label = $this->translator->trans('Update preferences');
$safeUrl = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$safeLabel = htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

return '<a href="' . $safeUrl . '">' . $safeLabel . '</a>' . self::PHP_SPACE;
}

return $url . self::PHP_SPACE;
});

$resolver->register(
Expand All @@ -54,6 +91,29 @@ public function personalize(string $value, string $email): string
$resolver->register('DOMAIN', fn() => $this->config->getValue(ConfigOption::Domain) ?? '');
$resolver->register('WEBSITE', fn() => $this->config->getValue(ConfigOption::Website) ?? '');

$resolver->register('LISTS', function () use ($user, $format) {
$names = $this->subscriberListRepository->getActiveListNamesForSubscriber(
subscriber: $user,
showPrivate: $this->preferencePageShowPrivateLists
);

if ($names === []) {
return $this->translator
->trans('Sorry, you are not subscribed to any of our newsletters with this email address.');
}

$separator = $format === OutputFormat::Html ? '<br/>' : "\n";

if ($format === OutputFormat::Html) {
$names = array_map(
static fn(string $name) => htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
$names
);
}

return implode($separator, $names);
});

$userAttributes = $this->attributesRepository->getForSubscriber($user);
foreach ($userAttributes as $userAttribute) {
$resolver->register(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,8 @@
#[AsMessageHandler]
class AsyncEmailMessageHandler
{
private EmailService $emailService;

public function __construct(EmailService $emailService)
public function __construct(private readonly EmailService $emailService)
{
$this->emailService = $emailService;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use PhpList\Core\Domain\Configuration\Model\OutputFormat;
use PhpList\Core\Domain\Configuration\Service\UserPersonalizer;
use PhpList\Core\Domain\Messaging\Exception\MessageCacheMissingException;
use PhpList\Core\Domain\Messaging\Exception\MessageSizeLimitExceededException;
Expand Down Expand Up @@ -195,15 +196,20 @@ private function handleEmailSending(
MessagePrecacheDto $precachedContent,
): void {
$processed = $this->messagePreparator->processMessageLinks(
$campaign->getId(),
$precachedContent,
$subscriber
campaignId: $campaign->getId(),
cachedMessageDto: $precachedContent,
subscriber: $subscriber
);
$processed->textContent = $this->userPersonalizer->personalize(
$processed->textContent,
$subscriber->getEmail(),
value: $processed->textContent,
email: $subscriber->getEmail(),
format:OutputFormat::Text,
);
$processed->footer = $this->userPersonalizer->personalize(
value: $processed->footer,
email: $subscriber->getEmail(),
format: OutputFormat::Text,
);
$processed->footer = $this->userPersonalizer->personalize($processed->footer, $subscriber->getEmail());

try {
$email = $this->rateLimitedCampaignMailer->composeEmail($campaign, $subscriber, $processed);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,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,
private readonly string $passwordResetUrl
) {
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,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,
private readonly string $confirmationUrl
) {
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

/**
Expand All @@ -47,14 +37,19 @@ 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());
$listOfLists = $this->getListNames($message->getListIds());
$replacedTextContent = str_replace('[LISTS]', $listOfLists, $personalizedTextContent);
$replacedTextContent = str_replace('[LISTS]', $listOfLists, $textContent);

$personalizedTextContent = $this->userPersonalizer->personalize(
value: $replacedTextContent,
email: $message->getUniqueId(),
format: OutputFormat::Text,
);
Comment on lines +41 to +47
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: [LISTS] replacement conflicts with UserPersonalizer.

The flow now replaces [LISTS] before calling personalize(), but UserPersonalizer expects to handle the [LISTS] placeholder itself (see lines 94-115 in UserPersonalizer.php). This creates duplication and breaks the expected behavior.

The pipeline failure confirms this:

Expected 'Hi {{name}}, you subscribed to: [LISTS]' but got 'Hi {{name}}, you subscribed to: '

The test expects [LISTS] to be present when personalize() is called so that UserPersonalizer can handle the replacement with proper formatting.

🔎 Proposed fix

Remove the manual [LISTS] replacement and let UserPersonalizer handle it:

     $subject = $this->configProvider->getValue(ConfigOption::SubscribeEmailSubject);
     $textContent = $this->configProvider->getValue(ConfigOption::SubscribeMessage);
-    $listOfLists = $this->getListNames($message->getListIds());
-    $replacedTextContent = str_replace('[LISTS]', $listOfLists, $textContent);
-
-    $personalizedTextContent = $this->userPersonalizer->personalize(
-        value: $replacedTextContent,
+    
+    $personalizedTextContent = $this->userPersonalizer->personalize(
+        value: $textContent,
         email: $message->getUniqueId(),
         format: OutputFormat::Text,
     );

If you need to ensure the list IDs are available to the personalizer, that should be handled through the subscriber's subscriptions rather than passing them separately.

Also applies to: 52-52, 61-61

🧰 Tools
🪛 GitHub Actions: phpList Core Build

[error] 46-46: Expectation failed for method name is "personalize" when invoked 1 time(s). Parameter 0 for invocation UserPersonalizer::personalize('Hi {{name}}, you subscribed to: ', 'user-123', OutputFormat): string does not match expected value. Expected 'Hi {{name}}, you subscribed to: [LISTS]' but got 'Hi {{name}}, you subscribed to: '

🤖 Prompt for AI Agents
In
src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php
around lines 41-47 (and similarly at lines 52 and 61), the code is pre-replacing
the [LISTS] placeholder before calling UserPersonalizer which causes duplication
and breaks personalization; remove the manual str_replace calls that replace
[LISTS] so you call $this->userPersonalizer->personalize(...) with the original
text containing [LISTS], and ensure any needed list/subscription data is
available through the subscriber model passed into the personalizer rather than
passing or injecting the replaced string.


$email = (new Email())
->to($message->getEmail())
->subject($subject)
->text($replacedTextContent);
->text($personalizedTextContent);

$this->emailService->sendEmail($email);

Expand All @@ -63,14 +58,6 @@ 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();
}
}

return implode(', ', $listNames);
return implode(', ', $this->subscriberListRepository->getListNames($listIds));
}
}
2 changes: 1 addition & 1 deletion src/Domain/Messaging/Service/MessagePrecacheService.php
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ private function applyRemoteContentIfPresent(MessagePrecacheDto $messagePrecache
} else {
$this->eventLogManager->log(
page: 'unknown page',
entry: 'Error fetching URL: '.$loadedMessageData['sendurl'].' cannot proceed',
entry: 'Error fetching URL: ' . $loadedMessageData['sendurl'] . ' cannot proceed',
);

return false;
Expand Down
38 changes: 38 additions & 0 deletions src/Domain/Subscription/Repository/SubscriberListRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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
{
$qb = $this->createQueryBuilder('l')
->select('l.name')
->innerJoin('l.subscriptions', 's')
->where('IDENTITY(s.subscriber) = :subscriberId')
->setParameter('subscriberId', $subscriber->getId());

if (!$showPrivate) {
$qb->andWhere('l.active = true');
}

$rows = $qb->getQuery()->getScalarResult();

return array_column($rows, 'name');
}
}
Loading
Loading