Skip to content

Conversation

@TatevikGr
Copy link
Contributor

@TatevikGr TatevikGr commented Dec 30, 2025

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced message personalization with expanded placeholder support for dynamic content
    • Configurable preference page settings for controlling private list visibility
    • Improved message forwarding capabilities with better content handling
    • New message format configurations for text and HTML alternatives
  • Improvements

    • Enhanced remote content fetching with improved error handling and fallback mechanisms
    • Better URL parameter management for preference and unsubscribe links
    • Refined email composition workflow for forwarded messages

✏️ 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

The PR introduces output format-aware placeholder resolution with extensible resolver implementations, refactors email/message construction through new MailConstructor services, adds configuration parameters for preference page and forwarding settings, adopts constructor property promotion across multiple message handlers, and extends repositories with subscriber/list data access methods.

Changes

Cohort / File(s) Summary
Configuration & Parameters
config/parameters.yml.dist, config/services/parameters.yml, config/services/services.yml
Added three new environment-backed parameters: app.preference_page_show_private_lists, messaging.forward_alternative_content, messaging.email_text_credits. Registered new services for HTML processing, URL rewriting, image handling, and message precaching. Injected preferencePageShowPrivateLists into UserPersonalizer.
Service Builders & Wiring
config/services/builders.yml, config/services/messenger.yml, config/services/repositories.yml, config/services/resolvers.yml
Introduced SystemMailConstructor and CampaignMailConstructor as concrete services; wired EmailBuilder instances with distinct constructors. Updated CampaignProcessorMessageHandler with autoconfiguration and emailBuilder injection. Added UrlCacheRepository service definition. Registered auto-tagging for PlaceholderValueResolverInterface implementations.
Placeholder System (Interface & Core)
src/Domain/Configuration/Service/Placeholder/PlaceholderValueResolverInterface.php, src/Domain/Configuration/Service/PlaceholderResolver.php
Introduced PlaceholderValueResolverInterface as extensibility contract with name(): string and __invoke(PlaceholderContext $ctx): string. Refactored PlaceholderResolver to use context-aware resolution with regex-based token matching and resolver registration instead of case-insensitive provider lookup.
Placeholder Resolvers (URL-based)
src/Domain/Configuration/Service/Placeholder/{Unsubscribe,Blacklist,Forward,Preferences,ConfirmationUrl,ForwardUrl,UnsubscribeUrl,BlacklistUrl,PreferencesUrl,ForwardUrl}ValueResolver.php
Added 11 new resolver classes for URL and link generation; each handles HTML escaping, context-aware formatting, translator integration for labels, and parameter appending. Uses LegacyUrlBuilder for URL construction and respects OutputFormat (HTML anchor tags vs. plain URLs).
Placeholder Resolvers (Content-based)
src/Domain/Configuration/Service/Placeholder/{Lists,Subscribe,Signature,Jumpoff,JumpoffUrl}ValueResolver.php
Added 5 new resolver classes for content generation; ListsValueResolver fetches subscriber's active list names with privacy filtering; Subscribe/SubscribeUrl handle subscribe links; Signature provides powered-by text/HTML; Jumpoff/JumpoffUrl for advanced user tracking.
Configuration Models & DTOs
src/Domain/Configuration/Model/OutputFormat.php, src/Domain/Configuration/Model/Dto/PlaceholderContext.php, src/Domain/Configuration/Model/ConfigOption.php
Added OutputFormat enum (Html/Text) for format-aware rendering. Introduced PlaceholderContext DTO encapsulating user, format, locale, forwardedBy, and messageId. Extended ConfigOption enum with BlacklistUrl, ForwardUrl, OrganisationName, VCardUrl, HtmlEmailStyle.
UserPersonalizer Refactoring
src/Domain/Configuration/Service/UserPersonalizer.php
Major refactor: added OutputFormat parameter to personalize() method; replaced hardcoded URL logic with PlaceholderValueResolverInterface-based extensibility; added PlaceholderContext-aware resolution; injected SubscriberListRepository, Translator, preferencePageShowPrivateLists flag; expanded placeholder set (EMAIL, FORWARDEDBY, MESSAGEID, USERID, WEBSITE, DOMAIN, etc.).
Email Construction Services
src/Domain/Messaging/Service/Constructor/MailConstructorInterface.php, src/Domain/Messaging/Service/Constructor/SystemMailConstructor.php, src/Domain/Messaging/Service/Constructor/MailConstructor.php
Introduced MailConstructorInterface as contract for content-to-email transformation. Moved and refactored SystemMailConstructor to Constructor namespace. Added comprehensive MailConstructor service handling user-specific remote content, templates, HTML styling, footers, and personalization with error handling and logging.
EmailBuilder & Related Services
src/Domain/Messaging/Service/Builder/EmailBuilder.php, src/Domain/Messaging/Service/MessagePrecacheService.php, src/Domain/Messaging/Service/RateLimitedCampaignMailer.php
Updated EmailBuilder to accept MailConstructorInterface instead of SystemMailConstructor; switched to named parameters. Refactored MessagePrecacheService remote content fetching logic. Extended RateLimitedCampaignMailer with RemotePageFetcher, SubscriberRepository, EventLogManager dependencies; added replaceUserSpecificRemoteContent helper for token-based remote content embedding.
Message Handlers (Property Promotion)
src/Domain/Messaging/MessageHandler/{AsyncEmailMessageHandler,PasswordResetMessageHandler,SubscriberConfirmationMessageHandler,SubscriptionConfirmationMessageHandler}.php
Refactored constructors to use PHP 8.0 promoted readonly properties, removing explicit property declarations and assignments. SubscriptionConfirmationMessageHandler now uses OutputFormat::Text and delegates list name resolution to repository.
CampaignProcessorMessageHandler
src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
Replaced UserPersonalizer dependency with MailConstructor; updated email composition to use MailConstructor->build() instead of rateLimitedCampaignMailer->composeEmail(); adjusted processMessageLinks to use named parameters; removed in-place personalization.
Repository Extensions
src/Domain/Subscription/Repository/SubscriberListRepository.php, src/Domain/Subscription/Repository/SubscriberRepository.php
Added getListNames(array $listIds): array to fetch list names by IDs. Added getActiveListNamesForSubscriber(Subscriber $subscriber, bool $showPrivate): array with privacy filtering. Added getDataById(int $subscriberId): array to SubscriberRepository.
Remaining Services & Utilities
src/Domain/Common/RemotePageFetcher.php, src/Domain/Messaging/Exception/RemotePageFetchException.php, src/Domain/Configuration/Service/LegacyUrlBuilder.php, src/Domain/Subscription/Service/SubscriberCsvImporter.php
Updated RemotePageFetcher return types to nullable (?string). Added RemotePageFetchException for fetch failures. Extended LegacyUrlBuilder with withEmail() and withQueryParam() helper. Refactored SubscriberCsvImporter to use promoted properties.
Tests & Translations
tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php, tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php, tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php, resources/translations/messages.en.xlf
Updated tests to reflect MailConstructor flow and reflection-based builder access. Updated import paths for moved SystemMailConstructor. Added 5 new translation units (Unsubscribe, This link, Confirm, Update preferences, subscription message).

Sequence Diagram

sequenceDiagram
    participant Handler as CampaignProcessorMessageHandler
    participant MailConstructor
    participant UserPersonalizer
    participant PlaceholderResolvers
    participant Mailer as RateLimitedCampaignMailer

    Handler->>MailConstructor: build(message, subscriber, precacheDto)
    MailConstructor->>MailConstructor: loadDefaultHtmlStyle()
    MailConstructor->>MailConstructor: replaceUserSpecificRemoteContent()
    MailConstructor->>UserPersonalizer: personalize(content, email, OutputFormat::Html)
    
    UserPersonalizer->>PlaceholderResolvers: iterate resolvers
    PlaceholderResolvers->>PlaceholderResolvers: resolve placeholders with PlaceholderContext
    PlaceholderResolvers-->>UserPersonalizer: resolved value (format-aware)
    
    UserPersonalizer-->>MailConstructor: personalized html content
    
    MailConstructor->>UserPersonalizer: personalize(content, email, OutputFormat::Text)
    UserPersonalizer->>PlaceholderResolvers: iterate resolvers (text mode)
    PlaceholderResolvers-->>UserPersonalizer: resolved value (plain text)
    UserPersonalizer-->>MailConstructor: personalized text content
    
    MailConstructor->>MailConstructor: apply templates, set footers
    MailConstructor-->>Handler: Email object
    
    Handler->>Mailer: send(email)
    Mailer-->>Handler: success
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • Feat/check max mail size #373: Modifies the messaging and email construction/precaching flow with overlapping changes to CampaignProcessorMessageHandler, MessagePrecacheService, and RateLimitedCampaignMailer dependencies and handling.
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.12% 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/user personalizer' is partially related to the changeset, referring to user personalization enhancements but lacking specificity about the broader scope of changes including placeholder resolvers, mail construction, and configuration updates.

✏️ 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.

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: 2

🧹 Nitpick comments (1)
src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php (1)

198-212: Named parameters improve clarity; minor formatting nit.

Good adoption of named parameters and OutputFormat::Text for text content personalization. Small inconsistency on line 206: missing space after colon (format:OutputFormat::Text vs format: OutputFormat::Text on line 211).

🔎 Suggested fix
         $processed->textContent = $this->userPersonalizer->personalize(
             value: $processed->textContent,
             email: $subscriber->getEmail(),
-            format:OutputFormat::Text,
+            format: OutputFormat::Text,
         );
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7f21c58 and 216978e.

📒 Files selected for processing (12)
  • config/parameters.yml.dist
  • config/services/services.yml
  • src/Domain/Configuration/Model/OutputFormat.php
  • src/Domain/Configuration/Service/UserPersonalizer.php
  • src/Domain/Messaging/MessageHandler/AsyncEmailMessageHandler.php
  • src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
  • src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php
  • src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php
  • src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php
  • src/Domain/Messaging/Service/MessagePrecacheService.php
  • src/Domain/Subscription/Repository/SubscriberListRepository.php
  • src/Domain/Subscription/Service/SubscriberCsvImporter.php
🧰 Additional context used
📓 Path-based instructions (2)
src/Domain/**

⚙️ CodeRabbit configuration file

src/Domain/**: You are reviewing PHP domain-layer code. Enforce domain purity, with a relaxed policy for DynamicListAttr:

  • ❌ Do not allow, flag ANY DB write / finalization:

    • $entityManager->flush(...), $this->entityManager->flush(...)
    • $em->beginTransaction(), $em->commit(), $em->rollback(), $em->transactional(...)
    • $em->getConnection()->executeStatement(...) for DML/DDL (INSERT/UPDATE/DELETE/ALTER/...)
  • ✅ Accessing Doctrine metadata, schema manager, or read-only schema info is acceptable
    as long as it does not modify state or perform writes. Accessing Doctrine persistence APIs
    persist, remove, etc.) is acceptable, allow scheduling changes in the UnitOfWork (no DB writes)

  • Relaxed rule for DynamicListAttr-related code:

    • DynamicListAttr is a special case dealing with dynamic tables/attrs.
    • It is acceptable for DynamicListAttr repositories/services to:
      • Create/update/drop DynamicListAttr tables/columns.
      • Use Doctrine persistence APIs (persist, remove, flush, etc.)
        as part of managing DynamicListAttr data and schema.
    • Do not flag persistence or schema-creation calls that are clearly scoped
      to DynamicListAttr tables or their management.
    • Still prefer keeping this logic well-encapsulated (e.g. in dedicated services/repos),
      not scattered across unrelated domain objects.
  • ⚠️ For non-DynamicListAttr code:

    • If code is invoking actual table-creation, DDL execution, or schema synchronization,
      then request moving that to the Infrastructure or Application layer (e.g. MessageHandler).
    • Repositories in Domain should be abstractions without side effects; they should express intent,
      not perform flush/transactional logic.

Files:

  • src/Domain/Configuration/Model/OutputFormat.php
  • src/Domain/Messaging/Service/MessagePrecacheService.php
  • src/Domain/Messaging/MessageHandler/AsyncEmailMessageHandler.php
  • src/Domain/Subscription/Repository/SubscriberListRepository.php
  • src/Domain/Configuration/Service/UserPersonalizer.php
  • src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
  • src/Domain/Subscription/Service/SubscriberCsvImporter.php
  • src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php
  • src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php
  • src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php
src/**/MessageHandler/**

⚙️ CodeRabbit configuration file

src/**/MessageHandler/**: Background jobs/workers may perform persistence and schema management.

  • ✅ Allow $entityManager->flush() when the job is the orchestration boundary.
  • ✅ Allow table creation, migration, or schema synchronization (e.g. via Doctrine SchemaTool or SchemaManager),
    as this is considered infrastructure-level orchestration.
  • For DynamicListAttr-related jobs, it is fine to orchestrate both data and schema changes here,
    as long as responsibilities remain clear and behavior is predictable.
  • Verify idempotency for schema operations where practical — e.g., check if a table exists before creating.
  • Ensure domain-layer code invoked by the job (outside the DynamicListAttr exception) remains free of persistence calls.
  • Batch flush operations where practical.

Files:

  • src/Domain/Messaging/MessageHandler/AsyncEmailMessageHandler.php
  • src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
  • src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php
  • src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php
  • src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php
🧬 Code graph analysis (7)
src/Domain/Messaging/MessageHandler/AsyncEmailMessageHandler.php (6)
src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php (1)
  • __construct (52-73)
src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php (1)
  • __construct (24-31)
src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php (1)
  • __construct (16-21)
src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php (1)
  • __construct (19-24)
src/Domain/Subscription/Service/SubscriberCsvImporter.php (1)
  • __construct (37-48)
src/Domain/Messaging/Service/MessageProcessingPreparator.php (1)
  • __construct (20-26)
src/Domain/Subscription/Repository/SubscriberListRepository.php (1)
src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php (1)
  • getListNames (59-62)
src/Domain/Configuration/Service/UserPersonalizer.php (1)
src/Domain/Subscription/Repository/SubscriberListRepository.php (2)
  • SubscriberListRepository (23-94)
  • getActiveListNamesForSubscriber (78-93)
src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php (3)
src/Domain/Configuration/Service/UserPersonalizer.php (1)
  • personalize (32-128)
src/Domain/Messaging/Message/SubscriberConfirmationMessage.php (1)
  • getEmail (26-29)
src/Domain/Messaging/Message/SubscriptionConfirmationMessage.php (1)
  • getEmail (29-32)
src/Domain/Subscription/Service/SubscriberCsvImporter.php (5)
src/Domain/Subscription/Service/Manager/SubscriberManager.php (1)
  • SubscriberManager (19-143)
src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php (1)
  • SubscriberAttributeManager (17-88)
src/Domain/Subscription/Repository/SubscriberRepository.php (1)
  • SubscriberRepository (21-208)
src/Domain/Subscription/Service/CsvToDtoImporter.php (1)
  • CsvToDtoImporter (14-62)
src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php (1)
  • SubscriberHistoryManager (18-143)
src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php (4)
src/Domain/Messaging/Service/EmailService.php (1)
  • EmailService (14-134)
src/Domain/Configuration/Service/Provider/ConfigProvider.php (1)
  • ConfigProvider (12-82)
src/Domain/Configuration/Service/UserPersonalizer.php (2)
  • UserPersonalizer (16-129)
  • personalize (32-128)
src/Domain/Subscription/Repository/SubscriberListRepository.php (2)
  • SubscriberListRepository (23-94)
  • getListNames (58-72)
src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php (3)
src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php (1)
  • __construct (24-31)
src/Domain/Messaging/MessageHandler/AsyncEmailMessageHandler.php (1)
  • __construct (17-19)
src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php (1)
  • __construct (16-21)
🪛 GitHub Actions: phpList Core Build
src/Domain/Configuration/Service/UserPersonalizer.php

[error] 20-20: Too few arguments to function PhpList\Core\Domain\Configuration\Service\UserPersonalizer::__construct(), 5 passed in /home/runner/work/core/core/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php on line 42 and at least 7 expected

src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php

[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: '

🔇 Additional comments (14)
src/Domain/Messaging/Service/MessagePrecacheService.php (1)

198-198: Formatting looks good.

The string concatenation adjustment is fine and has no behavioral impact.

Optional note: you might want to verify that $loadedMessageData['sendurl'] is the intended value to log here, since the actual fetch attempt uses $regs[1] from line 185. If they differ, logging $regs[1] might provide clearer debugging context. (Pre-existing pattern, so not blocking.)

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

17-19: Nice refactor to constructor property promotion.

Clean modernization using PHP 8's constructor property promotion. Consistent with the pattern adopted across other message handlers in this PR.

src/Domain/Subscription/Service/SubscriberCsvImporter.php (1)

37-48: Constructor property promotion looks good.

All 9 dependencies are now using PHP 8's constructor property promotion with private readonly. The class docblock appropriately notes this is an application-layer service exception for complex import orchestration.

src/Domain/Configuration/Model/OutputFormat.php (1)

1-11: Clean enum definition.

Simple and focused backed enum for output formatting. Good use of PHP 8.1 features. The lowercase string values follow common conventions.

config/parameters.yml.dist (1)

34-35: Parameter addition follows existing conventions.

The new parameter uses the same pattern as other boolean-like environment variables in this file (string '0'/'1' defaults). Environment variable naming is consistent.

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

16-21: Constructor property promotion looks solid.

Clean refactor consistent with the other message handlers in this PR. All dependencies properly marked as private readonly.

src/Domain/Subscription/Repository/SubscriberListRepository.php (2)

58-72: Clean implementation with proper empty-check guard.

Good practice returning early for empty input. The query is straightforward and uses proper parameter binding.


74-93: The semantic mapping is correct; no bug found.

The database column active is intentionally mapped to the entity property $public with getter isPublic() (lines 155–157 in SubscriberList.php). The filter l.active = true when $showPrivate = false correctly returns public (active) lists. The docblock accurately describes this: "only active/public lists are included." No changes needed.

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

19-24: LGTM! Clean constructor property promotion.

The refactor to use constructor property promotion is clean and consistent with the pattern used in other message handlers.

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

24-30: Constructor property promotion looks good.

Clean refactor to use promoted properties, consistent with the pattern applied across other handlers.

src/Domain/Configuration/Service/UserPersonalizer.php (4)

32-32: personalize() signature enhanced with OutputFormat.

The addition of the OutputFormat $format parameter enables format-aware rendering. This is a breaking change, but the enhanced functionality justifies it and I can see it's being adopted in the handlers (e.g., SubscriptionConfirmationMessageHandler).


42-85: URL rendering with format-aware HTML link generation looks solid.

The implementation properly handles both Html and Text formats:

  • HTML format generates anchor tags with translated labels
  • Proper escaping applied (ENT_QUOTES | ENT_SUBSTITUTE)
  • Text format returns plain URLs
  • Consistent pattern across all three URL placeholders

94-115: LISTS placeholder implementation is well-designed.

The logic handles all the important cases:

  • Fetches list names via repository with privacy flag support
  • Graceful empty-state handling with a translated message
  • Format-aware rendering (HTML <br/> vs text newline)
  • Proper HTML escaping when needed
  • Clean use of named parameters for clarity

20-30: Test constructor call is incomplete - needs the three new dependencies.

The UserPersonalizerTest.php instantiation at line 37 passes only 5 arguments, but the constructor now requires 8 (with 1 optional default). Add mocks for SubscriberListRepository and TranslatorInterface to the test setup and pass them to the constructor.

Comment on lines 123 to +127
PhpList\Core\Domain\Configuration\Service\UserPersonalizer:
autowire: true
autoconfigure: true
arguments:
$preferencePageShowPrivateLists: '%app.preference_page_show_private_lists%'
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.

Comment on lines +41 to +47
$replacedTextContent = str_replace('[LISTS]', $listOfLists, $textContent);

$personalizedTextContent = $this->userPersonalizer->personalize(
value: $replacedTextContent,
email: $message->getUniqueId(),
format: OutputFormat::Text,
);
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.

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: 2

🧹 Nitpick comments (1)
src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php (1)

198-212: Looks good overall, minor spacing nit.

The switch to named parameters makes the code much more readable. Using OutputFormat::Text for both textContent and footer is the right call for plain text parts.

Small thing: Line 206 is missing a space after format: while line 211 has it. Consistency would be nice, but no biggie.

-            format:OutputFormat::Text,
+            format: OutputFormat::Text,
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 216978e and 968044a.

📒 Files selected for processing (2)
  • src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
  • src/Domain/Messaging/Service/MessagePrecacheService.php
🧰 Additional context used
📓 Path-based instructions (2)
src/Domain/**

⚙️ CodeRabbit configuration file

src/Domain/**: You are reviewing PHP domain-layer code. Enforce domain purity, with a relaxed policy for DynamicListAttr:

  • ❌ Do not allow, flag ANY DB write / finalization:

    • $entityManager->flush(...), $this->entityManager->flush(...)
    • $em->beginTransaction(), $em->commit(), $em->rollback(), $em->transactional(...)
    • $em->getConnection()->executeStatement(...) for DML/DDL (INSERT/UPDATE/DELETE/ALTER/...)
  • ✅ Accessing Doctrine metadata, schema manager, or read-only schema info is acceptable
    as long as it does not modify state or perform writes. Accessing Doctrine persistence APIs
    persist, remove, etc.) is acceptable, allow scheduling changes in the UnitOfWork (no DB writes)

  • Relaxed rule for DynamicListAttr-related code:

    • DynamicListAttr is a special case dealing with dynamic tables/attrs.
    • It is acceptable for DynamicListAttr repositories/services to:
      • Create/update/drop DynamicListAttr tables/columns.
      • Use Doctrine persistence APIs (persist, remove, flush, etc.)
        as part of managing DynamicListAttr data and schema.
    • Do not flag persistence or schema-creation calls that are clearly scoped
      to DynamicListAttr tables or their management.
    • Still prefer keeping this logic well-encapsulated (e.g. in dedicated services/repos),
      not scattered across unrelated domain objects.
  • ⚠️ For non-DynamicListAttr code:

    • If code is invoking actual table-creation, DDL execution, or schema synchronization,
      then request moving that to the Infrastructure or Application layer (e.g. MessageHandler).
    • Repositories in Domain should be abstractions without side effects; they should express intent,
      not perform flush/transactional logic.

Files:

  • src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
  • src/Domain/Messaging/Service/MessagePrecacheService.php
src/**/MessageHandler/**

⚙️ CodeRabbit configuration file

src/**/MessageHandler/**: Background jobs/workers may perform persistence and schema management.

  • ✅ Allow $entityManager->flush() when the job is the orchestration boundary.
  • ✅ Allow table creation, migration, or schema synchronization (e.g. via Doctrine SchemaTool or SchemaManager),
    as this is considered infrastructure-level orchestration.
  • For DynamicListAttr-related jobs, it is fine to orchestrate both data and schema changes here,
    as long as responsibilities remain clear and behavior is predictable.
  • Verify idempotency for schema operations where practical — e.g., check if a table exists before creating.
  • Ensure domain-layer code invoked by the job (outside the DynamicListAttr exception) remains free of persistence calls.
  • Batch flush operations where practical.

Files:

  • src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
🧬 Code graph analysis (1)
src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php (3)
src/Domain/Configuration/Service/UserPersonalizer.php (1)
  • personalize (32-128)
src/Domain/Messaging/Service/Builder/EmailBuilder.php (1)
  • buildPhplistEmail (40-77)
src/Domain/Messaging/Service/RateLimitedCampaignMailer.php (1)
  • send (50-55)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: phpList on PHP 8.1 [Build, Test]
🔇 Additional comments (3)
src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php (1)

11-11: LGTM on the import.

Clean addition of the OutputFormat enum to support format-aware personalization. Nice and tidy.

src/Domain/Messaging/Service/MessagePrecacheService.php (2)

71-73: Clean refactor to unconditional call.

Making applyRemoteContentIfPresent always run and return a bool is a nice simplification. The early-exit pattern inside the method handles the user-specific URL case, so the caller doesn't need to worry about it. Solid.


199-206: Template reset logic is well-documented.

Appreciate the comment referencing issue #17086 - makes it clear why templates are discarded when remote content is used. The null assignments are explicit and intentional.

Comment on lines 215 to 221
$email = $this->emailBuilder->buildPhplistEmail(
$campaign->getId(),
$subscriber->getEmail(),
$processed->subject,
$processed->textContent,
);
$this->rateLimitedCampaignMailer->send($email);
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

Missing null check on buildPhplistEmail return value.

Hey, buildPhplistEmail can return null (e.g., if recipient validation fails or blacklist check triggers). Passing null to rateLimitedCampaignMailer->send() will throw a TypeError.

You've got the right pattern in handleAdminNotifications (lines 258-260) where you check for null and continue. Consider something similar here:

🔎 Proposed fix
             $email = $this->emailBuilder->buildPhplistEmail(
                 $campaign->getId(),
                 $subscriber->getEmail(),
                 $processed->subject,
                 $processed->textContent,
             );
+            if ($email === null) {
+                $this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent);
+                $this->logger->warning($this->translator->trans('Email could not be built for: %email%', [
+                    '%email%' => $subscriber->getEmail(),
+                ]));
+                return;
+            }
             $this->rateLimitedCampaignMailer->send($email);
🤖 Prompt for AI Agents
In src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
around lines 215 to 221, the result of
$this->emailBuilder->buildPhplistEmail(...) can be null and is passed directly
to $this->rateLimitedCampaignMailer->send(...), causing a TypeError; add a null
check after building the email (mirror the pattern used in
handleAdminNotifications at lines ~258-260): if the built email is null, skip
sending by continuing the loop (or otherwise return/skip this recipient); only
call send() when the email is non-null.

Comment on lines +182 to 207
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;
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

Logic looks solid, but there's a logging mismatch.

The restructured flow is clean and easy to follow. Early returns for the no-op cases, then fetch → fail-fast → replace → reset template. 👍

One thing though: on line 189 you fetch from $regs[1] (the URL extracted from content), but on line 193 you log $loadedMessageData['sendurl']. These might be different values, which could make debugging a head-scratcher.

🔎 Suggested fix
         $remoteContent = ($this->remotePageFetcher)($regs[1], []);
         if (!$remoteContent) {
             $this->eventLogManager->log(
                 page: 'unknown page',
-                entry: 'Error fetching URL: ' . $loadedMessageData['sendurl'] . ' cannot proceed',
+                entry: 'Error fetching URL: ' . $regs[1] . ' cannot proceed',
             );

             return false;
         }
🤖 Prompt for AI Agents
In src/Domain/Messaging/Service/MessagePrecacheService.php around lines 182 to
207, the error log uses $loadedMessageData['sendurl'] while the fetch actually
uses $regs[1], which can mislead debugging; update the EventLogManager call to
log the actual URL used for the fetch (use $regs[1]) and optionally include
$loadedMessageData['sendurl'] as additional context if it exists, ensuring you
reference only defined variables to avoid notices.

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

♻️ Duplicate comments (1)
config/services/services.yml (1)

156-160: String-to-boolean conversion issue remains unaddressed.

As noted in the previous review, the $preferencePageShowPrivateLists parameter is type-hinted as bool but receives a string value from the configuration. This causes (bool)'0' to evaluate to true instead of false, resulting in incorrect behavior where private lists are always shown when they should be hidden.

The previous review suggested using an integer in parameters.yml.dist or applying explicit casting in the service configuration. Please address this before merging.

🧹 Nitpick comments (1)
src/Domain/Messaging/Exception/RemotePageFetchException.php (1)

11-14: Consider accepting a URL parameter for better debugging context.

The fixed error message lacks information about which URL failed to fetch. While this might be logged elsewhere, including the URL in the exception message would make debugging easier when multiple remote pages are being fetched.

🔎 Proposed enhancement
-    public function __construct()
+    public function __construct(?string $url = null)
     {
-        parent::__construct('Failed to fetch URL for subscriber');
+        $message = 'Failed to fetch URL for subscriber';
+        if ($url !== null) {
+            $message .= ': ' . $url;
+        }
+        parent::__construct($message);
     }
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 968044a and 975b418.

📒 Files selected for processing (17)
  • config/parameters.yml.dist
  • config/services/builders.yml
  • config/services/messenger.yml
  • config/services/parameters.yml
  • config/services/repositories.yml
  • config/services/services.yml
  • src/Domain/Common/RemotePageFetcher.php
  • src/Domain/Messaging/Exception/RemotePageFetchException.php
  • src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
  • src/Domain/Messaging/Service/Builder/EmailBuilder.php
  • src/Domain/Messaging/Service/Constructor/MailConstructorInterface.php
  • src/Domain/Messaging/Service/Constructor/SystemMailConstructor.php
  • src/Domain/Messaging/Service/RateLimitedCampaignMailer.php
  • src/Domain/Subscription/Repository/SubscriberRepository.php
  • tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php
  • tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php
  • tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • config/parameters.yml.dist
🧰 Additional context used
📓 Path-based instructions (2)
src/Domain/**

⚙️ CodeRabbit configuration file

src/Domain/**: You are reviewing PHP domain-layer code. Enforce domain purity, with a relaxed policy for DynamicListAttr:

  • ❌ Do not allow, flag ANY DB write / finalization:

    • $entityManager->flush(...), $this->entityManager->flush(...)
    • $em->beginTransaction(), $em->commit(), $em->rollback(), $em->transactional(...)
    • $em->getConnection()->executeStatement(...) for DML/DDL (INSERT/UPDATE/DELETE/ALTER/...)
  • ✅ Accessing Doctrine metadata, schema manager, or read-only schema info is acceptable
    as long as it does not modify state or perform writes. Accessing Doctrine persistence APIs
    persist, remove, etc.) is acceptable, allow scheduling changes in the UnitOfWork (no DB writes)

  • Relaxed rule for DynamicListAttr-related code:

    • DynamicListAttr is a special case dealing with dynamic tables/attrs.
    • It is acceptable for DynamicListAttr repositories/services to:
      • Create/update/drop DynamicListAttr tables/columns.
      • Use Doctrine persistence APIs (persist, remove, flush, etc.)
        as part of managing DynamicListAttr data and schema.
    • Do not flag persistence or schema-creation calls that are clearly scoped
      to DynamicListAttr tables or their management.
    • Still prefer keeping this logic well-encapsulated (e.g. in dedicated services/repos),
      not scattered across unrelated domain objects.
  • ⚠️ For non-DynamicListAttr code:

    • If code is invoking actual table-creation, DDL execution, or schema synchronization,
      then request moving that to the Infrastructure or Application layer (e.g. MessageHandler).
    • Repositories in Domain should be abstractions without side effects; they should express intent,
      not perform flush/transactional logic.

Files:

  • src/Domain/Subscription/Repository/SubscriberRepository.php
  • src/Domain/Messaging/Exception/RemotePageFetchException.php
  • src/Domain/Common/RemotePageFetcher.php
  • src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
  • src/Domain/Messaging/Service/Constructor/SystemMailConstructor.php
  • src/Domain/Messaging/Service/Constructor/MailConstructorInterface.php
  • src/Domain/Messaging/Service/RateLimitedCampaignMailer.php
  • src/Domain/Messaging/Service/Builder/EmailBuilder.php
src/**/MessageHandler/**

⚙️ CodeRabbit configuration file

src/**/MessageHandler/**: Background jobs/workers may perform persistence and schema management.

  • ✅ Allow $entityManager->flush() when the job is the orchestration boundary.
  • ✅ Allow table creation, migration, or schema synchronization (e.g. via Doctrine SchemaTool or SchemaManager),
    as this is considered infrastructure-level orchestration.
  • For DynamicListAttr-related jobs, it is fine to orchestrate both data and schema changes here,
    as long as responsibilities remain clear and behavior is predictable.
  • Verify idempotency for schema operations where practical — e.g., check if a table exists before creating.
  • Ensure domain-layer code invoked by the job (outside the DynamicListAttr exception) remains free of persistence calls.
  • Batch flush operations where practical.

Files:

  • src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
🧬 Code graph analysis (7)
src/Domain/Messaging/Exception/RemotePageFetchException.php (2)
src/Domain/Common/RemotePageFetcher.php (1)
  • __construct (19-29)
src/Domain/Messaging/Service/RateLimitedCampaignMailer.php (1)
  • __construct (20-27)
tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php (1)
src/Domain/Messaging/Service/Constructor/SystemMailConstructor.php (1)
  • SystemMailConstructor (13-103)
src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php (2)
src/Domain/Configuration/Service/UserPersonalizer.php (1)
  • personalize (32-128)
src/Domain/Messaging/Service/RateLimitedCampaignMailer.php (1)
  • composeEmail (29-55)
tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php (1)
src/Domain/Messaging/Service/Constructor/SystemMailConstructor.php (1)
  • SystemMailConstructor (13-103)
src/Domain/Messaging/Service/Constructor/SystemMailConstructor.php (1)
src/Domain/Messaging/Service/Constructor/MailConstructorInterface.php (1)
  • __invoke (17-17)
src/Domain/Messaging/Service/Constructor/MailConstructorInterface.php (1)
src/Domain/Messaging/Service/Constructor/SystemMailConstructor.php (1)
  • __invoke (27-73)
src/Domain/Messaging/Service/RateLimitedCampaignMailer.php (4)
src/Domain/Common/RemotePageFetcher.php (1)
  • RemotePageFetcher (17-125)
src/Domain/Messaging/Exception/RemotePageFetchException.php (1)
  • RemotePageFetchException (9-15)
src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php (1)
  • MessagePrecacheDto (8-31)
src/Domain/Subscription/Repository/SubscriberRepository.php (2)
  • SubscriberRepository (21-218)
  • getDataById (209-217)
🔇 Additional comments (23)
config/services/repositories.yml (1)

25-28: Configuration is solid—the classes are properly wired.

The new UrlCacheRepository service definition follows the established pattern and both referenced classes exist in the codebase. Standard DI setup with the parent repository and model argument all checks out.

src/Domain/Messaging/Service/Constructor/MailConstructorInterface.php (1)

1-18: Clean interface abstraction!

This interface provides a clear contract for mail construction and enables flexible dependency injection. The documentation is precise and the return type tuple is well-defined.

tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php (1)

12-12: Namespace import correctly updated.

The import path properly reflects the new location of SystemMailConstructor in the Constructor namespace.

tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php (2)

11-11: Import path correctly updated.

Properly reflects the new namespace for SystemMailConstructor.


72-72: Parameter rename aligns with interface-based dependency.

Changing from systemMailConstructor to mailConstructor correctly reflects that EmailBuilder now depends on MailConstructorInterface rather than a specific implementation.

config/services/messenger.yml (1)

34-37: DI configuration is properly structured.

The service configuration follows Symfony conventions with appropriate autowiring and argument injection. Both referenced dependencies check out: Core.EmailBuilder.system is defined in builders.yml and app.config.message_from_address is defined in parameters.yml.

config/services/builders.yml (1)

27-50: LGTM! Clean separation of email builder variants.

The service definitions properly wire two EmailBuilder instances with different mail constructors while sharing common configuration parameters. This design allows for clean separation between system and campaign email construction logic.

config/services/services.yml (3)

46-77: LGTM! Well-documented service definitions.

The service definitions for content processing components (Html2Text, HtmlUrlRewriter, ExternalImageService, TemplateImageEmbedder) are properly configured with clear comments explaining their purpose and sensible default values.


178-192: LGTM! Clean service definitions for message processing.

The service definitions for MessageDataLoader, TextParser, and RemotePageFetcher are properly configured with appropriate parameter bindings and clear documentation.


194-201: LGTM! MessagePrecacheService properly configured.

The service definition includes all necessary parameters for pre-caching campaign message content with appropriate parameter bindings.

config/services/parameters.yml (1)

1-16: LGTM! Pragmatic dual-format parameter configuration.

The approach of providing both flattened parameters (for direct DI) and grouped arrays (for legacy/config-provider usage) is well-documented and pragmatic, addressing Symfony's limitation with dot-notation access into parameter arrays.

src/Domain/Common/RemotePageFetcher.php (2)

81-92: LGTM! Better error semantics with null return.

Returning null on fetch failure (instead of an empty string) provides clearer semantics—an empty string could be valid content, whereas null explicitly signals failure. This aligns well with the nullable return type and makes error handling more explicit.


31-31: Null handling already in place.

The return type change to ?string is justified by the implementation. Both callers (RateLimitedCampaignMailer and MessagePrecacheService) properly handle null returns—one with an explicit === null check that throws an exception, the other with a falsy check that returns early. No action needed.

src/Domain/Messaging/Service/Builder/EmailBuilder.php (2)

11-11: Nice refactor to interface-based dependency.

Switching from SystemMailConstructor to MailConstructorInterface is a solid move for flexibility and testability. The callable invocation pattern on line 63 is clean.

Also applies to: 29-29, 63-63


65-72: Named arguments improve readability here.

Using named arguments for createBaseEmail makes the call site self-documenting. Good stuff.

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

198-212: Named arguments and OutputFormat usage look good.

The explicit OutputFormat::Text parameter makes the personalization intent clear. Named arguments throughout improve readability.


214-221: Email composition flow updated correctly.

The new flow using composeEmail + send on rateLimitedCampaignMailer is clean. The previous concern about null checks from buildPhplistEmail no longer applies here since composeEmail returns a non-nullable Email.

src/Domain/Messaging/Service/Constructor/SystemMailConstructor.php (2)

5-5: Clean interface implementation.

The namespace move to Constructor subdirectory and MailConstructorInterface implementation are well done. The explicit string $content type improves type safety.

Also applies to: 13-13, 27-29


75-75: Good type annotation on private method.

Adding explicit string type to $message parameter aligns with the interface contract.

src/Domain/Messaging/Service/RateLimitedCampaignMailer.php (4)

20-27: Constructor promotion is clean.

The new dependencies are well organized with readonly property promotion. Good use of dependency injection.


35-38: User-specific content handling added.

Good check for the userSpecificUrl flag before doing the additional work. The flow is clear: fetch user data, then replace remote content.


67-103: Remote content replacement implementation looks solid.

The regex pattern for [URL:...] tokens, URL scheme normalization, and error handling with proper exception throwing are all well implemented.

One small consideration: the method mutates $messagePrecacheDto directly rather than returning a new instance. This is fine for a service-layer operation, just worth noting for anyone reading the code later.


89-96: Good error handling for remote fetch failures.

Logging the failure before throwing RemotePageFetchException provides good observability. However, this will abort the entire email send for this subscriber.

Is this the intended behavior? If one [URL:...] token fails to fetch, the whole email fails. Consider whether you'd want to:

  1. Skip that specific token and continue (graceful degradation)
  2. Fail the entire message (current behavior - strict mode)

If strict is intended, this is fine as-is.

Comment on lines +209 to +217
public function getDataById(int $subscriberId): array
{
return $this->createQueryBuilder('s')
->select('s')
->where('s.id = :subscriberId')
->setParameter('subscriberId', $subscriberId)
->getQuery()
->getArrayResult();
}
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

getArrayResult() returns an array of rows, not a single row.

When querying by a unique ID, getArrayResult() will return [['id' => 1, 'email' => '...', ...]] if found or [] if not found. This is probably not what callers expect for a "get by ID" method.

Consider using getOneOrNullResult() or accessing the first element explicitly.

🔎 Proposed fix
 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();
+        ->getOneOrNullResult(\Doctrine\ORM\Query::HYDRATE_ARRAY);
+    
+    return $result ?? [];
 }
🤖 Prompt for AI Agents
In src/Domain/Subscription/Repository/SubscriberRepository.php around lines
209-217, the method uses getArrayResult() which returns an array of rows rather
than a single row; change the query to fetch a single associative row by
replacing getArrayResult() with getOneOrNullResult(AbstractQuery::HYDRATE_ARRAY)
(or setMaxResults(1) and take the first element), and ensure the method returns
an associative array for a found subscriber or an empty array (or null if
preferred by callers) when none is found.

Comment on lines +233 to +248
// 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('<p>Test HTML message</p>');
});
->html('<p>Test HTML message</p>')
);
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

Property name mismatch will cause test failure.

The test tries to access a property named campaignEmailBuilder, but looking at the handler constructor (line 69), the property is actually named emailBuilder. This will throw a ReflectionException at runtime.

🔎 Proposed fix
         // campaign emails are built via campaignEmailBuilder and sent via RateLimitedCampaignMailer
         $campaignEmailBuilder = (new ReflectionClass($this->handler))
-            ->getProperty("campaignEmailBuilder");
+            ->getProperty("emailBuilder");
         /** @var EmailBuilder|MockObject $campaignBuilderMock */
         $campaignBuilderMock = $campaignEmailBuilder->getValue($this->handler);
🤖 Prompt for AI Agents
In
tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php
around lines 233 to 248, the test uses Reflection to access a non-existent
property campaignEmailBuilder which will throw a ReflectionException because the
handler property is named emailBuilder (see constructor at line 69); update the
ReflectionClass->getProperty call to use "emailBuilder" and adjust the local
variable name if desired so the mock is retrieved from the correct property,
then keep the existing expectation/method stubbing against that mock.

Comment on lines +239 to +248
$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('<p>Test HTML message</p>');
});
->html('<p>Test HTML message</p>')
);
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

🧩 Analysis chain

🏁 Script executed:

cat -n src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php | sed -n '210,225p'

Repository: phpList/core

Length of output: 912


🏁 Script executed:

cat -n tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php | sed -n '235,250p'

Repository: phpList/core

Length of output: 834


🏁 Script executed:

rg -n "composeEmail|buildPhplistEmail" src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php

Repository: phpList/core

Length of output: 197


🏁 Script executed:

cat -n src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php | sed -n '240,260p'

Repository: phpList/core

Length of output: 1169


🏁 Script executed:

cat -n src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php | sed -n '1,50p'

Repository: phpList/core

Length of output: 2747


🏁 Script executed:

cat -n tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php | sed -n '225,260p'

Repository: phpList/core

Length of output: 1648


🏁 Script executed:

cat -n tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php | sed -n '180,240p'

Repository: phpList/core

Length of output: 2960


🏁 Script executed:

cat -n tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php | sed -n '300,310p'

Repository: phpList/core

Length of output: 747


🏁 Script executed:

cat -n tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php | sed -n '378,395p'

Repository: phpList/core

Length of output: 1027


🏁 Script executed:

cat -n src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php | sed -n '50,120p'

Repository: phpList/core

Length of output: 3855


🏁 Script executed:

rg -n "campaignEmailBuilder|rateLimitedCampaignMailer" src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php | head -20

Repository: phpList/core

Length of output: 274


🏁 Script executed:

cat -n src/Domain/Messaging/Service/RateLimitedCampaignMailer.php | head -50

Repository: phpList/core

Length of output: 2252


🏁 Script executed:

rg -n "composeEmail" src/Domain/Messaging/Service/RateLimitedCampaignMailer.php -A 10 | head -30

Repository: phpList/core

Length of output: 522


🏁 Script executed:

cat -n tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php | sed -n '1,50p'

Repository: phpList/core

Length of output: 2885


🏁 Script executed:

cat -n tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php | sed -n '50,100p'

Repository: phpList/core

Length of output: 3141


🏁 Script executed:

rg -n "campaignEmailBuilder|mailer" tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php | head -20

Repository: phpList/core

Length of output: 1268


🏁 Script executed:

rg -n "campaignEmailBuilder" src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php -B 2 -A 2

Repository: phpList/core

Length of output: 38


🏁 Script executed:

cat -n src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php | sed -n '50,75p'

Repository: phpList/core

Length of output: 1592


🏁 Script executed:

rg -n "campaignEmailBuilder" src/ -t php

Repository: phpList/core

Length of output: 38


🏁 Script executed:

cat -n tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php | sed -n '233,240p'

Repository: phpList/core

Length of output: 574


🏁 Script executed:

python3 << 'EOF'
# Check if property "campaignEmailBuilder" exists in the handler
import subprocess
result = subprocess.run(
    ['grep', '-n', 'campaignEmailBuilder', 'src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php'],
    capture_output=True,
    text=True
)
print("Exit code:", result.returncode)
print("Output:", result.stdout)
print("Stderr:", result.stderr)
EOF

Repository: phpList/core

Length of output: 86


🏁 Script executed:

git ls-files src/Domain/Messaging/MessageHandler/ | head -20

Repository: phpList/core

Length of output: 416


🏁 Script executed:

cat -n tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php | sed -n '260,280p'

Repository: phpList/core

Length of output: 1137


🏁 Script executed:

cat -n src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php | grep -n "private\|public" | head -30

Repository: phpList/core

Length of output: 2288


🏁 Script executed:

python3 << 'EOF'
# Let's trace through the handler constructor to understand the properties
import re

with open('src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php', 'r') as f:
    content = f.read()
    
# Find all constructor parameters
matches = re.findall(r'private readonly (\w+) \$(\w+)', content)
print("Constructor parameters:")
for type_hint, param_name in matches:
    print(f"  ${param_name}: {type_hint}")
    
# Check if campaignEmailBuilder exists anywhere
if 'campaignEmailBuilder' in content:
    print("\nFound 'campaignEmailBuilder' in handler file")
else:
    print("\n'campaignEmailBuilder' NOT found in handler file")
    
if 'emailBuilder' in content:
    print("Found 'emailBuilder' in handler file")
else:
    print("'emailBuilder' NOT found in handler file")
EOF

Repository: phpList/core

Length of output: 897


🏁 Script executed:

cat -n tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php | sed -n '195,260p'

Repository: phpList/core

Length of output: 3182


🏁 Script executed:

cat -n src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php | sed -n '192,240p'

Repository: phpList/core

Length of output: 2445


🏁 Script executed:

cat -n tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php | sed -n '80,105p'

Repository: phpList/core

Length of output: 1409


🏁 Script executed:

python3 << 'EOF'
# Check if tests actually run and what happens when accessing nonexistent property
import subprocess

# Check if the test would fail
result = subprocess.run(
    ['grep', '-A', '5', 'getProperty', 'tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php'],
    capture_output=True,
    text=True
)
print("Test property access patterns:")
print(result.stdout)
EOF

Repository: phpList/core

Length of output: 1067


🏁 Script executed:

python3 << 'EOF'
# Let's verify if the test would actually run by checking if the property exists
import subprocess
import sys

# Run a simple PHP/reflection check simulation
test_code = '''<?php
class TestHandler {
    private readonly string $emailBuilder;
    public function __construct() {}
}

$reflection = new ReflectionClass(TestHandler::class);
try {
    $prop = $reflection->getProperty("campaignEmailBuilder");
    echo "Property found\\n";
} catch (ReflectionException $e) {
    echo "Property NOT found: " . $e->getMessage() . "\\n";
}

try {
    $prop = $reflection->getProperty("emailBuilder");
    echo "emailBuilder property found\\n";
} catch (ReflectionException $e) {
    echo "emailBuilder NOT found\\n";
}
?>'''

with open('/tmp/test_reflection.php', 'w') as f:
    f.write(test_code)

result = subprocess.run(['php', '/tmp/test_reflection.php'], capture_output=True, text=True)
print(result.stdout)
print(result.stderr)
EOF

Repository: phpList/core

Length of output: 164


Test mocks nonexistent property and wrong method on RateLimitedCampaignMailer.

The tests access a property campaignEmailBuilder that doesn't exist in the handler (which only has emailBuilder). Even if it did exist, the test mocks buildPhplistEmail, but the handler never calls that method for campaign emails. Instead, at line 215, the handler calls $this->rateLimitedCampaignMailer->composeEmail() followed by send().

The test setup should mock the actual RateLimitedCampaignMailer instance ($this->mailer in the test) with expectations on composeEmail() and send(), not attempt to access a nonexistent property to mock a different method.

Affects lines 239-248, 305-307, 383-388.

🤖 Prompt for AI Agents
In
tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php
around lines 239-248 (also affecting 305-307 and 383-388), the test is trying to
stub a nonexistent property campaignEmailBuilder and mock buildPhplistEmail, but
the handler actually uses $this->rateLimitedCampaignMailer (the test's
$this->mailer) and calls composeEmail() then send(); update the test to remove
any references to campaignEmailBuilder, instead set expectations on
$this->mailer (or the RateLimitedCampaignMailer mock) to receive
composeEmail(...) returning the Email object used in assertions and then expect
send(...) to be called (with appropriate args), and adjust the assertions at the
other affected line ranges to reflect these mocked calls.

Comment on lines +299 to +307
// Build email and throw on rate-limited sender
$campaignEmailBuilder = (new ReflectionClass($this->handler))
->getProperty("campaignEmailBuilder");
$campaignEmailBuilder->setAccessible(true);
/** @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'));
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

Same property name issue here.

Same fix needed - use emailBuilder instead of campaignEmailBuilder.

🔎 Proposed fix
         // Build email and throw on rate-limited sender
         $campaignEmailBuilder = (new ReflectionClass($this->handler))
-            ->getProperty("campaignEmailBuilder");
+            ->getProperty("emailBuilder");
         $campaignEmailBuilder->setAccessible(true);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Build email and throw on rate-limited sender
$campaignEmailBuilder = (new ReflectionClass($this->handler))
->getProperty("campaignEmailBuilder");
$campaignEmailBuilder->setAccessible(true);
/** @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'));
// Build email and throw on rate-limited sender
$campaignEmailBuilder = (new ReflectionClass($this->handler))
->getProperty("emailBuilder");
$campaignEmailBuilder->setAccessible(true);
/** @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'));
🤖 Prompt for AI Agents
In
tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php
around lines 299 to 307, the test accesses the wrong private property name
"campaignEmailBuilder"; change it to "emailBuilder" everywhere in this block
(the ReflectionClass->getProperty call and any related comments/annotations),
keep setAccessible(true) and getValue($this->handler) as before, and ensure the
docblock/typehint reflects EmailBuilder|MockObject if present.

Comment on lines +378 to +388
// 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'),
(new Email())->to('test2@example.com')->subject('Test Subject')->text('x')
);
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

Same property name mismatch.

Consistent fix needed here too.

🔎 Proposed fix
         // Configure builder to return emails for first two subscribers
         $campaignEmailBuilder = (new ReflectionClass($this->handler))
-            ->getProperty("campaignEmailBuilder");
+            ->getProperty("emailBuilder");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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'),
(new Email())->to('test2@example.com')->subject('Test Subject')->text('x')
);
// Configure builder to return emails for first two subscribers
$campaignEmailBuilder = (new ReflectionClass($this->handler))
->getProperty("emailBuilder");
/** @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'),
(new Email())->to('test2@example.com')->subject('Test Subject')->text('x')
);
🤖 Prompt for AI Agents
In
tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php
around lines 378 to 388, the ReflectionClass is trying to access a nonexistent
property "campaignEmailBuilder"; change the property name to the correct one
used in the class (e.g., "campaignBuilder") in the getProperty call, and adjust
the local variable name/comment if needed so the reflected property and mock
refer to the same, correct property.

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 @src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php:
- Around line 24-34: In BlacklistUrlValueResolver::__invoke replace the
incorrect ConfigOption::UnsubscribeUrl usage with ConfigOption::BlacklistUrl so
the resolver reads the blacklist base URL from the config; i.e. change the
argument passed to $this->config->getValue(...) and keep the subsequent call to
$this->urlBuilder->withEmail($base, $ctx->getUser()->getEmail()) and the HTML
escaping logic unchanged.

In @src/Domain/Messaging/Service/Constructor/PersonalizedContentConstructor.php:
- Line 63: The code in PersonalizedContentConstructor.php currently defaults
schemeless URLs to 'http://' which downgrades security; change the fallback in
the URL normalization logic (the expression assigning $url from $rawUrl, e.g.,
the ternary using preg_match('/^https?:\/\//i', $rawUrl)) to prepend 'https://'
instead of 'http://' so schemeless inputs become HTTPS by default; ensure you
only alter the fallback string and keep the existing regex check and variable
names ($rawUrl and $url) intact.
🧹 Nitpick comments (14)
src/Domain/Configuration/Service/Placeholder/JumpoffValueResolver.php (1)

29-33: Clarify the intent: HTML context returns empty string while text appends &jo=1.

The asymmetric behavior is a bit unusual—HTML renders nothing while text gets the full URL with the jump-off parameter. If this is intentional (e.g., HTML uses a different placeholder), a brief comment would help future maintainers understand the design choice.

Also, consider using http_build_query or the LegacyUrlBuilder pattern instead of raw string concatenation for &jo=1 to stay consistent with the URL-building approach elsewhere.

src/Domain/Configuration/Service/LegacyUrlBuilder.php (1)

19-38: Consider defensive handling for malformed or empty base URLs.

If $baseUrl is empty or lacks a host, the reconstructed URL could become malformed (e.g., https:// or https://:8080/path). The current fallbacks ($host ?? '') won't prevent this.

A quick guard at the top or returning $baseUrl unchanged when it's empty would prevent downstream surprises.

🛡️ Optional guard
 private function withQueryParam(string $baseUrl, string $paramName, string $paramValue): string
 {
+    if ($baseUrl === '') {
+        return '';
+    }
+
     $parts = parse_url($baseUrl) ?: [];
+    if (empty($parts['host'])) {
+        return $baseUrl;
+    }
+
     $query = [];
src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php (1)

11-11: Use the contracts interface instead of the deprecated one.

Symfony\Component\Translation\TranslatorInterface is deprecated. The modern alternative is Symfony\Contracts\Translation\TranslatorInterface.

📦 Fix the import
-use Symfony\Component\Translation\TranslatorInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php (2)

11-11: Use the contracts interface instead of the deprecated one.

Same as UnsubscribeValueResolver—switch to Symfony\Contracts\Translation\TranslatorInterface.

📦 Fix the import
-use Symfony\Component\Translation\TranslatorInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;

31-36: Consider a more appropriate label for the blacklist action.

The translation key 'Unsubscribe' is reused here, but blacklisting is semantically different from unsubscribing—blacklisting typically means "never email this address again" across all lists. Using a distinct key like 'Blacklist' or 'Stop all emails' would be clearer for users.

config/services/services.yml (1)

61-65: Hardcoded defaults might be better as parameters.

These literal defaults (externalImageMaxAge: 0, externalImageMaxSize: 204800, externalImageTimeout: 30) work, but extracting them to parameters.yml would make them easier to configure per environment without touching service definitions.

src/Domain/Messaging/Service/Constructor/PersonalizedContentConstructor.php (2)

16-16: Address the TODO or remove it before merging.

The // todo: check this class comment suggests this might be a work-in-progress. If it's ready for review, consider removing the TODO; if not, perhaps the review should wait.

Want me to open an issue to track any remaining work on this class?


76-76: Potential HTML comment injection if URL contains --.

If $url contains --, the resulting <!--$url--> becomes malformed HTML. Unlikely in practice, but something to be aware of if URLs come from user input.

src/Domain/Messaging/Service/RateLimitedCampaignMailer.php (3)

29-56: Method marked for removal but still contains business logic.

The // todo: remove this method comment is noted, but this method still has active callers (presumably). If it's truly deprecated, consider adding a @deprecated annotation so static analysis tools can flag usage.

Also worth noting: the method now calls replaceUserSpecificRemoteContent which creates side effects by mutating $messagePrecacheDto - might be worth documenting that the DTO is modified in-place.


90-97: Exception thrown after logging - good pattern.

Nice that you log the error before throwing RemotePageFetchException. However, the exception is thrown with no message or context. Consider passing meaningful info to the exception:

💡 Suggestion
-            throw new RemotePageFetchException();
+            throw new RemotePageFetchException(
+                sprintf('Failed to fetch remote URL: %s for subscriber: %s', $rawUrl, $subscriber->getEmail())
+            );

68-104: Duplication is intentional—composeEmail is marked for removal.

The method replaceUserSpecificRemoteContent is indeed duplicated in PersonalizedContentConstructor, but since composeEmail has a // todo: remove this method comment (line 29), this duplication is temporary and expected as part of the migration. No action needed here.

Minor edge case: the regex pattern [^\s]+ will break if a URL contains a ] character (e.g., [URL:https://example.com/path]query]), since it stops at the first non-whitespace ]. Unlikely but possible in URLs with special query params.

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

62-73: Consider consistent alignment for constructor parameters.

The alignment here is a bit inconsistent - some parameters have extra spacing (lines 62-73) while earlier ones don't. Not a big deal, just a formatting nit.


205-214: Format-aware personalization is a nice addition.

Using OutputFormat::Text for both textContent and footer is correct for the text email part. The named parameters make the intent clear.

Small formatting nit on line 208: missing space after format:.

🧹 Minor formatting fix
-            format:OutputFormat::Text,
+            format: OutputFormat::Text,
src/Domain/Configuration/Service/UserPersonalizer.php (1)

44-48: Inconsistent closure signatures between resolvers.

Line 44 uses fn(PlaceholderContext $ctx) => ... for EMAIL, but the closures for CONFIRMATIONURL (line 50), PREFERENCESURL (line 65), and LISTS (line 88) don't accept PlaceholderContext and instead use $format from the outer scope via use.

This inconsistency could lead to confusion. The injected $placeholderResolvers (line 46-48) likely implement __invoke(PlaceholderContext $ctx), so they'll work correctly. But the inline closures are mixed.

Consider making all closures accept PlaceholderContext $ctx for consistency, or document why some use closure capture instead.

♻️ Example of consistent approach
-        $resolver->register('CONFIRMATIONURL', function () use ($user, $format) {
+        $resolver->register('CONFIRMATIONURL', function (PlaceholderContext $ctx) use ($user) {
             $base = $this->config->getValue(ConfigOption::ConfirmationUrl) ?? '';
             $url = $this->urlBuilder->withUid($base, $user->getUniqueId());

-            if ($format === OutputFormat::Html) {
+            if ($ctx->isHtml()) {
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 975b418 and 9b45ab3.

📒 Files selected for processing (17)
  • config/services/resolvers.yml
  • config/services/services.yml
  • src/Domain/Configuration/Model/ConfigOption.php
  • src/Domain/Configuration/Model/Dto/PlaceholderContext.php
  • src/Domain/Configuration/Service/LegacyUrlBuilder.php
  • src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/JumpoffValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/PlaceholderValueResolverInterface.php
  • src/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php
  • src/Domain/Configuration/Service/PlaceholderResolver.php
  • src/Domain/Configuration/Service/UserPersonalizer.php
  • src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
  • src/Domain/Messaging/Service/Constructor/PersonalizedContentConstructor.php
  • src/Domain/Messaging/Service/RateLimitedCampaignMailer.php
🧰 Additional context used
📓 Path-based instructions (2)
src/Domain/**

⚙️ CodeRabbit configuration file

src/Domain/**: You are reviewing PHP domain-layer code. Enforce domain purity, with a relaxed policy for DynamicListAttr:

  • ❌ Do not allow, flag ANY DB write / finalization:

    • $entityManager->flush(...), $this->entityManager->flush(...)
    • $em->beginTransaction(), $em->commit(), $em->rollback(), $em->transactional(...)
    • $em->getConnection()->executeStatement(...) for DML/DDL (INSERT/UPDATE/DELETE/ALTER/...)
  • ✅ Accessing Doctrine metadata, schema manager, or read-only schema info is acceptable
    as long as it does not modify state or perform writes. Accessing Doctrine persistence APIs
    persist, remove, etc.) is acceptable, allow scheduling changes in the UnitOfWork (no DB writes)

  • Relaxed rule for DynamicListAttr-related code:

    • DynamicListAttr is a special case dealing with dynamic tables/attrs.
    • It is acceptable for DynamicListAttr repositories/services to:
      • Create/update/drop DynamicListAttr tables/columns.
      • Use Doctrine persistence APIs (persist, remove, flush, etc.)
        as part of managing DynamicListAttr data and schema.
    • Do not flag persistence or schema-creation calls that are clearly scoped
      to DynamicListAttr tables or their management.
    • Still prefer keeping this logic well-encapsulated (e.g. in dedicated services/repos),
      not scattered across unrelated domain objects.
  • ⚠️ For non-DynamicListAttr code:

    • If code is invoking actual table-creation, DDL execution, or schema synchronization,
      then request moving that to the Infrastructure or Application layer (e.g. MessageHandler).
    • Repositories in Domain should be abstractions without side effects; they should express intent,
      not perform flush/transactional logic.

Files:

  • src/Domain/Configuration/Model/ConfigOption.php
  • src/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolver.php
  • src/Domain/Configuration/Model/Dto/PlaceholderContext.php
  • src/Domain/Configuration/Service/Placeholder/PlaceholderValueResolverInterface.php
  • src/Domain/Messaging/Service/Constructor/PersonalizedContentConstructor.php
  • src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/JumpoffValueResolver.php
  • src/Domain/Configuration/Service/UserPersonalizer.php
  • src/Domain/Configuration/Service/LegacyUrlBuilder.php
  • src/Domain/Configuration/Service/PlaceholderResolver.php
  • src/Domain/Messaging/Service/RateLimitedCampaignMailer.php
  • src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
src/**/MessageHandler/**

⚙️ CodeRabbit configuration file

src/**/MessageHandler/**: Background jobs/workers may perform persistence and schema management.

  • ✅ Allow $entityManager->flush() when the job is the orchestration boundary.
  • ✅ Allow table creation, migration, or schema synchronization (e.g. via Doctrine SchemaTool or SchemaManager),
    as this is considered infrastructure-level orchestration.
  • For DynamicListAttr-related jobs, it is fine to orchestrate both data and schema changes here,
    as long as responsibilities remain clear and behavior is predictable.
  • Verify idempotency for schema operations where practical — e.g., check if a table exists before creating.
  • Ensure domain-layer code invoked by the job (outside the DynamicListAttr exception) remains free of persistence calls.
  • Batch flush operations where practical.

Files:

  • src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
🧬 Code graph analysis (8)
src/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolver.php (4)
src/Domain/Configuration/Service/Placeholder/JumpoffValueResolver.php (2)
  • JumpoffValueResolver (12-35)
  • name (19-22)
src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php (1)
  • name (19-22)
src/Domain/Configuration/Service/Placeholder/PlaceholderValueResolverInterface.php (1)
  • name (11-11)
src/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolver.php (1)
  • name (19-22)
src/Domain/Configuration/Service/Placeholder/PlaceholderValueResolverInterface.php (7)
src/Domain/Configuration/Model/Dto/PlaceholderContext.php (1)
  • PlaceholderContext (10-38)
src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php (2)
  • name (19-22)
  • __invoke (24-34)
src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php (2)
  • name (21-24)
  • __invoke (26-40)
src/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolver.php (1)
  • name (9-12)
src/Domain/Configuration/Service/Placeholder/JumpoffValueResolver.php (2)
  • name (19-22)
  • __invoke (24-34)
src/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolver.php (2)
  • name (19-22)
  • __invoke (24-34)
src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php (2)
  • name (21-24)
  • __invoke (26-40)
src/Domain/Messaging/Service/Constructor/PersonalizedContentConstructor.php (8)
src/Domain/Common/RemotePageFetcher.php (1)
  • RemotePageFetcher (17-125)
src/Domain/Configuration/Service/UserPersonalizer.php (2)
  • UserPersonalizer (18-121)
  • __construct (22-34)
src/Domain/Messaging/Exception/RemotePageFetchException.php (1)
  • RemotePageFetchException (9-15)
src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php (1)
  • MessagePrecacheDto (8-31)
src/Domain/Subscription/Repository/SubscriberRepository.php (2)
  • SubscriberRepository (21-218)
  • getDataById (209-217)
src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php (1)
  • __construct (14-17)
src/Domain/Configuration/Service/Placeholder/JumpoffValueResolver.php (1)
  • __construct (14-17)
src/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolver.php (1)
  • __construct (14-17)
src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php (7)
src/Domain/Configuration/Service/LegacyUrlBuilder.php (2)
  • LegacyUrlBuilder (7-39)
  • withEmail (14-17)
src/Domain/Configuration/Service/Provider/ConfigProvider.php (1)
  • ConfigProvider (12-82)
src/Domain/Configuration/Model/Dto/PlaceholderContext.php (3)
  • PlaceholderContext (10-38)
  • getUser (34-37)
  • isHtml (19-22)
src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php (2)
  • name (19-22)
  • __invoke (24-34)
src/Domain/Configuration/Service/Placeholder/JumpoffValueResolver.php (2)
  • name (19-22)
  • __invoke (24-34)
src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php (2)
  • name (21-24)
  • __invoke (26-40)
src/Domain/Configuration/Service/Placeholder/PlaceholderValueResolverInterface.php (2)
  • name (11-11)
  • __invoke (12-12)
src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php (6)
src/Domain/Configuration/Service/LegacyUrlBuilder.php (2)
  • LegacyUrlBuilder (7-39)
  • withUid (9-12)
src/Domain/Configuration/Model/Dto/PlaceholderContext.php (4)
  • PlaceholderContext (10-38)
  • __construct (12-17)
  • getUser (34-37)
  • isHtml (19-22)
src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php (3)
  • __construct (15-19)
  • name (21-24)
  • __invoke (26-40)
src/Domain/Configuration/Service/Placeholder/JumpoffValueResolver.php (3)
  • __construct (14-17)
  • name (19-22)
  • __invoke (24-34)
src/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolver.php (3)
  • __construct (14-17)
  • name (19-22)
  • __invoke (24-34)
src/Domain/Configuration/Service/Placeholder/PlaceholderValueResolverInterface.php (2)
  • name (11-11)
  • __invoke (12-12)
src/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolver.php (5)
src/Domain/Configuration/Service/LegacyUrlBuilder.php (2)
  • LegacyUrlBuilder (7-39)
  • withUid (9-12)
src/Domain/Configuration/Service/Provider/ConfigProvider.php (1)
  • ConfigProvider (12-82)
src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php (3)
  • __construct (15-19)
  • name (21-24)
  • __invoke (26-40)
src/Domain/Configuration/Service/Placeholder/JumpoffValueResolver.php (3)
  • __construct (14-17)
  • name (19-22)
  • __invoke (24-34)
src/Domain/Configuration/Service/Placeholder/PlaceholderValueResolverInterface.php (2)
  • name (11-11)
  • __invoke (12-12)
src/Domain/Configuration/Service/UserPersonalizer.php (3)
src/Domain/Configuration/Model/Dto/PlaceholderContext.php (1)
  • PlaceholderContext (10-38)
src/Domain/Subscription/Repository/SubscriberListRepository.php (2)
  • SubscriberListRepository (23-94)
  • getActiveListNamesForSubscriber (78-93)
src/Domain/Configuration/Service/PlaceholderResolver.php (3)
  • PlaceholderResolver (9-39)
  • register (14-17)
  • resolve (19-38)
src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php (2)
src/Domain/Messaging/Service/Constructor/PersonalizedContentConstructor.php (2)
  • PersonalizedContentConstructor (17-82)
  • build (27-43)
src/Domain/Configuration/Service/UserPersonalizer.php (2)
  • UserPersonalizer (18-121)
  • personalize (36-120)
🔇 Additional comments (17)
config/services/resolvers.yml (1)

16-19: Nice setup for auto-tagging placeholder resolvers.

The _instanceof configuration cleanly wires up all PlaceholderValueResolverInterface implementations with the phplist.placeholder_resolver tag. This keeps the service definitions DRY as new resolvers are added.

src/Domain/Configuration/Model/Dto/PlaceholderContext.php (1)

10-37: Clean DTO design with format-aware helpers.

The PlaceholderContext class is well-structured with readonly properties and useful predicates (isHtml(), isText()). The getUser() alias provides a nice semantic accessor. Solid work.

src/Domain/Configuration/Model/ConfigOption.php (1)

13-13: Consistent enum case addition.

The new BlacklistUrl case follows the existing naming and value conventions. 👍

src/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolver.php (1)

7-12: Potential issue: inherited __invoke() returns empty string for HTML.

This class inherits __invoke() from JumpoffValueResolver, which returns '' when $ctx->isHtml() is true. Other *UrlValueResolver classes (like UnsubscribeUrlValueResolver, BlacklistUrlValueResolver) return the actual URL (HTML-escaped) in HTML context.

Is returning an empty string for JUMPOFFURL in HTML intentional, or should this class override __invoke() to return the URL like its sibling resolvers?

Also, the implements PlaceholderValueResolverInterface is redundant since the parent already implements it.

♻️ If URL should be returned for HTML context
 class JumpoffUrlValueResolver extends JumpoffValueResolver implements PlaceholderValueResolverInterface
 {
     public function name(): string
     {
         return 'JUMPOFFURL';
     }
+
+    public function __invoke(PlaceholderContext $ctx): string
+    {
+        $base = $this->config->getValue(ConfigOption::UnsubscribeUrl) ?? '';
+        $url = $this->urlBuilder->withUid($base, $ctx->getUser()->getUniqueId()) . '&jo=1';
+
+        if ($ctx->isHtml()) {
+            return htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+        }
+
+        return $url;
+    }
 }

Note: This would require constructor injection of ConfigProvider and LegacyUrlBuilder, or making parent properties protected.

src/Domain/Configuration/Service/Placeholder/PlaceholderValueResolverInterface.php (1)

9-13: Clean interface design.

The callable pattern via __invoke() is a nice touch - keeps resolvers simple and easy to use. The name() method provides clear placeholder identification.

src/Domain/Configuration/Service/Placeholder/UnsubscribeUrlValueResolver.php (1)

12-34: Solid implementation with proper HTML escaping.

The resolver correctly builds the unsubscribe URL and applies appropriate HTML escaping for the HTML context. Clean and consistent with the resolver pattern.

src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php (1)

26-40: LGTM—clean HTML escaping and consistent pattern.

Proper use of htmlspecialchars for both label and URL, and the resolver follows the same structure as the related BlacklistValueResolver. Nice and tidy.

config/services/services.yml (1)

196-202: LGTM—service wiring for message precaching looks solid.

The dependencies and parameters are properly configured. Minor note: consider adding a trailing newline at EOF for consistency with POSIX conventions.

src/Domain/Configuration/Service/PlaceholderResolver.php (1)

25-37: Clean implementation with a note on case sensitivity.

The regex [A-Z0-9_]+ only matches uppercase placeholders. If lowercase or mixed-case tokens like [email] need to work, you'd want to add the i flag or include a-z in the character class. If uppercase-only is intentional, that's fine—just wanted to flag it.

The str_contains bail-out and the explicit string cast are nice touches.

src/Domain/Messaging/Service/Constructor/PersonalizedContentConstructor.php (1)

80-80: strip_tags comparison is a pragmatic HTML detector—works for this use case.

Just noting that this heuristic (strip_tags($content) !== $content) will mark content as HTML-formatted if it contains any tags, including benign ones like <br>. That's probably fine for email content, but worth keeping in mind.

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

20-27: Constructor looks solid with the new DI approach.

Clean property promotion with all the new dependencies needed for remote content fetching. No domain purity violations here.

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

11-26: Clean dependency swap from EmailBuilder (for compose) to PersonalizedContentConstructor.

The transition looks good. Note that you still keep EmailBuilder for admin notifications (line 70), which makes sense since that's a different flow.

Also applies to: 70-73


216-222: No null-return concern here; the type signature protects against it.

The build() method returns Email (not ?Email), so null is not a valid return value according to the type system. Any exception (including RemotePageFetchException from remote content replacement) is caught by the Throwable handler at line 231, which keeps the error handling safe.

src/Domain/Configuration/Service/UserPersonalizer.php (4)

22-34: Constructor expanded with format-aware dependencies.

The new dependencies make sense for supporting format-aware rendering. The $preferencePageShowPrivateLists flag with a default is a nice touch for backwards compatibility.


50-63: CONFIRMATIONURL resolver with proper HTML escaping - looks good.

The htmlspecialchars with ENT_QUOTES | ENT_SUBSTITUTE and explicit UTF-8 encoding is the correct approach for XSS prevention. The trailing space via self::PHP_SPACE is preserved from previous behavior.


87-109: LISTS resolver is nicely format-aware.

Good handling of:

  • Empty list case with translated message
  • HTML escaping of list names when in HTML format
  • Different separators (<br/> vs \n)

One thought: the empty-list message is quite long. If this appears in a layout-sensitive context, it might be worth having a shorter fallback. But that's a UX decision, not a code issue.


119-119: PlaceholderContext instantiation looks correct.

Passing $user and $format to the context aligns with the expected constructor signature from the relevant snippets. The context is then passed to resolve() which dispatches to all registered resolvers.

Comment on lines +24 to +34
public function __invoke(PlaceholderContext $ctx): string
{
$base = $this->config->getValue(ConfigOption::UnsubscribeUrl) ?? '';
$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 | 🟠 Major

Likely bug: should use ConfigOption::BlacklistUrl instead of UnsubscribeUrl.

Line 26 uses ConfigOption::UnsubscribeUrl as the base, but this is BlacklistUrlValueResolver. The new ConfigOption::BlacklistUrl case was added in this PR - should this resolver be using that instead?

For comparison, BlacklistValueResolver (from snippets) uses ConfigOption::BlacklistUrl.

🐛 Proposed fix
     public function __invoke(PlaceholderContext $ctx): string
     {
-        $base = $this->config->getValue(ConfigOption::UnsubscribeUrl) ?? '';
+        $base = $this->config->getValue(ConfigOption::BlacklistUrl) ?? '';
         $url = $this->urlBuilder->withEmail($base, $ctx->getUser()->getEmail());
 
         if ($ctx->isHtml()) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function __invoke(PlaceholderContext $ctx): string
{
$base = $this->config->getValue(ConfigOption::UnsubscribeUrl) ?? '';
$url = $this->urlBuilder->withEmail($base, $ctx->getUser()->getEmail());
if ($ctx->isHtml()) {
return htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
return $url;
}
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;
}
🤖 Prompt for AI Agents
In @src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php
around lines 24 - 34, In BlacklistUrlValueResolver::__invoke replace the
incorrect ConfigOption::UnsubscribeUrl usage with ConfigOption::BlacklistUrl so
the resolver reads the blacklist base URL from the config; i.e. change the
argument passed to $this->config->getValue(...) and keep the subsequent call to
$this->urlBuilder->withEmail($base, $ctx->getUser()->getEmail()) and the HTML
escaping logic unchanged.

continue;
}

$url = preg_match('/^https?:\/\//i', $rawUrl) ? $rawUrl : 'http://' . $rawUrl;
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

Default to https:// rather than http://.

Falling back to http:// for schemeless URLs is a security downgrade in 2025. Most sites expect HTTPS these days, and using HTTP could trigger mixed-content warnings or fail entirely on HTTPS-only hosts.

🔒 Suggested fix
-$url = preg_match('/^https?:\/\//i', $rawUrl) ? $rawUrl : 'http://' . $rawUrl;
+$url = preg_match('/^https?:\/\//i', $rawUrl) ? $rawUrl : 'https://' . $rawUrl;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$url = preg_match('/^https?:\/\//i', $rawUrl) ? $rawUrl : 'http://' . $rawUrl;
$url = preg_match('/^https?:\/\//i', $rawUrl) ? $rawUrl : 'https://' . $rawUrl;
🤖 Prompt for AI Agents
In @src/Domain/Messaging/Service/Constructor/PersonalizedContentConstructor.php
at line 63, The code in PersonalizedContentConstructor.php currently defaults
schemeless URLs to 'http://' which downgrades security; change the fallback in
the URL normalization logic (the expression assigning $url from $rawUrl, e.g.,
the ternary using preg_match('/^https?:\/\//i', $rawUrl)) to prepend 'https://'
instead of 'http://' so schemeless inputs become HTTPS by default; ensure you
only alter the fallback string and keep the existing regex check and variable
names ($rawUrl and $url) intact.

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: 9

🤖 Fix all issues with AI agents
In @resources/translations/messages.en.xlf:
- Around line 753-772: The target strings for the translation units PpLvt2Z,
njfOwly, 7r3SSnf, YibbDEd, and BkWF7jw incorrectly include a "__" prefix; update
each <target> value to exactly match its <source> (remove the "__" prefix) so
"Unsubscribe", "This link", "Confirm", "Update preferences", and "Sorry, you are
not subscribed to any of our newsletters with this email address." are used as
the target values.

In
@src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php:
- Around line 22-32: The uid is inserted into the query string without
URL-encoding in the __invoke method (using $ctx->getUser()->getUniqueId()),
which can break URLs; compute an encoded uid (e.g. $encodedUid =
rawurlencode($ctx->getUser()->getUniqueId())) and use that in both return paths,
and in the HTML branch also HTML-escape the encoded uid (e.g.
htmlspecialchars($encodedUid)) so the query value is both URL-safe and safe for
HTML output.

In @src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php:
- Around line 27-35: The HTML branch in ForwardUrlValueResolver.php (inside the
conditional using $ctx->isHtml() and the sprintf call) omits the trailing space
present in the non-HTML branch; edit the sprintf format string in that branch to
append a single trailing space (e.g., add a space after the final substitution
in the format) so the HTML output formatting matches the non-HTML branch while
preserving the existing htmlspecialchars calls and argument order.

In @src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php:
- Around line 34-41: The sprintf call in ForwardValueResolver returning the
anchor should URL-encode the uid from $ctx->getUser()->getUniqueId() and handle
nullable messageId from $ctx->messageId(); change the format to accept a string
for message id (or coalesce to a safe default) and pass
urlencode($ctx->getUser()->getUniqueId()) and a null-safe message id (e.g.,
$ctx->messageId() ?? 0 or (string)($ctx->messageId() ?? '')) so the uid won’t
break the query string and the message id won’t cause a %d type error.

In
@src/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolver.php:
- Around line 27-29: When rendering in HTML context inside
PreferencesUrlValueResolver (the branch checking $ctx->isHtml()), the $url is
not escaped; update the HTML branch to run the configured PreferencesUrl through
htmlspecialchars (same flags/encoding/boolean for double-encode as used in
ForwardUrlValueResolver) before formatting the final string so both the URL and
$sep are escaped and the output avoids rendering/XSS issues.

In @src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php:
- Around line 34-40: The uid parameter is not URL-encoded when building the link
in PreferencesValueResolver (the sprintf using $ctx->getUser()->getUniqueId()),
which can break links with special characters; update the code to URL-encode the
uid (use rawurlencode(...) as done in ForwardValueResolver) when interpolating
into the query string and apply the same change to the plain-text path
occurrence on the subsequent line so both HTML and text outputs use the encoded
UID.

In @src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php:
- Around line 25-34: The code calls
$this->config->getValue(ConfigOption::PoweredByText) and
$this->config->getValue(ConfigOption::PoweredByImage) which return ?string but
the method expects a non-nullable string and preg_replace on null triggers PHP
8.1+ deprecation warnings; fix by coalescing those calls to a safe string (e.g.
use null coalescing to '' or a sensible default) before returning or passing
into preg_replace, e.g. replace direct calls in the $ctx->isHtml() branch with
$text = $this->config->getValue(ConfigOption::PoweredByText) ?? '' and $img =
$this->config->getValue(ConfigOption::PoweredByImage) ?? '' and use $text /
preg_replace(..., $img) so the method never receives null.

In @src/Domain/Configuration/Service/UserPersonalizer.php:
- Around line 58-63: The CONTACTURL resolver currently unconditionally applies
htmlspecialchars; change it to only escape when the placeholder context requests
HTML by checking PlaceholderContext::$ctx->isHtml(): fetch the URL via
$this->config->getValue(ConfigOption::VCardUrl) ?? '', and return
htmlspecialchars(...) only if $ctx->isHtml() is true, otherwise return the raw
URL string so text-format output isn't HTML-escaped.

In @src/Domain/Messaging/Service/Constructor/MailConstructor.php:
- Around line 94-97: The build() method in MailConstructor.php currently returns
a bare new Email() discarding the computed $htmlmessage, $textmessage,
recipients and footer data; update the method to apply those computed values to
the Email instance (use the Email returned from new Email() and call its setters
to set subject, from, to/cc/bcc, setHtml/setText or equivalent, add attachments
and any headers/footers) and then return that configured Email object instead of
an empty one so the generated content and recipients are actually sent.
🧹 Nitpick comments (5)
src/Domain/Configuration/Service/Placeholder/SubscribeValueResolver.php (1)

28-34: Consider sprintf for consistency.

String concatenation works fine here, but other resolvers (like ForwardValueResolver) use sprintf for building HTML. Using a consistent approach across resolvers makes the codebase easier to maintain.

Optional refactor
-            return '<a href="' . $safeUrl . '">' . $safeLabel . '</a>';
+            return sprintf('<a href="%s">%s</a>', $safeUrl, $safeLabel);
src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php (1)

30-34: Consider potential preg_replace failure.

preg_replace() can return null on error, but the method signature requires a non-nullable string. While errors are rare, defensive handling would improve robustness.

💡 Optional: Add fallback for preg_replace errors
-            return preg_replace(
+            $result = preg_replace(
                 '/src=".*power-phplist.png"/',
                 'src="powerphplist.png"',
                 $this->config->getValue(ConfigOption::PoweredByImage) ?? ''
             );
+            
+            return $result ?? '';
src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php (1)

31-34: Potential XSS: translated message not escaped in HTML context.

When the list is empty, the translated string is returned raw. If output in HTML context, it could be a vector for XSS (unlikely with translations, but good hygiene).

♻️ Consider escaping the fallback message in HTML context
         if ($names === []) {
-            return $this->translator
-                ->trans('Sorry, you are not subscribed to any of our newsletters with this email address.');
+            $msg = $this->translator
+                ->trans('Sorry, you are not subscribed to any of our newsletters with this email address.');
+            return $ctx->isHtml()
+                ? htmlspecialchars($msg, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
+                : $msg;
         }
src/Domain/Messaging/Service/Constructor/MailConstructor.php (2)

41-42: Unused variables: $defaultstyle and $adddefaultstyle.

These are assigned but never read. If they're intended for future use, consider adding a TODO; otherwise, remove them to reduce noise.


117-117: Consider defaulting to HTTPS instead of HTTP.

Falling back to http:// for URLs without a protocol could cause mixed content issues or security downgrades on HTTPS sites.

♻️ Prefer HTTPS as default protocol
-            $url = preg_match('/^https?:\/\//i', $rawUrl) ? $rawUrl : 'http://' . $rawUrl;
+            $url = preg_match('/^https?:\/\//i', $rawUrl) ? $rawUrl : 'https://' . $rawUrl;
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9b45ab3 and de8f4a8.

📒 Files selected for processing (19)
  • config/parameters.yml.dist
  • config/services/builders.yml
  • resources/translations/messages.en.xlf
  • src/Domain/Configuration/Model/ConfigOption.php
  • src/Domain/Configuration/Model/Dto/PlaceholderContext.php
  • src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/SubscribeValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php
  • src/Domain/Configuration/Service/UserPersonalizer.php
  • src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
  • src/Domain/Messaging/Service/Constructor/MailConstructor.php
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/Domain/Configuration/Model/ConfigOption.php
  • src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php
  • config/parameters.yml.dist
  • config/services/builders.yml
🧰 Additional context used
📓 Path-based instructions (2)
src/Domain/**

⚙️ CodeRabbit configuration file

src/Domain/**: You are reviewing PHP domain-layer code. Enforce domain purity, with a relaxed policy for DynamicListAttr:

  • ❌ Do not allow, flag ANY DB write / finalization:

    • $entityManager->flush(...), $this->entityManager->flush(...)
    • $em->beginTransaction(), $em->commit(), $em->rollback(), $em->transactional(...)
    • $em->getConnection()->executeStatement(...) for DML/DDL (INSERT/UPDATE/DELETE/ALTER/...)
  • ✅ Accessing Doctrine metadata, schema manager, or read-only schema info is acceptable
    as long as it does not modify state or perform writes. Accessing Doctrine persistence APIs
    persist, remove, etc.) is acceptable, allow scheduling changes in the UnitOfWork (no DB writes)

  • Relaxed rule for DynamicListAttr-related code:

    • DynamicListAttr is a special case dealing with dynamic tables/attrs.
    • It is acceptable for DynamicListAttr repositories/services to:
      • Create/update/drop DynamicListAttr tables/columns.
      • Use Doctrine persistence APIs (persist, remove, flush, etc.)
        as part of managing DynamicListAttr data and schema.
    • Do not flag persistence or schema-creation calls that are clearly scoped
      to DynamicListAttr tables or their management.
    • Still prefer keeping this logic well-encapsulated (e.g. in dedicated services/repos),
      not scattered across unrelated domain objects.
  • ⚠️ For non-DynamicListAttr code:

    • If code is invoking actual table-creation, DDL execution, or schema synchronization,
      then request moving that to the Infrastructure or Application layer (e.g. MessageHandler).
    • Repositories in Domain should be abstractions without side effects; they should express intent,
      not perform flush/transactional logic.

Files:

  • src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolver.php
  • src/Domain/Configuration/Service/UserPersonalizer.php
  • src/Domain/Configuration/Service/Placeholder/SubscribeValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php
  • src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php
  • src/Domain/Messaging/Service/Constructor/MailConstructor.php
  • src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php
  • src/Domain/Configuration/Model/Dto/PlaceholderContext.php
  • src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php
  • src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
  • src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php
src/**/MessageHandler/**

⚙️ CodeRabbit configuration file

src/**/MessageHandler/**: Background jobs/workers may perform persistence and schema management.

  • ✅ Allow $entityManager->flush() when the job is the orchestration boundary.
  • ✅ Allow table creation, migration, or schema synchronization (e.g. via Doctrine SchemaTool or SchemaManager),
    as this is considered infrastructure-level orchestration.
  • For DynamicListAttr-related jobs, it is fine to orchestrate both data and schema changes here,
    as long as responsibilities remain clear and behavior is predictable.
  • Verify idempotency for schema operations where practical — e.g., check if a table exists before creating.
  • Ensure domain-layer code invoked by the job (outside the DynamicListAttr exception) remains free of persistence calls.
  • Batch flush operations where practical.

Files:

  • src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
🧬 Code graph analysis (13)
src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php (4)
src/Domain/Configuration/Service/Provider/ConfigProvider.php (1)
  • ConfigProvider (12-82)
src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php (3)
  • __construct (13-15)
  • name (17-20)
  • __invoke (22-32)
src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php (3)
  • __construct (13-17)
  • name (19-22)
  • __invoke (24-46)
src/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolver.php (3)
  • __construct (13-15)
  • name (17-20)
  • __invoke (22-32)
src/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolver.php (4)
src/Domain/Configuration/Service/Provider/ConfigProvider.php (1)
  • ConfigProvider (12-82)
src/Domain/Configuration/Model/Dto/PlaceholderContext.php (3)
  • PlaceholderContext (10-44)
  • __construct (12-18)
  • isHtml (20-23)
src/Domain/Configuration/Service/Placeholder/SubscribeValueResolver.php (3)
  • __construct (14-17)
  • name (19-22)
  • __invoke (24-37)
src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php (3)
  • __construct (15-19)
  • name (21-24)
  • __invoke (26-44)
src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php (3)
src/Domain/Configuration/Service/Provider/ConfigProvider.php (1)
  • ConfigProvider (12-82)
src/Domain/Configuration/Model/Dto/PlaceholderContext.php (1)
  • PlaceholderContext (10-44)
src/Domain/Configuration/Service/UserPersonalizer.php (1)
  • __construct (18-26)
src/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolver.php (4)
src/Domain/Configuration/Service/Provider/ConfigProvider.php (1)
  • ConfigProvider (12-82)
src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php (3)
  • __construct (13-15)
  • name (17-20)
  • __invoke (22-32)
src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php (3)
  • __construct (13-17)
  • name (19-22)
  • __invoke (24-46)
src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php (3)
  • __construct (14-17)
  • name (19-22)
  • __invoke (24-44)
src/Domain/Configuration/Service/UserPersonalizer.php (3)
src/Domain/Configuration/Service/Provider/ConfigProvider.php (1)
  • ConfigProvider (12-82)
src/Domain/Configuration/Model/Dto/PlaceholderContext.php (4)
  • PlaceholderContext (10-44)
  • __construct (12-18)
  • messageId (35-38)
  • forwardedBy (30-33)
src/Domain/Configuration/Service/PlaceholderResolver.php (3)
  • PlaceholderResolver (9-39)
  • register (14-17)
  • resolve (19-38)
src/Domain/Configuration/Service/Placeholder/SubscribeValueResolver.php (3)
src/Domain/Configuration/Model/Dto/PlaceholderContext.php (3)
  • PlaceholderContext (10-44)
  • __construct (12-18)
  • isHtml (20-23)
src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php (3)
  • __construct (14-17)
  • name (19-22)
  • __invoke (24-45)
src/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolver.php (3)
  • __construct (13-15)
  • name (17-20)
  • __invoke (22-31)
src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php (3)
src/Domain/Configuration/Model/Dto/PlaceholderContext.php (1)
  • PlaceholderContext (10-44)
src/Domain/Subscription/Repository/SubscriberListRepository.php (2)
  • SubscriberListRepository (23-94)
  • getActiveListNamesForSubscriber (78-93)
src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php (3)
  • __construct (14-17)
  • name (19-22)
  • __invoke (24-44)
src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php (3)
src/Domain/Configuration/Service/Provider/ConfigProvider.php (1)
  • ConfigProvider (12-82)
src/Domain/Configuration/Model/Dto/PlaceholderContext.php (5)
  • PlaceholderContext (10-44)
  • __construct (12-18)
  • isHtml (20-23)
  • getUser (40-43)
  • messageId (35-38)
src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php (3)
  • __construct (13-15)
  • name (17-20)
  • __invoke (22-38)
src/Domain/Messaging/Service/Constructor/MailConstructor.php (8)
src/Domain/Common/Html2Text.php (1)
  • Html2Text (10-85)
src/Domain/Common/RemotePageFetcher.php (1)
  • RemotePageFetcher (17-125)
src/Domain/Common/TextParser.php (1)
  • TextParser (7-95)
src/Domain/Configuration/Service/Provider/ConfigProvider.php (1)
  • ConfigProvider (12-82)
src/Domain/Configuration/Service/UserPersonalizer.php (1)
  • UserPersonalizer (16-82)
src/Domain/Messaging/Exception/RemotePageFetchException.php (1)
  • RemotePageFetchException (9-15)
src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php (1)
  • MessagePrecacheDto (8-31)
src/Domain/Subscription/Repository/SubscriberRepository.php (2)
  • SubscriberRepository (21-218)
  • getDataById (209-217)
src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php (3)
src/Domain/Configuration/Service/Provider/ConfigProvider.php (1)
  • ConfigProvider (12-82)
src/Domain/Configuration/Model/Dto/PlaceholderContext.php (1)
  • PlaceholderContext (10-44)
src/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolver.php (3)
  • __construct (13-15)
  • name (17-20)
  • __invoke (22-32)
src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php (3)
src/Domain/Configuration/Service/Provider/ConfigProvider.php (1)
  • ConfigProvider (12-82)
src/Domain/Configuration/Model/Dto/PlaceholderContext.php (5)
  • PlaceholderContext (10-44)
  • __construct (12-18)
  • isHtml (20-23)
  • getUser (40-43)
  • messageId (35-38)
src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php (3)
  • __construct (14-17)
  • name (19-22)
  • __invoke (24-45)
src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php (2)
src/Domain/Messaging/Service/Constructor/MailConstructor.php (2)
  • MailConstructor (21-136)
  • build (35-97)
src/Domain/Messaging/Service/RateLimitedCampaignMailer.php (1)
  • send (61-66)
src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php (3)
src/Domain/Configuration/Service/LegacyUrlBuilder.php (2)
  • LegacyUrlBuilder (7-39)
  • withUid (9-12)
src/Domain/Configuration/Service/Placeholder/JumpoffValueResolver.php (3)
  • __construct (14-17)
  • name (19-22)
  • __invoke (24-34)
src/Domain/Configuration/Service/Placeholder/PlaceholderValueResolverInterface.php (2)
  • name (11-11)
  • __invoke (12-12)
🔇 Additional comments (14)
src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php (1)

13-20: LGTM!

Constructor and name implementation follow the established pattern for placeholder resolvers.

src/Domain/Configuration/Service/Placeholder/SubscribeValueResolver.php (1)

14-22: LGTM!

Constructor and name implementation are clean and follow the established pattern.

src/Domain/Configuration/Model/Dto/PlaceholderContext.php (1)

10-44: LGTM!

Clean DTO design with appropriate use of readonly properties and constructor promotion. The context provides all necessary information for format-aware placeholder resolution.

src/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolver.php (1)

13-20: LGTM!

Constructor and name implementation follow the standard resolver pattern.

src/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolver.php (1)

11-32: LGTM!

Clean implementation with proper HTML escaping and straightforward logic. The resolver correctly handles both HTML and text output formats.

src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php (1)

14-22: LGTM!

Clean constructor with proper dependency injection and straightforward resolver name implementation.

src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php (1)

15-44: LGTM!

This resolver properly handles URL building via LegacyUrlBuilder.withUid(), which ensures URL encoding. The conditional logic for forwarded contexts (switching to BlacklistUrl) is clear, and HTML escaping is correctly applied.

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

204-210: TODO comment suggests incomplete implementation.

The // todo: use correct service comment hints this might be a work-in-progress. The flow looks solid—building email via MailConstructor then sending via rateLimitedCampaignMailer—but you might want to clarify what "correct service" refers to or remove the TODO if resolved.


69-69: Nice addition of MailConstructor dependency.

The constructor injection looks good and follows the existing pattern in this handler. The separation of email construction from sending is a clean design choice.

src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php (1)

24-46: Clean implementation following the established pattern.

The HTML escaping for list names and context-aware separator logic are well done. This mirrors the approach in PreferencesValueResolver nicely.

src/Domain/Messaging/Service/Constructor/MailConstructor.php (1)

20-21: Address the TODO comment.

The // todo: check this class hints at uncertainty. Given the incomplete build() method, this class seems to need significant work before it's ready.

src/Domain/Configuration/Service/UserPersonalizer.php (3)

44-44: FORWARDFORM returns empty string—intentional placeholder?

If this is WIP, a TODO comment would help. If the feature is deprecated or not needed, consider removing it entirely.


65-67: Nice extensibility via injected placeholder resolvers.

The iterable pattern allows adding new resolvers without modifying this class. Clean separation of concerns.


28-34: Good refactor to format-aware personalization.

The new signature with OutputFormat and context fields enables format-specific rendering throughout the placeholder system. The PlaceholderContext encapsulation is tidy.

Also applies to: 77-80

Comment on lines +753 to +772
<trans-unit id="PpLvt2Z" resname="Unsubscribe">
<source>Unsubscribe</source>
<target>__Unsubscribe</target>
</trans-unit>
<trans-unit id="njfOwly" resname="This link">
<source>This link</source>
<target>__This link</target>
</trans-unit>
<trans-unit id="7r3SSnf" resname="Confirm">
<source>Confirm</source>
<target>__Confirm</target>
</trans-unit>
<trans-unit id="YibbDEd" resname="Update preferences">
<source>Update preferences</source>
<target>__Update preferences</target>
</trans-unit>
<trans-unit id="BkWF7jw" resname="Sorry, you are not subscribed to any of our newsletters with this email address.">
<source>Sorry, you are not subscribed to any of our newsletters with this email address.</source>
<target>__Sorry, you are not subscribed to any of our newsletters with this email address.</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 examine the translation file structure and count the __ pattern
wc -l resources/translations/messages.en.xlf

Repository: phpList/core

Length of output: 98


🏁 Script executed:

# Count how many target values have the __ prefix
rg '<target>__' resources/translations/messages.en.xlf | wc -l

Repository: phpList/core

Length of output: 58


🏁 Script executed:

# Check how many total target values exist
rg '<target>' resources/translations/messages.en.xlf | wc -l

Repository: phpList/core

Length of output: 59


🏁 Script executed:

# Look for the specific translation units mentioned in lines 753-772
sed -n '753,772p' resources/translations/messages.en.xlf

Repository: phpList/core

Length of output: 1015


🏁 Script executed:

# Search codebase for how translation strings are used/imported
fd -e ts -e js -e tsx -e jsx | head -20 | xargs rg 'messages\.en' 2>/dev/null | head -10

Repository: phpList/core

Length of output: 38


🏁 Script executed:

# Look for any configuration or documentation about the __ prefix
fd -e md -e txt -e json | xargs rg '__' 2>/dev/null | grep -i "translation\|prefix\|placeholder" | head -10

Repository: phpList/core

Length of output: 38


🏁 Script executed:

# Check the beginning of the translation file for any documentation
head -30 resources/translations/messages.en.xlf

Repository: phpList/core

Length of output: 1545


🏁 Script executed:

# Look for other language translation files
fd -e xlf resources/translations/

Repository: phpList/core

Length of output: 453


🏁 Script executed:

# Check one other language file to see if it also uses __ prefix
rg '<target>__' resources/translations/messages.*.xlf 2>/dev/null

Repository: phpList/core

Length of output: 6080


🏁 Script executed:

# Search for code that loads or processes XLIFF files
fd -e php -e ts -e js | xargs rg 'xlf|XLIFF|translation' 2>/dev/null | head -15

Repository: phpList/core

Length of output: 606


🏁 Script executed:

# Look at a sample of targets without __ prefix to understand the pattern
rg '<target>(?!__)' resources/translations/messages.en.xlf | head -5

Repository: phpList/core

Length of output: 281


🏁 Script executed:

# Check git history of the translation file to understand the pattern
git log --oneline -20 resources/translations/messages.en.xlf

Repository: phpList/core

Length of output: 79


🏁 Script executed:

# Look for any code that processes or strips the __ prefix
rg '__' src/ --type php --type ts --type js -B 2 -A 2 | head -30

Repository: phpList/core

Length of output: 2120


🏁 Script executed:

# Check if there's a pattern in how translations are loaded
rg 'xlf|Xliff|XLIFF' src/ --type php -B 2 -A 2

Repository: phpList/core

Length of output: 38


🏁 Script executed:

# Check if there's any configuration or setup file that mentions this pattern
fd -e yaml -e yml -e json -e md | xargs rg '__' 2>/dev/null | grep -i "translation\|xliff\|prefix" | head -10

Repository: phpList/core

Length of output: 38


🏁 Script executed:

# Look at more context around the file to understand intent
head -100 resources/translations/messages.en.xlf | tail -60

Repository: phpList/core

Length of output: 3674


🏁 Script executed:

# Check if there are other language files to compare
fd --full-path 'resources/translations/' . -name '*.xlf'

Repository: phpList/core

Length of output: 286


Remove the __ prefix from target values to match source strings.

The new translation units incorrectly prefix target values with __ (e.g., __Unsubscribe, __This link). For an English-to-English translation file, targets should match sources exactly. This inconsistency appears earlier in the file where entries are correctly formed—compare the new entries against lines like "Not authorized" which correctly have target matching source. If these strings are used in the application, the __ prefix will display to users as-is, breaking the UI text.

Diff showing required fixes:
-        <target>__Unsubscribe</target>
+        <target>Unsubscribe</target>

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

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

-        <target>__Update preferences</target>
+        <target>Update preferences</target>

-        <target>__Sorry, you are not subscribed to any of our newsletters with this email address.</target>
+        <target>Sorry, you are not subscribed to any of our newsletters with this email address.</target>
🤖 Prompt for AI Agents
In @resources/translations/messages.en.xlf around lines 753 - 772, The target
strings for the translation units PpLvt2Z, njfOwly, 7r3SSnf, YibbDEd, and
BkWF7jw incorrectly include a "__" prefix; update each <target> value to exactly
match its <source> (remove the "__" prefix) so "Unsubscribe", "This link",
"Confirm", "Update preferences", and "Sorry, you are not subscribed to any of
our newsletters with this email address." are used as the target values.

Comment on lines +22 to +32
public function __invoke(PlaceholderContext $ctx): string
{
$url = $this->config->getValue(ConfigOption::SubscribeUrl) ?? '';
$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());
}
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

URL-encode the uid parameter.

The uid value is directly concatenated into the query string without URL encoding on both lines 28 and 31. This will produce malformed URLs if the uid contains special characters.

🔧 Suggested fix
         if ($ctx->isHtml()) {
-            return sprintf('%s%suid=%s', $url, htmlspecialchars($sep), $ctx->getUser()->getUniqueId());
+            return sprintf('%s%suid=%s', $url, htmlspecialchars($sep), urlencode($ctx->getUser()->getUniqueId()));
         }
 
-        return sprintf('%s%suid=%s', $url, $sep, $ctx->getUser()->getUniqueId());
+        return sprintf('%s%suid=%s', $url, $sep, urlencode($ctx->getUser()->getUniqueId()));
🤖 Prompt for AI Agents
In
@src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php
around lines 22 - 32, The uid is inserted into the query string without
URL-encoding in the __invoke method (using $ctx->getUser()->getUniqueId()),
which can break URLs; compute an encoded uid (e.g. $encodedUid =
rawurlencode($ctx->getUser()->getUniqueId())) and use that in both return paths,
and in the HTML branch also HTML-escape the encoded uid (e.g.
htmlspecialchars($encodedUid)) so the query value is both URL-safe and safe for
HTML output.

Comment on lines +27 to +35
if ($ctx->isHtml()) {
return sprintf(
'%s%suid=%s&amp;mid=%d',
htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
htmlspecialchars($sep),
$ctx->getUser()->getUniqueId(),
$ctx->messageId(),
);
}
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

Add trailing space in HTML branch for consistency.

The non-HTML branch (line 37) includes a trailing space, but the HTML branch doesn't. For consistent formatting across output modes, consider adding a space after the closing tag.

Suggested fix
         if ($ctx->isHtml()) {
             return sprintf(
-                '%s%suid=%s&amp;mid=%d',
+                '%s%suid=%s&amp;mid=%d ',
                 htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
                 htmlspecialchars($sep),
                 $ctx->getUser()->getUniqueId(),
                 $ctx->messageId(),
             );
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ($ctx->isHtml()) {
return sprintf(
'%s%suid=%s&amp;mid=%d',
htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
htmlspecialchars($sep),
$ctx->getUser()->getUniqueId(),
$ctx->messageId(),
);
}
if ($ctx->isHtml()) {
return sprintf(
'%s%suid=%s&amp;mid=%d ',
htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
htmlspecialchars($sep),
$ctx->getUser()->getUniqueId(),
$ctx->messageId(),
);
}
🤖 Prompt for AI Agents
In @src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php
around lines 27 - 35, The HTML branch in ForwardUrlValueResolver.php (inside the
conditional using $ctx->isHtml() and the sprintf call) omits the trailing space
present in the non-HTML branch; edit the sprintf format string in that branch to
append a single trailing space (e.g., add a space after the final substitution
in the format) so the HTML output formatting matches the non-HTML branch while
preserving the existing htmlspecialchars calls and argument order.

Comment on lines +34 to +41
return sprintf(
'<a href="%s%suid=%s&amp;mid=%d">%s</a> ',
$safeUrl,
htmlspecialchars($sep),
$ctx->getUser()->getUniqueId(),
$ctx->messageId(),
$safeLabel
);
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

URL-encode the uid parameter to prevent broken query strings.

The uid value from $ctx->getUser()->getUniqueId() is concatenated directly into the URL without URL encoding. If the uid contains special characters like &, =, or ?, the query string will break.

Additionally, $ctx->messageId() can return null (per PlaceholderContext), but it's used with %d format specifier, which expects an integer.

🔧 Suggested fix with URL encoding and null safety
             return sprintf(
-                '<a href="%s%suid=%s&amp;mid=%d">%s</a> ',
+                '<a href="%s%suid=%s&amp;mid=%s">%s</a> ',
                 $safeUrl,
                 htmlspecialchars($sep),
-                $ctx->getUser()->getUniqueId(),
-                $ctx->messageId(),
+                urlencode($ctx->getUser()->getUniqueId()),
+                urlencode((string)($ctx->messageId() ?? '')),
                 $safeLabel
             );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return sprintf(
'<a href="%s%suid=%s&amp;mid=%d">%s</a> ',
$safeUrl,
htmlspecialchars($sep),
$ctx->getUser()->getUniqueId(),
$ctx->messageId(),
$safeLabel
);
return sprintf(
'<a href="%s%suid=%s&amp;mid=%s">%s</a> ',
$safeUrl,
htmlspecialchars($sep),
urlencode($ctx->getUser()->getUniqueId()),
urlencode((string)($ctx->messageId() ?? '')),
$safeLabel
);
🤖 Prompt for AI Agents
In @src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php around
lines 34 - 41, The sprintf call in ForwardValueResolver returning the anchor
should URL-encode the uid from $ctx->getUser()->getUniqueId() and handle
nullable messageId from $ctx->messageId(); change the format to accept a string
for message id (or coalesce to a safe default) and pass
urlencode($ctx->getUser()->getUniqueId()) and a null-safe message id (e.g.,
$ctx->messageId() ?? 0 or (string)($ctx->messageId() ?? '')) so the uid won’t
break the query string and the message id won’t cause a %d type error.

Comment on lines +27 to +29
if ($ctx->isHtml()) {
return sprintf('%s%suid=%s', $url, htmlspecialchars($sep), $ctx->getUser()->getUniqueId());
}
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

Escape the URL in HTML context for security.

The separator is being escaped but the URL itself isn't. Other resolvers like ForwardUrlValueResolver (line 30) properly escape the URL. If the configured PreferencesUrl contains special HTML characters, this could lead to rendering issues or XSS vulnerabilities.

Recommended fix
         if ($ctx->isHtml()) {
-            return sprintf('%s%suid=%s', $url, htmlspecialchars($sep), $ctx->getUser()->getUniqueId());
+            return sprintf(
+                '%s%suid=%s',
+                htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
+                htmlspecialchars($sep),
+                $ctx->getUser()->getUniqueId()
+            );
         }
🤖 Prompt for AI Agents
In @src/Domain/Configuration/Service/Placeholder/PreferencesUrlValueResolver.php
around lines 27 - 29, When rendering in HTML context inside
PreferencesUrlValueResolver (the branch checking $ctx->isHtml()), the $url is
not escaped; update the HTML branch to run the configured PreferencesUrl through
htmlspecialchars (same flags/encoding/boolean for double-encode as used in
ForwardUrlValueResolver) before formatting the final string so both the URL and
$sep are escaped and the output avoids rendering/XSS issues.

Comment on lines +34 to +40
return sprintf(
'<a href="%s%suid=%s">%s</a> ',
$safeUrl,
htmlspecialchars($sep),
$ctx->getUser()->getUniqueId(),
$safeLabel,
);
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

URL-encode the uid parameter.

Similar to ForwardValueResolver, the uid is not URL-encoded before being concatenated into the query string. If the uid contains special URL characters, the generated link will break.

🔧 Suggested fix
             return sprintf(
                 '<a href="%s%suid=%s">%s</a> ',
                 $safeUrl,
                 htmlspecialchars($sep),
-                $ctx->getUser()->getUniqueId(),
+                urlencode($ctx->getUser()->getUniqueId()),
                 $safeLabel,
             );

Apply the same fix to line 43 for the plain text path:

-        return sprintf('%s%suid=%s', $url, $sep, $ctx->getUser()->getUniqueId());
+        return sprintf('%s%suid=%s', $url, $sep, urlencode($ctx->getUser()->getUniqueId()));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return sprintf(
'<a href="%s%suid=%s">%s</a> ',
$safeUrl,
htmlspecialchars($sep),
$ctx->getUser()->getUniqueId(),
$safeLabel,
);
return sprintf(
'<a href="%s%suid=%s">%s</a> ',
$safeUrl,
htmlspecialchars($sep),
urlencode($ctx->getUser()->getUniqueId()),
$safeLabel,
);
🤖 Prompt for AI Agents
In @src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php
around lines 34 - 40, The uid parameter is not URL-encoded when building the
link in PreferencesValueResolver (the sprintf using
$ctx->getUser()->getUniqueId()), which can break links with special characters;
update the code to URL-encode the uid (use rawurlencode(...) as done in
ForwardValueResolver) when interpolating into the query string and apply the
same change to the plain-text path occurrence on the subsequent line so both
HTML and text outputs use the encoded UID.

Comment on lines +25 to +34
if ($ctx->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)
);
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

Fix null handling for config values.

Lines 27 and 33 call $this->config->getValue(), which returns ?string, but the method's return type is non-nullable string. If the config values are missing, this produces a type error. Additionally, preg_replace on null (line 30-34) will trigger deprecation warnings in PHP 8.1+.

🔧 Suggested fix with null coalescing
         if ($ctx->isHtml()) {
             if ($this->emailTextCredits) {
-                return $this->config->getValue(ConfigOption::PoweredByText);
+                return $this->config->getValue(ConfigOption::PoweredByText) ?? '';
             }
 
             return preg_replace(
                 '/src=".*power-phplist.png"/',
                 'src="powerphplist.png"',
-                $this->config->getValue(ConfigOption::PoweredByImage)
+                $this->config->getValue(ConfigOption::PoweredByImage) ?? ''
             );
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ($ctx->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)
);
if ($ctx->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) ?? ''
);
🤖 Prompt for AI Agents
In @src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php
around lines 25 - 34, The code calls
$this->config->getValue(ConfigOption::PoweredByText) and
$this->config->getValue(ConfigOption::PoweredByImage) which return ?string but
the method expects a non-nullable string and preg_replace on null triggers PHP
8.1+ deprecation warnings; fix by coalescing those calls to a safe string (e.g.
use null coalescing to '' or a sensible default) before returning or passing
into preg_replace, e.g. replace direct calls in the $ctx->isHtml() branch with
$text = $this->config->getValue(ConfigOption::PoweredByText) ?? '' and $img =
$this->config->getValue(ConfigOption::PoweredByImage) ?? '' and use $text /
preg_replace(..., $img) so the method never receives null.

Comment on lines 58 to 63
$resolver->register(
'SUBSCRIBEURL',
fn() => ($this->config->getValue(ConfigOption::SubscribeUrl) ?? '') . self::PHP_SPACE
name: 'CONTACTURL',
resolver: fn(PlaceholderContext $ctx) => 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 | 🟡 Minor

CONTACTURL applies htmlspecialchars unconditionally.

For text format output, HTML escaping isn't needed and could produce unexpected &amp; sequences in URLs. Other resolvers check $ctx->isHtml() before escaping.

🐛 Make escaping conditional on format
         $resolver->register(
             name: 'CONTACTURL',
-            resolver: fn(PlaceholderContext $ctx) => htmlspecialchars(
-                $this->config->getValue(ConfigOption::VCardUrl) ?? ''
-            )
+            resolver: fn(PlaceholderContext $ctx) => $ctx->isHtml()
+                ? htmlspecialchars($this->config->getValue(ConfigOption::VCardUrl) ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
+                : ($this->config->getValue(ConfigOption::VCardUrl) ?? '')
         );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$resolver->register(
'SUBSCRIBEURL',
fn() => ($this->config->getValue(ConfigOption::SubscribeUrl) ?? '') . self::PHP_SPACE
name: 'CONTACTURL',
resolver: fn(PlaceholderContext $ctx) => htmlspecialchars(
$this->config->getValue(ConfigOption::VCardUrl) ?? ''
)
);
$resolver->register(
name: 'CONTACTURL',
resolver: fn(PlaceholderContext $ctx) => $ctx->isHtml()
? htmlspecialchars($this->config->getValue(ConfigOption::VCardUrl) ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
: ($this->config->getValue(ConfigOption::VCardUrl) ?? '')
);
🤖 Prompt for AI Agents
In @src/Domain/Configuration/Service/UserPersonalizer.php around lines 58 - 63,
The CONTACTURL resolver currently unconditionally applies htmlspecialchars;
change it to only escape when the placeholder context requests HTML by checking
PlaceholderContext::$ctx->isHtml(): fetch the URL via
$this->config->getValue(ConfigOption::VCardUrl) ?? '', and return
htmlspecialchars(...) only if $ctx->isHtml() is true, otherwise return the raw
URL string so text-format output isn't HTML-escaped.

Comment on lines +94 to +97

$mail = new Email();
return $mail;
}
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: build() returns an empty Email without applying computed content.

All the work done to compute $htmlmessage, $textmessage, and footers is discarded—the method returns new Email() with no subject, body, recipients, or anything else configured. This will send blank emails.

🐛 The Email needs to be configured with the computed values
         $mail = new Email();
+        // Apply computed content to the email
+        // $mail->subject(...);
+        // $mail->html($htmlmessage . $html['footer']);
+        // $mail->text($textmessage . $text['footer']);
+        // $mail->from(...);
+        // $mail->to($subscriber->getEmail());
         return $mail;

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @src/Domain/Messaging/Service/Constructor/MailConstructor.php around lines 94
- 97, The build() method in MailConstructor.php currently returns a bare new
Email() discarding the computed $htmlmessage, $textmessage, recipients and
footer data; update the method to apply those computed values to the Email
instance (use the Email returned from new Email() and call its setters to set
subject, from, to/cc/bcc, setHtml/setText or equivalent, add attachments and any
headers/footers) and then return that configured Email object instead of an
empty one so the generated content and recipients are actually sent.

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