-
Notifications
You must be signed in to change notification settings - Fork 31
Refactor CampaignProcessorMessageHandler #374
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
📝 WalkthroughWalkthroughAdds multiple new domain utilities (HtmlToText, HtmlUrlRewriter, RemotePageFetcher, TextParser), expands messaging precache and loading (MessagePrecacheDto, MessageDataLoader, MessagePrecacheService changes), shifts runtime personalization into CampaignProcessorMessageHandler, extends repositories/models, updates config/composer, and adjusts tests accordingly. Changes
Sequence Diagram(s)sequenceDiagram
participant Caller as Caller (Service)
participant Remote as RemotePageFetcher
participant HTTP as HttpClient
participant Cache as CacheInterface
participant DB as UrlCacheRepository / EntityManager
participant Rewriter as HtmlUrlRewriter
participant Logger as EventLogManager
Caller->>Remote: invoke(url, userData)
Remote->>Cache: get(hashedKey)
alt cache hit (fresh)
Cache-->>Remote: content
Remote-->>Caller: content
else cache miss / stale
Remote->>DB: findByUrlAndLastModified(url, lastModified)
DB-->>Remote: UrlCache or null
Remote->>HTTP: fetchUrlDirect(url) (HEAD/GET)
alt fetch success
HTTP-->>Remote: html
Remote->>Rewriter: addAbsoluteResources(html, baseUrl)
Rewriter-->>Remote: rewrittenHtml
Remote->>DB: persist/cleanup UrlCache entry
Remote->>Cache: set(hashedKey, rewrittenHtml)
Logger-->>Remote: log success
Remote-->>Caller: rewrittenHtml
else fetch fail
HTTP-->>Remote: error/empty
Logger-->>Remote: log failure
Remote-->>Caller: "" (empty)
end
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes
Possibly related PRs
Pre-merge checks and finishing touches❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this 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 errorsRight now you call
personalize()ongetText()andgetFooter()unconditionally. GivenMessageContent::getText()/getFooter()are treated as nullable elsewhere, this can blow up with aTypeErrorif either isnull(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
MessageProcessingPreparatoralready 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 caseStubbing
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/orgetText()) returnsnullto 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
📒 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.phpsrc/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.phpsrc/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 relevantThis 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 goodImporting and injecting
UserPersonalizerinto 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 solidInjecting a
UserPersonalizermock and stubbingpersonalize()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 pipelineUsing
$messagefor options and$processedContentfor 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 19
🧹 Nitpick comments (7)
src/Domain/Messaging/Repository/TemplateRepository.php (1)
16-19: Consider using inheritedfind()method.
AbstractRepositorylikely already provides afind($id)method (inherited from Doctrine'sEntityRepository) that does exactly this.If your goal is to add clarity, this wrapper is fine. Otherwise, callers could use
$repository->find($id)directly.src/Domain/Common/TextParser.php (1)
22-26: Protocol stripping is a partial XSS mitigation.The logic strips colons from non-standard protocols but still allows
javascriptURLs through (e.g.,javascript window.open(...)). Consider using an allowlist approach or a more robust sanitization library for URL schemes.src/Domain/Common/HtmlToText.php (2)
61-66: Regex for<a>tags afterstrip_tags()is likely dead code.
strip_tags($text)at line 38 removes all HTML tags, so this regex pattern matching<a href=...>will never find matches. If this is intentional for edge cases, a comment would help clarify.
74-80: Consider limiting iterations on whitespace reduction loops.These
whileloops could theoretically run many times on pathological input. For robustness, consider a reasonable iteration cap or usingpreg_replacewith a quantifier pattern like/ {2,}/to collapse in one pass.// reduce whitespace - while (preg_match('/ /', $text)) { - $text = preg_replace('/ /', ' ', $text); - } - while (preg_match("/\n\s*\n\s*\n/", $text)) { - $text = preg_replace("/\n\s*\n\s*\n/", "\n\n", $text); - } + $text = preg_replace('/ {2,}/', ' ', $text); + $text = preg_replace("/(\n\s*){3,}/", "\n\n", $text);src/Domain/Messaging/Service/MessageDataLoader.php (1)
101-101: Consider restrictingunserialize()withallowed_classes.Deserializing stored data without class restrictions can be a security risk if the data source is ever compromised. Pass
['allowed_classes' => false]or specify expected classes.- $unserialized = unserialize(substr($storedMessageDatum->getData(), 4)); + $unserialized = unserialize(substr($storedMessageDatum->getData(), 4), ['allowed_classes' => false]);src/Domain/Common/RemotePageFetcher.php (1)
31-43:utf8_encode/utf8_decodeare deprecated + URL substitution is risky (Line 31–Line 43, Line 56–Line 66, Line 69–Line 87).
utf8_encode()/utf8_decode()are deprecated in modern PHP; replace with safe string handling (or mbstring) and avoid double transcoding.- Consider validating/allowlisting schemes/hosts before fetching remote content (admin mistakes can still become SSRF-like incidents).
Suggested direction:- $url = utf8_encode(str_ireplace("[$key]", urlencode($val), utf8_decode($url))); + $url = str_ireplace("[$key]", rawurlencode((string) $val), $url);Also applies to: 56-66, 69-87
src/Domain/Common/HtmlUrlRewriter.php (1)
18-21: Tighten API surface + avoid leaking global libxml state (Line 18–Line 21, Line 122–Line 143).
libxml_use_internal_errors(true)changes global state; please restore the previous setting after parsing.normalizePath()should declare visibility (and ideallyprivate) + return type.Also applies to: 122-143
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (19)
composer.json(1 hunks)config/parameters.yml.dist(1 hunks)src/Domain/Common/HtmlToText.php(1 hunks)src/Domain/Common/HtmlUrlRewriter.php(1 hunks)src/Domain/Common/RemotePageFetcher.php(1 hunks)src/Domain/Common/TextParser.php(1 hunks)src/Domain/Configuration/Model/ConfigOption.php(1 hunks)src/Domain/Configuration/Repository/UrlCacheRepository.php(1 hunks)src/Domain/Identity/Model/AdminAttributeDefinition.php(4 hunks)src/Domain/Identity/Model/Administrator.php(4 hunks)src/Domain/Identity/Repository/AdminAttributeDefinitionRepository.php(2 hunks)src/Domain/Identity/Repository/AdministratorRepository.php(1 hunks)src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php(4 hunks)src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php(1 hunks)src/Domain/Messaging/Repository/MessageDataRepository.php(1 hunks)src/Domain/Messaging/Repository/TemplateImageRepository.php(1 hunks)src/Domain/Messaging/Repository/TemplateRepository.php(1 hunks)src/Domain/Messaging/Service/MessageDataLoader.php(1 hunks)src/Domain/Messaging/Service/MessagePrecacheService.php(2 hunks)
🧰 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/Repository/MessageDataRepository.phpsrc/Domain/Identity/Repository/AdministratorRepository.phpsrc/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.phpsrc/Domain/Configuration/Repository/UrlCacheRepository.phpsrc/Domain/Messaging/Repository/TemplateImageRepository.phpsrc/Domain/Common/TextParser.phpsrc/Domain/Identity/Model/AdminAttributeDefinition.phpsrc/Domain/Common/HtmlToText.phpsrc/Domain/Identity/Repository/AdminAttributeDefinitionRepository.phpsrc/Domain/Messaging/Service/MessageDataLoader.phpsrc/Domain/Identity/Model/Administrator.phpsrc/Domain/Messaging/Repository/TemplateRepository.phpsrc/Domain/Configuration/Model/ConfigOption.phpsrc/Domain/Messaging/Service/MessagePrecacheService.phpsrc/Domain/Common/RemotePageFetcher.phpsrc/Domain/Common/HtmlUrlRewriter.phpsrc/Domain/Messaging/Model/Dto/MessagePrecacheDto.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 (3)
src/Domain/Common/TextParser.php (1)
src/Domain/Common/HtmlToText.php (1)
__invoke(18-84)
src/Domain/Common/HtmlToText.php (1)
src/Domain/Configuration/Service/Provider/ConfigProvider.php (1)
ConfigProvider(12-82)
src/Domain/Messaging/Service/MessageDataLoader.php (2)
src/Domain/Configuration/Service/Provider/ConfigProvider.php (1)
ConfigProvider(12-82)src/Domain/Messaging/Repository/MessageDataRepository.php (2)
MessageDataRepository(12-26)getForMessage(22-25)
🔇 Additional comments (15)
src/Domain/Identity/Model/AdminAttributeDefinition.php (1)
42-47: LGTM! Relationship setup is clean.The OneToMany relationship is properly configured with orphanRemoval, correctly initialized in the constructor, and provides appropriate read access via the getter.
Also applies to: 63-63, 136-140
src/Domain/Identity/Model/Administrator.php (1)
71-72: LGTM! Relationship properly configured.The OneToMany relationship to SubscriberList is correctly set up as the inverse side (mappedBy: 'owner'), properly initialized, and provides clean read access.
Also applies to: 79-79, 222-228
src/Domain/Messaging/Repository/TemplateImageRepository.php (1)
16-24: LGTM! Query methods are well-scoped.Both methods correctly filter by
ti.template = 0to isolate non-template images, which aligns with the stated purpose of finding images by filename or ID that aren't associated with specific templates.Also applies to: 26-34
src/Domain/Configuration/Repository/UrlCacheRepository.php (1)
16-25: LGTM! Cache query methods look solid.The
findByUrlAndLastModifiedmethod correctly uses a greater-than comparison to locate cached entries newer than the provided timestamp, andgetByUrlprovides straightforward retrieval by URL.Also applies to: 27-31
src/Domain/Identity/Repository/AdministratorRepository.php (1)
59-71: LGTM! Query logic looks sound.The join path Administrator → ownedLists → listMessages → message is clean and correctly mapped. The DISTINCT clause appropriately handles potential duplicates.
src/Domain/Messaging/Repository/MessageDataRepository.php (1)
22-25: The original review comment is incorrect. ThegetForMessage()method query is actually correct.The
MessageDataentity uses a composite primary key of(id, name), where theidfield holds the message ID. The methodgetForMessage(int $messageId)correctly queriesfindBy(['id' => $messageId])to retrieve allMessageDataentries for a given message. Sinceidis part of the composite key, querying on it alone returns all rows with that message ID, regardless of theirnamevalue.The suggestion to change the filter to
['message' => $messageId]would not work—no such property exists on the entity.Likely an incorrect or invalid review comment.
src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php (2)
60-60: LGTM on dependency injection of UserPersonalizer.Clean addition of the
UserPersonalizerdependency to support per-subscriber personalization at the handler level.
79-83: Good defensive early exit pattern.Suspending the campaign and returning early when
getOrCacheBaseMessageContentreturns null is a solid safeguard against processing campaigns without valid content.src/Domain/Common/TextParser.php (2)
47-50: Potential regex injection via$jplaceholder.The
$jvariable is an integer, so it's safe here, but the pattern/\%\%$j\%\%/embeds a variable directly. This is fine for integers but worth noting for future maintainers if this pattern is copied elsewhere.
56-58: LGTM on placeholder handling.The bracket and dollar sign placeholder pattern is a reasonable workaround for regex limitations with special characters.
src/Domain/Identity/Repository/AdminAttributeDefinitionRepository.php (1)
22-36: LGTM ongetForAdminimplementation.Clean use of
COALESCEfor defaulting null values and proper left join filtering by administrator.src/Domain/Common/HtmlToText.php (1)
18-38: LGTM on HTML transformation pipeline.The placeholder-based approach for preserving links during
strip_tagsis a solid pattern for HTML-to-text conversion.src/Domain/Messaging/Service/MessageDataLoader.php (2)
28-86: LGTM on default message data initialization.Comprehensive defaults with config-driven values. The structure is clear and maintainable.
141-178: LGTM on fromfield parsing logic.Thorough handling of various email/name formats with good edge case coverage for sanitization.
src/Domain/Common/HtmlUrlRewriter.php (1)
13-73: Solid approach: DOM + CSS rewriting covers the important cases (Line 13–Line 73).
The wrapping/unwrapping pattern is a practical way to handle fragments, and rewritingsrcset+ CSSurl(...)/@import is a nice completeness touch.
| messaging.default_message_age: '%%env(DEFAULT_MESSAGEAGE)%%' | ||
| env(DEFAULT_MESSAGEAGE): '691200' | ||
| messaging.use_manual_text_part : '%%env(USE_MANUAL_TEXT_PART)%%' | ||
| env(USE_MANUAL_TEXT_PART): 0 | ||
|
|
||
| phplist.upload_images_dir: '%%env(PHPLIST_UPLOADIMAGES_DIR)%%' | ||
| env(PHPLIST_UPLOADIMAGES_DIR): 'images' | ||
| phplist.public_schema: '%%env(PUBLIC_SCHEMA)%%' | ||
| env(PUBLIC_SCHEMA): 'http' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor formatting inconsistency.
Line 94 has a space before the colon: messaging.use_manual_text_part : while all other parameter keys follow the pattern key: with no space.
Apply this diff for consistency:
- messaging.use_manual_text_part : '%%env(USE_MANUAL_TEXT_PART)%%'
+ messaging.use_manual_text_part: '%%env(USE_MANUAL_TEXT_PART)%%'📝 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.
| messaging.default_message_age: '%%env(DEFAULT_MESSAGEAGE)%%' | |
| env(DEFAULT_MESSAGEAGE): '691200' | |
| messaging.use_manual_text_part : '%%env(USE_MANUAL_TEXT_PART)%%' | |
| env(USE_MANUAL_TEXT_PART): 0 | |
| phplist.upload_images_dir: '%%env(PHPLIST_UPLOADIMAGES_DIR)%%' | |
| env(PHPLIST_UPLOADIMAGES_DIR): 'images' | |
| phplist.public_schema: '%%env(PUBLIC_SCHEMA)%%' | |
| env(PUBLIC_SCHEMA): 'http' | |
| messaging.default_message_age: '%%env(DEFAULT_MESSAGEAGE)%%' | |
| env(DEFAULT_MESSAGEAGE): '691200' | |
| messaging.use_manual_text_part: '%%env(USE_MANUAL_TEXT_PART)%%' | |
| env(USE_MANUAL_TEXT_PART): 0 | |
| phplist.upload_images_dir: '%%env(PHPLIST_UPLOADIMAGES_DIR)%%' | |
| env(PHPLIST_UPLOADIMAGES_DIR): 'images' | |
| phplist.public_schema: '%%env(PUBLIC_SCHEMA)%%' | |
| env(PUBLIC_SCHEMA): 'http' |
🤖 Prompt for AI Agents
In config/parameters.yml.dist around lines 92 to 100, there's a formatting
inconsistency at line 94 where the key "messaging.use_manual_text_part :" has an
extra space before the colon; update that line to remove the space so the key
follows the same "key:" pattern as the other parameters, keeping the existing
value/token unchanged and preserving surrounding indentation and newline
structure.
| $ww = $this->configProvider->getValue(ConfigOption::WordWrap) ?? self::WORD_WRAP; | ||
|
|
||
| return wordwrap($text, $ww); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Type mismatch: wordwrap expects int, but config returns string.
getValue() returns ?string, so $ww could be "75" (string). Cast to int for type safety.
- $ww = $this->configProvider->getValue(ConfigOption::WordWrap) ?? self::WORD_WRAP;
+ $ww = (int) ($this->configProvider->getValue(ConfigOption::WordWrap) ?? self::WORD_WRAP);
return wordwrap($text, $ww);📝 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.
| $ww = $this->configProvider->getValue(ConfigOption::WordWrap) ?? self::WORD_WRAP; | |
| return wordwrap($text, $ww); | |
| $ww = (int) ($this->configProvider->getValue(ConfigOption::WordWrap) ?? self::WORD_WRAP); | |
| return wordwrap($text, $ww); |
🤖 Prompt for AI Agents
In src/Domain/Common/HtmlToText.php around lines 81 to 83, the value retrieved
from configProvider is ?string but wordwrap requires an int; cast the config
value to int (e.g. $ww =
(int)($this->configProvider->getValue(ConfigOption::WordWrap) ??
self::WORD_WRAP);) or use intval with the same fallback so wordwrap always
receives an integer.
| public function __construct( | ||
| private readonly HttpClientInterface $httpClient, | ||
| private readonly CacheInterface $cache, | ||
| private readonly ConfigProvider $configProvider, | ||
| private readonly UrlCacheRepository $urlCacheRepository, | ||
| private readonly EventLogManager $eventLogManager, | ||
| private readonly HtmlUrlRewriter $htmlUrlRewriter, | ||
| private readonly EntityManagerInterface $entityManager, | ||
| private readonly int $defaultTtl = 300, | ||
| ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
fd RemotePageFetcher.phpRepository: phpList/core
Length of output: 95
🏁 Script executed:
cat -n src/Domain/Common/RemotePageFetcher.phpRepository: phpList/core
Length of output: 5109
Domain purity breach: persistence side effects in src/Domain/**
Lines 75 and 78 call $this->entityManager->remove() and $this->entityManager->persist() directly. This violates domain layer purity; move UrlCache write logic to Infrastructure or Application (e.g., a dedicated service or handler) and inject a repository interface that abstracts away the persistence concern.
🤖 Prompt for AI Agents
In src/Domain/Common/RemotePageFetcher.php around lines 19 and 28 (and
specifically lines ~75 and ~78) the domain class directly calls
$this->entityManager->remove() and $this->entityManager->persist(), creating
persistence side effects inside the Domain layer; replace direct EntityManager
usage by depending on an abstract repository interface (e.g.,
UrlCacheRepositoryInterface) injected into the constructor, move the concrete
EntityManager-based implementation into the Infrastructure layer, and refactor
RemotePageFetcher to call only repository methods (e.g.,
deleteUrlCache()/saveUrlCache()) so all persistence logic lives in
Infrastructure/Application and the Domain remains pure.
| private function fetchUrlDirect(string $url): string | ||
| { | ||
| try { | ||
| $response = $this->httpClient->request('GET', $url, [ | ||
| // 'timeout' => 10, | ||
| 'timeout' => 600, | ||
| 'allowRedirects' => 1, | ||
| 'method' => 'HEAD', | ||
| ]); | ||
|
|
||
| return $response->getContent(false); | ||
| } catch (Throwable $e) { | ||
| return ''; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
Symfony HttpClient request method options allowRedirects max_redirects
💡 Result:
Short answer: Symfony HttpClient uses the request option max_redirects (not allowRedirects). It controls how many redirects the client will follow (default 20). Set max_redirects: 0 to disable following redirects; set to an integer N to allow up to N redirects. You can set it per request or as a global default_options in framework.http_client. [1][2]
Examples:
- Per request:
$response = $client->request('GET', 'https://...', ['max_redirects' => 0]); [1] - Global (config/packages/framework.yaml):
framework:
http_client:
default_options:
max_redirects: 7 [1]
Sources: Symfony HttpClient docs and HttpClient interface defaults. [1][2]
🌐 Web query:
Symfony HttpClient API reference options parameters
💡 Result:
Here are the request option keys supported by Symfony's HttpClient (from HttpClientInterface::OPTIONS_DEFAULTS) with short notes on their expected values:
- auth_basic — array|string (username[:password]) for HTTP Basic auth. [1]
- auth_bearer — string bearer token. [1]
- query — array of query params to merge with the URL. [1]
- headers — iterable|string[]|string[][] request headers. [1]
- body — array|string|resource|\Traversable|\Closure (form payload, raw body, or streaming callback). [1]
- json — mixed (will set body to JSON and Content-Type if not provided). [1]
- user_data — mixed (opaque value accessible via $response->getInfo('user_data')). [1]
- max_redirects — int (max redirects; <=0 = no redirects). [1]
- http_version — string (e.g. "1.1", "2.0"). [1]
- base_uri — string (base URI for resolving relative URLs). [1]
- buffer — bool|resource|\Closure (whether/how to buffer response body). [1]
- on_progress — callable(int $dlNow, int $dlSize, array $info) called during progress. [1]
- resolve — array map of host => IP to override DNS. [1]
- proxy — string proxy URL (environment vars may be honored by implementations). [1]
- no_proxy — string (comma-separated hosts to bypass proxy). [1]
- timeout — float (idle timeout). [1]
- max_duration — float (maximum total time for request+response; <=0 = unlimited). [1]
- bindto — string (interface or local socket to bind to). [1]
- verify_peer, verify_host, cafile, capath, local_cert, local_pk, passphrase, ciphers, peer_fingerprint, capture_peer_cert_chain — SSL/peer verification and cert options. [1]
- extra — array (additional custom options that may be ignored by some implementations). [1]
For usage examples (headers, query, auth, redirects, proxies, withOptions()/default_options, HttpOptions helper), see the Symfony HttpClient docs. [2]
Sources: Symfony HttpClientInterface options listing and the HttpClient docs. [1][2]
HTTP request options are incorrect; code likely won't work as intended (Lines 89–103).
You call request('GET', ...) but pass invalid options: 'allowRedirects' doesn't exist in Symfony HttpClient (should be max_redirects), and 'method' => 'HEAD' isn't a valid option—it's silently ignored. The 600s timeout also masks the commented-out 10s, and swallowing all exceptions silently makes debugging hard.
Fix to:
- $response = $this->httpClient->request('GET', $url, [
- 'timeout' => 600,
- 'allowRedirects' => 1,
- 'method' => 'HEAD',
- ]);
+ $response = $this->httpClient->request('GET', $url, [
+ 'timeout' => 10,
+ 'max_redirects' => 5,
+ ]);Also log the exception (at least at debug level) instead of silently returning ''.
🤖 Prompt for AI Agents
In src/Domain/Common/RemotePageFetcher.php around lines 89-103, the HTTP options
and method are wrong and exceptions are swallowed; replace the options with
Symfony HttpClient-compatible ones (use 'max_redirects' instead of
'allowRedirects'), remove the invalid 'method' option and call
$this->httpClient->request('HEAD', $url, [...]) if you intend a HEAD request (or
keep 'GET' and call request('GET', ...)), reduce the timeout back to a
reasonable value (e.g. 10), and in the catch block log the exception at debug
level (e.g. $this->logger->debug or the class logger) before returning an empty
string so errors are not silently discarded.
| public function __construct( | ||
| private readonly CacheInterface $cache, | ||
| private readonly MessageDataLoader $messageDataLoader, | ||
| private readonly ConfigProvider $configProvider, | ||
| private readonly HtmlToText $htmlToText, | ||
| private readonly TextParser $textParser, | ||
| private readonly TemplateRepository $templateRepository, | ||
| private readonly TemplateImageRepository $templateImageRepository, | ||
| private readonly RemotePageFetcher $remotePageFetcher, | ||
| private readonly EventLogManager $eventLogManager, | ||
| private readonly AdminAttributeDefinitionRepository $adminAttributeDefRepository, | ||
| private readonly AdministratorRepository $adminRepository, | ||
| private readonly EntityManagerInterface $entityManager, | ||
| private readonly bool $useManualTextPart, | ||
| private readonly string $uploadImageDir, | ||
| private readonly string $publicSchema, | ||
| ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cd src/Domain/Messaging/Service && wc -l MessagePrecacheService.phpRepository: phpList/core
Length of output: 86
🏁 Script executed:
cd src/Domain/Messaging/Service && cat -n MessagePrecacheService.php | sed -n '25,41p'Repository: phpList/core
Length of output: 1110
🏁 Script executed:
cd src/Domain/Messaging/Service && cat -n MessagePrecacheService.php | sed -n '280,310p'Repository: phpList/core
Length of output: 1118
🏁 Script executed:
cd src/Domain/Messaging/Service && rg '\$entityManager\->(persist|remove|flush|begin|commit|rollback)' MessagePrecacheService.php -A 3 -B 1Repository: phpList/core
Length of output: 38
🏁 Script executed:
cd src/Domain/Messaging/Service && cat -n MessagePrecacheService.php | sed -n '250,305p'Repository: phpList/core
Length of output: 2852
🏁 Script executed:
cd src/Domain/Messaging/Service && cat -n MessagePrecacheService.php | sed -n '230,250p'Repository: phpList/core
Length of output: 1063
Domain purity breach: DB writes in src/Domain/** (lines 282, 302).
MessagePrecacheService.createCachedLogoImage() directly calls $entityManager->remove() and persist() for TemplateImage entities. Per coding guidelines, persistence side-effects belong in Infrastructure/Application layer, not Domain. Move these calls to a dedicated service/repository outside Domain, or wrap via an interface implemented in the Infrastructure layer.
🤖 Prompt for AI Agents
In src/Domain/Messaging/Service/MessagePrecacheService.php around lines 25–41
and specifically where createCachedLogoImage() does direct DB writes at lines
~282 and ~302, remove direct EntityManager->persist()/remove() calls from the
Domain service and instead delegate persistence to an abstraction in the Domain
(e.g., TemplateImagePersistenceInterface) that exposes methods like
saveTemplateImage(TemplateImage) and deleteTemplateImage(TemplateImage);
implement that interface in the Infrastructure layer using the EntityManager and
register it for DI, then inject the interface into MessagePrecacheService (or
use the existing TemplateImageRepository if moved/extended to the
Infrastructure-backed persistence interface) and replace the direct
persist/remove calls with calls to the new interface methods so all
side-effecting DB operations live in Infrastructure.
| public function getOrCacheBaseMessageContent(Message $campaign, ?bool $forwardContent = false): ?MessagePrecacheDto | ||
| { | ||
| $cacheKey = sprintf('messaging.message.base.%d', $campaign->getId()); | ||
|
|
||
| $cached = $this->getFromCache($cacheKey); | ||
| $cached = $this->cache->get($cacheKey); | ||
| if ($cached !== null) { | ||
| return $cached; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Blocker: cache key ignores $forwardContent and can return the wrong content (Line 48–Line 55, Line 85–Line 95).
$subject/$content/$footer depend on $forwardContent, but the cache key doesn’t—so whichever call runs first “poisons” the cache for the other mode.
Fix:
- $cacheKey = sprintf('messaging.message.base.%d', $campaign->getId());
+ $cacheKey = sprintf('messaging.message.base.%d.%d', $campaign->getId(), (int) $forwardContent);Also applies to: 85-95
🤖 Prompt for AI Agents
In src/Domain/Messaging/Service/MessagePrecacheService.php around lines 48–55
and again around lines 85–95 the cache key currently ignores the $forwardContent
flag so cached base message content for one mode can be returned for the other;
include $forwardContent (e.g. cast to int or '1'/'0') in the sprintf cache key
so keys differ by forward mode, and apply the same change to the second block so
both cache reads/writes use the forward-aware key.
| $cached = $this->cache->get($cacheKey); | ||
| if ($cached !== null) { | ||
| return $cached; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
fd -e php MessagePrecacheServiceRepository: phpList/core
Length of output: 111
🏁 Script executed:
cat -n src/Domain/Messaging/Service/MessagePrecacheService.phpRepository: phpList/core
Length of output: 16937
🏁 Script executed:
fd -e yaml -e xml -e php config | grep -i cache | head -20Repository: phpList/core
Length of output: 38
🏁 Script executed:
fd -name "*.yaml" -o -name "*.yml" -o -name "services.php" | head -20Repository: phpList/core
Length of output: 286
🏁 Script executed:
fd '\.(yaml|yml)$'Repository: phpList/core
Length of output: 644
🏁 Script executed:
grep -r "cache" config/ --include="*.yml" --include="*.yaml" -i | grep -E "(service|backend|psr|adapter)" | head -20Repository: phpList/core
Length of output: 500
🏁 Script executed:
cat config/services/services.yml | head -100Repository: phpList/core
Length of output: 3022
🏁 Script executed:
rg "cache\.app" config/Repository: phpList/core
Length of output: 248
🏁 Script executed:
grep -A 10 "cache:" config/config*.ymlRepository: phpList/core
Length of output: 38
🏁 Script executed:
fd MessagePrecacheDtoRepository: phpList/core
Length of output: 109
🏁 Script executed:
cat -n src/Domain/Messaging/Model/Dto/MessagePrecacheDto.phpRepository: phpList/core
Length of output: 1066
🏁 Script executed:
grep -r "framework:" config/ | head -5Repository: phpList/core
Length of output: 193
🏁 Script executed:
cat config/config.yml | grep -A 20 "^framework:"Repository: phpList/core
Length of output: 627
Consider adding TTL and cache key versioning (Lines 52–55, 193–195).
The cache stores the DTO object directly with no TTL, so it persists indefinitely across deployments. Add an expiration time to cache.set() and/or include a version/timestamp suffix in the cache key to prevent stale data after code changes.
🤖 Prompt for AI Agents
In src/Domain/Messaging/Service/MessagePrecacheService.php around lines 52–55
and 193–195, the code reads and writes the DTO to cache without an expiration
and uses a plain cache key, so cached DTOs can persist across deployments and
become stale; fix by appending a cache version or deploy timestamp suffix to the
cache key (e.g. a class constant CACHE_VERSION or an injected deployVersion) and
pass an explicit TTL when calling cache->set() (choose an appropriate expiry
like hours/days depending on expected staleness), ensuring the read uses the
same versioned key.
| $messagePrecacheDto->content = $this->parseLogoPlaceholders($messagePrecacheDto->content); | ||
| $messagePrecacheDto->template = $this->parseLogoPlaceholders($messagePrecacheDto->template); | ||
| $messagePrecacheDto->htmlFooter = $this->parseLogoPlaceholders($messagePrecacheDto->htmlFooter); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
parseLogoPlaceholders() needs types + null-safety (Line 184–Line 187, Line 228–Line 240).
You call it with $messagePrecacheDto->template which can be null (remote URL path sets it to null). Current implementation will warn/fail in preg_match_all.
Fix shape:
- private function parseLogoPlaceholders($content)
+ private function parseLogoPlaceholders(?string $content): ?string
{
+ if ($content === null || $content === '') {
+ return $content;
+ }
//# replace Logo placeholders
preg_match_all('/\[LOGO\:?(\d+)?\]/', $content, $logoInstances);
...
}Also applies to: 228-240
🤖 Prompt for AI Agents
In src/Domain/Messaging/Service/MessagePrecacheService.php around lines 184-187
and 228-240, parseLogoPlaceholders() is being called with properties that can be
null (e.g. template) causing preg_match_all warnings/failures; change the
parseLogoPlaceholders signature to accept and return ?string and add a null-safe
guard at the start (if input is null return null) before running any regex or
string operations, and update any internal variable types accordingly so callers
continue to receive null or a processed string without warnings.
| private function createCachedLogoImage(int $size): void | ||
| { | ||
| $logoImageId = $this->configProvider->getValue(ConfigOption::OrganisationLogo); | ||
| if (empty($logoImageId)) { | ||
| return; | ||
| } | ||
|
|
||
| $orgLogoImage = $this->templateImageRepository->findByFilename("ORGANISATIONLOGO$size.png"); | ||
| if (!empty($orgLogoImage->getData())) { | ||
| return; | ||
| } | ||
|
|
||
| $logoImage = $this->templateImageRepository->findById((int) $logoImageId); | ||
| $imageContent = base64_decode($logoImage->getData()); | ||
| if (empty($imageContent)) { | ||
| //# fall back to a single pixel, so that there are no broken images | ||
| $imageContent = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABGdBTUEAALGPC/xhBQAAAAZQTFRF////AAAAVcLTfgAAAAF0Uk5TAEDm2GYAAAABYktHRACIBR1IAAAACXBIWXMAAAsSAAALEgHS3X78AAAAB3RJTUUH0gQCEx05cqKA8gAAAApJREFUeJxjYAAAAAIAAUivpHEAAAAASUVORK5CYII='); | ||
| } | ||
|
|
||
| return null; | ||
| $imgSize = getimagesizefromstring($imageContent); | ||
| $sizeW = $imgSize[0]; | ||
| $sizeH = $imgSize[1]; | ||
| if ($sizeH > $sizeW) { | ||
| $sizeFactor = (float) ($size / $sizeH); | ||
| } else { | ||
| $sizeFactor = (float) ($size / $sizeW); | ||
| } | ||
| $newWidth = (int) ($sizeW * $sizeFactor); | ||
| $newHeight = (int) ($sizeH * $sizeFactor); | ||
|
|
||
| if ($sizeFactor < 1) { | ||
| $original = imagecreatefromstring($imageContent); | ||
| //# creates a black image (why would you want that....) | ||
| $resized = imagecreatetruecolor($newWidth, $newHeight); | ||
| imagesavealpha($resized, true); | ||
| //# white. All the methods to make it transparent didn't work for me @@TODO really make transparent | ||
| $transparent = imagecolorallocatealpha($resized, 255, 255, 255, 127); | ||
| imagefill($resized, 0, 0, $transparent); | ||
|
|
||
| if (imagecopyresized($resized, $original, 0, 0, 0, 0, $newWidth, $newHeight, $sizeW, $sizeH)) { | ||
| $this->entityManager->remove($orgLogoImage); | ||
|
|
||
| //# 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; | ||
| } | ||
| } | ||
| // else copy original | ||
| $templateImage = (new TemplateImage()) | ||
| ->setFilename("ORGANISATIONLOGO$size.png") | ||
| ->setMimetype($imgSize['mime']) | ||
| ->setData(base64_encode($imageContent)) | ||
| ->setWidth($newWidth) | ||
| ->setHeight($newHeight); | ||
|
|
||
| $this->entityManager->persist($templateImage); | ||
|
|
||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "*Repository*.php" | grep -i template | head -20Repository: phpList/core
Length of output: 249
🏁 Script executed:
find . -type f -path "*/Domain/Messaging/*" -name "*.php" | head -30Repository: phpList/core
Length of output: 1859
🏁 Script executed:
fd "MessagePrecacheService" --type fRepository: phpList/core
Length of output: 111
🏁 Script executed:
cat -n src/Domain/Messaging/Repository/TemplateImageRepository.phpRepository: phpList/core
Length of output: 1407
🏁 Script executed:
fd "TemplateImage.php" --type fRepository: phpList/core
Length of output: 100
🏁 Script executed:
head -100 src/Domain/Messaging/Service/MessagePrecacheService.phpRepository: phpList/core
Length of output: 5493
🏁 Script executed:
sed -n '240,310p' src/Domain/Messaging/Service/MessagePrecacheService.php | cat -nRepository: phpList/core
Length of output: 3250
🏁 Script executed:
cat -n src/Domain/Messaging/Model/TemplateImage.phpRepository: phpList/core
Length of output: 3753
🏁 Script executed:
# Check if there are any null checks for $logoImage before line 255
sed -n '242,260p' src/Domain/Messaging/Service/MessagePrecacheService.phpRepository: phpList/core
Length of output: 998
🏁 Script executed:
# Verify ob_* safety by checking PHP documentation context
# Check if there's any ob_start() before this method is called
rg "ob_start|ob_get_level" src/Domain/Messaging/Service/MessagePrecacheService.phpRepository: phpList/core
Length of output: 83
Fix null dereference hazards and move image generation + persistence to Infrastructure/Application layer (Lines 242–304).
- Line 250:
findByFilename()returns nullable?TemplateImage, but$orgLogoImage->getData()is called without null check → fatal error if method returns null. - Line 255:
findById()returns nullable?TemplateImage, but$logoImage->getData()is called immediately without null check → fatal error if method returns null. - Line 262:
getimagesizefromstring()can returnfalse; direct array access to$imgSize[0],$imgSize[1], and$imgSize['mime']at lines 263–264 and 298 will fatal if the function fails. - Lines 286–296: Output buffering without checking
ob_get_level()beforeob_get_contents()/ob_end_clean()can emit notices or corrupt output if no buffer is active. - Lines 283, 303:
$this->entityManager->remove()and$this->entityManager->persist()belong in Infrastructure/Application layer (e.g., MessageHandler), not in Domain service. Image generation + GD work + DB writes should be moved to a dedicated application/infrastructure service and called from here via a port.
🤖 Prompt for AI Agents
In src/Domain/Messaging/Service/MessagePrecacheService.php around lines 242–304,
there are multiple null-dereference and runtime hazards and a layering
violation: add null checks after templateImageRepository->findByFilename(...)
and findById(...) and handle the case when they return null (return or throw a
domain-specific exception), check the result of getimagesizefromstring() and
bail out cleanly if it returns false, avoid direct array access on $imgSize
until validated, guard any ob_get_contents()/ob_end_clean() calls by verifying
ob_get_level() > 0 (or use a local output buffer via ob_start() and always
ob_get_clean()), and remove all EntityManager persistence/removal and GD
image-generation side effects from this domain service — instead extract image
generation, resizing and repository persistence into an
Infrastructure/Application service behind an injected port/interface and call
that port from this domain service (pass raw image data or identifiers); keep
the domain service free of DB/GD side effects and only delegate to the new
application/infrastructure collaborator.
Summary by CodeRabbit
New Features
Refactor
Tests
Chores
✏️ Tip: You can customize this high-level summary in your review settings.
Thanks for contributing to phpList!