Skip to content

Feat: message sending-forwarding#374

Open
TatevikGr wants to merge 39 commits intomainfrom
ref/campaign-processing
Open

Feat: message sending-forwarding#374
TatevikGr wants to merge 39 commits intomainfrom
ref/campaign-processing

Conversation

@TatevikGr
Copy link
Contributor

@TatevikGr TatevikGr commented Dec 11, 2025

Summary by CodeRabbit

  • New Features

    • Forward-to-friend workflow with per-recipient limits, admin notifications and multi-format delivery (HTML/Text/PDF).
    • Message precaching including optional user-specific remote content and faster per-subscriber sending.
  • Improvements

    • Richer placeholder system, automatic URL absolutization, smarter image embedding and attachment handling.
    • Stronger campaign headers, delivery controls and admin/start observability.
  • Bug Fixes

    • Mail-size enforcement, more reliable attachment copy/retries and clearer failure handling.

Thanks for contributing to phpList!

@coderabbitai
Copy link

coderabbitai bot commented Dec 11, 2025

📝 Walkthrough

Walkthrough

Refactors messaging around a precache-driven pipeline, adds modular placeholder resolution, new email builders/constructors, forwarding orchestration (guard/delivery/stats), image/attachment utilities, mail-size enforcement, many repo/DTO additions, DI/config rebinding, and extensive unit tests.

Changes

Cohort / File(s) Summary
Campaign processing & precache
src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php, src/Domain/Messaging/Service/MessagePrecacheService.php, src/Domain/Messaging/Service/MessageDataLoader.php, src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php, src/Domain/Messaging/Service/MessageProcessingPreparator.php
Introduce MessagePrecacheDto-driven precache flow, MessageDataLoader to assemble message data, precache persistence and admin start notifications, send-start timestamping, and refactor per-subscriber processing with explicit cache-missing handling.
Email builders & content constructors
src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php, src/Domain/Messaging/Service/Builder/EmailBuilder.php, src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php, src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php, src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php, src/Domain/Messaging/Service/Constructor/SystemMailContentBuilder.php
Add Base/Email/System/Forward builders and Campaign/System content constructors centralizing validation, blacklist checks, headers, embedding, PDF/attachment handling, and forward-specific headers/stamps.
Forwarding orchestration
src/Domain/Messaging/Service/MessageForwardService.php, src/Domain/Messaging/Service/ForwardContentService.php, src/Domain/Messaging/Service/ForwardDeliveryService.php, src/Domain/Messaging/Service/ForwardingGuard.php, src/Domain/Messaging/Service/ForwardingStatsService.php, src/Domain/Messaging/Service/Manager/UserMessageForwardManager.php, src/Domain/Messaging/Model/Dto/MessageForwardDto.php, src/Domain/Messaging/Repository/UserMessageForwardRepository.php
Implement forward-to-friend flow: permission guard, precache + content build, forward email construction, Envelope delivery, forward record manager/repository, per-user limits, and forwarding stats/DTOs.
Placeholder / resolver system
src/Domain/Configuration/Service/PlaceholderResolver.php, src/Domain/Configuration/Service/MessagePlaceholderProcessor.php, src/Domain/Configuration/Model/Dto/PlaceholderContext.php, src/Domain/Configuration/Service/Placeholder/*
Introduce modular placeholder framework (value/pattern/supporting resolvers), PlaceholderContext DTO, many new resolvers (unsubscribe/confirm/forward/preferences/lists/signature/usertrack/etc.), and integrate into message processing.
Image / HTML / remote utilities
src/Domain/Messaging/Service/TemplateImageEmbedder.php, src/Domain/Messaging/Service/Manager/TemplateImageManager.php, src/Domain/Common/ExternalImageService.php, src/Domain/Common/HtmlUrlRewriter.php, src/Domain/Common/Html2Text.php, src/Domain/Common/TextParser.php, src/Domain/Common/RemotePageFetcher.php
Add external image caching/embedding, template image/logo handling, HTML URL rewriting (attributes/srcset/CSS), HTML→text conversion, text auto-linking, and remote page fetching with PSR/DB caching and rewrite.
Attachments, file helpers & mail-size
src/Domain/Messaging/Service/AttachmentAdder.php, src/Domain/Common/FileHelper.php, src/Domain/Common/PdfGenerator.php, src/Domain/Messaging/Service/MailSizeChecker.php, src/Domain/Common/OnceCacheGuard.php
Add attachment orchestration (memory-aware), repository copy flows and exceptions, FileHelper, PDF generator, MailSizeChecker with cache/log events, and OnceCacheGuard utility.
Model / repo additions & tweaks
src/Domain/Messaging/Repository/*, src/Domain/Subscription/Repository/*, src/Domain/Identity/Repository/*, src/Domain/Messaging/Model/Message.php, src/Domain/Messaging/Model/Message/MessageFormat.php, src/Domain/Configuration/Model/OutputFormat.php, src/Domain/Common/Model/ContentTransferEncoding.php
Add DTOs/enums (OutputFormat, ContentTransferEncoding, forwarding DTOs/results), migrate MessageFormat booleans→counters with increment methods, and add repository helpers (url cache, attachments, template images, message data, subscriber lists, forward history).
Exceptions & admin services
src/Domain/Messaging/Exception/*.php, src/Domain/Subscription/Exception/AttributeNotAllowedException.php, src/Domain/Identity/Service/AdminCopyEmailSender.php, src/Domain/Identity/Service/AdminNotifier.php
Introduce domain exceptions (cache missing, attachment copy, forward limits, remote fetch, blacklisted, invalid recipient) and admin copy/notification services wired to SystemEmailBuilder and Mailer.
DI / config / composer / tests
config/services/*.yml, config/parameters.yml.dist, composer.json, phpunit.xml.dist, tests/**
Extensive DI updates: autowire/register new services/resolvers/builders/managers; new config parameters and composer type/deps changes; add phpunit suite and large number of new/updated tests.
Refactors & style
src/Domain/Messaging/Service/RateLimitedCampaignMailer.php, src/Domain/Messaging/Service/EmailService.php, various handlers, PHPMD/PHPCS rules
Widespread constructor property promotion, RateLimitedCampaignMailer exposes send(Email), handler constructors updated, many lint/docblock removals and minor refactors.

Sequence Diagram(s)

sequenceDiagram
    rect rgba(63,81,181,0.5)
    participant Handler as CampaignProcessorMessageHandler
    end
    rect rgba(0,150,136,0.5)
    participant Loader as MessageDataLoader
    participant Precacher as MessagePrecacheService
    end
    rect rgba(255,193,7,0.5)
    participant Builder as EmailBuilder
    participant Mailer as RateLimitedCampaignMailer
    end
    rect rgba(233,30,99,0.5)
    participant DB as Database
    end

    Handler->>Loader: load consolidated message data
    Loader-->>Handler: messageData
    Handler->>Precacher: precacheMessage(campaign, messageData)
    Precacher-->>Handler: MessagePrecacheDto
    Handler->>Handler: handleAdminNotifications()
    loop per subscriber
        Handler->>Builder: buildCampaignEmail(messageId, MessagePrecacheDto)
        Builder->>Builder: validate recipient & blacklist
        Builder->>Builder: applyContentAndFormatting (embed/attach/pdf)
        alt email built
            Builder-->>Handler: Email + OutputFormat
            Handler->>Mailer: send(Email)
            Mailer-->>Handler: send result
            Handler->>DB: persist subscriber status
        else failure
            Handler->>DB: mark NotSent/Failed
        end
    end
Loading
sequenceDiagram
    rect rgba(63,81,181,0.5)
    participant ForwardSvc as MessageForwardService
    end
    rect rgba(0,150,136,0.5)
    participant Guard as ForwardingGuard
    participant Precache as MessagePrecacheService
    participant Content as ForwardContentService
    end
    rect rgba(255,193,7,0.5)
    participant Builder as ForwardEmailBuilder
    participant Delivery as ForwardDeliveryService
    participant Stats as ForwardingStatsService
    end
    rect rgba(233,30,99,0.5)
    participant Repo as UserMessageForwardRepository
    participant Admin as AdminNotifier
    end

    ForwardSvc->>Guard: assertCanForward(uid, campaign)
    Guard-->>ForwardSvc: Subscriber or throw
    ForwardSvc->>Precache: precacheMessage(campaign)
    ForwardSvc->>Content: getContents(campaign, subscriber, friendEmail, dto)
    Content->>Builder: buildForwardEmail(...)
    Builder-->>Content: Email or null
    alt Email present
        ForwardSvc->>Delivery: send(Email)
        Delivery->>Repo: persist forward record (sent)
        ForwardSvc->>Stats: incrementFriendsCount(subscriber)
        ForwardSvc->>Admin: notifyForwardSucceeded(...)
    else failure
        ForwardSvc->>Repo: persist forward record (failed)
        ForwardSvc->>Admin: notifyForwardFailed(...)
    end
    ForwardSvc->>Stats: updateFriendsCount(subscriber)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.97% 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 Title concisely identifies the primary feature (message sending and forwarding) and matches the changeset's main focus.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ref/campaign-processing

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php (1)

157-166: Guard personalization against null text/footer to avoid runtime errors

Right now you call personalize() on getText() and getFooter() unconditionally. Given MessageContent::getText() / getFooter() are treated as nullable elsewhere, this can blow up with a TypeError if either is null (and tests hide this by forcing non-null mocks).

A small null-check keeps this safe:

     private function handleEmailSending(
         Message $campaign,
         Subscriber $subscriber,
         UserMessage $userMessage,
         Message\MessageContent $precachedContent,
     ): void {
-        $processed = $this->messagePreparator->processMessageLinks($campaign->getId(), $precachedContent, $subscriber);
-        $processed->setText($this->userPersonalizer->personalize($processed->getText(), $subscriber->getEmail()));
-        $processed->setFooter($this->userPersonalizer->personalize($processed->getFooter(), $subscriber->getEmail()));
+        $processed = $this->messagePreparator->processMessageLinks(
+            $campaign->getId(),
+            $precachedContent,
+            $subscriber
+        );
+
+        $htmlText = $processed->getText();
+        if ($htmlText !== null) {
+            $processed->setText(
+                $this->userPersonalizer->personalize($htmlText, $subscriber->getEmail())
+            );
+        }
+
+        $footer = $processed->getFooter();
+        if ($footer !== null) {
+            $processed->setFooter(
+                $this->userPersonalizer->personalize($footer, $subscriber->getEmail())
+            );
+        }

This matches how MessageProcessingPreparator already treats these fields and avoids surprising failures when campaigns have no HTML or footer.

🧹 Nitpick comments (1)
tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php (1)

175-205: Content mocks align with new personalization flow; consider adding a null‑footer case

Stubbing getText() / getFooter() in these tests to return concrete HTML/footer strings is a good way to keep the new personalization path exercised without changing expectations.

Once you add null-guards in the handler, it’d be worth adding a small test where getFooter() (and/or getText()) returns null to lock in that behavior and prevent regressions.

Also applies to: 236-269, 291-325

📜 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 83431b1 and a72d2e9.

📒 Files selected for processing (5)
  • src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php (3 hunks)
  • src/Domain/Messaging/Service/MessageProcessingPreparator.php (1 hunks)
  • src/Domain/Messaging/Service/RateLimitedCampaignMailer.php (1 hunks)
  • tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php (6 hunks)
  • tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php (0 hunks)
💤 Files with no reviewable changes (1)
  • tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.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 persistence or transaction side effects here for normal domain models.

  • Flag ANY usage of Doctrine persistence APIs on regular domain entities, especially:

    • $entityManager->flush(...), $this->entityManager->flush(...)
    • $em->persist(...), $em->remove(...)
    • $em->beginTransaction(), $em->commit(), $em->rollback()
  • ✅ Accessing Doctrine metadata, schema manager, or read-only schema info is acceptable
    as long as it does not modify state or perform 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/Service/RateLimitedCampaignMailer.php
  • src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
  • src/Domain/Messaging/Service/MessageProcessingPreparator.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/Service/RateLimitedCampaignMailer.php (1)
src/Domain/Messaging/Message/AsyncEmailMessage.php (1)
  • getReplyTo (49-52)
⏰ 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). (2)
  • GitHub Check: phpList on PHP 8.1 [Build, Test]
  • GitHub Check: phpList on PHP 8.1 [Build, Test]
🔇 Additional comments (4)
src/Domain/Messaging/Service/MessageProcessingPreparator.php (1)

76-87: No behavioral change here; TODO still relevant

This is just a comment tweak; existing logic around getText() / getFooter() and link replacement remains consistent and safe. All good.

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

7-9: UserPersonalizer wiring looks good

Importing and injecting UserPersonalizer into the handler keeps responsibilities in the message handler (where persistence and orchestration already live) and lines up with the new personalization flow. No issues here.

Also applies to: 44-62

tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php (1)

10-11: Test wiring for UserPersonalizer is solid

Injecting a UserPersonalizer mock and stubbing personalize() to be identity keeps existing assertions unchanged while exercising the new dependency. The constructor args line up with the handler changes, so this looks good.

Also applies to: 48-92

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

23-42: composeEmail refactor improves clarity and matches new pipeline

Using $message for options and $processedContent for subject/text/HTML matches the new “preprocess then personalize” flow and keeps the method signature self-explanatory. The handler call site aligns with this, so the change looks clean.

coderabbitai[bot]

This comment was marked as resolved.

@TatevikGr TatevikGr force-pushed the ref/campaign-processing branch from 5fe45ab to 492e1d0 Compare December 15, 2025 09:32
coderabbitai[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

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

♻️ Duplicate comments (5)
src/Domain/Messaging/Service/Manager/TemplateImageManager.php (4)

101-113: Missing return type declaration (still unresolved).

This method still lacks a return type and parameter type hint. Add string $content and : string for type safety.

-    public function parseLogoPlaceholders($content)
+    public function parseLogoPlaceholders(string $content): string

122-125: Null pointer dereference risk (still unresolved).

findByFilename() can return null, but line 123 calls ->getData() without a null check. This will throw a fatal error.

         $orgLogoImage = $this->templateImageRepository->findByFilename("ORGANISATIONLOGO$size.png");
-        if (!empty($orgLogoImage->getData())) {
+        if ($orgLogoImage !== null && !empty($orgLogoImage->getData())) {
             return;
         }

127-128: Null pointer dereference risk (still unresolved).

findById() can return null, but line 128 calls ->getData() without checking. Add a null guard before decoding.

         $logoImage = $this->templateImageRepository->findById((int) $logoImageId);
+        if ($logoImage === null) {
+            return;
+        }
         $imageContent = base64_decode($logoImage->getData());

154-176: Output buffer handling is fragile (still unresolved).

The buffer capture (lines 169-175) grabs content before calling ob_start(), then echoes it back after capturing the image. This can corrupt output. Use a local buffer instead:

-                //# rather convoluted way to get the image contents
-                $buffer = ob_get_contents();
-                ob_end_clean();
-                ob_start();
-                imagepng($resized);
-                $imageContent = ob_get_contents();
-                ob_end_clean();
-                echo $buffer;
+                ob_start();
+                imagepng($resized);
+                $imageContent = ob_get_clean();
composer.json (1)

82-85: Past review comment still applies.

The addition of these three PHP extensions is correct for the new functionality, but as noted in the previous review, please update the PR description to document why these extensions are required.

🧹 Nitpick comments (5)
src/Domain/Common/ExternalImageService.php (5)

50-55: Avoid error suppression; handle mkdir failures explicitly.

Using @mkdir hides potential errors. Consider checking if the directory creation succeeds and logging or returning an error if it fails.

Apply this diff:

-        if (!file_exists($this->externalCacheDir)) {
-            @mkdir($this->externalCacheDir);
-        }
-
-        if (!file_exists($this->externalCacheDir) || !is_writable($this->externalCacheDir)) {
-            return false;
-        }
+        if (!file_exists($this->externalCacheDir)) {
+            if (!mkdir($this->externalCacheDir) && !is_dir($this->externalCacheDir)) {
+                return false;
+            }
+        }
+
+        if (!is_writable($this->externalCacheDir)) {
+            return false;
+        }

64-66: Remove redundant curl_init check.

Since ext-curl is now a required dependency in composer.json, the function_exists('curl_init') check is unnecessary.

Apply this diff:

-            if (function_exists('curl_init')) {
-                $cacheFileContent = $this->downloadUsingCurl($filename);
-            }
+            $cacheFileContent = $this->downloadUsingCurl($filename);

76-84: Avoid error suppression on file operations.

Using @fopen silently ignores errors. Handle the failure case explicitly instead.


94-117: Address error suppression and TODO comment.

This method uses @ suppression extensively (opendir, readdir, filemtime, unlink, closedir). Consider proper error handling or at least logging failures.

Also, the TODO at line 102 should be resolved or tracked in an issue.

Would you like me to open an issue to track the TODO comment?


10-177: Consider moving to Infrastructure layer.

This service performs filesystem and network I/O operations, which are side effects that typically belong in the Infrastructure layer rather than the Domain layer. According to the coding guidelines for domain purity, domain code should avoid persistence and transaction side effects.

While this service doesn't use Doctrine persistence, it does interact with external systems (filesystem and HTTP) which may violate domain purity principles. Consider moving this to src/Infrastructure/ instead.

Based on coding guidelines, domain layer should be pure and free of side effects.

📜 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 65c0030 and 3dcb90a.

📒 Files selected for processing (4)
  • composer.json (1 hunks)
  • config/parameters.yml.dist (2 hunks)
  • src/Domain/Common/ExternalImageService.php (1 hunks)
  • src/Domain/Messaging/Service/Manager/TemplateImageManager.php (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • config/parameters.yml.dist
🧰 Additional context used
📓 Path-based instructions (1)
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 persistence or transaction side effects here for normal domain models.

  • Flag ANY usage of Doctrine persistence APIs on regular domain entities, especially:

    • $entityManager->flush(...), $this->entityManager->flush(...)
    • $em->persist(...), $em->remove(...)
    • $em->beginTransaction(), $em->commit(), $em->rollback()
  • ✅ Accessing Doctrine metadata, schema manager, or read-only schema info is acceptable
    as long as it does not modify state or perform 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/Service/Manager/TemplateImageManager.php
  • src/Domain/Common/ExternalImageService.php
🧬 Code graph analysis (2)
src/Domain/Messaging/Service/Manager/TemplateImageManager.php (2)
src/Domain/Configuration/Service/Provider/ConfigProvider.php (1)
  • ConfigProvider (12-82)
src/Domain/Messaging/Repository/TemplateImageRepository.php (3)
  • TemplateImageRepository (12-35)
  • findByFilename (16-24)
  • findById (26-34)
src/Domain/Common/ExternalImageService.php (1)
src/Domain/Configuration/Service/Provider/ConfigProvider.php (1)
  • ConfigProvider (12-82)
🔇 Additional comments (3)
src/Domain/Common/ExternalImageService.php (3)

14-22: Constructor looks good.

Clean dependency injection and simple initialization logic.


24-38: Clean cache retrieval logic.

Proper validation and error handling with null returns.


119-126: Filename sanitization logic is correct.

The regex patterns work but could be documented for clarity. Consider adding a comment explaining what characters are being removed/replaced.

coderabbitai[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

@TatevikGr TatevikGr force-pushed the ref/campaign-processing branch from 8e75515 to 25ef84a Compare December 21, 2025 12:14
coderabbitai[bot]

This comment was marked as resolved.

@TatevikGr TatevikGr changed the title Refactor CampaignProcessorMessageHandler Refactor/update message sendin Dec 22, 2025
@TatevikGr TatevikGr changed the title Refactor/update message sendin Feat/message sending/forwarding Feb 2, 2026
@TatevikGr TatevikGr changed the title Feat/message sending/forwarding Feat: message sending-forwarding Feb 2, 2026
-  Message forwarding: send campaigns to friends (optional personal note), per-user limits, admin notifications on success/failure, and forwarding statistics; forwarded messages prefixed "Fwd".
-  Admin-copy emails: configurable toggle to send admin copies and select recipients.

---------

Co-authored-by: Tatevik <tatevikg1@gmail.com>
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: 14

🤖 Fix all issues with AI agents
In `@config/services/messenger.yml`:
- Around line 14-17: The CampaignProcessorMessageHandler has an
#[Autowire('%imap_bounce.email%')] on its constructor $bounceEmail parameter but
the service definition's arguments: block is empty; update the service entry for
PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler to
explicitly wire the parameter by adding an arguments mapping for $bounceEmail to
the imap_bounce.email parameter (i.e., set $bounceEmail ->
'%imap_bounce.email%') so DI can resolve the constructor argument.

In `@src/Domain/Common/ExternalImageService.php`:
- Around line 168-173: In isCacheableUrl, guard the str_contains call against a
null website value from $this->configProvider->getValue(ConfigOption::Website):
fetch the website into a local variable (e.g. $website), verify it is a
non-empty string (or coalesce to an empty string) before calling
str_contains(..., '://' . $website . '/'), and only perform that str_contains
check when $website is not null/empty so you avoid a TypeError from str_contains
when getValue returns null.

In `@src/Domain/Configuration/Service/MessagePlaceholderProcessor.php`:
- Around line 95-112: The current ensureStandardPlaceholders method uses
str_contains which misses different-cased placeholders and placeholders with
defaults (e.g. [FOOTER%%default]), causing duplicates; update the presence
checks to use case-insensitive regex that matches the placeholder name plus an
optional "%%..." default segment (for example use a pattern like
/\[(PLACEHOLDER)(?:%%[^\]]*)?\]/i) for each placeholder checked in
ensureStandardPlaceholders so appendContent is only called when no matching
placeholder (FOOTER, SIGNATURE, USERTRACK) exists, keeping the existing
separator logic and respecting OutputFormat::Html and the alwaysAddUserTrack
branch.

In
`@src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php`:
- Around line 22-36: The HTML branch of ConfirmationUrlValueResolver::__invoke
currently only escapes the separator; update it to escape the URL consistently
with sibling resolvers by passing htmlspecialchars($url, ENT_QUOTES |
ENT_SUBSTITUTE, 'UTF-8') (and keep escaping the separator as before) when
building the returned string; leave the non-HTML branch unchanged so only the
HTML output is sanitized.

In `@src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php`:
- Around line 25-49: The __invoke method in ForwardValueResolver builds a query
parameter mid using $ctx->messageId() which is nullable; when null it becomes
mid=0 or an empty param — guard against null by checking $ctx->messageId()
(and/or $ctx->forwardedBy()) and omit the mid parameter entirely when it is
null: update the HTML branch (where the anchor is built) and the plaintext
return (sprintf call) in __invoke to only append "&mid=..." / "mid=..." when
$ctx->messageId() is not null, keeping existing htmlspecialchars usage and uid
logic (methods: __invoke, messageId(), isHtml(), forwardedBy(),
getUser()->getUniqueId()).

In `@src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php`:
- Around line 25-50: In PreferencesValueResolver::__invoke, the UID ($uid) is
inserted raw into the href when $ctx->isHtml() — escape or build the final URL
before escaping to prevent broken/unsafe HTML; either run htmlspecialchars($uid,
ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') before concatenation or (preferable) use
the same pattern as UnsubscribeValueResolver by composing the full URL (e.g. via
urlBuilder->withUid(...) or equivalent) and then apply htmlspecialchars once to
the complete href ($safeUrl or $fullUrl) so the uid is properly encoded in the
anchor href.

In `@src/Domain/Identity/Service/AdminCopyEmailSender.php`:
- Around line 29-52: The __invoke method currently calls
$this->systemEmailBuilder->buildSystemEmail(data: $data) and passes its result
directly to MailerInterface::send, but buildSystemEmail can return null causing
a TypeError; modify the loop in AdminCopyEmailSender::__invoke to check if
$email is null after calling buildSystemEmail (use the same $data/$email
variables), and if null skip sending (and optionally log via
configProvider/logger) instead of constructing the Envelope and calling
$this->mailer->send with a null message; ensure resolveRecipientEmails and
Envelope creation remain unchanged for non-null $email.

In `@src/Domain/Messaging/Service/AttachmentAdder.php`:
- Around line 177-196: copyAttachmentToRepository is persisting the full path
returned by FileHelper::writeFileToDirectory into Attachment::setFilename, which
later causes attachFromRepository to build duplicate-prefixed paths; change the
code so that after writeFileToDirectory returns, you extract and store only the
basename (e.g. via PHP basename/pathinfo) before calling
Attachment::setFilename, or alternatively make writeFileToDirectory return a
relative name — but do not save the absolute path into Attachment::filename
(refer to copyAttachmentToRepository, FileHelper::writeFileToDirectory,
Attachment::setFilename, and attachFromRepository when making the change).

In `@src/Domain/Messaging/Service/Builder/EmailBuilder.php`:
- Around line 236-250: When htmlPref is true the Pdf branch only attaches a PDF
and never sets an email body, resulting in an empty message; update the Pdf
branch in EmailBuilder.php (the block using htmlPref, sentAs =
OutputFormat::Pdf, $this->pdfGenerator->createPdfBytes and $email->attach) to
also set a visible body before calling $this->attachmentAdder->add — e.g. call
$email->text("Please see attached PDF") and/or $email->html("<p>Please see
attached PDF</p>") so recipients have a readable fallback; keep throwing
AttachmentException on add failure as-is.
- Line 238: $textMessage is nullable but is being passed to
PdfGenerator::createPdfBytes(string) which requires a non-null string; add a
null-check before calling createPdfBytes (and the similar call around the other
occurrence) in EmailBuilder so you only call PdfGenerator::createPdfBytes when
$textMessage is !== null — otherwise provide a safe fallback (e.g. skip PDF
generation, pass an empty string, or throw a clear InvalidArgumentException).
Update the logic around the occurrences of $textMessage (the call at the line
with $this->pdfGenerator->createPdfBytes($textMessage) and the similar call at
the other occurrence) to guard against null values and handle the null case
explicitly.

In `@src/Domain/Messaging/Service/Constructor/SystemMailContentBuilder.php`:
- Around line 55-57: The strpos check for '</body>' in
SystemMailContentBuilder.php is using a truthy test that fails when the tag is
at position 0; change the condition in the elseif that currently calls
strpos($htmlContent, '</body>') to a strict comparison strpos($htmlContent,
'</body>') !== false so the block (which calls str_replace('</body>',
$phpListPowered.'</body>', $htmlContent)) runs correctly whenever the tag
exists; update that single conditional and leave the replacement using
$phpListPowered and $htmlContent untouched.

In `@src/Domain/Messaging/Service/MessageForwardService.php`:
- Around line 70-81: Wrap the call to
$this->forwardDeliveryService->send($email) and the subsequent success-path
calls (adminNotifier->notifyForwardSucceeded, forwardDeliveryService->markSent,
campaign->incrementSentCount, forwardingStatsService->incrementFriendsCount) in
a try-catch; on exception catch (\Throwable $e) call
$this->adminNotifier->notifyForwardFailed(...) and
$this->forwardDeliveryService->markFailed($campaign, $forwardingSubscriber,
$friendEmail) (passing the exception or its message as the notify/markFailed
APIs expect), then continue the loop so processing of remaining forwards
proceeds unchanged. Ensure the successful path still executes the same methods
(notifyForwardSucceeded, markSent, incrementSentCount, incrementFriendsCount)
when no exception occurs.

In `@src/Domain/Messaging/Service/MessagePrecacheService.php`:
- Around line 49-53: The cached value ($cached) retrieved via
$this->cache->get($cacheKey) is returned directly but the method signature
declares a boolean return (: bool), causing a TypeError on cache hit; change the
cache-hit branch to return a boolean (e.g., return (bool) $cached or return
true/false based on the cached content) instead of returning the DTO, and ensure
any code that saves into the cache for this key ($this->cache->set(...)) stores
a value that can be meaningfully cast to bool for the method's contract.

In `@tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php`:
- Around line 69-84: The test configures the attrManager mock twice for
createOrUpdateByName which is unreliable; remove the second expectation
(expects(self::never())) and instead assert behavior by either changing the
initial expectation to expects(self::atMostOnce()) or simply deleting the
reconfiguration so the first expects(self::once()) defined on
ForwardingStatsServiceTest remains authoritative; update the test around the
methods incrementFriendsCount and updateFriendsCount to rely on that single
expectation on attrManager->createOrUpdateByName.
🧹 Nitpick comments (9)
src/Domain/Analytics/Service/LinkTrackService.php (2)

43-43: TODO comment flagged for forwarded message handling.

Heads up on this reminder you left yourself. When forwarding, it sounds like you'll want to distinguish forwarded link tracking from regular user tracking. Makes sense to defer this if the forwarding flow isn't fully wired up yet.

Want me to help sketch out how the forwarded case could be handled here, or open an issue to track this work?


52-52: Minor: Redundant null coalescing.

The content property on MessagePrecacheDto is typed as string with a default of '', so it can't be null. The ?? '' is technically unnecessary here, though it's harmless as defensive coding.

💡 Optional cleanup
-        $links = $this->extractLinksFromHtml($content->content ?? '');
+        $links = $this->extractLinksFromHtml($content->content);
src/Domain/Configuration/Model/Dto/PlaceholderContext.php (1)

43-46: Minor: Redundant getter for public property.

getUser() duplicates access to $this->user which is already public readonly. Either make the property private (like $forwardedBy) or remove the getter for consistency.

Option A: Remove redundant getter
-    public function getUser(): Subscriber
-    {
-        return $this->user;
-    }
Option B: Make property private like others
     public function __construct(
-        public readonly Subscriber $user,
+        private readonly Subscriber $user,
         public readonly OutputFormat $format,
src/Domain/Messaging/Service/ForwardDeliveryService.php (1)

39-57: Consider extracting status strings to constants.

Both 'sent' and 'failed' are used here and in the repository query. A shared constant (e.g., UserMessageForward::STATUS_SENT) would reduce magic strings and ensure consistency.

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

31-33: Consider avoiding reverse-DNS on the request path.
gethostbyaddr() can block and add latency; consider making it optional (config flag) or caching the result, and rely on REMOTE_HOST when already present.

Also applies to: 46-52

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

208-246: Redundant configProvider mock setup.

Lines 210-220 duplicate the exact same willReturnMap configuration that's already set in setUp() at lines 63-73. This isn't harmful but adds noise.

♻️ Consider removing the duplicate mock setup
     public function testPrefersTextWhenNoHtmlContent(): void
     {
-        $this->configProvider
-            ->method('getValue')
-            ->willReturnMap([
-                [ConfigOption::MessageFromAddress, 'from@example.com'],
-                [ConfigOption::MessageFromName, 'From Name'],
-                [ConfigOption::UnsubscribeUrl, 'https://example.com/unsubscribe'],
-                [ConfigOption::PreferencesUrl, 'https://example.com/prefs'],
-                [ConfigOption::SubscribeUrl, 'https://example.com/subscribe'],
-                [ConfigOption::AdminAddress, 'admin@example.com'],
-                [ConfigOption::AlwaysSendTextDomains, ''],
-            ]);
-
         $this->blacklistRepository->method('isEmailBlacklisted')->willReturn(false);

358-390: Test documents a potential bug in production code.

Line 388-389 notes that List-Owner header uses UnsubscribeUrl instead of AdminAddress, which the comment flags as "likely a bug". The test asserts current behavior, which is the right approach for regression tests.

Consider opening an issue to track fixing this in the production code if it's indeed unintended.

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

36-37: Inconsistent string value casing.

PoweredByImage and PoweredByText use PascalCase while all other config values use lowercase or snake_case. If these must match existing legacy config keys, that's fine—just worth noting for future additions to follow a consistent pattern.


18-18: TODO comment should be resolved or documented.

This TODO has been here a while. Either track it in an issue, add context about what needs checking, or remove it if no longer relevant.

Want me to open an issue to track investigating where Domain is defined?

Comment on lines 14 to 17
PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler:
autowire: true
arguments:
$maxMailSize: '%messaging.max_mail_size%'

PhpList\Core\Domain\Subscription\MessageHandler\DynamicTableMessageHandler:
autowire: true
autoconfigure: true
tags: [ 'messenger.message_handler' ]
arguments:
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:

ast-grep --pattern $'class CampaignProcessorMessageHandler {
  $$$
  public function __construct($$$) {
    $$$
  }
  $$$
}'

Repository: phpList/core

Length of output: 38


🏁 Script executed:

fd -type f -name "*.php" | xargs grep -l "class CampaignProcessorMessageHandler"

Repository: phpList/core

Length of output: 228


🏁 Script executed:

rg "class CampaignProcessorMessageHandler" --type php -l

Repository: phpList/core

Length of output: 210


🏁 Script executed:

cat src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php

Repository: phpList/core

Length of output: 15405


The arguments: block needs to include the bounceEmail configuration.

The CampaignProcessorMessageHandler constructor has an #[Autowire('%imap_bounce.email%')] attribute on the $bounceEmail parameter, which requires explicit wiring. The empty arguments: block should define this value. Something like:

arguments:
  $bounceEmail: '%imap_bounce.email%'

Without it, dependency injection will fail for this handler.

🤖 Prompt for AI Agents
In `@config/services/messenger.yml` around lines 14 - 17, The
CampaignProcessorMessageHandler has an #[Autowire('%imap_bounce.email%')] on its
constructor $bounceEmail parameter but the service definition's arguments: block
is empty; update the service entry for
PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler to
explicitly wire the parameter by adding an arguments mapping for $bounceEmail to
the imap_bounce.email parameter (i.e., set $bounceEmail ->
'%imap_bounce.email%') so DI can resolve the constructor argument.

Comment on lines +168 to +173
private function isCacheableUrl($filename): bool
{
if (!(str_starts_with($filename, 'http'))
|| str_contains($filename, '://' . $this->configProvider->getValue(ConfigOption::Website) . '/')
) {
return false;
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

Guard str_contains against null website config.

getValue() can return null; str_contains() will TypeError.

🛠️ Fix
-        if (!(str_starts_with($filename, 'http'))
-            || str_contains($filename, '://' . $this->configProvider->getValue(ConfigOption::Website) . '/')
-        ) {
+        $website = $this->configProvider->getValue(ConfigOption::Website) ?? '';
+        if (!(str_starts_with($filename, 'http'))
+            || ($website !== '' && str_contains($filename, '://' . $website . '/'))
+        ) {
             return false;
         }
🤖 Prompt for AI Agents
In `@src/Domain/Common/ExternalImageService.php` around lines 168 - 173, In
isCacheableUrl, guard the str_contains call against a null website value from
$this->configProvider->getValue(ConfigOption::Website): fetch the website into a
local variable (e.g. $website), verify it is a non-empty string (or coalesce to
an empty string) before calling str_contains(..., '://' . $website . '/'), and
only perform that str_contains check when $website is not null/empty so you
avoid a TypeError from str_contains when getValue returns null.

Comment on lines +95 to +112
private function ensureStandardPlaceholders(string $value, OutputFormat $format): string
{
if (!str_contains($value, '[FOOTER]')) {
$sep = $format === OutputFormat::Html ? '<br />' : "\n\n";
$value = $this->appendContent($value, $sep . '[FOOTER]');
}

if (!str_contains($value, '[SIGNATURE]')) {
$sep = $format === OutputFormat::Html ? ' ' : "\n";
$value = $this->appendContent($value, $sep . '[SIGNATURE]');
}

if ($this->alwaysAddUserTrack && $format === OutputFormat::Html && !str_contains($value, '[USERTRACK]')) {
$value = $this->appendContent($value, ' ' . '[USERTRACK]');
}

return $value;
}
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

Detect existing placeholders case‑insensitively (and with defaults).
str_contains('[FOOTER]') misses [footer] and [FOOTER%%default], so standard placeholders can be appended twice. A regex check prevents duplicates.

🛠️ Suggested fix
-        if (!str_contains($value, '[FOOTER]')) {
+        if (!preg_match('/\[FOOTER(?:%%[^\]]+)?\]/i', $value)) {
             $sep = $format === OutputFormat::Html ? '<br />' : "\n\n";
             $value = $this->appendContent($value, $sep . '[FOOTER]');
         }

-        if (!str_contains($value, '[SIGNATURE]')) {
+        if (!preg_match('/\[SIGNATURE(?:%%[^\]]+)?\]/i', $value)) {
             $sep = $format === OutputFormat::Html ? ' ' : "\n";
             $value = $this->appendContent($value, $sep . '[SIGNATURE]');
         }

-        if ($this->alwaysAddUserTrack && $format === OutputFormat::Html && !str_contains($value, '[USERTRACK]')) {
+        if (
+            $this->alwaysAddUserTrack
+            && $format === OutputFormat::Html
+            && !preg_match('/\[USERTRACK(?:%%[^\]]+)?\]/i', $value)
+        ) {
             $value = $this->appendContent($value, ' ' . '[USERTRACK]');
         }
🤖 Prompt for AI Agents
In `@src/Domain/Configuration/Service/MessagePlaceholderProcessor.php` around
lines 95 - 112, The current ensureStandardPlaceholders method uses str_contains
which misses different-cased placeholders and placeholders with defaults (e.g.
[FOOTER%%default]), causing duplicates; update the presence checks to use
case-insensitive regex that matches the placeholder name plus an optional
"%%..." default segment (for example use a pattern like
/\[(PLACEHOLDER)(?:%%[^\]]*)?\]/i) for each placeholder checked in
ensureStandardPlaceholders so appendContent is only called when no matching
placeholder (FOOTER, SIGNATURE, USERTRACK) exists, keeping the existing
separator logic and respecting OutputFormat::Html and the alwaysAddUserTrack
branch.

Comment on lines +22 to +36
public function __invoke(PlaceholderContext $ctx): string
{
$url = $this->config->getValue(ConfigOption::ConfirmationUrl);
if (empty($url)) {
return '';
}
$sep = !str_contains($url, '?') ? '?' : '&';
$uid = $ctx->forwardedBy() ? 'forwarded' : $ctx->getUser()->getUniqueId();

if ($ctx->isHtml()) {
return sprintf('%s%suid=%s', $url, htmlspecialchars($sep), $uid);
}

return sprintf('%s%suid=%s', $url, $sep, $uid);
}
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

Inconsistent HTML escaping compared to sibling resolvers.

In the HTML branch (Line 32), only the separator is escaped, but the URL itself is output raw. Compare with ForwardUrlValueResolver and PreferencesUrlValueResolver from the relevant snippets, which both escape the URL using htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').

If the URL contains characters like & or ", the current implementation could produce invalid HTML or XSS vectors.

🔒 Suggested fix for consistent HTML escaping
     if ($ctx->isHtml()) {
-        return sprintf('%s%suid=%s', $url, htmlspecialchars($sep), $uid);
+        return sprintf(
+            '%s%suid=%s',
+            htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
+            htmlspecialchars($sep),
+            $uid
+        );
     }
🤖 Prompt for AI Agents
In
`@src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php`
around lines 22 - 36, The HTML branch of ConfirmationUrlValueResolver::__invoke
currently only escapes the separator; update it to escape the URL consistently
with sibling resolvers by passing htmlspecialchars($url, ENT_QUOTES |
ENT_SUBSTITUTE, 'UTF-8') (and keep escaping the separator as before) when
building the returned string; leave the non-HTML branch unchanged so only the
HTML output is sanitized.

Comment on lines +25 to +49
public function __invoke(PlaceholderContext $ctx): string
{
$url = $this->config->getValue(ConfigOption::ForwardUrl);
if (empty($url)) {
return '';
}
$sep = !str_contains($url, '?') ? '?' : '&';
$uid = $ctx->forwardedBy() ? 'forwarded' : $ctx->getUser()->getUniqueId();

if ($ctx->isHtml()) {
$label = $this->translator->trans('This link');

return '<a href="'
. htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. htmlspecialchars($sep)
. 'uid='
. $uid
. '&amp;mid='
. $ctx->messageId()
. '">'
. htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '</a> ';
}

return sprintf('%s%suid=%s&mid=%d ', $url, $sep, $uid, $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

🧩 Analysis chain

🏁 Script executed:

# Find PlaceholderContext definition
find . -type f -name "*.php" | xargs grep -l "class PlaceholderContext"

Repository: phpList/core

Length of output: 115


🏁 Script executed:

# Also search for the specific method signature
rg "messageId\s*\(" --type php -A 2 -B 2 | head -50

Repository: phpList/core

Length of output: 3544


🏁 Script executed:

# Check if ForwardValueResolver has any domain purity violations
cat -n src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php

Repository: phpList/core

Length of output: 1993


🏁 Script executed:

# Verify sprintf behavior with null and %d format in PHP
php -r "echo sprintf('mid=%d', null) . PHP_EOL; echo 'Concatenation: ' . 'mid=' . null . PHP_EOL;"

Repository: phpList/core

Length of output: 81


🏁 Script executed:

# Double-check if there are any other callers of messageId() that might have similar issues
rg "messageId\(\)" --type php -B 2 -A 2

Repository: phpList/core

Length of output: 3544


Guard against null messageId to prevent malformed query parameters.
messageId() is nullable (?int). When null, sprintf with %d converts it to 0, and string concatenation produces an empty value, resulting in mid=0 (text) or &amp;mid= (HTML)—both invalid. Early-return or omit the mid parameter when missing.

🛠️ Suggested fix
     public function __invoke(PlaceholderContext $ctx): string
     {
         $url = $this->config->getValue(ConfigOption::ForwardUrl);
         if (empty($url)) {
             return '';
         }
+        $mid = $ctx->messageId();
+        if ($mid === null) {
+            return '';
+        }
         $sep = !str_contains($url, '?') ? '?' : '&';
         $uid = $ctx->forwardedBy() ? 'forwarded' : $ctx->getUser()->getUniqueId();
@@
-                . $ctx->messageId()
+                . $mid
@@
-        return sprintf('%s%suid=%s&mid=%d ', $url, $sep, $uid, $ctx->messageId());
+        return sprintf('%s%suid=%s&mid=%d ', $url, $sep, $uid, $mid);
     }
🤖 Prompt for AI Agents
In `@src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php` around
lines 25 - 49, The __invoke method in ForwardValueResolver builds a query
parameter mid using $ctx->messageId() which is nullable; when null it becomes
mid=0 or an empty param — guard against null by checking $ctx->messageId()
(and/or $ctx->forwardedBy()) and omit the mid parameter entirely when it is
null: update the HTML branch (where the anchor is built) and the plaintext
return (sprintf call) in __invoke to only append "&mid=..." / "mid=..." when
$ctx->messageId() is not null, keeping existing htmlspecialchars usage and uid
logic (methods: __invoke, messageId(), isHtml(), forwardedBy(),
getUser()->getUniqueId()).

// send a PDF file to users who want html and text to everyone else
if ($htmlPref) {
$sentAs = OutputFormat::Pdf;
$pdfBytes = $this->pdfGenerator->createPdfBytes($textMessage);
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 look at the EmailBuilder.php file around lines 238 and 265
cat -n src/Domain/Messaging/Service/Builder/EmailBuilder.php | sed -n '230,270p'

Repository: phpList/core

Length of output: 1904


🏁 Script executed:

# Find the PdfGenerator class and check the createPdfBytes method
find . -type f -name "*.php" | xargs grep -l "createPdfBytes" | head -5

Repository: phpList/core

Length of output: 263


🏁 Script executed:

# Search for the PdfGenerator interface/class definition
rg -n "class PdfGenerator|interface PdfGenerator" -A 5

Repository: phpList/core

Length of output: 1007


🏁 Script executed:

cat -n src/Domain/Common/PdfGenerator.php

Repository: phpList/core

Length of output: 811


🏁 Script executed:

# Also check if there's any null checking or guards in EmailBuilder before line 238 and 265
cat -n src/Domain/Messaging/Service/Builder/EmailBuilder.php | sed -n '220,240p'

Repository: phpList/core

Length of output: 959


Type safety issue: nullable $textMessage passed to non-nullable parameter.

$textMessage is declared as ?string but PdfGenerator::createPdfBytes() expects a non-nullable string. With strict_types=1 enabled, passing null will cause a TypeError at runtime. Add a null check before these calls, or ensure the parameter is guaranteed non-null.

Also applies to: 265-265

🤖 Prompt for AI Agents
In `@src/Domain/Messaging/Service/Builder/EmailBuilder.php` at line 238,
$textMessage is nullable but is being passed to
PdfGenerator::createPdfBytes(string) which requires a non-null string; add a
null-check before calling createPdfBytes (and the similar call around the other
occurrence) in EmailBuilder so you only call PdfGenerator::createPdfBytes when
$textMessage is !== null — otherwise provide a safe fallback (e.g. skip PDF
generation, pass an empty string, or throw a clear InvalidArgumentException).
Update the logic around the occurrences of $textMessage (the call at the line
with $this->pdfGenerator->createPdfBytes($textMessage) and the similar call at
the other occurrence) to guard against null values and handle the null case
explicitly.

Comment on lines +55 to +57
} elseif (strpos($htmlContent, '</body>')) {
$htmlContent = str_replace('</body>', $phpListPowered.'</body>', $htmlContent);
} else {
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:

cat -n src/Domain/Messaging/Service/Constructor/SystemMailContentBuilder.php | sed -n '50,65p'

Repository: phpList/core

Length of output: 1191


Use strict strpos !== false for </body> detection.

If </body> is at position 0, strpos() returns 0 which is falsy in PHP, so the condition silently fails and the code falls through instead of replacing the tag.

🔧 Suggested fix
-                } elseif (strpos($htmlContent, '</body>')) {
+                } elseif (strpos($htmlContent, '</body>') !== false) {
📝 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
} elseif (strpos($htmlContent, '</body>')) {
$htmlContent = str_replace('</body>', $phpListPowered.'</body>', $htmlContent);
} else {
} elseif (strpos($htmlContent, '</body>') !== false) {
$htmlContent = str_replace('</body>', $phpListPowered.'</body>', $htmlContent);
} else {
🤖 Prompt for AI Agents
In `@src/Domain/Messaging/Service/Constructor/SystemMailContentBuilder.php` around
lines 55 - 57, The strpos check for '</body>' in SystemMailContentBuilder.php is
using a truthy test that fails when the tag is at position 0; change the
condition in the elseif that currently calls strpos($htmlContent, '</body>') to
a strict comparison strpos($htmlContent, '</body>') !== false so the block
(which calls str_replace('</body>', $phpListPowered.'</body>', $htmlContent))
runs correctly whenever the tag exists; update that single conditional and leave
the replacement using $phpListPowered and $htmlContent untouched.

Comment on lines +69 to +84
// After two increments (3 -> 4 -> 5), update should persist '5'
$this->attrManager->expects(self::once())
->method('createOrUpdateByName')
->with(
subscriber: self::identicalTo($subscriber),
attributeName: 'FriendsForwarded',
value: '5'
);

$service->incrementFriendsCount($subscriber);
$service->incrementFriendsCount($subscriber);
$service->updateFriendsCount($subscriber);

// Second update attempt should be a no-op due to cache reset
$this->attrManager->expects(self::never())->method('createOrUpdateByName');
$service->updateFriendsCount($subscriber);
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:

# First, let's locate and examine the test file
find . -name "ForwardingStatsServiceTest.php" -type f

Repository: phpList/core

Length of output: 124


🏁 Script executed:

# Also check what version of PHPUnit is being used
if [ -f composer.json ]; then
  cat composer.json | grep -A 5 "phpunit"
fi

if [ -f composer.lock ]; then
  grep -A 10 '"name": "phpunit/phpunit"' composer.lock | head -15
fi

Repository: phpList/core

Length of output: 286


🌐 Web query:

PHPUnit mock multiple expectations same method behavior

💡 Result:

In PHPUnit you generally don’t set multiple expects() blocks for the same method on the same mock; instead configure one expectation and then vary behavior by call order or arguments.

1) Same method, different return values per call (call order)

$mock->expects($this->exactly(2))
     ->method('foo')
     ->willReturn('first', 'second');

This returns different values on consecutive invocations and (with exactly(2)) also asserts the call count. [1]

2) Same method, different return values based on arguments

$mock->expects($this->any())
     ->method('foo')
     ->willReturnMap([
         ['a', 'b', 'ret1'],
         ['x', 'y', 'ret2'],
     ]);

willReturnMap() selects the return value by matching the argument list. [1]

3) Same method, complex “multiple expectations” logic (mix order + args)

Use a callback (you can track invocation number and inspect args):

$i = 0;
$mock->method('foo')->willReturnCallback(function($arg) use (&$i) {
    return (++$i === 1 && $arg === 'a') ? 'ret1' : 'ret2';
});

This is the most flexible approach when neither pure “consecutive calls” nor pure “argument map” is enough. [1]

Sources: PHPUnit manual on willReturn() (consecutive values), willReturnMap(), and call-count expectations (exactly(), etc.). [1]

[1] (docs.phpunit.de)

Citations:


🏁 Script executed:

# Let's examine the test file
cat -n ./tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php | head -100

Repository: phpList/core

Length of output: 4710


🏁 Script executed:

# Let's see the specific lines 69-84 and more context around it
cat -n ./tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php | sed -n '60,95p'

Repository: phpList/core

Length of output: 1718


Remove the redundant expectation reconfiguration.
PHPUnit doesn't support multiple expects() calls on the same method reliably—the second expects(self::never()) replaces rather than extends the first expects(self::once()). Once line 80 satisfies the once() expectation, reconfiguring with never() is both unreliable and unnecessary.

If you want to verify the second updateFriendsCount() call is truly a no-op, use expects(self::atMostOnce()) instead of expects(self::once()) on the initial setup. Otherwise, removing the line 83 reconfiguration is fine—the first expectation already enforces the persistence behavior.

Suggested fix
-        // Second update attempt should be a no-op due to cache reset
-        $this->attrManager->expects(self::never())->method('createOrUpdateByName');
         $service->updateFriendsCount($subscriber);
         self::assertTrue(true);
🤖 Prompt for AI Agents
In `@tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php` around
lines 69 - 84, The test configures the attrManager mock twice for
createOrUpdateByName which is unreliable; remove the second expectation
(expects(self::never())) and instead assert behavior by either changing the
initial expectation to expects(self::atMostOnce()) or simply deleting the
reconfiguration so the first expects(self::once()) defined on
ForwardingStatsServiceTest remains authoritative; update the test around the
methods incrementFriendsCount and updateFriendsCount to rely on that single
expectation on attrManager->createOrUpdateByName.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php`:
- Around line 59-65: The MessageForwardDto instantiation has the fromName and
fromEmail arguments swapped; the constructor is (array $emails, string $uid,
string $fromName, string $fromEmail, ?string $note). Update the test to pass the
display name as the third argument and the email as the fourth (i.e., swap
'from@example.com' and 'From' in the new MessageForwardDto call) so
MessageForwardDto receives correct fromName and fromEmail values.
🧹 Nitpick comments (3)
src/Domain/Messaging/Service/ForwardingGuard.php (1)

51-56: Consider extracting the status string to a constant.

The 'sent' status appears both here and in UserMessageForwardRepository::getCountByUserSince. A shared constant (or enum) would prevent typos and make the status values discoverable.

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

7-7: Unused import.

DateTimeImmutable is imported but not used in any test.

Proposed fix
-use DateTimeImmutable;
tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php (1)

7-7: Unused import.

DateTimeImmutable is imported but never used.

Proposed fix
-use DateTimeImmutable;

Comment on lines +59 to +65
forwardDto: new MessageForwardDto(
[],
'uuid',
'from@example.com',
'From',
null
)
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

Arguments are swapped in MessageForwardDto constructor.

The constructor signature is (array $emails, string $uid, string $fromName, string $fromEmail, ?string $note), but here you're passing 'from@example.com' as fromName and 'From' as fromEmail. That's backwards.

While the test still exercises the exception path (cache miss), it's worth fixing to keep test data realistic.

Proposed fix
         $service->getContents(
             campaign: $campaign,
             forwardingSubscriber: $subscriber,
             friendEmail: 'friend@example.com',
             forwardDto: new MessageForwardDto(
                 [],
                 'uuid',
-                'from@example.com',
-                'From',
+                'From',
+                'from@example.com',
                 null
             )
         );
📝 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
forwardDto: new MessageForwardDto(
[],
'uuid',
'from@example.com',
'From',
null
)
forwardDto: new MessageForwardDto(
[],
'uuid',
'From',
'from@example.com',
null
)
🤖 Prompt for AI Agents
In `@tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php` around
lines 59 - 65, The MessageForwardDto instantiation has the fromName and
fromEmail arguments swapped; the constructor is (array $emails, string $uid,
string $fromName, string $fromEmail, ?string $note). Update the test to pass the
display name as the third argument and the email as the fourth (i.e., swap
'from@example.com' and 'From' in the new MessageForwardDto call) so
MessageForwardDto receives correct fromName and fromEmail values.

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

🤖 Fix all issues with AI agents
In `@src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php`:
- Around line 89-98: The subject is being normalized with stripslashes() after
validation, so move normalization before validation: create a normalized subject
(e.g., $normalizedSubject = stripslashes($data->subject)) and use that when
calling validateRecipientAndSubject(to: $friendEmail, subject:
$normalizedSubject) and for any subsequent logic (including building $subject
with $this->translator->trans('Fwd') . ': ' . $normalizedSubject); leave
passesBlacklistCheck and exception types (InvalidRecipientOrSubjectException,
EmailBlacklistedException) unchanged.
- Around line 130-137: The Received header value from
HttpReceivedStampBuilder::buildStamp() must be sanitized before calling
Email::getHeaders()->addTextHeader('Received', ...); guard against CR/LF and
other control characters to prevent header injection: validate or normalize the
string returned by HttpReceivedStampBuilder (e.g., strip \r and \n, remove
control chars, and collapse whitespace or replace with a safe token) and only
call addTextHeader when the cleaned value is non-empty; keep this logic inside
ForwardEmailBuilder::applyCampaignHeaders so addTextHeader never receives
unsanitized REMOTE_HOST-derived content.
🧹 Nitpick comments (5)
config/PHPMD/rules.xml (1)

10-10: Disabling BooleanArgumentFlag loosens clean code checks.

Commenting out this rule means methods with boolean parameters won't be flagged anymore. Boolean flags often indicate a method is doing multiple things and could benefit from being split. If specific methods legitimately need boolean args, consider using @SuppressWarnings annotations on those methods instead of globally disabling the rule.

Any specific pain points that led to this? Would be good to know if this is temporary or intentional long-term.

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

34-38: Magic number 1 in cache key needs clarification.

The hardcoded 1 in the cache key is unclear - is this a format ID, version, or template identifier? Would be good to extract this to a named constant or add a brief comment explaining its purpose.

Also, a heads up: CacheInterface::get() returns mixed, so if something unexpected ends up in the cache, you'll get cryptic errors downstream when it's used as a MessagePrecacheDto. Consider adding type validation.

💡 Optional: Add type safety and clarify magic number
+    private const BASE_MESSAGE_FORMAT_ID = 1; // TODO: document what this represents
+
     /** `@return` array{Email, OutputFormat}
      * `@throws` MessageCacheMissingException
      */
     public function getContents(
         Message $campaign,
         Subscriber $forwardingSubscriber,
         string $friendEmail,
         MessageForwardDto $forwardDto
     ): array {
-        $messagePrecacheDto = $this->cache->get(sprintf('messaging.message.base.%d.%d', $campaign->getId(), 1));
+        $messagePrecacheDto = $this->cache->get(
+            sprintf('messaging.message.base.%d.%d', $campaign->getId(), self::BASE_MESSAGE_FORMAT_ID)
+        );
 
-        if ($messagePrecacheDto === null) {
+        if (!$messagePrecacheDto instanceof MessagePrecacheDto) {
             throw new MessageCacheMissingException();
         }

40-45: TODO tracked - link handling in forwarding context.

Good call leaving this note. The behavior of tracked links when forwarded to someone outside the subscriber list could have interesting implications for analytics.

Want me to open an issue to track this, or do you have a plan for addressing it later?

src/Domain/Messaging/Model/Dto/ForwardingRecipientResult.php (1)

9-14: Consider making reason readonly and passing it at construction time.

The reason property is mutable while email and status are readonly. In MessageForwardService, the pattern is to create the result, then mutate reason (lines 64, 85). This works, but constructing with all values upfront would be more consistent and enable full immutability.

♻️ Optional refactor for immutability
 public function __construct(
     public readonly string $email,
     public readonly string $status,
-    public ?string $reason = null,
+    public readonly ?string $reason = null,
 ) {
 }

Then in MessageForwardService, pass reason directly:

return new ForwardingRecipientResult(
    email: $friendEmail,
    status: 'failed',
    reason: 'precache_failed',
);
tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php (1)

141-178: Consider adding a test for send() throwing an exception.

Current tests cover getContents() throwing but not send(). If send() throws, the current implementation would break the batch loop (as flagged in the service review). Adding a test would document the expected behavior and catch regressions once the fix is applied.

Comment on lines +89 to +98
if (!$this->validateRecipientAndSubject(to: $friendEmail, subject: $data->subject)) {
throw new InvalidRecipientOrSubjectException();
}

if (!$this->passesBlacklistCheck(to: $friendEmail, skipBlacklistCheck: false)) {
throw new EmailBlacklistedException();
}

$subject = $this->translator->trans('Fwd') . ': ' . stripslashes($data->subject);

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

Normalize subject before validating.
stripslashes() happens after validation, so validation can drift from the actual subject. Consider normalizing first (or re‑validating after normalization).

💡 Suggested tweak
-        if (!$this->validateRecipientAndSubject(to: $friendEmail, subject: $data->subject)) {
+        $rawSubject = stripslashes($data->subject);
+        if (!$this->validateRecipientAndSubject(to: $friendEmail, subject: $rawSubject)) {
             throw new InvalidRecipientOrSubjectException();
         }
 
-        $subject = $this->translator->trans('Fwd') . ': ' . stripslashes($data->subject);
+        $subject = $this->translator->trans('Fwd') . ': ' . $rawSubject;
📝 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 (!$this->validateRecipientAndSubject(to: $friendEmail, subject: $data->subject)) {
throw new InvalidRecipientOrSubjectException();
}
if (!$this->passesBlacklistCheck(to: $friendEmail, skipBlacklistCheck: false)) {
throw new EmailBlacklistedException();
}
$subject = $this->translator->trans('Fwd') . ': ' . stripslashes($data->subject);
$rawSubject = stripslashes($data->subject);
if (!$this->validateRecipientAndSubject(to: $friendEmail, subject: $rawSubject)) {
throw new InvalidRecipientOrSubjectException();
}
if (!$this->passesBlacklistCheck(to: $friendEmail, skipBlacklistCheck: false)) {
throw new EmailBlacklistedException();
}
$subject = $this->translator->trans('Fwd') . ': ' . $rawSubject;
🤖 Prompt for AI Agents
In `@src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php` around lines 89
- 98, The subject is being normalized with stripslashes() after validation, so
move normalization before validation: create a normalized subject (e.g.,
$normalizedSubject = stripslashes($data->subject)) and use that when calling
validateRecipientAndSubject(to: $friendEmail, subject: $normalizedSubject) and
for any subsequent logic (including building $subject with
$this->translator->trans('Fwd') . ': ' . $normalizedSubject); leave
passesBlacklistCheck and exception types (InvalidRecipientOrSubjectException,
EmailBlacklistedException) unchanged.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/Domain/Messaging/Model/Message/MessageFormat.php`:
- Around line 19-32: The PostgreSQL init migration creates the MessageFormat
boolean columns that are mapped in the MessageFormat class as integers ($asText,
$asHtml, $asPdf, $asTextAndHtml, $asTextAndPdf), causing Doctrine hydration
errors; update the Postgres migration to define those columns as INT NOT NULL
DEFAULT 0 (matching MySQL and the ORM mapping) and optionally add explicit ORM
column options in the MessageFormat entity (ORM\Column on properties $asText,
$asHtml, $asPdf, $asTextAndHtml, $asTextAndPdf) to ensure type and default are
clear to Doctrine.
🧹 Nitpick comments (2)
config/services/services.yml (2)

7-23: Redundant autowire/autoconfigure can be removed from services covered by _defaults.

With the new _defaults block, services only need to declare properties that differ from the defaults. Most services here can drop explicit autowire: true and autoconfigure: true, keeping only what's necessary (e.g., public: true or custom arguments).

This applies throughout the file - not urgent, but would reduce noise.

♻️ Example cleanup for a few services
 PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter:
-    autowire: true
-    autoconfigure: true
     public: true

 PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter:
-    autowire: true
-    autoconfigure: true
     public: true

 PhpList\Core\Domain\Messaging\Service\EmailService:
-    autowire: true
-    autoconfigure: true
+    # Just remove these two lines entirely, or add 'public: true' if it needs to be public

 PhpList\Core\Domain\Messaging\Service\MessageForwardService:
-    autowire: true
-    autoconfigure: true
+    # Same here - can be reduced to just the service key with ~ if no special config

86-91: Hardcoded values won't act as conditional fallbacks.

The comment says "Use literal defaults if parameters are not defined" but Symfony YAML doesn't conditionally use parameters - these hardcoded values (0, 204800, 30) will always override any parameters you might define elsewhere with the same names.

If you want these to be configurable via parameters with fallback defaults, define the parameters in your parameters.yml or similar with default values, then reference them here:

♻️ Suggested approach for configurable defaults
   PhpList\Core\Domain\Common\ExternalImageService:
     autowire: true
     autoconfigure: true
     arguments:
       $tempDir: '%kernel.cache_dir%'
-      # Use literal defaults if parameters are not defined in this environment
-      $externalImageMaxAge: 0
-      $externalImageMaxSize: 204800
-      $externalImageTimeout: 30
+      $externalImageMaxAge: '%external_image.max_age%'
+      $externalImageMaxSize: '%external_image.max_size%'
+      $externalImageTimeout: '%external_image.timeout%'

Then define the parameters with defaults in your parameters file:

parameters:
    external_image.max_age: 0
    external_image.max_size: 204800
    external_image.timeout: 30

Comment on lines +19 to +32
#[ORM\Column(name: 'astext', type: 'integer')]
private int $asText = 0;

#[ORM\Column(name: 'ashtml', type: 'boolean')]
private bool $asHtml = false;
#[ORM\Column(name: 'ashtml', type: 'integer')]
private int $asHtml = 0;

#[ORM\Column(name: 'aspdf', type: 'boolean')]
private bool $asPdf = false;
#[ORM\Column(name: 'aspdf', type: 'integer')]
private int $asPdf = 0;

#[ORM\Column(name: 'astextandhtml', type: 'boolean')]
private bool $asTextAndHtml = false;
#[ORM\Column(name: 'astextandhtml', type: 'integer')]
private int $asTextAndHtml = 0;

#[ORM\Column(name: 'astextandpdf', type: 'boolean')]
private bool $asTextAndPdf = false;

public const FORMAT_TEXT = 'text';
public const FORMAT_HTML = 'html';
public const FORMAT_PDF = 'pdf';
#[ORM\Column(name: 'astextandpdf', type: 'integer')]
private int $asTextAndPdf = 0;
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:

# Check if the file exists and examine its full content
cd /tmp && git -C /tmp/repo_verification_workspace ls-files | grep -i messageformat || echo "File list approach"

Repository: phpList/core

Length of output: 177


🏁 Script executed:

# Find the file
find . -name "MessageFormat.php" -type f 2>/dev/null | head -20

Repository: phpList/core

Length of output: 110


🏁 Script executed:

# Search for migration files related to MessageFormat
find . -path "*/migrations/*" -name "*.php" -type f | head -20

Repository: phpList/core

Length of output: 38


🏁 Script executed:

# Search for schema changes or migration comments related to "astext" or message format
rg "astext|ashtml|aspdf" --type php -B 2 -A 2

Repository: phpList/core

Length of output: 7780


🏁 Script executed:

# Check for any doctrine schema generation or migration handling
rg "MessageFormat|message.*format" --type php --type yaml -l | head -20

Repository: phpList/core

Length of output: 1237


🏁 Script executed:

# Read the full MessageFormat.php file to see current state
cat -n ./src/Domain/Messaging/Model/Message/MessageFormat.php

Repository: phpList/core

Length of output: 3283


🏁 Script executed:

# Check the exact migration that handles the schema change
cat -n ./src/Migrations/Version20251028092902MySqlUpdate.php | sed -n '1,100p'

Repository: phpList/core

Length of output: 11143


🏁 Script executed:

# Look for PostgreSQL migration handling
cat -n ./src/Migrations/Version20251031072945PostGreInit.php | sed -n '1,100p'

Repository: phpList/core

Length of output: 11442


🏁 Script executed:

# Check if there are any other migrations after these that might affect the schema
find ./src/Migrations -name "*.php" -type f | sort | tail -10

Repository: phpList/core

Length of output: 215


🏁 Script executed:

# Check if there's a PostgreSQL update migration after the init
find ./src/Migrations -name "*PostGre*" -o -name "*postgres*" | sort

Repository: phpList/core

Length of output: 109


🏁 Script executed:

# Look at the full MySQL migration to see the complete picture
sed -n '94,94p' ./src/Migrations/Version20251028092902MySqlUpdate.php

Repository: phpList/core

Length of output: 702


🏁 Script executed:

# Search for any recent migrations that might update PostgreSQL schema for message format
rg "astext|ashtml|aspdf" ./src/Migrations -A 2 -B 2

Repository: phpList/core

Length of output: 5304


🏁 Script executed:

# Check if Doctrine's schema sync would properly handle the change
# Look for configuration related to migration generation
find . -name "doctrine.yaml" -o -name "doctrine.yml" | xargs cat 2>/dev/null | head -30

Repository: phpList/core

Length of output: 904


🏁 Script executed:

# Check if there are any other migration files for PostgreSQL after the init
find ./src/Migrations -type f -name "*.php" | xargs grep -l "PostgreSQL\|postgre" | sort

Repository: phpList/core

Length of output: 163


🏁 Script executed:

# Look at the down() migration for MySQL to see what it was before
sed -n '1,200p' ./src/Migrations/Version20251028092902MySqlUpdate.php | grep -A 50 "public function down"

Repository: phpList/core

Length of output: 3442


🏁 Script executed:

# Check the full line in PostgreSQL init that creates the message table
sed -n '115,115p' ./src/Migrations/Version20251031072945PostGreInit.php

Repository: phpList/core

Length of output: 150


🏁 Script executed:

# Verify the exact schema in the PostgreSQL migration for those fields
rg "astext.*BOOLEAN|ashtml.*BOOLEAN|aspdf.*BOOLEAN" ./src/Migrations/Version20251031072945PostGreInit.php -o

Repository: phpList/core

Length of output: 473


🏁 Script executed:

# Check if there are any PostgreSQL migrations AFTER the init that might alter the schema
find ./src/Migrations -type f -name "*PostGre*.php" | sort

Repository: phpList/core

Length of output: 109


🏁 Script executed:

# Look for any ALTER TABLE statements for message format fields in all migrations
rg "ALTER TABLE.*message.*astext|ALTER TABLE.*message.*ashtml" ./src/Migrations -B 2 -A 2

Repository: phpList/core

Length of output: 2753


🏁 Script executed:

# Double-check the exact PostgreSQL create statement
sed -n '115,115p' ./src/Migrations/Version20251031072945PostGreInit.php | grep -o "astext[^,]*" | head -1

Repository: phpList/core

Length of output: 38


🏁 Script executed:

# Let's verify what happens when the ORM tries to hydrate these fields from PostgreSQL
# Check if there's any type casting or handling in the MessageFormat class
cat -n ./src/Domain/Messaging/Model/Message/MessageFormat.php | sed -n '60,90p'

Repository: phpList/core

Length of output: 816


🏁 Script executed:

# Check if there's any conversion logic in repositories or services
rg "getAsText|incrementAsText" --type php -B 3 -A 3 | head -50

Repository: phpList/core

Length of output: 3317


🏁 Script executed:

# Verify the exact problem: PostgreSQL init has BOOLEAN, MySQL has INT DEFAULT 0
echo "=== PostgreSQL schema ===" && \
sed -n '115,115p' ./src/Migrations/Version20251031072945PostGreInit.php | grep -o "astext[^,]*\|ashtml[^,]*\|aspdf[^,]*" | head -5 && \
echo "=== MySQL schema ===" && \
grep "astext\|ashtml\|aspdf" ./src/Migrations/Version20251028092902MySqlUpdate.php | grep "CHANGE" | grep "down" -A 100 | head -1

Repository: phpList/core

Length of output: 102


🏁 Script executed:

# Look at exact line from PostgreSQL migration to see the CREATE TABLE statement
sed -n '115,115p' ./src/Migrations/Version20251031072945PostGreInit.php

Repository: phpList/core

Length of output: 150


🏁 Script executed:

# Check if there are any versions after PostGreInit
ls -la ./src/Migrations/ | grep "Version202510"

Repository: phpList/core

Length of output: 317


🏁 Script executed:

# Look for any migration that might handle PostgreSQL updates for message format
find ./src/Migrations -name "*.php" -type f -exec grep -l "astext.*INT\|ashtml.*INT" {} \;

Repository: phpList/core

Length of output: 163


🏁 Script executed:

# Let me check the full down() method for MySQL to see what the before-state was
sed -n '110,200p' ./src/Migrations/Version20251028092902MySqlUpdate.php | grep -A 5 "astext"

Repository: phpList/core

Length of output: 38


🏁 Script executed:

# Get the exact line number and content of the CREATE TABLE phplist_message in PostgreSQL migration
grep -n "CREATE TABLE phplist_message" ./src/Migrations/Version20251031072945PostGreInit.php

Repository: phpList/core

Length of output: 1676


🏁 Script executed:

# Extract and display the CREATE TABLE statement more cleanly
sed -n '115,115p' ./src/Migrations/Version20251031072945PostGreInit.php | sed 's/, /\n/g' | grep -i "astext\|ashtml\|aspdf\|astextandpdf\|astextandhtml"

Repository: phpList/core

Length of output: 38


🏁 Script executed:

# Check what the old state was by looking at the down() method in MySQL migration
sed -n '1,400p' ./src/Migrations/Version20251028092902MySqlUpdate.php | tail -100 | head -50

Repository: phpList/core

Length of output: 6066


🏁 Script executed:

# Confirm the exact line in MySQL migration where it converts the fields
sed -n '170,170p' ./src/Migrations/Version20251028092902MySqlUpdate.php | grep -o "astext[^,]*\|ashtml[^,]*\|aspdf[^,]*" | head -3

Repository: phpList/core

Length of output: 38


🏁 Script executed:

# Check if the up() in MySQL migration properly converts these fields
sed -n '94,94p' ./src/Migrations/Version20251028092902MySqlUpdate.php | grep -o "astext[^,]*\|ashtml[^,]*" | head -2

Repository: phpList/core

Length of output: 123


🏁 Script executed:

# Summary: check what type these were before the migration
cat ./src/Migrations/initial_schema.sql | grep -A 1 -B 1 "astext\|ashtml\|aspdf"

Repository: phpList/core

Length of output: 275


🏁 Script executed:

# Final confirmation: Look at the down() for MySQL to see what state these fields should revert to
sed -n '166,166p' ./src/Migrations/Version20251028092902MySqlUpdate.php

Repository: phpList/core

Length of output: 102


🏁 Script executed:

# Let me get the full down() statement for the message table
sed -n '1,400p' ./src/Migrations/Version20251028092902MySqlUpdate.php | grep -A 1 "CHANGE astext"

Repository: phpList/core

Length of output: 1557


Fix PostgreSQL migration schema for message format counters.

The MySQL migration correctly converts these fields to INT DEFAULT 0, but the PostgreSQL init migration creates them as BOOLEAN NOT NULL — this will cause Doctrine hydration failures since the ORM mapping expects type: 'integer'. Update the PostgreSQL init to create these fields as INT NOT NULL DEFAULT 0 to match MySQL and the original schema.

Additionally, consider adding explicit ORM options to the mapping:

🔧 Suggested ORM mapping updates
-    #[ORM\Column(name: 'astext', type: 'integer')]
+    #[ORM\Column(name: 'astext', type: 'integer', options: ['default' => 0])]
     private int $asText = 0;

-    #[ORM\Column(name: 'ashtml', type: 'integer')]
+    #[ORM\Column(name: 'ashtml', type: 'integer', options: ['default' => 0])]
     private int $asHtml = 0;

-    #[ORM\Column(name: 'aspdf', type: 'integer')]
+    #[ORM\Column(name: 'aspdf', type: 'integer', options: ['default' => 0])]
     private int $asPdf = 0;

-    #[ORM\Column(name: 'astextandhtml', type: 'integer')]
+    #[ORM\Column(name: 'astextandhtml', type: 'integer', options: ['default' => 0])]
     private int $asTextAndHtml = 0;

-    #[ORM\Column(name: 'astextandpdf', type: 'integer')]
+    #[ORM\Column(name: 'astextandpdf', type: 'integer', options: ['default' => 0])]
     private int $asTextAndPdf = 0;
🤖 Prompt for AI Agents
In `@src/Domain/Messaging/Model/Message/MessageFormat.php` around lines 19 - 32,
The PostgreSQL init migration creates the MessageFormat boolean columns that are
mapped in the MessageFormat class as integers ($asText, $asHtml, $asPdf,
$asTextAndHtml, $asTextAndPdf), causing Doctrine hydration errors; update the
Postgres migration to define those columns as INT NOT NULL DEFAULT 0 (matching
MySQL and the ORM mapping) and optionally add explicit ORM column options in the
MessageFormat entity (ORM\Column on properties $asText, $asHtml, $asPdf,
$asTextAndHtml, $asTextAndPdf) to ensure type and default are clear to Doctrine.

@TatevikGr TatevikGr force-pushed the ref/campaign-processing branch 3 times, most recently from 9f9c52e to ef094ea Compare February 3, 2026 19:08
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

🤖 Fix all issues with AI agents
In `@composer.json`:
- Line 161: composer.json is missing the Composer package for the registered
bundle FOS\RestBundle\FOSRestBundle; add the package
"friendsofsymfony/rest-bundle" to the require section with a Symfony
6.4‑compatible constraint (e.g. a 3.x/4.x range compatible with Symfony 6.4) so
the bundle registration resolves at bootstrap; update composer.json's require
entry accordingly and run composer update to install the package.

In `@src/DependencyInjection/PhpListCoreExtension.php`:
- Around line 18-23: The current hardcoded loop in PhpListCoreExtension.php only
loads three YAML files and skips the rest; replace that array with a glob scan:
use glob(__DIR__ . '/../../config/services/*.yml') to get all service YAML
files, iterate over each $path, check is_file($path) && is_readable($path), and
call $loader->load(basename($path)) so the loader receives the same filename
form as before; update the load logic inside the same method where the foreach
currently iterates over ['services.yml','builders.yml','managers.yml'].

"PhpList\\Core\\EmptyStartPageBundle\\EmptyStartPageBundle",
"FOS\\RestBundle\\FOSRestBundle",
"TatevikGr\\RssFeedBundle\\RssFeedBundle"
"FOS\\RestBundle\\FOSRestBundle"
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
# Check if FOSRestBundle or friendsofsymfony/rest-bundle is present in composer.json
echo "=== Searching composer.json ===" 
cat composer.json | grep -i "friendsofsymfony\|fosrest"

echo ""
echo "=== Full composer.json (for context) ==="
cat composer.json

Repository: phpList/core

Length of output: 5875


Add FOSRestBundle package to composer.json require section.

The bundle is registered in the Symfony configuration but the Composer package is missing. Add friendsofsymfony/rest-bundle with a version compatible for Symfony 6.4 to the require section, otherwise Symfony will fail during bootstrap.

🤖 Prompt for AI Agents
In `@composer.json` at line 161, composer.json is missing the Composer package for
the registered bundle FOS\RestBundle\FOSRestBundle; add the package
"friendsofsymfony/rest-bundle" to the require section with a Symfony
6.4‑compatible constraint (e.g. a 3.x/4.x range compatible with Symfony 6.4) so
the bundle registration resolves at bootstrap; update composer.json's require
entry accordingly and run composer update to install the package.

Comment on lines +18 to +23
// Load core service definitions if present (keep optional to avoid breaking consumers)
foreach (['services.yml', 'builders.yml', 'managers.yml'] as $file) {
$path = __DIR__ . '/../../config/services/' . $file;
if (is_file($path) && is_readable($path)) {
$loader->load($file);
}
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:

# List all YAML files in config/services directory
find . -path "./config/services/*.yml" -type f 2>/dev/null | sort

Repository: phpList/core

Length of output: 438


🏁 Script executed:

# Check for other YAML loaders in the DependencyInjection extension
rg -n "YamlFileLoader|load.*\.yml" src/DependencyInjection/ --type php

Repository: phpList/core

Length of output: 327


🏁 Script executed:

# Check if there are other service loaders or configuration elsewhere
rg -n "loadFromExtension|load.*services" src/ --type php -A 2

Repository: phpList/core

Length of output: 411


Switch to glob-based loading—9 of 12 service YAML files are currently skipped.

The hardcoded list only loads services.yml, builders.yml, and managers.yml. But config/services/ contains 12 YAML files total, including commands.yml, parameters.yml, repositories.yml, resolvers.yml, validators.yml, and others that won't be loaded. This will cause missing service definitions.

Use glob-based loading to pick up all files:

Refactor to auto-load all YAML files
-        foreach (['services.yml', 'builders.yml', 'managers.yml'] as $file) {
-            $path = __DIR__ . '/../../config/services/' . $file;
-            if (is_file($path) && is_readable($path)) {
-                $loader->load($file);
-            }
-        }
+        $serviceDir = __DIR__ . '/../../config/services';
+        foreach (glob($serviceDir . '/*.yml') as $path) {
+            if (is_readable($path)) {
+                $loader->load(basename($path));
+            }
+        }
📝 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
// Load core service definitions if present (keep optional to avoid breaking consumers)
foreach (['services.yml', 'builders.yml', 'managers.yml'] as $file) {
$path = __DIR__ . '/../../config/services/' . $file;
if (is_file($path) && is_readable($path)) {
$loader->load($file);
}
// Load core service definitions if present (keep optional to avoid breaking consumers)
$serviceDir = __DIR__ . '/../../config/services';
foreach (glob($serviceDir . '/*.yml') as $path) {
if (is_readable($path)) {
$loader->load(basename($path));
}
}
🤖 Prompt for AI Agents
In `@src/DependencyInjection/PhpListCoreExtension.php` around lines 18 - 23, The
current hardcoded loop in PhpListCoreExtension.php only loads three YAML files
and skips the rest; replace that array with a glob scan: use glob(__DIR__ .
'/../../config/services/*.yml') to get all service YAML files, iterate over each
$path, check is_file($path) && is_readable($path), and call
$loader->load(basename($path)) so the loader receives the same filename form as
before; update the load logic inside the same method where the foreach currently
iterates over ['services.yml','builders.yml','managers.yml'].

@TatevikGr TatevikGr force-pushed the ref/campaign-processing branch 4 times, most recently from 8697556 to 1dd0df0 Compare February 3, 2026 19:38
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/Unit/Composer/ModuleFinderTest.php (1)

33-42: ⚠️ Potential issue | 🟠 Major

Update three tests to mock findAll() instead of findModules().

The implementation of findBundleClasses() calls $this->packageRepository->findAll() (line 55 in ModuleFinder.php), but the following tests stub findModules():

  • testFindBundleClassesForNoModulesReturnsEmptyArray (line 33)
  • testFindBundleClassesForModulesWithoutBundlesReturnsEmptyArray (line 80)
  • testCreateBundleConfigurationYamlForNoModulesReturnsCommentOnly (line 144)

Since the mocked method won't actually be invoked, these tests won't properly validate the behavior and could mask bugs.

🤖 Fix all issues with AI agents
In `@composer.json`:
- Around line 160-161: Update the incorrect bundle FQCN in composer.json:
replace the entry "PhpList\\Core\\EmptyStartPageBundle\\PhpListCoreBundle" with
the correct fully-qualified class name "PhpList\\Core\\PhpListCoreBundle" so
Symfony can locate the PhpListCoreBundle class (fix the wrong namespace
reference to PhpList\Core\EmptyStartPageBundle and use
PhpList\Core\PhpListCoreBundle instead).

In `@src/Domain/Subscription/Repository/UserBlacklistRepository.php`:
- Around line 46-59: The isEmailBlacklisted method accepts a nullable $immediate
which, if null, currently behaves like false and enables grace time; normalize
it at the top of the function by coercing $immediate to a boolean default (e.g.,
$immediate = $immediate ?? true) so callers that accidentally pass null get the
intended immediate behavior; update the isEmailBlacklisted method (use the
$immediate parameter and $grace calculation logic) to apply this normalization
before computing $grace and $cutoff.
🧹 Nitpick comments (1)
config/services/builders.yml (1)

14-37: Consider using YAML anchors to reduce duplication.

All three email builders share identical constructor arguments. If these ever need updating, you'd have to change them in three places. YAML anchors can help keep things DRY.

♻️ Optional: Use YAML anchors to reduce repetition
+  .email_builder_defaults: &email_builder_args
+    $googleSenderId: '%messaging.google_sender_id%'
+    $useAmazonSes: '%messaging.use_amazon_ses%'
+    $usePrecedenceHeader: '%messaging.use_precedence_header%'
+    $devVersion: '%app.dev_version%'
+    $devEmail: '%app.dev_email%'
+
   PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder:
-    arguments:
-      $googleSenderId: '%messaging.google_sender_id%'
-      $useAmazonSes: '%messaging.use_amazon_ses%'
-      $usePrecedenceHeader: '%messaging.use_precedence_header%'
-      $devVersion: '%app.dev_version%'
-      $devEmail: '%app.dev_email%'
+    arguments: *email_builder_args

   PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder:
-    arguments:
-      $googleSenderId: '%messaging.google_sender_id%'
-      $useAmazonSes: '%messaging.use_amazon_ses%'
-      $usePrecedenceHeader: '%messaging.use_precedence_header%'
-      $devVersion: '%app.dev_version%'
-      $devEmail: '%app.dev_email%'
+    arguments: *email_builder_args

   PhpList\Core\Domain\Messaging\Service\Builder\ForwardEmailBuilder:
-    arguments:
-      $googleSenderId: '%messaging.google_sender_id%'
-      $useAmazonSes: '%messaging.use_amazon_ses%'
-      $usePrecedenceHeader: '%messaging.use_precedence_header%'
-      $devVersion: '%app.dev_version%'
-      $devEmail: '%app.dev_email%'
+    arguments: *email_builder_args

Note: The abstract service key starting with . prevents Symfony from trying to instantiate it. Feel free to skip this if you anticipate these builders diverging in their config soon.

Comment on lines 160 to +161
"PhpList\\Core\\EmptyStartPageBundle\\EmptyStartPageBundle",
"FOS\\RestBundle\\FOSRestBundle",
"TatevikGr\\RssFeedBundle\\RssFeedBundle"
"PhpList\\Core\\EmptyStartPageBundle\\PhpListCoreBundle",
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:

#!/bin/bash
# Verify bundle class definitions exist
rg -n --type=php 'class\s+EmptyStartPageBundle\b'
rg -n --type=php 'class\s+PhpListCoreBundle\b'

Repository: phpList/core

Length of output: 217


🏁 Script executed:

#!/bin/bash
# Check namespace declarations in both files
echo "=== EmptyStartPageBundle namespace ==="
head -20 src/EmptyStartPageBundle/EmptyStartPageBundle.php

echo -e "\n=== PhpListCoreBundle namespace ==="
head -20 src/PhpListCoreBundle.php

Repository: phpList/core

Length of output: 577


Fix incorrect bundle namespace in composer.json line 161.

The PhpListCoreBundle class is registered with the wrong fully-qualified name. It's actually in the PhpList\Core namespace, not PhpList\Core\EmptyStartPageBundle.

Change line 161 from:

"PhpList\\Core\\EmptyStartPageBundle\\PhpListCoreBundle",

To:

"PhpList\\Core\\PhpListCoreBundle",

Incorrect bundle registration will cause Symfony to fail loading the bundle during kernel boot.

🤖 Prompt for AI Agents
In `@composer.json` around lines 160 - 161, Update the incorrect bundle FQCN in
composer.json: replace the entry
"PhpList\\Core\\EmptyStartPageBundle\\PhpListCoreBundle" with the correct
fully-qualified class name "PhpList\\Core\\PhpListCoreBundle" so Symfony can
locate the PhpListCoreBundle class (fix the wrong namespace reference to
PhpList\Core\EmptyStartPageBundle and use PhpList\Core\PhpListCoreBundle
instead).

Comment on lines +46 to +59
public function isEmailBlacklisted(string $email, ?bool $immediate = true): bool
{
// allow 5 minutes to send the last message acknowledging unsubscription
$grTime = $this->blacklistGraceTime;
$grace = $immediate ? 0 : (($grTime >= 1 && $grTime <= 15) ? $grTime : 5);
$cutoff = (new DateTimeImmutable())->modify('-' . $grace .' minutes');

return $this->createQueryBuilder('ub')
->where('ub.email = :email')
->andWhere('ub.added < :cutoff')
->setParameter('email', $email)
->setParameter('cutoff', $cutoff)
->getQuery()
->getOneOrNullResult() !== null;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for all calls to isEmailBlacklisted
rg -n 'isEmailBlacklisted\s*\(' --type=php -A2 -B2

Repository: phpList/core

Length of output: 3491


The nullable-flag ambiguity is a valid defense, though not currently exploited. All existing callers pass only the email and rely on the default $immediate = true. However, the signature still allows null to sneak in, which would unintentionally enable grace time. The suggested normalization is sound—add $immediate = $immediate ?? true; to guard against future mistakes.

🤖 Prompt for AI Agents
In `@src/Domain/Subscription/Repository/UserBlacklistRepository.php` around lines
46 - 59, The isEmailBlacklisted method accepts a nullable $immediate which, if
null, currently behaves like false and enables grace time; normalize it at the
top of the function by coercing $immediate to a boolean default (e.g.,
$immediate = $immediate ?? true) so callers that accidentally pass null get the
intended immediate behavior; update the isEmailBlacklisted method (use the
$immediate parameter and $grace calculation logic) to apply this normalization
before computing $grace and $cutoff.

@TatevikGr TatevikGr force-pushed the ref/campaign-processing branch from 1dd0df0 to 0a9b9a4 Compare February 4, 2026 10:26
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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/Domain/Configuration/Service/Provider/ConfigProvider.php (1)

55-59: ⚠️ Potential issue | 🟡 Minor

Inconsistent in_array strictness.

Line 32 uses in_array($key, $this->booleanValues, true) (strict), but line 57 uses in_array($key, $this->booleanValues) (non-strict). Should be consistent.

🐛 Fix for consistency
-        if (in_array($key, $this->booleanValues)) {
+        if (in_array($key, $this->booleanValues, true)) {
             throw new InvalidArgumentException('Key is a boolean value, use isEnabled instead');
         }
🤖 Fix all issues with AI agents
In `@src/Domain/Common/RemotePageFetcher.php`:
- Around line 65-70: RemotePageFetcher currently performs persistence side
effects by calling $this->entityManager->remove() and
$this->urlCacheRepository->persist() inside src/Domain, which violates the rule
that Domain layer must not perform persistence. Modify RemotePageFetcher to stop
calling entityManager->remove and urlCacheRepository->persist directly: instead
have it create and return a domain value (e.g., an UrlCache instance via new
UrlCache()->setUrl(...)->setContent(...)->setLastModified(...)) and also return
or emit the list of existing UrlCache identifiers (from
$this->urlCacheRepository->getByUrl($url)) as a plain domain DTO/event; then
move the actual deletion and persistence logic into the
Application/Infrastructure layer (implementing repository persistence) where you
can call the repository implementation or entity manager to remove old caches
and persist the new UrlCache. Ensure method signatures (RemotePageFetcher) are
updated to return the UrlCache and items-to-delete or to dispatch a domain event
so infrastructure can handle persistence.

In `@src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php`:
- Around line 87-99: resolveDestinationEmail() is declared to return string but
can return null when $to is null and $this->devVersion is false; update the
contract by either (A) make the parameter non-nullable (change ?string $to to
string $to) and update callers to always pass a string, or (B) keep ?string $to
but explicitly handle null by throwing a clear exception (e.g.,
InvalidArgumentException) when $to is null and $this->devVersion is false so the
method always returns a string or throws; adjust the signature and/or add the
null-check/throw in resolveDestinationEmail() (method name:
resolveDestinationEmail, class: BaseEmailBuilder, fields: $this->devVersion and
$this->devEmail, exception currently thrown: DevEmailNotConfiguredException) and
update any callers/tests to match the chosen approach.
- Around line 101-113: In createBaseEmail ensure that both $fromEmail and
$subject are non-null before calling new Address(...) and $email->subject(...):
add explicit guards at the start of createBaseEmail (after resolving
$destinationEmail) that check $fromEmail !== null and $subject !== null and
throw a clear InvalidArgumentException (or Domain-specific exception) if either
is null; reference resolveDestinationEmail, Address, Email::subject,
ConfigProvider::getValue and MessagePrecacheDto::fromEmail in the error messages
so failures point to missing config/DTO values, and keep existing
validateRecipientAndSubject usage for other checks.

In `@src/Domain/Messaging/Service/MessagePrecacheService.php`:
- Around line 90-94: The calls to TemplateImageManager::parseLogoPlaceholders on
$messagePrecacheDto->template and $messagePrecacheDto->htmlFooter (and any other
nullable DTO properties) are unsafe because parseLogoPlaceholders(string
$content) requires a non-null string; update the block in MessagePrecacheService
(where $messagePrecacheDto->content, ->template, ->htmlFooter are set) to check
nullability before calling parseLogoPlaceholders — e.g. use conditional checks
or null-coalescing so you only call parseLogoPlaceholders when the property is
non-null and otherwise leave it null; ensure applyRemoteContentIfPresent()’s
nullable assignments are respected.

In `@src/Migrations/Version20260204094237.php`:
- Line 34: The migration shrinks phplist_linktrack_forward.url and
phplist_urlcache.url to VARCHAR(255) without checking existing data length; add
a pre-flight validation in the migration (e.g., in the up() method of
Version20260204094237) that runs SELECT COUNT(*) WHERE char_length(url) > 255
for both tables (phplist_linktrack_forward and phplist_urlcache) and
aborts/throws a clear exception (or logs the offending rows) if any rows exceed
255 characters, so the ALTER TABLE statements ($this->addSql('ALTER TABLE
phplist_linktrack_forward ALTER url TYPE VARCHAR(255)') and the analogous change
for phplist_urlcache) only run when it is safe to do so.
🧹 Nitpick comments (4)
src/Domain/Common/FileHelper.php (1)

11-14: Implicit boolean coercion on filesize() may be unclear.

filesize() returns int|false, and the loose boolean check treats 0-byte files as invalid. If that's intentional, a comment or explicit comparison would clarify intent.

♻️ Explicit comparison for clarity
 public function isValidFile(string $path): bool
 {
-    return is_file($path) && filesize($path);
+    // Reject non-files and empty (0-byte) files
+    return is_file($path) && filesize($path) > 0;
 }
src/Domain/Messaging/Service/MessagePrecacheService.php (2)

47-47: Consider adding type hint for $loadedMessageData.

The parameter lacks a type declaration. Adding array $loadedMessageData would improve type safety and IDE support.

♻️ Add type hint
-    public function precacheMessage(Message $campaign, $loadedMessageData, ?bool $forwardContent = false): bool
+    public function precacheMessage(Message $campaign, array $loadedMessageData, ?bool $forwardContent = false): bool

208-221: Potential null handling in str_ireplace.

Line 217 calls str_ireplace on $messagePrecacheDto->textContent which may be null. PHP coerces null to empty string, but explicit null-coalescing would be cleaner.

♻️ Explicit null handling
-            $messagePrecacheDto->textContent = str_ireplace($searchKey, $replace, $messagePrecacheDto->textContent);
+            $messagePrecacheDto->textContent = str_ireplace(
+                $searchKey,
+                $replace,
+                $messagePrecacheDto->textContent ?? ''
+            );
tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php (1)

116-122: Deprecated PHPUnit methods in use.

withConsecutive() and willReturnOnConsecutiveCalls() were deprecated in PHPUnit 9.6 and removed in PHPUnit 10. Consider refactoring to use a callback-based approach or separate expectations.

♻️ Refactor suggestion
-        $configProvider->expects(self::exactly(2))
-            ->method('getValue')
-            ->withConsecutive([ConfigOption::AdminAddress], [ConfigOption::AdminAddresses])
-            ->willReturnOnConsecutiveCalls(
-                'single@example.com',
-                ' admin1@example.com, , admin2@example.com ,admin1@example.com '
-            );
+        $valueMap = [
+            [ConfigOption::AdminAddress, 'single@example.com'],
+            [ConfigOption::AdminAddresses, ' admin1@example.com, , admin2@example.com ,admin1@example.com '],
+        ];
+        $configProvider->expects(self::exactly(2))
+            ->method('getValue')
+            ->willReturnCallback(function (ConfigOption $key) use (&$valueMap) {
+                foreach ($valueMap as $entry) {
+                    if ($entry[0] === $key) {
+                        return $entry[1];
+                    }
+                }
+                return null;
+            });

Comment on lines +65 to +70
$caches = $this->urlCacheRepository->getByUrl($url);
foreach ($caches as $cache) {
$this->entityManager->remove($cache);
}
$urlCache = (new UrlCache())->setUrl($url)->setContent($content)->setLastModified($lastModified);
$this->urlCacheRepository->persist($urlCache);
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

Domain layer persistence violation.

Lines 67 and 70 call $this->entityManager->remove() and $this->urlCacheRepository->persist() directly in src/Domain/**. Per coding guidelines, persistence side effects should be in Infrastructure/Application layer, not Domain.

As per coding guidelines: Domain services should not perform persistence operations - $entityManager->remove() and persistence calls are prohibited in src/Domain/**.

🤖 Prompt for AI Agents
In `@src/Domain/Common/RemotePageFetcher.php` around lines 65 - 70,
RemotePageFetcher currently performs persistence side effects by calling
$this->entityManager->remove() and $this->urlCacheRepository->persist() inside
src/Domain, which violates the rule that Domain layer must not perform
persistence. Modify RemotePageFetcher to stop calling entityManager->remove and
urlCacheRepository->persist directly: instead have it create and return a domain
value (e.g., an UrlCache instance via new
UrlCache()->setUrl(...)->setContent(...)->setLastModified(...)) and also return
or emit the list of existing UrlCache identifiers (from
$this->urlCacheRepository->getByUrl($url)) as a plain domain DTO/event; then
move the actual deletion and persistence logic into the
Application/Infrastructure layer (implementing repository persistence) where you
can call the repository implementation or entity manager to remove old caches
and persist the new UrlCache. Ensure method signatures (RemotePageFetcher) are
updated to return the UrlCache and items-to-delete or to dispatch a domain event
so infrastructure can handle persistence.

Comment on lines +87 to +99
protected function resolveDestinationEmail(?string $to): string
{
$destinationEmail = $to;

if ($this->devVersion) {
if (!$this->devEmail) {
throw new DevEmailNotConfiguredException();
}
$destinationEmail = $this->devEmail;
}

return $destinationEmail;
}
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 all call sites of resolveDestinationEmail
rg -n -C3 --type=php 'resolveDestinationEmail\s*\('

Repository: phpList/core

Length of output: 1298


🏁 Script executed:

#!/bin/bash
# Get context around the method definition
cd src/Domain/Messaging/Service/Builder
wc -l BaseEmailBuilder.php

Repository: phpList/core

Length of output: 80


🏁 Script executed:

#!/bin/bash
# Read the entire BaseEmailBuilder.php to understand the class context
cat -n src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php | head -150

Repository: phpList/core

Length of output: 6394


Fix the type contract: resolveDestinationEmail() cannot safely return null.

The method claims to return string but when $to is null and devVersion is false, it returns null anyway—this violates the return type hint and will throw a TypeError under strict types (which your code declares). Either handle null explicitly or remove the ? from the parameter.

Suggested fix
-    protected function resolveDestinationEmail(?string $to): string
+    protected function resolveDestinationEmail(string $to): string
     {
         $destinationEmail = $to;
🤖 Prompt for AI Agents
In `@src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php` around lines 87 -
99, resolveDestinationEmail() is declared to return string but can return null
when $to is null and $this->devVersion is false; update the contract by either
(A) make the parameter non-nullable (change ?string $to to string $to) and
update callers to always pass a string, or (B) keep ?string $to but explicitly
handle null by throwing a clear exception (e.g., InvalidArgumentException) when
$to is null and $this->devVersion is false so the method always returns a string
or throws; adjust the signature and/or add the null-check/throw in
resolveDestinationEmail() (method name: resolveDestinationEmail, class:
BaseEmailBuilder, fields: $this->devVersion and $this->devEmail, exception
currently thrown: DevEmailNotConfiguredException) and update any callers/tests
to match the chosen approach.

Comment on lines +101 to +113
protected function createBaseEmail(
string $to,
?string $fromEmail,
?string $fromName,
?string $subject,
) : Email {
$email = (new Email());
$destinationEmail = $this->resolveDestinationEmail($to);

$email->to($destinationEmail);
$email->from(new Address($fromEmail, $fromName ?? ''));
$email->subject($subject);

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
# First, let's look at the BaseEmailBuilder file to understand the context better
cat -n src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php | sed -n '90,130p'

Repository: phpList/core

Length of output: 1601


🏁 Script executed:

#!/bin/bash
# Find all call sites of createBaseEmail to see how it's invoked
rg -n 'createBaseEmail\s*\(' --type=php -A2

Repository: phpList/core

Length of output: 1503


🏁 Script executed:

#!/bin/bash
# Also check the full class structure to understand if there are parent classes or traits
rg -n -A50 'class BaseEmailBuilder' --type=php | head -60

Repository: phpList/core

Length of output: 5270


🏁 Script executed:

#!/bin/bash
# Check if Symfony's Address class is imported and look at how it's used
rg -n 'new Address' --type=php -B2 -A2

Repository: phpList/core

Length of output: 10991


🏁 Script executed:

#!/bin/bash
# Check SystemEmailBuilder call site 1
cat -n src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php | sed -n '65,80p'

Repository: phpList/core

Length of output: 787


🏁 Script executed:

#!/bin/bash
# Check SystemEmailBuilder call site 2
cat -n src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php | sed -n '110,125p'

Repository: phpList/core

Length of output: 885


🏁 Script executed:

#!/bin/bash
# Check ForwardEmailBuilder call site
cat -n src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php | sed -n '100,115p'

Repository: phpList/core

Length of output: 658


🏁 Script executed:

#!/bin/bash
# Check EmailBuilder call site
cat -n src/Domain/Messaging/Service/Builder/EmailBuilder.php | sed -n '85,100p'

Repository: phpList/core

Length of output: 754


🏁 Script executed:

#!/bin/bash
# Also check what happens to fromEmail & subject before these calls
cat -n src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php | sed -n '50,75p'

Repository: phpList/core

Length of output: 1181


🏁 Script executed:

#!/bin/bash
# Check ForwardEmailBuilder more context to see if there's validation
cat -n src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php | sed -n '50,115p'

Repository: phpList/core

Length of output: 2835


🏁 Script executed:

#!/bin/bash
# Check EmailBuilder method signature and validation
cat -n src/Domain/Messaging/Service/Builder/EmailBuilder.php | sed -n '50,100p'

Repository: phpList/core

Length of output: 2200


🏁 Script executed:

#!/bin/bash
# Check if ConfigProvider::getValue can return null
rg -n 'getValue.*MessageFromAddress' --type=php -B2 -A2

Repository: phpList/core

Length of output: 1699


🏁 Script executed:

#!/bin/bash
# Check Symfony Mime Address constructor behavior with null
rg -n 'class Address' --type=php | head -5

Repository: phpList/core

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Look for any tests that might show expected behavior
fd -e php test | xargs grep -l 'fromEmail.*null\|subject.*null' | head -5

Repository: phpList/core

Length of output: 278


🏁 Script executed:

#!/bin/bash
# Check the full ConfigProvider to understand getValue return type
rg -n 'function getValue|public function getValue' --type=php -A3 | head -20

Repository: phpList/core

Length of output: 1498


🏁 Script executed:

#!/bin/bash
# Check MessagePrecacheDto to see if fromEmail/fromName/subject are nullable
fd -name '*MessagePrecacheDto*' --type=php

Repository: phpList/core

Length of output: 286


🏁 Script executed:

#!/bin/bash
# Examine the DTO structure
cat -n src/Domain/Messaging/Service/MessagePrecacheDto.php 2>/dev/null | head -60

Repository: phpList/core

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Check if there are any tests for createBaseEmail with null values
rg -n 'createBaseEmail' --type=php tests/ -B2 -A5

Repository: phpList/core

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Search for MessagePrecacheDto
find . -name '*.php' -type f | xargs grep -l 'class MessagePrecacheDto'

Repository: phpList/core

Length of output: 111


🏁 Script executed:

#!/bin/bash
# Look at the actual file
rg -n 'class MessagePrecacheDto' --type=php -A30

Repository: phpList/core

Length of output: 2245


🏁 Script executed:

#!/bin/bash
# Make absolutely sure we got all createBaseEmail call sites
rg 'createBaseEmail' --type=php -B5 | grep -E '(fromEmail|subject|createBaseEmail)'

Repository: phpList/core

Length of output: 1330


🏁 Script executed:

#!/bin/bash
# Check if ConfigProvider has any guarantees about MessageFromAddress never being null
rg -n 'MessageFromAddress' --type=php | head -20

Repository: phpList/core

Length of output: 1166


🏁 Script executed:

#!/bin/bash
# Verify Symfony Mime Address behavior - search for its usage with null handling
rg -n 'new Address\(' --type=php | grep -E '(if|null|??|?:)' | head -10

Repository: phpList/core

Length of output: 1537


🏁 Script executed:

#!/bin/bash
# Check the exact return type and implementation of ConfigProvider::getValue
cat -n src/Domain/Configuration/Service/Provider/ConfigProvider.php | sed -n '55,65p'

Repository: phpList/core

Length of output: 629


🏁 Script executed:

#!/bin/bash
# Look for any tests that show Address constructor behavior with null
rg -n 'Address.*null' --type=php -B2 -A2

Repository: phpList/core

Length of output: 484


🏁 Script executed:

#!/bin/bash
# Check if there's any actual runtime issue or error handling around this
rg -n 'TypeError|InvalidArgumentException' --type=php src/Domain/Messaging/Service/Builder/ -B3 -A3

Repository: phpList/core

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Get the full picture - let's see what the actual Symfony Email and Address classes expect
# Search for any error handling or try-catch around Address instantiation
rg -n 'try|catch|throw' src/Domain/Messaging/Service/Builder/ --type=php -B2 -A2 | head -30

Repository: phpList/core

Length of output: 2374


🏁 Script executed:

#!/bin/bash
# Check all three concrete call sites more thoroughly to see validation flow
echo "=== SystemEmailBuilder buildCampaignEmail ===" && \
cat -n src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php | sed -n '53,76p'
echo "" && \
echo "=== EmailBuilder buildCampaignEmail ===" && \
cat -n src/Domain/Messaging/Service/Builder/EmailBuilder.php | sed -n '69,94p'
echo "" && \
echo "=== ForwardEmailBuilder buildForwardEmail ===" && \
cat -n src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php | sed -n '79,111p'

Repository: phpList/core

Length of output: 3721


Guard nullable fromEmail and subject before passing to Symfony Mime APIs.

Address constructor and Email::subject() require non-null strings; ConfigProvider::getValue() can return null, and MessagePrecacheDto::fromEmail is nullable. While validateRecipientAndSubject() validates some fields, it doesn't check fromEmail. Passing null will cause a TypeError at runtime.

Add validation in createBaseEmail() to enforce these as required:

Suggested guard
 protected function createBaseEmail(
     string $to,
     ?string $fromEmail,
     ?string $fromName,
     ?string $subject,
 ) : Email {
+    if ($fromEmail === null || trim($fromEmail) === '') {
+        throw new InvalidArgumentException('From email is required.');
+    }
+    if ($subject === null || trim($subject) === '') {
+        throw new InvalidArgumentException('Subject is required.');
+    }
     $email = (new Email());
     $destinationEmail = $this->resolveDestinationEmail($to);

     $email->to($destinationEmail);
     $email->from(new Address($fromEmail, $fromName ?? ''));
     $email->subject($subject);
📝 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
protected function createBaseEmail(
string $to,
?string $fromEmail,
?string $fromName,
?string $subject,
) : Email {
$email = (new Email());
$destinationEmail = $this->resolveDestinationEmail($to);
$email->to($destinationEmail);
$email->from(new Address($fromEmail, $fromName ?? ''));
$email->subject($subject);
protected function createBaseEmail(
string $to,
?string $fromEmail,
?string $fromName,
?string $subject,
) : Email {
if ($fromEmail === null || trim($fromEmail) === '') {
throw new InvalidArgumentException('From email is required.');
}
if ($subject === null || trim($subject) === '') {
throw new InvalidArgumentException('Subject is required.');
}
$email = (new Email());
$destinationEmail = $this->resolveDestinationEmail($to);
$email->to($destinationEmail);
$email->from(new Address($fromEmail, $fromName ?? ''));
$email->subject($subject);
🤖 Prompt for AI Agents
In `@src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php` around lines 101 -
113, In createBaseEmail ensure that both $fromEmail and $subject are non-null
before calling new Address(...) and $email->subject(...): add explicit guards at
the start of createBaseEmail (after resolving $destinationEmail) that check
$fromEmail !== null and $subject !== null and throw a clear
InvalidArgumentException (or Domain-specific exception) if either is null;
reference resolveDestinationEmail, Address, Email::subject,
ConfigProvider::getValue and MessagePrecacheDto::fromEmail in the error messages
so failures point to missing config/DTO values, and keep existing
validateRecipientAndSubject usage for other checks.

Comment on lines +90 to +94
$messagePrecacheDto->content = $this->templateImageManager->parseLogoPlaceholders($messagePrecacheDto->content);
$messagePrecacheDto->template = $this->templateImageManager
->parseLogoPlaceholders($messagePrecacheDto->template);
$messagePrecacheDto->htmlFooter = $this->templateImageManager
->parseLogoPlaceholders($messagePrecacheDto->htmlFooter);
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:

# Find and examine TemplateImageManager class
find . -type f -name "*.php" | xargs grep -l "class TemplateImageManager"

Repository: phpList/core

Length of output: 194


🏁 Script executed:

# Look for parseLogoPlaceholders method
rg "function parseLogoPlaceholders" -A 15

Repository: phpList/core

Length of output: 1677


🏁 Script executed:

# Check the MessagePrecacheService.php file around lines 90-94 and 201
fd "MessagePrecacheService.php" -x cat -n {} | head -210 | tail -125

Repository: phpList/core

Length of output: 6665


🏁 Script executed:

# Get the full method that contains lines 90-94 to see call ordering
fd "MessagePrecacheService.php" -x cat -n {} | grep -E "^\s+(40|41|42|43|44|45|46|47|48|49|50|51|52|53|54|55|56|57|58|59|60|61|62|63|64|65|66|67|68|69|70|71|72|73|74|75|76|77|78|79|80|81|82|83|84|85)" | head -50

Repository: phpList/core

Length of output: 2613


🏁 Script executed:

# Look at the method signature and structure - get lines 40-75
fd "MessagePrecacheService.php" -x sed -n '40,99p' {} -n

Repository: phpList/core

Length of output: 2847


🏁 Script executed:

# Search for MessagePrecacheDto class to understand default values
find . -type f -name "MessagePrecacheDto.php" -o -name "MessagePrecacheDTO.php" | head -1 | xargs cat -n | head -80

Repository: phpList/core

Length of output: 1221


Add null-safety handling before parseLogoPlaceholders calls for nullable properties.

$messagePrecacheDto->template is nullable and can be null (set at line 201 in applyRemoteContentIfPresent()). Since TemplateImageManager::parseLogoPlaceholders(string $content) expects a non-null string, passing null will raise a TypeError under strict types.

Use null-coalescing or conditional checks:

$messagePrecacheDto->template = $messagePrecacheDto->template 
    ? $this->templateImageManager->parseLogoPlaceholders($messagePrecacheDto->template)
    : null;
🤖 Prompt for AI Agents
In `@src/Domain/Messaging/Service/MessagePrecacheService.php` around lines 90 -
94, The calls to TemplateImageManager::parseLogoPlaceholders on
$messagePrecacheDto->template and $messagePrecacheDto->htmlFooter (and any other
nullable DTO properties) are unsafe because parseLogoPlaceholders(string
$content) requires a non-null string; update the block in MessagePrecacheService
(where $messagePrecacheDto->content, ->template, ->htmlFooter are set) to check
nullability before calling parseLogoPlaceholders — e.g. use conditional checks
or null-coalescing so you only call parseLogoPlaceholders when the property is
non-null and otherwise leave it null; ensure applyRemoteContentIfPresent()’s
nullable assignments are respected.

get_class($platform)
));

$this->addSql('ALTER TABLE phplist_linktrack_forward ALTER url TYPE VARCHAR(255)');
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

❓ Verification inconclusive

Script executed:

cat -n src/Migrations/Version20260204094237.php

Repository: phpList/core


Repository: phpList/core
Exit code: 0

stdout:

     1	<?php
     2	
     3	declare(strict_types=1);
     4	
     5	namespace PhpList\Core\Migrations;
     6	
     7	use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
     8	use Doctrine\DBAL\Platforms\MySQLPlatform;
     9	use Doctrine\Migrations\AbstractMigration;
    10	use Doctrine\DBAL\Schema\Schema;
    11	
    12	/**
    13	* ⚠️ Wizard warning:
    14	* Doctrine will `helpfully` remove url(255) prefixes and add collations 5.7 can’t read.
    15	* Review the SQL unless you enjoy debugging key length errors at 2 AM.
    16	*
    17	* Ex: phplist_linktrack_forward phplist_linktrack_forward_urlindex (but there are more)
    18	*/
    19	final class Version20260204094237 extends AbstractMigration
    20	{
    21	    public function getDescription(): string
    22	    {
    23	        return '';
    24	    }
    25	
    26	    public function up(Schema $schema): void
    27	    {
    28	        $platform = $this->connection->getDatabasePlatform();
    29	        $this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf(
    30	            'Unsupported platform for this migration: %s',
    31	            get_class($platform)
    32	        ));
    33	
    34	        $this->addSql('ALTER TABLE phplist_linktrack_forward ALTER url TYPE VARCHAR(255)');
    35	
    36	        $this->addSql('ALTER TABLE phplist_message ALTER astext TYPE INT USING astext::integer');
    37	        $this->addSql('ALTER TABLE phplist_message ALTER ashtml TYPE INT USING ashtml::integer');
    38	        $this->addSql('ALTER TABLE phplist_message ALTER aspdf TYPE INT USING aspdf::integer');
    39	        $this->addSql('ALTER TABLE phplist_message ALTER astextandhtml TYPE INT USING astextandhtml::integer');
    40	        $this->addSql('ALTER TABLE phplist_message ALTER astextandpdf TYPE INT USING astextandpdf::integer');
    41	
    42	        $this->addSql('ALTER TABLE phplist_urlcache ALTER url TYPE VARCHAR(255)');
    43	    }
    44	
    45	    public function down(Schema $schema): void
    46	    {
    47	        $platform = $this->connection->getDatabasePlatform();
    48	        $this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf(
    49	            'Unsupported platform for this migration: %s',
    50	            get_class($platform)
    51	        ));
    52	
    53	        $this->addSql('ALTER TABLE phplist_linktrack_forward ALTER url TYPE VARCHAR(2083)');
    54	
    55	        $this->addSql('ALTER TABLE phplist_message ALTER astext TYPE BOOLEAN USING (astext::integer <> 0)');
    56	        $this->addSql('ALTER TABLE phplist_message ALTER ashtml TYPE BOOLEAN USING (ashtml::integer <> 0)');
    57	        $this->addSql('ALTER TABLE phplist_message ALTER aspdf TYPE BOOLEAN USING (aspdf::integer <> 0)');
    58	        $this->addSql('ALTER TABLE phplist_message ALTER astextandhtml TYPE BOOLEAN USING (astextandhtml::integer <> 0)');
    59	        $this->addSql('ALTER TABLE phplist_message ALTER astextandpdf TYPE BOOLEAN USING (astextandpdf::integer <> 0)');
    60	
    61	        $this->addSql('ALTER TABLE phplist_urlcache ALTER url TYPE VARCHAR(2083)');
    62	    }
    63	}

Script executed:

fd -t f "schema\|fixture\|database" . -x file {} \; 2>/dev/null | head -20

Repository: phpList/core


Repository: phpList/core
Exit code: 0


Script executed:

# Search for existing column type definitions
rg "phplist_linktrack_forward|phplist_urlcache" --type yaml --type json --type php -A 3 | head -40

Repository: phpList/core


Repository: phpList/core
Exit code: 0

stdout:

src/Migrations/Version20251028092902MySqlUpdate.php:        $this->addSql('ALTER TABLE phplist_linktrack_forward RENAME INDEX urlindex TO phplist_linktrack_forward_urlindex;');
src/Migrations/Version20251028092902MySqlUpdate.php:        $this->addSql('ALTER TABLE phplist_linktrack_forward CHANGE urlhash urlhash VARCHAR(32) DEFAULT NULL');
src/Migrations/Version20251028092902MySqlUpdate.php:        $this->addSql('ALTER TABLE phplist_linktrack_forward RENAME INDEX uuididx TO phplist_linktrack_forward_uuididx');
src/Migrations/Version20251028092902MySqlUpdate.php:        $this->addSql('ALTER TABLE phplist_linktrack_forward RENAME INDEX urlunique TO phplist_linktrack_forward_urlunique');
src/Migrations/Version20251028092902MySqlUpdate.php-        $this->addSql('ALTER TABLE phplist_linktrack_ml RENAME INDEX fwdindex TO phplist_linktrack_ml_fwdindex');
src/Migrations/Version20251028092902MySqlUpdate.php-        $this->addSql('ALTER TABLE phplist_linktrack_ml RENAME INDEX midindex TO phplist_linktrack_ml_midindex');
src/Migrations/Version20251028092902MySqlUpdate.php-        $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX midindex TO phplist_linktrack_uml_click_midindex');
--
src/Migrations/Version20251028092902MySqlUpdate.php:        $this->addSql('ALTER TABLE phplist_urlcache RENAME INDEX urlindex TO phplist_urlcache_urlindex');
src/Migrations/Version20251028092902MySqlUpdate.php-        $this->addSql('ALTER TABLE phplist_user_attribute RENAME INDEX idnameindex TO phplist_user_attribute_idnameindex');
src/Migrations/Version20251028092902MySqlUpdate.php-        $this->addSql('ALTER TABLE phplist_user_attribute RENAME INDEX nameindex TO phplist_user_attribute_nameindex');
src/Migrations/Version20251028092902MySqlUpdate.php-        $this->addSql('DROP INDEX email ON phplist_user_blacklist');
--
src/Migrations/Version20251028092902MySqlUpdate.php:        $this->addSql('ALTER TABLE phplist_linktrack_forward DROP INDEX phplist_linktrack_forward_urlindex, ADD INDEX urlindex (url(255))');
src/Migrations/Version20251028092902MySqlUpdate.php:        $this->addSql('ALTER TABLE phplist_linktrack_forward CHANGE urlhash urlhash CHAR(32) DEFAULT NULL');
src/Migrations/Version20251028092902MySqlUpdate.php:        $this->addSql('ALTER TABLE phplist_linktrack_forward RENAME INDEX phplist_linktrack_forward_urlunique TO urlunique');
src/Migrations/Version20251028092902MySqlUpdate.php:        $this->addSql('ALTER TABLE phplist_linktrack_forward RENAME INDEX phplist_linktrack_forward_uuididx TO uuididx');
src/Migrations/Version20251028092902MySqlUpdate.php-        $this->addSql('ALTER TABLE phplist_linktrack_ml RENAME INDEX phplist_linktrack_ml_midindex TO midindex');
src/Migrations/Version20251028092902MySqlUpdate.php-        $this->addSql('ALTER TABLE phplist_linktrack_ml RENAME INDEX phplist_linktrack_ml_fwdindex TO fwdindex');
src/Migrations/Version20251028092902MySqlUpdate.php-        $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX phplist_linktrack_uml_click_miduidfwdid TO miduidfwdid');
--
src/Migrations/Version20251028092902MySqlUpdate.php:        $this->addSql('ALTER TABLE phplist_urlcache DROP INDEX phplist_urlcache_urlindex, ADD INDEX urlindex (url(255))');
src/Migrations/Version20251028092902MySqlUpdate.php-        $this->addSql('ALTER TABLE phplist_user_attribute RENAME INDEX phplist_user_attribute_nameindex TO nameindex');
src/Migrations/Version20251028092902MySqlUpdate.php-        $this->addSql('ALTER TABLE phplist_user_attribute RENAME INDEX phplist_user_attribute_idnameindex TO idnameindex');
src/Migrations/Version20251028092902MySqlUpdate.php-        $this->addSql('ALTER TABLE phplist_user_blacklist DROP INDEX `primary`, ADD UNIQUE INDEX email (email)');
--
src/Migrations/Version20260204094237.php:* Ex: phplist_linktrack_forward phplist_linktrack_forward_urlindex (but there are more)
src/Migrations/Version20260204094237.php-*/
src/Migrations/Version20260204094237.php-final class Version20260204094237 extends AbstractMigration
src/Migrations/Version20260204094237.php-{
--
src/Migrations/Version20260204094237.php:        $this->addSql('ALTER TABLE phplist_linktrack_forward ALTER url TYPE VARCHAR(255)');
src/Migrations/Version20260204094237.php-
src/Migrations/Version20260204094237.php-        $this->addSql('ALTER TABLE phplist_message ALTER astext TYPE INT USING astext::integer');
src/Migrations/Version20260204094237.php-        $this->addSql('ALTER TABLE phplist_message ALTER ashtml TYPE INT USING ashtml::integer');
--
src/Migrations/Version20260204094237.php:        $this->addSql('ALTER TABLE phplist_urlcache ALTER url TYPE VARCHAR(255)');
src/Migrations/Version20260204094237.php-    }
src/Migrations/Version20260204094237.php-
src/Migrations/Version20260204094237.php-    public function down(Schema $schema): void

URL shrinking to VARCHAR(255) lacks pre-flight check and will fail if existing data exceeds limit.

Lines 34 and 42 reduce phplist_linktrack_forward.url and phplist_urlcache.url from VARCHAR(2083) → VARCHAR(255). If any row exceeds 255 characters, PostgreSQL aborts the migration. Consider adding a pre-check to catch oversized data upfront before attempting the ALTER.

Example: Validate before shrinking
     public function up(Schema $schema): void
     {
         $platform = $this->connection->getDatabasePlatform();
         $this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf(
             'Unsupported platform for this migration: %s',
             get_class($platform)
         ));
 
+        $maxForward = (int) $this->connection->fetchOne(
+            'SELECT MAX(LENGTH(url)) FROM phplist_linktrack_forward'
+        );
+        if ($maxForward > 255) {
+            throw new \RuntimeException('phplist_linktrack_forward.url has entries > 255 chars');
+        }
+
+        $maxCache = (int) $this->connection->fetchOne(
+            'SELECT MAX(LENGTH(url)) FROM phplist_urlcache'
+        );
+        if ($maxCache > 255) {
+            throw new \RuntimeException('phplist_urlcache.url has entries > 255 chars');
+        }
+
         $this->addSql('ALTER TABLE phplist_linktrack_forward ALTER url TYPE VARCHAR(255)');

Also applies to: 42-42

🤖 Prompt for AI Agents
In `@src/Migrations/Version20260204094237.php` at line 34, The migration shrinks
phplist_linktrack_forward.url and phplist_urlcache.url to VARCHAR(255) without
checking existing data length; add a pre-flight validation in the migration
(e.g., in the up() method of Version20260204094237) that runs SELECT COUNT(*)
WHERE char_length(url) > 255 for both tables (phplist_linktrack_forward and
phplist_urlcache) and aborts/throws a clear exception (or logs the offending
rows) if any rows exceed 255 characters, so the ALTER TABLE statements
($this->addSql('ALTER TABLE phplist_linktrack_forward ALTER url TYPE
VARCHAR(255)') and the analogous change for phplist_urlcache) only run when it
is safe to do so.

@TatevikGr TatevikGr force-pushed the ref/campaign-processing branch 2 times, most recently from 9a23394 to 289099f Compare February 5, 2026 12:36
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/Messaging/Service/MessageDataLoader.php`:
- Around line 115-137: In mergeStoredMessageData, guard all stripslashes calls
against non-strings to avoid TypeError: when walking the unserialized payload in
array_walk_recursive (inside the if branch for 'SER:') only call stripslashes if
is_string($val) (leave other scalars as-is), and in the else branch where you
set $data = stripslashes($storedMessageDatum->getData()) change to conditionally
stripslashes only when is_string($storedMessageDatum->getData()); likewise guard
the stripslashes on storedMessageDatum->getName() before using it as the array
key so non-string names are not passed to stripslashes; keep existing logic and
constants (self::AS_FORMAT_FIELDS, mergeStoredMessageData, $storedMessageDatum)
otherwise.
- Around line 171-210: normaliseFromField assigns $defaultFrom into
$messageData['fromemail'] and then calls str_replace on that value, which can be
null and will cause a TypeError under strict_types; ensure $defaultFrom is
coerced to a safe string early (e.g. $defaultFrom ?? '') before assigning into
$messageData['fromemail'] (or apply coalescing when setting
$messageData['fromemail']) so subsequent calls like str_replace and trim operate
on strings; update the assignment sites in normaliseFromField (where
$messageData['fromemail'] = $defaultFrom) to use a non-null fallback.

In `@src/Domain/Messaging/Service/MessagePrecacheService.php`:
- Around line 80-85: The regex uses $this->uploadImageDir directly which can
contain regex metacharacters and break preg_replace; update the code in
MessagePrecacheService (where $messagePrecacheDto->content is set by
preg_replace) to wrap $this->uploadImageDir with
preg_quote($this->uploadImageDir, '/') to escape all regex-significant chars
(use '/' as the preg delimiter), then build the pattern using that quoted value
instead of str_replace; keep the rest of the pattern and replacement unchanged
so preg_replace correctly matches and rewrites img src paths.
🧹 Nitpick comments (1)
src/Domain/Messaging/Service/MessageDataLoader.php (1)

14-14: TODO should be resolved or tracked.

Chill reminder: this TODO can easily get forgotten. Either tighten types now or spin up a tracking issue so it doesn’t ship half-baked.

Comment on lines +115 to +137
private function mergeStoredMessageData(array &$messageData, Message $message): void
{
$storedMessageData = $this->messageDataRepository->getForMessage($message->getId());
foreach ($storedMessageData as $storedMessageDatum) {
if (str_starts_with($storedMessageDatum->getData(), 'SER:')) {
$unserialized = unserialize(substr($storedMessageDatum->getData(), 4), ['allowed_classes' => false]);
if (!is_array($unserialized)) {
$this->logger->warning('Invalid serialized data for message ID: ' . $message->getId());
continue;
}
array_walk_recursive($unserialized, function (&$val) {
$val = stripslashes($val);
});

$data = $unserialized;
} else {
$data = stripslashes($storedMessageDatum->getData());
}
if (!in_array($storedMessageDatum->getName(), self::AS_FORMAT_FIELDS)) {
//# don't overwrite counters in the message table from the data table
$messageData[stripslashes($storedMessageDatum->getName())] = $data;
}
}
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

🌐 Web query:

PHP 8 stripslashes TypeError int bool non-string parameter

💡 Result:

In PHP 8+, stripslashes() is strictly typed as:

  • stripslashes(string $string): string (php.net)

So if you pass an int, bool, null, etc., PHP throws a TypeError (older PHP often just emitted warnings or coerced types). PHP 8 specifically tightened internal function parameter checks and many now throw TypeError/ValueError instead of warnings. (php.watch)

Fix patterns

1) Only call it for strings (recommended)

$value = is_string($value) ? stripslashes($value) : $value;

2) If you expect arrays (e.g., $_POST)

function stripslashes_deep($v) {
    return is_array($v) ? array_map('stripslashes_deep', $v)
         : (is_string($v) ? stripslashes($v) : $v);
}
$_POST = stripslashes_deep($_POST);

(Manual notes stripslashes() isn’t recursive; you must handle arrays yourself.) (php.net)

3) If you truly want coercion

$value = stripslashes((string)$value);

Be careful: true becomes "1" and false becomes "" after casting.

Also sanity-check why you’re using it: magic_quotes_gpc is long gone, so many legacy stripslashes($_POST[...]) calls are unnecessary now (and often the root of these upgrade errors). (php.net)

Citations:


Guard stripslashes against non-strings in serialized payloads.

In PHP 8+, stripslashes(string $string): string is strictly typed and throws TypeError if passed an int, bool, or null. Since array_walk_recursive will hit all scalar values in the deserialized payload, this needs a type guard.

✅ Suggested fix
-                array_walk_recursive($unserialized, function (&$val) {
-                    $val = stripslashes($val);
-                });
+                array_walk_recursive($unserialized, function (&$val) {
+                    if (is_string($val)) {
+                        $val = stripslashes($val);
+                    }
+                });
🤖 Prompt for AI Agents
In `@src/Domain/Messaging/Service/MessageDataLoader.php` around lines 115 - 137,
In mergeStoredMessageData, guard all stripslashes calls against non-strings to
avoid TypeError: when walking the unserialized payload in array_walk_recursive
(inside the if branch for 'SER:') only call stripslashes if is_string($val)
(leave other scalars as-is), and in the else branch where you set $data =
stripslashes($storedMessageDatum->getData()) change to conditionally
stripslashes only when is_string($storedMessageDatum->getData()); likewise guard
the stripslashes on storedMessageDatum->getName() before using it as the array
key so non-string names are not passed to stripslashes; keep existing logic and
constants (self::AS_FORMAT_FIELDS, mergeStoredMessageData, $storedMessageDatum)
otherwise.

Comment on lines +171 to +210
private function normaliseFromField(array &$messageData, ?string $defaultFrom): void
{
//## parse the from field into it's components - email and name
if (preg_match('/([^ ]+@[^ ]+)/', $messageData['fromfield'], $regs)) {
// if there is an email in the from, rewrite it as "name <email>"
$messageData['fromname'] = str_replace($regs[0], '', $messageData['fromfield']);
$messageData['fromemail'] = $regs[0];
// if the email has < and > take them out here
$messageData['fromemail'] = str_replace('<', '', $messageData['fromemail']);
$messageData['fromemail'] = str_replace('>', '', $messageData['fromemail']);
// make sure there are no quotes around the name
$messageData['fromname'] = str_replace('"', '', ltrim(rtrim($messageData['fromname'])));
} elseif (str_contains($messageData['fromfield'], ' ')) {
// if there is a space, we need to add the email
$messageData['fromname'] = $messageData['fromfield'];
$messageData['fromemail'] = $defaultFrom;
} else {
$messageData['fromemail'] = $defaultFrom;
$messageData['fromname'] = $messageData['fromfield'];
}
// disallow an email address in the name
if (preg_match('/([^ ]+@[^ ]+)/', $messageData['fromname'], $regs)) {
$messageData['fromname'] = str_replace($regs[0], '', $messageData['fromname']);
}
// clean up
$messageData['fromemail'] = str_replace(',', '', $messageData['fromemail']);
$messageData['fromname'] = str_replace(',', '', $messageData['fromname']);

$messageData['fromname'] = trim($messageData['fromname']);

// erase double spacing
while (str_contains($messageData['fromname'], ' ')) {
$messageData['fromname'] = str_replace(' ', ' ', $messageData['fromname']);
}

//# if the name ends up being empty, copy the email
if (empty($messageData['fromname'])) {
$messageData['fromname'] = $messageData['fromemail'];
}
}
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

🌐 Web query:

PHP 8.x str_replace null parameter TypeError behavior

💡 Result:

In PHP 8.x, what happens when you pass null to str_replace() depends on PHP version and whether you’re in coercive mode (default) or strict_types.

str_replace() signature (PHP 8.x)

str_replace(array|string $search, array|string $replace, string|array $subject, int &$count = null): string|array [1]

Behavior when an argument is null

  • PHP 8.0 (default / coercive mode, no strict_types=1)
    Passing null to a non-nullable scalar/union parameter of an internal function was historically silently coerced (e.g., null"", 0, etc.). This includes cases like str_replace(..., null) for $subject (it will be coerced and the call proceeds). This was done for backward compatibility and consistency wasn’t enforced yet for internal functions. [3][2]

  • PHP 8.1+ (default / coercive mode)
    Passing null to a non-nullable internal-function parameter is deprecated (but still coerced and executed). So you’ll typically see:
    Deprecated: str_replace(): Passing null to parameter [Meta] Build core component classes #3 ($subject) ... is deprecated [2][3]

  • Any PHP 8.x with declare(strict_types=1);
    In strict mode, null is not coerced to satisfy scalar types—expect a TypeError when null is passed where it isn’t allowed. The PHP 8.1 change mainly affects the default coercive mode. [3][4]

What to do

If your code might pass null, normalize before calling:

$subject = $subject ?? '';
$result  = str_replace($search, $replace, $subject);

This avoids the PHP 8.1+ deprecation and will also keep you safe if this becomes a TypeError in a future major version (as planned by the RFC). [3][4]

Sources: PHP manual for str_replace() [1], PHP 8.1 migration deprecation note [2], RFC describing the change and its intent [3], overview/explanation (PHP.Watch) [4].


🏁 Script executed:

# Find the MessageDataLoader.php file and check the full context
find . -type f -name "MessageDataLoader.php" | head -5

Repository: phpList/core

Length of output: 108


🏁 Script executed:

# Check the MessageDataLoader.php file to see the full context
cat -n ./src/Domain/Messaging/Service/MessageDataLoader.php | head -100

Repository: phpList/core

Length of output: 4673


🏁 Script executed:

# Check for declare(strict_types
grep -n "strict_types\|public function.*normaliseFromField\|private function.*normaliseFromField" ./src/Domain/Messaging/Service/MessageDataLoader.php | head -20

Repository: phpList/core

Length of output: 175


🏁 Script executed:

# Look for where normaliseFromField is called and how $defaultFrom is passed
grep -B5 -A5 "normaliseFromField" ./src/Domain/Messaging/Service/MessageDataLoader.php | head -40

Repository: phpList/core

Length of output: 1133


Handle null $defaultFrom before string ops.

The file uses declare(strict_types=1), so PHP 8.x will throw a TypeError if null is passed to str_replace(). Since both config values can be null, $defaultFrom may be null—it gets assigned to $messageData['fromemail'] at lines 186 or 190, then passed to str_replace() at line 196. Add the coalescing operator early to prevent the error.

✅ Suggested fix
     private function normaliseFromField(array &$messageData, ?string $defaultFrom): void
     {
+        $defaultFrom ??= '';
         //## parse the from field into it's components - email and name
🤖 Prompt for AI Agents
In `@src/Domain/Messaging/Service/MessageDataLoader.php` around lines 171 - 210,
normaliseFromField assigns $defaultFrom into $messageData['fromemail'] and then
calls str_replace on that value, which can be null and will cause a TypeError
under strict_types; ensure $defaultFrom is coerced to a safe string early (e.g.
$defaultFrom ?? '') before assigning into $messageData['fromemail'] (or apply
coalescing when setting $messageData['fromemail']) so subsequent calls like
str_replace and trim operate on strings; update the assignment sites in
normaliseFromField (where $messageData['fromemail'] = $defaultFrom) to use a
non-null fallback.

Comment on lines +80 to +85
if ($this->uploadImageDir) {
//# escape subdirectories, otherwise this renders empty
$dir = str_replace('/', '\/', $this->uploadImageDir);
$messagePrecacheDto->content = preg_replace(
'/<img(.*)src="\/' . $dir . '(.*)>/iU',
'<img\\1src="' . $this->publicSchema . '://' . $baseurl . '/' . $this->uploadImageDir . '\\2>',
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

Escape uploadImageDir for regex safety.
If uploadImageDir contains regex metacharacters (e.g., . or +), the current pattern can mis-match and skip replacements. Use preg_quote instead of manual / escaping.

💡 Suggested fix
-            $dir = str_replace('/', '\/', $this->uploadImageDir);
+            $dir = preg_quote($this->uploadImageDir, '/');
🤖 Prompt for AI Agents
In `@src/Domain/Messaging/Service/MessagePrecacheService.php` around lines 80 -
85, The regex uses $this->uploadImageDir directly which can contain regex
metacharacters and break preg_replace; update the code in MessagePrecacheService
(where $messagePrecacheDto->content is set by preg_replace) to wrap
$this->uploadImageDir with preg_quote($this->uploadImageDir, '/') to escape all
regex-significant chars (use '/' as the preg delimiter), then build the pattern
using that quoted value instead of str_replace; keep the rest of the pattern and
replacement unchanged so preg_replace correctly matches and rewrites img src
paths.

@TatevikGr TatevikGr force-pushed the ref/campaign-processing branch from 289099f to ef65472 Compare February 5, 2026 13:01
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/Migrations/Version20260204094237.php`:
- Around line 12-18: The PHPDoc block in the migration class
Version20260204094237.php is stale and references URL/index collisions related
to phplist_linktrack_forward, but the migration only modifies phplist_message
columns (astext, ashtml, etc.); update or remove that warning so it matches the
actual changes—either delete the old URL/key-length warning entirely or replace
it with a short, accurate note describing that this migration alters
phplist_message text/html columns and to watch for collation/length issues when
altering string/blob columns.
🧹 Nitpick comments (4)
src/Migrations/Version20260204094237.php (2)

7-8: Unused import: MySQLPlatform

MySQLPlatform is imported but never referenced—the migration only guards against PostgreSQL. Feel free to drop it to keep things tidy.

 use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
-use Doctrine\DBAL\Platforms\MySQLPlatform;
 use Doctrine\Migrations\AbstractMigration;

21-24: Consider adding a meaningful migration description.

Returning an empty string from getDescription() is fine, but a brief note like "Convert phplist_message format columns from BOOLEAN to INT for PostgreSQL" would make migration history easier to scan.

     public function getDescription(): string
     {
-        return '';
+        return 'Convert phplist_message format columns (astext, ashtml, aspdf, astextandhtml, astextandpdf) from BOOLEAN to INT';
     }
src/Domain/Messaging/Service/MessagePrecacheService.php (1)

21-41: That's quite the constructor.

The @SuppressWarnings("ExcessiveParameterList") annotation acknowledges the large parameter count. While this works, you might consider grouping related dependencies into a config object or splitting responsibilities if this service grows further.

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

14-15: TODO comment indicates incomplete type coverage.

The // todo: check types comment suggests the type system isn't fully verified yet. Consider addressing this before merge or creating a follow-up issue.

Would you like me to open an issue to track the type verification task?

Comment on lines +12 to +18
/**
* ⚠️ Wizard warning:
* Doctrine will `helpfully` remove url(255) prefixes and add collations 5.7 can’t read.
* Review the SQL unless you enjoy debugging key length errors at 2 AM.
*
* Ex: phplist_linktrack_forward phplist_linktrack_forward_urlindex (but there are more)
*/
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

PHPDoc comment is stale and references removed code.

The warning talks about phplist_linktrack_forward, URL columns, and key length errors—but the migration now only touches phplist_message columns (astext, ashtml, etc.). Looks like the URL-related changes were removed but the comment stayed behind. Worth updating or removing to avoid confusion for the next person who reads this at 2 AM.

,

🤖 Prompt for AI Agents
In `@src/Migrations/Version20260204094237.php` around lines 12 - 18, The PHPDoc
block in the migration class Version20260204094237.php is stale and references
URL/index collisions related to phplist_linktrack_forward, but the migration
only modifies phplist_message columns (astext, ashtml, etc.); update or remove
that warning so it matches the actual changes—either delete the old
URL/key-length warning entirely or replace it with a short, accurate note
describing that this migration alters phplist_message text/html columns and to
watch for collation/length issues when altering string/blob columns.

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.

2 participants