Skip to content

Comments

Feat: message sending-forwarding#374

Merged
TatevikGr merged 39 commits intodevfrom
ref/campaign-processing
Feb 9, 2026
Merged

Feat: message sending-forwarding#374
TatevikGr merged 39 commits intodevfrom
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
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.

@TatevikGr TatevikGr changed the base branch from main to dev February 9, 2026 09:02
@TatevikGr TatevikGr merged commit d0ba222 into dev Feb 9, 2026
8 checks passed
@TatevikGr TatevikGr deleted the ref/campaign-processing branch February 9, 2026 09:03
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