Skip to content

Conversation

@TatevikGr
Copy link
Contributor

@TatevikGr TatevikGr commented Dec 30, 2025

Summary by CodeRabbit

  • New Features

    • PDF generation for messages, tracking-pixel user tracking, and richer attachment handling with downloadable copies.
    • Unified email builders for consistent message composition and per-subscriber remote-content fetching.
  • Improvements

    • Expanded placeholder-driven personalization (many URL/list resolvers, context-aware HTML/text rendering).
    • Better remote-content precaching, flexible output formats (HTML/Text/PDF), and additional configuration options and translations.
  • Tests

    • Unit tests updated to reflect builder and placeholder refactors.

✏️ Tip: You can customize this high-level summary in your review settings.

Thanks for contributing to phpList!

@coderabbitai
Copy link

coderabbitai bot commented Dec 30, 2025

📝 Walkthrough

Walkthrough

Introduces a context-aware placeholder system, DTOs/enums, mail content builders and constructors, attachment/pdf support, extensive DI/service rewiring, message-format refactors, repository helpers, new exceptions/helpers, many tests updated, and a new composer runtime dependency.

Changes

Cohort / File(s) Summary
Config & Parameters
config/parameters.yml.dist, config/services/parameters.yml
Added env-backed params (rest API domain, preference flag, messaging flags, attachment URL/path) and flattened app.config.* keys for DI.
Service wiring
config/services/*.yml (services.yml, builders.yml, messenger.yml, resolvers.yml, managers.yml, repositories.yml, providers.yml, parameters.yml)
Large DI reorganization: _defaults, many new/autowired services (builders/constructors/resolvers), tagging for resolvers, handler regrouping, manager consolidation, repo additions, and public/export adjustments.
Placeholder core & DTOs
src/Domain/Configuration/Service/PlaceholderResolver.php, src/Domain/Configuration/Model/Dto/PlaceholderContext.php, src/Domain/Configuration/Model/OutputFormat.php
New PlaceholderContext and OutputFormat; Resolver refactored to support exact, pattern and supporting resolvers and context-aware resolution.
Placeholder contracts & resolvers
src/Domain/Configuration/Service/Placeholder/*
Added interfaces (PlaceholderValueResolverInterface, PatternValueResolverInterface, SupportingPlaceholderResolverInterface) and many resolvers (UNSUBSCRIBE*, CONFIRMATION*, PREFERENCES*, SUBSCRIBE*, FORWARD*, BLACKLIST*, LISTS, SIGNATURE, USERTRACK, JUMPOFF*, CONTACT*, FOOTER, ForwardMessageId, etc.) handling HTML/text.
Placeholder processor
src/Domain/Configuration/Service/MessagePlaceholderProcessor.php
New processor composing resolvers, loading attributes, ensuring standard placeholders (FOOTER/SIGNATURE/USERTRACK) and producing resolved content per PlaceholderContext.
Mail content builders & interface
src/Domain/Messaging/Service/Constructor/*
New MailContentBuilderInterface plus SystemMailContentBuilder and CampaignMailContentBuilder producing [html,text] from MessagePrecacheDto, handling templates, remote content and formatting.
Email builders & mailer
src/Domain/Messaging/Service/Builder/*, src/Domain/Messaging/Service/RateLimitedCampaignMailer.php
EmailBuilder refactored to data-driven API (returns Email + OutputFormat), content/formatting/pdf/attachment paths added; SystemEmailBuilder added; mailer simplified to send prepared Email with rate-limiter.
Attachments & helpers
src/Domain/Messaging/Service/AttachmentAdder.php, src/Domain/Messaging/Repository/AttachmentRepository.php, src/Domain/Messaging/Exception/*, src/Domain/Common/{PdfGenerator.php,OnceCacheGuard.php,FileHelper.php}
New AttachmentAdder with copy/size/throttled logging, attachment exceptions, repo query helper, PdfGenerator (FPDF), OnceCacheGuard, and FileHelper utilities.
Precache / remote fetch
src/Domain/Messaging/Service/MessagePrecacheService.php, src/Domain/Common/RemotePageFetcher.php, src/Domain/Messaging/Exception/RemotePageFetchException.php
Precache flow now always considers remote tokens; fetch/error handling adjusted; new RemotePageFetchException; RemotePageFetcher returns empty string instead of null.
Models & DTOs
src/Domain/Messaging/Model/*, src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php
MessageFormat: booleans → integer counters with incrementors; Message::incrementSentCount(OutputFormat) added; MessagePrecacheDto->content made non-nullable.
Handlers & refactors
src/Domain/Messaging/MessageHandler/*, src/Domain/Subscription/Service/SubscriberCsvImporter.php
Campaign processing moved to builder-based flow with improved error handling (attachment copy), many handlers refactored to PHP 8 constructor promotion and DI attribute usage.
Repositories
src/Domain/Subscription/Repository/*, src/Domain/Messaging/Repository/*
Added helpers: getListNames, getActiveListNamesForSubscriber, getDataById, findAttachmentsForMessage.
Namespace & manager moves
src/Domain/Identity/Service/Manager/*, config/services/managers.yml, config/services/providers.yml
Identity services moved under Service\Manager namespace; manager services consolidated via domain resource; minor provider wiring changes.
Tests & translations
tests/Unit/**, tests/Integration/**, resources/translations/messages.en.xlf
Many tests updated to DTO/resolver/builder flows and namespace moves; new translations added.
Composer
composer.json
Added runtime dependency setasign/fpdf:^1.8.

Sequence Diagram(s)

sequenceDiagram
  participant Proc as CampaignProcessor
  participant Pre as MessagePrecacheService
  participant Builder as CampaignMailContentBuilder
  participant Placeholder as MessagePlaceholderProcessor
  participant EmailB as SystemEmailBuilder / EmailBuilder
  participant Mailer as RateLimitedCampaignMailer
  participant Repo as SubscriberRepository

  Proc->>Pre: request precache -> MessagePrecacheDto
  Pre->>Builder: build html + text (may fetch remote content)
  Builder->>Repo: load subscriber data
  Builder-->>Pre: return [html, text]
  Proc->>Placeholder: resolve placeholders (PlaceholderContext per subscriber)
  Placeholder->>Repo: load attributes/supporting data
  Placeholder-->>Proc: resolved content
  Proc->>EmailB: buildPhplistEmail(messageId, MessagePrecacheDto, htmlPref,...)
  EmailB-->>Mailer: send(Email, OutputFormat)
  Mailer->>Mailer: awaitTurn (rate limiter) / dispatch
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Feat/email building with attachments' is directly related to the main changes in the PR, which extensively refactor email construction and introduce attachment handling capabilities.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

coderabbitai[bot]

This comment was marked as outdated.

coderabbitai[bot]

This comment was marked as outdated.

coderabbitai[bot]

This comment was marked as outdated.

coderabbitai[bot]

This comment was marked as outdated.

coderabbitai[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as outdated.

@TatevikGr TatevikGr changed the title Feat/user personalizer Feat/email building with attachments Jan 16, 2026
@TatevikGr TatevikGr force-pushed the feat/user-personalizer branch from 0e21e56 to 5fd4e4a Compare January 16, 2026 09:32
coderabbitai[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

@TatevikGr TatevikGr force-pushed the feat/user-personalizer branch from 6a31c89 to 19f224b Compare January 21, 2026 09:03
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@config/parameters.yml.dist`:
- Around line 135-138: The two parameter entries phplist.attachment_download_url
and phplist.attachment_repository_path use single %env(...)% placeholders while
the rest of the template uses the escaped form %%env(...)%%; update those
entries to use the double-percent escaping (i.e., change
%env(PHPLIST_ATTACHMENT_DOWNLOAD_URL)% ->
%%env(PHPLIST_ATTACHMENT_DOWNLOAD_URL)%% and
%env(PHPLIST_ATTACHMENT_REPOSITORY_PATH)% ->
%%env(PHPLIST_ATTACHMENT_REPOSITORY_PATH)%%) so the template remains consistent
with the parameter handler’s expected escaping.

In `@resources/translations/messages.en.xlf`:
- Around line 777-780: Remove the duplicate translation unit for the message
with resname="Insufficient memory to add attachment to campaign %campaignId%
%totalSize% - %memLimit%": locate the two <trans-unit> entries with ids QLlIQQh
and 28lDkjt and delete one of them (keep the other intact) so only a single
translation unit with that resname/source/target remains; ensure the remaining
entry preserves the existing <source> and <target> values and that XML remains
well-formed after removal.

In `@src/Domain/Common/FileHelper.php`:
- Around line 36-59: In writeFileToDirectory: avoid orphaning the temp file
created by tempnam by either renaming the temp file to the extension-added name
(use rename(tempPath, newPath)) or generate the final filename first and create
that directly; open the file in binary mode ('wb') instead of 'w'; check return
values of fopen, fwrite and fclose (ensure fopen did not return false, check
fwrite bytes written equals strlen($contents)), delete any created temp/orphan
file on failure, and only return the full path when write and close succeed;
reference tempnam, rename/basename, fopen, fwrite and fclose to locate and
update the logic.

In `@src/Domain/Configuration/Service/Placeholder/ContactUrlValueResolver.php`:
- Around line 22-28: The HTML escaping in ContactUrlValueResolver::__invoke uses
htmlspecialchars() with defaults; update the non-text branch to call
htmlspecialchars($this->config->getValue(ConfigOption::VCardUrl) ?? '',
ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') so escaping flags and encoding are
explicit and consistent with other URL resolvers (leave the text branch
returning the raw value via PlaceholderContext::isText as-is).

In `@src/Domain/Messaging/Service/AttachmentAdder.php`:
- Around line 177-196: copyAttachmentToRepository in AttachmentAdder stores the
full path returned by writeFileToDirectory into $att->setFilename(), causing
attachFromRepository to later prepend the repository path again and produce
duplicated paths; change copyAttachmentToRepository to extract and store only
the basename/filename (not the full path) returned by writeFileToDirectory
before calling $att->setFilename(), preserve existing error handling via
handleCopyFailure, and ensure attachFromRepository continues to reconstruct the
full path by concatenating $this->attachmentRepositoryPath and the stored
filename.
♻️ Duplicate comments (6)
src/Domain/Subscription/Repository/SubscriberRepository.php (1)

209-217: Unsafe array access will throw an exception when subscriber doesn't exist.

Accessing [0] on getArrayResult() will cause an Undefined array key 0 error if no subscriber matches the ID. This is a runtime crash waiting to happen.

🐛 Proposed fix using getOneOrNullResult
 public function getDataById(int $subscriberId): array
 {
-    return $this->createQueryBuilder('s')
+    $result = $this->createQueryBuilder('s')
         ->select('s')
         ->where('s.id = :subscriberId')
         ->setParameter('subscriberId', $subscriberId)
         ->getQuery()
-        ->getArrayResult()[0];
+        ->getOneOrNullResult(\Doctrine\ORM\Query::HYDRATE_ARRAY);
+
+    return $result ?? [];
 }
resources/translations/messages.en.xlf (2)

753-772: The __ prefix issue was already flagged.

This was covered in a previous review comment. The target values should match the source values exactly for an English-to-English translation file.


773-812: Remove the __ prefix from remaining target values.

Same issue as the earlier entries - these target values incorrectly prefix with __. For an English-to-English translation file (where source-language="en" and target-language="en"), targets should match sources. The __ prefix will display to users as-is in the UI.

Suggested fix
       <trans-unit id="6LBFI8k" resname="This message contains attachments that can be viewed with a webbrowser">
         <source>This message contains attachments that can be viewed with a webbrowser</source>
-        <target>__This message contains attachments that can be viewed with a webbrowser</target>
+        <target>This message contains attachments that can be viewed with a webbrowser</target>
       </trans-unit>
       <trans-unit id="QLlIQQh" resname="Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%">
         <source>Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%</source>
-        <target>__Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%</target>
+        <target>Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%</target>
       </trans-unit>

Apply the same pattern (remove __ prefix) to all target values in lines 781-812.

src/Domain/Messaging/Service/AttachmentAdder.php (1)

59-63: Guard against empty To list before indexing.
Line 60 assumes at least one recipient; an empty array will throw.

🐛 Defensive fix
-                    $userEmail = $email->getTo()[0]->getAddress();
+                    $toRecipients = $email->getTo();
+                    if (empty($toRecipients)) {
+                        $this->eventLogManager->log(
+                            page: '',
+                            entry: $this->translator->trans(
+                                'Cannot add text attachment link - no recipient set for campaign %campaignId%',
+                                ['%campaignId%' => $campaignId]
+                            )
+                        );
+                        return false;
+                    }
+                    $userEmail = $toRecipients[0]->getAddress();
src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php (1)

233-256: Missing null check on system email in AttachmentCopyException handler.

The systemEmailBuilder->buildPhplistEmail() call at lines 243-248 can return null, but line 252 accesses $email->getTo()[0]->getAddress() without checking. This will throw a TypeError if $email is null.

Proposed fix
             $email = $this->systemEmailBuilder->buildPhplistEmail(
                 messageId: $campaign->getId(),
                 data: $data,
                 inBlast: false,
                 htmlPref: true,
             );
+
+            if ($email === null) {
+                $this->logger->error('Failed to build system error email for attachment exception');
+                throw $e;
+            }

             $envelope = new Envelope(
                 sender: new Address($this->bounceEmail, 'PHPList'),
                 recipients: [new Address($email->getTo()[0]->getAddress())],
             );
src/Domain/Messaging/Service/Builder/EmailBuilder.php (1)

117-137: Copy-paste bug: $adminAddress uses wrong config key.

Line 122 fetches the admin address using ConfigOption::UnsubscribeUrl instead of ConfigOption::AdminAddress. This means List-Owner header will contain the unsubscribe URL rather than an admin email address.

Proposed fix
-        $adminAddress = $this->configProvider->getValue(ConfigOption::UnsubscribeUrl) ?? '';
+        $adminAddress = $this->configProvider->getValue(ConfigOption::AdminAddress) ?? '';
🧹 Nitpick comments (3)
src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php (1)

65-76: N+1 query pattern — repository already has a batch method.

This loops and hits the DB for each list ID. The SubscriberListRepository already provides a getListNames(array $listIds) method that fetches all names in a single query.

♻️ Proposed fix using the existing batch method
     private function getListNames(array $listIds): string
     {
-        $names = [];
-        foreach ($listIds as $listId) {
-            $list = $this->subscriberListRepository->find($listId);
-            if ($list !== null) {
-                $names[] = $list->getName();
-            }
-        }
-
-        return implode(', ', $names);
+        $names = $this->subscriberListRepository->getListNames($listIds);
+        return implode(', ', $names);
     }
src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php (1)

91-93: TODO for CLICKTRACK still pending.
Line 92 leaves a TODO in the send path—worth tracking to avoid regressions later.

If you want, I can draft the CLICKTRACK insertion logic or open a tracking issue.

src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php (1)

52-91: Looks good overall, but consider cleaning up commented code.

The buildPhplistEmail method flow is solid. However, lines 69-70 have commented-out reply-to logic. If this is intentional (not needed for system emails), consider removing it to keep the code clean. If it's a TODO, maybe add a comment explaining why.

Comment on lines +777 to +780
<trans-unit id="QLlIQQh" resname="Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%">
<source>Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%</source>
<target>__Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%</target>
</trans-unit>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine the translation file
fd "messages.en.xlf" -type f

# Check the size and examine the specific lines mentioned
wc -l resources/translations/messages.en.xlf

Repository: phpList/core

Length of output: 286


🏁 Script executed:

# Now let's check for both duplicate entries in the file
cat -n resources/translations/messages.en.xlf | sed -n '770,810p'

Repository: phpList/core

Length of output: 3685


🏁 Script executed:

# Search for usage of both IDs in the codebase (all file types, not just PHP)
echo "Searching for usage of ID QLlIQQh:"
rg -n "QLlIQQh" -t php -t js -t ts

echo ""
echo "Searching for usage of ID 28lDkjt:"
rg -n "28lDkjt" -t php -t js -t ts

echo ""
echo "Searching for references to translation keys (broader search):"
rg -n "QLlIQQh|28lDkjt" --type-list | head -20

Repository: phpList/core

Length of output: 597


🏁 Script executed:

# Search more broadly - check if these resnames or IDs are used anywhere in the codebase
echo "Searching for resname pattern in all files:"
rg -n "Insufficient memory to add attachment to campaign" 

echo ""
echo "Searching for the ID pattern in all files:"
rg -n "QLlIQQh|28lDkjt"

Repository: phpList/core

Length of output: 1627


Remove duplicate translation entry.

Two identical translation units exist for the same message ("Insufficient memory to add attachment to campaign...") with different IDs: QLlIQQh (line 777) and 28lDkjt (line 797). Both have the same resname, source, and target. The message is used in the codebase but neither ID is directly referenced—only the string content matters. Remove one of these entries to eliminate confusion.

Suggested fix - remove duplicate
-      <trans-unit id="28lDkjt" resname="Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%">
-        <source>Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%</source>
-        <target>__Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%</target>
-      </trans-unit>
🤖 Prompt for AI Agents
In `@resources/translations/messages.en.xlf` around lines 777 - 780, Remove the
duplicate translation unit for the message with resname="Insufficient memory to
add attachment to campaign %campaignId% %totalSize% - %memLimit%": locate the
two <trans-unit> entries with ids QLlIQQh and 28lDkjt and delete one of them
(keep the other intact) so only a single translation unit with that
resname/source/target remains; ensure the remaining entry preserves the existing
<source> and <target> values and that XML remains well-formed after removal.

Comment on lines +22 to +28
public function __invoke(PlaceholderContext $ctx): string
{
if ($ctx->isText()) {
return $this->config->getValue(ConfigOption::VCardUrl) ?? '';
}

return htmlspecialchars($this->config->getValue(ConfigOption::VCardUrl) ?? '');
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

Harden HTML escaping to match other URL resolvers.

This uses htmlspecialchars() with default flags/encoding, which is inconsistent with other resolvers and can produce unsafe or incorrect escaping depending on ini defaults. Use explicit flags/UTF‑8 like the rest of the URL resolvers.

🔧 Proposed fix
     public function __invoke(PlaceholderContext $ctx): string
     {
-        if ($ctx->isText()) {
-            return $this->config->getValue(ConfigOption::VCardUrl) ?? '';
-        }
+        $url = $this->config->getValue(ConfigOption::VCardUrl) ?? '';
+        if ($ctx->isText()) {
+            return $url;
+        }
 
-        return htmlspecialchars($this->config->getValue(ConfigOption::VCardUrl) ?? '');
+        return htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
     }
🤖 Prompt for AI Agents
In `@src/Domain/Configuration/Service/Placeholder/ContactUrlValueResolver.php`
around lines 22 - 28, The HTML escaping in ContactUrlValueResolver::__invoke
uses htmlspecialchars() with defaults; update the non-text branch to call
htmlspecialchars($this->config->getValue(ConfigOption::VCardUrl) ?? '',
ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') so escaping flags and encoding are
explicit and consistent with other URL resolvers (leave the text branch
returning the raw value via PlaceholderContext::isText as-is).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@resources/translations/messages.en.xlf`:
- Around line 773-812: Multiple new trans-unit targets (ids 6LBFI8k, QLlIQQh,
HhEWxtR, TNQR4ZC, _DfkneV, VXRuG2l, 28lDkjt, DmqU7IE, 021aois, FbYZdLJ)
mistakenly include a "__" prefix; remove the leading "__" from each <target>
value so the target text exactly matches the <source> text for those trans-units
(e.g., update trans-unit id 6LBFI8k target to "This message contains attachments
that can be viewed with a webbrowser" and likewise for the others). Ensure you
update every listed trans-unit target (not just the first few) so none retain
the "__" prefix.

In `@src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php`:
- Around line 31-35: The return from preg_replace in SignatureValueResolver::
(the line returning preg_replace(...
$this->config->getValue(ConfigOption::PoweredByImage) ?? '')) can be null on
error and the method is declared to return string; update the return so you
defensively ensure a string (e.g., capture the preg_replace result into a
variable and return (is_string($result) ? $result : '') or coalesce with an
empty string) so the method never returns null while keeping the existing
replacement logic intact.

In `@src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php`:
- Around line 36-55: The validateRecipientAndSubject method currently only
checks for "\n" characters, leaving "\r" vulnerability for header injection;
update the checks in validateRecipientAndSubject (used in BaseEmailBuilder) to
detect both CR and LF characters (e.g. use a pattern that matches "\r" or "\n")
for both $to and $subject and keep the same eventLogManager->log messages and
early returns when invalid values are found.
♻️ Duplicate comments (10)
src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php (1)

35-45: URL-encode the uid parameter to prevent broken links.

The uid value from $ctx->getUser()->getUniqueId() is concatenated directly into the URL without encoding. If the uid contains URL-special characters (&, =, ?, etc.), the link will be malformed.

🔧 Suggested fix
-            return '<a href="'
-                . $safeUrl
-                . htmlspecialchars($sep)
-                . 'uid='
-                . $ctx->getUser()->getUniqueId()
-                . '">'
-                . $safeLabel
-                . '</a> ';
+            return '<a href="'
+                . $safeUrl
+                . htmlspecialchars($sep)
+                . 'uid='
+                . rawurlencode($ctx->getUser()->getUniqueId())
+                . '">'
+                . $safeLabel
+                . '</a> ';
         }

-        return sprintf('%s%suid=%s', $url, $sep, $ctx->getUser()->getUniqueId());
+        return sprintf('%s%suid=%s', $url, $sep, rawurlencode($ctx->getUser()->getUniqueId()));
src/Domain/Common/FileHelper.php (1)

36-60: Binary attachments still at risk: orphan temp file and text-mode write.

This method has interconnected bugs that can corrupt binary attachments and leave orphan files:

  1. Orphan temp file: tempnam() creates a real file on disk at line 42. Appending the extension at line 48 only modifies the $newFile variable—the original temp file remains. Then you write to a different path constructed at line 52, leaving the original orphaned.

  2. Text-mode write: Line 54 uses 'w' instead of 'wb', which can corrupt binary data on Windows due to \r\n conversion.

  3. Unchecked I/O: fopen() and fwrite() return values aren't checked—if either fails, you still return the path as if it succeeded.

🔧 Suggested fix
     public function writeFileToDirectory(string $directory, string $originalFilename, string $contents): ?string
     {
         $pathInfo = pathinfo($originalFilename);
         $name = $pathInfo['filename'] === '' ? 'file' : $pathInfo['filename'];
         $ext = $pathInfo['extension'] ?? '';

-        $newFile = tempnam($directory, $name);
-        if ($newFile === false) {
+        $tmpFile = tempnam($directory, $name);
+        if ($tmpFile === false) {
             return null;
         }

+        $fullPath = $tmpFile;
         if ($ext !== '') {
-            $newFile .= '.' . $ext;
+            $fullPath = $tmpFile . '.' . $ext;
+            if (!rename($tmpFile, $fullPath)) {
+                `@unlink`($tmpFile);
+                return null;
+            }
         }
-        $relativeName = basename($newFile);
-
-        $fullPath = $directory . '/' . $relativeName;

-        $fileHandle = fopen($fullPath, 'w');
-
-        fwrite($fileHandle, $contents);
-        fclose($fileHandle);
+        $fileHandle = fopen($fullPath, 'wb');
+        if ($fileHandle === false) {
+            `@unlink`($fullPath);
+            return null;
+        }
+
+        try {
+            if (fwrite($fileHandle, $contents) === false) {
+                `@unlink`($fullPath);
+                return null;
+            }
+        } finally {
+            fclose($fileHandle);
+        }

         return $fullPath;
     }
config/parameters.yml.dist (1)

135-138: Use the same env-placeholder escaping as the rest of the template.
These two entries still use single %env(...)% while the file consistently uses %%env(...)%%.

🔧 Suggested fix
-    phplist.attachment_download_url: '%env(PHPLIST_ATTACHMENT_DOWNLOAD_URL)%'
+    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)%'
+    phplist.attachment_repository_path: '%%env(PHPLIST_ATTACHMENT_REPOSITORY_PATH)%%'
     env(PHPLIST_ATTACHMENT_REPOSITORY_PATH): '/tmp'
src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php (1)

126-139: Avoid duplicate List-Unsubscribe headers for campaign sends.
Campaign emails also add a UID-based List-Unsubscribe header in applyCampaignHeaders, so the base header causes duplicates. Also guard for empty config values.

🔧 Gate base header to non-blast + non-empty URL
-        $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,
-                ])
-            )
-        );
+        $removeUrl = $this->configProvider->getValue(ConfigOption::UnsubscribeUrl);
+        if (!$inBlast && !empty($removeUrl)) {
+            $sep = !str_contains($removeUrl, '?') ? '?' : '&';
+            $email->getHeaders()->addTextHeader(
+                'List-Unsubscribe',
+                sprintf(
+                    '<%s%s%s>',
+                    $removeUrl,
+                    $sep,
+                    http_build_query([
+                        'email' => $destinationEmail,
+                        'jo' => 1,
+                    ])
+                )
+            );
+        }
src/Domain/Messaging/Service/AttachmentAdder.php (2)

59-68: Guard against empty recipients before indexing getTo().
$email->getTo()[0] will throw if no recipients are set.

🔧 Defensive fix
-                    $userEmail = $email->getTo()[0]->getAddress();
+                    $toRecipients = $email->getTo();
+                    if (empty($toRecipients)) {
+                        $this->eventLogManager->log(
+                            page: '',
+                            entry: $this->translator->trans(
+                                'Cannot add text attachment link - no recipient set for campaign %campaignId%',
+                                ['%campaignId%' => $campaignId]
+                            )
+                        );
+                        return false;
+                    }
+                    $userEmail = $toRecipients[0]->getAddress();
                     // todo: add endpoint in rest-api project
                     $viewUrl = $this->attachmentDownloadUrl . '/?id=' . $att->getId() . '&uid=' . $userEmail;

184-195: Store only the filename, not the full path.
writeFileToDirectory() returns a full path, which later gets prefixed again.

🔧 Suggested fix
-        $relativeName = $this->fileHelper->writeFileToDirectory(
+        $fullPath = $this->fileHelper->writeFileToDirectory(
             directory: $this->attachmentRepositoryPath,
             originalFilename: $remoteFile,
             contents: $contents
         );
 
-        if ($relativeName === null) {
+        if ($fullPath === null) {
             $this->handleCopyFailure($remoteFile, $campaignId, $key);
             return;
         }
 
-        $att->setFilename($relativeName);
+        $att->setFilename(basename($fullPath));
src/Domain/Messaging/Service/Builder/EmailBuilder.php (1)

119-123: Admin address uses the wrong config key.
$adminAddress should come from ConfigOption::AdminAddress, not UnsubscribeUrl.

🔧 Fix the config key
-        $adminAddress = $this->configProvider->getValue(ConfigOption::UnsubscribeUrl) ?? '';
+        $adminAddress = $this->configProvider->getValue(ConfigOption::AdminAddress) ?? '';
resources/translations/messages.en.xlf (2)

753-772: Remove the __ prefix from target values.

These new translation units have __ prefixed to their target values (e.g., __Unsubscribe, __This link). For an English-to-English translation file, targets should match their sources exactly. The __ prefix will display to users as-is, breaking UI text.

Examples needing fixes
-        <target>__Unsubscribe</target>
+        <target>Unsubscribe</target>

-        <target>__This link</target>
+        <target>This link</target>

-        <target>__Confirm</target>
+        <target>Confirm</target>

777-780: Remove duplicate translation entry.

There are two identical translation units for "Insufficient memory to add attachment to campaign..." with different IDs (QLlIQQh at line 777 and 28lDkjt at line 797). They have the same resname, source, and target. Remove one to eliminate confusion.

Suggested fix - remove the duplicate
-      <trans-unit id="28lDkjt" resname="Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%">
-        <source>Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%</source>
-        <target>__Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%</target>
-      </trans-unit>

Also applies to: 797-800

src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php (1)

243-254: Missing null check before accessing $email->getTo() in AttachmentCopyException handler.

Hey, so the systemEmailBuilder->buildPhplistEmail() call at lines 243-248 can return null, but you're accessing $email->getTo()[0]->getAddress() at line 252 without checking first. This will throw a TypeError if $email is null.

The past review comment mentioned this was addressed, but looking at the current code, it seems the fix didn't make it in.

Suggested fix
             $email = $this->systemEmailBuilder->buildPhplistEmail(
                 messageId: $campaign->getId(),
                 data: $data,
                 inBlast: false,
                 htmlPref: true,
             );
+            if ($email === null) {
+                $this->logger->error('Failed to build system error email for attachment exception');
+                throw $e;
+            }

             $envelope = new Envelope(
                 sender: new Address($this->bounceEmail, 'PHPList'),
                 recipients: [new Address($email->getTo()[0]->getAddress())],
             );
🧹 Nitpick comments (3)
src/Domain/Common/FileHelper.php (1)

11-14: Clarify intent: empty files are considered invalid.

filesize($path) returns 0 for empty files, which is falsy. So isValidFile returns false for zero-byte files. If that's intentional (attachments shouldn't be empty), this is fine. Otherwise, consider using filesize($path) !== false if you just want to check the file exists and is readable.

src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php (1)

65-76: N+1 query: use the existing batch method instead.

You're calling find($listId) in a loop, which executes N separate queries. The SubscriberListRepository already has a getListNames(array $listIds) method that fetches all names in a single query.

♻️ Suggested refactor
     private function getListNames(array $listIds): string
     {
-        $names = [];
-        foreach ($listIds as $listId) {
-            $list = $this->subscriberListRepository->find($listId);
-            if ($list !== null) {
-                $names[] = $list->getName();
-            }
-        }
-
-        return implode(', ', $names);
+        return implode(', ', $this->subscriberListRepository->getListNames($listIds));
     }
src/Domain/Configuration/Service/MessagePlaceholderProcessor.php (1)

46-63: FORWARDFORM is a stubbed placeholder with no implementation.

The hardcoded empty string at line 50 is the only reference in the codebase. If intentional (planned for later or handled elsewhere), add a comment explaining the intent. Otherwise, consider marking it TODO or removing it if it's legacy code.

Comment on lines +773 to +812
<trans-unit id="6LBFI8k" resname="This message contains attachments that can be viewed with a webbrowser">
<source>This message contains attachments that can be viewed with a webbrowser</source>
<target>__This message contains attachments that can be viewed with a webbrowser</target>
</trans-unit>
<trans-unit id="QLlIQQh" resname="Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%">
<source>Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%</source>
<target>__Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%</target>
</trans-unit>
<trans-unit id="HhEWxtR" resname="Add us to your address book">
<source>Add us to your address book</source>
<target>__Add us to your address book</target>
</trans-unit>
<trans-unit id="TNQR4ZC" resname="phpList system error">
<source>phpList system error</source>
<target>__phpList system error</target>
</trans-unit>
<trans-unit id="_DfkneV" resname="Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be copied to the repository. Check for permissions.">
<source>Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be copied to the repository. Check for permissions.</source>
<target>__Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be copied to the repository. Check for permissions.</target>
</trans-unit>
<trans-unit id="VXRuG2l" resname="failed to open attachment (%remoteFile%) to add to campaign %campaignId%">
<source>failed to open attachment (%remoteFile%) to add to campaign %campaignId%</source>
<target>__failed to open attachment (%remoteFile%) to add to campaign %campaignId%</target>
</trans-unit>
<trans-unit id="28lDkjt" resname="Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%">
<source>Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%</source>
<target>__Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%</target>
</trans-unit>
<trans-unit id="DmqU7IE" resname="Attachment %remoteFile% does not exist">
<source>Attachment %remoteFile% does not exist</source>
<target>__Attachment %remoteFile% does not exist</target>
</trans-unit>
<trans-unit id="021aois" resname="Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be found in the repository.">
<source>Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be found in the repository.</source>
<target>__Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be found in the repository.</target>
</trans-unit>
<trans-unit id="FbYZdLJ" resname="Location">
<source>Location</source>
<target>__Location</target>
</trans-unit>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

All new attachment/system error translations have the __ prefix issue too.

Just a heads up - all the newly added translations from line 773-812 have the same __ prefix problem. When fixing, make sure to hit all of them, not just the first batch.

🤖 Prompt for AI Agents
In `@resources/translations/messages.en.xlf` around lines 773 - 812, Multiple new
trans-unit targets (ids 6LBFI8k, QLlIQQh, HhEWxtR, TNQR4ZC, _DfkneV, VXRuG2l,
28lDkjt, DmqU7IE, 021aois, FbYZdLJ) mistakenly include a "__" prefix; remove the
leading "__" from each <target> value so the target text exactly matches the
<source> text for those trans-units (e.g., update trans-unit id 6LBFI8k target
to "This message contains attachments that can be viewed with a webbrowser" and
likewise for the others). Ensure you update every listed trans-unit target (not
just the first few) so none retain the "__" prefix.

Comment on lines +36 to +55
protected function validateRecipientAndSubject(?string $to, ?string $subject): bool
{
if (!$to || trim($to) === '') {
$this->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');

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

Block CRLF too in recipient/subject validation.
Only \n is checked, so \r can still slip through header injection.

🔧 Tighten validation
-        if (preg_match("/\n/", $to)) {
+        if (preg_match("/[\r\n]/", $to)) {
             $this->eventLogManager->log('', 'Error: invalid recipient, containing newlines, email blocked');
 
             return false;
         }
-        if (preg_match("/\n/", $subject)) {
+        if (preg_match("/[\r\n]/", $subject)) {
             $this->eventLogManager->log('', 'Error: invalid subject, containing newlines, email blocked');
 
             return false;
         }
🤖 Prompt for AI Agents
In `@src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php` around lines 36 -
55, The validateRecipientAndSubject method currently only checks for "\n"
characters, leaving "\r" vulnerability for header injection; update the checks
in validateRecipientAndSubject (used in BaseEmailBuilder) to detect both CR and
LF characters (e.g. use a pattern that matches "\r" or "\n") for both $to and
$subject and keep the same eventLogManager->log messages and early returns when
invalid values are found.

@TatevikGr TatevikGr force-pushed the feat/user-personalizer branch from 19f224b to f90f636 Compare January 21, 2026 09:37
@TatevikGr TatevikGr force-pushed the feat/user-personalizer branch from f90f636 to 8647fcf Compare January 21, 2026 09:38
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php`:
- Around line 25-35: In BlacklistUrlValueResolver::__invoke, guard against an
empty base by checking the value returned from
$this->config->getValue(ConfigOption::BlacklistUrl) before calling
$this->urlBuilder->withEmail(); if the base is empty or null, return an empty
string (respecting the method return type) so you don't call
LegacyUrlBuilder::withEmail() with an empty host and produce malformed URLs;
keep the existing htmlspecialchars branch unchanged but ensure it only runs when
a non-empty URL was generated.
♻️ Duplicate comments (10)
src/Domain/Configuration/Service/Placeholder/ContactUrlValueResolver.php (1)

24-28: Harden HTML escaping to match other URL resolvers.

htmlspecialchars() defaults can be unsafe/inconsistent. Use explicit flags/UTF‑8 and reuse the URL value.

🔧 Proposed fix
     public function __invoke(PlaceholderContext $ctx): string
     {
-        if ($ctx->isText()) {
-            return $this->config->getValue(ConfigOption::VCardUrl) ?? '';
-        }
+        $url = $this->config->getValue(ConfigOption::VCardUrl) ?? '';
+        if ($ctx->isText()) {
+            return $url;
+        }
 
-        return htmlspecialchars($this->config->getValue(ConfigOption::VCardUrl) ?? '');
+        return htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
     }
src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php (1)

31-35: Handle potential null return from preg_replace.

The null coalescing for the config value is in place, nice. However, preg_replace itself can return null on error (e.g., regex failure), and the method's return type is string. Worth adding a fallback.

Suggested fix
-            return preg_replace(
+            return preg_replace(
                 '/src=".*power-phplist.png"/',
                 'src="powerphplist.png"',
                 $this->config->getValue(ConfigOption::PoweredByImage) ?? ''
-            );
+            ) ?? '';
resources/translations/messages.en.xlf (2)

753-812: Strip the leading __ from the new English targets (Line 753-812).

These targets should mirror their sources in messages.en.xlf; otherwise the prefix will render in UI/email text.

🔧 Example fix
-        <target>__Unsubscribe</target>
+        <target>Unsubscribe</target>

Apply the same removal for all new <target> values in this block (including CDATA targets if any).


777-800: Remove the duplicate “Insufficient memory…” translation entry (Line 777-800).

The message appears twice with different IDs (QLlIQQh and 28lDkjt). Keep one to avoid ambiguity.

🔧 Example fix (remove the duplicate)
-      <trans-unit id="28lDkjt" resname="Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%">
-        <source>Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%</source>
-        <target>__Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%</target>
-      </trans-unit>
src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php (2)

48-54: Block \r in header injection validation.

Only \n is checked; \r can still be used for header injection.

🔧 Tighten validation
-        if (preg_match("/\n/", $to)) {
+        if (preg_match("/[\r\n]/", $to)) {
             $this->eventLogManager->log('', 'Error: invalid recipient, containing newlines, email blocked');

             return false;
         }
-        if (preg_match("/\n/", $subject)) {
+        if (preg_match("/[\r\n]/", $subject)) {
             $this->eventLogManager->log('', 'Error: invalid subject, containing newlines, email blocked');

             return false;
         }

126-139: Guard missing Unsubscribe URL and avoid duplicate header in campaigns.

getValue() is nullable; str_contains(null, ...) will throw. Also, campaign emails already add a List-Unsubscribe header in EmailBuilder::applyCampaignHeaders, so this duplicates it.

🛠️ Suggested fix
-        $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,
-                ])
-            )
-        );
+        $removeUrl = $this->configProvider->getValue(ConfigOption::UnsubscribeUrl) ?? '';
+        if (!$inBlast && $removeUrl !== '') {
+            $sep = !str_contains($removeUrl, '?') ? '?' : '&';
+            $email->getHeaders()->addTextHeader(
+                'List-Unsubscribe',
+                sprintf(
+                    '<%s%s%s>',
+                    $removeUrl,
+                    $sep,
+                    http_build_query([
+                        'email' => $destinationEmail,
+                        'jo' => 1,
+                    ])
+                )
+            );
+        }
src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php (1)

243-254: Null‑safe system error email before dereferencing recipients.

buildPhplistEmail() can return null; dereferencing $email->getTo()[0] will crash.

🩹 Proposed guard
             $email = $this->systemEmailBuilder->buildPhplistEmail(
                 messageId: $campaign->getId(),
                 data: $data,
                 inBlast: false,
                 htmlPref: true,
             );
+            if ($email === null || empty($email->getTo())) {
+                $this->logger->error('Failed to build system error email for attachment exception');
+                throw $e;
+            }

             $envelope = new Envelope(
                 sender: new Address($this->bounceEmail, 'PHPList'),
                 recipients: [new Address($email->getTo()[0]->getAddress())],
             );
src/Domain/Messaging/Service/AttachmentAdder.php (2)

59-68: Guard empty recipients before building attachment URLs.

$email->getTo()[0] will throw if no recipients are set.

🛡️ Safer handling
                 case OutputFormat::Text:
-                    $userEmail = $email->getTo()[0]->getAddress();
+                    $toRecipients = $email->getTo();
+                    if (empty($toRecipients)) {
+                        $this->eventLogManager->log(
+                            page: '',
+                            entry: $this->translator->trans(
+                                'Cannot add text attachment link - no recipient set for campaign %campaignId%',
+                                ['%campaignId%' => $campaignId]
+                            )
+                        );
+                        return false;
+                    }
+                    $userEmail = $toRecipients[0]->getAddress();

184-196: Store only the filename, not the full path.

writeFileToDirectory() returns a full path; later you prefix the repository path again, creating a broken path.

🔧 Suggested fix
-        $relativeName = $this->fileHelper->writeFileToDirectory(
+        $fullPath = $this->fileHelper->writeFileToDirectory(
             directory: $this->attachmentRepositoryPath,
             originalFilename: $remoteFile,
             contents: $contents
         );

-        if ($relativeName === null) {
+        if ($fullPath === null) {
             $this->handleCopyFailure($remoteFile, $campaignId, $key);
             return;
         }

-        $att->setFilename($relativeName);
+        $att->setFilename(basename($fullPath));
src/Domain/Messaging/Service/Builder/EmailBuilder.php (1)

119-123: Use AdminAddress for List-Owner.

$adminAddress is pulled from UnsubscribeUrl, which is the wrong config key for the List-Owner header.

🔧 Fix
-        $adminAddress = $this->configProvider->getValue(ConfigOption::UnsubscribeUrl) ?? '';
+        $adminAddress = $this->configProvider->getValue(ConfigOption::AdminAddress) ?? '';
🧹 Nitpick comments (3)
src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php (1)

24-37: Use urlencode() for query parameters to match the codebase pattern and improve defensiveness.

While uniqueId is currently generated as hex-only (bin2hex(random_bytes(16))), other similar handlers—PasswordResetMessageHandler and SubscriberConfirmationMessageHandler—already wrap it with urlencode() when building URLs. For consistency and defensive programming, apply the same here:

$uid = urlencode($ctx->getUser()->getUniqueId());
$mid = (int)$ctx->messageId();

if ($ctx->isHtml()) {
    return htmlspecialchars(
        sprintf('%s%suid=%s&mid=%d', $url, $sep, $uid, $mid),
        ENT_QUOTES | ENT_SUBSTITUTE,
        'UTF-8'
    );
}

return sprintf('%s%suid=%s&mid=%d', $url, $sep, $uid, $mid);
src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php (1)

65-75: Avoid N+1 list lookups in getListNames.

Looping find() per ID will issue one query per list. The repository already has a bulk query helper.

♻️ Suggested refactor
-        $names = [];
-        foreach ($listIds as $listId) {
-            $list = $this->subscriberListRepository->find($listId);
-            if ($list !== null) {
-                $names[] = $list->getName();
-            }
-        }
-
-        return implode(', ', $names);
+        $names = $this->subscriberListRepository->getListNames($listIds);
+        return implode(', ', $names);
src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php (1)

210-219: Handle null build result with status/logging.

If buildPhplistEmail() returns null, the user message stays Active and no trace is logged. Consider marking as NotSent (or similar) and logging.

💡 Possible tweak
             $result = $this->campaignEmailBuilder->buildPhplistEmail(
                 messageId: $campaign->getId(),
                 data: $processed,
                 skipBlacklistCheck: false,
                 inBlast: true,
                 htmlPref: $subscriber->hasHtmlEmail(),
             );
             if ($result === null) {
+                $this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent);
+                $this->logger->warning($this->translator->trans('Email could not be built for: %email%', [
+                    '%email%' => $subscriber->getEmail(),
+                ]));
                 return;
             }

Comment on lines +25 to +35
public function __invoke(PlaceholderContext $ctx): string
{
$base = $this->config->getValue(ConfigOption::BlacklistUrl) ?? '';
$url = $this->urlBuilder->withEmail($base, $ctx->getUser()->getEmail());

if ($ctx->isHtml()) {
return htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

return $url;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how other URL value resolvers handle empty base URLs
ast-grep --pattern $'$this->config->getValue($_) ?? $_'

# Look for similar resolvers to compare implementation patterns
rg -n -A5 'implements PlaceholderValueResolverInterface' --type php

Repository: phpList/core

Length of output: 16190


🏁 Script executed:

#!/bin/bash
# Find the LegacyUrlBuilder class and check the withEmail method
fd -e php -x grep -l 'LegacyUrlBuilder' {} \;

Repository: phpList/core

Length of output: 746


🏁 Script executed:

#!/bin/bash
# Check the ConfigOption enum to see all available config options
fd ConfigOption.php --type f | head -5

Repository: phpList/core

Length of output: 103


🏁 Script executed:

#!/bin/bash
# Search for how BlacklistUrl is used elsewhere in the codebase
rg -n 'BlacklistUrl' --type php -B2 -A2

Repository: phpList/core

Length of output: 3024


🏁 Script executed:

#!/bin/bash
# Read the LegacyUrlBuilder class to understand withEmail behavior
cat src/Domain/Configuration/Service/LegacyUrlBuilder.php

Repository: phpList/core

Length of output: 1235


🏁 Script executed:

#!/bin/bash
# Compare similar resolvers like UnsubscribeUrlValueResolver and PreferencesUrlValueResolver
cat src/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolver.php

Repository: phpList/core

Length of output: 1115


🏁 Script executed:

#!/bin/bash
# Check the full BlacklistValueResolver implementation
cat src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php

Repository: phpList/core

Length of output: 1443


Add a guard to handle empty base URL gracefully.

The concern is valid. When ConfigOption::BlacklistUrl isn't configured, LegacyUrlBuilder::withEmail() produces a malformed URL like https://[email protected] because the host defaults to an empty string. This pattern affects other URL resolvers in the codebase as well (e.g., UnsubscribeUrlValueResolver, BlacklistValueResolver).

Consider returning an empty string when the base URL is not configured, or adding a check before calling withEmail():

$base = $this->config->getValue(ConfigOption::BlacklistUrl) ?? '';
if (!$base) {
    return '';
}
$url = $this->urlBuilder->withEmail($base, $ctx->getUser()->getEmail());
🤖 Prompt for AI Agents
In `@src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php`
around lines 25 - 35, In BlacklistUrlValueResolver::__invoke, guard against an
empty base by checking the value returned from
$this->config->getValue(ConfigOption::BlacklistUrl) before calling
$this->urlBuilder->withEmail(); if the base is empty or null, return an empty
string (respecting the method return type) so you don't call
LegacyUrlBuilder::withEmail() with an empty host and produce malformed URLs;
keep the existing htmlspecialchars branch unchanged but ensure it only runs when
a non-empty URL was generated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants