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/parameters.yml.dist b/config/parameters.yml.dist index e34a7d2b..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)%%' @@ -28,7 +30,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/providers.yml b/config/services/providers.yml index bb4524c3..f4f06010 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,18 @@ services: autoconfigure: true arguments: $confPath: '%app.phplist_isp_conf_path%' + + PhpList\Core\Domain\Subscription\Service\Provider\CheckboxGroupValueProvider: + autowire: true + PhpList\Core\Domain\Subscription\Service\Provider\SelectOrRadioValueProvider: + autowire: true + PhpList\Core\Domain\Subscription\Service\Provider\ScalarValueProvider: + autowire: true + + PhpList\Core\Domain\Configuration\Service\Provider\DefaultConfigProvider: + 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 1289bea7..e9d4d8c6 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -1,156 +1,138 @@ 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: + autowire: true + arguments: + $prefix: '%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..bc236399 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,18 +114,27 @@ 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 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 + + cache.app.simple: + class: Symfony\Component\Cache\Psr16Cache + arguments: [ '@cache.app' ] + + Psr\SimpleCache\CacheInterface: '@cache.app.simple' 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/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/src/Domain/Configuration/Model/ConfigOption.php b/src/Domain/Configuration/Model/ConfigOption.php new file mode 100644 index 00000000..86b9286e --- /dev/null +++ b/src/Domain/Configuration/Model/ConfigOption.php @@ -0,0 +1,18 @@ +findOneBy(['key' => $name])?->getValue(); + } } diff --git a/src/Domain/Configuration/Service/LegacyUrlBuilder.php b/src/Domain/Configuration/Service/LegacyUrlBuilder.php new file mode 100644 index 00000000..4bc6366f --- /dev/null +++ b/src/Domain/Configuration/Service/LegacyUrlBuilder.php @@ -0,0 +1,29 @@ +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/PlaceholderResolver.php b/src/Domain/Configuration/Service/PlaceholderResolver.php new file mode 100644 index 00000000..3a0a3464 --- /dev/null +++ b/src/Domain/Configuration/Service/PlaceholderResolver.php @@ -0,0 +1,33 @@ + */ + 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 ($map) { + $key = strtoupper($map[1]); + if (!isset($this->providers[$key])) { + 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 new file mode 100644 index 00000000..a1db70fc --- /dev/null +++ b/src/Domain/Configuration/Service/Provider/ConfigProvider.php @@ -0,0 +1,85 @@ +booleanValues)) { + throw new InvalidArgumentException('Invalid boolean value key'); + } + $config = $this->configRepository->findOneBy(['item' => $key->value]); + + if ($config !== null) { + return $config->getValue() === '1'; + } + + return $this->defaultConfigs->has($key->value) && $this->defaultConfigs->get($key->value)['value'] === '1'; + } + + /** + * 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 + { + 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); + } + + if ($value !== null) { + return $value; + } + + return $this->defaultConfigs->has($key->value) ? $this->defaultConfigs->get($key->value)['value'] : null; + } + + /** @SuppressWarnings(PHPMD.StaticAccess) */ + public function getValueWithNamespace(ConfigOption $key): ?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); + } + + return null; + } +} diff --git a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php new file mode 100644 index 00000000..bbe14a46 --- /dev/null +++ b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php @@ -0,0 +1,587 @@ +defaults)) { + return; + } + + $publicSchema = 'http'; + $pageRoot = '/api/v2'; + + $this->defaults = [ + 'admin_address' => [ + 'value' => 'webmaster@[DOMAIN]', + 'description' => $this->translator->trans('Person in charge of this system (one email address)'), + 'type' => 'email', + 'allowempty' => false, + 'category' => 'general', + ], + 'organisation_name' => [ + 'value' => '', + 'description' => $this->translator->trans('Name of the organisation'), + 'type' => 'text', + 'allowempty' => true, + 'allowtags' => '

', + 'allowJS' => false, + 'category' => 'general', + ], + 'organisation_logo' => [ + 'value' => '', + 'description' => $this->translator->trans('Logo of the organisation'), + 'infoicon' => true, + 'type' => 'image', + 'allowempty' => true, + 'category' => 'general', + ], + 'date_format' => [ + 'value' => 'j F Y', + 'description' => $this->translator->trans('Date format'), + 'infoicon' => true, + 'type' => 'text', + 'allowempty' => false, + 'category' => 'general', + ], + 'rc_notification' => [ + 'value' => 0, + 'description' => $this->translator->trans('Show notification for Release Candidates'), + 'type' => 'boolean', + 'allowempty' => true, + 'category' => 'security', + ], + 'remote_processing_secret' => [ + 'value' => bin2hex(random_bytes(10)), + 'description' => $this->translator->trans('Secret for remote processing'), + 'type' => 'text', + 'category' => 'security', + ], + 'notify_admin_login' => [ + 'value' => 1, + 'description' => $this->translator->trans('Notify admin on login from new location'), + 'type' => 'boolean', + 'category' => 'security', + 'allowempty' => true, + ], + 'admin_addresses' => [ + 'value' => '', + 'description' => $this->translator->trans( + 'List of email addresses to CC in system messages (separate by commas)' + ), + 'type' => 'emaillist', + 'allowempty' => true, + 'category' => 'reporting', + ], + 'campaignfrom_default' => [ + 'value' => '', + 'description' => $this->translator->trans("Default for 'From:' in a campaign"), + 'type' => 'text', + 'allowempty' => true, + 'category' => 'campaign', + ], + 'notifystart_default' => [ + 'value' => '', + 'description' => $this->translator->trans("Default for 'address to alert when sending starts'"), + 'type' => 'email', + 'allowempty' => true, + 'category' => 'campaign', + ], + 'notifyend_default' => [ + 'value' => '', + 'description' => $this->translator->trans("Default for 'address to alert when sending finishes'"), + 'type' => 'email', + 'allowempty' => true, + 'category' => 'campaign', + ], + 'always_add_googletracking' => [ + 'value' => '0', + 'description' => $this->translator->trans('Always add analytics tracking code to campaigns'), + 'type' => 'boolean', + 'allowempty' => true, + 'category' => 'campaign', + ], + 'analytic_tracker' => [ + 'values' => ['google' => 'Google Analytics', 'matomo' => 'Matomo'], + 'value' => 'google', + 'description' => $this->translator->trans('Analytics tracking code to add to campaign URLs'), + 'type' => 'select', + 'allowempty' => false, + 'category' => 'campaign', + ], + 'report_address' => [ + 'value' => 'listreports@[DOMAIN]', + 'description' => $this->translator->trans( + 'Who gets the reports (email address, separate multiple emails with a comma)' + ), + 'type' => 'emaillist', + 'allowempty' => true, + 'category' => 'reporting', + ], + 'message_from_address' => [ + 'value' => 'noreply@[DOMAIN]', + 'description' => $this->translator->trans('From email address for system messages'), + 'type' => 'email', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'message_from_name' => [ + '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' => $this->translator->trans('Reply-to email address for system messages'), + 'type' => 'email', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'hide_single_list' => [ + 'value' => '1', + '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' => $this->translator->trans('Categories for lists. Separate with commas.'), + 'infoicon' => true, + 'type' => 'text', + 'allowempty' => true, + 'category' => 'list-organisation', + ], + 'displaycategories' => [ + 'value' => 0, + 'description' => $this->translator->trans('Display list categories on subscribe page'), + 'type' => 'boolean', + 'allowempty' => false, + 'category' => 'list-organisation', + ], + 'textline_width' => [ + 'value' => '40', + 'description' => $this->translator->trans('Width of a textline field (numerical)'), + 'type' => 'integer', + 'min' => 20, + 'max' => 150, + 'category' => 'subscription-ui', + ], + 'textarea_dimensions' => [ + 'value' => '10,40', + 'description' => $this->translator->trans('Dimensions of a textarea field (rows,columns)'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'subscription-ui', + ], + 'send_admin_copies' => [ + 'value' => '0', + 'description' => $this->translator->trans('Send notifications about subscribe, update and unsubscribe'), + 'type' => 'boolean', + 'allowempty' => true, + 'category' => 'reporting', + ], + 'defaultsubscribepage' => [ + 'value' => 1, + 'description' => $this->translator->trans('The default subscribe page when there are multiple'), + 'type' => 'integer', + 'min' => 1, + 'max' => 999, + 'allowempty' => true, + 'category' => 'subscription', + ], + 'defaultmessagetemplate' => [ + 'value' => 0, + 'description' => $this->translator->trans('The default HTML template to use when sending a message'), + 'type' => 'text', + 'allowempty' => true, + 'category' => 'campaign', + ], + 'systemmessagetemplate' => [ + 'value' => 0, + 'description' => $this->translator->trans('The HTML wrapper template for system messages'), + 'type' => 'integer', + 'min' => 0, + 'max' => 999, + 'allowempty' => true, + 'category' => 'transactional', + ], + 'subscribeurl' => [ + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=subscribe', + 'description' => $this->translator->trans('URL where subscribers can sign up'), + 'type' => 'url', + 'allowempty' => 0, + 'category' => 'subscription', + ], + 'unsubscribeurl' => [ + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=unsubscribe', + 'description' => $this->translator->trans('URL where subscribers can unsubscribe'), + 'type' => 'url', + 'allowempty' => 0, + 'category' => 'subscription', + ], + 'blacklisturl' => [ + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=donotsend', + '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' => $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' => $this->translator->trans('URL where subscribers can update their details'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'subscription', + ], + 'forwardurl' => [ + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=forward', + 'description' => $this->translator->trans('URL for forwarding messages'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'subscription', + ], + 'vcardurl' => [ + 'value' => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=vcard', + 'description' => $this->translator->trans('URL for downloading vcf card'), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'subscription', + ], + 'ajax_subscribeconfirmation' => [ + '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' => $this->translator->trans('Request for confirmation'), + 'description' => $this->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: + +[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' => $this->translator->trans('Message subscribers receive when they sign up'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'unsubscribesubject' => [ + 'value' => $this->translator->trans('Goodbye from our Newsletter'), + 'description' => $this->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. + +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' => $this->translator->trans('Message subscribers receive when they unsubscribe'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'confirmationsubject' => [ + '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', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'confirmationmessage' => [ + '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' => $this->translator->trans( + 'Message subscribers receive after confirming their email address' + ), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'updatesubject' => [ + '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', + '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' => [ + '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' => $this->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' => [ + 'value' => ' + When updating your details, your email address has changed. + Please confirm your new email address by visiting this webpage: + + [CONFIRMATIONURL] + + ', + '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', + ], + // 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' => [ + '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' => $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' => $this->translator->trans('Your personal location'), + 'description' => $this->translator->trans( + 'Subject of message when subscribers request their personal location' + ), + 'type' => 'text', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'messagefooter' => [ + 'value' => '-- + + + + ', + 'description' => $this->translator->trans('Default footer for sending a campaign'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'campaign', + ], + 'forwardfooter' => [ + 'value' => ' + + ', + 'description' => $this->translator->trans('Footer used when a message has been forwarded'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'campaign', + ], + '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. + +Your personal location is: +[PREFERENCESURL] + +Thank you.' + , + 'description' => $this->translator->trans('Message to send when they request their personal location'), + 'type' => 'textarea', + 'allowempty' => 0, + 'category' => 'transactional', + ], + 'remoteurl_append' => [ + 'value' => '', + 'description' => $this->translator->trans( + 'String to always append to remote URL when using send-a-webpage' + ), + 'type' => 'text', + 'allowempty' => true, + 'category' => 'campaign', + ], + 'wordwrap' => [ + 'value' => '75', + 'description' => $this->translator->trans('Width for Wordwrap of Text messages'), + 'type' => 'text', + 'allowempty' => true, + 'category' => 'campaign', + ], + 'html_email_style' => [ + 'value' => '', + 'description' => $this->translator->trans('CSS for HTML messages without a template'), + 'type' => 'textarea', + 'allowempty' => true, + 'category' => 'campaign', + ], + 'alwayssendtextto' => [ + 'value' => '', + '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' => $this->translator->trans('last time TLDs were fetched'), + 'type' => 'text', + 'allowempty' => true, + 'category' => 'system', + 'hidden' => true, + ], + 'internet_tlds' => [ + 'value' => '', + 'description' => $this->translator->trans('Top level domains'), + 'type' => 'textarea', + 'allowempty' => true, + 'category' => 'system', + 'hidden' => true, + ], + 'pageheader' => [ + 'value' => '

Welcome

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

Footer text

', + 'description' => $this->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|null $default + * @return mixed + */ + public function get(string $key, mixed $default = null): mixed + { + $this->init(); + + return $this->defaults[$key] ?? $default; + } + + /** + * Check if a config key exists + */ + public function has(string $key): bool + { + $this->init(); + + return isset($this->defaults[$key]); + } +} diff --git a/src/Domain/Configuration/Service/UserPersonalizer.php b/src/Domain/Configuration/Service/UserPersonalizer.php new file mode 100644 index 00000000..7aedf1d8 --- /dev/null +++ b/src/Domain/Configuration/Service/UserPersonalizer.php @@ -0,0 +1,69 @@ +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/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.') ); 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..6ecb965b --- /dev/null +++ b/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php @@ -0,0 +1,76 @@ +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(SubscriptionConfirmationMessage $message): void + { + $subject = $this->configProvider->getValue(ConfigOption::SubscribeEmailSubject); + $textContent = $this->configProvider->getValue(ConfigOption::SubscribeMessage); + $personalizedTextContent = $this->userPersonalizer->personalize($textContent, $message->getUniqueId()); + $listOfLists = $this->getListNames($message->getListIds()); + $replacedTextContent = str_replace('[LISTS]', $listOfLists, $personalizedTextContent); + + $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()]); + } + + private function getListNames(array $listIds): string + { + $listNames = []; + foreach ($listIds as $id) { + $list = $this->subscriberListRepository->find($id); + if ($list) { + $listNames[] = $list->getName(); + } + } + + return implode(', ', $listNames); + } +} diff --git a/src/Domain/Subscription/Repository/DynamicListAttrRepository.php b/src/Domain/Subscription/Repository/DynamicListAttrRepository.php new file mode 100644 index 00000000..104938b0 --- /dev/null +++ b/src/Domain/Subscription/Repository/DynamicListAttrRepository.php @@ -0,0 +1,62 @@ + + * @throws InvalidArgumentException + */ + 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; + + $queryBuilder = $this->connection->createQueryBuilder(); + $queryBuilder->select('name') + ->from($table) + ->where('id IN (:ids)') + ->setParameter('ids', array_map('intval', $ids), ArrayParameterType::INTEGER); + + return $queryBuilder->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; + + $queryBuilder = $this->connection->createQueryBuilder(); + $queryBuilder->select('name') + ->from($table) + ->where('id = :id') + ->setParameter('id', $id); + + $val = $queryBuilder->executeQuery()->fetchOne(); + + return $val === false ? null : (string) $val; + } +} 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/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/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(function ($value) { + $index = (int) trim($value); + return $index > 0 ? $index : 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..22b3ab4e --- /dev/null +++ b/src/Domain/Subscription/Service/Provider/SelectOrRadioValueProvider.php @@ -0,0 +1,35 @@ +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..c63b7f8c --- /dev/null +++ b/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php @@ -0,0 +1,26 @@ + $providers */ + public function __construct(private readonly iterable $providers) + { + } + + public function resolve(SubscriberAttributeValue $userAttr): string + { + foreach ($this->providers as $provider) { + 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 c88b935e..f5d9e535 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Domain\Subscription\Service; use Doctrine\ORM\EntityManagerInterface; +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; @@ -15,6 +16,7 @@ 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\Messenger\MessageBusInterface; use Symfony\Contracts\Translation\TranslatorInterface; use Throwable; @@ -32,6 +34,7 @@ class SubscriberCsvImporter private SubscriberAttributeDefinitionRepository $attrDefinitionRepository; private EntityManagerInterface $entityManager; private TranslatorInterface $translator; + private MessageBusInterface $messageBus; public function __construct( SubscriberManager $subscriberManager, @@ -42,6 +45,7 @@ public function __construct( SubscriberAttributeDefinitionRepository $attrDefinitionRepository, EntityManagerInterface $entityManager, TranslatorInterface $translator, + MessageBusInterface $messageBus, ) { $this->subscriberManager = $subscriberManager; $this->attributeManager = $attributeManager; @@ -51,6 +55,7 @@ public function __construct( $this->attrDefinitionRepository = $attrDefinitionRepository; $this->entityManager = $entityManager; $this->translator = $translator; + $this->messageBus = $messageBus; } /** @@ -83,9 +88,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%', @@ -149,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']++; @@ -174,13 +172,65 @@ private function processRow( $this->processAttributes($subscriber, $dto); - if (count($options->listIds) > 0) { + $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; + } + } + } + + $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; + } + // phpcs:ignore Generic.Commenting.Todo + // @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 ($dto->sendConfirmation && $addedNewSubscriberToList) { + $this->sendSubscribeEmail($subscriber, $options->listIds); } } } + private function sendSubscribeEmail(Subscriber $subscriber, array $listIds): void + { + $message = new SubscriptionConfirmationMessage( + email: $subscriber->getEmail(), + uniqueId: $subscriber->getUniqueId(), + listIds: $listIds, + htmlEmail: $subscriber->hasHtmlEmail(), + ); + + $this->messageBus->dispatch($message); + } + /** * Process subscriber attributes. * @@ -190,6 +240,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( 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/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php index 0e84fdec..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); @@ -49,16 +54,16 @@ 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()); - $options = new SubscriberImportOptions(); + $options = new SubscriberImportOptions(true); $result = $this->subscriberCsvImportManager->importFromCsv($uploadedFile, $options); $subscriberCountAfter = count($this->subscriberRepository->findAll()); 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()); } 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); + } +} 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/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/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)); + } +} 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)); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php index f825f704..1453cfa2 100644 --- a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php +++ b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php @@ -19,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 @@ -49,6 +50,7 @@ protected function setUp(): void attrDefinitionRepository: $this->attributeDefinitionRepositoryMock, entityManager: $entityManager, translator: new Translator('en'), + messageBus: $this->createMock(MessageBusInterface::class), ); }