Conversation
📝 WalkthroughWalkthroughRefactors 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
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
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)
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ 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.
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.
5fe45ab to
492e1d0
Compare
There was a problem hiding this comment.
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 $contentand: stringfor type safety.- public function parseLogoPlaceholders($content) + public function parseLogoPlaceholders(string $content): string
122-125: Null pointer dereference risk (still unresolved).
findByFilename()can returnnull, 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 returnnull, 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
@mkdirhides 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-curlis now a required dependency in composer.json, thefunction_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
@fopensilently 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
📒 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.phpsrc/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.
8e75515 to
25ef84a
Compare
- Message forwarding: send campaigns to friends (optional personal note), per-user limits, admin notifications on success/failure, and forwarding statistics; forwarded messages prefixed "Fwd". - Admin-copy emails: configurable toggle to send admin copies and select recipients. --------- Co-authored-by: Tatevik <tatevikg1@gmail.com>
There was a problem hiding this comment.
Actionable comments posted: 14
🤖 Fix all issues with AI agents
In `@config/services/messenger.yml`:
- Around line 14-17: The CampaignProcessorMessageHandler has an
#[Autowire('%imap_bounce.email%')] on its constructor $bounceEmail parameter but
the service definition's arguments: block is empty; update the service entry for
PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler to
explicitly wire the parameter by adding an arguments mapping for $bounceEmail to
the imap_bounce.email parameter (i.e., set $bounceEmail ->
'%imap_bounce.email%') so DI can resolve the constructor argument.
In `@src/Domain/Common/ExternalImageService.php`:
- Around line 168-173: In isCacheableUrl, guard the str_contains call against a
null website value from $this->configProvider->getValue(ConfigOption::Website):
fetch the website into a local variable (e.g. $website), verify it is a
non-empty string (or coalesce to an empty string) before calling
str_contains(..., '://' . $website . '/'), and only perform that str_contains
check when $website is not null/empty so you avoid a TypeError from str_contains
when getValue returns null.
In `@src/Domain/Configuration/Service/MessagePlaceholderProcessor.php`:
- Around line 95-112: The current ensureStandardPlaceholders method uses
str_contains which misses different-cased placeholders and placeholders with
defaults (e.g. [FOOTER%%default]), causing duplicates; update the presence
checks to use case-insensitive regex that matches the placeholder name plus an
optional "%%..." default segment (for example use a pattern like
/\[(PLACEHOLDER)(?:%%[^\]]*)?\]/i) for each placeholder checked in
ensureStandardPlaceholders so appendContent is only called when no matching
placeholder (FOOTER, SIGNATURE, USERTRACK) exists, keeping the existing
separator logic and respecting OutputFormat::Html and the alwaysAddUserTrack
branch.
In
`@src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php`:
- Around line 22-36: The HTML branch of ConfirmationUrlValueResolver::__invoke
currently only escapes the separator; update it to escape the URL consistently
with sibling resolvers by passing htmlspecialchars($url, ENT_QUOTES |
ENT_SUBSTITUTE, 'UTF-8') (and keep escaping the separator as before) when
building the returned string; leave the non-HTML branch unchanged so only the
HTML output is sanitized.
In `@src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php`:
- Around line 25-49: The __invoke method in ForwardValueResolver builds a query
parameter mid using $ctx->messageId() which is nullable; when null it becomes
mid=0 or an empty param — guard against null by checking $ctx->messageId()
(and/or $ctx->forwardedBy()) and omit the mid parameter entirely when it is
null: update the HTML branch (where the anchor is built) and the plaintext
return (sprintf call) in __invoke to only append "&mid=..." / "mid=..." when
$ctx->messageId() is not null, keeping existing htmlspecialchars usage and uid
logic (methods: __invoke, messageId(), isHtml(), forwardedBy(),
getUser()->getUniqueId()).
In `@src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php`:
- Around line 25-50: In PreferencesValueResolver::__invoke, the UID ($uid) is
inserted raw into the href when $ctx->isHtml() — escape or build the final URL
before escaping to prevent broken/unsafe HTML; either run htmlspecialchars($uid,
ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') before concatenation or (preferable) use
the same pattern as UnsubscribeValueResolver by composing the full URL (e.g. via
urlBuilder->withUid(...) or equivalent) and then apply htmlspecialchars once to
the complete href ($safeUrl or $fullUrl) so the uid is properly encoded in the
anchor href.
In `@src/Domain/Identity/Service/AdminCopyEmailSender.php`:
- Around line 29-52: The __invoke method currently calls
$this->systemEmailBuilder->buildSystemEmail(data: $data) and passes its result
directly to MailerInterface::send, but buildSystemEmail can return null causing
a TypeError; modify the loop in AdminCopyEmailSender::__invoke to check if
$email is null after calling buildSystemEmail (use the same $data/$email
variables), and if null skip sending (and optionally log via
configProvider/logger) instead of constructing the Envelope and calling
$this->mailer->send with a null message; ensure resolveRecipientEmails and
Envelope creation remain unchanged for non-null $email.
In `@src/Domain/Messaging/Service/AttachmentAdder.php`:
- Around line 177-196: copyAttachmentToRepository is persisting the full path
returned by FileHelper::writeFileToDirectory into Attachment::setFilename, which
later causes attachFromRepository to build duplicate-prefixed paths; change the
code so that after writeFileToDirectory returns, you extract and store only the
basename (e.g. via PHP basename/pathinfo) before calling
Attachment::setFilename, or alternatively make writeFileToDirectory return a
relative name — but do not save the absolute path into Attachment::filename
(refer to copyAttachmentToRepository, FileHelper::writeFileToDirectory,
Attachment::setFilename, and attachFromRepository when making the change).
In `@src/Domain/Messaging/Service/Builder/EmailBuilder.php`:
- Around line 236-250: When htmlPref is true the Pdf branch only attaches a PDF
and never sets an email body, resulting in an empty message; update the Pdf
branch in EmailBuilder.php (the block using htmlPref, sentAs =
OutputFormat::Pdf, $this->pdfGenerator->createPdfBytes and $email->attach) to
also set a visible body before calling $this->attachmentAdder->add — e.g. call
$email->text("Please see attached PDF") and/or $email->html("<p>Please see
attached PDF</p>") so recipients have a readable fallback; keep throwing
AttachmentException on add failure as-is.
- Line 238: $textMessage is nullable but is being passed to
PdfGenerator::createPdfBytes(string) which requires a non-null string; add a
null-check before calling createPdfBytes (and the similar call around the other
occurrence) in EmailBuilder so you only call PdfGenerator::createPdfBytes when
$textMessage is !== null — otherwise provide a safe fallback (e.g. skip PDF
generation, pass an empty string, or throw a clear InvalidArgumentException).
Update the logic around the occurrences of $textMessage (the call at the line
with $this->pdfGenerator->createPdfBytes($textMessage) and the similar call at
the other occurrence) to guard against null values and handle the null case
explicitly.
In `@src/Domain/Messaging/Service/Constructor/SystemMailContentBuilder.php`:
- Around line 55-57: The strpos check for '</body>' in
SystemMailContentBuilder.php is using a truthy test that fails when the tag is
at position 0; change the condition in the elseif that currently calls
strpos($htmlContent, '</body>') to a strict comparison strpos($htmlContent,
'</body>') !== false so the block (which calls str_replace('</body>',
$phpListPowered.'</body>', $htmlContent)) runs correctly whenever the tag
exists; update that single conditional and leave the replacement using
$phpListPowered and $htmlContent untouched.
In `@src/Domain/Messaging/Service/MessageForwardService.php`:
- Around line 70-81: Wrap the call to
$this->forwardDeliveryService->send($email) and the subsequent success-path
calls (adminNotifier->notifyForwardSucceeded, forwardDeliveryService->markSent,
campaign->incrementSentCount, forwardingStatsService->incrementFriendsCount) in
a try-catch; on exception catch (\Throwable $e) call
$this->adminNotifier->notifyForwardFailed(...) and
$this->forwardDeliveryService->markFailed($campaign, $forwardingSubscriber,
$friendEmail) (passing the exception or its message as the notify/markFailed
APIs expect), then continue the loop so processing of remaining forwards
proceeds unchanged. Ensure the successful path still executes the same methods
(notifyForwardSucceeded, markSent, incrementSentCount, incrementFriendsCount)
when no exception occurs.
In `@src/Domain/Messaging/Service/MessagePrecacheService.php`:
- Around line 49-53: The cached value ($cached) retrieved via
$this->cache->get($cacheKey) is returned directly but the method signature
declares a boolean return (: bool), causing a TypeError on cache hit; change the
cache-hit branch to return a boolean (e.g., return (bool) $cached or return
true/false based on the cached content) instead of returning the DTO, and ensure
any code that saves into the cache for this key ($this->cache->set(...)) stores
a value that can be meaningfully cast to bool for the method's contract.
In `@tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php`:
- Around line 69-84: The test configures the attrManager mock twice for
createOrUpdateByName which is unreliable; remove the second expectation
(expects(self::never())) and instead assert behavior by either changing the
initial expectation to expects(self::atMostOnce()) or simply deleting the
reconfiguration so the first expects(self::once()) defined on
ForwardingStatsServiceTest remains authoritative; update the test around the
methods incrementFriendsCount and updateFriendsCount to rely on that single
expectation on attrManager->createOrUpdateByName.
🧹 Nitpick comments (9)
src/Domain/Analytics/Service/LinkTrackService.php (2)
43-43: TODO comment flagged for forwarded message handling.Heads up on this reminder you left yourself. When forwarding, it sounds like you'll want to distinguish forwarded link tracking from regular user tracking. Makes sense to defer this if the forwarding flow isn't fully wired up yet.
Want me to help sketch out how the forwarded case could be handled here, or open an issue to track this work?
52-52: Minor: Redundant null coalescing.The
contentproperty onMessagePrecacheDtois typed asstringwith a default of'', so it can't be null. The?? ''is technically unnecessary here, though it's harmless as defensive coding.💡 Optional cleanup
- $links = $this->extractLinksFromHtml($content->content ?? ''); + $links = $this->extractLinksFromHtml($content->content);src/Domain/Configuration/Model/Dto/PlaceholderContext.php (1)
43-46: Minor: Redundant getter for public property.
getUser()duplicates access to$this->userwhich is alreadypublic readonly. Either make the property private (like$forwardedBy) or remove the getter for consistency.Option A: Remove redundant getter
- public function getUser(): Subscriber - { - return $this->user; - }Option B: Make property private like others
public function __construct( - public readonly Subscriber $user, + private readonly Subscriber $user, public readonly OutputFormat $format,src/Domain/Messaging/Service/ForwardDeliveryService.php (1)
39-57: Consider extracting status strings to constants.Both
'sent'and'failed'are used here and in the repository query. A shared constant (e.g.,UserMessageForward::STATUS_SENT) would reduce magic strings and ensure consistency.src/Domain/Messaging/Service/Builder/HttpReceivedStampBuilder.php (1)
31-33: Consider avoiding reverse-DNS on the request path.
gethostbyaddr()can block and add latency; consider making it optional (config flag) or caching the result, and rely onREMOTE_HOSTwhen already present.Also applies to: 46-52
tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php (2)
208-246: Redundant configProvider mock setup.Lines 210-220 duplicate the exact same
willReturnMapconfiguration that's already set insetUp()at lines 63-73. This isn't harmful but adds noise.♻️ Consider removing the duplicate mock setup
public function testPrefersTextWhenNoHtmlContent(): void { - $this->configProvider - ->method('getValue') - ->willReturnMap([ - [ConfigOption::MessageFromAddress, 'from@example.com'], - [ConfigOption::MessageFromName, 'From Name'], - [ConfigOption::UnsubscribeUrl, 'https://example.com/unsubscribe'], - [ConfigOption::PreferencesUrl, 'https://example.com/prefs'], - [ConfigOption::SubscribeUrl, 'https://example.com/subscribe'], - [ConfigOption::AdminAddress, 'admin@example.com'], - [ConfigOption::AlwaysSendTextDomains, ''], - ]); - $this->blacklistRepository->method('isEmailBlacklisted')->willReturn(false);
358-390: Test documents a potential bug in production code.Line 388-389 notes that
List-Ownerheader usesUnsubscribeUrlinstead ofAdminAddress, which the comment flags as "likely a bug". The test asserts current behavior, which is the right approach for regression tests.Consider opening an issue to track fixing this in the production code if it's indeed unintended.
src/Domain/Configuration/Model/ConfigOption.php (2)
36-37: Inconsistent string value casing.
PoweredByImageandPoweredByTextuse PascalCase while all other config values use lowercase or snake_case. If these must match existing legacy config keys, that's fine—just worth noting for future additions to follow a consistent pattern.
18-18: TODO comment should be resolved or documented.This TODO has been here a while. Either track it in an issue, add context about what needs checking, or remove it if no longer relevant.
Want me to open an issue to track investigating where
Domainis defined?
config/services/messenger.yml
Outdated
| PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler: | ||
| autowire: true | ||
| arguments: | ||
| $maxMailSize: '%messaging.max_mail_size%' | ||
|
|
||
| PhpList\Core\Domain\Subscription\MessageHandler\DynamicTableMessageHandler: | ||
| autowire: true | ||
| autoconfigure: true | ||
| tags: [ 'messenger.message_handler' ] | ||
| arguments: |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
ast-grep --pattern $'class CampaignProcessorMessageHandler {
$$$
public function __construct($$$) {
$$$
}
$$$
}'Repository: phpList/core
Length of output: 38
🏁 Script executed:
fd -type f -name "*.php" | xargs grep -l "class CampaignProcessorMessageHandler"Repository: phpList/core
Length of output: 228
🏁 Script executed:
rg "class CampaignProcessorMessageHandler" --type php -lRepository: phpList/core
Length of output: 210
🏁 Script executed:
cat src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.phpRepository: phpList/core
Length of output: 15405
The arguments: block needs to include the bounceEmail configuration.
The CampaignProcessorMessageHandler constructor has an #[Autowire('%imap_bounce.email%')] attribute on the $bounceEmail parameter, which requires explicit wiring. The empty arguments: block should define this value. Something like:
arguments:
$bounceEmail: '%imap_bounce.email%'Without it, dependency injection will fail for this handler.
🤖 Prompt for AI Agents
In `@config/services/messenger.yml` around lines 14 - 17, The
CampaignProcessorMessageHandler has an #[Autowire('%imap_bounce.email%')] on its
constructor $bounceEmail parameter but the service definition's arguments: block
is empty; update the service entry for
PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler to
explicitly wire the parameter by adding an arguments mapping for $bounceEmail to
the imap_bounce.email parameter (i.e., set $bounceEmail ->
'%imap_bounce.email%') so DI can resolve the constructor argument.
| private function isCacheableUrl($filename): bool | ||
| { | ||
| if (!(str_starts_with($filename, 'http')) | ||
| || str_contains($filename, '://' . $this->configProvider->getValue(ConfigOption::Website) . '/') | ||
| ) { | ||
| return false; |
There was a problem hiding this comment.
Guard str_contains against null website config.
getValue() can return null; str_contains() will TypeError.
🛠️ Fix
- if (!(str_starts_with($filename, 'http'))
- || str_contains($filename, '://' . $this->configProvider->getValue(ConfigOption::Website) . '/')
- ) {
+ $website = $this->configProvider->getValue(ConfigOption::Website) ?? '';
+ if (!(str_starts_with($filename, 'http'))
+ || ($website !== '' && str_contains($filename, '://' . $website . '/'))
+ ) {
return false;
}🤖 Prompt for AI Agents
In `@src/Domain/Common/ExternalImageService.php` around lines 168 - 173, In
isCacheableUrl, guard the str_contains call against a null website value from
$this->configProvider->getValue(ConfigOption::Website): fetch the website into a
local variable (e.g. $website), verify it is a non-empty string (or coalesce to
an empty string) before calling str_contains(..., '://' . $website . '/'), and
only perform that str_contains check when $website is not null/empty so you
avoid a TypeError from str_contains when getValue returns null.
| private function ensureStandardPlaceholders(string $value, OutputFormat $format): string | ||
| { | ||
| if (!str_contains($value, '[FOOTER]')) { | ||
| $sep = $format === OutputFormat::Html ? '<br />' : "\n\n"; | ||
| $value = $this->appendContent($value, $sep . '[FOOTER]'); | ||
| } | ||
|
|
||
| if (!str_contains($value, '[SIGNATURE]')) { | ||
| $sep = $format === OutputFormat::Html ? ' ' : "\n"; | ||
| $value = $this->appendContent($value, $sep . '[SIGNATURE]'); | ||
| } | ||
|
|
||
| if ($this->alwaysAddUserTrack && $format === OutputFormat::Html && !str_contains($value, '[USERTRACK]')) { | ||
| $value = $this->appendContent($value, ' ' . '[USERTRACK]'); | ||
| } | ||
|
|
||
| return $value; | ||
| } |
There was a problem hiding this comment.
Detect existing placeholders case‑insensitively (and with defaults).
str_contains('[FOOTER]') misses [footer] and [FOOTER%%default], so standard placeholders can be appended twice. A regex check prevents duplicates.
🛠️ Suggested fix
- if (!str_contains($value, '[FOOTER]')) {
+ if (!preg_match('/\[FOOTER(?:%%[^\]]+)?\]/i', $value)) {
$sep = $format === OutputFormat::Html ? '<br />' : "\n\n";
$value = $this->appendContent($value, $sep . '[FOOTER]');
}
- if (!str_contains($value, '[SIGNATURE]')) {
+ if (!preg_match('/\[SIGNATURE(?:%%[^\]]+)?\]/i', $value)) {
$sep = $format === OutputFormat::Html ? ' ' : "\n";
$value = $this->appendContent($value, $sep . '[SIGNATURE]');
}
- if ($this->alwaysAddUserTrack && $format === OutputFormat::Html && !str_contains($value, '[USERTRACK]')) {
+ if (
+ $this->alwaysAddUserTrack
+ && $format === OutputFormat::Html
+ && !preg_match('/\[USERTRACK(?:%%[^\]]+)?\]/i', $value)
+ ) {
$value = $this->appendContent($value, ' ' . '[USERTRACK]');
}🤖 Prompt for AI Agents
In `@src/Domain/Configuration/Service/MessagePlaceholderProcessor.php` around
lines 95 - 112, The current ensureStandardPlaceholders method uses str_contains
which misses different-cased placeholders and placeholders with defaults (e.g.
[FOOTER%%default]), causing duplicates; update the presence checks to use
case-insensitive regex that matches the placeholder name plus an optional
"%%..." default segment (for example use a pattern like
/\[(PLACEHOLDER)(?:%%[^\]]*)?\]/i) for each placeholder checked in
ensureStandardPlaceholders so appendContent is only called when no matching
placeholder (FOOTER, SIGNATURE, USERTRACK) exists, keeping the existing
separator logic and respecting OutputFormat::Html and the alwaysAddUserTrack
branch.
| public function __invoke(PlaceholderContext $ctx): string | ||
| { | ||
| $url = $this->config->getValue(ConfigOption::ConfirmationUrl); | ||
| if (empty($url)) { | ||
| return ''; | ||
| } | ||
| $sep = !str_contains($url, '?') ? '?' : '&'; | ||
| $uid = $ctx->forwardedBy() ? 'forwarded' : $ctx->getUser()->getUniqueId(); | ||
|
|
||
| if ($ctx->isHtml()) { | ||
| return sprintf('%s%suid=%s', $url, htmlspecialchars($sep), $uid); | ||
| } | ||
|
|
||
| return sprintf('%s%suid=%s', $url, $sep, $uid); | ||
| } |
There was a problem hiding this comment.
Inconsistent HTML escaping compared to sibling resolvers.
In the HTML branch (Line 32), only the separator is escaped, but the URL itself is output raw. Compare with ForwardUrlValueResolver and PreferencesUrlValueResolver from the relevant snippets, which both escape the URL using htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').
If the URL contains characters like & or ", the current implementation could produce invalid HTML or XSS vectors.
🔒 Suggested fix for consistent HTML escaping
if ($ctx->isHtml()) {
- return sprintf('%s%suid=%s', $url, htmlspecialchars($sep), $uid);
+ return sprintf(
+ '%s%suid=%s',
+ htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
+ htmlspecialchars($sep),
+ $uid
+ );
}🤖 Prompt for AI Agents
In
`@src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php`
around lines 22 - 36, The HTML branch of ConfirmationUrlValueResolver::__invoke
currently only escapes the separator; update it to escape the URL consistently
with sibling resolvers by passing htmlspecialchars($url, ENT_QUOTES |
ENT_SUBSTITUTE, 'UTF-8') (and keep escaping the separator as before) when
building the returned string; leave the non-HTML branch unchanged so only the
HTML output is sanitized.
| public function __invoke(PlaceholderContext $ctx): string | ||
| { | ||
| $url = $this->config->getValue(ConfigOption::ForwardUrl); | ||
| if (empty($url)) { | ||
| return ''; | ||
| } | ||
| $sep = !str_contains($url, '?') ? '?' : '&'; | ||
| $uid = $ctx->forwardedBy() ? 'forwarded' : $ctx->getUser()->getUniqueId(); | ||
|
|
||
| if ($ctx->isHtml()) { | ||
| $label = $this->translator->trans('This link'); | ||
|
|
||
| return '<a href="' | ||
| . htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') | ||
| . htmlspecialchars($sep) | ||
| . 'uid=' | ||
| . $uid | ||
| . '&mid=' | ||
| . $ctx->messageId() | ||
| . '">' | ||
| . htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') | ||
| . '</a> '; | ||
| } | ||
|
|
||
| return sprintf('%s%suid=%s&mid=%d ', $url, $sep, $uid, $ctx->messageId()); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find PlaceholderContext definition
find . -type f -name "*.php" | xargs grep -l "class PlaceholderContext"Repository: phpList/core
Length of output: 115
🏁 Script executed:
# Also search for the specific method signature
rg "messageId\s*\(" --type php -A 2 -B 2 | head -50Repository: phpList/core
Length of output: 3544
🏁 Script executed:
# Check if ForwardValueResolver has any domain purity violations
cat -n src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.phpRepository: phpList/core
Length of output: 1993
🏁 Script executed:
# Verify sprintf behavior with null and %d format in PHP
php -r "echo sprintf('mid=%d', null) . PHP_EOL; echo 'Concatenation: ' . 'mid=' . null . PHP_EOL;"Repository: phpList/core
Length of output: 81
🏁 Script executed:
# Double-check if there are any other callers of messageId() that might have similar issues
rg "messageId\(\)" --type php -B 2 -A 2Repository: phpList/core
Length of output: 3544
Guard against null messageId to prevent malformed query parameters.
messageId() is nullable (?int). When null, sprintf with %d converts it to 0, and string concatenation produces an empty value, resulting in mid=0 (text) or &mid= (HTML)—both invalid. Early-return or omit the mid parameter when missing.
🛠️ Suggested fix
public function __invoke(PlaceholderContext $ctx): string
{
$url = $this->config->getValue(ConfigOption::ForwardUrl);
if (empty($url)) {
return '';
}
+ $mid = $ctx->messageId();
+ if ($mid === null) {
+ return '';
+ }
$sep = !str_contains($url, '?') ? '?' : '&';
$uid = $ctx->forwardedBy() ? 'forwarded' : $ctx->getUser()->getUniqueId();
@@
- . $ctx->messageId()
+ . $mid
@@
- return sprintf('%s%suid=%s&mid=%d ', $url, $sep, $uid, $ctx->messageId());
+ return sprintf('%s%suid=%s&mid=%d ', $url, $sep, $uid, $mid);
}🤖 Prompt for AI Agents
In `@src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php` around
lines 25 - 49, The __invoke method in ForwardValueResolver builds a query
parameter mid using $ctx->messageId() which is nullable; when null it becomes
mid=0 or an empty param — guard against null by checking $ctx->messageId()
(and/or $ctx->forwardedBy()) and omit the mid parameter entirely when it is
null: update the HTML branch (where the anchor is built) and the plaintext
return (sprintf call) in __invoke to only append "&mid=..." / "mid=..." when
$ctx->messageId() is not null, keeping existing htmlspecialchars usage and uid
logic (methods: __invoke, messageId(), isHtml(), forwardedBy(),
getUser()->getUniqueId()).
| // send a PDF file to users who want html and text to everyone else | ||
| if ($htmlPref) { | ||
| $sentAs = OutputFormat::Pdf; | ||
| $pdfBytes = $this->pdfGenerator->createPdfBytes($textMessage); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's look at the EmailBuilder.php file around lines 238 and 265
cat -n src/Domain/Messaging/Service/Builder/EmailBuilder.php | sed -n '230,270p'Repository: phpList/core
Length of output: 1904
🏁 Script executed:
# Find the PdfGenerator class and check the createPdfBytes method
find . -type f -name "*.php" | xargs grep -l "createPdfBytes" | head -5Repository: phpList/core
Length of output: 263
🏁 Script executed:
# Search for the PdfGenerator interface/class definition
rg -n "class PdfGenerator|interface PdfGenerator" -A 5Repository: phpList/core
Length of output: 1007
🏁 Script executed:
cat -n src/Domain/Common/PdfGenerator.phpRepository: phpList/core
Length of output: 811
🏁 Script executed:
# Also check if there's any null checking or guards in EmailBuilder before line 238 and 265
cat -n src/Domain/Messaging/Service/Builder/EmailBuilder.php | sed -n '220,240p'Repository: phpList/core
Length of output: 959
Type safety issue: nullable $textMessage passed to non-nullable parameter.
$textMessage is declared as ?string but PdfGenerator::createPdfBytes() expects a non-nullable string. With strict_types=1 enabled, passing null will cause a TypeError at runtime. Add a null check before these calls, or ensure the parameter is guaranteed non-null.
Also applies to: 265-265
🤖 Prompt for AI Agents
In `@src/Domain/Messaging/Service/Builder/EmailBuilder.php` at line 238,
$textMessage is nullable but is being passed to
PdfGenerator::createPdfBytes(string) which requires a non-null string; add a
null-check before calling createPdfBytes (and the similar call around the other
occurrence) in EmailBuilder so you only call PdfGenerator::createPdfBytes when
$textMessage is !== null — otherwise provide a safe fallback (e.g. skip PDF
generation, pass an empty string, or throw a clear InvalidArgumentException).
Update the logic around the occurrences of $textMessage (the call at the line
with $this->pdfGenerator->createPdfBytes($textMessage) and the similar call at
the other occurrence) to guard against null values and handle the null case
explicitly.
| } elseif (strpos($htmlContent, '</body>')) { | ||
| $htmlContent = str_replace('</body>', $phpListPowered.'</body>', $htmlContent); | ||
| } else { |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n src/Domain/Messaging/Service/Constructor/SystemMailContentBuilder.php | sed -n '50,65p'Repository: phpList/core
Length of output: 1191
Use strict strpos !== false for </body> detection.
If </body> is at position 0, strpos() returns 0 which is falsy in PHP, so the condition silently fails and the code falls through instead of replacing the tag.
🔧 Suggested fix
- } elseif (strpos($htmlContent, '</body>')) {
+ } elseif (strpos($htmlContent, '</body>') !== false) {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| } elseif (strpos($htmlContent, '</body>')) { | |
| $htmlContent = str_replace('</body>', $phpListPowered.'</body>', $htmlContent); | |
| } else { | |
| } elseif (strpos($htmlContent, '</body>') !== false) { | |
| $htmlContent = str_replace('</body>', $phpListPowered.'</body>', $htmlContent); | |
| } else { |
🤖 Prompt for AI Agents
In `@src/Domain/Messaging/Service/Constructor/SystemMailContentBuilder.php` around
lines 55 - 57, The strpos check for '</body>' in SystemMailContentBuilder.php is
using a truthy test that fails when the tag is at position 0; change the
condition in the elseif that currently calls strpos($htmlContent, '</body>') to
a strict comparison strpos($htmlContent, '</body>') !== false so the block
(which calls str_replace('</body>', $phpListPowered.'</body>', $htmlContent))
runs correctly whenever the tag exists; update that single conditional and leave
the replacement using $phpListPowered and $htmlContent untouched.
| // After two increments (3 -> 4 -> 5), update should persist '5' | ||
| $this->attrManager->expects(self::once()) | ||
| ->method('createOrUpdateByName') | ||
| ->with( | ||
| subscriber: self::identicalTo($subscriber), | ||
| attributeName: 'FriendsForwarded', | ||
| value: '5' | ||
| ); | ||
|
|
||
| $service->incrementFriendsCount($subscriber); | ||
| $service->incrementFriendsCount($subscriber); | ||
| $service->updateFriendsCount($subscriber); | ||
|
|
||
| // Second update attempt should be a no-op due to cache reset | ||
| $this->attrManager->expects(self::never())->method('createOrUpdateByName'); | ||
| $service->updateFriendsCount($subscriber); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's locate and examine the test file
find . -name "ForwardingStatsServiceTest.php" -type fRepository: phpList/core
Length of output: 124
🏁 Script executed:
# Also check what version of PHPUnit is being used
if [ -f composer.json ]; then
cat composer.json | grep -A 5 "phpunit"
fi
if [ -f composer.lock ]; then
grep -A 10 '"name": "phpunit/phpunit"' composer.lock | head -15
fiRepository: phpList/core
Length of output: 286
🌐 Web query:
PHPUnit mock multiple expectations same method behavior
💡 Result:
In PHPUnit you generally don’t set multiple expects() blocks for the same method on the same mock; instead configure one expectation and then vary behavior by call order or arguments.
1) Same method, different return values per call (call order)
$mock->expects($this->exactly(2))
->method('foo')
->willReturn('first', 'second');This returns different values on consecutive invocations and (with exactly(2)) also asserts the call count. [1]
2) Same method, different return values based on arguments
$mock->expects($this->any())
->method('foo')
->willReturnMap([
['a', 'b', 'ret1'],
['x', 'y', 'ret2'],
]);willReturnMap() selects the return value by matching the argument list. [1]
3) Same method, complex “multiple expectations” logic (mix order + args)
Use a callback (you can track invocation number and inspect args):
$i = 0;
$mock->method('foo')->willReturnCallback(function($arg) use (&$i) {
return (++$i === 1 && $arg === 'a') ? 'ret1' : 'ret2';
});This is the most flexible approach when neither pure “consecutive calls” nor pure “argument map” is enough. [1]
Sources: PHPUnit manual on willReturn() (consecutive values), willReturnMap(), and call-count expectations (exactly(), etc.). [1]
[1] (docs.phpunit.de)
Citations:
🏁 Script executed:
# Let's examine the test file
cat -n ./tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php | head -100Repository: phpList/core
Length of output: 4710
🏁 Script executed:
# Let's see the specific lines 69-84 and more context around it
cat -n ./tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php | sed -n '60,95p'Repository: phpList/core
Length of output: 1718
Remove the redundant expectation reconfiguration.
PHPUnit doesn't support multiple expects() calls on the same method reliably—the second expects(self::never()) replaces rather than extends the first expects(self::once()). Once line 80 satisfies the once() expectation, reconfiguring with never() is both unreliable and unnecessary.
If you want to verify the second updateFriendsCount() call is truly a no-op, use expects(self::atMostOnce()) instead of expects(self::once()) on the initial setup. Otherwise, removing the line 83 reconfiguration is fine—the first expectation already enforces the persistence behavior.
Suggested fix
- // Second update attempt should be a no-op due to cache reset
- $this->attrManager->expects(self::never())->method('createOrUpdateByName');
$service->updateFriendsCount($subscriber);
self::assertTrue(true);🤖 Prompt for AI Agents
In `@tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php` around
lines 69 - 84, The test configures the attrManager mock twice for
createOrUpdateByName which is unreliable; remove the second expectation
(expects(self::never())) and instead assert behavior by either changing the
initial expectation to expects(self::atMostOnce()) or simply deleting the
reconfiguration so the first expects(self::once()) defined on
ForwardingStatsServiceTest remains authoritative; update the test around the
methods incrementFriendsCount and updateFriendsCount to rely on that single
expectation on attrManager->createOrUpdateByName.
There was a problem hiding this comment.
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 inUserMessageForwardRepository::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.
DateTimeImmutableis imported but not used in any test.Proposed fix
-use DateTimeImmutable;tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php (1)
7-7: Unused import.
DateTimeImmutableis imported but never used.Proposed fix
-use DateTimeImmutable;
| forwardDto: new MessageForwardDto( | ||
| [], | ||
| 'uuid', | ||
| 'from@example.com', | ||
| 'From', | ||
| null | ||
| ) |
There was a problem hiding this comment.
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.
| 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.
There was a problem hiding this comment.
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
@SuppressWarningsannotations 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 number1in cache key needs clarification.The hardcoded
1in 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()returnsmixed, so if something unexpected ends up in the cache, you'll get cryptic errors downstream when it's used as aMessagePrecacheDto. 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 makingreasonreadonly and passing it at construction time.The
reasonproperty is mutable whilestatusare readonly. InMessageForwardService, the pattern is to create the result, then mutatereason(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, passreasondirectly:return new ForwardingRecipientResult( email: $friendEmail, status: 'failed', reason: 'precache_failed', );tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php (1)
141-178: Consider adding a test forsend()throwing an exception.Current tests cover
getContents()throwing but notsend(). Ifsend()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.
| 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); | ||
|
|
There was a problem hiding this comment.
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.
| 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.
There was a problem hiding this comment.
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: Redundantautowire/autoconfigurecan be removed from services covered by_defaults.With the new
_defaultsblock, services only need to declare properties that differ from the defaults. Most services here can drop explicitautowire: trueandautoconfigure: true, keeping only what's necessary (e.g.,public: trueor customarguments).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.ymlor 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
| #[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; |
There was a problem hiding this comment.
🧩 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 -20Repository: phpList/core
Length of output: 110
🏁 Script executed:
# Search for migration files related to MessageFormat
find . -path "*/migrations/*" -name "*.php" -type f | head -20Repository: 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 2Repository: 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 -20Repository: 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.phpRepository: 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 -10Repository: 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*" | sortRepository: 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.phpRepository: 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 2Repository: 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 -30Repository: 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" | sortRepository: 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.phpRepository: 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 -oRepository: 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" | sortRepository: 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 2Repository: 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 -1Repository: 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 -50Repository: 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 -1Repository: 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.phpRepository: 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.phpRepository: 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 -50Repository: 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 -3Repository: 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 -2Repository: 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.phpRepository: 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.
9f9c52e to
ef094ea
Compare
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
🧩 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.jsonRepository: 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.
| // 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); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# List all YAML files in config/services directory
find . -path "./config/services/*.yml" -type f 2>/dev/null | sortRepository: 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 phpRepository: 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 2Repository: 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.
| // 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'].
8697556 to
1dd0df0
Compare
There was a problem hiding this comment.
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 | 🟠 MajorUpdate three tests to mock
findAll()instead offindModules().The implementation of
findBundleClasses()calls$this->packageRepository->findAll()(line 55 inModuleFinder.php), but the following tests stubfindModules():
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_argsNote: 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.
| "PhpList\\Core\\EmptyStartPageBundle\\EmptyStartPageBundle", | ||
| "FOS\\RestBundle\\FOSRestBundle", | ||
| "TatevikGr\\RssFeedBundle\\RssFeedBundle" | ||
| "PhpList\\Core\\EmptyStartPageBundle\\PhpListCoreBundle", |
There was a problem hiding this comment.
🧩 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.phpRepository: 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).
| 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; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for all calls to isEmailBlacklisted
rg -n 'isEmailBlacklisted\s*\(' --type=php -A2 -B2Repository: 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.
1dd0df0 to
0a9b9a4
Compare
There was a problem hiding this comment.
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 | 🟡 MinorInconsistent
in_arraystrictness.Line 32 uses
in_array($key, $this->booleanValues, true)(strict), but line 57 usesin_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 onfilesize()may be unclear.
filesize()returnsint|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 $loadedMessageDatawould 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 instr_ireplace.Line 217 calls
str_ireplaceon$messagePrecacheDto->textContentwhich 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()andwillReturnOnConsecutiveCalls()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; + });
| $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); |
There was a problem hiding this comment.
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.
| protected function resolveDestinationEmail(?string $to): string | ||
| { | ||
| $destinationEmail = $to; | ||
|
|
||
| if ($this->devVersion) { | ||
| if (!$this->devEmail) { | ||
| throw new DevEmailNotConfiguredException(); | ||
| } | ||
| $destinationEmail = $this->devEmail; | ||
| } | ||
|
|
||
| return $destinationEmail; | ||
| } |
There was a problem hiding this comment.
🧩 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.phpRepository: 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 -150Repository: 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.
| 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); | ||
|
|
There was a problem hiding this comment.
🧩 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 -A2Repository: 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 -60Repository: 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 -A2Repository: 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 -A2Repository: 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 -5Repository: 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 -5Repository: 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 -20Repository: phpList/core
Length of output: 1498
🏁 Script executed:
#!/bin/bash
# Check MessagePrecacheDto to see if fromEmail/fromName/subject are nullable
fd -name '*MessagePrecacheDto*' --type=phpRepository: 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 -60Repository: 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 -A5Repository: 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 -A30Repository: 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 -20Repository: 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 -10Repository: 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 -A2Repository: 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 -A3Repository: 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 -30Repository: 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.
| 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.
| $messagePrecacheDto->content = $this->templateImageManager->parseLogoPlaceholders($messagePrecacheDto->content); | ||
| $messagePrecacheDto->template = $this->templateImageManager | ||
| ->parseLogoPlaceholders($messagePrecacheDto->template); | ||
| $messagePrecacheDto->htmlFooter = $this->templateImageManager | ||
| ->parseLogoPlaceholders($messagePrecacheDto->htmlFooter); |
There was a problem hiding this comment.
🧩 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 15Repository: 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 -125Repository: 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 -50Repository: 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' {} -nRepository: 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 -80Repository: 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)'); |
There was a problem hiding this comment.
❓ Verification inconclusive
Script executed:
cat -n src/Migrations/Version20260204094237.phpRepository: 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 -20Repository: 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 -40Repository: 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.
9a23394 to
289099f
Compare
There was a problem hiding this comment.
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.
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 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:
- 1: https://www.php.net/manual/en/function.stripslashes.php?utm_source=openai
- 2: https://php.watch/versions/8.0/internal-function-exceptions?utm_source=openai
- 3: https://www.php.net/manual/en/function.stripslashes.php?utm_source=openai
- 4: https://www.php.net/manual/en/function.stripslashes.php?utm_source=openai
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.
| 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']; | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 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)
Passingnullto a non-nullable scalar/union parameter of an internal function was historically silently coerced (e.g.,null→"",0, etc.). This includes cases likestr_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)
Passingnullto 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,nullis not coerced to satisfy scalar types—expect aTypeErrorwhennullis 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 -5Repository: 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 -100Repository: 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 -20Repository: 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 -40Repository: 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.
| 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>', |
There was a problem hiding this comment.
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.
289099f to
ef65472
Compare
There was a problem hiding this comment.
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
MySQLPlatformis 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 typescomment 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?
| /** | ||
| * ⚠️ 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) | ||
| */ |
There was a problem hiding this comment.
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.
Summary by CodeRabbit
New Features
Improvements
Bug Fixes
Thanks for contributing to phpList!