From 638456a36399bef23faf7daa0c909bb270e5b9b8 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 30 Sep 2025 12:40:45 +0400 Subject: [PATCH 01/16] Skip password and modified fields while import, do not subscribe blacklisted users --- src/Domain/Subscription/Service/SubscriberCsvImporter.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php index c88b935e..e5ad182b 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php @@ -174,7 +174,7 @@ private function processRow( $this->processAttributes($subscriber, $dto); - if (count($options->listIds) > 0) { + if (!$subscriber->isBlacklisted() && count($options->listIds) > 0) { foreach ($options->listIds as $listId) { $this->subscriptionManager->addSubscriberToAList($subscriber, $listId); } @@ -190,6 +190,12 @@ private function processRow( private function processAttributes(Subscriber $subscriber, ImportSubscriberDto $dto): void { foreach ($dto->extraAttributes as $key => $value) { + $lowerKey = strtolower((string)$key); + // Do not import or update sensitive/system fields from CSV + if (in_array($lowerKey, ['password', 'modified'], true)) { + continue; + } + $attributeDefinition = $this->attrDefinitionRepository->findOneByName($key); if ($attributeDefinition !== null) { $this->attributeManager->createOrUpdate( From 3ed373c34ad351771c971bb90932c906cf2d366e Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 1 Oct 2025 11:58:42 +0400 Subject: [PATCH 02/16] DefaultConfigProvider --- .../Provider/DefaultConfigProvider.php | 561 ++++++++++++++++++ 1 file changed, 561 insertions(+) create mode 100644 src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php diff --git a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php new file mode 100644 index 00000000..e35a124a --- /dev/null +++ b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php @@ -0,0 +1,561 @@ + array( + 'value' => 'webmaster@[DOMAIN]', + 'description' => s('Person in charge of this system (one email address)'), + 'type' => 'email', + 'allowempty' => false, + 'category' => 'general', + ), + 'organisation_name' => array( + 'value' => '', + 'description' => s('Name of the organisation'), + 'type' => 'text', + 'allowempty' => true, + 'allowtags' => '

', + 'allowJS' => false, + 'category' => 'general', + ), + 'organisation_logo' => array( + 'value' => '', + 'description' => s('Logo of the organisation'), + 'infoicon' => true, + 'type' => 'image', + 'allowempty' => true, + 'category' => 'general', + ), + 'date_format' => array( + 'value' => 'j F Y', + 'description' => s('Date format'), + 'infoicon' => true, + 'type' => 'text', + 'allowempty' => false, + 'category' => 'general', + ), + 'rc_notification' => array( + 'value' => 0, + 'description' => s('Show notification for Release Candidates'), + 'type' => 'boolean', + 'allowempty' => true, + 'category' => 'security', + ), + 'remote_processing_secret' => array( + 'value' => bin2hex(random_bytes(10)), + 'description' => s('Secret for remote processing'), + 'type' => 'text', + 'category' => 'security', + ), + 'notify_admin_login' => array( + 'value' => 1, + 'description' => s('Notify admin on login from new location'), + 'type' => 'boolean', + 'category' => 'security', + 'allowempty' => true, + ), + 'admin_addresses' => array( + 'value' => '', + 'description' => s('List of email addresses to CC in system messages (separate by commas)'), + 'type' => 'emaillist', + 'allowempty' => true, + 'category' => 'reporting', + ), + 'campaignfrom_default' => array( + 'value' => '', + 'description' => s("Default for 'From:' in a campaign"), + 'type' => 'text', + 'allowempty' => true, + 'category' => 'campaign', + ), + 'notifystart_default' => array( + 'value' => '', + 'description' => s("Default for 'address to alert when sending starts'"), + 'type' => 'email', + 'allowempty' => true, + 'category' => 'campaign', + ), + 'notifyend_default' => array( + 'value' => '', + 'description' => s("Default for 'address to alert when sending finishes'"), + 'type' => 'email', + 'allowempty' => true, + 'category' => 'campaign', + ), + 'always_add_googletracking' => array( + 'value' => '0', + 'description' => s('Always add analytics tracking code to campaigns'), + 'type' => 'boolean', + 'allowempty' => true, + 'category' => 'campaign', + ), + 'analytic_tracker' => array( + 'values' => array('google' => 'Google Analytics', 'matomo' => 'Matomo'), + 'value' => 'google', + 'description' => s('Analytics tracking code to add to campaign URLs'), + 'type' => 'select', + 'allowempty' => false, + 'category' => 'campaign', + ), + 'report_address' => array( + 'value' => 'listreports@[DOMAIN]', + 'description' => s('Who gets the reports (email address, separate multiple emails with a comma)'), + 'type' => 'emaillist', + 'allowempty' => true, + 'category' => 'reporting', + ), + 'message_from_address' => array( + 'value' => 'noreply@[DOMAIN]', + 'description' => s('From email address for system messages'), + 'type' => 'email', + 'allowempty' => 0, + 'category' => 'transactional', + ), + 'message_from_name' => array( + 'value' => s('Webmaster'), + 'description' => s('Name for system messages'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'transactional', + ), + 'message_replyto_address' => array( + 'value' => 'noreply@[DOMAIN]', + 'description' => s('Reply-to email address for system messages'), + 'type' => 'email', + 'allowempty' => 0, + 'category' => 'transactional', + ), + 'hide_single_list' => array( + 'value' => '1', + 'description' => s('If there is only one visible list, should it be hidden in the page and automatically subscribe users who sign up'), + 'type' => 'boolean', + 'allowempty' => true, + 'category' => 'subscription-ui', + ), + 'list_categories' => array( + 'value' => '', + 'description' => s('Categories for lists. Separate with commas.'), + 'infoicon' => true, + 'type' => 'text', + 'allowempty' => true, + 'category' => 'list-organisation', + ), + + 'displaycategories' => array( + 'value' => 0, + 'description' => s('Display list categories on subscribe page'), + 'type' => 'boolean', + 'allowempty' => false, + 'category' => 'list-organisation', + ), + 'textline_width' => array( + 'value' => '40', + 'description' => s('Width of a textline field (numerical)'), + 'type' => 'integer', + 'min' => 20, + 'max' => 150, + 'category' => 'subscription-ui', + ), + 'textarea_dimensions' => array( + 'value' => '10,40', + 'description' => s('Dimensions of a textarea field (rows,columns)'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'subscription-ui', + ), + 'send_admin_copies' => array( + 'value' => '0', + 'description' => s('Send notifications about subscribe, update and unsubscribe'), + 'type' => 'boolean', + 'allowempty' => true, + 'category' => 'reporting', + ), + 'defaultsubscribepage' => array( + 'value' => 1, + 'description' => s('The default subscribe page when there are multiple'), + 'type' => 'integer', + 'min' => 1, + 'max' => 999, + 'allowempty' => true, + 'category' => 'subscription', + ), + 'defaultmessagetemplate' => array( + 'value' => 0, + 'description' => s('The default HTML template to use when sending a message'), + 'type' => 'text', + 'allowempty' => true, + 'category' => 'campaign', + ), + 'systemmessagetemplate' => array( + 'value' => 0, + 'description' => s('The HTML wrapper template for system messages'), + 'type' => 'integer', + 'min' => 0, + 'max' => 999, // or max(id) from template + 'allowempty' => true, + 'category' => 'transactional', + ), + 'subscribeurl' => array( + 'value' => $GLOBALS['public_scheme']."://[WEBSITE]$pageroot/?p=subscribe", + 'description' => s('URL where subscribers can sign up'), + 'type' => 'url', + 'allowempty' => 0, + 'category' => 'subscription', + ), + 'unsubscribeurl' => array( + 'value' => $GLOBALS['public_scheme']."://[WEBSITE]$pageroot/?p=unsubscribe", + 'description' => s('URL where subscribers can unsubscribe'), + 'type' => 'url', + 'allowempty' => 0, + 'category' => 'subscription', + ), + 'blacklisturl' => array( + 'value' => $GLOBALS['public_scheme']."://[WEBSITE]$pageroot/?p=donotsend", + 'description' => s('URL where unknown users can unsubscribe (do-not-send-list)'), + 'type' => 'url', + 'allowempty' => 0, + 'category' => 'subscription', + ), + 'confirmationurl' => array( + 'value' => $GLOBALS['public_scheme']."://[WEBSITE]$pageroot/?p=confirm", + 'description' => s('URL where subscribers have to confirm their subscription'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'subscription', + ), + 'preferencesurl' => array( + 'value' => $GLOBALS['public_scheme']."://[WEBSITE]$pageroot/?p=preferences", + 'description' => s('URL where subscribers can update their details'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'subscription', + ), + 'forwardurl' => array( + 'value' => $GLOBALS['public_scheme']."://[WEBSITE]$pageroot/?p=forward", + 'description' => s('URL for forwarding messages'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'subscription', + ), + 'vcardurl' => array( + 'value' => $GLOBALS['public_scheme']."://[WEBSITE]$pageroot/?p=vcard", + 'description' => s('URL for downloading vcf card'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'subscription', + ), + 'ajax_subscribeconfirmation' => array( + 'value' => s('

Thanks, you have been added to our newsletter

You will receive an email to confirm your subscription. Please click the link in the email to confirm

'), + 'description' => s('Text to display when subscription with an AJAX request was successful'), + 'type' => 'textarea', + 'allowempty' => true, + 'category' => 'subscription', + ), + 'subscribesubject' => array( + 'value' => s('Request for confirmation'), + 'description' => s('Subject of the message subscribers receive when they sign up'), + 'infoicon' => true, + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'transactional', + ), + 'subscribemessage' => array( + 'value' => + ' You have been subscribed to the following newsletters: + +[LISTS] + + +Please click the following link to confirm it\'s really you: + +[CONFIRMATIONURL] + + +In order to provide you with this service we\'ll need to + +Transfer your contact information to [DOMAIN] +Store your contact information in your [DOMAIN] account +Send you emails from [DOMAIN] +Track your interactions with these emails for marketing purposes + +If this is not correct, or you do not agree, simply take no action and delete this message.' + , + 'description' => s('Message subscribers receive when they sign up'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ), + 'unsubscribesubject' => array( + 'value' => s('Goodbye from our Newsletter'), + 'description' => s('Subject of the message subscribers receive when they unsubscribe'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'transactional', + ), + 'unsubscribemessage' => array( + 'value' => + 'Goodbye from our Newsletter, sorry to see you go. + +You have been unsubscribed from our newsletters. + +This is the last email you will receive from us. Our newsletter system, phpList, +will refuse to send you any further messages, without manual intervention by our administrator. + +If there is an error in this information, you can re-subscribe: +please go to [SUBSCRIBEURL] and follow the steps. + +Thank you' + , + 'description' => s('Message subscribers receive when they unsubscribe'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ), + 'confirmationsubject' => array( + 'value' => s('Welcome to our Newsletter'), + 'description' => s('Subject of the message subscribers receive after confirming their email address'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'transactional', + ), + 'confirmationmessage' => array( + 'value' => + 'Welcome to our Newsletter + +Please keep this message for later reference. + +Your email address has been added to the following newsletter(s): +[LISTS] + +To update your details and preferences please go to [PREFERENCESURL]. +If you do not want to receive any more messages, please go to [UNSUBSCRIBEURL]. + +Thank you' + , + 'description' => s('Message subscribers receive after confirming their email address'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ), + 'updatesubject' => array( + 'value' => s('[notify] Change of List-Membership details'), + 'description' => s('Subject of the message subscribers receive when they have changed their details'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'transactional', + ), + // the message that is sent when a user updates their information. + // just to make sure they approve of it. + // confirmationinfo is replaced by one of the options below + // userdata is replaced by the information in the database + 'updatemessage' => array( + 'value' => + 'This message is to inform you of a change of your details on our newsletter database + +You are currently member of the following newsletters: + +[LISTS] + +[CONFIRMATIONINFO] + +The information on our system for you is as follows: + +[USERDATA] + +If this is not correct, please update your information at the following location: + +[PREFERENCESURL] + +Thank you' + , + 'description' => s('Message subscribers receive when they have changed their details'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ), + // this is the text that is placed in the [!-- confirmation --] location of the above + // message, in case the email is sent to their new email address and they have changed + // their email address + 'emailchanged_text' => array( + 'value' => ' + When updating your details, your email address has changed. + Please confirm your new email address by visiting this webpage: + + [CONFIRMATIONURL] + + ', + 'description' => s('Part of the message that is sent to their new email address when subscribers change their information, and the email address has changed'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ), + // this is the text that is placed in the [!-- confirmation --] location of the above + // message, in case the email is sent to their old email address and they have changed + // their email address + 'emailchanged_text_oldaddress' => array( + 'value' => + 'Please Note: when updating your details, your email address has changed. + +A message has been sent to your new email address with a URL +to confirm this change. Please visit this website to activate +your membership.' + , + 'description' => s('Part of the message that is sent to their old email address when subscribers change their information, and the email address has changed'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ), + 'personallocation_subject' => array( + 'value' => s('Your personal location'), + 'description' => s('Subject of message when subscribers request their personal location'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'transactional', + ), + 'messagefooter' => array( + 'value' => '-- + + + + ', + 'description' => s('Default footer for sending a campaign'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'campaign', + ), + 'forwardfooter' => array( + 'value' => ' + + ', + 'description' => s('Footer used when a message has been forwarded'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'campaign', + ), + 'personallocation_message' => array( + 'value' => + + 'You have requested your personal location to update your details from our website. +The location is below. Please make sure that you use the full line as mentioned below. +Sometimes email programmes can wrap the line into multiple lines. + +Your personal location is: +[PREFERENCESURL] + +Thank you.' + , + 'description' => s('Message to send when they request their personal location'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ), + 'remoteurl_append' => array( + 'value' => '', + 'description' => s('String to always append to remote URL when using send-a-webpage'), + 'type' => 'text', + 'allowempty' => true, + 'category' => 'campaign', + ), + 'wordwrap' => array( + 'value' => '75', + 'description' => s('Width for Wordwrap of Text messages'), + 'type' => 'text', + 'allowempty' => true, + 'category' => 'campaign', + ), + 'html_email_style' => array( + 'value' => '', + 'description' => s('CSS for HTML messages without a template'), + 'type' => 'textarea', + 'allowempty' => true, + 'category' => 'campaign', + ), + 'alwayssendtextto' => array( + 'value' => '', + 'description' => s('Domains that only accept text emails, one per line'), + 'type' => 'textarea', + 'allowempty' => true, + 'category' => 'campaign', + ), + 'tld_last_sync' => array( + 'value' => '0', + 'description' => s('last time TLDs were fetched'), + 'type' => 'text', + 'allowempty' => true, + 'category' => 'system', + 'hidden' => true, + ), + 'internet_tlds' => array( + 'value' => '', + 'description' => s('Top level domains'), + 'type' => 'textarea', + 'allowempty' => true, + 'category' => 'system', + 'hidden' => true, + ), + 'pageheader' => array( + 'value' => '

Welcome

', + 'description' => s('Header of public pages.'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'subscription-ui', + ), + 'pagefooter' => array( + 'value' => '

Footer text

', + 'description' => s('Footer of public pages'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'subscription-ui', + ), + ); + } + + /** + * Get a single default config item by key + * + * @param string $key + * @param mixed $default + * @return mixed + */ + public static function get(string $key, $default = null) + { + self::init(); + return self::$defaults[$key] ?? $default; + } + + /** + * Check if a config key exists + */ + public static function has(string $key): bool + { + self::init(); + return isset(self::$defaults[$key]); + } +} From 5d968e2f3331cef5c412905ac25085c3860a9639 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 1 Oct 2025 12:24:00 +0400 Subject: [PATCH 03/16] Use ConfigProvider in ProcessQueueCommand --- .../Configuration/Model/ConfigOption.php | 11 +++++ .../Service/Manager/ConfigManager.php | 6 --- .../Service/Provider/ConfigProvider.php | 44 +++++++++++++++++++ .../Messaging/Command/ProcessQueueCommand.php | 11 ++--- 4 files changed, 61 insertions(+), 11 deletions(-) create mode 100644 src/Domain/Configuration/Model/ConfigOption.php create mode 100644 src/Domain/Configuration/Service/Provider/ConfigProvider.php diff --git a/src/Domain/Configuration/Model/ConfigOption.php b/src/Domain/Configuration/Model/ConfigOption.php new file mode 100644 index 00000000..ebcd2202 --- /dev/null +++ b/src/Domain/Configuration/Model/ConfigOption.php @@ -0,0 +1,11 @@ +configRepository = $configRepository; } - public function inMaintenanceMode(): bool - { - $config = $this->getByItem('maintenancemode'); - return $config?->getValue() === '1'; - } - /** * Get a configuration item by its key */ diff --git a/src/Domain/Configuration/Service/Provider/ConfigProvider.php b/src/Domain/Configuration/Service/Provider/ConfigProvider.php new file mode 100644 index 00000000..f4ac4ba1 --- /dev/null +++ b/src/Domain/Configuration/Service/Provider/ConfigProvider.php @@ -0,0 +1,44 @@ +configRepository = $configRepository; + } + + public function isEnabled(ConfigOption $key): bool + { + if (!in_array($key, $this->booleanValues)) { + throw new InvalidArgumentException('Invalid boolean value key'); + } + $config = $this->configRepository->findOneBy(['item' => $key->value]); + + return $config?->getValue() === '1'; + } + + /** + * Get configuration value by its key + */ + public function getValue(string $ikey, ?string $default = null): ?string + { + $config = $this->configRepository->findOneBy(['item' => $ikey]); + + return $config?->getValue() ?? $default; + } + +} diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php index b9a9068a..7ed9c0b5 100644 --- a/src/Domain/Messaging/Command/ProcessQueueCommand.php +++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php @@ -5,7 +5,8 @@ namespace PhpList\Core\Domain\Messaging\Command; use DateTimeImmutable; -use PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager; +use PhpList\Core\Domain\Configuration\Model\ConfigOption; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; @@ -28,7 +29,7 @@ class ProcessQueueCommand extends Command private LockFactory $lockFactory; private MessageProcessingPreparator $messagePreparator; private CampaignProcessor $campaignProcessor; - private ConfigManager $configManager; + private ConfigProvider $configProvider; private TranslatorInterface $translator; public function __construct( @@ -36,7 +37,7 @@ public function __construct( LockFactory $lockFactory, MessageProcessingPreparator $messagePreparator, CampaignProcessor $campaignProcessor, - ConfigManager $configManager, + ConfigProvider $configProvider, TranslatorInterface $translator ) { parent::__construct(); @@ -44,7 +45,7 @@ public function __construct( $this->lockFactory = $lockFactory; $this->messagePreparator = $messagePreparator; $this->campaignProcessor = $campaignProcessor; - $this->configManager = $configManager; + $this->configProvider = $configProvider; $this->translator = $translator; } @@ -60,7 +61,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - if ($this->configManager->inMaintenanceMode()) { + if ($this->configProvider->isEnabled(ConfigOption::MaintenanceMode)) { $output->writeln( $this->translator->trans('The system is in maintenance mode, stopping. Try again later.') ); From 0e2aab3a3e40bda0956c54f0388b5c2fdb924b16 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 1 Oct 2025 12:24:36 +0400 Subject: [PATCH 04/16] Use ConfigProvider in SubscriberCsvImporter + send email --- .../Service/SubscriberCsvImporter.php | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php index e5ad182b..a1f3fb6c 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php @@ -5,6 +5,8 @@ namespace PhpList\Core\Domain\Subscription\Service; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Configuration\Model\ConfigOption; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Subscription\Exception\CouldNotReadUploadedFileException; use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions; @@ -14,6 +16,9 @@ use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager; +use PhpList\Core\Domain\Messaging\Service\EmailService; +use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; +use Symfony\Component\Mime\Email; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Contracts\Translation\TranslatorInterface; use Throwable; @@ -32,6 +37,9 @@ class SubscriberCsvImporter private SubscriberAttributeDefinitionRepository $attrDefinitionRepository; private EntityManagerInterface $entityManager; private TranslatorInterface $translator; + private EmailService $emailService; + private ConfigProvider $configProvider; + private SubscriberListRepository $subscriberListRepository; public function __construct( SubscriberManager $subscriberManager, @@ -42,6 +50,9 @@ public function __construct( SubscriberAttributeDefinitionRepository $attrDefinitionRepository, EntityManagerInterface $entityManager, TranslatorInterface $translator, + EmailService $emailService, + ConfigProvider $configProvider, + SubscriberListRepository $subscriberListRepository, ) { $this->subscriberManager = $subscriberManager; $this->attributeManager = $attributeManager; @@ -51,6 +62,9 @@ public function __construct( $this->attrDefinitionRepository = $attrDefinitionRepository; $this->entityManager = $entityManager; $this->translator = $translator; + $this->emailService = $emailService; + $this->configProvider = $configProvider; + $this->subscriberListRepository = $subscriberListRepository; } /** @@ -83,9 +97,6 @@ public function importFromCsv(UploadedFile $file, SubscriberImportOptions $optio foreach ($result['valid'] as $dto) { try { $this->processRow($dto, $options, $stats); - if (!$options->dryRun) { - $this->entityManager->flush(); - } } catch (Throwable $e) { $stats['errors'][] = $this->translator->trans( 'Error processing %email%: %error%', @@ -179,6 +190,37 @@ private function processRow( $this->subscriptionManager->addSubscriberToAList($subscriber, $listId); } } + + if (!$options->dryRun) { + $this->entityManager->flush(); + if ($this->configProvider->isEnabled(ConfigOption::SendSubscribeMessage)) { + $this->sendSubscribeEmail($dto->email, $options->listIds); + } + } + } + + private function sendSubscribeEmail(string $subscriberEmail, array $listIds): void + { + $listNames = []; + foreach ($listIds as $id) { + $list = $this->subscriberListRepository->find($id); + if ($list) { + $listNames[] = $list->getName(); + } + } + $listOfLists = implode(', ', $listNames); + + $subject = $this->configProvider->getValue('subscribesubject', 'Subscription'); + $message = $this->configProvider->getValue('subscribemessage', 'You have been subscribed to: [LISTS]'); + $message = str_replace('[LISTS]', $listOfLists, (string)$message); + + $email = (new Email()) + ->to($subscriberEmail) + ->subject((string)$subject) + ->text($message) + ->html(nl2br(htmlentities($message))); + + $this->emailService->sendEmail($email); } /** From e17b861cecfc374795424a6681c2aea1494cdc3e Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 1 Oct 2025 12:33:07 +0400 Subject: [PATCH 05/16] Rename paramProvider --- .../{ConfigProvider.php => ParameterProvider.php} | 2 +- src/Domain/Analytics/Service/LinkTrackService.php | 10 +++++----- tests/Integration/Core/ConfigProviderTest.php | 8 ++++---- .../Analytics/Service/LinkTrackServiceTest.php | 14 +++++++------- 4 files changed, 17 insertions(+), 17 deletions(-) rename src/Core/{ConfigProvider.php => ParameterProvider.php} (93%) diff --git a/src/Core/ConfigProvider.php b/src/Core/ParameterProvider.php similarity index 93% rename from src/Core/ConfigProvider.php rename to src/Core/ParameterProvider.php index b78f365f..ac278984 100644 --- a/src/Core/ConfigProvider.php +++ b/src/Core/ParameterProvider.php @@ -4,7 +4,7 @@ namespace PhpList\Core\Core; -class ConfigProvider +class ParameterProvider { public function __construct(private readonly array $config) { diff --git a/src/Domain/Analytics/Service/LinkTrackService.php b/src/Domain/Analytics/Service/LinkTrackService.php index 2252a0f0..75242104 100644 --- a/src/Domain/Analytics/Service/LinkTrackService.php +++ b/src/Domain/Analytics/Service/LinkTrackService.php @@ -4,7 +4,7 @@ namespace PhpList\Core\Domain\Analytics\Service; -use PhpList\Core\Core\ConfigProvider; +use PhpList\Core\Core\ParameterProvider; use PhpList\Core\Domain\Analytics\Exception\MissingMessageIdException; use PhpList\Core\Domain\Analytics\Model\LinkTrack; use PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository; @@ -13,12 +13,12 @@ class LinkTrackService { private LinkTrackRepository $linkTrackRepository; - private ConfigProvider $configProvider; + private ParameterProvider $paramProvider; - public function __construct(LinkTrackRepository $linkTrackRepository, ConfigProvider $configProvider) + public function __construct(LinkTrackRepository $linkTrackRepository, ParameterProvider $paramProvider) { $this->linkTrackRepository = $linkTrackRepository; - $this->configProvider = $configProvider; + $this->paramProvider = $paramProvider; } public function getUrlById(int $id): ?string @@ -29,7 +29,7 @@ public function getUrlById(int $id): ?string public function isExtractAndSaveLinksApplicable(): bool { - return (bool)$this->configProvider->get('click_track', false); + return (bool)$this->paramProvider->get('click_track', false); } /** diff --git a/tests/Integration/Core/ConfigProviderTest.php b/tests/Integration/Core/ConfigProviderTest.php index d2fdf896..5776bd17 100644 --- a/tests/Integration/Core/ConfigProviderTest.php +++ b/tests/Integration/Core/ConfigProviderTest.php @@ -4,14 +4,14 @@ namespace PhpList\Core\Tests\Integration\Core; -use PhpList\Core\Core\ConfigProvider; +use PhpList\Core\Core\ParameterProvider; use PHPUnit\Framework\TestCase; class ConfigProviderTest extends TestCase { public function testReturnsConfigValueIfExists(): void { - $provider = new ConfigProvider([ + $provider = new ParameterProvider([ 'site_name' => 'phpList', 'debug' => true, ]); @@ -22,7 +22,7 @@ public function testReturnsConfigValueIfExists(): void public function testReturnsDefaultIfKeyMissing(): void { - $provider = new ConfigProvider([ + $provider = new ParameterProvider([ 'site_name' => 'phpList', ]); @@ -33,7 +33,7 @@ public function testReturnsDefaultIfKeyMissing(): void public function testReturnsAllConfig(): void { $data = ['a' => 1, 'b' => 2]; - $provider = new ConfigProvider($data); + $provider = new ParameterProvider($data); $this->assertSame($data, $provider->all()); } diff --git a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php index 109fb634..c136ec51 100644 --- a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php @@ -4,7 +4,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Analytics\Service; -use PhpList\Core\Core\ConfigProvider; +use PhpList\Core\Core\ParameterProvider; use PhpList\Core\Domain\Analytics\Exception\MissingMessageIdException; use PhpList\Core\Domain\Analytics\Model\LinkTrack; use PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository; @@ -22,13 +22,13 @@ class LinkTrackServiceTest extends TestCase protected function setUp(): void { $this->linkTrackRepository = $this->createMock(LinkTrackRepository::class); - $configProvider = $this->createMock(ConfigProvider::class); + $paramProvider = $this->createMock(ParameterProvider::class); - $configProvider->method('get') + $paramProvider->method('get') ->with('click_track', false) ->willReturn(true); - $this->subject = new LinkTrackService($this->linkTrackRepository, $configProvider); + $this->subject = new LinkTrackService($this->linkTrackRepository, $paramProvider); } public function testExtractAndSaveLinksWithNoLinks(): void @@ -185,12 +185,12 @@ public function testIsExtractAndSaveLinksApplicableWhenClickTrackIsTrue(): void public function testIsExtractAndSaveLinksApplicableWhenClickTrackIsFalse(): void { - $configProvider = $this->createMock(ConfigProvider::class); - $configProvider->method('get') + $paramProvider = $this->createMock(ParameterProvider::class); + $paramProvider->method('get') ->with('click_track', false) ->willReturn(false); - $subject = new LinkTrackService($this->linkTrackRepository, $configProvider); + $subject = new LinkTrackService($this->linkTrackRepository, $paramProvider); self::assertFalse($subject->isExtractAndSaveLinksApplicable()); } From 183af8f533e150b78b4cca594ded4618dbb5db57 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 1 Oct 2025 12:49:41 +0400 Subject: [PATCH 06/16] Test fix --- .../Configuration/Service/Provider/ConfigProvider.php | 1 + .../Subscription/Service/SubscriberCsvImporter.php | 6 +++--- .../Service/SubscriberCsvImporterTest.php | 11 +++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Domain/Configuration/Service/Provider/ConfigProvider.php b/src/Domain/Configuration/Service/Provider/ConfigProvider.php index f4ac4ba1..aed0e161 100644 --- a/src/Domain/Configuration/Service/Provider/ConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/ConfigProvider.php @@ -12,6 +12,7 @@ class ConfigProvider { private array $booleanValues = [ ConfigOption::MaintenanceMode, + ConfigOption::SendSubscribeMessage, ]; private ConfigRepository $configRepository; diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php index a1f3fb6c..63fd40e6 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php @@ -7,19 +7,19 @@ use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; +use PhpList\Core\Domain\Messaging\Service\EmailService; use PhpList\Core\Domain\Subscription\Exception\CouldNotReadUploadedFileException; use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; +use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager; -use PhpList\Core\Domain\Messaging\Service\EmailService; -use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; -use Symfony\Component\Mime\Email; use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\Mime\Email; use Symfony\Contracts\Translation\TranslatorInterface; use Throwable; diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php index f825f704..af387ce9 100644 --- a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php +++ b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php @@ -11,6 +11,10 @@ use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; +use PhpList\Core\Domain\Configuration\Model\ConfigOption; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; +use PhpList\Core\Domain\Messaging\Service\EmailService; +use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use PhpList\Core\Domain\Subscription\Service\CsvImporter; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; @@ -39,6 +43,10 @@ protected function setUp(): void $this->csvImporterMock = $this->createMock(CsvImporter::class); $this->attributeDefinitionRepositoryMock = $this->createMock(SubscriberAttributeDefinitionRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); + $configProvider = $this->createMock(ConfigProvider::class); + $emailService = $this->createMock(EmailService::class); + $subscriberListRepository = $this->createMock(SubscriberListRepository::class); + $configProvider->method('isEnabled')->with(ConfigOption::SendSubscribeMessage)->willReturn(false); $this->subject = new SubscriberCsvImporter( subscriberManager: $this->subscriberManagerMock, @@ -49,6 +57,9 @@ protected function setUp(): void attrDefinitionRepository: $this->attributeDefinitionRepositoryMock, entityManager: $entityManager, translator: new Translator('en'), + emailService: $emailService, + configProvider: $configProvider, + subscriberListRepository: $subscriberListRepository, ); } From 3bcf040c14f2427ef521b2946e6587f2e3253a6f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 2 Oct 2025 10:48:35 +0400 Subject: [PATCH 07/16] AttributeValueProvider for dynamic tables --- config/services/providers.yml | 8 +- config/services/repositories.yml | 99 ++++++--------- config/services/resolvers.yml | 15 +++ config/services/services.yml | 8 -- .../Repository/DynamicListAttrRepository.php | 60 +++++++++ .../Provider/AttributeValueProvider.php | 16 +++ .../Provider/CheckboxGroupValueProvider.php | 39 ++++++ .../Service/Provider/ScalarValueProvider.php | 21 ++++ .../Provider/SelectOrRadioValueProvider.php | 31 +++++ .../Resolver/AttributeValueResolver.php | 25 ++++ .../CheckboxGroupValueProviderTest.php | 118 ++++++++++++++++++ 11 files changed, 372 insertions(+), 68 deletions(-) create mode 100644 config/services/resolvers.yml create mode 100644 src/Domain/Subscription/Repository/DynamicListAttrRepository.php create mode 100644 src/Domain/Subscription/Service/Provider/AttributeValueProvider.php create mode 100644 src/Domain/Subscription/Service/Provider/CheckboxGroupValueProvider.php create mode 100644 src/Domain/Subscription/Service/Provider/ScalarValueProvider.php create mode 100644 src/Domain/Subscription/Service/Provider/SelectOrRadioValueProvider.php create mode 100644 src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php create mode 100644 tests/Unit/Domain/Subscription/Service/Provider/CheckboxGroupValueProviderTest.php diff --git a/config/services/providers.yml b/config/services/providers.yml index bb4524c3..17b69e39 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -3,7 +3,7 @@ services: autowire: true autoconfigure: true - PhpList\Core\Core\ConfigProvider: + PhpList\Core\Core\ParameterProvider: arguments: $config: '%app.config%' @@ -12,3 +12,9 @@ services: autoconfigure: true arguments: $confPath: '%app.phplist_isp_conf_path%' + + PhpList\Core\Domain\Subscription\Service\Provider\CheckboxGroupValueProvider: ~ + PhpList\Core\Domain\Subscription\Service\Provider\SelectOrRadioValueProvider: ~ + PhpList\Core\Domain\Subscription\Service\Provider\ScalarValueProvider: ~ + + PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider: diff --git a/config/services/repositories.yml b/config/services/repositories.yml index 1289bea7..ac5b50a9 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -1,156 +1,137 @@ services: + PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Analytics\Model\LinkTrack + PhpList\Core\Domain\Analytics\Repository\UserMessageViewRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Analytics\Model\UserMessageView + PhpList\Core\Domain\Analytics\Repository\LinkTrackUmlClickRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Analytics\Model\LinkTrackUmlClick + + PhpList\Core\Domain\Configuration\Repository\ConfigRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Configuration\Model\Config - PhpList\Core\Domain\Configuration\Repository\EventLogRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Configuration\Model\EventLog + PhpList\Core\Domain\Identity\Repository\AdministratorRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Identity\Model\Administrator - Doctrine\ORM\Mapping\ClassMetadata\ClassMetadata - PhpList\Core\Security\HashGenerator - PhpList\Core\Domain\Identity\Repository\AdminAttributeValueRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Identity\Model\AdminAttributeValue - PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition - PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Identity\Model\AdministratorToken - PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Identity\Model\AdminPasswordRequest + PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Subscription\Model\SubscriberList - PhpList\Core\Domain\Subscription\Repository\SubscriberRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Subscription\Model\Subscriber - PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue - PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition - PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Subscription\Model\Subscription + PhpList\Core\Domain\Subscription\Repository\DynamicListAttrRepository: + arguments: + $prefix: '%env(default:phplist_ DATABASE_PREFIX)%' + PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscriberHistory + PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\UserBlacklist + PhpList\Core\Domain\Subscription\Repository\UserBlacklistDataRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\UserBlacklistData + PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscribePage + PhpList\Core\Domain\Subscription\Repository\SubscriberPageDataRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscribePageData + PhpList\Core\Domain\Messaging\Repository\MessageRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Messaging\Model\Message - PhpList\Core\Domain\Messaging\Repository\TemplateRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Messaging\Model\Template - PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Messaging\Model\TemplateImage - PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Messaging\Model\UserMessageBounce - PhpList\Core\Domain\Messaging\Repository\UserMessageForwardRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Messaging\Model\UserMessageForward - - PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Analytics\Model\LinkTrack - - PhpList\Core\Domain\Analytics\Repository\UserMessageViewRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Analytics\Model\UserMessageView - - PhpList\Core\Domain\Analytics\Repository\LinkTrackUmlClickRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Analytics\Model\LinkTrackUmlClick - PhpList\Core\Domain\Messaging\Repository\UserMessageRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Messaging\Model\UserMessage - - PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\SubscriberHistory - PhpList\Core\Domain\Messaging\Repository\ListMessageRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Messaging\Model\ListMessage - - PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\UserBlacklist - - PhpList\Core\Domain\Subscription\Repository\UserBlacklistDataRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\UserBlacklistData - - PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\SubscribePage - - PhpList\Core\Domain\Subscription\Repository\SubscriberPageDataRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Subscription\Model\SubscribePageData - PhpList\Core\Domain\Messaging\Repository\BounceRegexRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Messaging\Model\BounceRegex - PhpList\Core\Domain\Messaging\Repository\BounceRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Messaging\Model\Bounce - PhpList\Core\Domain\Messaging\Repository\BounceRegexBounceRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Messaging\Model\BounceRegex - PhpList\Core\Domain\Messaging\Repository\SendProcessRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: diff --git a/config/services/resolvers.yml b/config/services/resolvers.yml new file mode 100644 index 00000000..bf8f9fc7 --- /dev/null +++ b/config/services/resolvers.yml @@ -0,0 +1,15 @@ +services: + PhpList\Core\Domain\Subscription\Service\Resolver\AttributeValueResolver: + arguments: + $providers: + - '@PhpList\Core\Domain\Subscription\Service\Provider\CheckboxGroupValueProvider' + - '@PhpList\Core\Domain\Subscription\Service\Provider\SelectOrRadioValueProvider' + - '@PhpList\Core\Domain\Subscription\Service\Provider\ScalarValueProvider' + + PhpList\Core\Domain\Common\ClientIpResolver: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\BounceActionResolver: + arguments: + - !tagged_iterator { tag: 'phplist.bounce_action_handler' } diff --git a/config/services/services.yml b/config/services/services.yml index 89bf99b9..fac384b9 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -44,10 +44,6 @@ services: $mailqueueBatchPeriod: '%messaging.mail_queue_period%' $mailqueueThrottle: '%messaging.mail_queue_throttle%' - PhpList\Core\Domain\Common\ClientIpResolver: - autowire: true - autoconfigure: true - PhpList\Core\Domain\Common\SystemInfoCollector: autowire: true autoconfigure: true @@ -118,10 +114,6 @@ services: autoconfigure: true resource: '../../src/Domain/Messaging/Service/Handler/*Handler.php' - PhpList\Core\Domain\Messaging\Service\BounceActionResolver: - arguments: - - !tagged_iterator { tag: 'phplist.bounce_action_handler' } - PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter: autowire: true autoconfigure: true diff --git a/src/Domain/Subscription/Repository/DynamicListAttrRepository.php b/src/Domain/Subscription/Repository/DynamicListAttrRepository.php new file mode 100644 index 00000000..c951d5a3 --- /dev/null +++ b/src/Domain/Subscription/Repository/DynamicListAttrRepository.php @@ -0,0 +1,60 @@ + + * @throws Exception + */ + public function fetchOptionNames(string $listTable, array $ids): array + { + if (empty($ids)) return []; + + if (!preg_match('/^[A-Za-z0-9_]+$/', $listTable)) { + throw new InvalidArgumentException('Invalid list table'); + } + + $table = $this->prefix . 'listattr_' . $listTable; + + $qb = $this->connection->createQueryBuilder(); + $qb->select('name') + ->from($table) + ->where('id IN (:ids)') + ->setParameter('ids', array_map('intval', $ids), ArrayParameterType::INTEGER); + + return $qb->executeQuery()->fetchFirstColumn(); + } + + public function fetchSingleOptionName(string $listTable, int $id): ?string + { + if (!preg_match('/^[A-Za-z0-9_]+$/', $listTable)) { + throw new InvalidArgumentException('Invalid list table'); + } + + $table = $this->prefix . 'listattr_' . $listTable; + + $qb = $this->connection->createQueryBuilder(); + $qb->select('name') + ->from($table) + ->where('id = :id') + ->setParameter('id', $id); + + $val = $qb->executeQuery()->fetchOne(); + + return $val === false ? null : (string) $val; + } +} diff --git a/src/Domain/Subscription/Service/Provider/AttributeValueProvider.php b/src/Domain/Subscription/Service/Provider/AttributeValueProvider.php new file mode 100644 index 00000000..e4f9f31f --- /dev/null +++ b/src/Domain/Subscription/Service/Provider/AttributeValueProvider.php @@ -0,0 +1,16 @@ +getType() === 'checkboxgroup'; + } + + public function getValue(SubscriberAttributeDefinition $attribute, SubscriberAttributeValue $userValue): string + { + $csv = $userValue->getValue() ?? ''; + if ($csv === '') { + return ''; + } + + $ids = array_values(array_filter(array_map( + fn($v) => ($i = (int)trim($v)) > 0 ? $i : null, + explode(',', $csv) + ))); + + if (empty($ids) || !$attribute->getTableName()) return ''; + + $names = $this->repo->fetchOptionNames($attribute->getTableName(), $ids); + + return implode('; ', $names); + } +} diff --git a/src/Domain/Subscription/Service/Provider/ScalarValueProvider.php b/src/Domain/Subscription/Service/Provider/ScalarValueProvider.php new file mode 100644 index 00000000..427fe1db --- /dev/null +++ b/src/Domain/Subscription/Service/Provider/ScalarValueProvider.php @@ -0,0 +1,21 @@ +getType() === null; + } + + public function getValue(SubscriberAttributeDefinition $attribute, SubscriberAttributeValue $userValue): string + { + return $userValue->getValue() ?? ''; + } +} diff --git a/src/Domain/Subscription/Service/Provider/SelectOrRadioValueProvider.php b/src/Domain/Subscription/Service/Provider/SelectOrRadioValueProvider.php new file mode 100644 index 00000000..7e4d8fc0 --- /dev/null +++ b/src/Domain/Subscription/Service/Provider/SelectOrRadioValueProvider.php @@ -0,0 +1,31 @@ +getType(), ['select','radio'], true); + } + + public function getValue(SubscriberAttributeDefinition $attribute, SubscriberAttributeValue $userValue): string + { + if (!$attribute->getTableName()) return ''; + + $id = (int)($userValue->getValue() ?? 0); + if ($id <= 0) { + return ''; + } + + return $this->repo->fetchSingleOptionName($attribute->getTableName(), $id) ?? ''; + } +} diff --git a/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php b/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php new file mode 100644 index 00000000..4b294c14 --- /dev/null +++ b/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php @@ -0,0 +1,25 @@ + $providers */ + public function __construct(private readonly iterable $providers) {} + + public function resolve(SubscriberAttributeDefinition $attribute, SubscriberAttributeValue $userAttr): string + { + foreach ($this->providers as $provider) { + if ($provider->supports($attribute)) { + return $provider->getValue($attribute, $userAttr); + } + } + return ''; + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Provider/CheckboxGroupValueProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/CheckboxGroupValueProviderTest.php new file mode 100644 index 00000000..b5be5650 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/Provider/CheckboxGroupValueProviderTest.php @@ -0,0 +1,118 @@ +repo = $this->createMock(DynamicListAttrRepository::class); + $this->subject = new CheckboxGroupValueProvider($this->repo); + } + + private function createAttribute( + string $type = 'checkboxgroup', + ?string $tableName = 'colors' + ): SubscriberAttributeDefinition { + $attr = new SubscriberAttributeDefinition(); + $attr->setName('prefs')->setType($type)->setTableName($tableName); + + return $attr; + } + + private function createUserAttr(SubscriberAttributeDefinition $def, ?string $value): SubscriberAttributeValue + { + $subscriber = new Subscriber(); + $userAttr = new SubscriberAttributeValue($def, $subscriber); + $userAttr->setValue($value); + + return $userAttr; + } + + public function testSupportsReturnsTrueForCheckboxgroup(): void + { + $attr = $this->createAttribute('checkboxgroup'); + self::assertTrue($this->subject->supports($attr)); + } + + public function testSupportsReturnsFalseForOtherTypes(): void + { + $attr = $this->createAttribute('textline'); + self::assertFalse($this->subject->supports($attr)); + } + + public function testGetValueReturnsEmptyStringForNullOrEmptyValue(): void + { + $attr = $this->createAttribute(); + + $uaNull = $this->createUserAttr($attr, null); + self::assertSame('', $this->subject->getValue($attr, $uaNull)); + + $uaEmpty = $this->createUserAttr($attr, ''); + self::assertSame('', $this->subject->getValue($attr, $uaEmpty)); + } + + public function testGetValueReturnsEmptyStringWhenNoParsedIds(): void + { + $attr = $this->createAttribute(); + $ua = $this->createUserAttr($attr, '0, -1, foo, bar'); + + // Repository should not be called in this case + $this->repo->expects(self::never())->method('fetchOptionNames'); + + self::assertSame('', $this->subject->getValue($attr, $ua)); + } + + public function testGetValueReturnsEmptyStringWhenNoTableName(): void + { + $attr = $this->createAttribute('checkboxgroup', null); + $ua = $this->createUserAttr($attr, '1,2'); + + $this->repo->expects(self::never())->method('fetchOptionNames'); + + self::assertSame('', $this->subject->getValue($attr, $ua)); + } + + public function testGetValueFetchesNamesAndJoinsWithSemicolon(): void + { + $attr = $this->createAttribute('checkboxgroup', 'colors'); + $ua = $this->createUserAttr($attr, '1, 2,3'); + + $this->repo + ->expects(self::once()) + ->method('fetchOptionNames') + ->with('colors', [1, 2, 3]) + ->willReturn(['Red', 'Green', 'Blue']); + + self::assertSame('Red; Green; Blue', $this->subject->getValue($attr, $ua)); + } + + public function testGetValueParsesAndPreservesOrderAndFiltersInvalids(): void + { + $attr = $this->createAttribute('checkboxgroup', 'sizes'); + $ua = $this->createUserAttr($attr, '3, 0, -2, two, 1, 2 , 2'); + // After parsing: [3,1,2,2] -> duplicates are allowed and passed through to repository + $this->repo + ->expects(self::once()) + ->method('fetchOptionNames') + ->with('sizes', [3, 1, 2, 2]) + ->willReturn(['Large', 'Small', 'Medium', 'Medium']); + + self::assertSame('Large; Small; Medium; Medium', $this->subject->getValue($attr, $ua)); + } +} From 33811bf2d895fdd93be38d23a390b7d2dd5e6aaa Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 2 Oct 2025 11:21:06 +0400 Subject: [PATCH 08/16] Update: config provider --- .../Configuration/Model/ConfigOption.php | 9 +++- .../Repository/ConfigRepository.php | 4 ++ .../Service/Provider/ConfigProvider.php | 42 +++++++++++++++---- .../Model/Dto/SubscriberImportOptions.php | 1 + .../Service/Manager/SubscriptionManager.php | 4 +- .../Service/SubscriberCsvImporter.php | 8 +++- 6 files changed, 54 insertions(+), 14 deletions(-) diff --git a/src/Domain/Configuration/Model/ConfigOption.php b/src/Domain/Configuration/Model/ConfigOption.php index ebcd2202..86b9286e 100644 --- a/src/Domain/Configuration/Model/ConfigOption.php +++ b/src/Domain/Configuration/Model/ConfigOption.php @@ -7,5 +7,12 @@ enum ConfigOption: string { case MaintenanceMode = 'maintenancemode'; - case SendSubscribeMessage = 'subscribemessage'; + case SubscribeMessage = 'subscribemessage'; + case SubscribeEmailSubject = 'subscribesubject'; + case UnsubscribeUrl = 'unsubscribeurl'; + case ConfirmationUrl = 'confirmationurl'; + case PreferencesUrl = 'preferencesurl'; + case SubscribeUrl = 'subscribeurl'; + case Domain = 'domain'; + case Website = 'website'; } diff --git a/src/Domain/Configuration/Repository/ConfigRepository.php b/src/Domain/Configuration/Repository/ConfigRepository.php index ad74ff51..960b14f3 100644 --- a/src/Domain/Configuration/Repository/ConfigRepository.php +++ b/src/Domain/Configuration/Repository/ConfigRepository.php @@ -8,4 +8,8 @@ class ConfigRepository extends AbstractRepository { + public function findValueByItem(string $name): ?string + { + return $this->findOneBy(['item' => $name])?->getValue(); + } } diff --git a/src/Domain/Configuration/Service/Provider/ConfigProvider.php b/src/Domain/Configuration/Service/Provider/ConfigProvider.php index aed0e161..d07728b4 100644 --- a/src/Domain/Configuration/Service/Provider/ConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/ConfigProvider.php @@ -7,19 +7,19 @@ use InvalidArgumentException; use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Repository\ConfigRepository; +use Psr\SimpleCache\CacheInterface; class ConfigProvider { private array $booleanValues = [ ConfigOption::MaintenanceMode, - ConfigOption::SendSubscribeMessage, ]; - private ConfigRepository $configRepository; - - public function __construct(ConfigRepository $configRepository) - { - $this->configRepository = $configRepository; + public function __construct( + private readonly ConfigRepository $configRepository, + private readonly CacheInterface $cache, + private readonly int $ttlSeconds = 300 + ) { } public function isEnabled(ConfigOption $key): bool @@ -35,11 +35,35 @@ public function isEnabled(ConfigOption $key): bool /** * Get configuration value by its key */ - public function getValue(string $ikey, ?string $default = null): ?string + public function getValue(ConfigOption $key, ?string $default = null): ?string { - $config = $this->configRepository->findOneBy(['item' => $ikey]); + if (in_array($key, $this->booleanValues)) { + throw new InvalidArgumentException('Key is a boolean value, use isEnabled instead'); + } + $cacheKey = 'cfg:' . $key->value; + $value = $this->cache->get($cacheKey); + if ($value === null) { + $value = $this->configRepository->findValueByItem($key->value); + $this->cache->set($cacheKey, $value, $this->ttlSeconds); + } - return $config?->getValue() ?? $default; + return $value ?? $default; } + public function getValueWithNamespace(ConfigOption $key, ?string $default = null): ?string + { + $full = $this->getValue($key); + if ($full !== null && $full !== '') { + return $full; + } + + if (str_contains($key->value, ':')) { + [$parent] = explode(':', $key->value, 2); + $parentKey = ConfigOption::from($parent); + + return $this->getValue($parentKey, $default); + } + + return $default; + } } diff --git a/src/Domain/Subscription/Model/Dto/SubscriberImportOptions.php b/src/Domain/Subscription/Model/Dto/SubscriberImportOptions.php index 8a180e03..d181d757 100644 --- a/src/Domain/Subscription/Model/Dto/SubscriberImportOptions.php +++ b/src/Domain/Subscription/Model/Dto/SubscriberImportOptions.php @@ -14,6 +14,7 @@ public function __construct( public readonly array $listIds = [], public readonly bool $dryRun = false, public readonly bool $skipInvalidEmail = true, + public readonly bool $notifySubscribers = false, ) { } } diff --git a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php index 6bed4d5b..c9921bab 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php @@ -32,12 +32,12 @@ public function __construct( $this->translator = $translator; } - public function addSubscriberToAList(Subscriber $subscriber, int $listId): Subscription + public function addSubscriberToAList(Subscriber $subscriber, int $listId): ?Subscription { $existingSubscription = $this->subscriptionRepository ->findOneBySubscriberEmailAndListId($listId, $subscriber->getEmail()); if ($existingSubscription) { - return $existingSubscription; + return null; } $subscriberList = $this->subscriberListRepository->find($listId); if (!$subscriberList) { diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php index 63fd40e6..350c502c 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php @@ -185,15 +185,19 @@ private function processRow( $this->processAttributes($subscriber, $dto); + $addedNewSubscriberToList = false; if (!$subscriber->isBlacklisted() && count($options->listIds) > 0) { foreach ($options->listIds as $listId) { - $this->subscriptionManager->addSubscriberToAList($subscriber, $listId); + $created = $this->subscriptionManager->addSubscriberToAList($subscriber, $listId); + if ($created) { + $addedNewSubscriberToList = true; + } } } if (!$options->dryRun) { $this->entityManager->flush(); - if ($this->configProvider->isEnabled(ConfigOption::SendSubscribeMessage)) { + if ($options->notifySubscribers && $addedNewSubscriberToList) { $this->sendSubscribeEmail($dto->email, $options->listIds); } } From 46a19fcfe93425c0585e75fcb71e805603fac0b7 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 3 Oct 2025 11:19:32 +0400 Subject: [PATCH 09/16] Translations in default configs --- README.md | 8 + config/services/providers.yml | 4 + resources/translations/messages.en.xlf | 1049 ++++++++++------- resources/translations/security.en.xlf | 86 ++ resources/translations/validators.en.xlf | 694 +++++++++++ .../Service/Provider/ConfigProvider.php | 22 +- .../Provider/DefaultConfigProvider.php | 386 +++--- 7 files changed, 1649 insertions(+), 600 deletions(-) create mode 100644 resources/translations/security.en.xlf create mode 100644 resources/translations/validators.en.xlf diff --git a/README.md b/README.md index ffe011ca..2d2bc213 100755 --- a/README.md +++ b/README.md @@ -214,3 +214,11 @@ For detailed configuration instructions, see the [Mailer Transports documentatio ## Copyright phpList is copyright (C) 2000-2025 [phpList Ltd](https://www.phplist.com/). + + +### Translations +command to extract translation strings + +```bash +php bin/console translation:extract --force en --format=xlf +``` diff --git a/config/services/providers.yml b/config/services/providers.yml index 17b69e39..45436651 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -17,4 +17,8 @@ services: PhpList\Core\Domain\Subscription\Service\Provider\SelectOrRadioValueProvider: ~ PhpList\Core\Domain\Subscription\Service\Provider\ScalarValueProvider: ~ + PhpList\Core\Domain\Configuration\Service\Provider\DefaultConfigProvider: + calls: + - [ setTranslator, [ '@translator' ] ] + PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider: diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf index 6f128be1..5bfb12fd 100644 --- a/resources/translations/messages.en.xlf +++ b/resources/translations/messages.en.xlf @@ -1,40 +1,36 @@ - - - - - - - Not authorized - Not authorized - - - - Failed admin login attempt for '%login%' - Failed admin login attempt for '%login%' - - - - Login attempt for disabled admin '%login%' - Login attempt for disabled admin '%login%' - - - - - Administrator not found - Administrator not found - - - - Attribute definition already exists. - Attribute definition already exists. - - - - Password Reset Request - - - - Hello, + + + +
+ +
+ + + Not authorized + Not authorized + + + Failed admin login attempt for '%login%' + Failed admin login attempt for '%login%' + + + Login attempt for disabled admin '%login%' + Login attempt for disabled admin '%login%' + + + Administrator not found + Administrator not found + + + Attribute definition already exists. + Attribute definition already exists. + + + Password Reset Request + Password Reset Request + + + Hello, A password reset has been requested for your account. Please use the following token to reset your password: @@ -45,7 +41,7 @@ Thank you. - + Hello, A password reset has been requested for your account. @@ -57,34 +53,31 @@ Thank you. - - - - Password Reset Request!

-

Hello! A password reset has been requested for your account.

-

Please use the following token to reset your password:

-

Reset Password

-

If you did not request this password reset, please ignore this email.

-

Thank you.

]]> - - + + <p>Password Reset Request!</p> +<p>Hello! A password reset has been requested for your account.</p> +<p>Please use the following token to reset your password:</p> +<p><a href="%confirmation_link%">Reset Password</a></p> +<p>If you did not request this password reset, please ignore this email.</p> +<p>Thank you.</p> + Password Reset Request!

Hello! A password reset has been requested for your account.

Please use the following token to reset your password:

Reset Password

If you did not request this password reset, please ignore this email.

Thank you.

- ]]> -
-
- - - Please confirm your subscription - Please confirm your subscription - - - - Thank you for subscribing! + + ]]>
+
+ + Request for confirmation + Request for confirmation + + + Thank you for subscribing! Please confirm your subscription by clicking the link below: @@ -92,7 +85,7 @@ If you did not request this subscription, please ignore this email. - Thank you for subscribing! + Thank you for subscribing! Please confirm your subscription by clicking the link below: @@ -100,351 +93,597 @@ If you did not request this subscription, please ignore this email. - - - - Thank you for subscribing!

-

Please confirm your subscription by clicking the link below:

-

Confirm Subscription

-

If you did not request this subscription, please ignore this email.

]]> +
+ + <p>Thank you for subscribing!</p> +<p>Please confirm your subscription by clicking the link below:</p> +<p><a href="%confirmation_link%">Confirm Subscription</a></p> +<p>If you did not request this subscription, please ignore this email.</p> - Thank you for subscribing!

+ Thank you for subscribing!

Please confirm your subscription by clicking the link below:

Confirm Subscription

-

If you did not request this subscription, please ignore this email.

]]> -
-
- - - - PHP IMAP extension not available. Falling back to Webklex IMAP. - PHP IMAP extension not available. Falling back to Webklex IMAP. - - - - Could not apply force lock. Aborting. - Could not apply force lock. Aborting. - - - - Another bounce processing is already running. Aborting. - Another bounce processing is already running. Aborting. - - - - Queue is already being processed by another instance. - Queue is already being processed by another instance. - - - - The system is in maintenance mode, stopping. Try again later. - The system is in maintenance mode, stopping. Try again later. - - - - Bounce processing completed. - Bounce processing completed. - - - - Recipient email address not provided - Recipient email address not provided - - - - Invalid email address: %email% - Invalid email address: %email% - - - - Sending test email synchronously to %email% - Sending test email synchronously to %email% - - - - Queuing test email for %email% - Queuing test email for %email% - - - - Test email sent successfully! - Test email sent successfully! - - - - Test email queued successfully! It will be sent asynchronously. - Test email queued successfully! It will be sent asynchronously. - - - - Failed to send test email: %error% - Failed to send test email: %error% - - - - Email address auto blacklisted by bounce rule %rule_id% - Email address auto blacklisted by bounce rule %rule_id% - - - - Auto Unsubscribed - Auto Unsubscribed - - - - User auto unsubscribed for bounce rule %rule_id% - User auto unsubscribed for bounce rule %rule_id% - - - - email auto unsubscribed for bounce rule %rule_id% - email auto unsubscribed for bounce rule %rule_id% - - - - Subscriber auto blacklisted by bounce rule %rule_id% - Subscriber auto blacklisted by bounce rule %rule_id% - - - - User auto unsubscribed for bounce rule %%rule_id% - User auto unsubscribed for bounce rule %%rule_id% - - - - Auto confirmed - Auto confirmed - - - - Auto unconfirmed - Auto unconfirmed - - - - Subscriber auto confirmed for bounce rule %rule_id% - Subscriber auto confirmed for bounce rule %rule_id% - - - - Requeued campaign; next embargo at %time% - Requeued campaign; next embargo at %time% - - - - Subscriber auto unconfirmed for bounce rule %rule_id% - Subscriber auto unconfirmed for bounce rule %rule_id% - - - - Running in test mode, not deleting messages from mailbox - Running in test mode, not deleting messages from mailbox - - - - Processed messages will be deleted from the mailbox - Processed messages will be deleted from the mailbox - - - - Processing bounces based on active bounce rules - Processing bounces based on active bounce rules - - - - No active rules - No active rules - - - - Processed %processed% out of %total% bounces for advanced bounce rules - Processed %processed% out of %total% bounces for advanced bounce rules - - - - %processed% bounces processed by advanced processing - %processed% bounces processed by advanced processing - - - %not_processed% bounces were not matched by advanced processing rules - %not_processed% bounces were not matched by advanced processing rules - - - - Opening mbox %file% - Opening mbox %file% - - - Connecting to %mailbox% - Connecting to %mailbox% - - - Please do not interrupt this process - Please do not interrupt this process - - - mbox file path must be provided with --mailbox. - mbox file path must be provided with --mailbox. - - - - Invalid email, marking unconfirmed: %email% - Invalid email, marking unconfirmed: %email% - - - Failed to send to: %email% - Failed to send to: %email% - - - - Reprocessing unidentified bounces - Reprocessing unidentified bounces - - - %total% bounces to reprocess - %total% bounces to reprocess - - - %count% out of %total% processed - %count% out of %total% processed - - - %reparsed% bounces were re-processed and %reidentified% bounces were re-identified - %reparsed% bounces were re-processed and %reidentified% bounces were re-identified - - - - Identifying consecutive bounces - Identifying consecutive bounces - - - Nothing to do - Nothing to do - - - Processed %processed% out of %total% subscribers - Processed %processed% out of %total% subscribers - - - Total of %total% subscribers processed - Total of %total% subscribers processed - - - Subscriber auto unconfirmed for %count% consecutive bounces - Subscriber auto unconfirmed for %count% consecutive bounces - - - %count% consecutive bounces, threshold reached - %count% consecutive bounces, threshold reached - - - - Reached max processing time; stopping cleanly. - Reached max processing time; stopping cleanly. - - - - Giving a UUID to %count% subscribers, this may take a while - Giving a UUID to %count% subscribers, this may take a while - - - Giving a UUID to %count% campaigns - Giving a UUID to %count% campaigns - - - - Batch limit reached, sleeping %sleep%s to respect MAILQUEUE_BATCH_PERIOD - Batch limit reached, sleeping %sleep%s to respect MAILQUEUE_BATCH_PERIOD - - - - Value must be an array of image URLs. - Value must be an array of image URLs. - - - Image "%url%" is not a full URL. - Image "%url%" is not a full URL. - - - Image "%url%" does not exist (HTTP %code%) - Image "%url%" does not exist (HTTP %code%) - - - Image "%url%" could not be validated: %message% - Image "%url%" could not be validated: %message% - - - - Not full URLs: %urls% - Not full URLs: %urls% - - - - - - Subscriber list not found. - Subscriber list not found. - - - - Subscriber does not exists. - Subscriber does not exists. - - - - Subscription not found for this subscriber and list. - Subscription not found for this subscriber and list. - - - Attribute definition already exists - Attribute definition already exists - - - Another attribute with this name already exists. - Another attribute with this name already exists. - - - - Subscribe page not found - Subscribe page not found - - - Value is required - Value is required - - - Subscriber not found - Subscriber not found - - - Unexpected error: %error% - Unexpected error: %error% - - - Added to blacklist for reason %reason% - Added to blacklist for reason %reason% - - - Could not read the uploaded file. - Could not read the uploaded file. - - - Error processing %email%: %error% - Error processing %email%: %error% - - - General import error: %error% - General import error: %error% - - - Value must be a string. - Value must be a string. - - - Invalid attribute type: "%type%". Valid types are: %valid_types% - Invalid attribute type: "%type%". Valid types are: %valid_types% - - - -
+

If you did not request this subscription, please ignore this email.

+ ]]> +
+ + PHP IMAP extension not available. Falling back to Webklex IMAP. + PHP IMAP extension not available. Falling back to Webklex IMAP. + + + Could not apply force lock. Aborting. + Could not apply force lock. Aborting. + + + Another bounce processing is already running. Aborting. + Another bounce processing is already running. Aborting. + + + Queue is already being processed by another instance. + Queue is already being processed by another instance. + + + The system is in maintenance mode, stopping. Try again later. + The system is in maintenance mode, stopping. Try again later. + + + Bounce processing completed. + Bounce processing completed. + + + Recipient email address not provided + Recipient email address not provided + + + Invalid email address: %email% + Invalid email address: %email% + + + Sending test email synchronously to %email% + Sending test email synchronously to %email% + + + Queuing test email for %email% + Queuing test email for %email% + + + Test email sent successfully! + Test email sent successfully! + + + Test email queued successfully! It will be sent asynchronously. + Test email queued successfully! It will be sent asynchronously. + + + Failed to send test email: %error% + Failed to send test email: %error% + + + Email address auto blacklisted by bounce rule %rule_id% + Email address auto blacklisted by bounce rule %rule_id% + + + Auto Unsubscribed + Auto Unsubscribed + + + User auto unsubscribed for bounce rule %rule_id% + User auto unsubscribed for bounce rule %rule_id% + + + email auto unsubscribed for bounce rule %rule_id% + email auto unsubscribed for bounce rule %rule_id% + + + Subscriber auto blacklisted by bounce rule %rule_id% + Subscriber auto blacklisted by bounce rule %rule_id% + + + User auto unsubscribed for bounce rule %%rule_id% + User auto unsubscribed for bounce rule %%rule_id% + + + Auto confirmed + Auto confirmed + + + Auto unconfirmed + Auto unconfirmed + + + Subscriber auto confirmed for bounce rule %rule_id% + Subscriber auto confirmed for bounce rule %rule_id% + + + Requeued campaign; next embargo at %time% + Requeued campaign; next embargo at %time% + + + Subscriber auto unconfirmed for bounce rule %rule_id% + Subscriber auto unconfirmed for bounce rule %rule_id% + + + Running in test mode, not deleting messages from mailbox + Running in test mode, not deleting messages from mailbox + + + Processed messages will be deleted from the mailbox + Processed messages will be deleted from the mailbox + + + Processing bounces based on active bounce rules + Processing bounces based on active bounce rules + + + No active rules + No active rules + + + Processed %processed% out of %total% bounces for advanced bounce rules + Processed %processed% out of %total% bounces for advanced bounce rules + + + %processed% bounces processed by advanced processing + %processed% bounces processed by advanced processing + + + %not_processed% bounces were not matched by advanced processing rules + %not_processed% bounces were not matched by advanced processing rules + + + Opening mbox %file% + Opening mbox %file% + + + Connecting to %mailbox% + Connecting to %mailbox% + + + Please do not interrupt this process + Please do not interrupt this process + + + mbox file path must be provided with --mailbox. + mbox file path must be provided with --mailbox. + + + Invalid email, marking unconfirmed: %email% + Invalid email, marking unconfirmed: %email% + + + Failed to send to: %email% + Failed to send to: %email% + + + Reprocessing unidentified bounces + Reprocessing unidentified bounces + + + %total% bounces to reprocess + %total% bounces to reprocess + + + %count% out of %total% processed + %count% out of %total% processed + + + %reparsed% bounces were re-processed and %reidentified% bounces were re-identified + %reparsed% bounces were re-processed and %reidentified% bounces were re-identified + + + Identifying consecutive bounces + Identifying consecutive bounces + + + Nothing to do + Nothing to do + + + Processed %processed% out of %total% subscribers + Processed %processed% out of %total% subscribers + + + Total of %total% subscribers processed + Total of %total% subscribers processed + + + Subscriber auto unconfirmed for %count% consecutive bounces + Subscriber auto unconfirmed for %count% consecutive bounces + + + %count% consecutive bounces, threshold reached + %count% consecutive bounces, threshold reached + + + Reached max processing time; stopping cleanly. + Reached max processing time; stopping cleanly. + + + Giving a UUID to %count% subscribers, this may take a while + Giving a UUID to %count% subscribers, this may take a while + + + Giving a UUID to %count% campaigns + Giving a UUID to %count% campaigns + + + Batch limit reached, sleeping %sleep%s to respect MAILQUEUE_BATCH_PERIOD + Batch limit reached, sleeping %sleep%s to respect MAILQUEUE_BATCH_PERIOD + + + Value must be an array of image URLs. + Value must be an array of image URLs. + + + Image "%url%" is not a full URL. + Image "%url%" is not a full URL. + + + Image "%url%" does not exist (HTTP %code%) + Image "%url%" does not exist (HTTP %code%) + + + Image "%url%" could not be validated: %message% + Image "%url%" could not be validated: %message% + + + Not full URLs: %urls% + Not full URLs: %urls% + + + Subscriber list not found. + Subscriber list not found. + + + Subscriber does not exists. + Subscriber does not exists. + + + Subscription not found for this subscriber and list. + Subscription not found for this subscriber and list. + + + Attribute definition already exists + Attribute definition already exists + + + Another attribute with this name already exists. + Another attribute with this name already exists. + + + Subscribe page not found + Subscribe page not found + + + Value is required + Value is required + + + Subscriber not found + Subscriber not found + + + Unexpected error: %error% + Unexpected error: %error% + + + Added to blacklist for reason %reason% + Added to blacklist for reason %reason% + + + Could not read the uploaded file. + Could not read the uploaded file. + + + Error processing %email%: %error% + Error processing %email%: %error% + + + General import error: %error% + General import error: %error% + + + Value must be a string. + Value must be a string. + + + Invalid attribute type: "%type%". Valid types are: %valid_types% + Invalid attribute type: "%type%". Valid types are: %valid_types% + + + Thank you for subscribing! + +Please confirm your subscription by clicking the link below: + +%confirmation_link% + +If you did not request this subscription, please ignore this email. + __Thank you for subscribing! + +Please confirm your subscription by clicking the link below: + +%confirmation_link% + +If you did not request this subscription, please ignore this email. + + + <p>Thank you for subscribing!</p><p>Please confirm your subscription by clicking the link below:</p><p><a href="%confirmation_link%">Confirm Subscription</a></p><p>If you did not request this subscription, please ignore this email.</p> + Thank you for subscribing!

Please confirm your subscription by clicking the link below:

Confirm Subscription

If you did not request this subscription, please ignore this email.

]]>
+
+ + Hello, + +A password reset has been requested for your account. +Please use the following token to reset your password: + +%token% + +If you did not request this password reset, please ignore this email. + +Thank you. + __Hello, + +A password reset has been requested for your account. +Please use the following token to reset your password: + +%token% + +If you did not request this password reset, please ignore this email. + +Thank you. + + + <p>Password Reset Request!</p><p>Hello! A password reset has been requested for your account.</p><p>Please use the following token to reset your password:</p><p><a href="%confirmation_link%">Reset Password</a></p><p>If you did not request this password reset, please ignore this email.</p><p>Thank you.</p> + Password Reset Request!

Hello! A password reset has been requested for your account.

Please use the following token to reset your password:

Reset Password

If you did not request this password reset, please ignore this email.

Thank you.

]]>
+
+ + Person in charge of this system (one email address) + __Person in charge of this system (one email address) + + + Name of the organisation + __Name of the organisation + + + Logo of the organisation + __Logo of the organisation + + + Date format + __Date format + + + Show notification for Release Candidates + __Show notification for Release Candidates + + + Secret for remote processing + __Secret for remote processing + + + Notify admin on login from new location + __Notify admin on login from new location + + + List of email addresses to CC in system messages (separate by commas) + __List of email addresses to CC in system messages (separate by commas) + + + Default for 'From:' in a campaign + __Default for 'From:' in a campaign + + + Default for 'address to alert when sending starts' + __Default for 'address to alert when sending starts' + + + Default for 'address to alert when sending finishes' + __Default for 'address to alert when sending finishes' + + + Always add analytics tracking code to campaigns + __Always add analytics tracking code to campaigns + + + Analytics tracking code to add to campaign URLs + __Analytics tracking code to add to campaign URLs + + + Who gets the reports (email address, separate multiple emails with a comma) + __Who gets the reports (email address, separate multiple emails with a comma) + + + From email address for system messages + __From email address for system messages + + + Webmaster + __Webmaster + + + Name for system messages + __Name for system messages + + + Reply-to email address for system messages + __Reply-to email address for system messages + + + If there is only one visible list, should it be hidden in the page and automatically subscribe users who sign up + __If there is only one visible list, should it be hidden in the page and automatically subscribe users who sign up + + + Categories for lists. Separate with commas. + __Categories for lists. Separate with commas. + + + Display list categories on subscribe page + __Display list categories on subscribe page + + + Width of a textline field (numerical) + __Width of a textline field (numerical) + + + Dimensions of a textarea field (rows,columns) + __Dimensions of a textarea field (rows,columns) + + + Send notifications about subscribe, update and unsubscribe + __Send notifications about subscribe, update and unsubscribe + + + The default subscribe page when there are multiple + __The default subscribe page when there are multiple + + + The default HTML template to use when sending a message + __The default HTML template to use when sending a message + + + The HTML wrapper template for system messages + __The HTML wrapper template for system messages + + + URL where subscribers can sign up + __URL where subscribers can sign up + + + URL where subscribers can unsubscribe + __URL where subscribers can unsubscribe + + + URL where unknown users can unsubscribe (do-not-send-list) + __URL where unknown users can unsubscribe (do-not-send-list) + + + URL where subscribers have to confirm their subscription + __URL where subscribers have to confirm their subscription + + + URL where subscribers can update their details + __URL where subscribers can update their details + + + URL for forwarding messages + __URL for forwarding messages + + + URL for downloading vcf card + __URL for downloading vcf card + + + <h3>Thanks, you have been added to our newsletter</h3><p>You will receive an email to confirm your subscription. Please click the link in the email to confirm</p> + Thanks, you have been added to our newsletter

You will receive an email to confirm your subscription. Please click the link in the email to confirm

]]>
+
+ + Text to display when subscription with an AJAX request was successful + __Text to display when subscription with an AJAX request was successful + + + Subject of the message subscribers receive when they sign up + __Subject of the message subscribers receive when they sign up + + + Message subscribers receive when they sign up + __Message subscribers receive when they sign up + + + Goodbye from our Newsletter + __Goodbye from our Newsletter + + + Subject of the message subscribers receive when they unsubscribe + __Subject of the message subscribers receive when they unsubscribe + + + Message subscribers receive when they unsubscribe + __Message subscribers receive when they unsubscribe + + + Welcome to our Newsletter + __Welcome to our Newsletter + + + Subject of the message subscribers receive after confirming their email address + __Subject of the message subscribers receive after confirming their email address + + + Message subscribers receive after confirming their email address + __Message subscribers receive after confirming their email address + + + [notify] Change of List-Membership details + __[notify] Change of List-Membership details + + + Subject of the message subscribers receive when they have changed their details + __Subject of the message subscribers receive when they have changed their details + + + Message subscribers receive when they have changed their details + __Message subscribers receive when they have changed their details + + + Part of the message that is sent to their new email address when subscribers change their information, and the email address has changed + __Part of the message that is sent to their new email address when subscribers change their information, and the email address has changed + + + Part of the message that is sent to their old email address when subscribers change their information, and the email address has changed + __Part of the message that is sent to their old email address when subscribers change their information, and the email address has changed + + + Your personal location + __Your personal location + + + Subject of message when subscribers request their personal location + __Subject of message when subscribers request their personal location + + + Default footer for sending a campaign + __Default footer for sending a campaign + + + Footer used when a message has been forwarded + __Footer used when a message has been forwarded + + + Message to send when they request their personal location + __Message to send when they request their personal location + + + String to always append to remote URL when using send-a-webpage + __String to always append to remote URL when using send-a-webpage + + + Width for Wordwrap of Text messages + __Width for Wordwrap of Text messages + + + CSS for HTML messages without a template + __CSS for HTML messages without a template + + + Domains that only accept text emails, one per line + __Domains that only accept text emails, one per line + + + last time TLDs were fetched + __last time TLDs were fetched + + + Top level domains + __Top level domains + + + Header of public pages. + __Header of public pages. + + + Footer of public pages + __Footer of public pages + + +
diff --git a/resources/translations/security.en.xlf b/resources/translations/security.en.xlf new file mode 100644 index 00000000..d053cd60 --- /dev/null +++ b/resources/translations/security.en.xlf @@ -0,0 +1,86 @@ + + + +
+ +
+ + + An authentication exception occurred. + An authentication exception occurred. + + + Authentication credentials could not be found. + Authentication credentials could not be found. + + + Authentication request could not be processed due to a system problem. + Authentication request could not be processed due to a system problem. + + + Invalid credentials. + Invalid credentials. + + + Cookie has already been used by someone else. + Cookie has already been used by someone else. + + + Not privileged to request the resource. + Not privileged to request the resource. + + + Invalid CSRF token. + Invalid CSRF token. + + + No authentication provider found to support the authentication token. + No authentication provider found to support the authentication token. + + + No session available, it either timed out or cookies are not enabled. + No session available, it either timed out or cookies are not enabled. + + + No token could be found. + No token could be found. + + + Username could not be found. + Username could not be found. + + + Account has expired. + Account has expired. + + + Credentials have expired. + Credentials have expired. + + + Account is disabled. + Account is disabled. + + + Account is locked. + Account is locked. + + + Too many failed login attempts, please try again later. + Too many failed login attempts, please try again later. + + + Invalid or expired login link. + Invalid or expired login link. + + + Too many failed login attempts, please try again in %minutes% minute. + Too many failed login attempts, please try again in %minutes% minute. + + + Too many failed login attempts, please try again in %minutes% minutes. + Too many failed login attempts, please try again in %minutes% minutes. + + +
+
diff --git a/resources/translations/validators.en.xlf b/resources/translations/validators.en.xlf new file mode 100644 index 00000000..41617e3b --- /dev/null +++ b/resources/translations/validators.en.xlf @@ -0,0 +1,694 @@ + + + +
+ +
+ + + This value should be false. + This value should be false. + + + This value should be true. + This value should be true. + + + This value should be of type {{ type }}. + This value should be of type {{ type }}. + + + This value should be blank. + This value should be blank. + + + The value you selected is not a valid choice. + The value you selected is not a valid choice. + + + You must select at least {{ limit }} choice.|You must select at least {{ limit }} choices. + You must select at least {{ limit }} choice.|You must select at least {{ limit }} choices. + + + You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices. + You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices. + + + One or more of the given values is invalid. + One or more of the given values is invalid. + + + This field was not expected. + This field was not expected. + + + This field is missing. + This field is missing. + + + This value is not a valid date. + This value is not a valid date. + + + This value is not a valid datetime. + This value is not a valid datetime. + + + This value is not a valid email address. + This value is not a valid email address. + + + The file could not be found. + The file could not be found. + + + The file is not readable. + The file is not readable. + + + The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}. + The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}. + + + The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}. + The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}. + + + This value should be {{ limit }} or less. + This value should be {{ limit }} or less. + + + This value is too long. It should have {{ limit }} character or less.|This value is too long. It should have {{ limit }} characters or less. + This value is too long. It should have {{ limit }} character or less.|This value is too long. It should have {{ limit }} characters or less. + + + This value should be {{ limit }} or more. + This value should be {{ limit }} or more. + + + This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more. + This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more. + + + This value should not be blank. + This value should not be blank. + + + This value should not be null. + This value should not be null. + + + This value should be null. + This value should be null. + + + This value is not valid. + This value is not valid. + + + This value is not a valid time. + This value is not a valid time. + + + This value is not a valid URL. + This value is not a valid URL. + + + The two values should be equal. + The two values should be equal. + + + The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}. + The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}. + + + The file is too large. + The file is too large. + + + The file could not be uploaded. + The file could not be uploaded. + + + This value should be a valid number. + This value should be a valid number. + + + This file is not a valid image. + This file is not a valid image. + + + This is not a valid IP address. + This value is not a valid IP address. + + + This value is not a valid language. + This value is not a valid language. + + + This value is not a valid locale. + This value is not a valid locale. + + + This value is not a valid country. + This value is not a valid country. + + + This value is already used. + This value is already used. + + + The size of the image could not be detected. + The size of the image could not be detected. + + + The image width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. + The image width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. + + + The image width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. + The image width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. + + + The image height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. + The image height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. + + + The image height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. + The image height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. + + + This value should be the user's current password. + This value should be the user's current password. + + + This value should have exactly {{ limit }} character.|This value should have exactly {{ limit }} characters. + This value should have exactly {{ limit }} character.|This value should have exactly {{ limit }} characters. + + + The file was only partially uploaded. + The file was only partially uploaded. + + + No file was uploaded. + No file was uploaded. + + + No temporary folder was configured in php.ini. + No temporary folder was configured in php.ini, or the configured folder does not exist. + + + Cannot write temporary file to disk. + Cannot write temporary file to disk. + + + A PHP extension caused the upload to fail. + A PHP extension caused the upload to fail. + + + This collection should contain {{ limit }} element or more.|This collection should contain {{ limit }} elements or more. + This collection should contain {{ limit }} element or more.|This collection should contain {{ limit }} elements or more. + + + This collection should contain {{ limit }} element or less.|This collection should contain {{ limit }} elements or less. + This collection should contain {{ limit }} element or less.|This collection should contain {{ limit }} elements or less. + + + This collection should contain exactly {{ limit }} element.|This collection should contain exactly {{ limit }} elements. + This collection should contain exactly {{ limit }} element.|This collection should contain exactly {{ limit }} elements. + + + Invalid card number. + Invalid card number. + + + Unsupported card type or invalid card number. + Unsupported card type or invalid card number. + + + This is not a valid International Bank Account Number (IBAN). + This value is not a valid International Bank Account Number (IBAN). + + + This value is not a valid ISBN-10. + This value is not a valid ISBN-10. + + + This value is not a valid ISBN-13. + This value is not a valid ISBN-13. + + + This value is neither a valid ISBN-10 nor a valid ISBN-13. + This value is neither a valid ISBN-10 nor a valid ISBN-13. + + + This value is not a valid ISSN. + This value is not a valid ISSN. + + + This value is not a valid currency. + This value is not a valid currency. + + + This value should be equal to {{ compared_value }}. + This value should be equal to {{ compared_value }}. + + + This value should be greater than {{ compared_value }}. + This value should be greater than {{ compared_value }}. + + + This value should be greater than or equal to {{ compared_value }}. + This value should be greater than or equal to {{ compared_value }}. + + + This value should be identical to {{ compared_value_type }} {{ compared_value }}. + This value should be identical to {{ compared_value_type }} {{ compared_value }}. + + + This value should be less than {{ compared_value }}. + This value should be less than {{ compared_value }}. + + + This value should be less than or equal to {{ compared_value }}. + This value should be less than or equal to {{ compared_value }}. + + + This value should not be equal to {{ compared_value }}. + This value should not be equal to {{ compared_value }}. + + + This value should not be identical to {{ compared_value_type }} {{ compared_value }}. + This value should not be identical to {{ compared_value_type }} {{ compared_value }}. + + + The image ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. + The image ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. + + + The image ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. + The image ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. + + + The image is square ({{ width }}x{{ height }}px). Square images are not allowed. + The image is square ({{ width }}x{{ height }}px). Square images are not allowed. + + + The image is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented images are not allowed. + The image is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented images are not allowed. + + + The image is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented images are not allowed. + The image is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented images are not allowed. + + + An empty file is not allowed. + An empty file is not allowed. + + + The host could not be resolved. + The host could not be resolved. + + + This value does not match the expected {{ charset }} charset. + This value does not match the expected {{ charset }} charset. + + + This is not a valid Business Identifier Code (BIC). + This value is not a valid Business Identifier Code (BIC). + + + Error + Error + + + This is not a valid UUID. + This value is not a valid UUID. + + + This value should be a multiple of {{ compared_value }}. + This value should be a multiple of {{ compared_value }}. + + + This Business Identifier Code (BIC) is not associated with IBAN {{ iban }}. + This Business Identifier Code (BIC) is not associated with IBAN {{ iban }}. + + + This value should be valid JSON. + This value should be valid JSON. + + + This collection should contain only unique elements. + This collection should contain only unique elements. + + + This value should be positive. + This value should be positive. + + + This value should be either positive or zero. + This value should be either positive or zero. + + + This value should be negative. + This value should be negative. + + + This value should be either negative or zero. + This value should be either negative or zero. + + + This value is not a valid timezone. + This value is not a valid timezone. + + + This password has been leaked in a data breach, it must not be used. Please use another password. + This password has been leaked in a data breach, it must not be used. Please use another password. + + + This value should be between {{ min }} and {{ max }}. + This value should be between {{ min }} and {{ max }}. + + + This value is not a valid hostname. + This value is not a valid hostname. + + + The number of elements in this collection should be a multiple of {{ compared_value }}. + The number of elements in this collection should be a multiple of {{ compared_value }}. + + + This value should satisfy at least one of the following constraints: + This value should satisfy at least one of the following constraints: + + + Each element of this collection should satisfy its own set of constraints. + Each element of this collection should satisfy its own set of constraints. + + + This value is not a valid International Securities Identification Number (ISIN). + This value is not a valid International Securities Identification Number (ISIN). + + + This value should be a valid expression. + This value should be a valid expression. + + + This value is not a valid CSS color. + This value is not a valid CSS color. + + + This value is not a valid CIDR notation. + This value is not a valid CIDR notation. + + + The value of the netmask should be between {{ min }} and {{ max }}. + The value of the netmask should be between {{ min }} and {{ max }}. + + + The filename is too long. It should have {{ filename_max_length }} character or less.|The filename is too long. It should have {{ filename_max_length }} characters or less. + The filename is too long. It should have {{ filename_max_length }} character or less.|The filename is too long. It should have {{ filename_max_length }} characters or less. + + + The password strength is too low. Please use a stronger password. + The password strength is too low. Please use a stronger password. + + + This value contains characters that are not allowed by the current restriction-level. + This value contains characters that are not allowed by the current restriction-level. + + + Using invisible characters is not allowed. + Using invisible characters is not allowed. + + + Mixing numbers from different scripts is not allowed. + Mixing numbers from different scripts is not allowed. + + + Using hidden overlay characters is not allowed. + Using hidden overlay characters is not allowed. + + + The extension of the file is invalid ({{ extension }}). Allowed extensions are {{ extensions }}. + The extension of the file is invalid ({{ extension }}). Allowed extensions are {{ extensions }}. + + + The detected character encoding is invalid ({{ detected }}). Allowed encodings are {{ encodings }}. + The detected character encoding is invalid ({{ detected }}). Allowed encodings are {{ encodings }}. + + + This value is not a valid MAC address. + This value is not a valid MAC address. + + + This URL is missing a top-level domain. + This URL is missing a top-level domain. + + + This value is too short. It should contain at least one word.|This value is too short. It should contain at least {{ min }} words. + This value is too short. It should contain at least one word.|This value is too short. It should contain at least {{ min }} words. + + + This value is too long. It should contain one word.|This value is too long. It should contain {{ max }} words or less. + This value is too long. It should contain one word.|This value is too long. It should contain {{ max }} words or less. + + + This value does not represent a valid week in the ISO 8601 format. + This value does not represent a valid week in the ISO 8601 format. + + + This value is not a valid week. + This value is not a valid week. + + + This value should not be before week "{{ min }}". + This value should not be before week "{{ min }}". + + + This value should not be after week "{{ max }}". + This value should not be after week "{{ max }}". + + + This value is not a valid Twig template. + This value is not a valid Twig template. + + + This file is not a valid video. + This file is not a valid video. + + + The size of the video could not be detected. + The size of the video could not be detected. + + + The video width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. + The video width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. + + + The video width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. + The video width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. + + + The video height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. + The video height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. + + + The video height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. + The video height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. + + + The video has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. + The video has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. + + + The video has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. + The video has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. + + + The video ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. + The video ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. + + + The video ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. + The video ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. + + + The video is square ({{ width }}x{{ height }}px). Square videos are not allowed. + The video is square ({{ width }}x{{ height }}px). Square videos are not allowed. + + + The video is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented videos are not allowed. + The video is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented videos are not allowed. + + + The video is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented videos are not allowed. + The video is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented videos are not allowed. + + + The video file is corrupted. + The video file is corrupted. + + + The video contains multiple streams. Only one stream is allowed. + The video contains multiple streams. Only one stream is allowed. + + + Unsupported video codec "{{ codec }}". + Unsupported video codec "{{ codec }}". + + + Unsupported video container "{{ container }}". + Unsupported video container "{{ container }}". + + + The image file is corrupted. + The image file is corrupted. + + + The image has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. + The image has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. + + + The image has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. + The image has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. + + + This filename does not match the expected charset. + This filename does not match the expected charset. + + + This form should not contain extra fields. + This form should not contain extra fields. + + + The uploaded file was too large. Please try to upload a smaller file. + The uploaded file was too large. Please try to upload a smaller file. + + + The CSRF token is invalid. Please try to resubmit the form. + The CSRF token is invalid. Please try to resubmit the form. + + + This value is not a valid HTML5 color. + This value is not a valid HTML5 color. + + + Please enter a valid birthdate. + Please enter a valid birthdate. + + + The selected choice is invalid. + The selected choice is invalid. + + + The collection is invalid. + The collection is invalid. + + + Please select a valid color. + Please select a valid color. + + + Please select a valid country. + Please select a valid country. + + + Please select a valid currency. + Please select a valid currency. + + + Please choose a valid date interval. + Please choose a valid date interval. + + + Please enter a valid date and time. + Please enter a valid date and time. + + + Please enter a valid date. + Please enter a valid date. + + + Please select a valid file. + Please select a valid file. + + + The hidden field is invalid. + The hidden field is invalid. + + + Please enter an integer. + Please enter an integer. + + + Please select a valid language. + Please select a valid language. + + + Please select a valid locale. + Please select a valid locale. + + + Please enter a valid money amount. + Please enter a valid money amount. + + + Please enter a number. + Please enter a number. + + + The password is invalid. + The password is invalid. + + + Please enter a percentage value. + Please enter a percentage value. + + + The values do not match. + The values do not match. + + + Please enter a valid time. + Please enter a valid time. + + + Please select a valid timezone. + Please select a valid timezone. + + + Please enter a valid URL. + Please enter a valid URL. + + + Please enter a valid search term. + Please enter a valid search term. + + + Please provide a valid phone number. + Please provide a valid phone number. + + + The checkbox has an invalid value. + The checkbox has an invalid value. + + + Please enter a valid email address. + Please enter a valid email address. + + + Please select a valid option. + Please select a valid option. + + + Please select a valid range. + Please select a valid range. + + + Please enter a valid week. + Please enter a valid week. + + +
+
diff --git a/src/Domain/Configuration/Service/Provider/ConfigProvider.php b/src/Domain/Configuration/Service/Provider/ConfigProvider.php index d07728b4..ce00f9b7 100644 --- a/src/Domain/Configuration/Service/Provider/ConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/ConfigProvider.php @@ -29,13 +29,17 @@ public function isEnabled(ConfigOption $key): bool } $config = $this->configRepository->findOneBy(['item' => $key->value]); - return $config?->getValue() === '1'; + if ($config !== null) { + $config->getValue() === '1'; + } + + return DefaultConfigProvider::has($key->value) && DefaultConfigProvider::get($key->value) === '1'; } /** - * Get configuration value by its key + * Get configuration value by its key, from settings or default configs or default value (if provided) */ - public function getValue(ConfigOption $key, ?string $default = null): ?string + public function getValue(ConfigOption $key): ?string { if (in_array($key, $this->booleanValues)) { throw new InvalidArgumentException('Key is a boolean value, use isEnabled instead'); @@ -47,10 +51,14 @@ public function getValue(ConfigOption $key, ?string $default = null): ?string $this->cache->set($cacheKey, $value, $this->ttlSeconds); } - return $value ?? $default; + if ($value !== null) { + return $value; + } + + return DefaultConfigProvider::has($key->value) ? DefaultConfigProvider::get($key->value) : null; } - public function getValueWithNamespace(ConfigOption $key, ?string $default = null): ?string + public function getValueWithNamespace(ConfigOption $key): ?string { $full = $this->getValue($key); if ($full !== null && $full !== '') { @@ -61,9 +69,9 @@ public function getValueWithNamespace(ConfigOption $key, ?string $default = null [$parent] = explode(':', $key->value, 2); $parentKey = ConfigOption::from($parent); - return $this->getValue($parentKey, $default); + return $this->getValue($parentKey); } - return $default; + return null; } } diff --git a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php index e35a124a..299d97e4 100644 --- a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php @@ -4,6 +4,8 @@ namespace PhpList\Core\Domain\Configuration\Service\Provider; +use Symfony\Contracts\Translation\TranslatorInterface; + class DefaultConfigProvider { /** @@ -12,271 +14,280 @@ class DefaultConfigProvider */ private static array $defaults = []; + private static TranslatorInterface $translator; + + public static function setTranslator(TranslatorInterface $translator): void + { + self::$translator = $translator; + } + private static function init(): void { if (!empty(self::$defaults)) { return; } - self::$defaults = array( - 'admin_address' => array( + $publicSchema = 'http'; + $pageRoot = '/api/v2'; + + self::$defaults = [ + 'admin_address' => [ 'value' => 'webmaster@[DOMAIN]', - 'description' => s('Person in charge of this system (one email address)'), + 'description' => self::$translator->trans('Person in charge of this system (one email address)'), 'type' => 'email', 'allowempty' => false, 'category' => 'general', - ), - 'organisation_name' => array( + ], + 'organisation_name' => [ 'value' => '', - 'description' => s('Name of the organisation'), + 'description' => self::$translator->trans('Name of the organisation'), 'type' => 'text', 'allowempty' => true, 'allowtags' => '

', 'allowJS' => false, 'category' => 'general', - ), - 'organisation_logo' => array( + ], + 'organisation_logo' => [ 'value' => '', - 'description' => s('Logo of the organisation'), + 'description' => self::$translator->trans('Logo of the organisation'), 'infoicon' => true, 'type' => 'image', 'allowempty' => true, 'category' => 'general', - ), - 'date_format' => array( + ], + 'date_format' => [ 'value' => 'j F Y', - 'description' => s('Date format'), + 'description' => self::$translator->trans('Date format'), 'infoicon' => true, 'type' => 'text', 'allowempty' => false, 'category' => 'general', - ), - 'rc_notification' => array( + ], + 'rc_notification' => [ 'value' => 0, - 'description' => s('Show notification for Release Candidates'), + 'description' => self::$translator->trans('Show notification for Release Candidates'), 'type' => 'boolean', 'allowempty' => true, 'category' => 'security', - ), - 'remote_processing_secret' => array( + ], + 'remote_processing_secret' => [ 'value' => bin2hex(random_bytes(10)), - 'description' => s('Secret for remote processing'), + 'description' => self::$translator->trans('Secret for remote processing'), 'type' => 'text', 'category' => 'security', - ), - 'notify_admin_login' => array( + ], + 'notify_admin_login' => [ 'value' => 1, - 'description' => s('Notify admin on login from new location'), + 'description' => self::$translator->trans('Notify admin on login from new location'), 'type' => 'boolean', 'category' => 'security', 'allowempty' => true, - ), - 'admin_addresses' => array( + ], + 'admin_addresses' => [ 'value' => '', - 'description' => s('List of email addresses to CC in system messages (separate by commas)'), + 'description' => self::$translator->trans('List of email addresses to CC in system messages (separate by commas)'), 'type' => 'emaillist', 'allowempty' => true, 'category' => 'reporting', - ), - 'campaignfrom_default' => array( + ], + 'campaignfrom_default' => [ 'value' => '', - 'description' => s("Default for 'From:' in a campaign"), + 'description' => self::$translator->trans("Default for 'From:' in a campaign"), 'type' => 'text', 'allowempty' => true, 'category' => 'campaign', - ), - 'notifystart_default' => array( + ], + 'notifystart_default' => [ 'value' => '', - 'description' => s("Default for 'address to alert when sending starts'"), + 'description' => self::$translator->trans("Default for 'address to alert when sending starts'"), 'type' => 'email', 'allowempty' => true, 'category' => 'campaign', - ), - 'notifyend_default' => array( + ], + 'notifyend_default' => [ 'value' => '', - 'description' => s("Default for 'address to alert when sending finishes'"), + 'description' => self::$translator->trans("Default for 'address to alert when sending finishes'"), 'type' => 'email', 'allowempty' => true, 'category' => 'campaign', - ), - 'always_add_googletracking' => array( + ], + 'always_add_googletracking' => [ 'value' => '0', - 'description' => s('Always add analytics tracking code to campaigns'), + 'description' => self::$translator->trans('Always add analytics tracking code to campaigns'), 'type' => 'boolean', 'allowempty' => true, 'category' => 'campaign', - ), - 'analytic_tracker' => array( + ], + 'analytic_tracker' => [ 'values' => array('google' => 'Google Analytics', 'matomo' => 'Matomo'), 'value' => 'google', - 'description' => s('Analytics tracking code to add to campaign URLs'), + 'description' => self::$translator->trans('Analytics tracking code to add to campaign URLs'), 'type' => 'select', 'allowempty' => false, 'category' => 'campaign', - ), - 'report_address' => array( + ], + 'report_address' => [ 'value' => 'listreports@[DOMAIN]', - 'description' => s('Who gets the reports (email address, separate multiple emails with a comma)'), + 'description' => self::$translator->trans('Who gets the reports (email address, separate multiple emails with a comma)'), 'type' => 'emaillist', 'allowempty' => true, 'category' => 'reporting', - ), - 'message_from_address' => array( + ], + 'message_from_address' => [ 'value' => 'noreply@[DOMAIN]', - 'description' => s('From email address for system messages'), + 'description' => self::$translator->trans('From email address for system messages'), 'type' => 'email', 'allowempty' => 0, 'category' => 'transactional', - ), - 'message_from_name' => array( - 'value' => s('Webmaster'), - 'description' => s('Name for system messages'), + ], + 'message_from_name' => [ + 'value' => self::$translator->trans('Webmaster'), + 'description' => self::$translator->trans('Name for system messages'), 'type' => 'text', 'allowempty' => 0, 'category' => 'transactional', - ), - 'message_replyto_address' => array( + ], + 'message_replyto_address' => [ 'value' => 'noreply@[DOMAIN]', - 'description' => s('Reply-to email address for system messages'), + 'description' => self::$translator->trans('Reply-to email address for system messages'), 'type' => 'email', 'allowempty' => 0, 'category' => 'transactional', - ), - 'hide_single_list' => array( + ], + 'hide_single_list' => [ 'value' => '1', - 'description' => s('If there is only one visible list, should it be hidden in the page and automatically subscribe users who sign up'), + 'description' => self::$translator->trans('If there is only one visible list, should it be hidden in the page and automatically subscribe users who sign up'), 'type' => 'boolean', 'allowempty' => true, 'category' => 'subscription-ui', - ), - 'list_categories' => array( + ], + 'list_categories' => [ 'value' => '', - 'description' => s('Categories for lists. Separate with commas.'), + 'description' => self::$translator->trans('Categories for lists. Separate with commas.'), 'infoicon' => true, 'type' => 'text', 'allowempty' => true, 'category' => 'list-organisation', - ), - - 'displaycategories' => array( + ], + 'displaycategories' => [ 'value' => 0, - 'description' => s('Display list categories on subscribe page'), + 'description' => self::$translator->trans('Display list categories on subscribe page'), 'type' => 'boolean', 'allowempty' => false, 'category' => 'list-organisation', - ), - 'textline_width' => array( + ], + 'textline_width' => [ 'value' => '40', - 'description' => s('Width of a textline field (numerical)'), + 'description' => self::$translator->trans('Width of a textline field (numerical)'), 'type' => 'integer', 'min' => 20, 'max' => 150, 'category' => 'subscription-ui', - ), - 'textarea_dimensions' => array( + ], + 'textarea_dimensions' => [ 'value' => '10,40', - 'description' => s('Dimensions of a textarea field (rows,columns)'), + 'description' => self::$translator->trans('Dimensions of a textarea field (rows,columns)'), 'type' => 'text', 'allowempty' => 0, 'category' => 'subscription-ui', - ), - 'send_admin_copies' => array( + ], + 'send_admin_copies' => [ 'value' => '0', - 'description' => s('Send notifications about subscribe, update and unsubscribe'), + 'description' => self::$translator->trans('Send notifications about subscribe, update and unsubscribe'), 'type' => 'boolean', 'allowempty' => true, 'category' => 'reporting', - ), - 'defaultsubscribepage' => array( + ], + 'defaultsubscribepage' => [ 'value' => 1, - 'description' => s('The default subscribe page when there are multiple'), + 'description' => self::$translator->trans('The default subscribe page when there are multiple'), 'type' => 'integer', 'min' => 1, 'max' => 999, 'allowempty' => true, 'category' => 'subscription', - ), - 'defaultmessagetemplate' => array( + ], + 'defaultmessagetemplate' => [ 'value' => 0, - 'description' => s('The default HTML template to use when sending a message'), + 'description' => self::$translator->trans('The default HTML template to use when sending a message'), 'type' => 'text', 'allowempty' => true, 'category' => 'campaign', - ), - 'systemmessagetemplate' => array( + ], + 'systemmessagetemplate' => [ 'value' => 0, - 'description' => s('The HTML wrapper template for system messages'), + 'description' => self::$translator->trans('The HTML wrapper template for system messages'), 'type' => 'integer', 'min' => 0, 'max' => 999, // or max(id) from template 'allowempty' => true, 'category' => 'transactional', - ), - 'subscribeurl' => array( - 'value' => $GLOBALS['public_scheme']."://[WEBSITE]$pageroot/?p=subscribe", - 'description' => s('URL where subscribers can sign up'), + ], + 'subscribeurl' => [ + 'value' => $publicSchema."://[WEBSITE]$pageRoot/subscribe", + 'description' => self::$translator->trans('URL where subscribers can sign up'), 'type' => 'url', 'allowempty' => 0, 'category' => 'subscription', - ), - 'unsubscribeurl' => array( - 'value' => $GLOBALS['public_scheme']."://[WEBSITE]$pageroot/?p=unsubscribe", - 'description' => s('URL where subscribers can unsubscribe'), + ], + 'unsubscribeurl' => [ + 'value' => $publicSchema."://[WEBSITE]$pageRoot/unsubscribe", + 'description' => self::$translator->trans('URL where subscribers can unsubscribe'), 'type' => 'url', 'allowempty' => 0, 'category' => 'subscription', - ), - 'blacklisturl' => array( - 'value' => $GLOBALS['public_scheme']."://[WEBSITE]$pageroot/?p=donotsend", - 'description' => s('URL where unknown users can unsubscribe (do-not-send-list)'), + ], + 'blacklisturl' => [ + 'value' => $publicSchema."://[WEBSITE]$pageRoot/donotsend", + 'description' => self::$translator->trans('URL where unknown users can unsubscribe (do-not-send-list)'), 'type' => 'url', 'allowempty' => 0, 'category' => 'subscription', - ), - 'confirmationurl' => array( - 'value' => $GLOBALS['public_scheme']."://[WEBSITE]$pageroot/?p=confirm", - 'description' => s('URL where subscribers have to confirm their subscription'), + ], + 'confirmationurl' => [ + 'value' => $publicSchema."://[WEBSITE]$pageRoot/confirm", + 'description' => self::$translator->trans('URL where subscribers have to confirm their subscription'), 'type' => 'text', 'allowempty' => 0, 'category' => 'subscription', - ), - 'preferencesurl' => array( - 'value' => $GLOBALS['public_scheme']."://[WEBSITE]$pageroot/?p=preferences", - 'description' => s('URL where subscribers can update their details'), + ], + 'preferencesurl' => [ + 'value' => $publicSchema."://[WEBSITE]$pageRoot/preferences", + 'description' => self::$translator->trans('URL where subscribers can update their details'), 'type' => 'text', 'allowempty' => 0, 'category' => 'subscription', - ), - 'forwardurl' => array( - 'value' => $GLOBALS['public_scheme']."://[WEBSITE]$pageroot/?p=forward", - 'description' => s('URL for forwarding messages'), + ], + 'forwardurl' => [ + 'value' => $publicSchema."://[WEBSITE]$pageRoot/forward", + 'description' => self::$translator->trans('URL for forwarding messages'), 'type' => 'text', 'allowempty' => 0, 'category' => 'subscription', - ), - 'vcardurl' => array( - 'value' => $GLOBALS['public_scheme']."://[WEBSITE]$pageroot/?p=vcard", - 'description' => s('URL for downloading vcf card'), + ], + 'vcardurl' => [ + 'value' => $publicSchema."://[WEBSITE]$pageRoot/vcard", + 'description' => self::$translator->trans('URL for downloading vcf card'), 'type' => 'text', 'allowempty' => 0, 'category' => 'subscription', - ), - 'ajax_subscribeconfirmation' => array( - 'value' => s('

Thanks, you have been added to our newsletter

You will receive an email to confirm your subscription. Please click the link in the email to confirm

'), - 'description' => s('Text to display when subscription with an AJAX request was successful'), + ], + 'ajax_subscribeconfirmation' => [ + 'value' => self::$translator->trans('

Thanks, you have been added to our newsletter

You will receive an email to confirm your subscription. Please click the link in the email to confirm

'), + 'description' => self::$translator->trans('Text to display when subscription with an AJAX request was successful'), 'type' => 'textarea', 'allowempty' => true, 'category' => 'subscription', - ), - 'subscribesubject' => array( - 'value' => s('Request for confirmation'), - 'description' => s('Subject of the message subscribers receive when they sign up'), + ], + 'subscribesubject' => [ + 'value' => self::$translator->trans('Request for confirmation'), + 'description' => self::$translator->trans('Subject of the message subscribers receive when they sign up'), 'infoicon' => true, 'type' => 'text', 'allowempty' => 0, 'category' => 'transactional', - ), - 'subscribemessage' => array( + ], + 'subscribemessage' => [ 'value' => ' You have been subscribed to the following newsletters: @@ -297,19 +308,19 @@ private static function init(): void If this is not correct, or you do not agree, simply take no action and delete this message.' , - 'description' => s('Message subscribers receive when they sign up'), + 'description' => self::$translator->trans('Message subscribers receive when they sign up'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'transactional', - ), - 'unsubscribesubject' => array( - 'value' => s('Goodbye from our Newsletter'), - 'description' => s('Subject of the message subscribers receive when they unsubscribe'), + ], + 'unsubscribesubject' => [ + 'value' => self::$translator->trans('Goodbye from our Newsletter'), + 'description' => self::$translator->trans('Subject of the message subscribers receive when they unsubscribe'), 'type' => 'text', 'allowempty' => 0, 'category' => 'transactional', - ), - 'unsubscribemessage' => array( + ], + 'unsubscribemessage' => [ 'value' => 'Goodbye from our Newsletter, sorry to see you go. @@ -323,19 +334,19 @@ private static function init(): void Thank you' , - 'description' => s('Message subscribers receive when they unsubscribe'), + 'description' => self::$translator->trans('Message subscribers receive when they unsubscribe'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'transactional', - ), - 'confirmationsubject' => array( - 'value' => s('Welcome to our Newsletter'), - 'description' => s('Subject of the message subscribers receive after confirming their email address'), + ], + 'confirmationsubject' => [ + 'value' => self::$translator->trans('Welcome to our Newsletter'), + 'description' => self::$translator->trans('Subject of the message subscribers receive after confirming their email address'), 'type' => 'text', 'allowempty' => 0, 'category' => 'transactional', - ), - 'confirmationmessage' => array( + ], + 'confirmationmessage' => [ 'value' => 'Welcome to our Newsletter @@ -349,23 +360,23 @@ private static function init(): void Thank you' , - 'description' => s('Message subscribers receive after confirming their email address'), + 'description' => self::$translator->trans('Message subscribers receive after confirming their email address'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'transactional', - ), - 'updatesubject' => array( - 'value' => s('[notify] Change of List-Membership details'), - 'description' => s('Subject of the message subscribers receive when they have changed their details'), + ], + 'updatesubject' => [ + 'value' => self::$translator->trans('[notify] Change of List-Membership details'), + 'description' => self::$translator->trans('Subject of the message subscribers receive when they have changed their details'), 'type' => 'text', 'allowempty' => 0, 'category' => 'transactional', - ), + ], // the message that is sent when a user updates their information. // just to make sure they approve of it. // confirmationinfo is replaced by one of the options below // userdata is replaced by the information in the database - 'updatemessage' => array( + 'updatemessage' => [ 'value' => 'This message is to inform you of a change of your details on our newsletter database @@ -385,15 +396,15 @@ private static function init(): void Thank you' , - 'description' => s('Message subscribers receive when they have changed their details'), + 'description' => self::$translator->trans('Message subscribers receive when they have changed their details'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'transactional', - ), + ], // this is the text that is placed in the [!-- confirmation --] location of the above // message, in case the email is sent to their new email address and they have changed // their email address - 'emailchanged_text' => array( + 'emailchanged_text' => [ 'value' => ' When updating your details, your email address has changed. Please confirm your new email address by visiting this webpage: @@ -401,15 +412,15 @@ private static function init(): void [CONFIRMATIONURL] ', - 'description' => s('Part of the message that is sent to their new email address when subscribers change their information, and the email address has changed'), + 'description' => self::$translator->trans('Part of the message that is sent to their new email address when subscribers change their information, and the email address has changed'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'transactional', - ), + ], // this is the text that is placed in the [!-- confirmation --] location of the above // message, in case the email is sent to their old email address and they have changed // their email address - 'emailchanged_text_oldaddress' => array( + 'emailchanged_text_oldaddress' => [ 'value' => 'Please Note: when updating your details, your email address has changed. @@ -417,19 +428,19 @@ private static function init(): void to confirm this change. Please visit this website to activate your membership.' , - 'description' => s('Part of the message that is sent to their old email address when subscribers change their information, and the email address has changed'), + 'description' => self::$translator->trans('Part of the message that is sent to their old email address when subscribers change their information, and the email address has changed'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'transactional', - ), - 'personallocation_subject' => array( - 'value' => s('Your personal location'), - 'description' => s('Subject of message when subscribers request their personal location'), + ], + 'personallocation_subject' => [ + 'value' => self::$translator->trans('Your personal location'), + 'description' => self::$translator->trans('Subject of message when subscribers request their personal location'), 'type' => 'text', 'allowempty' => 0, 'category' => 'transactional', - ), - 'messagefooter' => array( + ], + 'messagefooter' => [ 'value' => '-- ', - 'description' => s('Default footer for sending a campaign'), + 'description' => self::$translator->trans('Default footer for sending a campaign'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'campaign', - ), - 'forwardfooter' => array( + ], + 'forwardfooter' => [ 'value' => ' ', - 'description' => s('Footer used when a message has been forwarded'), + 'description' => self::$translator->trans('Footer used when a message has been forwarded'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'campaign', - ), - 'personallocation_message' => array( + ], + 'personallocation_message' => [ 'value' => - 'You have requested your personal location to update your details from our website. The location is below. Please make sure that you use the full line as mentioned below. Sometimes email programmes can wrap the line into multiple lines. @@ -471,80 +481,80 @@ private static function init(): void Thank you.' , - 'description' => s('Message to send when they request their personal location'), + 'description' => self::$translator->trans('Message to send when they request their personal location'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'transactional', - ), - 'remoteurl_append' => array( + ], + 'remoteurl_append' => [ 'value' => '', - 'description' => s('String to always append to remote URL when using send-a-webpage'), + 'description' => self::$translator->trans('String to always append to remote URL when using send-a-webpage'), 'type' => 'text', 'allowempty' => true, 'category' => 'campaign', - ), - 'wordwrap' => array( + ], + 'wordwrap' => [ 'value' => '75', - 'description' => s('Width for Wordwrap of Text messages'), + 'description' => self::$translator->trans('Width for Wordwrap of Text messages'), 'type' => 'text', 'allowempty' => true, 'category' => 'campaign', - ), - 'html_email_style' => array( + ], + 'html_email_style' => [ 'value' => '', - 'description' => s('CSS for HTML messages without a template'), + 'description' => self::$translator->trans('CSS for HTML messages without a template'), 'type' => 'textarea', 'allowempty' => true, 'category' => 'campaign', - ), - 'alwayssendtextto' => array( + ], + 'alwayssendtextto' => [ 'value' => '', - 'description' => s('Domains that only accept text emails, one per line'), + 'description' => self::$translator->trans('Domains that only accept text emails, one per line'), 'type' => 'textarea', 'allowempty' => true, 'category' => 'campaign', - ), - 'tld_last_sync' => array( + ], + 'tld_last_sync' => [ 'value' => '0', - 'description' => s('last time TLDs were fetched'), + 'description' => self::$translator->trans('last time TLDs were fetched'), 'type' => 'text', 'allowempty' => true, 'category' => 'system', 'hidden' => true, - ), - 'internet_tlds' => array( + ], + 'internet_tlds' => [ 'value' => '', - 'description' => s('Top level domains'), + 'description' => self::$translator->trans('Top level domains'), 'type' => 'textarea', 'allowempty' => true, 'category' => 'system', 'hidden' => true, - ), - 'pageheader' => array( + ], + 'pageheader' => [ 'value' => '

Welcome

', - 'description' => s('Header of public pages.'), + 'description' => self::$translator->trans('Header of public pages.'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'subscription-ui', - ), - 'pagefooter' => array( + ], + 'pagefooter' => [ 'value' => '

Footer text

', - 'description' => s('Footer of public pages'), + 'description' => self::$translator->trans('Footer of public pages'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'subscription-ui', - ), - ); + ], + ]; } /** * Get a single default config item by key * * @param string $key - * @param mixed $default + * @param mixed|null $default * @return mixed */ - public static function get(string $key, $default = null) + public static function get(string $key, mixed $default = null) { self::init(); return self::$defaults[$key] ?? $default; From 6a9946316e530c24028f9a3bfbed7652fe21f000 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 3 Oct 2025 12:12:56 +0400 Subject: [PATCH 10/16] Email with messageHandler --- config/parameters.yml.dist | 4 +- config/services/messenger.yml | 6 ++ config/services/services.yml | 9 ++- .../Service/LegacyUrlBuilder.php | 30 ++++++++ .../Service/PlaceholderResolver.php | 31 +++++++++ .../Provider/DefaultConfigProvider.php | 14 ++-- .../Service/UserPersonalizer.php | 68 +++++++++++++++++++ .../SubscriptionConfirmationMessage.php | 51 ++++++++++++++ ...SubscriptionConfirmationMessageHandler.php | 57 ++++++++++++++++ .../SubscriberAttributeValueRepository.php | 12 ++++ .../Resolver/AttributeValueResolver.php | 7 +- .../Service/SubscriberCsvImporter.php | 49 ++++--------- .../SubscriberCsvImportManagerTest.php | 10 +-- 13 files changed, 295 insertions(+), 53 deletions(-) create mode 100644 src/Domain/Configuration/Service/LegacyUrlBuilder.php create mode 100644 src/Domain/Configuration/Service/PlaceholderResolver.php create mode 100644 src/Domain/Configuration/Service/UserPersonalizer.php create mode 100644 src/Domain/Messaging/Message/SubscriptionConfirmationMessage.php create mode 100644 src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index e34a7d2b..fb2ce8ee 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -28,7 +28,9 @@ parameters: app.mailer_dsn: '%%env(MAILER_DSN)%%' env(MAILER_DSN): 'null://null' app.confirmation_url: '%%env(CONFIRMATION_URL)%%' - env(CONFIRMATION_URL): 'https://example.com/confirm/' + env(CONFIRMATION_URL): 'https://example.com/subscriber/confirm/' + app.subscription_confirmation_url: '%%env(SUBSCRIPTION_CONFIRMATION_URL)%%' + env(SUBSCRIPTION_CONFIRMATION_URL): 'https://example.com/subscription/confirm/' app.password_reset_url: '%%env(PASSWORD_RESET_URL)%%' env(PASSWORD_RESET_URL): 'https://example.com/reset/' diff --git a/config/services/messenger.yml b/config/services/messenger.yml index 3d1e59b2..80f893f4 100644 --- a/config/services/messenger.yml +++ b/config/services/messenger.yml @@ -22,3 +22,9 @@ services: tags: [ 'messenger.message_handler' ] arguments: $passwordResetUrl: '%app.password_reset_url%' + + PhpList\Core\Domain\Messaging\MessageHandler\SubscriptionConfirmationMessageHandler: + autowire: true + autoconfigure: true + tags: [ 'messenger.message_handler' ] + diff --git a/config/services/services.yml b/config/services/services.yml index fac384b9..d33ddd2c 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -120,8 +120,15 @@ services: arguments: $maxSeconds: '%messaging.max_process_time%' - PhpList\Core\Domain\Identity\Service\PermissionChecker: autowire: true autoconfigure: true public: true + + PhpList\Core\Domain\Configuration\Service\UserPersonalizer: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder: + autowire: true + autoconfigure: true diff --git a/src/Domain/Configuration/Service/LegacyUrlBuilder.php b/src/Domain/Configuration/Service/LegacyUrlBuilder.php new file mode 100644 index 00000000..107f236c --- /dev/null +++ b/src/Domain/Configuration/Service/LegacyUrlBuilder.php @@ -0,0 +1,30 @@ + */ + private array $providers = []; + + public function register(string $token, callable $provider): void + { + // tokens like [UNSUBSCRIBEURL] (case-insensitive) + $this->providers[strtoupper($token)] = $provider; + } + + public function resolve(?string $input): ?string + { + if ($input === null || $input === '') return $input; + + // Replace [TOKEN] (case-insensitive) + return preg_replace_callback('/\[(\w+)\]/i', function ($m) { + $key = strtoupper($m[1]); + if (!isset($this->providers[$key])) { + return $m[0]; + } + return (string) ($this->providers[$key])(); + }, $input); + } +} diff --git a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php index 299d97e4..bbb796ce 100644 --- a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php @@ -224,49 +224,49 @@ private static function init(): void 'category' => 'transactional', ], 'subscribeurl' => [ - 'value' => $publicSchema."://[WEBSITE]$pageRoot/subscribe", + 'value' => $publicSchema."://[WEBSITE]$pageRoot/?p=subscribe", 'description' => self::$translator->trans('URL where subscribers can sign up'), 'type' => 'url', 'allowempty' => 0, 'category' => 'subscription', ], 'unsubscribeurl' => [ - 'value' => $publicSchema."://[WEBSITE]$pageRoot/unsubscribe", + 'value' => $publicSchema."://[WEBSITE]$pageRoot/?p=unsubscribe", 'description' => self::$translator->trans('URL where subscribers can unsubscribe'), 'type' => 'url', 'allowempty' => 0, 'category' => 'subscription', ], 'blacklisturl' => [ - 'value' => $publicSchema."://[WEBSITE]$pageRoot/donotsend", + 'value' => $publicSchema."://[WEBSITE]$pageRoot/?p=donotsend", 'description' => self::$translator->trans('URL where unknown users can unsubscribe (do-not-send-list)'), 'type' => 'url', 'allowempty' => 0, 'category' => 'subscription', ], 'confirmationurl' => [ - 'value' => $publicSchema."://[WEBSITE]$pageRoot/confirm", + 'value' => $publicSchema."://[WEBSITE]$pageRoot/?p=confirm", 'description' => self::$translator->trans('URL where subscribers have to confirm their subscription'), 'type' => 'text', 'allowempty' => 0, 'category' => 'subscription', ], 'preferencesurl' => [ - 'value' => $publicSchema."://[WEBSITE]$pageRoot/preferences", + 'value' => $publicSchema."://[WEBSITE]$pageRoot/?p=preferences", 'description' => self::$translator->trans('URL where subscribers can update their details'), 'type' => 'text', 'allowempty' => 0, 'category' => 'subscription', ], 'forwardurl' => [ - 'value' => $publicSchema."://[WEBSITE]$pageRoot/forward", + 'value' => $publicSchema."://[WEBSITE]$pageRoot/?p=forward", 'description' => self::$translator->trans('URL for forwarding messages'), 'type' => 'text', 'allowempty' => 0, 'category' => 'subscription', ], 'vcardurl' => [ - 'value' => $publicSchema."://[WEBSITE]$pageRoot/vcard", + 'value' => $publicSchema."://[WEBSITE]$pageRoot/?p=vcard", 'description' => self::$translator->trans('URL for downloading vcf card'), 'type' => 'text', 'allowempty' => 0, diff --git a/src/Domain/Configuration/Service/UserPersonalizer.php b/src/Domain/Configuration/Service/UserPersonalizer.php new file mode 100644 index 00000000..c7f55d4f --- /dev/null +++ b/src/Domain/Configuration/Service/UserPersonalizer.php @@ -0,0 +1,68 @@ +subscriberRepository->findOneByEmail($email); + if (!$user) { + return $value; + } + + $resolver = new PlaceholderResolver(); + $resolver->register('EMAIL', fn() => $user->getEmail()); + + $resolver->register('UNSUBSCRIBEURL', function () use ($user) { + $base = $this->config->getValue(ConfigOption::UnsubscribeUrl) ?? ''; + return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE; + }); + + $resolver->register('CONFIRMATIONURL', function () use ($user) { + $base = $this->config->getValue(ConfigOption::ConfirmationUrl) ?? ''; + return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE; + }); + $resolver->register('PREFERENCESURL', function () use ($user) { + $base = $this->config->getValue(ConfigOption::PreferencesUrl) ?? ''; + return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE; + }); + + $resolver->register( + 'SUBSCRIBEURL', + fn() => ($this->config->getValue(ConfigOption::SubscribeUrl) ?? '') . self::PHP_SPACE + ); + $resolver->register('DOMAIN', fn() => $this->config->getValue(ConfigOption::Domain) ?? ''); + $resolver->register('WEBSITE', fn() => $this->config->getValue(ConfigOption::Website) ?? ''); + + $userAttributes = $this->attributesRepository->getForSubscriber($user); + foreach ($userAttributes as $userAttribute) { + $resolver->register( + strtoupper($userAttribute->getAttributeDefinition()->getName()), + fn() => $this->attributeValueResolver->resolve($userAttribute) + ); + } + + $out = $resolver->resolve($value); + + return (string) $out; + } +} diff --git a/src/Domain/Messaging/Message/SubscriptionConfirmationMessage.php b/src/Domain/Messaging/Message/SubscriptionConfirmationMessage.php new file mode 100644 index 00000000..22515145 --- /dev/null +++ b/src/Domain/Messaging/Message/SubscriptionConfirmationMessage.php @@ -0,0 +1,51 @@ +email = $email; + $this->uniqueId = $uniqueId; + $this->listIds = $listIds; + $this->htmlEmail = $htmlEmail; + } + + public function getEmail(): string + { + return $this->email; + } + + public function getUniqueId(): string + { + return $this->uniqueId; + } + + public function getListIds(): array + { + return $this->listIds; + } + + public function hasHtmlEmail(): bool + { + return $this->htmlEmail; + } +} diff --git a/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php b/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php new file mode 100644 index 00000000..51514627 --- /dev/null +++ b/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php @@ -0,0 +1,57 @@ +emailService = $emailService; + $this->configProvider = $configProvider; + $this->logger = $logger; + $this->userPersonalizer = $userPersonalizer; + } + + /** + * Process a subscription confirmation message by sending the confirmation email + */ + public function __invoke(SubscriberConfirmationMessage $message): void + { + $subject = $this->configProvider->getValue(ConfigOption::SubscribeEmailSubject); + $textContent = $this->configProvider->getValue(ConfigOption::SubscribeMessage); + $replacedTextContent = $this->userPersonalizer->personalize($textContent, $message->getUniqueId()); + + $email = (new Email()) + ->to($message->getEmail()) + ->subject($subject) + ->text($replacedTextContent); + + $this->emailService->sendEmail($email); + + $this->logger->info('Subscription confirmation email sent to {email}', ['email' => $message->getEmail()]); + } +} diff --git a/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php b/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php index d29d56ff..6da037fd 100644 --- a/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php @@ -64,4 +64,16 @@ public function getFilteredAfterId(int $lastId, int $limit, ?FilterRequestInterf ->getQuery() ->getResult(); } + + /** @return SubscriberAttributeValue[] */ + public function getForSubscriber(Subscriber $subscriber): array + { + return $this->createQueryBuilder('sa') + ->join('sa.subscriber', 's') + ->join('sa.attributeDefinition', 'ad') + ->where('s = :subscriber') + ->setParameter('subscriber', $subscriber) + ->getQuery() + ->getResult(); + } } diff --git a/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php b/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php index 4b294c14..4edf42d1 100644 --- a/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php +++ b/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php @@ -4,7 +4,6 @@ namespace PhpList\Core\Domain\Subscription\Service\Resolver; -use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue; use PhpList\Core\Domain\Subscription\Service\Provider\AttributeValueProvider; @@ -13,11 +12,11 @@ class AttributeValueResolver /** @param iterable $providers */ public function __construct(private readonly iterable $providers) {} - public function resolve(SubscriberAttributeDefinition $attribute, SubscriberAttributeValue $userAttr): string + public function resolve(SubscriberAttributeValue $userAttr): string { foreach ($this->providers as $provider) { - if ($provider->supports($attribute)) { - return $provider->getValue($attribute, $userAttr); + if ($provider->supports($userAttr->getAttributeDefinition())) { + return $provider->getValue($userAttr->getAttributeDefinition(), $userAttr); } } return ''; diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php index 350c502c..1e7c9217 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php @@ -5,21 +5,18 @@ namespace PhpList\Core\Domain\Subscription\Service; use Doctrine\ORM\EntityManagerInterface; -use PhpList\Core\Domain\Configuration\Model\ConfigOption; -use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; -use PhpList\Core\Domain\Messaging\Service\EmailService; +use PhpList\Core\Domain\Messaging\Message\SubscriptionConfirmationMessage; use PhpList\Core\Domain\Subscription\Exception\CouldNotReadUploadedFileException; use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; -use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager; use Symfony\Component\HttpFoundation\File\UploadedFile; -use Symfony\Component\Mime\Email; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Contracts\Translation\TranslatorInterface; use Throwable; @@ -37,9 +34,7 @@ class SubscriberCsvImporter private SubscriberAttributeDefinitionRepository $attrDefinitionRepository; private EntityManagerInterface $entityManager; private TranslatorInterface $translator; - private EmailService $emailService; - private ConfigProvider $configProvider; - private SubscriberListRepository $subscriberListRepository; + private MessageBusInterface $messageBus; public function __construct( SubscriberManager $subscriberManager, @@ -50,9 +45,7 @@ public function __construct( SubscriberAttributeDefinitionRepository $attrDefinitionRepository, EntityManagerInterface $entityManager, TranslatorInterface $translator, - EmailService $emailService, - ConfigProvider $configProvider, - SubscriberListRepository $subscriberListRepository, + MessageBusInterface $messageBus, ) { $this->subscriberManager = $subscriberManager; $this->attributeManager = $attributeManager; @@ -62,9 +55,7 @@ public function __construct( $this->attrDefinitionRepository = $attrDefinitionRepository; $this->entityManager = $entityManager; $this->translator = $translator; - $this->emailService = $emailService; - $this->configProvider = $configProvider; - $this->subscriberListRepository = $subscriberListRepository; + $this->messageBus = $messageBus; } /** @@ -198,33 +189,21 @@ private function processRow( if (!$options->dryRun) { $this->entityManager->flush(); if ($options->notifySubscribers && $addedNewSubscriberToList) { - $this->sendSubscribeEmail($dto->email, $options->listIds); + $this->sendSubscribeEmail($subscriber, $options->listIds); } } } - private function sendSubscribeEmail(string $subscriberEmail, array $listIds): void + private function sendSubscribeEmail(Subscriber $subscriber, array $listIds): void { - $listNames = []; - foreach ($listIds as $id) { - $list = $this->subscriberListRepository->find($id); - if ($list) { - $listNames[] = $list->getName(); - } - } - $listOfLists = implode(', ', $listNames); - - $subject = $this->configProvider->getValue('subscribesubject', 'Subscription'); - $message = $this->configProvider->getValue('subscribemessage', 'You have been subscribed to: [LISTS]'); - $message = str_replace('[LISTS]', $listOfLists, (string)$message); - - $email = (new Email()) - ->to($subscriberEmail) - ->subject((string)$subject) - ->text($message) - ->html(nl2br(htmlentities($message))); + $message = new SubscriptionConfirmationMessage( + email: $subscriber->getEmail(), + uniqueId: $subscriber->getUniqueId(), + listIds: $listIds, + htmlEmail: $subscriber->hasHtmlEmail(), + ); - $this->emailService->sendEmail($email); + $this->messageBus->dispatch($message); } /** diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php index 0e84fdec..77a1f61d 100644 --- a/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php +++ b/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php @@ -49,11 +49,11 @@ public function testImportFromCsvCreatesNewSubscribers(): void file_put_contents($tempFile, $csvContent); $uploadedFile = new UploadedFile( - $tempFile, - 'subscribers.csv', - 'text/csv', - null, - true + path: $tempFile, + originalName: 'subscribers.csv', + mimeType: 'text/csv', + error: null, + test: true ); $subscriberCountBefore = count($this->subscriberRepository->findAll()); From fc6ba926336a1f581e966acf7b75e46d78ac19bd Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 3 Oct 2025 13:47:10 +0400 Subject: [PATCH 11/16] Style fix --- .../Service/PlaceholderResolver.php | 6 +-- .../Service/Provider/ConfigProvider.php | 3 ++ .../Provider/DefaultConfigProvider.php | 2 + .../Model/Dto/SubscriberImportOptions.php | 1 - .../Repository/DynamicListAttrRepository.php | 12 ++--- .../Provider/CheckboxGroupValueProvider.php | 2 +- .../Service/SubscriberCsvImporter.php | 44 ++++++++++++++----- .../Command/ProcessQueueCommandTest.php | 4 +- .../Service/SubscriberCsvImporterTest.php | 13 +----- 9 files changed, 53 insertions(+), 34 deletions(-) diff --git a/src/Domain/Configuration/Service/PlaceholderResolver.php b/src/Domain/Configuration/Service/PlaceholderResolver.php index bb269f00..e2f4c562 100644 --- a/src/Domain/Configuration/Service/PlaceholderResolver.php +++ b/src/Domain/Configuration/Service/PlaceholderResolver.php @@ -20,10 +20,10 @@ public function resolve(?string $input): ?string if ($input === null || $input === '') return $input; // Replace [TOKEN] (case-insensitive) - return preg_replace_callback('/\[(\w+)\]/i', function ($m) { - $key = strtoupper($m[1]); + return preg_replace_callback('/\[(\w+)\]/i', function ($map) { + $key = strtoupper($map[1]); if (!isset($this->providers[$key])) { - return $m[0]; + return $map[0]; } return (string) ($this->providers[$key])(); }, $input); diff --git a/src/Domain/Configuration/Service/Provider/ConfigProvider.php b/src/Domain/Configuration/Service/Provider/ConfigProvider.php index ce00f9b7..eef01788 100644 --- a/src/Domain/Configuration/Service/Provider/ConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/ConfigProvider.php @@ -22,6 +22,7 @@ public function __construct( ) { } + /** @SuppressWarnings(PHPMD.StaticAccess) */ public function isEnabled(ConfigOption $key): bool { if (!in_array($key, $this->booleanValues)) { @@ -38,6 +39,7 @@ public function isEnabled(ConfigOption $key): bool /** * Get configuration value by its key, from settings or default configs or default value (if provided) + * @SuppressWarnings(PHPMD.StaticAccess) */ public function getValue(ConfigOption $key): ?string { @@ -58,6 +60,7 @@ public function getValue(ConfigOption $key): ?string return DefaultConfigProvider::has($key->value) ? DefaultConfigProvider::get($key->value) : null; } + /** @SuppressWarnings(PHPMD.StaticAccess) */ public function getValueWithNamespace(ConfigOption $key): ?string { $full = $this->getValue($key); diff --git a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php index bbb796ce..81d86e85 100644 --- a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php @@ -6,6 +6,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; +/** @SuppressWarnings(PHPMD.StaticAccess) */ class DefaultConfigProvider { /** @@ -21,6 +22,7 @@ public static function setTranslator(TranslatorInterface $translator): void self::$translator = $translator; } + /** @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ private static function init(): void { if (!empty(self::$defaults)) { diff --git a/src/Domain/Subscription/Model/Dto/SubscriberImportOptions.php b/src/Domain/Subscription/Model/Dto/SubscriberImportOptions.php index d181d757..8a180e03 100644 --- a/src/Domain/Subscription/Model/Dto/SubscriberImportOptions.php +++ b/src/Domain/Subscription/Model/Dto/SubscriberImportOptions.php @@ -14,7 +14,6 @@ public function __construct( public readonly array $listIds = [], public readonly bool $dryRun = false, public readonly bool $skipInvalidEmail = true, - public readonly bool $notifySubscribers = false, ) { } } diff --git a/src/Domain/Subscription/Repository/DynamicListAttrRepository.php b/src/Domain/Subscription/Repository/DynamicListAttrRepository.php index c951d5a3..1c1d23d1 100644 --- a/src/Domain/Subscription/Repository/DynamicListAttrRepository.php +++ b/src/Domain/Subscription/Repository/DynamicListAttrRepository.php @@ -30,13 +30,13 @@ public function fetchOptionNames(string $listTable, array $ids): array $table = $this->prefix . 'listattr_' . $listTable; - $qb = $this->connection->createQueryBuilder(); - $qb->select('name') + $queryBuilder = $this->connection->createQueryBuilder(); + $queryBuilder->select('name') ->from($table) ->where('id IN (:ids)') ->setParameter('ids', array_map('intval', $ids), ArrayParameterType::INTEGER); - return $qb->executeQuery()->fetchFirstColumn(); + return $queryBuilder->executeQuery()->fetchFirstColumn(); } public function fetchSingleOptionName(string $listTable, int $id): ?string @@ -47,13 +47,13 @@ public function fetchSingleOptionName(string $listTable, int $id): ?string $table = $this->prefix . 'listattr_' . $listTable; - $qb = $this->connection->createQueryBuilder(); - $qb->select('name') + $queryBuilder = $this->connection->createQueryBuilder(); + $queryBuilder->select('name') ->from($table) ->where('id = :id') ->setParameter('id', $id); - $val = $qb->executeQuery()->fetchOne(); + $val = $queryBuilder->executeQuery()->fetchOne(); return $val === false ? null : (string) $val; } diff --git a/src/Domain/Subscription/Service/Provider/CheckboxGroupValueProvider.php b/src/Domain/Subscription/Service/Provider/CheckboxGroupValueProvider.php index 098e384e..8b2d571a 100644 --- a/src/Domain/Subscription/Service/Provider/CheckboxGroupValueProvider.php +++ b/src/Domain/Subscription/Service/Provider/CheckboxGroupValueProvider.php @@ -26,7 +26,7 @@ public function getValue(SubscriberAttributeDefinition $attribute, SubscriberAtt } $ids = array_values(array_filter(array_map( - fn($v) => ($i = (int)trim($v)) > 0 ? $i : null, + fn($value) => ($index = (int)trim($value)) > 0 ? $index : null, explode(',', $csv) ))); diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php index 1e7c9217..bd35db3a 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php @@ -151,21 +151,17 @@ private function processRow( SubscriberImportOptions $options, array &$stats, ): void { - if (!filter_var($dto->email, FILTER_VALIDATE_EMAIL)) { - if ($options->skipInvalidEmail) { - $stats['skipped']++; - return; - } else { - $dto->email = 'invalid_' . $dto->email; - $dto->sendConfirmation = false; - } + if ($this->handleInvalidEmail($dto, $options, $stats)) { + return; } - $subscriber = $this->subscriberRepository->findOneByEmail($dto->email); + $subscriber = $this->subscriberRepository->findOneByEmail($dto->email); if ($subscriber && !$options->updateExisting) { $stats['skipped']++; + return; } + if ($subscriber) { $this->subscriberManager->updateFromImport($subscriber, $dto); $stats['updated']++; @@ -186,9 +182,37 @@ private function processRow( } } + $this->handleFlushAndEmail($subscriber, $options,$dto, $addedNewSubscriberToList); + } + + private function handleInvalidEmail( + ImportSubscriberDto $dto, + SubscriberImportOptions $options, + array &$stats + ): bool { + if (!filter_var($dto->email, FILTER_VALIDATE_EMAIL)) { + if ($options->skipInvalidEmail) { + $stats['skipped']++; + + return true; + } + // todo: check + $dto->email = 'invalid_' . $dto->email; + $dto->sendConfirmation = false; + } + + return false; + } + + private function handleFlushAndEmail( + Subscriber $subscriber, + SubscriberImportOptions $options, + ImportSubscriberDto $dto, + bool $addedNewSubscriberToList + ): void { if (!$options->dryRun) { $this->entityManager->flush(); - if ($options->notifySubscribers && $addedNewSubscriberToList) { + if ($dto->sendConfirmation && $addedNewSubscriberToList) { $this->sendSubscribeEmail($subscriber, $options->listIds); } } diff --git a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php index d8e837ba..5cd84c5e 100644 --- a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php +++ b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php @@ -5,7 +5,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Command; use Exception; -use PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager; +use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Messaging\Command\ProcessQueueCommand; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; @@ -46,7 +46,7 @@ protected function setUp(): void lockFactory: $lockFactory, messagePreparator: $this->messageProcessingPreparator, campaignProcessor: $this->campaignProcessor, - configManager: $this->createMock(ConfigManager::class), + configProvider: $this->createMock(ConfigProvider::class), translator: $this->translator, ); diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php index af387ce9..1453cfa2 100644 --- a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php +++ b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php @@ -11,10 +11,6 @@ use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; -use PhpList\Core\Domain\Configuration\Model\ConfigOption; -use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; -use PhpList\Core\Domain\Messaging\Service\EmailService; -use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use PhpList\Core\Domain\Subscription\Service\CsvImporter; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; @@ -23,6 +19,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Translation\Translator; class SubscriberCsvImporterTest extends TestCase @@ -43,10 +40,6 @@ protected function setUp(): void $this->csvImporterMock = $this->createMock(CsvImporter::class); $this->attributeDefinitionRepositoryMock = $this->createMock(SubscriberAttributeDefinitionRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); - $configProvider = $this->createMock(ConfigProvider::class); - $emailService = $this->createMock(EmailService::class); - $subscriberListRepository = $this->createMock(SubscriberListRepository::class); - $configProvider->method('isEnabled')->with(ConfigOption::SendSubscribeMessage)->willReturn(false); $this->subject = new SubscriberCsvImporter( subscriberManager: $this->subscriberManagerMock, @@ -57,9 +50,7 @@ protected function setUp(): void attrDefinitionRepository: $this->attributeDefinitionRepositoryMock, entityManager: $entityManager, translator: new Translator('en'), - emailService: $emailService, - configProvider: $configProvider, - subscriberListRepository: $subscriberListRepository, + messageBus: $this->createMock(MessageBusInterface::class), ); } From f9a7a3ca20d6cbe68211ad42d0575984dd7fe1ba Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 6 Oct 2025 09:55:36 +0400 Subject: [PATCH 12/16] PhpCs fix --- .../Service/LegacyUrlBuilder.php | 15 ++-- .../Service/PlaceholderResolver.php | 4 +- .../Service/Provider/ConfigProvider.php | 6 +- .../Provider/DefaultConfigProvider.php | 77 +++++++++++-------- .../Service/UserPersonalizer.php | 3 +- .../Repository/DynamicListAttrRepository.php | 10 ++- .../Provider/CheckboxGroupValueProvider.php | 19 +++-- .../Provider/SelectOrRadioValueProvider.php | 8 +- .../Resolver/AttributeValueResolver.php | 4 +- .../Service/SubscriberCsvImporter.php | 5 +- 10 files changed, 94 insertions(+), 57 deletions(-) diff --git a/src/Domain/Configuration/Service/LegacyUrlBuilder.php b/src/Domain/Configuration/Service/LegacyUrlBuilder.php index 107f236c..4bc6366f 100644 --- a/src/Domain/Configuration/Service/LegacyUrlBuilder.php +++ b/src/Domain/Configuration/Service/LegacyUrlBuilder.php @@ -17,14 +17,13 @@ public function withUid(string $baseUrl, string $uid): string $parts['query'] = http_build_query($query); - // rebuild url - $scheme = $parts['scheme'] ?? 'https'; - $host = $parts['host'] ?? ''; - $port = isset($parts['port']) ? ':'.$parts['port'] : ''; - $path = $parts['path'] ?? ''; - $queryStr = $parts['query'] ? '?'.$parts['query'] : ''; - $frag = isset($parts['fragment']) ? '#'.$parts['fragment'] : ''; + $scheme = $parts['scheme'] ?? 'https'; + $host = $parts['host'] ?? ''; + $port = isset($parts['port']) ? ':' . $parts['port'] : ''; + $path = $parts['path'] ?? ''; + $queryStr = $parts['query'] ? '?' . $parts['query'] : ''; + $frag = isset($parts['fragment']) ? '#' . $parts['fragment'] : ''; - return "{$scheme}://{$host}{$port}{$path}{$queryStr}{$frag}"; + return $scheme . '://' . $host . $port . $path . $queryStr . $frag; } } diff --git a/src/Domain/Configuration/Service/PlaceholderResolver.php b/src/Domain/Configuration/Service/PlaceholderResolver.php index e2f4c562..3a0a3464 100644 --- a/src/Domain/Configuration/Service/PlaceholderResolver.php +++ b/src/Domain/Configuration/Service/PlaceholderResolver.php @@ -17,7 +17,9 @@ public function register(string $token, callable $provider): void public function resolve(?string $input): ?string { - if ($input === null || $input === '') return $input; + if ($input === null || $input === '') { + return $input; + } // Replace [TOKEN] (case-insensitive) return preg_replace_callback('/\[(\w+)\]/i', function ($map) { diff --git a/src/Domain/Configuration/Service/Provider/ConfigProvider.php b/src/Domain/Configuration/Service/Provider/ConfigProvider.php index eef01788..4a97b784 100644 --- a/src/Domain/Configuration/Service/Provider/ConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/ConfigProvider.php @@ -22,7 +22,10 @@ public function __construct( ) { } - /** @SuppressWarnings(PHPMD.StaticAccess) */ + /** + * @SuppressWarnings(PHPMD.StaticAccess) + * @throws InvalidArgumentException + */ public function isEnabled(ConfigOption $key): bool { if (!in_array($key, $this->booleanValues)) { @@ -40,6 +43,7 @@ public function isEnabled(ConfigOption $key): bool /** * Get configuration value by its key, from settings or default configs or default value (if provided) * @SuppressWarnings(PHPMD.StaticAccess) + * @throws InvalidArgumentException */ public function getValue(ConfigOption $key): ?string { diff --git a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php index 81d86e85..ca11617f 100644 --- a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php @@ -6,6 +6,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; +// phpcs:disable Generic.Files.LineLength /** @SuppressWarnings(PHPMD.StaticAccess) */ class DefaultConfigProvider { @@ -87,7 +88,9 @@ private static function init(): void ], 'admin_addresses' => [ 'value' => '', - 'description' => self::$translator->trans('List of email addresses to CC in system messages (separate by commas)'), + 'description' => self::$translator->trans( + 'List of email addresses to CC in system messages (separate by commas)' + ), 'type' => 'emaillist', 'allowempty' => true, 'category' => 'reporting', @@ -121,7 +124,7 @@ private static function init(): void 'category' => 'campaign', ], 'analytic_tracker' => [ - 'values' => array('google' => 'Google Analytics', 'matomo' => 'Matomo'), + 'values' => ['google' => 'Google Analytics', 'matomo' => 'Matomo'], 'value' => 'google', 'description' => self::$translator->trans('Analytics tracking code to add to campaign URLs'), 'type' => 'select', @@ -130,7 +133,9 @@ private static function init(): void ], 'report_address' => [ 'value' => 'listreports@[DOMAIN]', - 'description' => self::$translator->trans('Who gets the reports (email address, separate multiple emails with a comma)'), + 'description' => self::$translator->trans( + 'Who gets the reports (email address, separate multiple emails with a comma)' + ), 'type' => 'emaillist', 'allowempty' => true, 'category' => 'reporting', @@ -221,54 +226,54 @@ private static function init(): void 'description' => self::$translator->trans('The HTML wrapper template for system messages'), 'type' => 'integer', 'min' => 0, - 'max' => 999, // or max(id) from template + 'max' => 999, 'allowempty' => true, 'category' => 'transactional', ], 'subscribeurl' => [ - 'value' => $publicSchema."://[WEBSITE]$pageRoot/?p=subscribe", + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=subscribe', 'description' => self::$translator->trans('URL where subscribers can sign up'), 'type' => 'url', 'allowempty' => 0, 'category' => 'subscription', ], 'unsubscribeurl' => [ - 'value' => $publicSchema."://[WEBSITE]$pageRoot/?p=unsubscribe", + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=unsubscribe', 'description' => self::$translator->trans('URL where subscribers can unsubscribe'), 'type' => 'url', 'allowempty' => 0, 'category' => 'subscription', ], 'blacklisturl' => [ - 'value' => $publicSchema."://[WEBSITE]$pageRoot/?p=donotsend", + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=donotsend', 'description' => self::$translator->trans('URL where unknown users can unsubscribe (do-not-send-list)'), 'type' => 'url', 'allowempty' => 0, 'category' => 'subscription', ], 'confirmationurl' => [ - 'value' => $publicSchema."://[WEBSITE]$pageRoot/?p=confirm", + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=confirm', 'description' => self::$translator->trans('URL where subscribers have to confirm their subscription'), 'type' => 'text', 'allowempty' => 0, 'category' => 'subscription', ], 'preferencesurl' => [ - 'value' => $publicSchema."://[WEBSITE]$pageRoot/?p=preferences", + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=preferences', 'description' => self::$translator->trans('URL where subscribers can update their details'), 'type' => 'text', 'allowempty' => 0, 'category' => 'subscription', ], 'forwardurl' => [ - 'value' => $publicSchema."://[WEBSITE]$pageRoot/?p=forward", + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=forward', 'description' => self::$translator->trans('URL for forwarding messages'), 'type' => 'text', 'allowempty' => 0, 'category' => 'subscription', ], 'vcardurl' => [ - 'value' => $publicSchema."://[WEBSITE]$pageRoot/?p=vcard", + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=vcard', 'description' => self::$translator->trans('URL for downloading vcf card'), 'type' => 'text', 'allowempty' => 0, @@ -283,15 +288,16 @@ private static function init(): void ], 'subscribesubject' => [ 'value' => self::$translator->trans('Request for confirmation'), - 'description' => self::$translator->trans('Subject of the message subscribers receive when they sign up'), + 'description' => self::$translator->trans( + 'Subject of the message subscribers receive when they sign up' + ), 'infoicon' => true, 'type' => 'text', 'allowempty' => 0, 'category' => 'transactional', ], 'subscribemessage' => [ - 'value' => - ' You have been subscribed to the following newsletters: + 'value' => ' You have been subscribed to the following newsletters: [LISTS] @@ -317,14 +323,15 @@ private static function init(): void ], 'unsubscribesubject' => [ 'value' => self::$translator->trans('Goodbye from our Newsletter'), - 'description' => self::$translator->trans('Subject of the message subscribers receive when they unsubscribe'), + 'description' => self::$translator->trans( + 'Subject of the message subscribers receive when they unsubscribe' + ), 'type' => 'text', 'allowempty' => 0, 'category' => 'transactional', ], 'unsubscribemessage' => [ - 'value' => - 'Goodbye from our Newsletter, sorry to see you go. + 'value' => 'Goodbye from our Newsletter, sorry to see you go. You have been unsubscribed from our newsletters. @@ -343,14 +350,15 @@ private static function init(): void ], 'confirmationsubject' => [ 'value' => self::$translator->trans('Welcome to our Newsletter'), - 'description' => self::$translator->trans('Subject of the message subscribers receive after confirming their email address'), + 'description' => self::$translator->trans( + 'Subject of the message subscribers receive after confirming their email address' + ), 'type' => 'text', 'allowempty' => 0, 'category' => 'transactional', ], 'confirmationmessage' => [ - 'value' => - 'Welcome to our Newsletter + 'value' => 'Welcome to our Newsletter Please keep this message for later reference. @@ -362,14 +370,18 @@ private static function init(): void Thank you' , - 'description' => self::$translator->trans('Message subscribers receive after confirming their email address'), + 'description' => self::$translator->trans( + 'Message subscribers receive after confirming their email address' + ), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'transactional', ], 'updatesubject' => [ 'value' => self::$translator->trans('[notify] Change of List-Membership details'), - 'description' => self::$translator->trans('Subject of the message subscribers receive when they have changed their details'), + 'description' => self::$translator->trans( + 'Subject of the message subscribers receive when they have changed their details' + ), 'type' => 'text', 'allowempty' => 0, 'category' => 'transactional', @@ -379,8 +391,7 @@ private static function init(): void // confirmationinfo is replaced by one of the options below // userdata is replaced by the information in the database 'updatemessage' => [ - 'value' => - 'This message is to inform you of a change of your details on our newsletter database + 'value' => 'This message is to inform you of a change of your details on our newsletter database You are currently member of the following newsletters: @@ -398,7 +409,9 @@ private static function init(): void Thank you' , - 'description' => self::$translator->trans('Message subscribers receive when they have changed their details'), + 'description' => self::$translator->trans( + 'Message subscribers receive when they have changed their details' + ), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'transactional', @@ -423,8 +436,7 @@ private static function init(): void // message, in case the email is sent to their old email address and they have changed // their email address 'emailchanged_text_oldaddress' => [ - 'value' => - 'Please Note: when updating your details, your email address has changed. + 'value' => 'Please Note: when updating your details, your email address has changed. A message has been sent to your new email address with a URL to confirm this change. Please visit this website to activate @@ -437,7 +449,9 @@ private static function init(): void ], 'personallocation_subject' => [ 'value' => self::$translator->trans('Your personal location'), - 'description' => self::$translator->trans('Subject of message when subscribers request their personal location'), + 'description' => self::$translator->trans( + 'Subject of message when subscribers request their personal location' + ), 'type' => 'text', 'allowempty' => 0, 'category' => 'transactional', @@ -473,8 +487,7 @@ private static function init(): void 'category' => 'campaign', ], 'personallocation_message' => [ - 'value' => - 'You have requested your personal location to update your details from our website. + 'value' => 'You have requested your personal location to update your details from our website. The location is below. Please make sure that you use the full line as mentioned below. Sometimes email programmes can wrap the line into multiple lines. @@ -490,7 +503,9 @@ private static function init(): void ], 'remoteurl_append' => [ 'value' => '', - 'description' => self::$translator->trans('String to always append to remote URL when using send-a-webpage'), + 'description' => self::$translator->trans( + 'String to always append to remote URL when using send-a-webpage' + ), 'type' => 'text', 'allowempty' => true, 'category' => 'campaign', diff --git a/src/Domain/Configuration/Service/UserPersonalizer.php b/src/Domain/Configuration/Service/UserPersonalizer.php index c7f55d4f..7aedf1d8 100644 --- a/src/Domain/Configuration/Service/UserPersonalizer.php +++ b/src/Domain/Configuration/Service/UserPersonalizer.php @@ -20,7 +20,8 @@ public function __construct( private readonly SubscriberRepository $subscriberRepository, private readonly SubscriberAttributeValueRepository $attributesRepository, private readonly AttributeValueResolver $attributeValueResolver - ) {} + ) { + } public function personalize(string $value, string $email): string { diff --git a/src/Domain/Subscription/Repository/DynamicListAttrRepository.php b/src/Domain/Subscription/Repository/DynamicListAttrRepository.php index 1c1d23d1..d01ab916 100644 --- a/src/Domain/Subscription/Repository/DynamicListAttrRepository.php +++ b/src/Domain/Subscription/Repository/DynamicListAttrRepository.php @@ -8,21 +8,25 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception; use InvalidArgumentException; +use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; class DynamicListAttrRepository { public function __construct( private readonly Connection $connection, private readonly string $prefix = 'phplist_' - ) {} + ) { + } /** * @return list - * @throws Exception + * @throws InvalidArgumentException */ public function fetchOptionNames(string $listTable, array $ids): array { - if (empty($ids)) return []; + if (empty($ids)) { + return []; + } if (!preg_match('/^[A-Za-z0-9_]+$/', $listTable)) { throw new InvalidArgumentException('Invalid list table'); diff --git a/src/Domain/Subscription/Service/Provider/CheckboxGroupValueProvider.php b/src/Domain/Subscription/Service/Provider/CheckboxGroupValueProvider.php index 8b2d571a..3b33f629 100644 --- a/src/Domain/Subscription/Service/Provider/CheckboxGroupValueProvider.php +++ b/src/Domain/Subscription/Service/Provider/CheckboxGroupValueProvider.php @@ -10,11 +10,14 @@ class CheckboxGroupValueProvider implements AttributeValueProvider { - public function __construct(private DynamicListAttrRepository $repo) {} + public function __construct(private readonly DynamicListAttrRepository $repo) + { + } public function supports(SubscriberAttributeDefinition $attribute): bool { - // todo: check what real types exist in the database + // phpcs:ignore Generic.Commenting.Todo + // @todo: check what real types exist in the database return $attribute->getType() === 'checkboxgroup'; } @@ -25,12 +28,14 @@ public function getValue(SubscriberAttributeDefinition $attribute, SubscriberAtt return ''; } - $ids = array_values(array_filter(array_map( - fn($value) => ($index = (int)trim($value)) > 0 ? $index : null, - explode(',', $csv) - ))); + $ids = array_values(array_filter(array_map(function ($value) { + $index = (int) trim($value); + return $index > 0 ? $index : null; + }, explode(',', $csv)))); - if (empty($ids) || !$attribute->getTableName()) return ''; + if (empty($ids) || !$attribute->getTableName()) { + return ''; + } $names = $this->repo->fetchOptionNames($attribute->getTableName(), $ids); diff --git a/src/Domain/Subscription/Service/Provider/SelectOrRadioValueProvider.php b/src/Domain/Subscription/Service/Provider/SelectOrRadioValueProvider.php index 7e4d8fc0..22b3ab4e 100644 --- a/src/Domain/Subscription/Service/Provider/SelectOrRadioValueProvider.php +++ b/src/Domain/Subscription/Service/Provider/SelectOrRadioValueProvider.php @@ -10,7 +10,9 @@ class SelectOrRadioValueProvider implements AttributeValueProvider { - public function __construct(private readonly DynamicListAttrRepository $repo) {} + public function __construct(private readonly DynamicListAttrRepository $repo) + { + } public function supports(SubscriberAttributeDefinition $attribute): bool { @@ -19,7 +21,9 @@ public function supports(SubscriberAttributeDefinition $attribute): bool public function getValue(SubscriberAttributeDefinition $attribute, SubscriberAttributeValue $userValue): string { - if (!$attribute->getTableName()) return ''; + if (!$attribute->getTableName()) { + return ''; + } $id = (int)($userValue->getValue() ?? 0); if ($id <= 0) { diff --git a/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php b/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php index 4edf42d1..c63b7f8c 100644 --- a/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php +++ b/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php @@ -10,7 +10,9 @@ class AttributeValueResolver { /** @param iterable $providers */ - public function __construct(private readonly iterable $providers) {} + public function __construct(private readonly iterable $providers) + { + } public function resolve(SubscriberAttributeValue $userAttr): string { diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php index bd35db3a..f5d9e535 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php @@ -182,7 +182,7 @@ private function processRow( } } - $this->handleFlushAndEmail($subscriber, $options,$dto, $addedNewSubscriberToList); + $this->handleFlushAndEmail($subscriber, $options, $dto, $addedNewSubscriberToList); } private function handleInvalidEmail( @@ -196,7 +196,8 @@ private function handleInvalidEmail( return true; } - // todo: check + // phpcs:ignore Generic.Commenting.Todo + // @todo: check $dto->email = 'invalid_' . $dto->email; $dto->sendConfirmation = false; } From b0caf80b0827200c4d4356801c3ad8e515a36816 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 6 Oct 2025 10:53:38 +0400 Subject: [PATCH 13/16] Fix configs --- config/parameters.yml.dist | 2 + config/services/providers.yml | 15 +- config/services/repositories.yml | 3 +- config/services/services.yml | 6 + .../Repository/ConfigRepository.php | 2 +- .../Service/Provider/ConfigProvider.php | 7 +- .../Provider/DefaultConfigProvider.php | 157 +++++++++--------- .../Repository/DynamicListAttrRepository.php | 2 - .../SubscriberCsvImportManagerTest.php | 6 +- 9 files changed, 108 insertions(+), 92 deletions(-) diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index fb2ce8ee..4e9e0cff 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -21,6 +21,8 @@ parameters: env(PHPLIST_DATABASE_USER): 'phplist' database_password: '%%env(PHPLIST_DATABASE_PASSWORD)%%' env(PHPLIST_DATABASE_PASSWORD): 'phplist' + database_prefix: '%%env(DATABASE_PREFIX)%%' + env(DATABASE_PREFIX): 'phplist_' # Email configuration app.mailer_from: '%%env(MAILER_FROM)%%' diff --git a/config/services/providers.yml b/config/services/providers.yml index 45436651..f4f06010 100644 --- a/config/services/providers.yml +++ b/config/services/providers.yml @@ -13,12 +13,17 @@ services: arguments: $confPath: '%app.phplist_isp_conf_path%' - PhpList\Core\Domain\Subscription\Service\Provider\CheckboxGroupValueProvider: ~ - PhpList\Core\Domain\Subscription\Service\Provider\SelectOrRadioValueProvider: ~ - PhpList\Core\Domain\Subscription\Service\Provider\ScalarValueProvider: ~ + PhpList\Core\Domain\Subscription\Service\Provider\CheckboxGroupValueProvider: + autowire: true + PhpList\Core\Domain\Subscription\Service\Provider\SelectOrRadioValueProvider: + autowire: true + PhpList\Core\Domain\Subscription\Service\Provider\ScalarValueProvider: + autowire: true PhpList\Core\Domain\Configuration\Service\Provider\DefaultConfigProvider: - calls: - - [ setTranslator, [ '@translator' ] ] + autowire: true PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider: + autowire: true + arguments: + $cache: '@Psr\SimpleCache\CacheInterface' diff --git a/config/services/repositories.yml b/config/services/repositories.yml index ac5b50a9..e9d4d8c6 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -68,8 +68,9 @@ services: arguments: - PhpList\Core\Domain\Subscription\Model\Subscription PhpList\Core\Domain\Subscription\Repository\DynamicListAttrRepository: + autowire: true arguments: - $prefix: '%env(default:phplist_ DATABASE_PREFIX)%' + $prefix: '%database_prefix%' PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: diff --git a/config/services/services.yml b/config/services/services.yml index d33ddd2c..bc236399 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -132,3 +132,9 @@ services: PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder: autowire: true autoconfigure: true + + cache.app.simple: + class: Symfony\Component\Cache\Psr16Cache + arguments: [ '@cache.app' ] + + Psr\SimpleCache\CacheInterface: '@cache.app.simple' diff --git a/src/Domain/Configuration/Repository/ConfigRepository.php b/src/Domain/Configuration/Repository/ConfigRepository.php index 960b14f3..ea4a0680 100644 --- a/src/Domain/Configuration/Repository/ConfigRepository.php +++ b/src/Domain/Configuration/Repository/ConfigRepository.php @@ -10,6 +10,6 @@ class ConfigRepository extends AbstractRepository { public function findValueByItem(string $name): ?string { - return $this->findOneBy(['item' => $name])?->getValue(); + return $this->findOneBy(['key' => $name])?->getValue(); } } diff --git a/src/Domain/Configuration/Service/Provider/ConfigProvider.php b/src/Domain/Configuration/Service/Provider/ConfigProvider.php index 4a97b784..c47da111 100644 --- a/src/Domain/Configuration/Service/Provider/ConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/ConfigProvider.php @@ -18,7 +18,8 @@ class ConfigProvider public function __construct( private readonly ConfigRepository $configRepository, private readonly CacheInterface $cache, - private readonly int $ttlSeconds = 300 + private readonly DefaultConfigProvider $defaultConfigs, + private readonly ?int $ttlSeconds = 300 ) { } @@ -37,7 +38,7 @@ public function isEnabled(ConfigOption $key): bool $config->getValue() === '1'; } - return DefaultConfigProvider::has($key->value) && DefaultConfigProvider::get($key->value) === '1'; + return $this->defaultConfigs->has($key->value) && $this->defaultConfigs->get($key->value)['value'] === '1'; } /** @@ -61,7 +62,7 @@ public function getValue(ConfigOption $key): ?string return $value; } - return DefaultConfigProvider::has($key->value) ? DefaultConfigProvider::get($key->value) : null; + return $this->defaultConfigs->has($key->value) ? $this->defaultConfigs->get($key->value)['value'] : null; } /** @SuppressWarnings(PHPMD.StaticAccess) */ diff --git a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php index ca11617f..28035d0a 100644 --- a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php @@ -14,36 +14,33 @@ class DefaultConfigProvider * Holds all default configuration values * @var array */ - private static array $defaults = []; + private array $defaults = []; - private static TranslatorInterface $translator; - - public static function setTranslator(TranslatorInterface $translator): void + public function __construct(private readonly TranslatorInterface $translator) { - self::$translator = $translator; } /** @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ - private static function init(): void + private function init(): void { - if (!empty(self::$defaults)) { + if (!empty($this->defaults)) { return; } $publicSchema = 'http'; $pageRoot = '/api/v2'; - self::$defaults = [ + $this->defaults = [ 'admin_address' => [ 'value' => 'webmaster@[DOMAIN]', - 'description' => self::$translator->trans('Person in charge of this system (one email address)'), + 'description' => $this->translator->trans('Person in charge of this system (one email address)'), 'type' => 'email', 'allowempty' => false, 'category' => 'general', ], 'organisation_name' => [ 'value' => '', - 'description' => self::$translator->trans('Name of the organisation'), + 'description' => $this->translator->trans('Name of the organisation'), 'type' => 'text', 'allowempty' => true, 'allowtags' => '

', @@ -52,7 +49,7 @@ private static function init(): void ], 'organisation_logo' => [ 'value' => '', - 'description' => self::$translator->trans('Logo of the organisation'), + 'description' => $this->translator->trans('Logo of the organisation'), 'infoicon' => true, 'type' => 'image', 'allowempty' => true, @@ -60,7 +57,7 @@ private static function init(): void ], 'date_format' => [ 'value' => 'j F Y', - 'description' => self::$translator->trans('Date format'), + 'description' => $this->translator->trans('Date format'), 'infoicon' => true, 'type' => 'text', 'allowempty' => false, @@ -68,27 +65,27 @@ private static function init(): void ], 'rc_notification' => [ 'value' => 0, - 'description' => self::$translator->trans('Show notification for Release Candidates'), + 'description' => $this->translator->trans('Show notification for Release Candidates'), 'type' => 'boolean', 'allowempty' => true, 'category' => 'security', ], 'remote_processing_secret' => [ 'value' => bin2hex(random_bytes(10)), - 'description' => self::$translator->trans('Secret for remote processing'), + 'description' => $this->translator->trans('Secret for remote processing'), 'type' => 'text', 'category' => 'security', ], 'notify_admin_login' => [ 'value' => 1, - 'description' => self::$translator->trans('Notify admin on login from new location'), + 'description' => $this->translator->trans('Notify admin on login from new location'), 'type' => 'boolean', 'category' => 'security', 'allowempty' => true, ], 'admin_addresses' => [ 'value' => '', - 'description' => self::$translator->trans( + 'description' => $this->translator->trans( 'List of email addresses to CC in system messages (separate by commas)' ), 'type' => 'emaillist', @@ -97,28 +94,28 @@ private static function init(): void ], 'campaignfrom_default' => [ 'value' => '', - 'description' => self::$translator->trans("Default for 'From:' in a campaign"), + 'description' => $this->translator->trans("Default for 'From:' in a campaign"), 'type' => 'text', 'allowempty' => true, 'category' => 'campaign', ], 'notifystart_default' => [ 'value' => '', - 'description' => self::$translator->trans("Default for 'address to alert when sending starts'"), + 'description' => $this->translator->trans("Default for 'address to alert when sending starts'"), 'type' => 'email', 'allowempty' => true, 'category' => 'campaign', ], 'notifyend_default' => [ 'value' => '', - 'description' => self::$translator->trans("Default for 'address to alert when sending finishes'"), + 'description' => $this->translator->trans("Default for 'address to alert when sending finishes'"), 'type' => 'email', 'allowempty' => true, 'category' => 'campaign', ], 'always_add_googletracking' => [ 'value' => '0', - 'description' => self::$translator->trans('Always add analytics tracking code to campaigns'), + 'description' => $this->translator->trans('Always add analytics tracking code to campaigns'), 'type' => 'boolean', 'allowempty' => true, 'category' => 'campaign', @@ -126,14 +123,14 @@ private static function init(): void 'analytic_tracker' => [ 'values' => ['google' => 'Google Analytics', 'matomo' => 'Matomo'], 'value' => 'google', - 'description' => self::$translator->trans('Analytics tracking code to add to campaign URLs'), + 'description' => $this->translator->trans('Analytics tracking code to add to campaign URLs'), 'type' => 'select', 'allowempty' => false, 'category' => 'campaign', ], 'report_address' => [ 'value' => 'listreports@[DOMAIN]', - 'description' => self::$translator->trans( + 'description' => $this->translator->trans( 'Who gets the reports (email address, separate multiple emails with a comma)' ), 'type' => 'emaillist', @@ -142,35 +139,35 @@ private static function init(): void ], 'message_from_address' => [ 'value' => 'noreply@[DOMAIN]', - 'description' => self::$translator->trans('From email address for system messages'), + 'description' => $this->translator->trans('From email address for system messages'), 'type' => 'email', 'allowempty' => 0, 'category' => 'transactional', ], 'message_from_name' => [ - 'value' => self::$translator->trans('Webmaster'), - 'description' => self::$translator->trans('Name for system messages'), + 'value' => $this->translator->trans('Webmaster'), + 'description' => $this->translator->trans('Name for system messages'), 'type' => 'text', 'allowempty' => 0, 'category' => 'transactional', ], 'message_replyto_address' => [ 'value' => 'noreply@[DOMAIN]', - 'description' => self::$translator->trans('Reply-to email address for system messages'), + 'description' => $this->translator->trans('Reply-to email address for system messages'), 'type' => 'email', 'allowempty' => 0, 'category' => 'transactional', ], 'hide_single_list' => [ 'value' => '1', - 'description' => self::$translator->trans('If there is only one visible list, should it be hidden in the page and automatically subscribe users who sign up'), + 'description' => $this->translator->trans('If there is only one visible list, should it be hidden in the page and automatically subscribe users who sign up'), 'type' => 'boolean', 'allowempty' => true, 'category' => 'subscription-ui', ], 'list_categories' => [ 'value' => '', - 'description' => self::$translator->trans('Categories for lists. Separate with commas.'), + 'description' => $this->translator->trans('Categories for lists. Separate with commas.'), 'infoicon' => true, 'type' => 'text', 'allowempty' => true, @@ -178,14 +175,14 @@ private static function init(): void ], 'displaycategories' => [ 'value' => 0, - 'description' => self::$translator->trans('Display list categories on subscribe page'), + 'description' => $this->translator->trans('Display list categories on subscribe page'), 'type' => 'boolean', 'allowempty' => false, 'category' => 'list-organisation', ], 'textline_width' => [ 'value' => '40', - 'description' => self::$translator->trans('Width of a textline field (numerical)'), + 'description' => $this->translator->trans('Width of a textline field (numerical)'), 'type' => 'integer', 'min' => 20, 'max' => 150, @@ -193,21 +190,21 @@ private static function init(): void ], 'textarea_dimensions' => [ 'value' => '10,40', - 'description' => self::$translator->trans('Dimensions of a textarea field (rows,columns)'), + 'description' => $this->translator->trans('Dimensions of a textarea field (rows,columns)'), 'type' => 'text', 'allowempty' => 0, 'category' => 'subscription-ui', ], 'send_admin_copies' => [ 'value' => '0', - 'description' => self::$translator->trans('Send notifications about subscribe, update and unsubscribe'), + 'description' => $this->translator->trans('Send notifications about subscribe, update and unsubscribe'), 'type' => 'boolean', 'allowempty' => true, 'category' => 'reporting', ], 'defaultsubscribepage' => [ 'value' => 1, - 'description' => self::$translator->trans('The default subscribe page when there are multiple'), + 'description' => $this->translator->trans('The default subscribe page when there are multiple'), 'type' => 'integer', 'min' => 1, 'max' => 999, @@ -216,14 +213,14 @@ private static function init(): void ], 'defaultmessagetemplate' => [ 'value' => 0, - 'description' => self::$translator->trans('The default HTML template to use when sending a message'), + 'description' => $this->translator->trans('The default HTML template to use when sending a message'), 'type' => 'text', 'allowempty' => true, 'category' => 'campaign', ], 'systemmessagetemplate' => [ 'value' => 0, - 'description' => self::$translator->trans('The HTML wrapper template for system messages'), + 'description' => $this->translator->trans('The HTML wrapper template for system messages'), 'type' => 'integer', 'min' => 0, 'max' => 999, @@ -232,63 +229,63 @@ private static function init(): void ], 'subscribeurl' => [ 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=subscribe', - 'description' => self::$translator->trans('URL where subscribers can sign up'), + 'description' => $this->translator->trans('URL where subscribers can sign up'), 'type' => 'url', 'allowempty' => 0, 'category' => 'subscription', ], 'unsubscribeurl' => [ 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=unsubscribe', - 'description' => self::$translator->trans('URL where subscribers can unsubscribe'), + 'description' => $this->translator->trans('URL where subscribers can unsubscribe'), 'type' => 'url', 'allowempty' => 0, 'category' => 'subscription', ], 'blacklisturl' => [ 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=donotsend', - 'description' => self::$translator->trans('URL where unknown users can unsubscribe (do-not-send-list)'), + 'description' => $this->translator->trans('URL where unknown users can unsubscribe (do-not-send-list)'), 'type' => 'url', 'allowempty' => 0, 'category' => 'subscription', ], 'confirmationurl' => [ 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=confirm', - 'description' => self::$translator->trans('URL where subscribers have to confirm their subscription'), + 'description' => $this->translator->trans('URL where subscribers have to confirm their subscription'), 'type' => 'text', 'allowempty' => 0, 'category' => 'subscription', ], 'preferencesurl' => [ 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=preferences', - 'description' => self::$translator->trans('URL where subscribers can update their details'), + 'description' => $this->translator->trans('URL where subscribers can update their details'), 'type' => 'text', 'allowempty' => 0, 'category' => 'subscription', ], 'forwardurl' => [ 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=forward', - 'description' => self::$translator->trans('URL for forwarding messages'), + 'description' => $this->translator->trans('URL for forwarding messages'), 'type' => 'text', 'allowempty' => 0, 'category' => 'subscription', ], 'vcardurl' => [ 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=vcard', - 'description' => self::$translator->trans('URL for downloading vcf card'), + 'description' => $this->translator->trans('URL for downloading vcf card'), 'type' => 'text', 'allowempty' => 0, 'category' => 'subscription', ], 'ajax_subscribeconfirmation' => [ - 'value' => self::$translator->trans('

Thanks, you have been added to our newsletter

You will receive an email to confirm your subscription. Please click the link in the email to confirm

'), - 'description' => self::$translator->trans('Text to display when subscription with an AJAX request was successful'), + 'value' => $this->translator->trans('

Thanks, you have been added to our newsletter

You will receive an email to confirm your subscription. Please click the link in the email to confirm

'), + 'description' => $this->translator->trans('Text to display when subscription with an AJAX request was successful'), 'type' => 'textarea', 'allowempty' => true, 'category' => 'subscription', ], 'subscribesubject' => [ - 'value' => self::$translator->trans('Request for confirmation'), - 'description' => self::$translator->trans( + 'value' => $this->translator->trans('Request for confirmation'), + 'description' => $this->translator->trans( 'Subject of the message subscribers receive when they sign up' ), 'infoicon' => true, @@ -316,14 +313,14 @@ private static function init(): void If this is not correct, or you do not agree, simply take no action and delete this message.' , - 'description' => self::$translator->trans('Message subscribers receive when they sign up'), + 'description' => $this->translator->trans('Message subscribers receive when they sign up'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'transactional', ], 'unsubscribesubject' => [ - 'value' => self::$translator->trans('Goodbye from our Newsletter'), - 'description' => self::$translator->trans( + 'value' => $this->translator->trans('Goodbye from our Newsletter'), + 'description' => $this->translator->trans( 'Subject of the message subscribers receive when they unsubscribe' ), 'type' => 'text', @@ -343,14 +340,14 @@ private static function init(): void Thank you' , - 'description' => self::$translator->trans('Message subscribers receive when they unsubscribe'), + 'description' => $this->translator->trans('Message subscribers receive when they unsubscribe'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'transactional', ], 'confirmationsubject' => [ - 'value' => self::$translator->trans('Welcome to our Newsletter'), - 'description' => self::$translator->trans( + 'value' => $this->translator->trans('Welcome to our Newsletter'), + 'description' => $this->translator->trans( 'Subject of the message subscribers receive after confirming their email address' ), 'type' => 'text', @@ -370,7 +367,7 @@ private static function init(): void Thank you' , - 'description' => self::$translator->trans( + 'description' => $this->translator->trans( 'Message subscribers receive after confirming their email address' ), 'type' => 'textarea', @@ -378,8 +375,8 @@ private static function init(): void 'category' => 'transactional', ], 'updatesubject' => [ - 'value' => self::$translator->trans('[notify] Change of List-Membership details'), - 'description' => self::$translator->trans( + 'value' => $this->translator->trans('[notify] Change of List-Membership details'), + 'description' => $this->translator->trans( 'Subject of the message subscribers receive when they have changed their details' ), 'type' => 'text', @@ -409,7 +406,7 @@ private static function init(): void Thank you' , - 'description' => self::$translator->trans( + 'description' => $this->translator->trans( 'Message subscribers receive when they have changed their details' ), 'type' => 'textarea', @@ -427,7 +424,7 @@ private static function init(): void [CONFIRMATIONURL] ', - 'description' => self::$translator->trans('Part of the message that is sent to their new email address when subscribers change their information, and the email address has changed'), + 'description' => $this->translator->trans('Part of the message that is sent to their new email address when subscribers change their information, and the email address has changed'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'transactional', @@ -442,14 +439,14 @@ private static function init(): void to confirm this change. Please visit this website to activate your membership.' , - 'description' => self::$translator->trans('Part of the message that is sent to their old email address when subscribers change their information, and the email address has changed'), + 'description' => $this->translator->trans('Part of the message that is sent to their old email address when subscribers change their information, and the email address has changed'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'transactional', ], 'personallocation_subject' => [ - 'value' => self::$translator->trans('Your personal location'), - 'description' => self::$translator->trans( + 'value' => $this->translator->trans('Your personal location'), + 'description' => $this->translator->trans( 'Subject of message when subscribers request their personal location' ), 'type' => 'text', @@ -467,7 +464,7 @@ private static function init(): void ', - 'description' => self::$translator->trans('Default footer for sending a campaign'), + 'description' => $this->translator->trans('Default footer for sending a campaign'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'campaign', @@ -481,7 +478,7 @@ private static function init(): void

You can also opt out completely from receiving any further email from our newsletter application, phpList.

', - 'description' => self::$translator->trans('Footer used when a message has been forwarded'), + 'description' => $this->translator->trans('Footer used when a message has been forwarded'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'campaign', @@ -496,14 +493,14 @@ private static function init(): void Thank you.' , - 'description' => self::$translator->trans('Message to send when they request their personal location'), + 'description' => $this->translator->trans('Message to send when they request their personal location'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'transactional', ], 'remoteurl_append' => [ 'value' => '', - 'description' => self::$translator->trans( + 'description' => $this->translator->trans( 'String to always append to remote URL when using send-a-webpage' ), 'type' => 'text', @@ -512,28 +509,28 @@ private static function init(): void ], 'wordwrap' => [ 'value' => '75', - 'description' => self::$translator->trans('Width for Wordwrap of Text messages'), + 'description' => $this->translator->trans('Width for Wordwrap of Text messages'), 'type' => 'text', 'allowempty' => true, 'category' => 'campaign', ], 'html_email_style' => [ 'value' => '', - 'description' => self::$translator->trans('CSS for HTML messages without a template'), + 'description' => $this->translator->trans('CSS for HTML messages without a template'), 'type' => 'textarea', 'allowempty' => true, 'category' => 'campaign', ], 'alwayssendtextto' => [ 'value' => '', - 'description' => self::$translator->trans('Domains that only accept text emails, one per line'), + 'description' => $this->translator->trans('Domains that only accept text emails, one per line'), 'type' => 'textarea', 'allowempty' => true, 'category' => 'campaign', ], 'tld_last_sync' => [ 'value' => '0', - 'description' => self::$translator->trans('last time TLDs were fetched'), + 'description' => $this->translator->trans('last time TLDs were fetched'), 'type' => 'text', 'allowempty' => true, 'category' => 'system', @@ -541,7 +538,7 @@ private static function init(): void ], 'internet_tlds' => [ 'value' => '', - 'description' => self::$translator->trans('Top level domains'), + 'description' => $this->translator->trans('Top level domains'), 'type' => 'textarea', 'allowempty' => true, 'category' => 'system', @@ -549,14 +546,14 @@ private static function init(): void ], 'pageheader' => [ 'value' => '

Welcome

', - 'description' => self::$translator->trans('Header of public pages.'), + 'description' => $this->translator->trans('Header of public pages.'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'subscription-ui', ], 'pagefooter' => [ 'value' => '

Footer text

', - 'description' => self::$translator->trans('Footer of public pages'), + 'description' => $this->translator->trans('Footer of public pages'), 'type' => 'textarea', 'allowempty' => 0, 'category' => 'subscription-ui', @@ -571,18 +568,24 @@ private static function init(): void * @param mixed|null $default * @return mixed */ - public static function get(string $key, mixed $default = null) + public function get(string $key, mixed $default = null): mixed { - self::init(); - return self::$defaults[$key] ?? $default; + if (empty($this->defaults)) { + $this->init(); + } + + return $this->defaults[$key] ?? $default; } /** * Check if a config key exists */ - public static function has(string $key): bool + public function has(string $key): bool { - self::init(); - return isset(self::$defaults[$key]); + if (empty($this->defaults)) { + $this->init(); + } + + return isset($this->defaults[$key]); } } diff --git a/src/Domain/Subscription/Repository/DynamicListAttrRepository.php b/src/Domain/Subscription/Repository/DynamicListAttrRepository.php index d01ab916..104938b0 100644 --- a/src/Domain/Subscription/Repository/DynamicListAttrRepository.php +++ b/src/Domain/Subscription/Repository/DynamicListAttrRepository.php @@ -6,9 +6,7 @@ use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Exception; use InvalidArgumentException; -use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; class DynamicListAttrRepository { diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php index 77a1f61d..d8ea0a1c 100644 --- a/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php +++ b/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php @@ -42,8 +42,8 @@ public function testImportFromCsvCreatesNewSubscribers(): void $this->entityManager->flush(); $csvContent = "email,confirmed,html_email,blacklisted,disabled,extra_data,first_name\n"; - $csvContent .= "test@example.com,1,1,0,0,\"Some extra data\",John\n"; - $csvContent .= "another@example.com,0,0,1,1,\"More data\",Jane\n"; + $csvContent .= "test1@example.com,1,1,0,0,\"Some extra data\",John\n"; + $csvContent .= "another1@example.com,0,0,1,1,\"More data\",Jane\n"; $tempFile = tempnam(sys_get_temp_dir(), 'csv_test'); file_put_contents($tempFile, $csvContent); @@ -58,7 +58,7 @@ public function testImportFromCsvCreatesNewSubscribers(): void $subscriberCountBefore = count($this->subscriberRepository->findAll()); - $options = new SubscriberImportOptions(); + $options = new SubscriberImportOptions(true); $result = $this->subscriberCsvImportManager->importFromCsv($uploadedFile, $options); $subscriberCountAfter = count($this->subscriberRepository->findAll()); From 113d6c2084801e44cd66f8344de5897c322c6a6f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 6 Oct 2025 11:56:17 +0400 Subject: [PATCH 14/16] replace list names --- ...SubscriptionConfirmationMessageHandler.php | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php b/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php index 51514627..3aaf0988 100644 --- a/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php @@ -7,8 +7,9 @@ use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Configuration\Service\UserPersonalizer; -use PhpList\Core\Domain\Messaging\Message\SubscriberConfirmationMessage; +use PhpList\Core\Domain\Messaging\Message\SubscriptionConfirmationMessage; use PhpList\Core\Domain\Messaging\Service\EmailService; +use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Mime\Email; @@ -23,27 +24,32 @@ class SubscriptionConfirmationMessageHandler 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, ) { $this->emailService = $emailService; $this->configProvider = $configProvider; $this->logger = $logger; $this->userPersonalizer = $userPersonalizer; + $this->subscriberListRepository = $subscriberListRepository; } /** * Process a subscription confirmation message by sending the confirmation email */ - public function __invoke(SubscriberConfirmationMessage $message): void + public function __invoke(SubscriptionConfirmationMessage $message): void { $subject = $this->configProvider->getValue(ConfigOption::SubscribeEmailSubject); $textContent = $this->configProvider->getValue(ConfigOption::SubscribeMessage); - $replacedTextContent = $this->userPersonalizer->personalize($textContent, $message->getUniqueId()); + $personalizedTextContent = $this->userPersonalizer->personalize($textContent, $message->getUniqueId()); + $listOfLists = $this->getListNames($message->getListIds()); + $replacedTextContent = str_replace('[LISTS]', $personalizedTextContent, $listOfLists); $email = (new Email()) ->to($message->getEmail()) @@ -54,4 +60,17 @@ public function __invoke(SubscriberConfirmationMessage $message): void $this->logger->info('Subscription confirmation email sent to {email}', ['email' => $message->getEmail()]); } + + private function getListNames(array $listIds): string + { + $listNames = []; + foreach ($listIds as $id) { + $list = $this->subscriberListRepository->find($id); + if ($list) { + $listNames[] = $list->getName(); + } + } + + return implode(', ', $listNames); + } } From eda14655d69f401f405e423a35a71afcb4059ba8 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 6 Oct 2025 12:05:07 +0400 Subject: [PATCH 15/16] Add tests + fix --- .../Service/Provider/ConfigProvider.php | 2 +- .../Provider/DefaultConfigProvider.php | 10 +- .../SubscriberCsvImportManagerTest.php | 9 +- .../Service/LegacyUrlBuilderTest.php | 79 +++++ .../Service/PlaceholderResolverTest.php | 92 ++++++ .../Service/Provider/ConfigProviderTest.php | 279 ++++++++++++++++++ .../Provider/DefaultConfigProviderTest.php | 127 ++++++++ .../Service/UserPersonalizerTest.php | 218 ++++++++++++++ 8 files changed, 806 insertions(+), 10 deletions(-) create mode 100644 tests/Unit/Domain/Configuration/Service/LegacyUrlBuilderTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php create mode 100644 tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php diff --git a/src/Domain/Configuration/Service/Provider/ConfigProvider.php b/src/Domain/Configuration/Service/Provider/ConfigProvider.php index c47da111..a1db70fc 100644 --- a/src/Domain/Configuration/Service/Provider/ConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/ConfigProvider.php @@ -35,7 +35,7 @@ public function isEnabled(ConfigOption $key): bool $config = $this->configRepository->findOneBy(['item' => $key->value]); if ($config !== null) { - $config->getValue() === '1'; + return $config->getValue() === '1'; } return $this->defaultConfigs->has($key->value) && $this->defaultConfigs->get($key->value)['value'] === '1'; diff --git a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php index 28035d0a..bbe14a46 100644 --- a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php @@ -16,7 +16,7 @@ class DefaultConfigProvider */ private array $defaults = []; - public function __construct(private readonly TranslatorInterface $translator) + public function __construct(private TranslatorInterface $translator) { } @@ -570,9 +570,7 @@ private function init(): void */ public function get(string $key, mixed $default = null): mixed { - if (empty($this->defaults)) { - $this->init(); - } + $this->init(); return $this->defaults[$key] ?? $default; } @@ -582,9 +580,7 @@ public function get(string $key, mixed $default = null): mixed */ public function has(string $key): bool { - if (empty($this->defaults)) { - $this->init(); - } + $this->init(); return isset($this->defaults[$key]); } diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php index d8ea0a1c..c9d60ae3 100644 --- a/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php +++ b/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Tests\Integration\Domain\Subscription\Service; +use Doctrine\ORM\Tools\SchemaTool; use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; @@ -28,6 +29,10 @@ class SubscriberCsvImportManagerTest extends KernelTestCase protected function setUp(): void { parent::setUp(); + $this->setUpDatabaseTest(); + $schemaTool = new SchemaTool($this->entityManager); + $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); + $schemaTool->dropSchema($metadata); $this->loadSchema(); $this->subscriberCsvImportManager = self::getContainer()->get(SubscriberCsvImporter::class); @@ -42,8 +47,8 @@ public function testImportFromCsvCreatesNewSubscribers(): void $this->entityManager->flush(); $csvContent = "email,confirmed,html_email,blacklisted,disabled,extra_data,first_name\n"; - $csvContent .= "test1@example.com,1,1,0,0,\"Some extra data\",John\n"; - $csvContent .= "another1@example.com,0,0,1,1,\"More data\",Jane\n"; + $csvContent .= "test@example.com,1,1,0,0,\"Some extra data\",John\n"; + $csvContent .= "another@example.com,0,0,1,1,\"More data\",Jane\n"; $tempFile = tempnam(sys_get_temp_dir(), 'csv_test'); file_put_contents($tempFile, $csvContent); diff --git a/tests/Unit/Domain/Configuration/Service/LegacyUrlBuilderTest.php b/tests/Unit/Domain/Configuration/Service/LegacyUrlBuilderTest.php new file mode 100644 index 00000000..9f5cfe96 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/LegacyUrlBuilderTest.php @@ -0,0 +1,79 @@ +withUid($baseUrl, $uid); + + $this->assertSame($expected, $actual); + } + + public static function provideWithUidCases(): array + { + return [ + 'no query -> add uid' => [ + 'https://example.com/page', + 'ABC123', + 'https://example.com/page?uid=ABC123', + ], + 'existing query -> append uid' => [ + 'https://example.com/page?foo=bar', + 'ABC123', + 'https://example.com/page?foo=bar&uid=ABC123', + ], + 'existing uid -> override (uid replaced)' => [ + 'https://example.com/page?uid=OLD&x=1', + 'ABC123', + 'https://example.com/page?uid=ABC123&x=1', + ], + 'port and fragment preserved' => [ + 'http://example.com:8080/path?x=1#frag', + 'ABC123', + 'http://example.com:8080/path?x=1&uid=ABC123#frag', + ], + 'relative url -> defaults to https with empty host' => [ + '/relative/path', + 'ABC123', + // scheme defaults to https; empty host -> "https:///" + path + 'https:///relative/path?uid=ABC123', + ], + 'no query/fragment/port/host only' => [ + 'http://example.com', + 'ZZZ', + 'http://example.com?uid=ZZZ', + ], + ]; + } + + public function testQueryEncodingIsUrlEncoded(): void + { + $builder = new LegacyUrlBuilder(); + + $url = 'https://example.com/path?name=John+Doe&city=New+York'; + $result = $builder->withUid($url, 'üñíčødé space'); + + // Ensure it is a valid URL and uid is url-encoded inside query + $parts = parse_url($result); + parse_str($parts['query'] ?? '', $query); + + $this->assertSame('John Doe', $query['name']); + $this->assertSame('New York', $query['city']); + $this->assertSame('üñíčødé space', $query['uid']); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php b/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php new file mode 100644 index 00000000..e2a1d719 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php @@ -0,0 +1,92 @@ +assertNull($resolver->resolve(null)); + $this->assertSame('', $resolver->resolve('')); + } + + public function testUnregisteredTokensRemainUnchanged(): void + { + $resolver = new PlaceholderResolver(); + + $input = 'Hello [NAME], click [UNSUBSCRIBEURL] to opt out.'; + $this->assertSame($input, $resolver->resolve($input)); + } + + public function testCaseInsensitiveTokenResolution(): void + { + $resolver = new PlaceholderResolver(); + $resolver->register('unsubscribeurl', fn () => 'https://u.example/u/123'); + + $input = 'Click [UnSubscribeUrl]'; + $expect = 'Click https://u.example/u/123'; + + $this->assertSame($expect, $resolver->resolve($input)); + } + + public function testMultipleDifferentTokensAreResolved(): void + { + $resolver = new PlaceholderResolver(); + $resolver->register('NAME', fn () => 'Ada'); + $resolver->register('EMAIL', fn () => 'ada@example.com'); + + $input = 'Hi [NAME] <[email]>'; + $expect = 'Hi Ada '; + + $this->assertSame($expect, $resolver->resolve($input)); + } + + public function testAdjacentAndRepeatedTokens(): void + { + $resolver = new PlaceholderResolver(); + + $count = 0; + $resolver->register('X', function () use (&$count) { + $count++; + return 'V'; + }); + + $input = 'Start [x][X]-[x] End'; + $expect = 'Start VV-V End'; + + $this->assertSame($expect, $resolver->resolve($input)); + $this->assertSame(3, $count); + } + + public function testDigitsAndUnderscoresInToken(): void + { + $resolver = new PlaceholderResolver(); + $resolver->register('USER_2', fn () => 'Bob#2'); + + $input = 'Hello [user_2]!'; + $expect = 'Hello Bob#2!'; + + $this->assertSame($expect, $resolver->resolve($input)); + } + + public function testUnknownTokensArePreservedVerbatim(): void + { + $resolver = new PlaceholderResolver(); + $resolver->register('KNOWN', fn () => 'K'); + + $input = 'A[UNKNOWN]B[KNOWN]C'; + $expect = 'A[UNKNOWN]BKC'; + + $this->assertSame($expect, $resolver->resolve($input)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php b/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php new file mode 100644 index 00000000..12e36ed9 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php @@ -0,0 +1,279 @@ +repo = $this->createMock(ConfigRepository::class); + $this->cache = $this->createMock(CacheInterface::class); + $this->defaults = $this->createMock(DefaultConfigProvider::class); + + $this->provider = new ConfigProvider( + configRepository: $this->repo, + cache: $this->cache, + defaultConfigs: $this->defaults, + ttlSeconds: 300 + ); + } + + /** + * Utility: pick a non-boolean enum case (i.e., anything except MaintenanceMode). + */ + private function pickNonBooleanCase(): ConfigOption + { + foreach (ConfigOption::cases() as $case) { + if ($case !== ConfigOption::MaintenanceMode) { + return $case; + } + } + $this->markTestSkipped('No non-boolean ConfigOption cases available to test.'); + } + + /** + * Utility: pick a namespaced case "parent:child" where parent exists as its own case. + */ + private function pickNamespacedCasePair(): array + { + $byValue = []; + foreach (ConfigOption::cases() as $c) { + $byValue[$c->value] = $c; + } + + foreach (ConfigOption::cases() as $c) { + if (!str_contains($c->value, ':')) { + continue; + } + [$parent] = explode(':', $c->value, 2); + if (isset($byValue[$parent])) { + return [$c, $byValue[$parent]]; + } + } + + $this->markTestSkipped('No namespaced ConfigOption (parent:child) pair found.'); + } + + public function testIsEnabledRejectsNonBooleanKeys(): void + { + $nonBoolean = $this->pickNonBooleanCase(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid boolean value key'); + + $this->provider->isEnabled($nonBoolean); + } + + public function testIsEnabledUsesRepositoryValueWhenPresent(): void + { + $key = ConfigOption::MaintenanceMode; + + $configEntity = $this->createMock(Config::class); + $configEntity->method('getValue')->willReturn('1'); + + $this->repo + ->expects($this->once()) + ->method('findOneBy') + ->with(['item' => $key->value]) + ->willReturn($configEntity); + + // Defaults should not be consulted if repo has value + $this->defaults->expects($this->never())->method('has'); + $this->defaults->expects($this->never())->method('get'); + + $enabled = $this->provider->isEnabled($key); + + $this->assertTrue($enabled, 'When repo has value "1", isEnabled() should return true.'); + } + + public function testIsEnabledFallsBackToDefaultsWhenRepoMissing(): void + { + $key = ConfigOption::MaintenanceMode; + + $this->repo + ->expects($this->once()) + ->method('findOneBy') + ->with(['item' => $key->value]) + ->willReturn(null); + + $this->defaults + ->expects($this->once()) + ->method('has') + ->with($key->value) + ->willReturn(true); + + $this->defaults + ->expects($this->once()) + ->method('get') + ->with($key->value) + ->willReturn(['value' => '1']); + + $this->assertTrue($this->provider->isEnabled($key)); + } + + public function testGetValueRejectsBooleanKeys(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Key is a boolean value, use isEnabled instead'); + + $this->provider->getValue(ConfigOption::MaintenanceMode); + } + + public function testGetValueReturnsFromCacheWhenPresent(): void + { + $key = $this->pickNonBooleanCase(); + $cacheKey = 'cfg:' . $key->value; + + $this->cache + ->expects($this->once()) + ->method('get') + ->with($cacheKey) + ->willReturn('CACHED'); + + $this->repo->expects($this->never())->method('findValueByItem'); + $this->defaults->expects($this->never())->method('has'); + $this->defaults->expects($this->never())->method('get'); + + $this->assertSame('CACHED', $this->provider->getValue($key)); + } + + public function testGetValueLoadsFromRepoAndCachesWhenCacheMiss(): void + { + $key = $this->pickNonBooleanCase(); + $cacheKey = 'cfg:' . $key->value; + + $this->cache + ->expects($this->once()) + ->method('get') + ->with($cacheKey) + ->willReturn(null); + + $this->repo + ->expects($this->once()) + ->method('findValueByItem') + ->with($key->value) + ->willReturn('DBVAL'); + + $this->cache + ->expects($this->once()) + ->method('set') + ->with($cacheKey, 'DBVAL', 300); + + $this->defaults->expects($this->never())->method('has'); + $this->defaults->expects($this->never())->method('get'); + + $this->assertSame('DBVAL', $this->provider->getValue($key)); + } + + public function testGetValueFallsBackToDefaultConfigsWhenNoCacheAndNoRepo(): void + { + $key = $this->pickNonBooleanCase(); + $cacheKey = 'cfg:' . $key->value; + + $this->cache + ->expects($this->once()) + ->method('get') + ->with($cacheKey) + ->willReturn(null); + + $this->repo + ->expects($this->once()) + ->method('findValueByItem') + ->with($key->value) + ->willReturn(null); + + $this->cache + ->expects($this->once()) + ->method('set') + ->with($cacheKey, null, 300); + + $this->defaults + ->expects($this->once()) + ->method('has') + ->with($key->value) + ->willReturn(true); + + $this->defaults + ->expects($this->once()) + ->method('get') + ->with($key->value) + ->willReturn(['value' => 'DEF']); + + $this->assertSame('DEF', $this->provider->getValue($key)); + } + + public function testGetValueReturnsNullWhenNoCacheNoRepoNoDefault(): void + { + $key = $this->pickNonBooleanCase(); + $cacheKey = 'cfg:' . $key->value; + + $this->cache->expects($this->once())->method('get')->with($cacheKey)->willReturn(null); + $this->repo->expects($this->once())->method('findValueByItem')->with($key->value)->willReturn(null); + $this->cache->expects($this->once())->method('set')->with($cacheKey, null, 300); + + $this->defaults->expects($this->once())->method('has')->with($key->value)->willReturn(false); + $this->defaults->expects($this->never())->method('get'); + + $this->assertNull($this->provider->getValue($key)); + } + + public function testGetValueWithNamespacePrefersFullValue(): void + { + $key = $this->pickNonBooleanCase(); + + // Force getValue($key) to return a non-empty string + $this->cache->method('get')->willReturn('FULL'); + $this->repo->expects($this->never())->method('findValueByItem'); + + $this->assertSame('FULL', $this->provider->getValueWithNamespace($key)); + } + + public function testGetValueWithNamespaceFallsBackToParentWhenFullEmpty(): void + { + [$child, $parent] = $this->pickNamespacedCasePair(); + + // Simulate: child is empty (null or ''), parent has value "PARENTVAL" + $this->cache + ->method('get') + ->willReturnMap([ + ['cfg:' . $child->value, null], + ['cfg:' . $parent->value, 'PARENTVAL'], + ]); + + // child -> repo null; parent -> not consulted because cache returns value + $this->repo + ->method('findValueByItem') + ->willReturnMap([ + [$child->value, null], + ]); + + // child miss is cached as null, parent value is not rewritten here (already cached) + $this->cache + ->expects($this->atLeastOnce()) + ->method('set'); + + $this->defaults->method('has')->willReturn(false); + + $this->assertSame('PARENTVAL', $this->provider->getValueWithNamespace($child)); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php b/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php new file mode 100644 index 00000000..ae5b96cb --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php @@ -0,0 +1,127 @@ +translator = $this->createMock(TranslatorInterface::class); + $this->provider = new DefaultConfigProvider($this->translator); + } + + public function testHasReturnsTrueForKnownKey(): void + { + $this->assertTrue($this->provider->has('admin_address')); + } + + public function testGetReturnsArrayShapeForKnownKey(): void + { + $item = $this->provider->get('admin_address'); + + $this->assertIsArray($item); + $this->assertArrayHasKey('value', $item); + $this->assertArrayHasKey('description', $item); + $this->assertArrayHasKey('type', $item); + $this->assertArrayHasKey('category', $item); + + // basic sanity check + $this->assertSame('email', $item['type']); + $this->assertSame('general', $item['category']); + $this->assertStringContainsString('[DOMAIN]', (string) $item['value']); + } + + public function testGetReturnsProvidedDefaultWhenUnknownKey(): void + { + $fallback = ['value' => 'X', 'type' => 'text']; + $this->assertSame($fallback, $this->provider->get('does_not_exist', $fallback)); + } + + public function testRemoteProcessingSecretIsRandomHexOfExpectedLength(): void + { + $item = $this->provider->get('remote_processing_secret'); + $this->assertIsArray($item); + $this->assertArrayHasKey('value', $item); + + $val = (string) $item['value']; + // bin2hex(random_bytes(10)) => 20 hex chars + $this->assertMatchesRegularExpression('/^[0-9a-f]{20}$/i', $val); + } + + public function testSubscribeUrlDefaultsToHttpAndApiV2Path(): void + { + $item = $this->provider->get('subscribeurl'); + $this->assertIsArray($item); + $url = (string) $item['value']; + + $this->assertStringStartsWith('http://', $url); + $this->assertStringContainsString('[WEBSITE]', $url); + $this->assertStringContainsString('/api/v2/?p=subscribe', $url); + } + + public function testUnsubscribeUrlDefaults(): void + { + $item = $this->provider->get('unsubscribeurl'); + $url = (string) $item['value']; + + $this->assertStringStartsWith('http://', $url); + $this->assertStringContainsString('/api/v2/?p=unsubscribe', $url); + } + + public function testTranslatorIsUsedOnlyOnFirstInit(): void + { + $this->translator + ->expects($this->atLeastOnce()) + ->method('trans') + ->willReturnArgument(0); + $this->provider->get('admin_address'); + + // Subsequent calls should not trigger init again + $translator = $this->createMock(TranslatorInterface::class); + $translator + ->expects($this->never()) + ->method('trans'); + + $reflection = new ReflectionClass($this->provider); + $prop = $reflection->getProperty('translator'); + + $prop->setValue($this->provider, $translator); + $this->provider->get('unsubscribeurl'); + $this->provider->has('pageheader'); + } + + public function testKnownKeysHaveReasonableTypes(): void + { + $keys = [ + 'admin_address' => 'email', + 'organisation_name' => 'text', + 'organisation_logo' => 'image', + 'date_format' => 'text', + 'rc_notification' => 'boolean', + 'notify_admin_login' => 'boolean', + 'message_from_address' => 'email', + 'message_from_name' => 'text', + 'message_replyto_address' => 'email', + ]; + + foreach ($keys as $key => $type) { + $item = $this->provider->get($key); + $this->assertIsArray($item, 'Item should be an array. Key: ' . $key); + $this->assertSame($type, $item['type'] ?? null, $key .': should have type ' . $type); + } + } +} diff --git a/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php b/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php new file mode 100644 index 00000000..0c7f7dfd --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php @@ -0,0 +1,218 @@ +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 + ); + } + + public function testReturnsOriginalWhenSubscriberNotFound(): void + { + $this->subRepo + ->expects($this->once()) + ->method('findOneByEmail') + ->with('nobody@example.com') + ->willReturn(null); + + $result = $this->personalizer->personalize('Hello [EMAIL]', 'nobody@example.com'); + + $this->assertSame('Hello [EMAIL]', $result); + } + + public function testBuiltInPlaceholdersAreResolved(): void + { + $email = 'ada@example.com'; + $uid = 'U123'; + + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getEmail')->willReturn($email); + $subscriber->method('getUniqueId')->willReturn($uid); + + $this->subRepo + ->expects($this->once()) + ->method('findOneByEmail') + ->with($email) + ->willReturn($subscriber); + + // Config values for URLs + domain/website + subscribe url + $this->config->method('getValue')->willReturnCallback(function ($opt) { + return match ($opt) { + ConfigOption::UnsubscribeUrl => 'https://u.example/unsub', + ConfigOption::ConfirmationUrl => 'https://u.example/confirm', + ConfigOption::PreferencesUrl => 'https://u.example/prefs', + ConfigOption::SubscribeUrl => 'https://u.example/subscribe', + ConfigOption::Domain => 'example.org', + ConfigOption::Website => 'site.example.org', + default => null, + }; + }); + + // LegacyUrlBuilder glue behavior + $this->urlBuilder + ->method('withUid') + ->willReturnCallback(fn(string $base, string $u) => $base . '?uid=' . $u); + + $this->attrRepo + ->expects($this->once()) + ->method('getForSubscriber') + ->with($subscriber) + ->willReturn([]); + + $input = 'Email: [EMAIL] + Unsub: [UNSUBSCRIBEURL] + Conf: [confirmationurl] + Prefs: [PREFERENCESURL] + Sub: [SUBSCRIBEURL] + Domain: [DOMAIN] + Website: [WEBSITE]'; + + + $result = $this->personalizer->personalize($input, $email); + + $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('Domain: example.org', $result); + $this->assertStringContainsString('Website: site.example.org', $result); + } + + public function testDynamicUserAttributesAreResolvedCaseInsensitive(): void + { + $email = 'bob@example.com'; + $uid = 'U999'; + + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getEmail')->willReturn($email); + $subscriber->method('getUniqueId')->willReturn($uid); + + $this->subRepo + ->expects($this->once()) + ->method('findOneByEmail') + ->with($email) + ->willReturn($subscriber); + + // Only needed so registration for URL placeholders doesn't blow up; values don't matter in this test + $this->config->method('getValue')->willReturnMap([ + [ConfigOption::UnsubscribeUrl, ''], + [ConfigOption::ConfirmationUrl, ''], + [ConfigOption::PreferencesUrl, ''], + [ConfigOption::SubscribeUrl, ''], + [ConfigOption::Domain, 'example.org'], + [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'); + $attrValue = $this->createMock(SubscriberAttributeValue::class); + $attrValue->method('getAttributeDefinition')->willReturn($attrDefinition); + + $this->attrRepo + ->expects($this->once()) + ->method('getForSubscriber') + ->with($subscriber) + ->willReturn([$attrValue]); + + // When resolver is called with our attr value, return computed string + $this->attrResolver + ->expects($this->once()) + ->method('resolve') + ->with($attrValue) + ->willReturn('Bob #2'); + + $input = 'Hello [full_name2], your email is [email].'; + $result = $this->personalizer->personalize($input, $email); + + $this->assertSame('Hello Bob #2, your email is bob@example.com.', $result); + } + + public function testMultipleOccurrencesAndAdjacency(): void + { + $email = 'eve@example.com'; + $uid = 'UID42'; + + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getEmail')->willReturn($email); + $subscriber->method('getUniqueId')->willReturn($uid); + + $this->subRepo->method('findOneByEmail')->willReturn($subscriber); + + $this->config->method('getValue')->willReturnMap([ + [ConfigOption::UnsubscribeUrl, 'https://x/unsub'], + [ConfigOption::ConfirmationUrl, 'https://x/conf'], + [ConfigOption::PreferencesUrl, 'https://x/prefs'], + [ConfigOption::SubscribeUrl, 'https://x/sub'], + [ConfigOption::Domain, 'x.tld'], + [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'); + $valFoo = $this->createMock(SubscriberAttributeValue::class); + $valFoo->method('getAttributeDefinition')->willReturn($defFoo); + + $defBar = $this->createMock(SubscriberAttributeDefinition::class); + $defBar->method('getName')->willReturn('bar'); + $valBar = $this->createMock(SubscriberAttributeValue::class); + $valBar->method('getAttributeDefinition')->willReturn($defBar); + + $this->attrRepo->method('getForSubscriber')->willReturn([$valFoo, $valBar]); + + $this->attrResolver + ->method('resolve') + ->willReturnMap([ + [$valFoo, 'FVAL'], + [$valBar, 'BVAL'], + ]); + + $input = '[foo][BAR]-[email]-[UNSUBSCRIBEURL]'; + $out = $this->personalizer->personalize($input, $email); + + $this->assertSame('FVALBVAL-eve@example.com-https://x/unsub?uid=UID42 ', $out); + } +} From f00eebbbe111edf960604e3469684405440aea6e Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 7 Oct 2025 16:22:20 +0400 Subject: [PATCH 16/16] Add more tests + fix handler --- ...SubscriptionConfirmationMessageHandler.php | 2 +- ...criptionConfirmationMessageHandlerTest.php | 139 +++++++++++++++++ .../DynamicListAttrRepositoryTest.php | 147 ++++++++++++++++++ .../Service/AttributeValueResolverTest.php | 83 ++++++++++ .../Provider/ScalarValueProviderTest.php | 57 +++++++ .../SelectOrRadioValueProviderTest.php | 115 ++++++++++++++ 6 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php create mode 100644 tests/Unit/Domain/Subscription/Repository/DynamicListAttrRepositoryTest.php create mode 100644 tests/Unit/Domain/Subscription/Service/AttributeValueResolverTest.php create mode 100644 tests/Unit/Domain/Subscription/Service/Provider/ScalarValueProviderTest.php create mode 100644 tests/Unit/Domain/Subscription/Service/Provider/SelectOrRadioValueProviderTest.php diff --git a/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php b/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php index 3aaf0988..6ecb965b 100644 --- a/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php @@ -49,7 +49,7 @@ public function __invoke(SubscriptionConfirmationMessage $message): void $textContent = $this->configProvider->getValue(ConfigOption::SubscribeMessage); $personalizedTextContent = $this->userPersonalizer->personalize($textContent, $message->getUniqueId()); $listOfLists = $this->getListNames($message->getListIds()); - $replacedTextContent = str_replace('[LISTS]', $personalizedTextContent, $listOfLists); + $replacedTextContent = str_replace('[LISTS]', $listOfLists, $personalizedTextContent); $email = (new Email()) ->to($message->getEmail()) diff --git a/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php new file mode 100644 index 00000000..6288c5f4 --- /dev/null +++ b/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php @@ -0,0 +1,139 @@ +createMock(EmailService::class); + $configProvider = $this->createMock(ConfigProvider::class); + $logger = $this->createMock(LoggerInterface::class); + $personalizer = $this->createMock(UserPersonalizer::class); + $listRepo = $this->createMock(SubscriberListRepository::class); + + $handler = new SubscriptionConfirmationMessageHandler( + emailService: $emailService, + configProvider: $configProvider, + logger: $logger, + userPersonalizer: $personalizer, + subscriberListRepository: $listRepo + ); + $configProvider + ->expects($this->exactly(2)) + ->method('getValue') + ->willReturnMap([ + [ConfigOption::SubscribeEmailSubject, 'Please confirm your subscription'], + [ConfigOption::SubscribeMessage, 'Hi {{name}}, you subscribed to: [LISTS]'], + ]); + + $message = new SubscriptionConfirmationMessage('alice@example.com', 'user-123', [10, 11]); + + $personalizer->expects($this->once()) + ->method('personalize') + ->with('Hi {{name}}, you subscribed to: [LISTS]', 'user-123') + ->willReturn('Hi Alice, you subscribed to: [LISTS]'); + + $listA = $this->createMock(SubscriberList::class); + $listA->method('getName')->willReturn('Releases'); + $listB = $this->createMock(SubscriberList::class); + $listB->method('getName')->willReturn('Security Advisories'); + + $listRepo->method('find') + ->willReturnCallback(function (int $id) use ($listA, $listB) { + return match ($id) { + 10 => $listA, + 11 => $listB, + default => null + }; + }); + + // Capture the Email object passed to EmailService + $emailService->expects($this->once()) + ->method('sendEmail') + ->with($this->callback(function (Email $email): bool { + $addresses = $email->getTo(); + if (count($addresses) !== 1 || $addresses[0]->getAddress() !== 'alice@example.com') { + return false; + } + if ($email->getSubject() !== 'Please confirm your subscription') { + return false; + } + $body = $email->getTextBody(); + return $body === 'Hi Alice, you subscribed to: Releases, Security Advisories'; + })); + + $logger->expects($this->once()) + ->method('info') + ->with( + 'Subscription confirmation email sent to {email}', + ['email' => 'alice@example.com'] + ); + + $handler($message); + } + + public function testHandlesMissingListsGracefullyAndEmptyJoin(): void + { + $emailService = $this->createMock(EmailService::class); + $configProvider = $this->createMock(ConfigProvider::class); + $logger = $this->createMock(LoggerInterface::class); + $personalizer = $this->createMock(UserPersonalizer::class); + $listRepo = $this->createMock(SubscriberListRepository::class); + + $handler = new SubscriptionConfirmationMessageHandler( + emailService: $emailService, + configProvider: $configProvider, + logger: $logger, + userPersonalizer: $personalizer, + subscriberListRepository: $listRepo + ); + + $configProvider->method('getValue') + ->willReturnMap([ + [ConfigOption::SubscribeEmailSubject, 'Please confirm your subscription'], + [ConfigOption::SubscribeMessage, 'Lists: [LISTS]'], + ]); + + $message = $this->createMock(SubscriptionConfirmationMessage::class); + $message->method('getEmail')->willReturn('bob@example.com'); + $message->method('getUniqueId')->willReturn('user-456'); + $message->method('getListIds')->willReturn([42]); + + $personalizer->method('personalize') + ->with('Lists: [LISTS]', 'user-456') + ->willReturn('Lists: [LISTS]'); + + $listRepo->method('find')->with(42)->willReturn(null); + + $emailService->expects($this->once()) + ->method('sendEmail') + ->with($this->callback(function (Email $email): bool { + // Intended empty replacement when no lists found -> empty string + return $email->getTextBody() === 'Lists: '; + })); + + $logger->expects($this->once()) + ->method('info') + ->with('Subscription confirmation email sent to {email}', ['email' => 'bob@example.com']); + + $handler($message); + } +} diff --git a/tests/Unit/Domain/Subscription/Repository/DynamicListAttrRepositoryTest.php b/tests/Unit/Domain/Subscription/Repository/DynamicListAttrRepositoryTest.php new file mode 100644 index 00000000..948c8347 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Repository/DynamicListAttrRepositoryTest.php @@ -0,0 +1,147 @@ +createMock(Connection::class); + $repo = new DynamicListAttrRepository($conn, 'phplist_'); + + $this->assertSame([], $repo->fetchOptionNames('valid_table', [])); + $this->assertSame([], $repo->fetchOptionNames('valid_table', [])); + } + + public function testFetchOptionNamesThrowsOnInvalidTable(): void + { + $conn = $this->createMock(Connection::class); + $repo = new DynamicListAttrRepository($conn, 'phplist_'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid list table'); + + $repo->fetchOptionNames('invalid-table;', [1, 2]); + } + + public function testFetchOptionNamesReturnsNames(): void + { + $conn = $this->createMock(Connection::class); + + $qb = $this->getMockBuilder(QueryBuilder::class) + ->disableOriginalConstructor() + ->onlyMethods(['select', 'from', 'where', 'setParameter', 'executeQuery']) + ->getMock(); + + $qb->expects($this->once()) + ->method('select') + ->with('name') + ->willReturnSelf(); + + $qb->expects($this->once()) + ->method('from') + ->with('phplist_listattr_users') + ->willReturnSelf(); + + $qb->expects($this->once()) + ->method('where') + ->with('id IN (:ids)') + ->willReturnSelf(); + + // Expect integer coercion of IDs and correct array parameter type + $qb->expects($this->once()) + ->method('setParameter') + ->with( + 'ids', + [1, 2, 3], + ArrayParameterType::INTEGER + ) + ->willReturnSelf(); + + // Mock Result + $result = $this->createMock(Result::class); + $result->expects($this->once()) + ->method('fetchFirstColumn') + ->willReturn(['alpha', 'beta', 'gamma']); + + $qb->expects($this->once()) + ->method('executeQuery') + ->willReturn($result); + + $conn->method('createQueryBuilder')->willReturn($qb); + + $repo = new DynamicListAttrRepository($conn, 'phplist_'); + $names = $repo->fetchOptionNames('users', [1, '2', 3]); + + $this->assertSame(['alpha', 'beta', 'gamma'], $names); + } + + public function testFetchSingleOptionNameThrowsOnInvalidTable(): void + { + $conn = $this->createMock(Connection::class); + $repo = new DynamicListAttrRepository($conn, 'phplist_'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid list table'); + + $repo->fetchSingleOptionName('bad name!', 10); + } + + public function testFetchSingleOptionNameReturnsString(): void + { + $conn = $this->createMock(Connection::class); + + $qb = $this->getMockBuilder(QueryBuilder::class) + ->disableOriginalConstructor() + ->onlyMethods(['select', 'from', 'where', 'setParameter', 'executeQuery']) + ->getMock(); + + $qb->expects($this->once())->method('select')->with('name')->willReturnSelf(); + $qb->expects($this->once())->method('from')->with('phplist_listattr_ukcountries')->willReturnSelf(); + $qb->expects($this->once())->method('where')->with('id = :id')->willReturnSelf(); + $qb->expects($this->once())->method('setParameter')->with('id', 42)->willReturnSelf(); + + $result = $this->createMock(Result::class); + $result->expects($this->once())->method('fetchOne')->willReturn('Bradford'); + + $qb->expects($this->once())->method('executeQuery')->willReturn($result); + $conn->method('createQueryBuilder')->willReturn($qb); + + $repo = new DynamicListAttrRepository($conn, 'phplist_'); + $this->assertSame('Bradford', $repo->fetchSingleOptionName('ukcountries', 42)); + } + + public function testFetchSingleOptionNameReturnsNullWhenNotFound(): void + { + $conn = $this->createMock(Connection::class); + + $qb = $this->getMockBuilder(QueryBuilder::class) + ->disableOriginalConstructor() + ->onlyMethods(['select', 'from', 'where', 'setParameter', 'executeQuery']) + ->getMock(); + + $qb->method('select')->with('name')->willReturnSelf(); + $qb->method('from')->with('phplist_listattr_termsofservices')->willReturnSelf(); + $qb->method('where')->with('id = :id')->willReturnSelf(); + $qb->method('setParameter')->with('id', 999)->willReturnSelf(); + + $result = $this->createMock(Result::class); + $result->expects($this->once())->method('fetchOne')->willReturn(false); + + $qb->method('executeQuery')->willReturn($result); + $conn->method('createQueryBuilder')->willReturn($qb); + + $repo = new DynamicListAttrRepository($conn, 'phplist_'); + $this->assertNull($repo->fetchSingleOptionName('termsofservices', 999)); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/AttributeValueResolverTest.php b/tests/Unit/Domain/Subscription/Service/AttributeValueResolverTest.php new file mode 100644 index 00000000..515557c8 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/AttributeValueResolverTest.php @@ -0,0 +1,83 @@ +createMock(SubscriberAttributeDefinition::class); + $userAttr = $this->createMock(SubscriberAttributeValue::class); + $userAttr->method('getAttributeDefinition')->willReturn($def); + + $p1 = $this->createMock(AttributeValueProvider::class); + $p1->expects($this->once())->method('supports')->with($def)->willReturn(false); + $p1->expects($this->never())->method('getValue'); + + $p2 = $this->createMock(AttributeValueProvider::class); + $p2->expects($this->once())->method('supports')->with($def)->willReturn(false); + $p2->expects($this->never())->method('getValue'); + + $resolver = new AttributeValueResolver([$p1, $p2]); + + self::assertSame('', $resolver->resolve($userAttr)); + } + + public function testResolveReturnsValueFromFirstSupportingProvider(): void + { + $def = $this->createMock(SubscriberAttributeDefinition::class); + $userAttr = $this->createMock(SubscriberAttributeValue::class); + $userAttr->method('getAttributeDefinition')->willReturn($def); + + $nonSupporting = $this->createMock(AttributeValueProvider::class); + $nonSupporting->expects($this->once())->method('supports')->with($def)->willReturn(false); + $nonSupporting->expects($this->never())->method('getValue'); + + $supporting = $this->createMock(AttributeValueProvider::class); + $supporting->expects($this->once())->method('supports')->with($def)->willReturn(true); + $supporting->expects($this->once()) + ->method('getValue') + ->with($def, $userAttr) + ->willReturn('Resolved Value'); + + // This provider should never be interrogated because resolver exits early. + $afterFirstMatch = $this->createMock(AttributeValueProvider::class); + $afterFirstMatch->expects($this->never())->method('supports'); + $afterFirstMatch->expects($this->never())->method('getValue'); + + $resolver = new AttributeValueResolver([$nonSupporting, $supporting, $afterFirstMatch]); + + self::assertSame('Resolved Value', $resolver->resolve($userAttr)); + } + + public function testResolveHonorsProviderOrderFirstMatchWins(): void + { + $def = $this->createMock(SubscriberAttributeDefinition::class); + $userAttr = $this->createMock(SubscriberAttributeValue::class); + $userAttr->method('getAttributeDefinition')->willReturn($def); + + $firstSupporting = $this->createMock(AttributeValueProvider::class); + $firstSupporting->expects($this->once())->method('supports')->with($def)->willReturn(true); + $firstSupporting->expects($this->once()) + ->method('getValue') + ->with($def, $userAttr) + ->willReturn('first'); + + $secondSupporting = $this->createMock(AttributeValueProvider::class); + // Must not be called because the first already matched + $secondSupporting->expects($this->never())->method('supports'); + $secondSupporting->expects($this->never())->method('getValue'); + + $resolver = new AttributeValueResolver([$firstSupporting, $secondSupporting]); + + self::assertSame('first', $resolver->resolve($userAttr)); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Provider/ScalarValueProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/ScalarValueProviderTest.php new file mode 100644 index 00000000..2b28c295 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/Provider/ScalarValueProviderTest.php @@ -0,0 +1,57 @@ +createMock(SubscriberAttributeDefinition::class); + $attr->method('getType')->willReturn(null); + + self::assertTrue($provider->supports($attr)); + } + + public function testSupportsReturnsFalseWhenTypeIsNotNull(): void + { + $provider = new ScalarValueProvider(); + + $attr = $this->createMock(SubscriberAttributeDefinition::class); + $attr->method('getType')->willReturn('checkbox'); + + self::assertFalse($provider->supports($attr)); + } + + public function testGetValueReturnsUnderlyingString(): void + { + $provider = new ScalarValueProvider(); + + $attr = $this->createMock(SubscriberAttributeDefinition::class); + + $value = $this->createMock(SubscriberAttributeValue::class); + $value->method('getValue')->willReturn('hello'); + + self::assertSame('hello', $provider->getValue($attr, $value)); + } + + public function testGetValueReturnsEmptyStringWhenNull(): void + { + $provider = new ScalarValueProvider(); + + $attr = $this->createMock(SubscriberAttributeDefinition::class); + + $value = $this->createMock(SubscriberAttributeValue::class); + $value->method('getValue')->willReturn(null); + + self::assertSame('', $provider->getValue($attr, $value)); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Provider/SelectOrRadioValueProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/SelectOrRadioValueProviderTest.php new file mode 100644 index 00000000..38849fd7 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/Provider/SelectOrRadioValueProviderTest.php @@ -0,0 +1,115 @@ +createMock(DynamicListAttrRepository::class); + $provider = new SelectOrRadioValueProvider($repo); + + $attrSelect = $this->createMock(SubscriberAttributeDefinition::class); + $attrSelect->method('getType')->willReturn('select'); + self::assertTrue($provider->supports($attrSelect)); + + $attrRadio = $this->createMock(SubscriberAttributeDefinition::class); + $attrRadio->method('getType')->willReturn('radio'); + self::assertTrue($provider->supports($attrRadio)); + } + + public function testSupportsReturnsFalseForOtherTypes(): void + { + $repo = $this->createMock(DynamicListAttrRepository::class); + $provider = new SelectOrRadioValueProvider($repo); + + $attr = $this->createMock(SubscriberAttributeDefinition::class); + $attr->method('getType')->willReturn('checkboxgroup'); + + self::assertFalse($provider->supports($attr)); + } + + public function testGetValueReturnsEmptyWhenNoTableName(): void + { + $repo = $this->createMock(DynamicListAttrRepository::class); + $provider = new SelectOrRadioValueProvider($repo); + + $attr = $this->createMock(SubscriberAttributeDefinition::class); + $attr->method('getTableName')->willReturn(null); + + $val = $this->createMock(SubscriberAttributeValue::class); + $val->method('getValue')->willReturn('10'); + + $repo->expects($this->never())->method('fetchSingleOptionName'); + + self::assertSame('', $provider->getValue($attr, $val)); + } + + public function testGetValueReturnsEmptyWhenValueNullOrNonPositive(): void + { + $repo = $this->createMock(DynamicListAttrRepository::class); + $provider = new SelectOrRadioValueProvider($repo); + + $attr = $this->createMock(SubscriberAttributeDefinition::class); + $attr->method('getTableName')->willReturn('products'); + + $valNull = $this->createMock(SubscriberAttributeValue::class); + $valNull->method('getValue')->willReturn(null); + $repo->expects($this->never())->method('fetchSingleOptionName'); + self::assertSame('', $provider->getValue($attr, $valNull)); + + $valZero = $this->createMock(SubscriberAttributeValue::class); + $valZero->method('getValue')->willReturn('0'); + self::assertSame('', $provider->getValue($attr, $valZero)); + + $valNegative = $this->createMock(SubscriberAttributeValue::class); + $valNegative->method('getValue')->willReturn('-5'); + self::assertSame('', $provider->getValue($attr, $valNegative)); + } + + public function testGetValueReturnsEmptyWhenRepoReturnsNull(): void + { + $repo = $this->createMock(DynamicListAttrRepository::class); + $provider = new SelectOrRadioValueProvider($repo); + + $attr = $this->createMock(SubscriberAttributeDefinition::class); + $attr->method('getTableName')->willReturn('users'); + + $val = $this->createMock(SubscriberAttributeValue::class); + $val->method('getValue')->willReturn('7'); + + $repo->expects($this->once()) + ->method('fetchSingleOptionName') + ->with('users', 7) + ->willReturn(null); + + self::assertSame('', $provider->getValue($attr, $val)); + } + + public function testGetValueHappyPathReturnsNameFromRepo(): void + { + $repo = $this->createMock(DynamicListAttrRepository::class); + $provider = new SelectOrRadioValueProvider($repo); + + $attr = $this->createMock(SubscriberAttributeDefinition::class); + $attr->method('getTableName')->willReturn('countries'); + + $val = $this->createMock(SubscriberAttributeValue::class); + $val->method('getValue')->willReturn(' 42 '); + + $repo->expects($this->once()) + ->method('fetchSingleOptionName') + ->with('countries', 42) + ->willReturn('Armenia'); + + self::assertSame('Armenia', $provider->getValue($attr, $val)); + } +}