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/PHPMD/rules.xml b/config/PHPMD/rules.xml index 40900dd9..9738d792 100644 --- a/config/PHPMD/rules.xml +++ b/config/PHPMD/rules.xml @@ -46,7 +46,7 @@ - + diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index 92b33bed..8d3af216 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)%%' @@ -105,6 +109,8 @@ parameters: env(GOOGLE_SENDERID): '' messaging.use_amazon_ses: '%%env(USE_AMAZONSES)%%' env(USE_AMAZONSES): '0' + messaging.use_precedence_header: '%%env(USE_PRECEDENCE_HEADER)%%' + env(USE_PRECEDENCE_HEADER): '0' messaging.embed_external_images: '%%env(EMBEDEXTERNALIMAGES)%%' env(EMBEDEXTERNALIMAGES): '0' messaging.embed_uploaded_images: '%%env(EMBEDUPLOADIMAGES)%%' @@ -115,6 +121,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' @@ -122,3 +134,7 @@ parameters: env(FCKIMAGES_DIR): 'uploadimages' phplist.public_schema: '%%env(PUBLIC_SCHEMA)%%' env(PUBLIC_SCHEMA): 'https' + phplist.attachment_download_url: '%%env(PHPLIST_ATTACHMENT_DOWNLOAD_URL)%%' + env(PHPLIST_ATTACHMENT_DOWNLOAD_URL): 'https://example.com/download/' + phplist.attachment_repository_path: '%%env(PHPLIST_ATTACHMENT_REPOSITORY_PATH)%%' + env(PHPLIST_ATTACHMENT_REPOSITORY_PATH): '/tmp' diff --git a/config/services/builders.yml b/config/services/builders.yml index 10a994a4..8a386408 100644 --- a/config/services/builders.yml +++ b/config/services/builders.yml @@ -4,22 +4,30 @@ services: autoconfigure: true public: false - PhpList\Core\Domain\Messaging\Service\Builder\MessageBuilder: - autowire: true - autoconfigure: true + PhpList\Core\Domain\: + resource: '../../src/Domain/*/Service/Builder/*' - PhpList\Core\Domain\Messaging\Service\Builder\MessageFormatBuilder: - autowire: true - autoconfigure: true + # Concrete mail constructors + PhpList\Core\Domain\Messaging\Service\Constructor\SystemMailContentBuilder: ~ + PhpList\Core\Domain\Messaging\Service\Constructor\CampaignMailContentBuilder: ~ - PhpList\Core\Domain\Messaging\Service\Builder\MessageScheduleBuilder: - autowire: true - autoconfigure: true + # Two EmailBuilder services with different constructors injected + Core.EmailBuilder.system: + class: PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder + 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%' - PhpList\Core\Domain\Messaging\Service\Builder\MessageContentBuilder: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Builder\MessageOptionsBuilder: - autowire: true - autoconfigure: true + 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/managers.yml b/config/services/managers.yml index 75475459..83059bc9 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -4,53 +4,11 @@ services: autoconfigure: true public: false - PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager: - autowire: true - autoconfigure: true + PhpList\Core\Domain\: + resource: '../../src/Domain/*/Service/Manager/*' + exclude: '../../src/Domain/*/Service/Manager/Builder/*' - PhpList\Core\Domain\Identity\Service\SessionManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Identity\Service\AdministratorManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Identity\Service\AdminAttributeManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Identity\Service\PasswordManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\DynamicListAttrManager: - autowire: true - autoconfigure: true + PhpList\Core\Bounce\Service\Manager\BounceManager: ~ Doctrine\DBAL\Schema\AbstractSchemaManager: factory: ['@doctrine.dbal.default_connection', 'createSchemaManager'] @@ -62,55 +20,3 @@ services: arguments: $dbPrefix: '%database_prefix%' $dynamicListTablePrefix: '%list_table_prefix%' - - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\MessageManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\BounceRegexManager: - autowire: true - autoconfigure: true - - PhpList\Core\Bounce\Service\Manager\BounceManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\SendProcessManager: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Messaging\Service\Manager\MessageDataManager: - autowire: true - autoconfigure: true diff --git a/config/services/messenger.yml b/config/services/messenger.yml index 3c8f27bb..eb4b5d7a 100644 --- a/config/services/messenger.yml +++ b/config/services/messenger.yml @@ -5,34 +5,15 @@ services: resource: '../../src/Domain/Messaging/MessageHandler' tags: [ 'messenger.message_handler' ] - PhpList\Core\Domain\Messaging\MessageHandler\SubscriberConfirmationMessageHandler: + # Register Subscription message handlers (e.g., DynamicTableMessageHandler) + PhpList\Core\Domain\Subscription\MessageHandler\: autowire: true - autoconfigure: true - tags: [ 'messenger.message_handler' ] - arguments: - $confirmationUrl: '%app.confirmation_url%' - - PhpList\Core\Domain\Messaging\MessageHandler\AsyncEmailMessageHandler: - autowire: true - autoconfigure: true - tags: [ 'messenger.message_handler' ] - - PhpList\Core\Domain\Messaging\MessageHandler\PasswordResetMessageHandler: - autowire: true - autoconfigure: true - tags: [ 'messenger.message_handler' ] - arguments: - $passwordResetUrl: '%app.password_reset_url%' - - PhpList\Core\Domain\Messaging\MessageHandler\SubscriptionConfirmationMessageHandler: - autowire: true - autoconfigure: true + resource: '../../src/Domain/Subscription/MessageHandler' tags: [ 'messenger.message_handler' ] PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler: autowire: true - - PhpList\Core\Domain\Subscription\MessageHandler\DynamicTableMessageHandler: - autowire: true autoconfigure: true - tags: [ 'messenger.message_handler' ] + arguments: + $campaignEmailBuilder: '@Core.EmailBuilder.campaign' + $systemEmailBuilder: '@Core.EmailBuilder.system' 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/providers.yml b/config/services/providers.yml index b7b66be8..481b23a3 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -7,12 +7,6 @@ services: arguments: $config: '%app.config%' - PhpList\Core\Domain\Common\IspRestrictionsProvider: - autowire: true - autoconfigure: true - arguments: - $confPath: '%app.phplist_isp_conf_path%' - PhpList\Core\Domain\Subscription\Service\Provider\CheckboxGroupValueProvider: autowire: true PhpList\Core\Domain\Subscription\Service\Provider\SelectOrRadioValueProvider: @@ -30,3 +24,6 @@ services: PhpList\Core\Domain\Subscription\Service\Provider\SubscriberAttributeChangeSetProvider: autowire: true + + PhpList\Core\Domain\Common\IspRestrictionsProvider: + autowire: true 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..c07ee0bb 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -1,4 +1,9 @@ services: + _defaults: + autowire: true + autoconfigure: true + public: false + PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter: autowire: true autoconfigure: true @@ -12,9 +17,6 @@ services: PhpList\Core\Domain\Messaging\Service\EmailService: autowire: true autoconfigure: true - arguments: - $defaultFromEmail: '%app.mailer_from%' - $bounceEmail: '%imap_bounce.email%' PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService: autowire: true @@ -43,6 +45,59 @@ services: autowire: true autoconfigure: true + PhpList\Core\Domain\Common\OnceCacheGuard: + 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 + + PhpList\Core\Domain\Common\FileHelper: + 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 +175,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 +198,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..fee9ed2f 100644 --- a/resources/translations/messages.en.xlf +++ b/resources/translations/messages.en.xlf @@ -750,6 +750,66 @@ 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% %totalSize% - %memLimit% + __Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %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% + + + Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit% + __Insufficient memory to add attachment to campaign %campaignId% %totalSize% - %memLimit% + + + Attachment %remoteFile% does not exist + __Attachment %remoteFile% does not exist + + + Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be found in the repository. + __Error, when trying to send campaign %campaignId% the attachment (%remoteFile%) could not be found in the repository. + + + Location + __Location + diff --git a/src/Domain/Common/FileHelper.php b/src/Domain/Common/FileHelper.php new file mode 100644 index 00000000..5dab8b05 --- /dev/null +++ b/src/Domain/Common/FileHelper.php @@ -0,0 +1,61 @@ +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..806e11e8 --- /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..40cecc5b 100644 --- a/src/Domain/Common/RemotePageFetcher.php +++ b/src/Domain/Common/RemotePageFetcher.php @@ -75,7 +75,7 @@ public function __invoke(string $url, array $userData): string ]); } - return $content; + return $content ?? ''; } private function fetchUrlDirect(string $url): string 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..e040a19c --- /dev/null +++ b/src/Domain/Configuration/Model/Dto/PlaceholderContext.php @@ -0,0 +1,47 @@ +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..32884855 --- /dev/null +++ b/src/Domain/Configuration/Model/OutputFormat.php @@ -0,0 +1,14 @@ +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..f5fab8f3 --- /dev/null +++ b/src/Domain/Configuration/Service/MessagePlaceholderProcessor.php @@ -0,0 +1,126 @@ + */ + 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 { + $value = $this->ensureStandardPlaceholders($value, $format); + + $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) ?? '' + ); + + 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 ensureStandardPlaceholders(string $value, OutputFormat $format): string + { + if (!str_contains($value, '[FOOTER]')) { + $sep = $format === OutputFormat::Html ? '
' : "\n\n"; + $value = $this->appendContent($value, $sep . '[FOOTER]'); + } + + if (!str_contains($value, '[SIGNATURE]')) { + $sep = $format === OutputFormat::Html ? ' ' : "\n"; + $value = $this->appendContent($value, $sep . '[SIGNATURE]'); + } + + if ($this->alwaysAddUserTrack && $format === OutputFormat::Html && !str_contains($value, '[USERTRACK]')) { + $value = $this->appendContent($value, '[USERTRACK]'); + } + + return $value; + } + + 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..5c26e8fc --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/BlacklistUrlValueResolver.php @@ -0,0 +1,36 @@ +config->getValue(ConfigOption::BlacklistUrl) ?? ''; + $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..168d3e4e --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/BlacklistValueResolver.php @@ -0,0 +1,42 @@ +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..bdf0cadf --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ConfirmationUrlValueResolver.php @@ -0,0 +1,33 @@ +config->getValue(ConfigOption::ConfirmationUrl) ?? ''; + $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/ContactUrlValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ContactUrlValueResolver.php new file mode 100644 index 00000000..8fbe5dec --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ContactUrlValueResolver.php @@ -0,0 +1,30 @@ +isText()) { + return $this->config->getValue(ConfigOption::VCardUrl) ?? ''; + } + + return htmlspecialchars($this->config->getValue(ConfigOption::VCardUrl) ?? ''); + } +} diff --git a/src/Domain/Configuration/Service/Placeholder/ContactValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ContactValueResolver.php new file mode 100644 index 00000000..4ffe5679 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ContactValueResolver.php @@ -0,0 +1,39 @@ +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..fd1e9d4b --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php @@ -0,0 +1,33 @@ +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..d81d0c3d --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ForwardMessageIdValueResolver.php @@ -0,0 +1,68 @@ +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..32a9b5a1 --- /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..d12a3116 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ForwardValueResolver.php @@ -0,0 +1,47 @@ +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..5a77deb4 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php @@ -0,0 +1,48 @@ +subscriberListRepository->getActiveListNamesForSubscriber( + subscriber: $ctx->getUser(), + showPrivate: $this->showPrivateLists + ); + + 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', + htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + 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..46f44366 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/PreferencesValueResolver.php @@ -0,0 +1,47 @@ +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 '' + . $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..ebd90daa --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php @@ -0,0 +1,40 @@ +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..26a9c9c0 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/SubscribeValueResolver.php @@ -0,0 +1,39 @@ +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..48aeea14 --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/UnsubscribeValueResolver.php @@ -0,0 +1,46 @@ +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..86e97b7d --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/UserDataSupportingResolver.php @@ -0,0 +1,53 @@ +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..2e900e8c --- /dev/null +++ b/src/Domain/Configuration/Service/Placeholder/UserTrackValueResolver.php @@ -0,0 +1,41 @@ +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..61760c22 100644 --- a/src/Domain/Configuration/Service/PlaceholderResolver.php +++ b/src/Domain/Configuration/Service/PlaceholderResolver.php @@ -4,30 +4,101 @@ 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 $resolver) { + $value = preg_replace_callback( + $resolver['pattern'], + fn(array $match) => (string) ($resolver['resolver'])($context, $match), + $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 preg_replace_callback( + '/\[([^\]%%]+)(?:%%([^\]]+))?\]/i', + fn(array $matches) => $this->resolveSinglePlaceholder($matches, $context), + $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; + } + + private function resolveSinglePlaceholder(array $matches, PlaceholderContext $context): string + { + $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; } - return (string) ($this->providers[$key])(); - }, $input); + + $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]; } } diff --git a/src/Domain/Configuration/Service/UserPersonalizer.php b/src/Domain/Configuration/Service/UserPersonalizer.php index 7aedf1d8..6edd90f9 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,31 @@ 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('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( - 'SUBSCRIBEURL', - fn() => ($this->config->getValue(ConfigOption::SubscribeUrl) ?? '') . self::PHP_SPACE + 'DOMAIN', + fn(PlaceholderContext $ctx) => $this->config->getValue(ConfigOption::Domain) ?? '' + ); + $resolver->register( + 'WEBSITE', + fn(PlaceholderContext $ctx) => $this->config->getValue(ConfigOption::Website) ?? '' ); - $resolver->register('DOMAIN', fn() => $this->config->getValue(ConfigOption::Domain) ?? ''); - $resolver->register('WEBSITE', fn() => $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/Identity/Command/ImportDefaultsCommand.php b/src/Domain/Identity/Command/ImportDefaultsCommand.php index cf36e765..47ac4295 100644 --- a/src/Domain/Identity/Command/ImportDefaultsCommand.php +++ b/src/Domain/Identity/Command/ImportDefaultsCommand.php @@ -8,7 +8,7 @@ use PhpList\Core\Domain\Identity\Model\Dto\CreateAdministratorDto; use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; -use PhpList\Core\Domain\Identity\Service\AdministratorManager; +use PhpList\Core\Domain\Identity\Service\Manager\AdministratorManager; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; diff --git a/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php b/src/Domain/Identity/Service/Manager/AdminAttributeDefinitionManager.php similarity index 98% rename from src/Domain/Identity/Service/AdminAttributeDefinitionManager.php rename to src/Domain/Identity/Service/Manager/AdminAttributeDefinitionManager.php index 50a58bd0..93684b0a 100644 --- a/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php +++ b/src/Domain/Identity/Service/Manager/AdminAttributeDefinitionManager.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Identity\Service; +namespace PhpList\Core\Domain\Identity\Service\Manager; +use PhpList\Core\Domain\Identity\Exception\AttributeDefinitionCreationException; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; use PhpList\Core\Domain\Identity\Model\Dto\AdminAttributeDefinitionDto; use PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository; -use PhpList\Core\Domain\Identity\Exception\AttributeDefinitionCreationException; use PhpList\Core\Domain\Identity\Validator\AttributeTypeValidator; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/src/Domain/Identity/Service/AdminAttributeManager.php b/src/Domain/Identity/Service/Manager/AdminAttributeManager.php similarity index 97% rename from src/Domain/Identity/Service/AdminAttributeManager.php rename to src/Domain/Identity/Service/Manager/AdminAttributeManager.php index 53fa330e..ab123bce 100644 --- a/src/Domain/Identity/Service/AdminAttributeManager.php +++ b/src/Domain/Identity/Service/Manager/AdminAttributeManager.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Identity\Service; +namespace PhpList\Core\Domain\Identity\Service\Manager; +use PhpList\Core\Domain\Identity\Exception\AdminAttributeCreationException; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; use PhpList\Core\Domain\Identity\Model\AdminAttributeValue; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Repository\AdminAttributeValueRepository; -use PhpList\Core\Domain\Identity\Exception\AdminAttributeCreationException; class AdminAttributeManager { diff --git a/src/Domain/Identity/Service/AdministratorManager.php b/src/Domain/Identity/Service/Manager/AdministratorManager.php similarity index 97% rename from src/Domain/Identity/Service/AdministratorManager.php rename to src/Domain/Identity/Service/Manager/AdministratorManager.php index 814557a1..940eaa42 100644 --- a/src/Domain/Identity/Service/AdministratorManager.php +++ b/src/Domain/Identity/Service/Manager/AdministratorManager.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Identity\Service; +namespace PhpList\Core\Domain\Identity\Service\Manager; use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Identity\Model\Administrator; diff --git a/src/Domain/Identity/Service/PasswordManager.php b/src/Domain/Identity/Service/Manager/PasswordManager.php similarity index 98% rename from src/Domain/Identity/Service/PasswordManager.php rename to src/Domain/Identity/Service/Manager/PasswordManager.php index 35d5d1ff..01f9bb7d 100644 --- a/src/Domain/Identity/Service/PasswordManager.php +++ b/src/Domain/Identity/Service/Manager/PasswordManager.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Identity\Service; +namespace PhpList\Core\Domain\Identity\Service\Manager; use DateTime; -use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest; use PhpList\Core\Domain\Identity\Model\Administrator; -use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; +use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; +use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; use PhpList\Core\Domain\Messaging\Message\PasswordResetMessage; use PhpList\Core\Security\HashGenerator; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; diff --git a/src/Domain/Identity/Service/SessionManager.php b/src/Domain/Identity/Service/Manager/SessionManager.php similarity index 97% rename from src/Domain/Identity/Service/SessionManager.php rename to src/Domain/Identity/Service/Manager/SessionManager.php index cfeaa706..6e10007d 100644 --- a/src/Domain/Identity/Service/SessionManager.php +++ b/src/Domain/Identity/Service/Manager/SessionManager.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Identity\Service; +namespace PhpList\Core\Domain\Identity\Service\Manager; -use Symfony\Contracts\Translation\TranslatorInterface; use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; +use Symfony\Contracts\Translation\TranslatorInterface; class SessionManager { 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..36490e84 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; @@ -22,6 +24,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\Builder\SystemEmailBuilder; use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; use PhpList\Core\Domain\Messaging\Service\MailSizeChecker; use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; @@ -34,6 +37,7 @@ use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; @@ -63,11 +67,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 SystemEmailBuilder $systemEmailBuilder, + private readonly EmailBuilder $campaignEmailBuilder, private readonly MailSizeChecker $mailSizeChecker, - private readonly string $messageEnvelope, + private readonly ConfigProvider $configProvider, + #[Autowire('%imap_bounce.email%')] private readonly string $bounceEmail, ) { } @@ -194,27 +199,60 @@ 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); + $result = $this->campaignEmailBuilder->buildPhplistEmail( + messageId: $campaign->getId(), + data: $processed, + skipBlacklistCheck: false, + inBlast: true, + htmlPref: $subscriber->hasHtmlEmail(), + ); + if ($result === null) { + return; + } + [$email, $sentAs] = $result; + $email = $this->campaignEmailBuilder->applyCampaignHeaders(email: $email, subscriber: $subscriber); + + $this->rateLimitedCampaignMailer->send($email); ($this->mailSizeChecker)($campaign, $email, $subscriber->hasHtmlEmail()); $this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent); + $campaign->incrementSentCount($sentAs); } catch (MessageSizeLimitExceededException $e) { // stop after the first message if size is exceeded $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, + htmlPref: true, + ); + + $envelope = new Envelope( + sender: new Address($this->bounceEmail, '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,15 +271,19 @@ 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, + htmlPref: true, ); if (!$email) { @@ -250,7 +292,7 @@ private function handleAdminNotifications(Message $campaign, array $loadedMessag // todo: check if from name should be from config $envelope = new Envelope( - sender: new Address($this->messageEnvelope, 'PHPList'), + sender: new Address($this->bounceEmail, 'PHPList'), recipients: [new Address($email->getTo()[0]->getAddress())], ); $this->mailer->send(message: $email, envelope: $envelope); @@ -301,6 +343,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..a364d013 100644 --- a/src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php @@ -6,6 +6,7 @@ use PhpList\Core\Domain\Messaging\Message\PasswordResetMessage; use PhpList\Core\Domain\Messaging\Service\EmailService; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Mime\Email; use Symfony\Contracts\Translation\TranslatorInterface; @@ -13,15 +14,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, + #[Autowire('%app.password_reset_url%')] private readonly string $passwordResetUrl + ) { } /** diff --git a/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php b/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php index 69ec42cb..d07519a8 100644 --- a/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php @@ -6,6 +6,7 @@ use PhpList\Core\Domain\Messaging\Message\SubscriberConfirmationMessage; use PhpList\Core\Domain\Messaging\Service\EmailService; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Mime\Email; use Symfony\Contracts\Translation\TranslatorInterface; @@ -16,15 +17,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, + #[Autowire('%app.confirmation_url%')]private readonly string $confirmationUrl + ) { } /** diff --git a/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php b/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php index 6ecb965b..e859c401 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,25 @@ 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()); + + if (empty($subject) || empty($textContent)) { + $this->logger->error('Subscription email configuration is missing. Email not sent.'); + return; + } + + $personalizedTextContent = $this->userPersonalizer->personalize( + value: $textContent, + email: $message->getEmail(), + format: OutputFormat::Text, + ); + $listOfLists = $this->getListNames($message->getListIds()); - $replacedTextContent = str_replace('[LISTS]', $listOfLists, $personalizedTextContent); + $personalizedTextContent = str_replace('[LISTS]', $listOfLists, $personalizedTextContent); $email = (new Email()) ->to($message->getEmail()) ->subject($subject) - ->text($replacedTextContent); + ->text($personalizedTextContent); $this->emailService->sendEmail($email); @@ -63,14 +64,14 @@ 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(); + $names = []; + foreach ($listIds as $listId) { + $list = $this->subscriberListRepository->find($listId); + if ($list !== null) { + $names[] = $list->getName(); } } - return implode(', ', $listNames); + return implode(', ', $names); } } diff --git a/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php b/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php index 10fdaf93..9a25e76f 100644 --- a/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php +++ b/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php @@ -13,7 +13,7 @@ class MessagePrecacheDto public ?string $fromEmail = null; public ?string $to = null; public string $subject = ''; - public ?string $content = null; + public string $content = ''; public string $textContent = ''; public string $footer = ''; public ?string $textFooter = null; diff --git a/src/Domain/Messaging/Model/Message.php b/src/Domain/Messaging/Model/Message.php index 90f5036c..00b41897 100644 --- a/src/Domain/Messaging/Model/Message.php +++ b/src/Domain/Messaging/Model/Message.php @@ -5,7 +5,6 @@ namespace PhpList\Core\Domain\Messaging\Model; use DateTime; -use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -13,6 +12,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\Identity; use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate; use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat; @@ -194,4 +194,15 @@ public function getListMessages(): Collection { return $this->listMessages; } + + public function incrementSentCount(OutputFormat $sentAs): void + { + match ($sentAs) { + OutputFormat::Html => $this->format->incrementAsHtml(), + OutputFormat::Text => $this->format->incrementAsText(), + OutputFormat::Pdf => $this->format->incrementAsPdf(), + OutputFormat::TextAndHtml => $this->format->incrementAsTextAndHtml(), + OutputFormat::TextAndPdf => $this->format->incrementAsTextAndPdf(), + }; + } } diff --git a/src/Domain/Messaging/Model/Message/MessageFormat.php b/src/Domain/Messaging/Model/Message/MessageFormat.php index 5deedefb..2efcfcc8 100644 --- a/src/Domain/Messaging/Model/Message/MessageFormat.php +++ b/src/Domain/Messaging/Model/Message/MessageFormat.php @@ -5,7 +5,6 @@ namespace PhpList\Core\Domain\Messaging\Model\Message; use Doctrine\ORM\Mapping as ORM; -use InvalidArgumentException; use PhpList\Core\Domain\Common\Model\Interfaces\EmbeddableInterface; #[ORM\Embeddable] @@ -17,20 +16,20 @@ class MessageFormat implements EmbeddableInterface #[ORM\Column(name: 'sendformat', type: 'string', length: 20, nullable: true)] private ?string $sendFormat = null; - #[ORM\Column(name: 'astext', type: 'boolean')] - private bool $asText = false; + #[ORM\Column(name: 'astext', type: 'integer')] + private int $asText = 0; - #[ORM\Column(name: 'ashtml', type: 'boolean')] - private bool $asHtml = false; + #[ORM\Column(name: 'ashtml', type: 'integer')] + private int $asHtml = 0; - #[ORM\Column(name: 'aspdf', type: 'boolean')] - private bool $asPdf = false; + #[ORM\Column(name: 'aspdf', type: 'integer')] + private int $asPdf = 0; - #[ORM\Column(name: 'astextandhtml', type: 'boolean')] - private bool $asTextAndHtml = false; + #[ORM\Column(name: 'astextandhtml', type: 'integer')] + private int $asTextAndHtml = 0; - #[ORM\Column(name: 'astextandpdf', type: 'boolean')] - private bool $asTextAndPdf = false; + #[ORM\Column(name: 'astextandpdf', type: 'integer')] + private int $asTextAndPdf = 0; public const FORMAT_TEXT = 'text'; public const FORMAT_HTML = 'html'; @@ -39,12 +38,9 @@ class MessageFormat implements EmbeddableInterface public function __construct( bool $htmlFormatted, ?string $sendFormat, - array $formatOptions = [] ) { $this->htmlFormatted = $htmlFormatted; $this->sendFormat = $sendFormat; - - $this->setFormatOptions($formatOptions); } public function isHtmlFormatted(): bool @@ -69,31 +65,56 @@ public function setSendFormat(?string $sendFormat): self return $this; } - public function isAsText(): bool + public function getAsText(): int { return $this->asText; } - public function isAsHtml(): bool + public function getAsHtml(): int { return $this->asHtml; } - public function isAsTextAndHtml(): bool + public function getAsTextAndHtml(): int { return $this->asTextAndHtml; } - public function isAsPdf(): bool + public function getAsPdf(): int { return $this->asPdf; } - public function isAsTextAndPdf(): bool + public function getAsTextAndPdf(): int { return $this->asTextAndPdf; } + public function incrementAsText(): void + { + $this->asText++; + } + + public function incrementAsHtml(): void + { + $this->asHtml++; + } + + public function incrementAsTextAndHtml(): void + { + $this->asTextAndHtml++; + } + + public function incrementAsPdf(): void + { + $this->asPdf++; + } + + public function incrementAsTextAndPdf(): void + { + $this->asTextAndPdf++; + } + public function getFormatOptions(): array { return array_values(array_filter([ @@ -102,21 +123,4 @@ public function getFormatOptions(): array $this->asPdf ? self::FORMAT_PDF : null, ])); } - - public function setFormatOptions(array $formatOptions): self - { - foreach ($formatOptions as $option) { - match ($option) { - self::FORMAT_TEXT => $this->asText = true, - self::FORMAT_HTML => $this->asHtml = true, - self::FORMAT_PDF => $this->asPdf = true, - default => throw new InvalidArgumentException('Invalid format option: ' . $option) - }; - } - - $this->asTextAndHtml = $this->asText && $this->asHtml; - $this->asTextAndPdf = $this->asText && $this->asPdf; - - return $this; - } } 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..30fb3ea9 --- /dev/null +++ b/src/Domain/Messaging/Service/AttachmentAdder.php @@ -0,0 +1,248 @@ +attachmentRepository->findAttachmentsForMessage($campaignId); + + if (empty($attachments)) { + return true; + } + + if ($format === OutputFormat::Text) { + $this->prependTextAttachmentNotice($email); + } + + $totalSize = 0; + $memoryLimit = $this->getMemoryLimit(); + + foreach ($attachments as $att) { + $totalSize += $att->getSize(); + if (!$this->hasMemoryForAttachment($totalSize, $memoryLimit, $campaignId)) { + return false; + } + + switch ($format) { + case OutputFormat::Html: + if (!$this->handleHtmlAttachment($email, $att, $campaignId)) { + return false; + } + break; + + case OutputFormat::Text: + $userEmail = $email->getTo()[0]->getAddress(); + // todo: add endpoint in rest-api project + $viewUrl = $this->attachmentDownloadUrl . '/?id=' . $att->getId() . '&uid=' . $userEmail; + + $email->text( + $email->getTextBody() + . $att->getDescription() . "\n" + . $this->translator->trans('Location') . ': ' . $viewUrl . "\n\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, + }); + } + + private function prependTextAttachmentNotice(Email $email): void + { + $pre = $this->translator->trans('This message contains attachments that can be viewed with a webbrowser'); + $email->text($email->getTextBody() . $pre . ":\n"); + } + + private function hasMemoryForAttachment(?int $totalSize, int $memoryLimit, int $campaignId): bool + { + // 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; + } + + return true; + } + + private function handleHtmlAttachment(Email $email, Attachment $att, int $campaignId): bool + { + $key = 'attaching_fail:' . sha1($campaignId . '|' . $att->getRemoteFile()); + if ($this->attachFromRepository($email, $att)) { + return true; + } + + if ($this->fileHelper->isValidFile($att->getRemoteFile())) { + return $this->handleLocalAttachment($email, $att, $campaignId, $key); + } + + $this->handleMissingAttachment($att, $campaignId, $key); + + return false; + } + + private function attachFromRepository(Email $email, Attachment $att): bool + { + $attachmentPath = $this->attachmentRepositoryPath . '/' . $att->getFilename(); + + if (!$this->fileHelper->isValidFile($attachmentPath)) { + return false; + } + + $contents = $this->fileHelper->readFileContents($attachmentPath); + if ($contents === null) { + return false; + } + + $email->attach($contents, basename($att->getRemoteFile()), $att->getMimeType()); + + return true; + } + + private function handleLocalAttachment(Email $email, Attachment $att, int $campaignId, string $key): bool + { + $remoteFile = $att->getRemoteFile(); + $contents = $this->fileHelper->readFileContents($remoteFile); + + if ($contents === null) { + $this->eventLogManager->log( + page: '', + entry: $this->translator->trans( + 'failed to open attachment (%remoteFile%) to add to campaign %campaignId%', + [ + '%remoteFile%' => $remoteFile, + '%campaignId%' => $campaignId, + ] + ) + ); + + return false; + } + + $email->attach($contents, basename($remoteFile), $att->getMimeType()); + $this->copyAttachmentToRepository($att, $contents, $campaignId, $key); + + return true; + } + + private function copyAttachmentToRepository(Attachment $att, string $contents, int $campaignId, string $key): void + { + $remoteFile = $att->getRemoteFile(); + if ($remoteFile === null) { + return; + } + + $relativeName = $this->fileHelper->writeFileToDirectory( + directory: $this->attachmentRepositoryPath, + originalFilename: $remoteFile, + contents: $contents + ); + + if ($relativeName === null) { + $this->handleCopyFailure($remoteFile, $campaignId, $key); + return; + } + + $att->setFilename($relativeName); + } + + private function handleCopyFailure(string $remoteFile, int $campaignId, string $key): void + { + if ($this->onceCacheGuard->firstTime($key, 3600)) { + $this->eventLogManager->log( + page: '', + entry: 'Unable to make a copy of attachment ' . $remoteFile . ' 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%' => $remoteFile, + ] + ); + + throw new AttachmentCopyException($errorMessage); + } + + // Not the first time => silently allow send to continue + } + + private function handleMissingAttachment(Attachment $att, int $campaignId, string $key): void + { + $remoteFile = $att->getRemoteFile(); + + if ($this->onceCacheGuard->firstTime($key, 3600)) { + $this->eventLogManager->log( + page: '', + entry: $this->translator->trans( + 'Attachment %remoteFile% does not exist', + [ + '%remoteFile%' => $remoteFile, + ] + ) + ); + + $errorMessage = $this->translator->trans( + 'Error, when trying to send campaign %campaignId% the attachment (%remoteFile%)' + . ' could not be found in the repository.', + [ + '%campaignId%' => $campaignId, + '%remoteFile%' => $remoteFile, + ] + ); + + throw new AttachmentCopyException($errorMessage); + } + } +} diff --git a/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php b/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php new file mode 100644 index 00000000..a674cf30 --- /dev/null +++ b/src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php @@ -0,0 +1,154 @@ +eventLogManager->log('', sprintf('Error: empty To: in message with subject %s to send', $subject)); + + return false; + } + if (!$subject || trim($subject) === '') { + $this->eventLogManager->log('', sprintf('Error: empty Subject: in message to send to %s', $to)); + + return false; + } + if (preg_match("/\n/", $to)) { + $this->eventLogManager->log('', 'Error: invalid recipient, containing newlines, email blocked'); + + return false; + } + if (preg_match("/\n/", $subject)) { + $this->eventLogManager->log('', 'Error: invalid subject, containing newlines, email blocked'); + + return false; + } + + return true; + } + + protected function passesBlacklistCheck(string $to, ?bool $skipBlacklistCheck): bool + { + + if (!$skipBlacklistCheck && $this->blacklistRepository->isEmailBlacklisted($to)) { + $this->eventLogManager->log('', sprintf('Error, %s is blacklisted, not sending', $to)); + $subscriber = $this->subscriberRepository->findOneByEmail($to); + if (!$subscriber) { + $this->logger->error('Error: subscriber not found', ['email' => $to]); + + return false; + } + $subscriber->setBlacklisted(true); + + $this->subscriberHistoryManager->addHistory( + subscriber: $subscriber, + message: 'Marked Blacklisted', + details: 'Found user in blacklist while trying to send an email, marked black listed', + ); + + return false; + } + + return true; + } + + protected function resolveDestinationEmail(?string $to): string + { + $destinationEmail = $to; + + if ($this->devVersion) { + if (!$this->devEmail) { + throw new DevEmailNotConfiguredException(); + } + $destinationEmail = $this->devEmail; + } + + return $destinationEmail; + } + + protected function createBaseEmail( + int $messageId, + string $originalTo, + ?string $fromEmail, + ?string $fromName, + ?string $subject, + ?bool $inBlast + ) : Email { + $email = (new Email()); + $destinationEmail = $this->resolveDestinationEmail($originalTo); + + $email->getHeaders()->addTextHeader('X-MessageID', (string)$messageId); + $email->getHeaders()->addTextHeader('X-ListMember', $destinationEmail); + if ($this->googleSenderId !== '') { + $email->getHeaders()->addTextHeader('Feedback-ID', sprintf('%s:%s', $messageId, $this->googleSenderId)); + } + + if (!$this->useAmazonSes && $this->usePrecedenceHeader) { + $email->getHeaders()->addTextHeader('Precedence', 'bulk'); + } + + if ($inBlast) { + $email->getHeaders()->addTextHeader('X-Blast', '1'); + } + + $removeUrl = $this->configProvider->getValue(ConfigOption::UnsubscribeUrl); + $sep = !str_contains($removeUrl, '?') ? '?' : '&'; + $email->getHeaders()->addTextHeader( + 'List-Unsubscribe', + sprintf( + '<%s%s%s>', + $removeUrl, + $sep, + http_build_query([ + 'email' => $destinationEmail, + 'jo' => 1, + ]) + ) + ); + + if ($this->devEmail && $destinationEmail === $this->devEmail && $originalTo !== $this->devEmail) { + $email->getHeaders()->addMailboxHeader( + 'X-Originally-To', + new Address($originalTo) + ); + } + + $email->to($destinationEmail); + $email->from(new Address($fromEmail, $fromName ?? '')); + $email->subject($subject); + + return $email; + } +} diff --git a/src/Domain/Messaging/Service/Builder/EmailBuilder.php b/src/Domain/Messaging/Service/Builder/EmailBuilder.php index 87bb9c8f..3c27e583 100644 --- a/src/Domain/Messaging/Service/Builder/EmailBuilder.php +++ b/src/Domain/Messaging/Service/Builder/EmailBuilder.php @@ -4,211 +4,264 @@ 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\DevEmailNotConfiguredException; -use PhpList\Core\Domain\Messaging\Service\SystemMailConstructor; +use PhpList\Core\Domain\Messaging\Exception\AttachmentException; +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; use Psr\Log\LoggerInterface; +use Symfony\Component\Mailer\Exception\LogicException; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; +use Symfony\Contracts\Translation\TranslatorInterface; -/** @SuppressWarnings("ExcessiveParameterList") */ -class EmailBuilder +/** @SuppressWarnings("ExcessiveParameterList") @SuppressWarnings("PHPMD.CouplingBetweenObjects") */ +class EmailBuilder extends BaseEmailBuilder { public function __construct( - private readonly ConfigProvider $configProvider, - private readonly EventLogManager $eventLogManager, - private readonly UserBlacklistRepository $blacklistRepository, - private readonly SubscriberHistoryManager $subscriberHistoryManager, - private readonly SubscriberRepository $subscriberRepository, - private readonly SystemMailConstructor $systemMailConstructor, + ConfigProvider $configProvider, + EventLogManager $eventLogManager, + UserBlacklistRepository $blacklistRepository, + SubscriberHistoryManager $subscriberHistoryManager, + SubscriberRepository $subscriberRepository, + LoggerInterface $logger, + private readonly MailContentBuilderInterface $mailConstructor, private readonly TemplateImageEmbedder $templateImageEmbedder, - private readonly LoggerInterface $logger, - private readonly string $googleSenderId, - private readonly bool $useAmazonSes, - private readonly bool $usePrecedenceHeader, - private readonly bool $devVersion = true, - private readonly ?string $devEmail = null, + private readonly LegacyUrlBuilder $urlBuilder, + private readonly PdfGenerator $pdfGenerator, + private readonly AttachmentAdder $attachmentAdder, + private readonly TranslatorInterface $translator, + string $googleSenderId, + bool $useAmazonSes, + bool $usePrecedenceHeader, + bool $devVersion = true, + ?string $devEmail = null, ) { + parent::__construct( + configProvider: $configProvider, + eventLogManager: $eventLogManager, + blacklistRepository: $blacklistRepository, + subscriberHistoryManager: $subscriberHistoryManager, + subscriberRepository: $subscriberRepository, + logger: $logger, + googleSenderId: $googleSenderId, + useAmazonSes: $useAmazonSes, + usePrecedenceHeader: $usePrecedenceHeader, + devVersion: $devVersion, + devEmail: $devEmail, + ); } 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)) { + ?bool $htmlPref = false, + ?bool $isTestMail = false, + ): ?array { + 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; } - $fromEmail = $this->configProvider->getValue(ConfigOption::MessageFromAddress); - $fromName = $this->configProvider->getValue(ConfigOption::MessageFromName); -// $messageReplyToAddress = $this->configProvider->getValue(ConfigOption::MessageReplyToAddress); -// $replyTo = $messageReplyToAddress ?: $fromEmail; - - [$destinationEmail, $message] = $this->resolveDestinationEmailAndMessage($to, $message); + $fromEmail = $data->fromEmail; + $fromName = $data->fromName; + $subject = (!$isTestMail ? '' : $this->translator->trans('(test)') . ' ') . $data->subject; - [$htmlMessage, $textMessage] = ($this->systemMailConstructor)($message, $subject); + [$htmlMessage, $textMessage] = ($this->mailConstructor)(messagePrecacheDto: $data); $email = $this->createBaseEmail( - $messageId, - $destinationEmail, - $fromEmail, - $fromName, - $subject, - $inBlast + messageId: $messageId, + originalTo: $data->to, + fromEmail: $fromEmail, + fromName: $fromName, + subject: $subject, + inBlast: $inBlast ); - $this->applyContentAndFormatting($email, $htmlMessage, $textMessage, $messageId); + if (!empty($data->replyToEmail)) { + $email->addReplyTo(new Address($data->replyToEmail, $data->replyToName)); + } elseif ($isTestMail) { + $testReplyAddress = $this->configProvider->getValue(ConfigOption::AdminAddress); + if (!empty($testReplyAddress)) { + $email->addReplyTo(new Address($testReplyAddress, '')); + } + } - return $email; + $sentAs = $this->applyContentAndFormatting( + email: $email, + htmlMessage: $htmlMessage, + textMessage: $textMessage, + messageId: $messageId, + data: $data, + htmlPref: $htmlPref, + ); + + return [$email, $sentAs]; } - private function validateRecipientAndSubject(?string $to, ?string $subject): bool + public function applyCampaignHeaders(Email $email, Subscriber $subscriber): Email { - if (!$to) { - $this->eventLogManager->log('', sprintf('Error: empty To: in message with subject %s to send', $subject)); + $preferencesUrl = $this->configProvider->getValue(ConfigOption::PreferencesUrl) ?? ''; + $unsubscribeUrl = $this->configProvider->getValue(ConfigOption::UnsubscribeUrl) ?? ''; + $subscribeUrl = $this->configProvider->getValue(ConfigOption::SubscribeUrl) ?? ''; + $adminAddress = $this->configProvider->getValue(ConfigOption::UnsubscribeUrl) ?? ''; - return false; - } - if (!$subject) { - $this->eventLogManager->log('', sprintf('Error: empty Subject: in message to send to %s', $to)); - - return false; - } - if (preg_match("/\n/", $to)) { - $this->eventLogManager->log('', 'Error: invalid recipient, containing newlines, email blocked'); + $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 false; - } - if (preg_match("/\n/", $subject)) { - $this->eventLogManager->log('', 'Error: invalid subject, containing newlines, email blocked'); + return $email; + } - return false; + public function applyContentAndFormatting( + Email $email, + ?string $htmlMessage, + ?string $textMessage, + int $messageId, + MessagePrecacheDto $data, + bool $htmlPref = false + ): OutputFormat { + $htmlPref = $this->shouldPreferHtml($htmlMessage, $htmlPref, $email); + $normalizedFormat = $this->normalizeSendFormat($data->sendFormat); + + // so what do we actually send? + switch ($normalizedFormat) { + case 'pdf': + $sentAs = $this->applyPdfFormat($email, $textMessage, $messageId, $htmlPref); + break; + case 'text_and_pdf': + $sentAs = $this->applyTextAndPdfFormat($email, $textMessage, $messageId, $htmlPref); + break; + case 'text': + $sentAs = OutputFormat::Text; + $email->text($textMessage); + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Text)) { + throw new AttachmentException(); + } + break; + default: + if ($htmlPref && $htmlMessage) { + $sentAs = OutputFormat::TextAndHtml; + $htmlMessage = ($this->templateImageEmbedder)(html: $htmlMessage, messageId: $messageId); + $email->html($htmlMessage); + $email->text($textMessage); + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Html)) { + throw new AttachmentException(); + } + } else { + $sentAs = OutputFormat::Text; + $email->text($textMessage); + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Text)) { + throw new AttachmentException(); + } + } + break; } - return true; + return $sentAs; } - private function passesBlacklistCheck(?string $to, ?bool $skipBlacklistCheck): bool + private function shouldPreferHtml(?string $htmlMessage, bool $htmlPref, Email $email): bool { + if (empty($email->getTo())) { + throw new LogicException('No recipients specified'); + } + // If we have HTML content, default to preferring HTML + $htmlPref = $htmlPref || (is_string($htmlMessage) && trim($htmlMessage) !== ''); - if (!$skipBlacklistCheck && $this->blacklistRepository->isEmailBlacklisted($to)) { - $this->eventLogManager->log('', sprintf('Error, %s is blacklisted, not sending', $to)); - $subscriber = $this->subscriberRepository->findOneByEmail($to); - if (!$subscriber) { - $this->logger->error('Error: subscriber not found', ['email' => $to]); - - return false; - } - $subscriber->setBlacklisted(true); - - $this->subscriberHistoryManager->addHistory( - subscriber: $subscriber, - message: 'Marked Blacklisted', - details: 'Found user in blacklist while trying to send an email, marked black listed', - ); + // Domain-based text-only override + $domain = substr(strrchr($email->getTo()[0]->getAddress(), '@'), 1); + $textDomains = explode("\n", trim($this->configProvider->getValue(ConfigOption::AlwaysSendTextDomains) ?? '')); + if (in_array($domain, $textDomains, true)) { return false; } - return true; + return $htmlPref; } - private function resolveDestinationEmailAndMessage(?string $to, ?string $message): array + private function normalizeSendFormat(?string $sendFormat): string { - $destinationEmail = $to; + $format = strtolower(trim((string) $sendFormat)); + + return match ($format) { + 'pdf' => 'pdf', + 'text and pdf' => 'text_and_pdf', + 'text' => 'text', + // the default is for these too: 'both', 'html', 'text and html', + default => 'html_and_text', + }; + } - if ($this->devVersion) { - if (!$this->devEmail) { - throw new DevEmailNotConfiguredException(); + private function applyPdfFormat(Email $email, ?string $textMessage, int $messageId, bool $htmlPref): OutputFormat + { + // send a PDF file to users who want html and text to everyone else + if ($htmlPref) { + $sentAs = OutputFormat::Pdf; + $pdfBytes = $this->pdfGenerator->createPdfBytes($textMessage); + $email->attach($pdfBytes, 'message.pdf', 'application/pdf'); + + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Html)) { + throw new AttachmentException(); + } + } else { + $sentAs = OutputFormat::Text; + $email->text($textMessage); + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Text)) { + throw new AttachmentException(); } - $destinationEmail = $this->devEmail; } - return [$destinationEmail, $message]; + return $sentAs; } - private function createBaseEmail( + private function applyTextAndPdfFormat( + Email $email, + ?string $textMessage, int $messageId, - mixed $destinationEmail, - ?string $fromEmail, - ?string $fromName, - ?string $subject, - ?bool $inBlast - ) : Email { - $email = (new Email()); - - $email->getHeaders()->addTextHeader('X-MessageID', (string)$messageId); - $email->getHeaders()->addTextHeader('X-ListMember', $destinationEmail); - if ($this->googleSenderId !== '') { - $email->getHeaders()->addTextHeader('Feedback-ID', sprintf('%s:%s', $messageId, $this->googleSenderId)); - } - - if (!$this->useAmazonSes && $this->usePrecedenceHeader) { - $email->getHeaders()->addTextHeader('Precedence', 'bulk'); - } - - if ($inBlast) { - $email->getHeaders()->addTextHeader('X-Blast', '1'); - } - - $removeUrl = $this->configProvider->getValue(ConfigOption::UnsubscribeUrl); - $sep = !str_contains($removeUrl, '?') ? '?' : '&'; - $email->getHeaders()->addTextHeader( - 'List-Unsubscribe', - sprintf( - '<%s%s%s>', - $removeUrl, - $sep, - http_build_query([ - 'email' => $destinationEmail, - 'jo' => 1, - ]) - ) - ); - - - if ($this->devEmail && $destinationEmail !== $this->devEmail) { - $email->getHeaders()->addMailboxHeader( - 'X-Originally-To', - new Address($destinationEmail) - ); - } - - $email->to($destinationEmail); - $email->from(new Address($fromEmail, $fromName)); - $email->subject($subject); - - return $email; - } - - private function applyContentAndFormatting(Email $email, $htmlMessage, $textMessage, int $messageId): void - { - // Word wrapping disabled here to avoid reliance on config provider during content assembly + bool $htmlPref + ): OutputFormat { + // send a PDF file to users who want html and text to everyone else + if ($htmlPref) { + $sentAs = OutputFormat::TextAndPdf; + $pdfBytes = $this->pdfGenerator->createPdfBytes($textMessage); + $email->attach($pdfBytes, 'message.pdf', 'application/pdf'); + $email->text($textMessage); - if (!empty($htmlMessage)) { - // Embed/transform images and use the returned HTML content - $htmlMessage = ($this->templateImageEmbedder)(html: $htmlMessage, messageId: $messageId); - $email->html($htmlMessage); + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Html)) { + throw new AttachmentException(); + } + } else { + $sentAs = OutputFormat::Text; $email->text($textMessage); - //# In the above phpMailer strips all tags, which removes the links - // which are wrapped in < and > by HTML2text - //# so add it again + if (!$this->attachmentAdder->add($email, $messageId, OutputFormat::Text)) { + throw new AttachmentException(); + } } - // Ensure text body is always set - $email->text($textMessage); + + return $sentAs; } } diff --git a/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php b/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php index c6b05fc2..2bd5cf7f 100644 --- a/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php +++ b/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php @@ -19,7 +19,6 @@ public function build(object $dto): MessageFormat return new MessageFormat( htmlFormatted: $dto->htmlFormated, sendFormat: $dto->sendFormat, - formatOptions: $dto->formatOptions ); } } diff --git a/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php b/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php new file mode 100644 index 00000000..f5d52a69 --- /dev/null +++ b/src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php @@ -0,0 +1,110 @@ +validateRecipientAndSubject(to: $data->to, subject: $data->subject)) { + return null; + } + + if (!$this->passesBlacklistCheck(to: $data->to, skipBlacklistCheck: $skipBlacklistCheck)) { + return null; + } + + $fromEmail = $this->configProvider->getValue(ConfigOption::MessageFromAddress); + $fromName = $this->configProvider->getValue(ConfigOption::MessageFromName); +// $messageReplyToAddress = $this->configProvider->getValue(ConfigOption::MessageReplyToAddress); +// $replyTo = $messageReplyToAddress ?: $fromEmail; + + [$htmlMessage, $textMessage] = ($this->mailConstructor)(messagePrecacheDto: $data); + + $email = $this->createBaseEmail( + messageId: $messageId, + originalTo: $data->to, + fromEmail: $fromEmail, + fromName: $fromName, + subject: $data->subject, + inBlast: $inBlast + ); + + $this->applyContentAndFormatting( + email: $email, + htmlMessage: $htmlMessage, + textMessage: $textMessage, + messageId: $messageId, + ); + + return $email; + } + + protected function applyContentAndFormatting( + Email $email, + ?string $htmlMessage, + ?string $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); + $email->html($htmlMessage); + //# In the above phpMailer strips all tags, which removes the links + // which are wrapped in < and > by HTML2text so add it again + } + // Ensure text body is always set + $email->text($textMessage); + } +} diff --git a/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php b/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php new file mode 100644 index 00000000..25bdf075 --- /dev/null +++ b/src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php @@ -0,0 +1,159 @@ +subscriberRepository->findOneByEmail($messagePrecacheDto->to); + if (!$subscriber) { + throw new SubscriberNotFoundException( + sprintf('Subscriber with email %s not found', $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 ? $messagePrecacheDto->textContent : $content; + $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]; + + $url = preg_match('/^https?:\/\//i', $rawUrl) ? $rawUrl : 'https://' . $rawUrl; + + $remoteContent = ($this->remotePageFetcher)($url, $userData); + + if ($remoteContent === '') { + $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/EmailService.php b/src/Domain/Messaging/Service/EmailService.php index 2a45b0fd..bced6a8b 100644 --- a/src/Domain/Messaging/Service/EmailService.php +++ b/src/Domain/Messaging/Service/EmailService.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Domain\Messaging\Service; use PhpList\Core\Domain\Messaging\Message\AsyncEmailMessage; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Messenger\MessageBusInterface; @@ -13,21 +14,12 @@ class EmailService { - private MailerInterface $mailer; - private MessageBusInterface $messageBus; - private string $defaultFromEmail; - private string $bounceEmail; - public function __construct( - MailerInterface $mailer, - MessageBusInterface $messageBus, - string $defaultFromEmail, - string $bounceEmail, + private readonly MailerInterface $mailer, + private readonly MessageBusInterface $messageBus, + #[Autowire('%app.mailer_from%')] private readonly string $defaultFromEmail, + #[Autowire('%imap_bounce.email%')] private readonly string $bounceEmail, ) { - $this->mailer = $mailer; - $this->messageBus = $messageBus; - $this->defaultFromEmail = $defaultFromEmail; - $this->bounceEmail = $bounceEmail; } public function sendEmail( diff --git a/src/Domain/Messaging/Service/MessagePrecacheService.php b/src/Domain/Messaging/Service/MessagePrecacheService.php index 2a0f6b28..90b847a5 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,30 @@ 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..0a1a60cd 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 + { + $queryBuilder = $this->createQueryBuilder('l') + ->select('l.name') + ->innerJoin('l.subscriptions', 's') + ->where('IDENTITY(s.subscriber) = :subscriberId') + ->setParameter('subscriberId', $subscriber->getId()); + + if (!$showPrivate) { + $queryBuilder->andWhere('l.active = true'); + } + + $rows = $queryBuilder->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..4da427c6 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()[0]; + } } 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/Integration/Domain/Messaging/Fixtures/MessageFixture.php b/tests/Integration/Domain/Messaging/Fixtures/MessageFixture.php index 52930d14..548becf9 100644 --- a/tests/Integration/Domain/Messaging/Fixtures/MessageFixture.php +++ b/tests/Integration/Domain/Messaging/Fixtures/MessageFixture.php @@ -49,53 +49,48 @@ public function load(ObjectManager $manager): void $template = $templateRepository->find($row['template']); $format = new MessageFormat( - (bool)$row['htmlformatted'], - $row['sendformat'], - array_keys(array_filter([ - MessageFormat::FORMAT_TEXT => $row['astext'], - MessageFormat::FORMAT_HTML => $row['ashtml'], - MessageFormat::FORMAT_PDF => $row['aspdf'], - ])) + htmlFormatted: (bool)$row['htmlformatted'], + sendFormat: $row['sendformat'], ); $schedule = new MessageSchedule( - (int)$row['repeatinterval'], - new DateTime($row['repeatuntil']), - (int)$row['requeueinterval'], - new DateTime($row['requeueuntil']), - new DateTime($row['embargo']), + repeatInterval: (int)$row['repeatinterval'], + repeatUntil: new DateTime($row['repeatuntil']), + requeueInterval: (int)$row['requeueinterval'], + requeueUntil: new DateTime($row['requeueuntil']), + embargo: new DateTime($row['embargo']), ); $metadata = new MessageMetadata( - $row['status'], - (int)$row['bouncecount'], - new DateTime($row['entered']), - new DateTime($row['sent']), - new DateTime($row['sendstart']), + status: $row['status'], + bounceCount: (int)$row['bouncecount'], + entered: new DateTime($row['entered']), + sent: new DateTime($row['sent']), + sendStart: new DateTime($row['sendstart']), ); $metadata->setProcessed((bool) $row['processed']); - $metadata->setViews($row['viewed']); + $metadata->setViews((int) $row['viewed']); $content = new MessageContent( - $row['subject'], - $row['message'], - $row['textmessage'], - $row['footer'] + subject: $row['subject'], + text: $row['message'], + textMessage: $row['textmessage'], + footer: $row['footer'] ); $options = new MessageOptions( - $row['fromfield'], - $row['tofield'], - $row['replyto'], - $row['userselection'], - $row['rsstemplate'], + fromField: $row['fromfield'], + toField: $row['tofield'], + replyTo: $row['replyto'], + userSelection: $row['userselection'], + rssTemplate: $row['rsstemplate'], ); $message = new Message( - $format, - $schedule, - $metadata, - $content, - $options, - $admin, - $template, + format: $format, + schedule: $schedule, + metadata: $metadata, + content: $content, + options: $options, + owner: $admin, + template: $template, ); $this->setSubjectId($message, (int)$row['id']); $this->setSubjectProperty($message, 'uuid', $row['uuid']); diff --git a/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php b/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php index e2a1d719..36bb5626 100644 --- a/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php +++ b/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Configuration\Service; +use PhpList\Core\Domain\Configuration\Model\Dto\PlaceholderContext; use PhpList\Core\Domain\Configuration\Service\PlaceholderResolver; use PHPUnit\Framework\TestCase; @@ -12,31 +13,33 @@ */ final class PlaceholderResolverTest extends TestCase { - public function testNullAndEmptyAreReturnedAsIs(): void + public function testEmptyAreReturnedAsIs(): void { $resolver = new PlaceholderResolver(); + $placeholderContext = $this->createMock(PlaceholderContext::class); - $this->assertNull($resolver->resolve(null)); - $this->assertSame('', $resolver->resolve('')); + $this->assertSame('', $resolver->resolve('', $placeholderContext)); } public function testUnregisteredTokensRemainUnchanged(): void { $resolver = new PlaceholderResolver(); + $placeholderContext = $this->createMock(PlaceholderContext::class); $input = 'Hello [NAME], click [UNSUBSCRIBEURL] to opt out.'; - $this->assertSame($input, $resolver->resolve($input)); + $this->assertSame($input, $resolver->resolve($input, $placeholderContext)); } public function testCaseInsensitiveTokenResolution(): void { $resolver = new PlaceholderResolver(); $resolver->register('unsubscribeurl', fn () => 'https://u.example/u/123'); + $placeholderContext = $this->createMock(PlaceholderContext::class); $input = 'Click [UnSubscribeUrl]'; $expect = 'Click https://u.example/u/123'; - $this->assertSame($expect, $resolver->resolve($input)); + $this->assertSame($expect, $resolver->resolve($input, $placeholderContext)); } public function testMultipleDifferentTokensAreResolved(): void @@ -44,16 +47,18 @@ public function testMultipleDifferentTokensAreResolved(): void $resolver = new PlaceholderResolver(); $resolver->register('NAME', fn () => 'Ada'); $resolver->register('EMAIL', fn () => 'ada@example.com'); + $placeholderContext = $this->createMock(PlaceholderContext::class); $input = 'Hi [NAME] <[email]>'; $expect = 'Hi Ada '; - $this->assertSame($expect, $resolver->resolve($input)); + $this->assertSame($expect, $resolver->resolve($input, $placeholderContext)); } public function testAdjacentAndRepeatedTokens(): void { $resolver = new PlaceholderResolver(); + $placeholderContext = $this->createMock(PlaceholderContext::class); $count = 0; $resolver->register('X', function () use (&$count) { @@ -64,7 +69,7 @@ public function testAdjacentAndRepeatedTokens(): void $input = 'Start [x][X]-[x] End'; $expect = 'Start VV-V End'; - $this->assertSame($expect, $resolver->resolve($input)); + $this->assertSame($expect, $resolver->resolve($input, $placeholderContext)); $this->assertSame(3, $count); } @@ -72,21 +77,23 @@ public function testDigitsAndUnderscoresInToken(): void { $resolver = new PlaceholderResolver(); $resolver->register('USER_2', fn () => 'Bob#2'); + $placeholderContext = $this->createMock(PlaceholderContext::class); $input = 'Hello [user_2]!'; $expect = 'Hello Bob#2!'; - $this->assertSame($expect, $resolver->resolve($input)); + $this->assertSame($expect, $resolver->resolve($input, $placeholderContext)); } public function testUnknownTokensArePreservedVerbatim(): void { $resolver = new PlaceholderResolver(); $resolver->register('KNOWN', fn () => 'K'); + $placeholderContext = $this->createMock(PlaceholderContext::class); $input = 'A[UNKNOWN]B[KNOWN]C'; $expect = 'A[UNKNOWN]BKC'; - $this->assertSame($expect, $resolver->resolve($input)); + $this->assertSame($expect, $resolver->resolve($input, $placeholderContext)); } } diff --git a/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php b/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php index 0c7f7dfd..5a83a739 100644 --- a/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php +++ b/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php @@ -5,8 +5,13 @@ namespace PhpList\Core\Tests\Unit\Domain\Configuration\Service; use PhpList\Core\Domain\Configuration\Model\ConfigOption; -use PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; +use PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder; +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\UserPersonalizer; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; @@ -20,7 +25,6 @@ final class UserPersonalizerTest extends TestCase { private ConfigProvider&MockObject $config; - private LegacyUrlBuilder&MockObject $urlBuilder; private SubscriberRepository&MockObject $subRepo; private SubscriberAttributeValueRepository&MockObject $attrRepo; private AttributeValueResolver&MockObject $attrResolver; @@ -29,17 +33,22 @@ final class UserPersonalizerTest extends TestCase protected function setUp(): void { $this->config = $this->createMock(ConfigProvider::class); - $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class); $this->subRepo = $this->createMock(SubscriberRepository::class); $this->attrRepo = $this->createMock(SubscriberAttributeValueRepository::class); $this->attrResolver = $this->createMock(AttributeValueResolver::class); $this->personalizer = new UserPersonalizer( - $this->config, - $this->urlBuilder, - $this->subRepo, - $this->attrRepo, - $this->attrResolver + config: $this->config, + subscriberRepository: $this->subRepo, + attributesRepository: $this->attrRepo, + attributeValueResolver: $this->attrResolver, + unsubscribeUrlValueResolver: new UnsubscribeUrlValueResolver( + config: $this->config, + urlBuilder: new LegacyUrlBuilder() + ), + confirmationUrlValueResolver: new ConfirmationUrlValueResolver($this->config), + preferencesUrlValueResolver: new PreferencesUrlValueResolver($this->config), + subscribeUrlValueResolver: new SubscribeUrlValueResolver($this->config), ); } @@ -51,7 +60,7 @@ public function testReturnsOriginalWhenSubscriberNotFound(): void ->with('nobody@example.com') ->willReturn(null); - $result = $this->personalizer->personalize('Hello [EMAIL]', 'nobody@example.com'); + $result = $this->personalizer->personalize('Hello [EMAIL]', 'nobody@example.com', OutputFormat::Text); $this->assertSame('Hello [EMAIL]', $result); } @@ -84,11 +93,6 @@ public function testBuiltInPlaceholdersAreResolved(): void }; }); - // LegacyUrlBuilder glue behavior - $this->urlBuilder - ->method('withUid') - ->willReturnCallback(fn(string $base, string $u) => $base . '?uid=' . $u); - $this->attrRepo ->expects($this->once()) ->method('getForSubscriber') @@ -97,21 +101,20 @@ public function testBuiltInPlaceholdersAreResolved(): void $input = 'Email: [EMAIL] Unsub: [UNSUBSCRIBEURL] - Conf: [confirmationurl] + Conf: [CONFIRMATIONURL] Prefs: [PREFERENCESURL] Sub: [SUBSCRIBEURL] Domain: [DOMAIN] Website: [WEBSITE]'; - $result = $this->personalizer->personalize($input, $email); + $result = $this->personalizer->personalize($input, $email, OutputFormat::Text); $this->assertStringContainsString('Email: ada@example.com', $result); - // trailing space is expected after URL placeholders - $this->assertStringContainsString('Unsub: https://u.example/unsub?uid=U123 ', $result); - $this->assertStringContainsString('Conf: https://u.example/confirm?uid=U123 ', $result); - $this->assertStringContainsString('Prefs: https://u.example/prefs?uid=U123 ', $result); - $this->assertStringContainsString('Sub: https://u.example/subscribe ', $result); + $this->assertStringContainsString('Unsub: https://u.example/unsub?uid=U123', $result); + $this->assertStringContainsString('Conf: https://u.example/confirm?uid=U123', $result); + $this->assertStringContainsString('Prefs: https://u.example/prefs?uid=U123', $result); + $this->assertStringContainsString('Sub: https://u.example/subscribe', $result); $this->assertStringContainsString('Domain: example.org', $result); $this->assertStringContainsString('Website: site.example.org', $result); } @@ -141,8 +144,6 @@ public function testDynamicUserAttributesAreResolvedCaseInsensitive(): void [ConfigOption::Website, 'site.example.org'], ]); - $this->urlBuilder->method('withUid')->willReturnCallback(fn(string $b, string $u) => $b . '?uid=' . $u); - // Build a fake attribute value entity with definition NAME => "Full Name" $attrDefinition = $this->createMock(SubscriberAttributeDefinition::class); $attrDefinition->method('getName')->willReturn('Full_Name2'); @@ -163,7 +164,7 @@ public function testDynamicUserAttributesAreResolvedCaseInsensitive(): void ->willReturn('Bob #2'); $input = 'Hello [full_name2], your email is [email].'; - $result = $this->personalizer->personalize($input, $email); + $result = $this->personalizer->personalize($input, $email, OutputFormat::Text); $this->assertSame('Hello Bob #2, your email is bob@example.com.', $result); } @@ -188,8 +189,6 @@ public function testMultipleOccurrencesAndAdjacency(): void [ConfigOption::Website, 'w.x.tld'], ]); - $this->urlBuilder->method('withUid')->willReturnCallback(fn(string $b, string $u) => $b . '?uid=' . $u); - // Two attributes: FOO & BAR $defFoo = $this->createMock(SubscriberAttributeDefinition::class); $defFoo->method('getName')->willReturn('FOO'); @@ -211,8 +210,8 @@ public function testMultipleOccurrencesAndAdjacency(): void ]); $input = '[foo][BAR]-[email]-[UNSUBSCRIBEURL]'; - $out = $this->personalizer->personalize($input, $email); + $out = $this->personalizer->personalize($input, $email, OutputFormat::Text); - $this->assertSame('FVALBVAL-eve@example.com-https://x/unsub?uid=UID42 ', $out); + $this->assertSame('FVALBVAL-eve@example.com-https://x/unsub?uid=UID42', $out); } } diff --git a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php index edf16d37..1a7a885e 100644 --- a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php @@ -4,11 +4,11 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Service; +use PhpList\Core\Domain\Identity\Exception\AttributeDefinitionCreationException; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; use PhpList\Core\Domain\Identity\Model\Dto\AdminAttributeDefinitionDto; use PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository; -use PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager; -use PhpList\Core\Domain\Identity\Exception\AttributeDefinitionCreationException; +use PhpList\Core\Domain\Identity\Service\Manager\AdminAttributeDefinitionManager; use PhpList\Core\Domain\Identity\Validator\AttributeTypeValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php b/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php index d0ea805c..697798b7 100644 --- a/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php @@ -4,12 +4,12 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Service; +use PhpList\Core\Domain\Identity\Exception\AdminAttributeCreationException; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; use PhpList\Core\Domain\Identity\Model\AdminAttributeValue; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Repository\AdminAttributeValueRepository; -use PhpList\Core\Domain\Identity\Service\AdminAttributeManager; -use PhpList\Core\Domain\Identity\Exception\AdminAttributeCreationException; +use PhpList\Core\Domain\Identity\Service\Manager\AdminAttributeManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php b/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php index 8a61b4fd..0a56460f 100644 --- a/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php @@ -8,7 +8,7 @@ use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Model\Dto\CreateAdministratorDto; use PhpList\Core\Domain\Identity\Model\Dto\UpdateAdministratorDto; -use PhpList\Core\Domain\Identity\Service\AdministratorManager; +use PhpList\Core\Domain\Identity\Service\Manager\AdministratorManager; use PhpList\Core\Security\HashGenerator; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php index b9b53039..fd7aae53 100644 --- a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php @@ -5,18 +5,18 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Service; use DateTime; -use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest; use PhpList\Core\Domain\Identity\Model\Administrator; -use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; +use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; -use PhpList\Core\Domain\Identity\Service\PasswordManager; +use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; +use PhpList\Core\Domain\Identity\Service\Manager\PasswordManager; use PhpList\Core\Domain\Messaging\Message\PasswordResetMessage; use PhpList\Core\Security\HashGenerator; -use Symfony\Component\Messenger\Envelope; -use Symfony\Component\Messenger\MessageBusInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Contracts\Translation\TranslatorInterface; class PasswordManagerTest extends TestCase diff --git a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php index da620f12..e655f4a5 100644 --- a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php @@ -8,7 +8,7 @@ use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; -use PhpList\Core\Domain\Identity\Service\SessionManager; +use PhpList\Core\Domain\Identity\Service\Manager\SessionManager; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php index 9fa4b4f6..dd562504 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php @@ -6,7 +6,8 @@ use Doctrine\ORM\EntityManagerInterface; use Exception; -use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage; use PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler; use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; @@ -17,6 +18,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\Builder\SystemEmailBuilder; use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; use PhpList\Core\Domain\Messaging\Service\MailSizeChecker; use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; @@ -31,6 +33,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 +68,10 @@ 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); $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,11 +87,12 @@ 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), + systemEmailBuilder: $this->createMock(SystemEmailBuilder::class), + campaignEmailBuilder: $this->createMock(EmailBuilder::class), mailSizeChecker: $this->createMock(MailSizeChecker::class), - messageEnvelope: 'messageEnvelope', + configProvider: $this->createMock(ConfigProvider::class), + bounceEmail: 'bounce@email.com', ); } @@ -229,29 +225,25 @@ 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

'), + OutputFormat::Html + ]); - $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 +292,21 @@ 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'), + OutputFormat::Text + ]); + $exception = new Exception('Test exception'); - $this->symfonyMailer->expects($this->once()) + $this->mailer->expects($this->once()) ->method('send') ->willThrowException($exception); @@ -369,7 +374,25 @@ 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'), + OutputFormat::Text + ], + [ + (new Email())->to('test2@example.com')->subject('Test Subject')->text('x'), + OutputFormat::Text + ], + ); + + $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..2a4e1ed4 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php @@ -4,13 +4,13 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\MessageHandler; +use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; use PhpList\Core\Domain\Messaging\Message\SubscriptionConfirmationMessage; use PhpList\Core\Domain\Subscription\Model\SubscriberList; use PHPUnit\Framework\TestCase; 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\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); + $userPersonalizer = $this->createMock(UserPersonalizer::class); $listRepo = $this->createMock(SubscriberListRepository::class); $handler = new SubscriptionConfirmationMessageHandler( emailService: $emailService, configProvider: $configProvider, logger: $logger, - userPersonalizer: $personalizer, + userPersonalizer: $userPersonalizer, subscriberListRepository: $listRepo ); $configProvider @@ -44,11 +44,15 @@ public function testSendsEmailWithPersonalizedContentAndListNames(): void [ConfigOption::SubscribeMessage, 'Hi {{name}}, you subscribed to: [LISTS]'], ]); - $message = new SubscriptionConfirmationMessage('alice@example.com', 'user-123', [10, 11]); + $message = new SubscriptionConfirmationMessage( + email: 'alice@example.com', + uniqueId: 'user-123', + listIds: [10, 11], + ); - $personalizer->expects($this->once()) + $userPersonalizer->expects($this->once()) ->method('personalize') - ->with('Hi {{name}}, you subscribed to: [LISTS]', 'user-123') + ->with('Hi {{name}}, you subscribed to: [LISTS]', 'alice@example.com') ->willReturn('Hi Alice, you subscribed to: [LISTS]'); $listA = $this->createMock(SubscriberList::class); @@ -95,14 +99,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); + $userPersonalizer = $this->createMock(UserPersonalizer::class); $listRepo = $this->createMock(SubscriberListRepository::class); $handler = new SubscriptionConfirmationMessageHandler( emailService: $emailService, configProvider: $configProvider, logger: $logger, - userPersonalizer: $personalizer, + userPersonalizer: $userPersonalizer, subscriberListRepository: $listRepo ); @@ -117,11 +121,11 @@ public function testHandlesMissingListsGracefullyAndEmptyJoin(): void $message->method('getUniqueId')->willReturn('user-456'); $message->method('getListIds')->willReturn([42]); - $personalizer->method('personalize') - ->with('Lists: [LISTS]', 'user-456') + $userPersonalizer->method('personalize') + ->with('Lists: [LISTS]', 'bob@example.com') ->willReturn('Lists: [LISTS]'); - $listRepo->method('find')->with(42)->willReturn(null); + $listRepo->method('getListNames')->with([42])->willReturn([]); $emailService->expects($this->once()) ->method('sendEmail') diff --git a/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php index 1c7e809e..da9c65d1 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php @@ -4,11 +4,15 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder; +use PhpList\Core\Domain\Common\PdfGenerator; use PhpList\Core\Domain\Configuration\Model\ConfigOption; +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\Model\Dto\MessagePrecacheDto; +use PhpList\Core\Domain\Messaging\Service\AttachmentAdder; 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; @@ -18,6 +22,7 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Component\Mime\Address; +use Symfony\Contracts\Translation\TranslatorInterface; class EmailBuilderTest extends TestCase { @@ -26,8 +31,9 @@ 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 AttachmentAdder&MockObject $attachmentAdder; private LoggerInterface&MockObject $logger; protected function setUp(): void @@ -37,7 +43,8 @@ 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->attachmentAdder = $this->createMock(AttachmentAdder::class); + $this->systemMailConstructor = $this->getMockBuilder(SystemMailContentBuilder::class) ->disableOriginalConstructor() ->onlyMethods(['__invoke']) ->getMock(); @@ -69,9 +76,13 @@ private function createBuilder( blacklistRepository: $this->blacklistRepository, subscriberHistoryManager: $this->subscriberHistoryManager, subscriberRepository: $this->subscriberRepository, - systemMailConstructor: $this->systemMailConstructor, - templateImageEmbedder: $this->templateImageEmbedder, logger: $this->logger, + mailConstructor: $this->systemMailConstructor, + templateImageEmbedder: $this->templateImageEmbedder, + urlBuilder: $this->createMock(LegacyUrlBuilder::class), + pdfGenerator: $this->createMock(PdfGenerator::class), + attachmentAdder: $this->attachmentAdder, + translator: $this->createMock(TranslatorInterface::class), googleSenderId: $googleSenderId, useAmazonSes: $useAmazonSes, usePrecedenceHeader: $usePrecedenceHeader, @@ -83,18 +94,25 @@ private function createBuilder( public function testReturnsNullWhenMissingRecipient(): void { $this->eventLogManager->expects($this->once())->method('log'); + $dto = new MessagePrecacheDto(); + $dto->to = null; + $dto->subject = 'Subj'; + $dto->content = 'Body'; $builder = $this->createBuilder(); - $result = $builder->buildPhplistEmail(messageId: 123, to: null, subject: 'Subj', message: 'Body'); + $result = $builder->buildPhplistEmail(messageId: 123, data: $dto); $this->assertNull($result); } public function testReturnsNullWhenMissingSubject(): void { $this->eventLogManager->expects($this->once())->method('log'); + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->content = 'Body'; $builder = $this->createBuilder(); - $result = $builder->buildPhplistEmail(messageId: 123, to: 'user@example.com', subject: null, message: 'Body'); + $result = $builder->buildPhplistEmail(messageId: 123, data: $dto); $this->assertNull($result); } @@ -110,20 +128,30 @@ public function testReturnsNullWhenBlacklistedAndHistoryUpdated(): void $this->subscriberRepository->method('findOneByEmail')->with('user@example.com')->willReturn($subscriber); $this->subscriberHistoryManager->expects($this->once())->method('addHistory'); + $dto = new MessagePrecacheDto(); + $dto->to = 'user@example.com'; + $dto->subject = 'Hi'; + $dto->content = 'Body'; $builder = $this->createBuilder(); - $result = $builder->buildPhplistEmail(messageId: 55, to: 'user@example.com', subject: 'Hi', message: 'Body'); + $result = $builder->buildPhplistEmail(messageId: 55, data: $dto); $this->assertNull($result); } public function testBuildsEmailWithExpectedHeadersAndBodiesInDevMode(): void { $this->blacklistRepository->method('isEmailBlacklisted')->willReturn(false); + $this->attachmentAdder->method('add')->willReturn(true); + $dto = new MessagePrecacheDto(); + $dto->to = 'real@example.com'; + $dto->subject = 'Subject'; + $dto->content = 'TEXT'; + $dto->fromEmail = 'sender@example.com'; // SystemMailConstructor returns both html and text bodies $this->systemMailConstructor->expects($this->once()) ->method('__invoke') - ->with('Body', 'Subject') + ->with($dto) ->willReturn(['

HTML

', 'TEXT']); // TemplateImageEmbedder invoked when HTML present @@ -140,16 +168,15 @@ public function testBuildsEmailWithExpectedHeadersAndBodiesInDevMode(): void devEmail: 'dev@example.com' ); - $email = $builder->buildPhplistEmail( + $result = $builder->buildPhplistEmail( messageId: 777, - to: 'real@example.com', - subject: 'Subject', - message: 'Body', + data: $dto, skipBlacklistCheck: false, inBlast: true ); - $this->assertNotNull($email); + $this->assertNotNull($result); + [$email, $sentAs] = $result; // Recipient is redirected to dev email in dev mode $this->assertCount(1, $email->getTo()); diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php index 17d93eae..ed4645ed 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php @@ -25,7 +25,6 @@ public function testBuildsMessageFormatSuccessfully(): void $this->assertSame(true, $messageFormat->isHtmlFormatted()); $this->assertSame('html', $messageFormat->getSendFormat()); - $this->assertEqualsCanonicalizing(['html', 'text'], $messageFormat->getFormatOptions()); } public function testThrowsExceptionOnInvalidDto(): void diff --git a/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php b/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php index 7a92a420..d60b38e1 100644 --- a/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php +++ b/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php @@ -4,19 +4,10 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; -use PhpList\Core\Domain\Messaging\Model\Message; -use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; -use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; -use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat; -use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata; -use PhpList\Core\Domain\Messaging\Model\Message\MessageOptions; -use PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule; use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer; use PhpList\Core\Domain\Messaging\Service\SendRateLimiter; -use PhpList\Core\Domain\Subscription\Model\Subscriber; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use ReflectionProperty; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; @@ -34,61 +25,6 @@ protected function setUp(): void $this->sut = new RateLimitedCampaignMailer($this->mailer, $this->limiter); } - public function testComposeEmailSetsHeadersAndBody(): void - { - $message = $this->buildMessage( - subject: 'Subject', - textBody: 'Plain text', - htmlBody: '

HTML

', - from: 'from@example.com', - replyTo: 'reply@example.com' - ); - - $subscriber = new Subscriber(); - $this->setSubscriberEmail($subscriber, 'user@example.com'); - - $precached = new MessagePrecacheDto(); - $precached->subject = 'Subject'; - $precached->textContent = 'Plain text'; - $precached->content = '

HTML

'; - - $email = $this->sut->composeEmail($message, $subscriber, $precached); - - $this->assertInstanceOf(Email::class, $email); - $this->assertSame('user@example.com', $email->getTo()[0]->getAddress()); - $this->assertSame('Subject', $email->getSubject()); - $this->assertSame('from@example.com', $email->getFrom()[0]->getAddress()); - $this->assertSame('reply@example.com', $email->getReplyTo()[0]->getAddress()); - $this->assertSame('Plain text', $email->getTextBody()); - $this->assertSame('

HTML

', $email->getHtmlBody()); - } - - public function testComposeEmailWithoutOptionalHeaders(): void - { - $message = $this->buildMessage( - subject: 'No headers', - textBody: 'text', - htmlBody: 'h', - from: '', - replyTo: '' - ); - - $subscriber = new Subscriber(); - $this->setSubscriberEmail($subscriber, 'user2@example.com'); - - $precached = new MessagePrecacheDto(); - $precached->subject = 'No headers'; - $precached->textContent = 'text'; - $precached->content = 'h'; - - $email = $this->sut->composeEmail($message, $subscriber, $precached); - - $this->assertSame('user2@example.com', $email->getTo()[0]->getAddress()); - $this->assertSame('No headers', $email->getSubject()); - $this->assertSame([], $email->getFrom()); - $this->assertSame([], $email->getReplyTo()); - } - public function testSendUsesLimiterAroundMailer(): void { $email = (new Email())->to('someone@example.com'); @@ -102,44 +38,4 @@ public function testSendUsesLimiterAroundMailer(): void $this->sut->send($email); } - - private function buildMessage( - string $subject, - string $textBody, - string $htmlBody, - string $from, - string $replyTo - ): Message { - $content = new MessageContent( - subject: $subject, - text: $htmlBody, - textMessage: $textBody, - footer: null, - ); - $format = new MessageFormat( - htmlFormatted: true, - sendFormat: MessageFormat::FORMAT_HTML, - formatOptions: [MessageFormat::FORMAT_HTML] - ); - $schedule = new MessageSchedule( - repeatInterval: 0, - repeatUntil: null, - requeueInterval: 0, - requeueUntil: null, - embargo: null - ); - $metadata = new MessageMetadata(); - $options = new MessageOptions(fromField: $from, toField: '', replyTo: $replyTo); - - return new Message($format, $schedule, $metadata, $content, $options, null, null); - } - - /** - * Subscriber has no public setter for email, so we use reflection. - */ - private function setSubscriberEmail(Subscriber $subscriber, string $email): void - { - $ref = new ReflectionProperty($subscriber, 'email'); - $ref->setValue($subscriber, $email); - } } diff --git a/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php b/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php index de7f09c9..8f7ee7f7 100644 --- a/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php +++ b/tests/Unit/Domain/Messaging/Service/SystemMailConstructorTest.php @@ -7,10 +7,11 @@ use PhpList\Core\Domain\Common\Html2Text; use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; +use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; 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 +36,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 +44,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, @@ -58,8 +59,11 @@ public function testPlainTextWithoutTemplateLinkifiedAndNl2br(): void // Html2Text is not used when source is plain text $this->html2Text->expects($this->never())->method('__invoke'); + $dto = new MessagePrecacheDto(); + $dto->subject = 'Subject'; + $dto->content = 'Line1' . "\n" . 'Visit http://example.com'; - [$html, $text] = $constructor('Line1' . "\n" . 'Visit http://example.com', 'Subject'); + [$html, $text] = $constructor($dto); $this->assertSame("Line1\nVisit http://example.com", $text); $this->assertStringContainsString('Line1method('__invoke') ->with('

Hello

') ->willReturn('Hello'); + $dto = new MessagePrecacheDto(); + $dto->subject = 'Subject'; + $dto->content = '

Hello

'; - [$html, $text] = $constructor('

Hello

', 'Subject'); + [$html, $text] = $constructor($dto); $this->assertSame('

Hello

', $html); $this->assertSame('Hello', $text); @@ -107,15 +114,18 @@ public function testTemplateWithSignaturePlaceholderUsesPoweredByImageWhenFlagFa ->with('Powered') ->willReturn('Powered'); - $constructor = new SystemMailConstructor( + $constructor = new SystemMailContentBuilder( html2Text: $this->html2Text, configProvider: $this->configProvider, templateRepository: $this->templateRepository, templateImageManager: $this->templateImageManager, poweredByPhplist: false, ); + $dto = new MessagePrecacheDto(); + $dto->subject = 'Subject'; + $dto->content = 'Body'; - [$html, $text] = $constructor('Body', 'Subject'); + [$html, $text] = $constructor($dto); // HTML should contain processed powered-by image (src rewritten to powerphplist.png) in place of [SIGNATURE] $this->assertStringContainsString('Subject: Body', $html); @@ -149,15 +159,18 @@ public function testTemplateWithoutSignatureAppendsPoweredByTextAndBeforeBodyEnd ) ->willReturnOnConsecutiveCalls('Hello World', 'PB'); - $constructor = new SystemMailConstructor( + $constructor = new SystemMailContentBuilder( html2Text: $this->html2Text, configProvider: $this->configProvider, templateRepository: $this->templateRepository, templateImageManager: $this->templateImageManager, poweredByPhplist: true, ); + $dto = new MessagePrecacheDto(); + $dto->subject = 'Sub'; + $dto->content = 'Hello World'; - [$html, $text] = $constructor('Hello World', 'Sub'); + [$html, $text] = $constructor($dto); // HTML path: since poweredByPhplist=true, raw PoweredByText should be inserted before $this->assertStringContainsString('Hello World', $html);