diff --git a/composer.json b/composer.json index 92a29565..aba1a887 100644 --- a/composer.json +++ b/composer.json @@ -83,7 +83,8 @@ "ext-libxml": "*", "ext-gd": "*", "ext-curl": "*", - "ext-fileinfo": "*" + "ext-fileinfo": "*", + "setasign/fpdf": "^1.8" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index 92b33bed..38e54bf3 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -31,6 +31,10 @@ parameters: env(APP_DEV_EMAIL): 'dev@dev.com' app.powered_by_phplist: '%%env(APP_POWERED_BY_PHPLIST)%%' env(APP_POWERED_BY_PHPLIST): '0' + app.preference_page_show_private_lists: '%%env(PREFERENCEPAGE_SHOW_PRIVATE_LISTS)%%' + env(PREFERENCEPAGE_SHOW_PRIVATE_LISTS): '0' + app.rest_api_domain: '%%env(REST_API_DOMAIN)%%' + env(REST_API_DOMAIN): 'https://example.com/api/v2' # Email configuration app.mailer_from: '%%env(MAILER_FROM)%%' @@ -115,6 +119,12 @@ parameters: env(EXTERNALIMAGE_TIMEOUT): '30' messaging.external_image_max_size: '%%env(EXTERNALIMAGE_MAXSIZE)%%' env(EXTERNALIMAGE_MAXSIZE): '204800' + messaging.forward_alternative_content: '%%env(FORWARD_ALTERNATIVE_CONTENT)%%' + env(FORWARD_ALTERNATIVE_CONTENT): '0' + messaging.email_text_credits: '%%env(EMAILTEXTCREDITS)%%' + env(EMAILTEXTCREDITS): '0' + messaging.always_add_user_track: '%%env(ALWAYS_ADD_USERTRACK)%%' + env(ALWAYS_ADD_USERTRACK): '1' phplist.upload_images_dir: '%%env(PHPLIST_UPLOADIMAGES_DIR)%%' env(PHPLIST_UPLOADIMAGES_DIR): 'images' diff --git a/config/services/builders.yml b/config/services/builders.yml index 10a994a4..1b4316dd 100644 --- a/config/services/builders.yml +++ b/config/services/builders.yml @@ -23,3 +23,28 @@ services: PhpList\Core\Domain\Messaging\Service\Builder\MessageOptionsBuilder: autowire: true autoconfigure: true + + # Concrete mail constructors + PhpList\Core\Domain\Messaging\Service\Constructor\SystemMailContentBuilder: ~ + PhpList\Core\Domain\Messaging\Service\Constructor\CampaignMailContentBuilder: ~ + + # Two EmailBuilder services with different constructors injected + Core.EmailBuilder.system: + class: PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder + arguments: + $mailConstructor: '@PhpList\Core\Domain\Messaging\Service\Constructor\SystemMailContentBuilder' + $googleSenderId: '%messaging.google_sender_id%' + $useAmazonSes: '%messaging.use_amazon_ses%' + $usePrecedenceHeader: '%messaging.use_precedence_header%' + $devVersion: '%app.dev_version%' + $devEmail: '%app.dev_email%' + + Core.EmailBuilder.campaign: + class: PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder + arguments: + $mailConstructor: '@PhpList\Core\Domain\Messaging\Service\Constructor\CampaignMailContentBuilder' + $googleSenderId: '%messaging.google_sender_id%' + $useAmazonSes: '%messaging.use_amazon_ses%' + $usePrecedenceHeader: '%messaging.use_precedence_header%' + $devVersion: '%app.dev_version%' + $devEmail: '%app.dev_email%' diff --git a/config/services/messenger.yml b/config/services/messenger.yml index 3c8f27bb..5a3a1f26 100644 --- a/config/services/messenger.yml +++ b/config/services/messenger.yml @@ -31,6 +31,11 @@ services: PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler: autowire: true + autoconfigure: true + arguments: + $campaignEmailBuilder: '@Core.EmailBuilder.campaign' + $systemEmailBuilder: '@Core.EmailBuilder.system' + $messageEnvelope: '%app.config.message_from_address%' PhpList\Core\Domain\Subscription\MessageHandler\DynamicTableMessageHandler: autowire: true diff --git a/config/services/parameters.yml b/config/services/parameters.yml index ebf1d99b..18aa6ccf 100644 --- a/config/services/parameters.yml +++ b/config/services/parameters.yml @@ -1,4 +1,9 @@ parameters: + # Flattened parameters for direct DI usage (Symfony does not support dot access into arrays) + app.config.message_from_address: 'news@example.com' + app.config.default_message_age: 15768000 + + # Keep original grouped array for legacy/config-provider usage app.config: message_from_address: 'news@example.com' admin_address: 'admin@example.com' diff --git a/config/services/repositories.yml b/config/services/repositories.yml index ea1f0001..37b31c18 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -22,6 +22,11 @@ services: arguments: - PhpList\Core\Domain\Configuration\Model\EventLog + PhpList\Core\Domain\Configuration\Repository\UrlCacheRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Configuration\Model\UrlCache + PhpList\Core\Domain\Identity\Repository\AdministratorRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository @@ -145,3 +150,13 @@ services: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Messaging\Model\MessageData + + PhpList\Core\Domain\Messaging\Repository\AttachmentRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\Attachment + + PhpList\Core\Domain\Messaging\Repository\MessageAttachmentRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\MessageAttachment diff --git a/config/services/resolvers.yml b/config/services/resolvers.yml index 99c08356..6dfab328 100644 --- a/config/services/resolvers.yml +++ b/config/services/resolvers.yml @@ -13,3 +13,27 @@ services: PhpList\Core\Bounce\Service\BounceActionResolver: arguments: - !tagged_iterator { tag: 'phplist.bounce_action_handler' } + + PhpList\Core\Domain\Configuration\Service\Placeholder\UnsubscribeUrlValueResolver: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\Placeholder\ConfirmationUrlValueResolver: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\Placeholder\PreferencesUrlValueResolver: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\Placeholder\SubscribeUrlValueResolver: + autowire: true + autoconfigure: true + + _instanceof: + PhpList\Core\Domain\Configuration\Service\Placeholder\PlaceholderValueResolverInterface: + tags: ['phplist.placeholder_resolver'] + PhpList\Core\Domain\Configuration\Service\Placeholder\PatternValueResolverInterface: + tags: [ 'phplist.pattern_resolver' ] + PhpList\Core\Domain\Configuration\Service\Placeholder\SupportingPlaceholderResolverInterface: + tags: [ 'phplist.supporting_placeholder_resolver' ] diff --git a/config/services/services.yml b/config/services/services.yml index cf298621..c494c3ea 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -43,6 +43,51 @@ services: autowire: true autoconfigure: true + # Html to Text converter used by mail constructors + PhpList\Core\Domain\Common\Html2Text: + autowire: true + autoconfigure: true + + # Rewrites relative asset URLs in fetched HTML to absolute ones + PhpList\Core\Domain\Common\HtmlUrlRewriter: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Common\PdfGenerator: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\AttachmentAdder: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\UserPersonalizer: + autowire: true + autoconfigure: true + + # External image caching/downloading helper used by TemplateImageEmbedder + 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 + + # Embed images from templates and filesystem into HTML emails + PhpList\Core\Domain\Messaging\Service\TemplateImageEmbedder: + autowire: true + autoconfigure: true + arguments: + $documentRoot: '%kernel.project_dir%/public' + # Reuse upload_images_dir for editorImagesDir if a dedicated parameter is absent + $editorImagesDir: '%phplist.upload_images_dir%' + $embedExternalImages: '%messaging.embed_external_images%' + $embedUploadedImages: '%messaging.embed_uploaded_images%' + $uploadImagesDir: '%phplist.upload_images_dir%' + PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer: autowire: true autoconfigure: true @@ -120,9 +165,13 @@ services: autoconfigure: true public: true - PhpList\Core\Domain\Configuration\Service\UserPersonalizer: + PhpList\Core\Domain\Configuration\Service\MessagePlaceholderProcessor: autowire: true autoconfigure: true + arguments: + $placeholderResolvers: !tagged_iterator phplist.placeholder_resolver + $patternResolvers: !tagged_iterator phplist.pattern_resolver + $supportingResolvers: !tagged_iterator phplist.supporting_placeholder_resolver PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder: autowire: true @@ -139,3 +188,28 @@ services: autoconfigure: true arguments: $maxMailSize: '%messaging.max_mail_size%' + + # Loads and normalises message data for campaigns + PhpList\Core\Domain\Messaging\Service\MessageDataLoader: + autowire: true + autoconfigure: true + arguments: + $defaultMessageAge: '%app.config.default_message_age%' + + # Common helpers required by precache/message building + PhpList\Core\Domain\Common\TextParser: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Common\RemotePageFetcher: + autowire: true + autoconfigure: true + + # Pre-caches base message content (HTML/Text/template) for campaigns + PhpList\Core\Domain\Messaging\Service\MessagePrecacheService: + autowire: true + autoconfigure: true + arguments: + $useManualTextPart: '%messaging.use_manual_text_part%' + $uploadImageDir: '%phplist.upload_images_dir%' + $publicSchema: '%phplist.public_schema%' diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf index 40a24785..906de934 100644 --- a/resources/translations/messages.en.xlf +++ b/resources/translations/messages.en.xlf @@ -750,6 +750,50 @@ Thank you. phplist has started sending the campaign with subject %subject% __phplist has started sending the campaign with subject %subject% + + Unsubscribe + __Unsubscribe + + + This link + __This link + + + Confirm + __Confirm + + + Update preferences + __Update preferences + + + Sorry, you are not subscribed to any of our newsletters with this email address. + __Sorry, you are not subscribed to any of our newsletters with this email address. + + + This message contains attachments that can be viewed with a webbrowser + __This message contains attachments that can be viewed with a webbrowser + + + Insufficient memory to add attachment to campaign %campaignId% %tatalSize% - %memLimit% + __Insufficient memory to add attachment to campaign %campaignId% %tatalSize% - %memLimit% + + + Add us to your address book + __Add us to your address book + + + phpList system error + __phpList system error + + + Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be copied to the repository. Check for permissions. + __Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be copied to the repository. Check for permissions. + + + failed to open attachment (%remoteFile%) to add to campaign %campaignId% + __failed to open attachment (%remoteFile%) to add to campaign %campaignId% + diff --git a/src/Domain/Common/OnceCacheGuard.php b/src/Domain/Common/OnceCacheGuard.php new file mode 100644 index 00000000..f9801450 --- /dev/null +++ b/src/Domain/Common/OnceCacheGuard.php @@ -0,0 +1,26 @@ +cache->has($key)) { + return false; + } + // mark as seen + $this->cache->set($key, true, $ttlSeconds); + + return true; + } +} diff --git a/src/Domain/Common/PdfGenerator.php b/src/Domain/Common/PdfGenerator.php new file mode 100644 index 00000000..4abfd4a7 --- /dev/null +++ b/src/Domain/Common/PdfGenerator.php @@ -0,0 +1,21 @@ +SetCreator('phpList'); + $pdf->AddPage(); + $pdf->SetFont('Arial', '', 12); + $pdf->Write(6, $text); + + return $pdf->Output('','S'); + } +} diff --git a/src/Domain/Common/RemotePageFetcher.php b/src/Domain/Common/RemotePageFetcher.php index 0d37b76e..74d31a7a 100644 --- a/src/Domain/Common/RemotePageFetcher.php +++ b/src/Domain/Common/RemotePageFetcher.php @@ -28,7 +28,7 @@ public function __construct( ) { } - public function __invoke(string $url, array $userData): string + public function __invoke(string $url, array $userData): ?string { $url = $this->prepareUrl($url, $userData); @@ -78,7 +78,7 @@ public function __invoke(string $url, array $userData): string return $content; } - private function fetchUrlDirect(string $url): string + private function fetchUrlDirect(string $url): ?string { try { $response = $this->httpClient->request('GET', $url, [ @@ -88,7 +88,7 @@ private function fetchUrlDirect(string $url): string return $response->getContent(false); } catch (Throwable $e) { - return ''; + return null; } } diff --git a/src/Domain/Configuration/Model/ConfigOption.php b/src/Domain/Configuration/Model/ConfigOption.php index 9222a24b..5ce47d1c 100644 --- a/src/Domain/Configuration/Model/ConfigOption.php +++ b/src/Domain/Configuration/Model/ConfigOption.php @@ -10,9 +10,12 @@ enum ConfigOption: string case SubscribeMessage = 'subscribemessage'; case SubscribeEmailSubject = 'subscribesubject'; case UnsubscribeUrl = 'unsubscribeurl'; + case BlacklistUrl = 'blacklisturl'; + case ForwardUrl = 'forwardurl'; case ConfirmationUrl = 'confirmationurl'; case PreferencesUrl = 'preferencesurl'; case SubscribeUrl = 'subscribeurl'; + // todo: check where is this defined case Domain = 'domain'; case Website = 'website'; case MessageFromAddress = 'message_from_address'; @@ -33,4 +36,9 @@ enum ConfigOption: string case PoweredByText = 'PoweredByText'; case UploadImageRoot = 'uploadimageroot'; case PageRoot = 'pageroot'; + case OrganisationName = 'organisation_name'; + case VCardUrl = 'vcardurl'; + case HtmlEmailStyle = 'html_email_style'; + case AlwaysSendTextDomains = 'alwayssendtextto'; + case ReportAddress = 'report_address'; } diff --git a/src/Domain/Configuration/Model/Dto/PlaceholderContext.php b/src/Domain/Configuration/Model/Dto/PlaceholderContext.php new file mode 100644 index 00000000..70461bc3 --- /dev/null +++ b/src/Domain/Configuration/Model/Dto/PlaceholderContext.php @@ -0,0 +1,46 @@ +format === OutputFormat::Html; + } + + public function isText(): bool + { + return $this->format === OutputFormat::Text; + } + + public function forwardedBy(): ?string + { + return $this->forwardedBy; + } + + public function messageId(): ?int + { + return $this->messageId; + } + + public function getUser(): Subscriber + { + return $this->user; + } +} diff --git a/src/Domain/Configuration/Model/OutputFormat.php b/src/Domain/Configuration/Model/OutputFormat.php new file mode 100644 index 00000000..c413d3ed --- /dev/null +++ b/src/Domain/Configuration/Model/OutputFormat.php @@ -0,0 +1,11 @@ +withQueryParam($baseUrl, 'uid', $uid); + } + + public function withEmail(string $baseUrl, string $email): string + { + return $this->withQueryParam($baseUrl, 'email', $email); + } + + private function withQueryParam(string $baseUrl, string $paramName, string $paramValue): string { $parts = parse_url($baseUrl) ?: []; $query = []; if (!empty($parts['query'])) { parse_str($parts['query'], $query); } - $query['uid'] = $uid; + $query[$paramName] = $paramValue; $parts['query'] = http_build_query($query); diff --git a/src/Domain/Configuration/Service/MessagePlaceholderProcessor.php b/src/Domain/Configuration/Service/MessagePlaceholderProcessor.php new file mode 100644 index 00000000..7da67ccb --- /dev/null +++ b/src/Domain/Configuration/Service/MessagePlaceholderProcessor.php @@ -0,0 +1,125 @@ + */ + private readonly iterable $placeholderResolvers, + /** @var iterable */ + private readonly iterable $patternResolvers, + /** @var iterable */ + private readonly iterable $supportingResolvers, + #[Autowire('messaging.always_add_user_track')] private readonly bool $alwaysAddUserTrack, + ) { + } + + public function process( + string $value, + Subscriber $user, + OutputFormat $format, + MessagePrecacheDto $messagePrecacheDto, + ?int $campaignId = null, + ?string $forwardedBy = null, + ): string { + if (!strpos($value, '[FOOTER]')) { + $sep = $format === OutputFormat::Html ? '
' : "\n\n"; + $value = $this->appendContent($value, $sep . '[FOOTER]'); + } + + if (!strpos($value, '[SIGNATURE]')) { + $sep = $format === OutputFormat::Html ? ' ' : "\n"; + $value = $this->appendContent($value, $sep . '[SIGNATURE]'); + } + + if ($this->alwaysAddUserTrack && $format === OutputFormat::Html && !strpos($value, '[USERTRACK]')) { + $value = $this->appendContent($value, '[USERTRACK]'); + } + + $resolver = new PlaceholderResolver(); + $resolver->register('EMAIL', fn(PlaceholderContext $ctx) => $ctx->user->getEmail()); + $resolver->register('FORWARDEDBY', fn(PlaceholderContext $ctx) => $ctx->forwardedBy()); + $resolver->register('MESSAGEID', fn(PlaceholderContext $ctx) => $ctx->messageId()); + $resolver->register('FORWARDFORM', fn(PlaceholderContext $ctx) => ''); + $resolver->register('USERID', fn(PlaceholderContext $ctx) => $ctx->user->getUniqueId()); + $resolver->register( + name: 'WEBSITE', + resolver: fn(PlaceholderContext $ctx) => $this->config->getValue(ConfigOption::Website) ?? '' + ); + $resolver->register( + name: 'DOMAIN', + resolver: fn(PlaceholderContext $ctx) => $this->config->getValue(ConfigOption::Domain) ?? '' + ); + $resolver->register( + name: 'ORGANIZATION_NAME', + resolver: fn(PlaceholderContext $ctx) => $this->config->getValue(ConfigOption::OrganisationName) ?? '' + ); + $resolver->register( + name: 'CONTACTURL', + resolver: fn(PlaceholderContext $ctx) => htmlspecialchars( + $this->config->getValue(ConfigOption::VCardUrl) ?? '' + ) + ); + + foreach ($this->placeholderResolvers as $placeholderResolver) { + $resolver->register($placeholderResolver->name(), $placeholderResolver); + } + + foreach ($this->patternResolvers as $patternResolver) { + $resolver->registerPattern($patternResolver->pattern(), $patternResolver); + } + + foreach ($this->supportingResolvers as $supportingResolver) { + $resolver->registerSupporting($supportingResolver); + } + + $userAttributes = $this->attributesRepository->getForSubscriber($user); + foreach ($userAttributes as $userAttribute) { + $resolver->register( + name: strtoupper($userAttribute->getAttributeDefinition()->getName()), + resolver: fn(PlaceholderContext $ctx) => $this->attributeValueResolver->resolve($userAttribute) + ); + } + + return $resolver->resolve( + value: $value, + context: new PlaceholderContext( + user: $user, + format: $format, + messagePrecacheDto: $messagePrecacheDto, + forwardedBy: $forwardedBy, + messageId: $campaignId, + ) + ); + } + + private function appendContent(string $message, string $append): string + { + if (preg_match('##i', $message)) { + $message = preg_replace('##i', $append . '', $message); + } else { + $message .= $append; + } + + return $message; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php b/src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php new file mode 100644 index 00000000..fbe2b545 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php @@ -0,0 +1,35 @@ +config->getValue(ConfigOption::UnsubscribeUrl) ?? ''; + $url = $this->urlBuilder->withEmail($base, $ctx->getUser()->getEmail()); + + if ($ctx->isHtml()) { + return htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + return $url; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php b/src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php new file mode 100644 index 00000000..6973e6ff --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php @@ -0,0 +1,41 @@ +config->getValue(ConfigOption::BlacklistUrl) ?? ''; + $url = $this->urlBuilder->withEmail($base, $ctx->getUser()->getEmail()); + + if ($ctx->isHtml()) { + $label = $this->translator->trans('Unsubscribe'); + $safeLabel = htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $safeUrl = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return '' . $safeLabel . ''; + } + + return $url; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php new file mode 100644 index 00000000..01571a64 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php @@ -0,0 +1,33 @@ +config->getValue(ConfigOption::SubscribeUrl) ?? ''; + $sep = !str_contains($url, '?') ? '?' : '&'; + + if ($ctx->isHtml()) { + return sprintf('%s%suid=%s', $url, htmlspecialchars($sep), $ctx->getUser()->getUniqueId()); + } + + return sprintf('%s%suid=%s', $url, $sep, $ctx->getUser()->getUniqueId()); + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/ContactValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ContactValueResolver.php new file mode 100644 index 00000000..d4a5cdfa --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ContactValueResolver.php @@ -0,0 +1,38 @@ +config->getValue(ConfigOption::VCardUrl); + $label = $this->translator->trans('Add us to your address book'); + + if ($ctx->isHtml()) { + $href = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $text = htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return sprintf('%s', $href, $text); + } + + return $label !== '' ? ($label . ': ' . $url) : $url; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php b/src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php new file mode 100644 index 00000000..d60a5ab8 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php @@ -0,0 +1,32 @@ +forwardAlternativeContent && $ctx->messagePrecacheDto) { + return stripslashes($ctx->messagePrecacheDto->footer); + } + + return $this->config->getValue(ConfigOption::ForwardFooter); + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolver.php new file mode 100644 index 00000000..000cc159 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolver.php @@ -0,0 +1,66 @@ +translator->trans('This link'); + + if (str_contains($newForward, ':')) { + [$forwardMessage, $label] = explode(':', $newForward, 2); + } else { + $forwardMessage = $newForward; + } + + $forwardMessage = trim($forwardMessage); + if ($forwardMessage === '') { + return ''; + } + + $messageId = (int) $forwardMessage; + + $url = $this->config->getValue(ConfigOption::ForwardUrl) ?? ''; + $sep = !str_contains($url, '?') ? '?' : '&'; + $uid = $ctx->getUser()->getUniqueId(); + + if ($ctx->isHtml()) { + $forwardUrl = sprintf('%s%suid=%s&mid=%d', + htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + htmlspecialchars($sep, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + $uid, + $messageId + ); + + return sprintf( + '%s', + $forwardUrl, + htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + ); + } + + $forwardUrl = sprintf('%s%suid=%s&mid=%d', $url, $sep, $uid, $messageId); + + return $label . ' ' . $forwardUrl; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php new file mode 100644 index 00000000..6774205b --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ForwardUrlValueResolver.php @@ -0,0 +1,39 @@ +config->getValue(ConfigOption::ForwardUrl) ?? ''; + $sep = !str_contains($url, '?') ? '?' : '&'; + + if ($ctx->isHtml()) { + return sprintf( + '%s%suid=%s&mid=%d', + htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + htmlspecialchars($sep), + $ctx->getUser()->getUniqueId(), + $ctx->messageId(), + ); + } + + return sprintf('%s%suid=%s&mid=%d ', $url, $sep, $ctx->getUser()->getUniqueId(), $ctx->messageId()); + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php new file mode 100644 index 00000000..ce60b114 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php @@ -0,0 +1,46 @@ +config->getValue(ConfigOption::ForwardUrl) ?? ''; + $sep = !str_contains($url, '?') ? '?' : '&'; + + if ($ctx->isHtml()) { + $label = $this->translator->trans('This link'); + + return '' + . htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . ' '; + } + + return sprintf('%s%suid=%s&mid=%d ', $url, $sep, $ctx->getUser()->getUniqueId(), $ctx->messageId()); + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolver.php b/src/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolver.php new file mode 100644 index 00000000..44780d6b --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/JumpoffUrlValueResolver.php @@ -0,0 +1,13 @@ +config->getValue(ConfigOption::UnsubscribeUrl) ?? ''; + $url = $this->urlBuilder->withUid($base, $ctx->getUser()->getUniqueId()); + + if ($ctx->isHtml()) { + return ''; + } + + return $url . '&jo=1'; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php new file mode 100644 index 00000000..bc2daac5 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php @@ -0,0 +1,47 @@ +subscriberListRepository->getActiveListNamesForSubscriber( + subscriber: $ctx->getUser(), + showPrivate: $this->preferencePageShowPrivateLists + ); + + if ($names === []) { + return $this->translator + ->trans('Sorry, you are not subscribed to any of our newsletters with this email address.'); + } + + $separator = $ctx->isHtml() ? '
' : "\n"; + + if ($ctx->isHtml()) { + $names = array_map( + static fn(string $name) => htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + $names + ); + } + + return implode($separator, $names); + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/PatternValueResolverInterface.php b/src/Domain/Configuration/Service/Placeholder/PatternValueResolverInterface.php new file mode 100644 index 00000000..ad170cde --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/PatternValueResolverInterface.php @@ -0,0 +1,13 @@ +config->getValue(ConfigOption::PreferencesUrl) ?? ''; + $sep = !str_contains($url, '?') ? '?' : '&'; + + if ($ctx->isHtml()) { + return sprintf('%s%suid=%s', $url, htmlspecialchars($sep), $ctx->getUser()->getUniqueId()); + } + + return sprintf('%s%suid=%s', $url, $sep, $ctx->getUser()->getUniqueId()); + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php b/src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php new file mode 100644 index 00000000..199cb644 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php @@ -0,0 +1,45 @@ +config->getValue(ConfigOption::PreferencesUrl) ?? ''; + $sep = !str_contains($url, '?') ? '?' : '&'; + + if ($ctx->isHtml()) { + $label = $this->translator->trans('This link'); + $safeLabel = htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $safeUrl = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return sprintf( + '%s ', + $safeUrl, + htmlspecialchars($sep), + $ctx->getUser()->getUniqueId(), + $safeLabel, + ); + } + + return sprintf('%s%suid=%s', $url, $sep, $ctx->getUser()->getUniqueId()); + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php b/src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php new file mode 100644 index 00000000..33edaaae --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php @@ -0,0 +1,39 @@ +isHtml()) { + if ($this->emailTextCredits) { + return $this->config->getValue(ConfigOption::PoweredByText); + } + + return preg_replace( + '/src=".*power-phplist.png"/', + 'src="powerphplist.png"', + $this->config->getValue(ConfigOption::PoweredByImage) + ); + } + + return "\n\n-- powered by phpList, www.phplist.com --\n\n"; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolver.php b/src/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolver.php new file mode 100644 index 00000000..9ab3c151 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/SubscribeUrlValueResolver.php @@ -0,0 +1,32 @@ +config->getValue(ConfigOption::SubscribeUrl) ?? ''; + + if ($ctx->isHtml()) { + return htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + return $url; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/SubscribeValueResolver.php b/src/Domain/Configuration/Service/Placeholder/SubscribeValueResolver.php new file mode 100644 index 00000000..53f5cfb4 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/SubscribeValueResolver.php @@ -0,0 +1,38 @@ +config->getValue(ConfigOption::SubscribeUrl) ?? ''; + + if ($ctx->isHtml()) { + $label = $this->translator->trans('This link'); + $safeLabel = htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $safeUrl = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return '' . $safeLabel . ''; + } + + return $url; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/SupportingPlaceholderResolverInterface.php b/src/Domain/Configuration/Service/Placeholder/SupportingPlaceholderResolverInterface.php new file mode 100644 index 00000000..72004acd --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/SupportingPlaceholderResolverInterface.php @@ -0,0 +1,13 @@ +config->getValue(ConfigOption::UnsubscribeUrl) ?? ''; + $url = $this->urlBuilder->withUid($base, $ctx->getUser()->getUniqueId()); + + if ($ctx->isHtml()) { + return htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + return $url; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php b/src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php new file mode 100644 index 00000000..26ec462d --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php @@ -0,0 +1,45 @@ +config->getValue(ConfigOption::UnsubscribeUrl) ?? ''; + if ($ctx->forwardedBy()) { + //0013076: Problem found during testing: message part must be parsed correctly as well. + $base = $this->config->getValue(ConfigOption::BlacklistUrl) ?? ''; + } + $url = $this->urlBuilder->withUid($base, $ctx->getUser()->getUniqueId()); + + if ($ctx->isHtml()) { + $label = $this->translator->trans('Unsubscribe'); + $safeLabel = htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $safeUrl = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return '' . $safeLabel . ''; + } + + return $url; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/UserDataSupportingResolver.php b/src/Domain/Configuration/Service/Placeholder/UserDataSupportingResolver.php new file mode 100644 index 00000000..36b3beb4 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/UserDataSupportingResolver.php @@ -0,0 +1,50 @@ +supportedKeys); + } + + public function resolve(string $key, PlaceholderContext $ctx): ?string + { + $canon = strtoupper($key); + $data = $this->subscriberRepository->getDataById($ctx->getUser()->getId()); + + foreach ($data as $k => $value) { + if (strtoupper((string) $k) !== $canon) { + continue; + } + if ($value === null || $value === '') { + return null; + } + return is_scalar($value) ? (string) $value : null; + } + return null; + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php b/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php new file mode 100644 index 00000000..23484039 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php @@ -0,0 +1,40 @@ +config->getValue(ConfigOption::Domain) ?? $this->restApiDomain; + + if ($ctx->isText()) { + return ''; + } + + return ''; + } +} diff --git a/src/Domain/Configuration/Service/PlaceholderResolver.php b/src/Domain/Configuration/Service/PlaceholderResolver.php index 3a0a3464..9b6aa3cd 100644 --- a/src/Domain/Configuration/Service/PlaceholderResolver.php +++ b/src/Domain/Configuration/Service/PlaceholderResolver.php @@ -4,30 +4,97 @@ namespace PhpList\Core\Domain\Configuration\Service; +use PhpList\Core\Domain\Configuration\Model\Dto\PlaceholderContext; +use PhpList\Core\Domain\Configuration\Service\Placeholder\SupportingPlaceholderResolverInterface; + class PlaceholderResolver { - /** @var array */ - private array $providers = []; + /** @var array */ + private array $resolvers = []; + + /** @var array */ + private array $patternResolvers = []; + + /** @var SupportingPlaceholderResolverInterface[] */ + private array $supportingResolvers = []; + + public function register(string $name, callable $resolver): void + { + $name = $this->normalizePlaceholderKey($name); + $this->resolvers[strtoupper($name)] = $resolver; + } - public function register(string $token, callable $provider): void + public function registerPattern(string $pattern, callable $resolver): void { - // tokens like [UNSUBSCRIBEURL] (case-insensitive) - $this->providers[strtoupper($token)] = $provider; + $this->patternResolvers[] = ['pattern' => $pattern, 'resolver' => $resolver]; } - public function resolve(?string $input): ?string + public function registerSupporting(SupportingPlaceholderResolverInterface $resolver): void { - if ($input === null || $input === '') { - return $input; + $this->supportingResolvers[] = $resolver; + } + + public function resolve(string $value, PlaceholderContext $context): string + { + if (!str_contains($value, '[')) { + return $value; + } + + foreach ($this->patternResolvers as $r) { + $value = preg_replace_callback( + $r['pattern'], + fn(array $m) => (string) ($r['resolver'])($context, $m), + $value + ); } - // Replace [TOKEN] (case-insensitive) - return preg_replace_callback('/\[(\w+)\]/i', function ($map) { - $key = strtoupper($map[1]); - if (!isset($this->providers[$key])) { - return $map[0]; - } - return (string) ($this->providers[$key])(); - }, $input); + return preg_replace_callback( + '/\[([^\]%%]+)(?:%%([^\]]+))?\]/i', + function (array $matches) use ($context) { + $rawKey = $matches[1]; + $default = $matches[2] ?? null; + + $keyNormalized = $this->normalizePlaceholderKey($rawKey); + $canon = strtoupper($this->normalizePlaceholderKey($rawKey)); + + // 1) Exact resolver (system placeholders) + if (isset($this->resolvers[$canon])) { + $resolved = (string) ($this->resolvers[$canon])($context); + + if ($default !== null && $resolved === '') { + return $default; + } + return $resolved; + } + + // 2) Supporting resolvers (userdata, attributes, etc.) + foreach ($this->supportingResolvers as $resolver) { + if (!$resolver->supports($keyNormalized, $context) && !$resolver->supports($canon, $context)) { + continue; + } + + $resolved = $resolver->resolve($keyNormalized, $context); + $resolved = $resolved ?? ''; + + if ($default !== null && $resolved === '') { + return $default; + } + return $resolved; + } + + // 3) if there is a %%default, use it; otherwise keep placeholder unchanged + return $default ?? $matches[0]; }, + $value + ); + } + + private function normalizePlaceholderKey(string $rawKey): string + { + $key = trim($rawKey); + $key = html_entity_decode($key, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $key = str_ireplace("\xC2\xA0", ' ', $key); + $key = str_ireplace(' ', ' ', $key); + + return preg_replace('/\s+/u', ' ', $key) ?? $key; } } diff --git a/src/Domain/Configuration/Service/UserPersonalizer.php b/src/Domain/Configuration/Service/UserPersonalizer.php index 7aedf1d8..efd865fb 100644 --- a/src/Domain/Configuration/Service/UserPersonalizer.php +++ b/src/Domain/Configuration/Service/UserPersonalizer.php @@ -5,6 +5,12 @@ namespace PhpList\Core\Domain\Configuration\Service; use PhpList\Core\Domain\Configuration\Model\ConfigOption; +use PhpList\Core\Domain\Configuration\Model\Dto\PlaceholderContext; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; +use PhpList\Core\Domain\Configuration\Service\Placeholder\ConfirmationUrlValueResolver; +use PhpList\Core\Domain\Configuration\Service\Placeholder\PreferencesUrlValueResolver; +use PhpList\Core\Domain\Configuration\Service\Placeholder\SubscribeUrlValueResolver; +use PhpList\Core\Domain\Configuration\Service\Placeholder\UnsubscribeUrlValueResolver; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; @@ -12,18 +18,19 @@ class UserPersonalizer { - private const PHP_SPACE = ' '; - public function __construct( private readonly ConfigProvider $config, - private readonly LegacyUrlBuilder $urlBuilder, private readonly SubscriberRepository $subscriberRepository, private readonly SubscriberAttributeValueRepository $attributesRepository, - private readonly AttributeValueResolver $attributeValueResolver + private readonly AttributeValueResolver $attributeValueResolver, + private readonly UnsubscribeUrlValueResolver $unsubscribeUrlValueResolver, + private readonly ConfirmationUrlValueResolver $confirmationUrlValueResolver, + private readonly PreferencesUrlValueResolver $preferencesUrlValueResolver, + private readonly SubscribeUrlValueResolver $subscribeUrlValueResolver, ) { } - public function personalize(string $value, string $email): string + public function personalize(string $value, string $email, OutputFormat $format): string { $user = $this->subscriberRepository->findOneByEmail($email); if (!$user) { @@ -31,39 +38,25 @@ public function personalize(string $value, string $email): string } $resolver = new PlaceholderResolver(); - $resolver->register('EMAIL', fn() => $user->getEmail()); - - $resolver->register('UNSUBSCRIBEURL', function () use ($user) { - $base = $this->config->getValue(ConfigOption::UnsubscribeUrl) ?? ''; - return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE; - }); - - $resolver->register('CONFIRMATIONURL', function () use ($user) { - $base = $this->config->getValue(ConfigOption::ConfirmationUrl) ?? ''; - return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE; - }); - $resolver->register('PREFERENCESURL', function () use ($user) { - $base = $this->config->getValue(ConfigOption::PreferencesUrl) ?? ''; - return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE; - }); - - $resolver->register( - 'SUBSCRIBEURL', - fn() => ($this->config->getValue(ConfigOption::SubscribeUrl) ?? '') . self::PHP_SPACE - ); - $resolver->register('DOMAIN', fn() => $this->config->getValue(ConfigOption::Domain) ?? ''); - $resolver->register('WEBSITE', fn() => $this->config->getValue(ConfigOption::Website) ?? ''); + $resolver->register('EMAIL', fn(PlaceholderContext $ctx) => $ctx->user->getEmail()); + $resolver->register($this->unsubscribeUrlValueResolver->name(), $this->unsubscribeUrlValueResolver); + $resolver->register($this->confirmationUrlValueResolver->name(), $this->confirmationUrlValueResolver); + $resolver->register($this->preferencesUrlValueResolver->name(), $this->preferencesUrlValueResolver); + $resolver->register($this->subscribeUrlValueResolver->name(), $this->subscribeUrlValueResolver); + $resolver->register('DOMAIN', fn(PlaceholderContext $ctx) => $this->config->getValue(ConfigOption::Domain) ?? ''); + $resolver->register('WEBSITE', fn(PlaceholderContext $ctx) => $this->config->getValue(ConfigOption::Website) ?? ''); $userAttributes = $this->attributesRepository->getForSubscriber($user); foreach ($userAttributes as $userAttribute) { $resolver->register( strtoupper($userAttribute->getAttributeDefinition()->getName()), - fn() => $this->attributeValueResolver->resolve($userAttribute) + fn(PlaceholderContext $ctx) => $this->attributeValueResolver->resolve($userAttribute) ); } - $out = $resolver->resolve($value); - - return (string) $out; + return $resolver->resolve( + value: $value, + context: new PlaceholderContext(user: $user, format: $format) + ); } } diff --git a/src/Domain/Messaging/Exception/AttachmentCopyException.php b/src/Domain/Messaging/Exception/AttachmentCopyException.php new file mode 100644 index 00000000..dd56d73d --- /dev/null +++ b/src/Domain/Messaging/Exception/AttachmentCopyException.php @@ -0,0 +1,12 @@ +emailService = $emailService; } /** diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php index 35b4c841..c610689f 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php @@ -8,7 +8,9 @@ use DateTimeImmutable; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; -use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; +use PhpList\Core\Domain\Configuration\Model\ConfigOption; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; +use PhpList\Core\Domain\Messaging\Exception\AttachmentCopyException; use PhpList\Core\Domain\Messaging\Exception\MessageCacheMissingException; use PhpList\Core\Domain\Messaging\Exception\MessageSizeLimitExceededException; use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage; @@ -63,11 +65,12 @@ public function __construct( private readonly SubscriberHistoryManager $subscriberHistoryManager, private readonly MessageRepository $messageRepository, private readonly MessagePrecacheService $precacheService, - private readonly UserPersonalizer $userPersonalizer, private readonly MessageDataLoader $messageDataLoader, - private readonly EmailBuilder $emailBuilder, + private readonly EmailBuilder $systemEmailBuilder, + private readonly EmailBuilder $campaignEmailBuilder, private readonly MailSizeChecker $mailSizeChecker, private readonly string $messageEnvelope, + private readonly ConfigProvider $configProvider, ) { } @@ -194,20 +197,23 @@ private function handleEmailSending( UserMessage $userMessage, MessagePrecacheDto $precachedContent, ): void { + // todo: check at which point link tracking should be applied (maybe after constructing ful text?) $processed = $this->messagePreparator->processMessageLinks( - $campaign->getId(), - $precachedContent, - $subscriber + campaignId: $campaign->getId(), + cachedMessageDto: $precachedContent, + subscriber: $subscriber ); - $processed->textContent = $this->userPersonalizer->personalize( - $processed->textContent, - $subscriber->getEmail(), - ); - $processed->footer = $this->userPersonalizer->personalize($processed->footer, $subscriber->getEmail()); try { - $email = $this->rateLimitedCampaignMailer->composeEmail($campaign, $subscriber, $processed); - $this->mailer->send($email); + $email = $this->campaignEmailBuilder->buildPhplistEmail( + messageId: $campaign->getId(), + data: $processed, + skipBlacklistCheck: false, + inBlast: true, + ); + $email = $this->campaignEmailBuilder->applyCampaignHeaders(email: $email, subscriber: $subscriber); + + $this->rateLimitedCampaignMailer->send($email); ($this->mailSizeChecker)($campaign, $email, $subscriber->hasHtmlEmail()); $this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent); } catch (MessageSizeLimitExceededException $e) { @@ -215,6 +221,29 @@ private function handleEmailSending( $this->updateMessageStatus($campaign, MessageStatus::Suspended); $this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent); + throw $e; + } catch (AttachmentCopyException $e) { + // stop after the first message if size is exceeded + $this->updateMessageStatus($campaign, MessageStatus::Suspended); + $this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent); + + $data = new MessagePrecacheDto(); + $data->to = $this->configProvider->getValue(ConfigOption::ReportAddress); + $data->subject = $this->translator->trans('phpList system error'); + $data->content = $this->translator->trans($e->getMessage()); + + $email = $this->systemEmailBuilder->buildPhplistEmail( + messageId: $campaign->getId(), + data: $data, + inBlast: false, + ); + + $envelope = new Envelope( + sender: new Address($this->messageEnvelope, 'PHPList'), + recipients: [new Address($email->getTo()[0]->getAddress())], + ); + $this->mailer->send(message: $email, envelope: $envelope); + throw $e; } catch (Throwable $e) { $this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent); @@ -233,14 +262,17 @@ private function handleAdminNotifications(Message $campaign, array $loadedMessag if (!empty($loadedMessageData['notify_start']) && !isset($loadedMessageData['start_notified'])) { $notifications = explode(',', $loadedMessageData['notify_start']); foreach ($notifications as $notification) { - $email = $this->emailBuilder->buildPhplistEmail( + $data = new MessagePrecacheDto(); + $data->to = $notification; + $data->subject = $this->translator->trans('Campaign started'); + $data->content = $this->translator->trans( + 'phplist has started sending the campaign with subject %subject%', + ['%subject%' => $loadedMessageData['subject']] + ); + + $email = $this->systemEmailBuilder->buildPhplistEmail( messageId: $campaign->getId(), - to: $notification, - subject: $this->translator->trans('Campaign started'), - message: $this->translator->trans( - 'phplist has started sending the campaign with subject %subject%', - ['%subject%' => $loadedMessageData['subject']] - ), + data: $data, inBlast: false, ); @@ -301,6 +333,7 @@ private function processSubscribersForCampaign(Message $campaign, array $subscri if ($messagePrecacheDto === null) { throw new MessageCacheMissingException(); } + // todo: maybe catch exception and return false to stop early? $this->handleEmailSending($campaign, $subscriber, $userMessage, $messagePrecacheDto); } diff --git a/src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php b/src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php index 7d2a3096..ceedf35a 100644 --- a/src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php @@ -13,15 +13,11 @@ #[AsMessageHandler] class PasswordResetMessageHandler { - private EmailService $emailService; - private TranslatorInterface $translator; - private string $passwordResetUrl; - - public function __construct(EmailService $emailService, TranslatorInterface $translator, string $passwordResetUrl) - { - $this->emailService = $emailService; - $this->translator = $translator; - $this->passwordResetUrl = $passwordResetUrl; + public function __construct( + private readonly EmailService $emailService, + private readonly TranslatorInterface $translator, + private readonly string $passwordResetUrl + ) { } /** diff --git a/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php b/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php index 69ec42cb..4a9e3c07 100644 --- a/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php @@ -16,15 +16,11 @@ #[AsMessageHandler] class SubscriberConfirmationMessageHandler { - private EmailService $emailService; - private TranslatorInterface $translator; - private string $confirmationUrl; - - public function __construct(EmailService $emailService, TranslatorInterface $translator, string $confirmationUrl) - { - $this->emailService = $emailService; - $this->translator = $translator; - $this->confirmationUrl = $confirmationUrl; + public function __construct( + private readonly EmailService $emailService, + private readonly TranslatorInterface $translator, + private readonly string $confirmationUrl + ) { } /** diff --git a/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php b/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php index 6ecb965b..e36a3f50 100644 --- a/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Domain\Messaging\MessageHandler; use PhpList\Core\Domain\Configuration\Model\ConfigOption; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; use PhpList\Core\Domain\Messaging\Message\SubscriptionConfirmationMessage; @@ -20,24 +21,13 @@ #[AsMessageHandler] class SubscriptionConfirmationMessageHandler { - private EmailService $emailService; - private ConfigProvider $configProvider; - private LoggerInterface $logger; - private UserPersonalizer $userPersonalizer; - private SubscriberListRepository $subscriberListRepository; - public function __construct( - EmailService $emailService, - ConfigProvider $configProvider, - LoggerInterface $logger, - UserPersonalizer $userPersonalizer, - SubscriberListRepository $subscriberListRepository, + private readonly EmailService $emailService, + private readonly ConfigProvider $configProvider, + private readonly LoggerInterface $logger, + private readonly UserPersonalizer $userPersonalizer, + private readonly SubscriberListRepository $subscriberListRepository, ) { - $this->emailService = $emailService; - $this->configProvider = $configProvider; - $this->logger = $logger; - $this->userPersonalizer = $userPersonalizer; - $this->subscriberListRepository = $subscriberListRepository; } /** @@ -47,14 +37,19 @@ public function __invoke(SubscriptionConfirmationMessage $message): void { $subject = $this->configProvider->getValue(ConfigOption::SubscribeEmailSubject); $textContent = $this->configProvider->getValue(ConfigOption::SubscribeMessage); - $personalizedTextContent = $this->userPersonalizer->personalize($textContent, $message->getUniqueId()); $listOfLists = $this->getListNames($message->getListIds()); - $replacedTextContent = str_replace('[LISTS]', $listOfLists, $personalizedTextContent); + $replacedTextContent = str_replace('[LISTS]', $listOfLists, $textContent); + + $personalizedTextContent = $this->userPersonalizer->personalize( + value: $replacedTextContent, + email: $message->getEmail(), + format: OutputFormat::Text, + ); $email = (new Email()) ->to($message->getEmail()) ->subject($subject) - ->text($replacedTextContent); + ->text($personalizedTextContent); $this->emailService->sendEmail($email); @@ -63,14 +58,6 @@ public function __invoke(SubscriptionConfirmationMessage $message): void private function getListNames(array $listIds): string { - $listNames = []; - foreach ($listIds as $id) { - $list = $this->subscriberListRepository->find($id); - if ($list) { - $listNames[] = $list->getName(); - } - } - - return implode(', ', $listNames); + return implode(', ', $this->subscriberListRepository->getListNames($listIds)); } } diff --git a/src/Domain/Messaging/Repository/AttachmentRepository.php b/src/Domain/Messaging/Repository/AttachmentRepository.php index 393c8cb1..368ccee0 100644 --- a/src/Domain/Messaging/Repository/AttachmentRepository.php +++ b/src/Domain/Messaging/Repository/AttachmentRepository.php @@ -7,8 +7,31 @@ use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; +use PhpList\Core\Domain\Messaging\Model\Attachment; +use PhpList\Core\Domain\Messaging\Model\MessageAttachment; class AttachmentRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + /** + * @return Attachment[] + */ + public function findAttachmentsForMessage(int $messageId): array + { + return $this->getEntityManager() + ->createQueryBuilder() + ->select('a') + ->from(Attachment::class, 'a') + ->innerJoin( + MessageAttachment::class, + 'ma', + 'WITH', + 'ma.attachmentId = a.id' + ) + ->where('ma.messageId = :messageId') + ->setParameter('messageId', $messageId) + ->getQuery() + ->getResult(); + } } diff --git a/src/Domain/Messaging/Service/AttachmentAdder.php b/src/Domain/Messaging/Service/AttachmentAdder.php new file mode 100644 index 00000000..0f3b0b19 --- /dev/null +++ b/src/Domain/Messaging/Service/AttachmentAdder.php @@ -0,0 +1,173 @@ +attachmentRepository->findAttachmentsForMessage($campaignId); + + if (empty($attachments)) { + return true; + } + + if ($format === OutputFormat::Text) { + $pre = $this->translator->trans('This message contains attachments that can be viewed with a webbrowser'); + $email->text($email->getTextBody() . $pre . ":\n"); + } + + $totalSize = 0; + $memoryLimit = $this->getMemoryLimit(); + + foreach ($attachments as $att) { + $totalSize += $att->getSize(); + // the 3 is roughly the size increase to encode the string + if ($memoryLimit > 0 && (3 * $totalSize) > $memoryLimit) { + $this->eventLogManager->log( + '', + $this->translator->trans( + 'Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit%', + [ + '%campaignId%' => $campaignId, + '%totalSize%' => $totalSize, + '%memLimit%' => $memoryLimit + ] + ) + ); + return false; + } + $key = 'attaching_fail:' . sha1($campaignId . '|' . $att->getRemoteFile()); + + switch ($format) { + case OutputFormat::Html: + $attachmentPath = $this->attachmentRepositoryPath . '/' . $att->getFilename(); + if (is_file($attachmentPath) && filesize($attachmentPath)) { + $fp = fopen($attachmentPath, 'r'); + if ($fp) { + $contents = fread($fp, filesize($attachmentPath)); + fclose($fp); + $email->attachFromPath($contents, basename($att->getRemoteFile()), $att->getMimeType()); + } + } elseif (is_file($att->getRemoteFile()) && filesize($att->getRemoteFile())) { + // handle local filesystem attachments + $fp = fopen($att->getRemoteFile(), 'r'); + if ($fp) { + $contents = fread($fp, filesize($att->getRemoteFile())); + fclose($fp); + $email->attachFromPath($contents, basename($att->getRemoteFile()), $att->getMimeType()); + [$name, $ext] = explode('.', basename($att->getRemoteFile())); + // create a temporary file to make sure to use a unique file name to store with + $newFile = tempnam($this->attachmentRepositoryPath, $name); + $newFile .= '.'.$ext; + $newFile = basename($newFile); + $fd = fopen($this->attachmentRepositoryPath . '/' . $newFile, 'w'); + fwrite($fd, $contents); + fclose($fd); + // check that it was successful + if (filesize($this->attachmentRepositoryPath . '/' . $newFile)) { + $att->setFilename($newFile); + } else { + if ($this->onceCacheGuard->firstTime($key, 3600)) { + $this->eventLogManager->log( + page: '', + entry: 'Unable to make a copy of attachment '.$att->getRemoteFile().' in repository' + ); + $errorMessage = $this->translator->trans( + 'Error, when trying to send campaign %campaignId% the attachment (%remoteFile%)' + . ' could not be copied to the repository. Check for permissions.', + [ + '%campaignId%' => $campaignId, + '%remoteFile%' => $att->getRemoteFile(), + ] + ); + + throw new AttachmentCopyException($errorMessage); + } else { + return false; + } + } + } else { + $this->eventLogManager->log( + page: '', + entry: $this->translator->trans( + 'failed to open attachment (%remoteFile%) to add to campaign %campaignId%', + [ + '%remoteFile%' => $att->getRemoteFile(), + '%campaignId%' => $campaignId, + ] + ) + ); + return false; + } + } else { + //# as above, avoid sending it many times + if ($this->onceCacheGuard->firstTime($key, 3600)) { + $this->eventLogManager->log( + page: '', + entry: $this->translator->trans( + 'Attachment %remoteFile% does not exist', + [ + '%remoteFile%' => $att->getRemoteFile(), + ] + ) + ); + $errorMessage = $this->translator->trans( + 'Error, when trying to send campaign %campaignId% the attachment (%remoteFile%)' + . ' could not be found in the repository.', + [ + '%campaignId%' => $campaignId, + '%remoteFile%' => $att->getRemoteFile(), + ] + ); + throw new AttachmentCopyException($errorMessage); + } + return false; + } + break; + + case OutputFormat::Text: + $viewurl = $GLOBALS['public_scheme'].'://'.$website.$GLOBALS['pageroot'].'/dl.php?id='.$att['id']; + if (!empty($hash)) { + $viewurl .= '&uid='.$hash; + } + $email->append_text($att['description']."\n".$GLOBALS['strLocation'].': '.$viewurl."\n"); + break; + } + } + + return true; + } + + private function getMemoryLimit(): int + { + $val = ini_get('memory_limit'); + sscanf($val, '%f%c', $number, $unit); + + return (int)($number * match (strtolower($unit ?? '')) { + 'g' => 1024 ** 3, + 'm' => 1024 ** 2, + 'k' => 1024, + default => 1, + }); + } +} diff --git a/src/Domain/Messaging/Service/Builder/EmailBuilder.php b/src/Domain/Messaging/Service/Builder/EmailBuilder.php index 87bb9c8f..25108c99 100644 --- a/src/Domain/Messaging/Service/Builder/EmailBuilder.php +++ b/src/Domain/Messaging/Service/Builder/EmailBuilder.php @@ -4,12 +4,19 @@ namespace PhpList\Core\Domain\Messaging\Service\Builder; +use PhpList\Core\Domain\Common\PdfGenerator; use PhpList\Core\Domain\Configuration\Model\ConfigOption; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; +use PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder; use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; +use PhpList\Core\Domain\Messaging\Exception\AttachmentException; use PhpList\Core\Domain\Messaging\Exception\DevEmailNotConfiguredException; -use PhpList\Core\Domain\Messaging\Service\SystemMailConstructor; +use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; +use PhpList\Core\Domain\Messaging\Service\AttachmentAdder; +use PhpList\Core\Domain\Messaging\Service\Constructor\MailContentBuilderInterface; use PhpList\Core\Domain\Messaging\Service\TemplateImageEmbedder; +use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; @@ -26,9 +33,13 @@ public function __construct( private readonly UserBlacklistRepository $blacklistRepository, private readonly SubscriberHistoryManager $subscriberHistoryManager, private readonly SubscriberRepository $subscriberRepository, - private readonly SystemMailConstructor $systemMailConstructor, + private readonly MailContentBuilderInterface $mailConstructor, private readonly TemplateImageEmbedder $templateImageEmbedder, private readonly LoggerInterface $logger, + private readonly ConfigProvider $config, + private readonly LegacyUrlBuilder $urlBuilder, + private readonly PdfGenerator $pdfGenerator, + private readonly AttachmentAdder $attachmentAdder, private readonly string $googleSenderId, private readonly bool $useAmazonSes, private readonly bool $usePrecedenceHeader, @@ -39,17 +50,15 @@ public function __construct( public function buildPhplistEmail( int $messageId, - ?string $to = null, - ?string $subject = null, - ?string $message = null, + MessagePrecacheDto $data, ?bool $skipBlacklistCheck = false, ?bool $inBlast = true, ): ?Email { - if (!$this->validateRecipientAndSubject($to, $subject)) { + if (!$this->validateRecipientAndSubject(to: $data->to, subject: $data->subject)) { return null; } - if (!$this->passesBlacklistCheck($to, $skipBlacklistCheck)) { + if (!$this->passesBlacklistCheck(to: $data->to, skipBlacklistCheck: $skipBlacklistCheck)) { return null; } @@ -58,32 +67,60 @@ public function buildPhplistEmail( // $messageReplyToAddress = $this->configProvider->getValue(ConfigOption::MessageReplyToAddress); // $replyTo = $messageReplyToAddress ?: $fromEmail; - [$destinationEmail, $message] = $this->resolveDestinationEmailAndMessage($to, $message); + $destinationEmail = $this->resolveDestinationEmail($data->to); - [$htmlMessage, $textMessage] = ($this->systemMailConstructor)($message, $subject); + [$htmlMessage, $textMessage] = ($this->mailConstructor)(messagePrecacheDto: $data); $email = $this->createBaseEmail( - $messageId, - $destinationEmail, - $fromEmail, - $fromName, - $subject, - $inBlast + messageId: $messageId, + destinationEmail: $destinationEmail, + fromEmail: $fromEmail, + fromName: $fromName, + subject: $data->subject, + inBlast: $inBlast ); - $this->applyContentAndFormatting($email, $htmlMessage, $textMessage, $messageId); + $this->applyContentAndFormatting( + email: $email, + htmlMessage: $htmlMessage, + textMessage: $textMessage, + messageId: $messageId, + data: $data, + ); + + return $email; + } + + public function applyCampaignHeaders(Email $email, Subscriber $subscriber): Email + { + $preferencesUrl = $this->config->getValue(ConfigOption::PreferencesUrl) ?? ''; + $unsubscribeUrl = $this->config->getValue(ConfigOption::UnsubscribeUrl) ?? ''; + $subscribeUrl = $this->config->getValue(ConfigOption::SubscribeUrl) ?? ''; + $adminAddress = $this->config->getValue(ConfigOption::UnsubscribeUrl) ?? ''; + + $email->getHeaders()->addTextHeader( + 'List-Help', + '<' . $this->urlBuilder->withUid($preferencesUrl, $subscriber->getUniqueId()) . '>' + ); + $email->getHeaders()->addTextHeader( + 'List-Unsubscribe', + '<' . $this->urlBuilder->withUid($unsubscribeUrl, $subscriber->getUniqueId()) . '&jo=1>' + ); + $email->getHeaders()->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'); + $email->getHeaders()->addTextHeader('List-Subscribe', '<'. $subscribeUrl . '>'); + $email->getHeaders()->addTextHeader('List-Owner', ''); return $email; } private function validateRecipientAndSubject(?string $to, ?string $subject): bool { - if (!$to) { + if (!$to || trim($to) === '') { $this->eventLogManager->log('', sprintf('Error: empty To: in message with subject %s to send', $subject)); return false; } - if (!$subject) { + if (!$subject || trim($subject) === '') { $this->eventLogManager->log('', sprintf('Error: empty Subject: in message to send to %s', $to)); return false; @@ -102,7 +139,7 @@ private function validateRecipientAndSubject(?string $to, ?string $subject): boo return true; } - private function passesBlacklistCheck(?string $to, ?bool $skipBlacklistCheck): bool + private function passesBlacklistCheck(string $to, ?bool $skipBlacklistCheck): bool { if (!$skipBlacklistCheck && $this->blacklistRepository->isEmailBlacklisted($to)) { @@ -127,7 +164,7 @@ private function passesBlacklistCheck(?string $to, ?bool $skipBlacklistCheck): b return true; } - private function resolveDestinationEmailAndMessage(?string $to, ?string $message): array + private function resolveDestinationEmail(?string $to): string { $destinationEmail = $to; @@ -138,7 +175,7 @@ private function resolveDestinationEmailAndMessage(?string $to, ?string $message $destinationEmail = $this->devEmail; } - return [$destinationEmail, $message]; + return $destinationEmail; } private function createBaseEmail( @@ -180,7 +217,6 @@ private function createBaseEmail( ) ); - if ($this->devEmail && $destinationEmail !== $this->devEmail) { $email->getHeaders()->addMailboxHeader( 'X-Originally-To', @@ -198,7 +234,6 @@ private function createBaseEmail( private function applyContentAndFormatting(Email $email, $htmlMessage, $textMessage, int $messageId): void { // Word wrapping disabled here to avoid reliance on config provider during content assembly - if (!empty($htmlMessage)) { // Embed/transform images and use the returned HTML content $htmlMessage = ($this->templateImageEmbedder)(html: $htmlMessage, messageId: $messageId); diff --git a/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php b/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php new file mode 100644 index 00000000..8a8c227a --- /dev/null +++ b/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php @@ -0,0 +1,159 @@ +subscriberRepository->findOneByEmail($messagePrecacheDto->to); + $addDefaultStyle = false; + + if ($messagePrecacheDto->userSpecificUrl) { + $userData = $this->subscriberRepository->getDataById($subscriber->getId()); + $this->replaceUserSpecificRemoteContent($messagePrecacheDto, $subscriber, $userData); + } + + $content = $messagePrecacheDto->content; + $hasText = !empty($messagePrecacheDto->textContent); + if ($messagePrecacheDto->htmlFormatted) { + $textContent = $hasText ? $messagePrecacheDto->textContent : ($this->html2Text)($content); + $htmlContent = $content; + } else { + $textContent = $hasText ? $content : $messagePrecacheDto->textContent; + $htmlContent = ($this->textParser)($content); + } + + if ($messagePrecacheDto->template) { + // template used: use only the content of the body element if it is present + if (preg_match('|(.+)|is', $htmlContent, $matches)) { + $htmlContent = $matches[1]; + } + $htmlMessage = str_replace('[CONTENT]', $htmlContent, $messagePrecacheDto->template); + } else { + $htmlMessage = $htmlContent; + $addDefaultStyle = true; + } + if ($messagePrecacheDto->templateText) { + $textMessage = str_replace('[CONTENT]', $textContent, $messagePrecacheDto->templateText); + } else { + $textMessage = $textContent; + } + + $textMessage = $this->placeholderProcessor->process( + value: $textMessage, + user: $subscriber, + format: OutputFormat::Text, + messagePrecacheDto: $messagePrecacheDto, + campaignId: $campaignId, + ); + + $htmlMessage = $this->placeholderProcessor->process( + value: $htmlMessage, + user: $subscriber, + format: OutputFormat::Html, + messagePrecacheDto: $messagePrecacheDto, + campaignId: $campaignId, + ); + + $htmlMessage = $this->ensureHtmlFormating(content: $htmlMessage, addDefaultStyle: $addDefaultStyle); + // todo: add link CLICKTRACK to $htmlMessage + + return [$htmlMessage, $textMessage]; + } + + private function replaceUserSpecificRemoteContent( + MessagePrecacheDto $messagePrecacheDto, + Subscriber $subscriber, + array $userData + ): void { + if (!preg_match_all('/\[URL:(^\s]+)]/i', $messagePrecacheDto->content, $matches, PREG_SET_ORDER)) { + return; + } + + $content = $messagePrecacheDto->content; + foreach ($matches as $match) { + $token = $match[0]; + $rawUrl = $match[1]; + + if (!$rawUrl) { + continue; + } + + $url = preg_match('/^https?:\/\//i', $rawUrl) ? $rawUrl : 'https://' . $rawUrl; + + $remoteContent = ($this->remotePageFetcher)($url, $userData); + + if ($remoteContent === null) { + $this->eventLogManager->log( + '', + sprintf('Error fetching URL: %s to send to %s', $rawUrl, $subscriber->getEmail()) + ); + + throw new RemotePageFetchException(); + } + + $content = str_replace($token, '' . $remoteContent, $content); + } + + $messagePrecacheDto->content = $content; + $messagePrecacheDto->htmlFormatted = strip_tags($content) !== $content; + } + + private function ensureHtmlFormating(string $content, bool $addDefaultStyle): string + { + if (!preg_match('##ims', $content)) { + $content = '' . $content . ''; + } + if (!preg_match('##ims', $content)) { + $defaultStyle = $this->configProvider->getValue(ConfigOption::HtmlEmailStyle); + + if (!$addDefaultStyle) { + $defaultStyle = ''; + } + $content = ' + + + ' . $defaultStyle . '' . $content; + } + if (!preg_match('##ims', $content)) { + $content = '' . $content . ''; + } + + //# remove trailing code after + $content = preg_replace('#.*#msi', '', $content); + + //# the editor sometimes places

and

around the URL + $content = str_ireplace('

', '', $content); + } +} diff --git a/src/Domain/Messaging/Service/Constructor/MailContentBuilderInterface.php b/src/Domain/Messaging/Service/Constructor/MailContentBuilderInterface.php new file mode 100644 index 00000000..024b86d7 --- /dev/null +++ b/src/Domain/Messaging/Service/Constructor/MailContentBuilderInterface.php @@ -0,0 +1,19 @@ +poweredByText = $configProvider->getValue(ConfigOption::PoweredByText); } - public function __invoke($message, string $subject = ''): array + public function __invoke(MessagePrecacheDto $messagePrecacheDto, ?int $campaignId = null): array { - [$htmlMessage, $textMessage] = $this->buildMessageBodies($message); + [$htmlMessage, $textMessage] = $this->buildMessageBodies($messagePrecacheDto->content); $htmlContent = $htmlMessage; $textContent = $textMessage; @@ -38,7 +39,7 @@ public function __invoke($message, string $subject = ''): array $htmlTemplate = stripslashes($template->getContent()); $textTemplate = stripslashes($template->getText()); $htmlContent = str_replace('[CONTENT]', $htmlMessage, $htmlTemplate); - $htmlContent = str_replace('[SUBJECT]', $subject, $htmlContent); + $htmlContent = str_replace('[SUBJECT]', $messagePrecacheDto->subject, $htmlContent); $htmlContent = str_replace('[FOOTER]', '', $htmlContent); if (!$this->poweredByPhplist) { $phpListPowered = preg_replace( @@ -58,7 +59,7 @@ public function __invoke($message, string $subject = ''): array } $htmlContent = $this->templateImageManager->parseLogoPlaceholders($htmlContent); $textContent = str_replace('[CONTENT]', $textMessage, $textTemplate); - $textContent = str_replace('[SUBJECT]', $subject, $textContent); + $textContent = str_replace('[SUBJECT]', $messagePrecacheDto->subject, $textContent); $textContent = str_replace('[FOOTER]', '', $textContent); $phpListPowered = trim(($this->html2Text)($this->poweredByText)); if (str_contains($textContent, '[SIGNATURE]')) { @@ -72,7 +73,7 @@ public function __invoke($message, string $subject = ''): array return [$htmlContent, $textContent]; } - private function buildMessageBodies($message): array + private function buildMessageBodies(string $message): array { $hasHTML = strip_tags($message) !== $message; diff --git a/src/Domain/Messaging/Service/MessagePrecacheService.php b/src/Domain/Messaging/Service/MessagePrecacheService.php index 2a0f6b28..39caaab5 100644 --- a/src/Domain/Messaging/Service/MessagePrecacheService.php +++ b/src/Domain/Messaging/Service/MessagePrecacheService.php @@ -68,10 +68,8 @@ public function precacheMessage(Message $campaign, $loadedMessageData, ?bool $fo //# but that has quite some impact on speed. So check if that's the case and apply $messagePrecacheDto->userSpecificUrl = preg_match('/\[.+\]/', $loadedMessageData['sendurl']); - if (!$messagePrecacheDto->userSpecificUrl) { - if (!$this->applyRemoteContentIfPresent($messagePrecacheDto, $loadedMessageData)) { - return false; - } + if (!$this->applyRemoteContentIfPresent($messagePrecacheDto, $loadedMessageData)) { + return false; } $messagePrecacheDto->googleTrack = $loadedMessageData['google_track']; @@ -181,27 +179,31 @@ private function applyTemplate(MessagePrecacheDto $messagePrecacheDto, $loadedMe private function applyRemoteContentIfPresent(MessagePrecacheDto $messagePrecacheDto, $loadedMessageData): bool { - if (preg_match('/\[URL:([^\s]+)\]/i', $messagePrecacheDto->content, $regs)) { - $remoteContent = ($this->remotePageFetcher)($regs[1], []); - - if ($remoteContent) { - $messagePrecacheDto->content = str_replace($regs[0], $remoteContent, $messagePrecacheDto->content); - $messagePrecacheDto->htmlFormatted = $this->isHtml($remoteContent); - - //# 17086 - disregard any template settings when we have a valid remote URL - $messagePrecacheDto->template = null; - $messagePrecacheDto->templateText = null; - $messagePrecacheDto->templateId = null; - } else { - $this->eventLogManager->log( - page: 'unknown page', - entry: 'Error fetching URL: '.$loadedMessageData['sendurl'].' cannot proceed', - ); - - return false; - } + if ( + $messagePrecacheDto->userSpecificUrl + || !preg_match('/\[URL:([^\s]+)\]/i', $messagePrecacheDto->content, $regs) + ) { + return true; + } + + $remoteContent = ($this->remotePageFetcher)($regs[1], []); + if (!$remoteContent) { + $this->eventLogManager->log( + page: 'unknown page', + entry: 'Error fetching URL: ' . $loadedMessageData['sendurl'] . ' cannot proceed', + ); + + return false; } + $messagePrecacheDto->content = str_replace($regs[0], $remoteContent, $messagePrecacheDto->content); + $messagePrecacheDto->htmlFormatted = $this->isHtml($remoteContent); + + //# 17086 - disregard any template settings when we have a valid remote URL + $messagePrecacheDto->template = null; + $messagePrecacheDto->templateText = null; + $messagePrecacheDto->templateId = null; + return true; } diff --git a/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php b/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php index fe1c64eb..fb3ec64d 100644 --- a/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php +++ b/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php @@ -4,44 +4,16 @@ namespace PhpList\Core\Domain\Messaging\Service; -use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; -use PhpList\Core\Domain\Messaging\Model\Message; -use PhpList\Core\Domain\Subscription\Model\Subscriber; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; class RateLimitedCampaignMailer { - private MailerInterface $mailer; - private SendRateLimiter $limiter; - public function __construct(MailerInterface $mailer, SendRateLimiter $limiter) - { - $this->mailer = $mailer; - $this->limiter = $limiter; - } - - public function composeEmail( - Message $message, - Subscriber $subscriber, - MessagePrecacheDto $messagePrecacheDto, - ): Email { - $email = new Email(); - if ($message->getOptions()->getFromField() !== '') { - $email->from($message->getOptions()->getFromField()); - } - - if ($message->getOptions()->getReplyTo() !== '') { - $email->replyTo($message->getOptions()->getReplyTo()); - } - - $html = $messagePrecacheDto->content . $messagePrecacheDto->htmlFooter; - - return $email - ->to($subscriber->getEmail()) - ->subject($messagePrecacheDto->subject) - ->text($messagePrecacheDto->textContent) - ->html($html); + public function __construct( + private readonly MailerInterface $mailer, + private readonly SendRateLimiter $limiter, + ) { } /** diff --git a/src/Domain/Subscription/Repository/SubscriberListRepository.php b/src/Domain/Subscription/Repository/SubscriberListRepository.php index d8910751..988c23e3 100644 --- a/src/Domain/Subscription/Repository/SubscriberListRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberListRepository.php @@ -9,6 +9,7 @@ use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberList; /** @@ -53,4 +54,41 @@ public function getAllActive(): array ->getQuery() ->getResult(); } + + public function getListNames(array $listIds): array + { + if ($listIds === []) { + return []; + } + + $lists = $this->createQueryBuilder('l') + ->select('l.name') + ->where('l.id IN (:ids)') + ->setParameter('ids', $listIds) + ->getQuery() + ->getScalarResult(); + + return array_column($lists, 'name'); + } + + /** + * Returns the names of lists the given subscriber is subscribed to. + * If $showPrivate is false, only active/public lists are included. + */ + public function getActiveListNamesForSubscriber(Subscriber $subscriber, bool $showPrivate): array + { + $qb = $this->createQueryBuilder('l') + ->select('l.name') + ->innerJoin('l.subscriptions', 's') + ->where('IDENTITY(s.subscriber) = :subscriberId') + ->setParameter('subscriberId', $subscriber->getId()); + + if (!$showPrivate) { + $qb->andWhere('l.active = true'); + } + + $rows = $qb->getQuery()->getScalarResult(); + + return array_column($rows, 'name'); + } } diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index af776a75..dd939dad 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -205,4 +205,14 @@ public function decrementBounceCount(Subscriber $subscriber): void ->getQuery() ->execute(); } + + public function getDataById(int $subscriberId): array + { + return $this->createQueryBuilder('s') + ->select('s') + ->where('s.id = :subscriberId') + ->setParameter('subscriberId', $subscriberId) + ->getQuery() + ->getArrayResult(); + } } diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php index f962c9ab..f9db822a 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php @@ -34,36 +34,17 @@ */ class SubscriberCsvImporter { - private SubscriberManager $subscriberManager; - private SubscriberAttributeManager $attributeManager; - private SubscriptionManager $subscriptionManager; - private SubscriberRepository $subscriberRepository; - private CsvToDtoImporter $csvToDtoImporter; - private EntityManagerInterface $entityManager; - private TranslatorInterface $translator; - private MessageBusInterface $messageBus; - private SubscriberHistoryManager $subscriberHistoryManager; - public function __construct( - SubscriberManager $subscriberManager, - SubscriberAttributeManager $attributeManager, - SubscriptionManager $subscriptionManager, - SubscriberRepository $subscriberRepository, - CsvToDtoImporter $csvToDtoImporter, - EntityManagerInterface $entityManager, - TranslatorInterface $translator, - MessageBusInterface $messageBus, - SubscriberHistoryManager $subscriberHistoryManager, + private readonly SubscriberManager $subscriberManager, + private readonly SubscriberAttributeManager $attributeManager, + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriberRepository $subscriberRepository, + private readonly CsvToDtoImporter $csvToDtoImporter, + private readonly EntityManagerInterface $entityManager, + private readonly TranslatorInterface $translator, + private readonly MessageBusInterface $messageBus, + private readonly SubscriberHistoryManager $subscriberHistoryManager, ) { - $this->subscriberManager = $subscriberManager; - $this->attributeManager = $attributeManager; - $this->subscriptionManager = $subscriptionManager; - $this->subscriberRepository = $subscriberRepository; - $this->csvToDtoImporter = $csvToDtoImporter; - $this->entityManager = $entityManager; - $this->translator = $translator; - $this->messageBus = $messageBus; - $this->subscriberHistoryManager = $subscriberHistoryManager; } /** diff --git a/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php b/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php index 0c7f7dfd..c1f8ab3b 100644 --- a/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php +++ b/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php @@ -7,7 +7,7 @@ use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; -use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; +use PhpList\Core\Domain\Configuration\Service\MessagePlaceholderProcessor; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue; diff --git a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php index 9fa4b4f6..0cf4e8bb 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php @@ -6,7 +6,6 @@ use Doctrine\ORM\EntityManagerInterface; use Exception; -use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage; use PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler; use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; @@ -17,6 +16,7 @@ use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; use PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder; +use PhpList\Core\Domain\Messaging\Service\Constructor\CampaignMailContentBuilder; use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; use PhpList\Core\Domain\Messaging\Service\MailSizeChecker; use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; @@ -31,6 +31,7 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; +use ReflectionClass; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; use Symfony\Component\Translation\Translator; @@ -65,18 +66,11 @@ protected function setUp(): void $this->precacheService = $this->createMock(MessagePrecacheService::class); $this->cache = $this->createMock(CacheInterface::class); $this->symfonyMailer = $this->createMock(MailerInterface::class); - $userPersonalizer = $this->createMock(UserPersonalizer::class); + $this->mailConstructor = $this->createMock(CampaignMailContentBuilder::class); $timeLimiter->method('start'); $timeLimiter->method('shouldStop')->willReturn(false); - // Ensure personalization returns original text so assertions on replaced links remain valid - $userPersonalizer - ->method('personalize') - ->willReturnCallback(function (string $text) { - return $text; - }); - $this->handler = new CampaignProcessorMessageHandler( mailer: $this->symfonyMailer, rateLimitedCampaignMailer: $this->mailer, @@ -92,10 +86,10 @@ protected function setUp(): void subscriberHistoryManager: $this->createMock(SubscriberHistoryManager::class), messageRepository: $this->messageRepository, precacheService: $this->precacheService, - userPersonalizer: $userPersonalizer, messageDataLoader: $this->createMock(MessageDataLoader::class), emailBuilder: $this->createMock(EmailBuilder::class), mailSizeChecker: $this->createMock(MailSizeChecker::class), + mailConstructor: $this->mailConstructor, messageEnvelope: 'messageEnvelope', ); } @@ -229,29 +223,24 @@ public function testInvokeWithValidSubscriberEmail(): void ->with(1, $precached, $subscriber) ->willReturn($precached); - $this->mailer->expects($this->once()) - ->method('composeEmail') - ->with( - $this->identicalTo($campaign), - $this->identicalTo($subscriber), - $this->identicalTo($precached) - ) - ->willReturnCallback(function ($camp, $sub, $proc) use ($campaign, $subscriber, $precached) { - $this->assertSame($campaign, $camp); - $this->assertSame($subscriber, $sub); - $this->assertSame($precached, $proc); + // campaign emails are built via campaignEmailBuilder and sent via RateLimitedCampaignMailer + $campaignEmailBuilder = (new ReflectionClass($this->handler)) + ->getProperty("campaignEmailBuilder"); + /** @var EmailBuilder|MockObject $campaignBuilderMock */ + $campaignBuilderMock = $campaignEmailBuilder->getValue($this->handler); - return (new Email()) + $campaignBuilderMock->expects($this->once()) + ->method('buildPhplistEmail') + ->willReturn( + (new Email()) ->from('news@example.com') ->to('test@example.com') ->subject('Test Subject') ->text('Test text message') - ->html('

Test HTML message

'); - }); + ->html('

Test HTML message

') + ); - $this->symfonyMailer->expects($this->once()) - ->method('send') - ->with($this->isInstanceOf(Email::class)); + $this->mailer->expects($this->any())->method('send'); $metadata->expects($this->atLeastOnce()) ->method('setStatus'); @@ -300,8 +289,18 @@ public function testInvokeWithMailerException(): void ->with(123, $precached, $subscriber) ->willReturn($precached); + // Build email and throw on rate-limited sender + $campaignEmailBuilder = (new ReflectionClass($this->handler)) + ->getProperty("campaignEmailBuilder"); + + /** @var EmailBuilder|MockObject $campaignBuilderMock */ + $campaignBuilderMock = $campaignEmailBuilder->getValue($this->handler); + $campaignBuilderMock->expects($this->once()) + ->method('buildPhplistEmail') + ->willReturn((new Email())->to('test@example.com')->subject('Test Subject')->text('x')); + $exception = new Exception('Test exception'); - $this->symfonyMailer->expects($this->once()) + $this->mailer->expects($this->once()) ->method('send') ->willThrowException($exception); @@ -369,7 +368,19 @@ public function testInvokeWithMultipleSubscribers(): void ) ->willReturnOnConsecutiveCalls($precached, $precached); - $this->symfonyMailer->expects($this->exactly(2)) + // Configure builder to return emails for first two subscribers + $campaignEmailBuilder = (new ReflectionClass($this->handler)) + ->getProperty("campaignEmailBuilder"); + /** @var EmailBuilder|MockObject $campaignBuilderMock */ + $campaignBuilderMock = $campaignEmailBuilder->getValue($this->handler); + $campaignBuilderMock->expects($this->exactly(2)) + ->method('buildPhplistEmail') + ->willReturnOnConsecutiveCalls( + (new Email())->to('test1@example.com')->subject('Test Subject')->text('x'), + (new Email())->to('test2@example.com')->subject('Test Subject')->text('x') + ); + + $this->mailer->expects($this->exactly(2)) ->method('send'); $metadata->expects($this->atLeastOnce()) diff --git a/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php index 6288c5f4..4fbe2071 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php @@ -10,7 +10,7 @@ use PhpList\Core\Domain\Messaging\MessageHandler\SubscriptionConfirmationMessageHandler; use PhpList\Core\Domain\Messaging\Service\EmailService; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; -use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; +use PhpList\Core\Domain\Configuration\Service\MessagePlaceholderProcessor; use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use Psr\Log\LoggerInterface; @@ -26,14 +26,14 @@ public function testSendsEmailWithPersonalizedContentAndListNames(): void $emailService = $this->createMock(EmailService::class); $configProvider = $this->createMock(ConfigProvider::class); $logger = $this->createMock(LoggerInterface::class); - $personalizer = $this->createMock(UserPersonalizer::class); + $placeholderProcessor = $this->createMock(MessagePlaceholderProcessor::class); $listRepo = $this->createMock(SubscriberListRepository::class); $handler = new SubscriptionConfirmationMessageHandler( emailService: $emailService, configProvider: $configProvider, logger: $logger, - userPersonalizer: $personalizer, + placeholderProcessor: $placeholderProcessor, subscriberListRepository: $listRepo ); $configProvider @@ -46,7 +46,7 @@ public function testSendsEmailWithPersonalizedContentAndListNames(): void $message = new SubscriptionConfirmationMessage('alice@example.com', 'user-123', [10, 11]); - $personalizer->expects($this->once()) + $placeholderProcessor->expects($this->once()) ->method('personalize') ->with('Hi {{name}}, you subscribed to: [LISTS]', 'user-123') ->willReturn('Hi Alice, you subscribed to: [LISTS]'); @@ -95,14 +95,14 @@ public function testHandlesMissingListsGracefullyAndEmptyJoin(): void $emailService = $this->createMock(EmailService::class); $configProvider = $this->createMock(ConfigProvider::class); $logger = $this->createMock(LoggerInterface::class); - $personalizer = $this->createMock(UserPersonalizer::class); + $placeholderProcessor = $this->createMock(MessagePlaceholderProcessor::class); $listRepo = $this->createMock(SubscriberListRepository::class); $handler = new SubscriptionConfirmationMessageHandler( emailService: $emailService, configProvider: $configProvider, logger: $logger, - userPersonalizer: $personalizer, + placeholderProcessor: $placeholderProcessor, subscriberListRepository: $listRepo ); @@ -117,7 +117,7 @@ public function testHandlesMissingListsGracefullyAndEmptyJoin(): void $message->method('getUniqueId')->willReturn('user-456'); $message->method('getListIds')->willReturn([42]); - $personalizer->method('personalize') + $placeholderProcessor->method('personalize') ->with('Lists: [LISTS]', 'user-456') ->willReturn('Lists: [LISTS]'); diff --git a/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php index 1c7e809e..59744d30 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php @@ -8,7 +8,7 @@ use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder; -use PhpList\Core\Domain\Messaging\Service\SystemMailConstructor; +use PhpList\Core\Domain\Messaging\Service\Constructor\SystemMailContentBuilder; use PhpList\Core\Domain\Messaging\Service\TemplateImageEmbedder; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; @@ -26,7 +26,7 @@ class EmailBuilderTest extends TestCase private UserBlacklistRepository&MockObject $blacklistRepository; private SubscriberHistoryManager&MockObject $subscriberHistoryManager; private SubscriberRepository&MockObject $subscriberRepository; - private SystemMailConstructor&MockObject $systemMailConstructor; + private SystemMailContentBuilder&MockObject $systemMailConstructor; private TemplateImageEmbedder&MockObject $templateImageEmbedder; private LoggerInterface&MockObject $logger; @@ -37,7 +37,7 @@ protected function setUp(): void $this->blacklistRepository = $this->createMock(UserBlacklistRepository::class); $this->subscriberHistoryManager = $this->createMock(SubscriberHistoryManager::class); $this->subscriberRepository = $this->createMock(SubscriberRepository::class); - $this->systemMailConstructor = $this->getMockBuilder(SystemMailConstructor::class) + $this->systemMailConstructor = $this->getMockBuilder(SystemMailContentBuilder::class) ->disableOriginalConstructor() ->onlyMethods(['__invoke']) ->getMock(); @@ -69,7 +69,7 @@ private function createBuilder( blacklistRepository: $this->blacklistRepository, subscriberHistoryManager: $this->subscriberHistoryManager, subscriberRepository: $this->subscriberRepository, - systemMailConstructor: $this->systemMailConstructor, + mailConstructor: $this->systemMailConstructor, templateImageEmbedder: $this->templateImageEmbedder, logger: $this->logger, googleSenderId: $googleSenderId, diff --git a/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php b/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php index de7f09c9..566d722b 100644 --- a/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php +++ b/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php @@ -9,8 +9,8 @@ use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Messaging\Model\Template; use PhpList\Core\Domain\Messaging\Repository\TemplateRepository; +use PhpList\Core\Domain\Messaging\Service\Constructor\SystemMailContentBuilder; use PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager; -use PhpList\Core\Domain\Messaging\Service\SystemMailConstructor; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -35,7 +35,7 @@ protected function setUp(): void ->getMock(); } - private function createConstructor(bool $poweredByPhplist = false): SystemMailConstructor + private function createConstructor(bool $poweredByPhplist = false): SystemMailContentBuilder { // Defaults needed by constructor $this->configProvider->method('getValue')->willReturnMap([ @@ -43,7 +43,7 @@ private function createConstructor(bool $poweredByPhplist = false): SystemMailCo [ConfigOption::SystemMessageTemplate, null], ]); - return new SystemMailConstructor( + return new SystemMailContentBuilder( html2Text: $this->html2Text, configProvider: $this->configProvider, templateRepository: $this->templateRepository, @@ -107,7 +107,7 @@ public function testTemplateWithSignaturePlaceholderUsesPoweredByImageWhenFlagFa ->with('Powered') ->willReturn('Powered'); - $constructor = new SystemMailConstructor( + $constructor = new SystemMailContentBuilder( html2Text: $this->html2Text, configProvider: $this->configProvider, templateRepository: $this->templateRepository, @@ -149,7 +149,7 @@ public function testTemplateWithoutSignatureAppendsPoweredByTextAndBeforeBodyEnd ) ->willReturnOnConsecutiveCalls('Hello World', 'PB'); - $constructor = new SystemMailConstructor( + $constructor = new SystemMailContentBuilder( html2Text: $this->html2Text, configProvider: $this->configProvider, templateRepository: $this->templateRepository,