diff --git a/.github/workflows/i18n-validate.yml b/.github/workflows/i18n-validate.yml
new file mode 100644
index 00000000..4e49efa8
--- /dev/null
+++ b/.github/workflows/i18n-validate.yml
@@ -0,0 +1,69 @@
+name: I18n Validate
+
+on:
+ pull_request:
+ paths:
+ - 'resources/translations/**/*.xlf'
+ - 'composer.lock'
+ - 'composer.json'
+
+jobs:
+ validate-xliff:
+ runs-on: ubuntu-22.04
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.1']
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: imap, zip
+ tools: composer:v2
+ coverage: none
+
+ - name: Cache Composer packages
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.composer/cache/files
+ key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-composer-${{ matrix.php }}-
+
+ - name: Install dependencies (no dev autoloader scripts)
+ run: |
+ set -euo pipefail
+ composer install --no-interaction --no-progress --prefer-dist
+
+ - name: Lint XLIFF with Symfony
+ run: |
+ set -euo pipefail
+ # Adjust the directory to match your repo layout
+ php bin/console lint:xliff resources/translations
+
+ - name: Validate XLIFF XML with xmllint
+ run: |
+ set -euo pipefail
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends libxml2-utils
+ # Adjust root dir; prune vendor; accept spaces/newlines safely
+ find resources/translations -type f -name '*.xlf' -not -path '*/vendor/*' -print0 \
+ | xargs -0 -n1 xmllint --noout
+
+ - name: Symfony translation sanity (extract dry-run)
+ run: |
+ set -euo pipefail
+ # Show what would be created/updated without writing files
+ php bin/console translation:extract en \
+ --format=xlf \
+ --domain=messages \
+ --dump-messages \
+ --no-interaction
+ # Note: omit --force to keep this a dry-run
diff --git a/.weblate b/.weblate
new file mode 100644
index 00000000..5917a8b8
--- /dev/null
+++ b/.weblate
@@ -0,0 +1,23 @@
+# .weblate
+---
+projects:
+ - slug: phplist-core
+ name: phpList core
+ components:
+ - slug: messages
+ name: Messages
+ files:
+ # {language} is Weblate’s placeholder (e.g., fr, de, es)
+ - src: resources/translations/messages.en.xlf
+ template: true
+ # Where localized files live (mirrors Symfony layout)
+ target: resources/translations/messages.{language}.xlf
+ file_format: xliff
+ language_code_style: bcp
+ # Ensure placeholders like %name% are preserved
+ parse_file_headers: true
+ check_flags:
+ - xml-invalid
+ - placeholders
+ - urls
+ - accelerated
diff --git a/composer.json b/composer.json
index be974681..2b391014 100644
--- a/composer.json
+++ b/composer.json
@@ -68,7 +68,9 @@
"symfony/sendgrid-mailer": "^6.4",
"symfony/twig-bundle": "^6.4",
"symfony/messenger": "^6.4",
- "symfony/lock": "^6.4"
+ "symfony/lock": "^6.4",
+ "webklex/php-imap": "^6.2",
+ "ext-imap": "*"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
diff --git a/config/PHPMD/rules.xml b/config/PHPMD/rules.xml
index 2d88410b..a0fbf650 100644
--- a/config/PHPMD/rules.xml
+++ b/config/PHPMD/rules.xml
@@ -51,7 +51,7 @@
-
+
diff --git a/config/PhpCodeSniffer/ruleset.xml b/config/PhpCodeSniffer/ruleset.xml
index d0258304..fdba2edf 100644
--- a/config/PhpCodeSniffer/ruleset.xml
+++ b/config/PhpCodeSniffer/ruleset.xml
@@ -15,7 +15,6 @@
-
@@ -41,9 +40,6 @@
-
-
-
@@ -54,7 +50,6 @@
-
@@ -66,9 +61,6 @@
-
-
-
@@ -110,6 +102,5 @@
-
diff --git a/config/config.yml b/config/config.yml
index e235f999..7de6dca6 100644
--- a/config/config.yml
+++ b/config/config.yml
@@ -10,7 +10,10 @@ parameters:
framework:
#esi: ~
- #translator: { fallbacks: ['%locale%'] }
+ translator:
+ default_path: '%kernel.project_dir%/resources/translations'
+ fallbacks: ['%locale%']
+
secret: '%secret%'
router:
resource: '%kernel.project_dir%/config/routing.yml'
diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist
index 621a8b81..54c649d8 100644
--- a/config/parameters.yml.dist
+++ b/config/parameters.yml.dist
@@ -32,6 +32,32 @@ parameters:
app.password_reset_url: '%%env(PASSWORD_RESET_URL)%%'
env(PASSWORD_RESET_URL): 'https://example.com/reset/'
+ # bounce email settings
+ imap_bounce.email: '%%env(BOUNCE_EMAIL)%%'
+ env(BOUNCE_EMAIL): 'bounce@phplist.com'
+ imap_bounce.password: '%%env(BOUNCE_IMAP_PASS)%%'
+ env(BOUNCE_IMAP_PASS): 'bounce@phplist.com'
+ imap_bounce.host: '%%env(BOUNCE_IMAP_HOST)%%'
+ env(BOUNCE_IMAP_HOST): 'imap.phplist.com'
+ imap_bounce.port: '%%env(BOUNCE_IMAP_PORT)%%'
+ env(BOUNCE_IMAP_PORT): '993'
+ imap_bounce.encryption: '%%env(BOUNCE_IMAP_ENCRYPTION)%%'
+ env(BOUNCE_IMAP_ENCRYPTION): 'ssl'
+ imap_bounce.mailbox: '%%env(BOUNCE_IMAP_MAILBOX)%%'
+ env(BOUNCE_IMAP_MAILBOX): '/var/spool/mail/bounces'
+ imap_bounce.mailbox_name: '%%env(BOUNCE_IMAP_MAILBOX_NAME)%%'
+ env(BOUNCE_IMAP_MAILBOX_NAME): 'INBOX,ONE_MORE'
+ imap_bounce.protocol: '%%env(BOUNCE_IMAP_PROTOCOL)%%'
+ env(BOUNCE_IMAP_PROTOCOL): 'imap'
+ imap_bounce.unsubscribe_threshold: '%%env(BOUNCE_IMAP_UNSUBSCRIBE_THRESHOLD)%%'
+ env(BOUNCE_IMAP_UNSUBSCRIBE_THRESHOLD): '5'
+ imap_bounce.blacklist_threshold: '%%env(BOUNCE_IMAP_BLACKLIST_THRESHOLD)%%'
+ env(BOUNCE_IMAP_BLACKLIST_THRESHOLD): '3'
+ imap_bounce.purge: '%%env(BOUNCE_IMAP_PURGE)%%'
+ env(BOUNCE_IMAP_PURGE): '0'
+ imap_bounce.purge_unprocessed: '%%env(BOUNCE_IMAP_PURGE_UNPROCESSED)%%'
+ env(BOUNCE_IMAP_PURGE_UNPROCESSED): '0'
+
# Messenger configuration for asynchronous processing
app.messenger_transport_dsn: '%%env(MESSENGER_TRANSPORT_DSN)%%'
env(MESSENGER_TRANSPORT_DSN): 'doctrine://default?auto_setup=true'
diff --git a/config/services.yml b/config/services.yml
index b83adce3..47be8241 100644
--- a/config/services.yml
+++ b/config/services.yml
@@ -1,51 +1,51 @@
imports:
- - { resource: 'services/*.yml' }
+ - { resource: 'services/*.yml' }
services:
- _defaults:
- autowire: true
- autoconfigure: true
- public: false
-
- PhpList\Core\Core\ConfigProvider:
- arguments:
- $config: '%app.config%'
-
- PhpList\Core\Core\ApplicationStructure:
- public: true
-
- PhpList\Core\Security\Authentication:
- public: true
-
- PhpList\Core\Security\HashGenerator:
- public: true
-
- PhpList\Core\Routing\ExtraLoader:
- tags: [routing.loader]
-
- PhpList\Core\Domain\Common\Repository\AbstractRepository:
- abstract: true
- autowire: true
- autoconfigure: false
- public: true
- factory: ['@doctrine.orm.entity_manager', getRepository]
-
- # controllers are imported separately to make sure they're public
- # and have a tag that allows actions to type-hint services
- PhpList\Core\EmptyStartPageBundle\Controller\:
- resource: '../src/EmptyStartPageBundle/Controller'
- public: true
- tags: [controller.service_arguments]
-
- doctrine.orm.metadata.annotation_reader:
- alias: doctrine.annotation_reader
-
- doctrine.annotation_reader:
- class: Doctrine\Common\Annotations\AnnotationReader
- autowire: true
-
- doctrine.orm.default_annotation_metadata_driver:
- class: Doctrine\ORM\Mapping\Driver\AnnotationDriver
- arguments:
- - '@annotation_reader'
- - '%kernel.project_dir%/src/Domain/Model/'
+ _defaults:
+ autowire: true
+ autoconfigure: true
+ public: false
+
+ PhpList\Core\Core\ConfigProvider:
+ arguments:
+ $config: '%app.config%'
+
+ PhpList\Core\Core\ApplicationStructure:
+ public: true
+
+ PhpList\Core\Security\Authentication:
+ public: true
+
+ PhpList\Core\Security\HashGenerator:
+ public: true
+
+ PhpList\Core\Routing\ExtraLoader:
+ tags: [routing.loader]
+
+ PhpList\Core\Domain\Common\Repository\AbstractRepository:
+ abstract: true
+ autowire: true
+ autoconfigure: false
+ public: true
+ factory: ['@doctrine.orm.entity_manager', getRepository]
+
+ # controllers are imported separately to make sure they're public
+ # and have a tag that allows actions to type-hint services
+ PhpList\Core\EmptyStartPageBundle\Controller\:
+ resource: '../src/EmptyStartPageBundle/Controller'
+ public: true
+ tags: [controller.service_arguments]
+
+ doctrine.orm.metadata.annotation_reader:
+ alias: doctrine.annotation_reader
+
+ doctrine.annotation_reader:
+ class: Doctrine\Common\Annotations\AnnotationReader
+ autowire: true
+
+ doctrine.orm.default_annotation_metadata_driver:
+ class: Doctrine\ORM\Mapping\Driver\AnnotationDriver
+ arguments:
+ - '@annotation_reader'
+ - '%kernel.project_dir%/src/Domain/Model/'
diff --git a/config/services/builders.yml b/config/services/builders.yml
index c18961d6..10a994a4 100644
--- a/config/services/builders.yml
+++ b/config/services/builders.yml
@@ -20,6 +20,6 @@ services:
autowire: true
autoconfigure: true
- PhpListPhpList\Core\Domain\Messaging\Service\Builder\MessageOptionsBuilder:
+ PhpList\Core\Domain\Messaging\Service\Builder\MessageOptionsBuilder:
autowire: true
autoconfigure: true
diff --git a/config/services/commands.yml b/config/services/commands.yml
index 5cc1a241..d9305748 100644
--- a/config/services/commands.yml
+++ b/config/services/commands.yml
@@ -11,3 +11,7 @@ services:
PhpList\Core\Domain\Identity\Command\:
resource: '../../src/Domain/Identity/Command'
tags: ['console.command']
+
+ PhpList\Core\Domain\Messaging\Command\ProcessBouncesCommand:
+ arguments:
+ $protocolProcessors: !tagged_iterator 'phplist.bounce_protocol_processor'
diff --git a/config/services/managers.yml b/config/services/managers.yml
index 4f57fc11..22dbe066 100644
--- a/config/services/managers.yml
+++ b/config/services/managers.yml
@@ -4,43 +4,43 @@ services:
autoconfigure: true
public: false
- PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager:
+ PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Identity\Service\SessionManager:
+ PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager:
+ PhpList\Core\Domain\Identity\Service\SessionManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager:
+ PhpList\Core\Domain\Identity\Service\AdministratorManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Messaging\Service\MessageManager:
+ PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Messaging\Service\TemplateManager:
+ PhpList\Core\Domain\Identity\Service\AdminAttributeManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Messaging\Service\TemplateImageManager:
+ PhpList\Core\Domain\Identity\Service\PasswordManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Identity\Service\AdministratorManager:
+ PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager:
+ PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Identity\Service\AdminAttributeManager:
+ PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager:
autowire: true
autoconfigure: true
@@ -56,14 +56,42 @@ services:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager:
+ PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager:
autowire: true
autoconfigure: true
- PhpList\Core\Domain\Identity\Service\PasswordManager:
+ PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Messaging\Service\Manager\MessageManager:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Messaging\Service\Manager\BounceRegexManager:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Messaging\Service\Manager\BounceManager:
autowire: true
autoconfigure: true
PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager:
autowire: true
autoconfigure: true
+
+ PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Messaging\Service\Manager\SendProcessManager:
+ autowire: true
+ autoconfigure: true
diff --git a/config/services/processor.yml b/config/services/processor.yml
new file mode 100644
index 00000000..acbd11c0
--- /dev/null
+++ b/config/services/processor.yml
@@ -0,0 +1,21 @@
+services:
+ _defaults:
+ autowire: true
+ autoconfigure: true
+ public: false
+
+ PhpList\Core\Domain\Messaging\Service\Processor\PopBounceProcessor:
+ arguments:
+ $host: '%imap_bounce.host%'
+ $port: '%imap_bounce.port%'
+ $mailboxNames: '%imap_bounce.mailbox_name%'
+ tags: ['phplist.bounce_protocol_processor']
+
+ PhpList\Core\Domain\Messaging\Service\Processor\MboxBounceProcessor:
+ tags: ['phplist.bounce_protocol_processor']
+
+ PhpList\Core\Domain\Messaging\Service\Processor\AdvancedBounceRulesProcessor: ~
+
+ PhpList\Core\Domain\Messaging\Service\Processor\UnidentifiedBounceReprocessor: ~
+
+ PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor: ~
diff --git a/config/services/repositories.yml b/config/services/repositories.yml
index eca3a31c..1289bea7 100644
--- a/config/services/repositories.yml
+++ b/config/services/repositories.yml
@@ -1,112 +1,157 @@
services:
- 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\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\Configuration\Repository\ConfigRepository:
- parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
- arguments:
- - PhpList\Core\Domain\Configuration\Model\Config
-
- 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\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\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:
+ - PhpList\Core\Domain\Messaging\Model\SendProcess
diff --git a/config/services/services.yml b/config/services/services.yml
index 7b9f921c..f1b68e74 100644
--- a/config/services/services.yml
+++ b/config/services/services.yml
@@ -1,36 +1,114 @@
services:
- PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter:
- autowire: true
- autoconfigure: true
- public: true
-
- PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter:
- autowire: true
- autoconfigure: true
- public: true
-
- PhpList\Core\Domain\Messaging\Service\EmailService:
- autowire: true
- autoconfigure: true
- arguments:
- $defaultFromEmail: '%app.mailer_from%'
-
- PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService:
- autowire: true
- autoconfigure: true
- public: true
-
- PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator:
- autowire: true
- autoconfigure: true
- public: true
-
- PhpList\Core\Domain\Analytics\Service\LinkTrackService:
- autowire: true
- autoconfigure: true
- public: true
-
- PhpList\Core\Domain\Messaging\Service\CampaignProcessor:
- autowire: true
- autoconfigure: true
- public: true
+ PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter:
+ autowire: true
+ autoconfigure: true
+ public: true
+
+ PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter:
+ autowire: true
+ autoconfigure: true
+ public: true
+
+ PhpList\Core\Domain\Messaging\Service\EmailService:
+ autowire: true
+ autoconfigure: true
+ arguments:
+ $defaultFromEmail: '%app.mailer_from%'
+ $bounceEmail: '%imap_bounce.email%'
+
+ PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService:
+ autowire: true
+ autoconfigure: true
+ public: true
+
+ PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator:
+ autowire: true
+ autoconfigure: true
+ public: true
+
+ PhpList\Core\Domain\Analytics\Service\LinkTrackService:
+ autowire: true
+ autoconfigure: true
+ public: true
+
+ PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor:
+ autowire: true
+ autoconfigure: true
+ public: true
+
+ PhpList\Core\Domain\Common\ClientIpResolver:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Common\SystemInfoCollector:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler:
+ autowire: true
+ autoconfigure: true
+ arguments:
+ $unsubscribeThreshold: '%imap_bounce.unsubscribe_threshold%'
+ $blacklistThreshold: '%imap_bounce.blacklist_threshold%'
+
+ Webklex\PHPIMAP\ClientManager: ~
+
+ PhpList\Core\Domain\Messaging\Service\WebklexImapClientFactory:
+ autowire: true
+ autoconfigure: true
+ arguments:
+ $mailbox: '%imap_bounce.mailbox%'# e.g. "{imap.example.com:993/imap/ssl}INBOX" or "/var/mail/user"
+ $host: '%imap_bounce.host%'
+ $port: '%imap_bounce.port%'
+ $encryption: '%imap_bounce.encryption%'
+ $username: '%imap_bounce.email%'
+ $password: '%imap_bounce.password%'
+ $protocol: '%imap_bounce.protocol%'
+
+ PhpList\Core\Domain\Common\Mail\NativeImapMailReader:
+ arguments:
+ $username: '%imap_bounce.email%'
+ $password: '%imap_bounce.password%'
+
+ PhpList\Core\Domain\Messaging\Service\NativeBounceProcessingService:
+ autowire: true
+ autoconfigure: true
+ arguments:
+ $purgeProcessed: '%imap_bounce.purge%'
+ $purgeUnprocessed: '%imap_bounce.purge_unprocessed%'
+
+ PhpList\Core\Domain\Messaging\Service\WebklexBounceProcessingService:
+ autowire: true
+ autoconfigure: true
+ arguments:
+ $purgeProcessed: '%imap_bounce.purge%'
+ $purgeUnprocessed: '%imap_bounce.purge_unprocessed%'
+
+ PhpList\Core\Domain\Messaging\Service\LockService:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService:
+ autowire: true
+ autoconfigure: true
+
+ PhpList\Core\Domain\Messaging\Service\MessageParser:
+ autowire: true
+ autoconfigure: true
+
+ _instanceof:
+ PhpList\Core\Domain\Messaging\Service\Handler\BounceActionHandlerInterface:
+ tags:
+ - { name: 'phplist.bounce_action_handler' }
+
+ PhpList\Core\Domain\Messaging\Service\Handler\:
+ 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\Identity\Service\PermissionChecker:
+ autowire: true
+ autoconfigure: true
+ public: true
diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf
new file mode 100644
index 00000000..7e176e3e
--- /dev/null
+++ b/resources/translations/messages.en.xlf
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+ 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
+
+
+
+
+ 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.
+
+
+
+
+
diff --git a/resources/translations/messages.hy.xlf b/resources/translations/messages.hy.xlf
new file mode 100644
index 00000000..a7e43314
--- /dev/null
+++ b/resources/translations/messages.hy.xlf
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+ Not authorized
+
+
+ Failed admin login attempt for '%login%'
+
+
+ Login attempt for disabled admin '%login%'
+
+
+
+ Administrator not found
+
+
+
+ Subscriber list not found.
+
+
+ Subscriber does not exists.
+
+
+ Subscription not found for this subscriber and list.
+
+
+
+
diff --git a/resources/translations/messages.ru.xlf b/resources/translations/messages.ru.xlf
new file mode 100644
index 00000000..a4287561
--- /dev/null
+++ b/resources/translations/messages.ru.xlf
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+ Not authorized
+
+
+ Failed admin login attempt for '%login%'
+ Неудачная попытка авторизоваться как администратор '%login%
+
+
+ Login attempt for disabled admin '%login%'
+ Администратор «%login%» отключён, попытка входа не удалась
+
+
+
+ Administrator not found
+ Администратор не найден
+
+
+
+ Subscriber list not found.
+ Список подписчиков не найден.
+
+
+ Subscriber does not exists.
+ Подписчик не существует.
+
+
+ Subscription not found for this subscriber and list.
+
+
+
+
diff --git a/src/Core/ApplicationKernel.php b/src/Core/ApplicationKernel.php
index 97249b45..8f43e62b 100644
--- a/src/Core/ApplicationKernel.php
+++ b/src/Core/ApplicationKernel.php
@@ -106,6 +106,7 @@ protected function build(ContainerBuilder $container): void
{
$container->setParameter('kernel.application_dir', $this->getApplicationDir());
$container->addCompilerPass(new DoctrineMappingPass());
+ $container->addCompilerPass(new BounceProcessorPass());
}
/**
diff --git a/src/Core/BounceProcessorPass.php b/src/Core/BounceProcessorPass.php
new file mode 100644
index 00000000..2ab5c9c5
--- /dev/null
+++ b/src/Core/BounceProcessorPass.php
@@ -0,0 +1,28 @@
+hasDefinition($native) || !$container->hasDefinition($webklex)) {
+ return;
+ }
+
+ $aliasTo = extension_loaded('imap') ? $native : $webklex;
+
+ $container->setAlias(BounceProcessingServiceInterface::class, $aliasTo)->setPublic(false);
+ }
+}
diff --git a/src/Domain/Common/ClientIpResolver.php b/src/Domain/Common/ClientIpResolver.php
new file mode 100644
index 00000000..65cbbb6c
--- /dev/null
+++ b/src/Domain/Common/ClientIpResolver.php
@@ -0,0 +1,28 @@
+requestStack = $requestStack;
+ }
+
+ public function resolve(): string
+ {
+ $request = $this->requestStack->getCurrentRequest();
+
+ if ($request !== null) {
+ return $request->getClientIp() ?? '';
+ }
+
+ return (gethostname() ?: 'localhost') . ':' . getmypid();
+ }
+}
diff --git a/src/Domain/Common/I18n/Messages.php b/src/Domain/Common/I18n/Messages.php
new file mode 100644
index 00000000..f9e8822f
--- /dev/null
+++ b/src/Domain/Common/I18n/Messages.php
@@ -0,0 +1,29 @@
+username = $username;
+ $this->password = $password;
+ }
+
+ public function open(string $mailbox, int $options = 0): Connection
+ {
+ $link = imap_open($mailbox, $this->username, $this->password, $options);
+
+ if ($link === false) {
+ throw new RuntimeException('Cannot open mailbox: '.(imap_last_error() ?: 'unknown error'));
+ }
+
+ return $link;
+ }
+
+ public function numMessages(Connection $link): int
+ {
+ return imap_num_msg($link);
+ }
+
+ public function fetchHeader(Connection $link, int $msgNo): string
+ {
+ return imap_fetchheader($link, $msgNo) ?: '';
+ }
+
+ public function headerDate(Connection $link, int $msgNo): DateTimeImmutable
+ {
+ $info = imap_headerinfo($link, $msgNo);
+ $date = $info->date ?? null;
+
+ return $date ? new DateTimeImmutable($date) : new DateTimeImmutable();
+ }
+
+ public function body(Connection $link, int $msgNo): string
+ {
+ return imap_body($link, $msgNo) ?: '';
+ }
+
+ public function delete(Connection $link, int $msgNo): void
+ {
+ imap_delete($link, (string)$msgNo);
+ }
+
+ public function close(Connection $link, bool $expunge): void
+ {
+ $expunge ? imap_close($link, CL_EXPUNGE) : imap_close($link);
+ }
+}
diff --git a/src/Domain/Common/Model/Interfaces/OwnableInterface.php b/src/Domain/Common/Model/Interfaces/OwnableInterface.php
new file mode 100644
index 00000000..16e54e40
--- /dev/null
+++ b/src/Domain/Common/Model/Interfaces/OwnableInterface.php
@@ -0,0 +1,12 @@
+ use defaults)
+ */
+ public function __construct(
+ RequestStack $requestStack,
+ array $configuredKeys = []
+ ) {
+ $this->requestStack = $requestStack;
+ $this->configuredKeys = $configuredKeys;
+ }
+
+ /**
+ * Return key=>value pairs (already sanitized for safe logging/HTML display).
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ * @return array
+ */
+ public function collect(): array
+ {
+ $request = $this->requestStack->getCurrentRequest() ?? Request::createFromGlobals();
+ $data = [];
+ $headers = $request->headers;
+
+ $data['HTTP_USER_AGENT'] = (string) $headers->get('User-Agent', '');
+ $data['HTTP_REFERER'] = (string) $headers->get('Referer', '');
+ $data['HTTP_X_FORWARDED_FOR'] = (string) $headers->get('X-Forwarded-For', '');
+ $data['REQUEST_URI'] = $request->getRequestUri();
+ $data['REMOTE_ADDR'] = $request->getClientIp() ?? '';
+
+ $keys = $this->configuredKeys ?: $this->defaultKeys;
+
+ $out = [];
+ foreach ($keys as $key) {
+ if (!array_key_exists($key, $data)) {
+ continue;
+ }
+ $val = $data[$key];
+
+ $safeKey = strip_tags($key);
+ $safeVal = htmlspecialchars((string) $val, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+
+ $out[$safeKey] = $safeVal;
+ }
+
+ return $out;
+ }
+
+ /**
+ * Convenience to match the legacy multi-line string format.
+ */
+ public function collectAsString(): string
+ {
+ $pairs = $this->collect();
+ if (!$pairs) {
+ return '';
+ }
+ $lines = [];
+ foreach ($pairs as $k => $v) {
+ $lines[] = sprintf('%s = %s', $k, $v);
+ }
+ return "\n" . implode("\n", $lines);
+ }
+}
diff --git a/src/Domain/Configuration/Model/Filter/EventLogFilter.php b/src/Domain/Configuration/Model/Filter/EventLogFilter.php
new file mode 100644
index 00000000..12a824ca
--- /dev/null
+++ b/src/Domain/Configuration/Model/Filter/EventLogFilter.php
@@ -0,0 +1,33 @@
+page;
+ }
+
+ public function getDateFrom(): ?DateTimeInterface
+ {
+ return $this->dateFrom;
+ }
+
+ public function getDateTo(): ?DateTimeInterface
+ {
+ return $this->dateTo;
+ }
+}
diff --git a/src/Domain/Configuration/Model/I18n.php b/src/Domain/Configuration/Model/I18n.php
index bffed897..b8eefd63 100644
--- a/src/Domain/Configuration/Model/I18n.php
+++ b/src/Domain/Configuration/Model/I18n.php
@@ -8,6 +8,11 @@
use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
use PhpList\Core\Domain\Configuration\Repository\I18nRepository;
+/**
+ * @deprecated
+ *
+ * Symfony\Contracts\Translation will be used instead.
+ */
#[ORM\Entity(repositoryClass: I18nRepository::class)]
#[ORM\Table(name: 'phplist_i18n')]
#[ORM\UniqueConstraint(name: 'lanorigunq', columns: ['lan', 'original'])]
diff --git a/src/Domain/Configuration/Repository/EventLogRepository.php b/src/Domain/Configuration/Repository/EventLogRepository.php
index 7caf5462..47640007 100644
--- a/src/Domain/Configuration/Repository/EventLogRepository.php
+++ b/src/Domain/Configuration/Repository/EventLogRepository.php
@@ -4,11 +4,48 @@
namespace PhpList\Core\Domain\Configuration\Repository;
+use InvalidArgumentException;
+use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface;
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Configuration\Model\Filter\EventLogFilter;
+use PhpList\Core\Domain\Configuration\Model\EventLog;
class EventLogRepository extends AbstractRepository implements PaginatableRepositoryInterface
{
use CursorPaginationTrait;
+
+ /**
+ * @return EventLog[]
+ * @throws InvalidArgumentException
+ */
+ public function getFilteredAfterId(int $lastId, int $limit, ?FilterRequestInterface $filter = null): array
+ {
+ $queryBuilder = $this->createQueryBuilder('e')
+ ->andWhere('e.id > :lastId')
+ ->setParameter('lastId', $lastId)
+ ->orderBy('e.id', 'ASC')
+ ->setMaxResults($limit);
+
+ if ($filter === null) {
+ return $queryBuilder->getQuery()->getResult();
+ }
+
+ if (!$filter instanceof EventLogFilter) {
+ throw new InvalidArgumentException('Expected EventLogFilter.');
+ }
+
+ if ($filter->getPage() !== null) {
+ $queryBuilder->andWhere('e.page = :page')->setParameter('page', $filter->getPage());
+ }
+ if ($filter->getDateFrom() !== null) {
+ $queryBuilder->andWhere('e.entered >= :dateFrom')->setParameter('dateFrom', $filter->getDateFrom());
+ }
+ if ($filter->getDateTo() !== null) {
+ $queryBuilder->andWhere('e.entered <= :dateTo')->setParameter('dateTo', $filter->getDateTo());
+ }
+
+ return $queryBuilder->getQuery()->getResult();
+ }
}
diff --git a/src/Domain/Configuration/Repository/I18nRepository.php b/src/Domain/Configuration/Repository/I18nRepository.php
index f4465103..33fa599a 100644
--- a/src/Domain/Configuration/Repository/I18nRepository.php
+++ b/src/Domain/Configuration/Repository/I18nRepository.php
@@ -6,6 +6,7 @@
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
+/** @deprecated */
class I18nRepository extends AbstractRepository
{
}
diff --git a/src/Domain/Configuration/Service/Manager/EventLogManager.php b/src/Domain/Configuration/Service/Manager/EventLogManager.php
new file mode 100644
index 00000000..374db7ed
--- /dev/null
+++ b/src/Domain/Configuration/Service/Manager/EventLogManager.php
@@ -0,0 +1,54 @@
+repository = $repository;
+ }
+
+ public function log(string $page, string $entry): EventLog
+ {
+ $log = (new EventLog())
+ ->setEntered(new DateTimeImmutable())
+ ->setPage($page)
+ ->setEntry($entry);
+
+ $this->repository->save($log);
+
+ return $log;
+ }
+
+ /**
+ * Get event logs with optional filters (page and date range) and cursor pagination.
+ *
+ * @return EventLog[]
+ */
+ public function get(
+ int $lastId = 0,
+ int $limit = 50,
+ ?string $page = null,
+ ?DateTimeInterface $dateFrom = null,
+ ?DateTimeInterface $dateTo = null
+ ): array {
+ $filter = new EventLogFilter($page, $dateFrom, $dateTo);
+ return $this->repository->getFilteredAfterId($lastId, $limit, $filter);
+ }
+
+ public function delete(EventLog $log): void
+ {
+ $this->repository->remove($log);
+ }
+}
diff --git a/src/Domain/Identity/Model/Administrator.php b/src/Domain/Identity/Model/Administrator.php
index 34cfc9e8..c22b4b9f 100644
--- a/src/Domain/Identity/Model/Administrator.php
+++ b/src/Domain/Identity/Model/Administrator.php
@@ -11,6 +11,7 @@
use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
use PhpList\Core\Domain\Common\Model\Interfaces\Identity;
use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate;
+use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface;
use PhpList\Core\Domain\Identity\Repository\AdministratorRepository;
/**
@@ -221,4 +222,13 @@ public function getModifiedBy(): ?string
{
return $this->modifiedBy;
}
+
+ public function owns(OwnableInterface $resource): bool
+ {
+ if ($this->getId() === null) {
+ return false;
+ }
+
+ return $resource->getOwner()->getId() === $this->getId();
+ }
}
diff --git a/src/Domain/Identity/Repository/UserBlacklistDataRepository.php b/src/Domain/Identity/Repository/UserBlacklistDataRepository.php
deleted file mode 100644
index 0f06722b..00000000
--- a/src/Domain/Identity/Repository/UserBlacklistDataRepository.php
+++ /dev/null
@@ -1,11 +0,0 @@
-passwordRequestRepository = $passwordRequestRepository;
$this->administratorRepository = $administratorRepository;
$this->hashGenerator = $hashGenerator;
$this->messageBus = $messageBus;
+ $this->translator = $translator;
}
/**
@@ -47,7 +52,8 @@ public function generatePasswordResetToken(string $email): string
{
$administrator = $this->administratorRepository->findOneBy(['email' => $email]);
if ($administrator === null) {
- throw new NotFoundHttpException('Administrator not found', null, 1500567100);
+ $message = $this->translator->trans(Messages::IDENTITY_ADMIN_NOT_FOUND);
+ throw new NotFoundHttpException($message, null, 1500567100);
}
$existingRequests = $this->passwordRequestRepository->findByAdmin($administrator);
diff --git a/src/Domain/Identity/Service/PermissionChecker.php b/src/Domain/Identity/Service/PermissionChecker.php
new file mode 100644
index 00000000..8fc241b7
--- /dev/null
+++ b/src/Domain/Identity/Service/PermissionChecker.php
@@ -0,0 +1,89 @@
+ PrivilegeFlag::Subscribers,
+ SubscriberList::class => PrivilegeFlag::Subscribers,
+ Message::class => PrivilegeFlag::Campaigns,
+ ];
+
+ private const OWNERSHIP_MAP = [
+ Subscriber::class => SubscriberList::class,
+ Message::class => SubscriberList::class
+ ];
+
+ public function canManage(Administrator $actor, DomainModel $resource): bool
+ {
+ if ($actor->isSuperUser()) {
+ return true;
+ }
+
+ $required = $this->resolveRequiredPrivilege($resource);
+ if ($required !== null && !$actor->getPrivileges()->has($required)) {
+ return false;
+ }
+
+ if ($resource instanceof OwnableInterface) {
+ return $actor->owns($resource);
+ }
+
+ $notRestricted = true;
+ foreach (self::OWNERSHIP_MAP as $resourceClass => $relatedClass) {
+ if ($resource instanceof $resourceClass) {
+ $related = $this->resolveRelatedEntity($resource, $relatedClass);
+ $notRestricted = $this->checkRelatedResources($related, $actor);
+ }
+ }
+
+ return $notRestricted;
+ }
+
+ private function resolveRequiredPrivilege(DomainModel $resource): ?PrivilegeFlag
+ {
+ foreach (self::REQUIRED_PRIVILEGE_MAP as $class => $flag) {
+ if ($resource instanceof $class) {
+ return $flag;
+ }
+ }
+
+ return null;
+ }
+
+ /** @return OwnableInterface[] */
+ private function resolveRelatedEntity(DomainModel $resource, string $relatedClass): array
+ {
+ if ($resource instanceof Subscriber && $relatedClass === SubscriberList::class) {
+ return $resource->getSubscribedLists()->toArray();
+ }
+
+ if ($resource instanceof Message && $relatedClass === SubscriberList::class) {
+ return $resource->getListMessages()->map(fn($lm) => $lm->getSubscriberList())->toArray();
+ }
+
+ return [];
+ }
+
+ private function checkRelatedResources(array $related, Administrator $actor): bool
+ {
+ foreach ($related as $relatedResource) {
+ if ($actor->owns($relatedResource)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Domain/Identity/Service/SessionManager.php b/src/Domain/Identity/Service/SessionManager.php
index 52daafa3..82f52af1 100644
--- a/src/Domain/Identity/Service/SessionManager.php
+++ b/src/Domain/Identity/Service/SessionManager.php
@@ -4,6 +4,9 @@
namespace PhpList\Core\Domain\Identity\Service;
+use PhpList\Core\Domain\Common\I18n\Messages;
+use Symfony\Contracts\Translation\TranslatorInterface;
+use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager;
use PhpList\Core\Domain\Identity\Model\AdministratorToken;
use PhpList\Core\Domain\Identity\Repository\AdministratorRepository;
use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository;
@@ -13,24 +16,36 @@ class SessionManager
{
private AdministratorTokenRepository $tokenRepository;
private AdministratorRepository $administratorRepository;
+ private EventLogManager $eventLogManager;
+ private TranslatorInterface $translator;
public function __construct(
AdministratorTokenRepository $tokenRepository,
- AdministratorRepository $administratorRepository
+ AdministratorRepository $administratorRepository,
+ EventLogManager $eventLogManager,
+ TranslatorInterface $translator
) {
$this->tokenRepository = $tokenRepository;
$this->administratorRepository = $administratorRepository;
+ $this->eventLogManager = $eventLogManager;
+ $this->translator = $translator;
}
public function createSession(string $loginName, string $password): AdministratorToken
{
$administrator = $this->administratorRepository->findOneByLoginCredentials($loginName, $password);
if ($administrator === null) {
- throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567098);
+ $entry = $this->translator->trans(Messages::AUTH_LOGIN_FAILED, ['login' => $loginName]);
+ $this->eventLogManager->log('login', $entry);
+ $message = $this->translator->trans(Messages::AUTH_NOT_AUTHORIZED);
+ throw new UnauthorizedHttpException('', $message, null, 1500567098);
}
if ($administrator->isDisabled()) {
- throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567099);
+ $entry = $this->translator->trans(Messages::AUTH_LOGIN_DISABLED, ['login' => $loginName]);
+ $this->eventLogManager->log('login', $entry);
+ $message = $this->translator->trans(Messages::AUTH_NOT_AUTHORIZED);
+ throw new UnauthorizedHttpException('', $message, null, 1500567099);
}
$token = new AdministratorToken();
diff --git a/src/Domain/Messaging/Command/ProcessBouncesCommand.php b/src/Domain/Messaging/Command/ProcessBouncesCommand.php
new file mode 100644
index 00000000..f1e3b403
--- /dev/null
+++ b/src/Domain/Messaging/Command/ProcessBouncesCommand.php
@@ -0,0 +1,114 @@
+addOption('protocol', null, InputOption::VALUE_REQUIRED, 'Mailbox protocol: pop or mbox', 'pop')
+ ->addOption(
+ 'purge-unprocessed',
+ null,
+ InputOption::VALUE_NONE,
+ 'Delete/remove unprocessed messages from mailbox'
+ )
+ ->addOption('rules-batch-size', null, InputOption::VALUE_OPTIONAL, 'Advanced rules batch size', '1000')
+ ->addOption('test', 't', InputOption::VALUE_NONE, 'Test mode: do not delete from mailbox')
+ ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force run: kill other processes if locked');
+ }
+
+ public function __construct(
+ private readonly LockService $lockService,
+ private readonly LoggerInterface $logger,
+ /** @var iterable */
+ private readonly iterable $protocolProcessors,
+ private readonly AdvancedBounceRulesProcessor $advancedRulesProcessor,
+ private readonly UnidentifiedBounceReprocessor $unidentifiedReprocessor,
+ private readonly ConsecutiveBounceHandler $consecutiveBounceHandler,
+ ) {
+ parent::__construct();
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $inputOutput = new SymfonyStyle($input, $output);
+
+ if (!function_exists('imap_open')) {
+ $inputOutput->note(self::IMAP_NOT_AVAILABLE);
+ }
+
+ $force = (bool)$input->getOption('force');
+ $lock = $this->lockService->acquirePageLock('bounce_processor', $force);
+
+ if (($lock ?? 0) === 0) {
+ $inputOutput->warning($force ? self::FORCE_LOCK_FAILED : self::ALREADY_LOCKED);
+
+ return $force ? Command::FAILURE : Command::SUCCESS;
+ }
+
+ try {
+ $inputOutput->title('Processing bounces');
+ $protocol = (string)$input->getOption('protocol');
+
+ $downloadReport = '';
+
+ $processor = $this->findProcessorFor($protocol);
+ if ($processor === null) {
+ $inputOutput->error('Unsupported protocol: '.$protocol);
+
+ return Command::FAILURE;
+ }
+
+ $downloadReport .= $processor->process($input, $inputOutput);
+ $this->unidentifiedReprocessor->process($inputOutput);
+ $this->advancedRulesProcessor->process($inputOutput, (int)$input->getOption('rules-batch-size'));
+ $this->consecutiveBounceHandler->handle($inputOutput);
+
+ $this->logger->info('Bounce processing completed', ['downloadReport' => $downloadReport]);
+ $inputOutput->success('Bounce processing completed.');
+
+ return Command::SUCCESS;
+ } catch (Exception $e) {
+ $this->logger->error('Bounce processing failed', ['exception' => $e]);
+ $inputOutput->error('Error: '.$e->getMessage());
+
+ return Command::FAILURE;
+ } finally {
+ $this->lockService->release($lock);
+ }
+ }
+
+ private function findProcessorFor(string $protocol): ?BounceProtocolProcessor
+ {
+ foreach ($this->protocolProcessors as $processor) {
+ if ($processor->getProtocol() === $protocol) {
+ return $processor;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php
index 43937f91..820d403d 100644
--- a/src/Domain/Messaging/Command/ProcessQueueCommand.php
+++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php
@@ -4,14 +4,14 @@
namespace PhpList\Core\Domain\Messaging\Command;
-use PhpList\Core\Domain\Messaging\Service\CampaignProcessor;
+use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
+use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator;
+use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor;
+use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
-use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator;
use Symfony\Component\Lock\LockFactory;
-use Symfony\Component\Console\Attribute\AsCommand;
use Throwable;
#[AsCommand(
diff --git a/src/Domain/Messaging/Model/BounceRegex.php b/src/Domain/Messaging/Model/BounceRegex.php
index 510aaad8..0401d26b 100644
--- a/src/Domain/Messaging/Model/BounceRegex.php
+++ b/src/Domain/Messaging/Model/BounceRegex.php
@@ -31,8 +31,8 @@ class BounceRegex implements DomainModel, Identity
#[ORM\Column(name: 'listorder', type: 'integer', nullable: true, options: ['default' => 0])]
private ?int $listOrder = 0;
- #[ORM\Column(type: 'integer', nullable: true)]
- private ?int $admin;
+ #[ORM\Column(name: 'admin', type: 'integer', nullable: true)]
+ private ?int $adminId;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $comment;
@@ -48,7 +48,7 @@ public function __construct(
?string $regexHash = null,
?string $action = null,
?int $listOrder = 0,
- ?int $admin = null,
+ ?int $adminId = null,
?string $comment = null,
?string $status = null,
?int $count = 0
@@ -57,7 +57,7 @@ public function __construct(
$this->regexHash = $regexHash;
$this->action = $action;
$this->listOrder = $listOrder;
- $this->admin = $admin;
+ $this->adminId = $adminId;
$this->comment = $comment;
$this->status = $status;
$this->count = $count;
@@ -112,14 +112,14 @@ public function setListOrder(?int $listOrder): self
return $this;
}
- public function getAdmin(): ?int
+ public function getAdminId(): ?int
{
- return $this->admin;
+ return $this->adminId;
}
- public function setAdmin(?int $admin): self
+ public function setAdminId(?int $adminId): self
{
- $this->admin = $admin;
+ $this->adminId = $adminId;
return $this;
}
diff --git a/src/Domain/Messaging/Model/BounceRegexBounce.php b/src/Domain/Messaging/Model/BounceRegexBounce.php
index 9dbd3168..e815cd1f 100644
--- a/src/Domain/Messaging/Model/BounceRegexBounce.php
+++ b/src/Domain/Messaging/Model/BounceRegexBounce.php
@@ -13,38 +13,38 @@
class BounceRegexBounce implements DomainModel
{
#[ORM\Id]
- #[ORM\Column(type: 'integer')]
- private int $regex;
+ #[ORM\Column(name: 'regex', type: 'integer')]
+ private int $regexId;
#[ORM\Id]
- #[ORM\Column(type: 'integer')]
- private int $bounce;
+ #[ORM\Column(name: 'bounce', type: 'integer')]
+ private int $bounceId;
- public function __construct(int $regex, int $bounce)
+ public function __construct(int $regexId, int $bounceId)
{
- $this->regex = $regex;
- $this->bounce = $bounce;
+ $this->regexId = $regexId;
+ $this->bounceId = $bounceId;
}
- public function getRegex(): int
+ public function getRegexId(): int
{
- return $this->regex;
+ return $this->regexId;
}
- public function setRegex(int $regex): self
+ public function setRegexId(int $regexId): self
{
- $this->regex = $regex;
+ $this->regexId = $regexId;
return $this;
}
- public function getBounce(): int
+ public function getBounceId(): int
{
- return $this->bounce;
+ return $this->bounceId;
}
- public function setBounce(int $bounce): self
+ public function setBounceId(int $bounceId): self
{
- $this->bounce = $bounce;
+ $this->bounceId = $bounceId;
return $this;
}
}
diff --git a/src/Domain/Messaging/Model/Message.php b/src/Domain/Messaging/Model/Message.php
index fbbfec8a..5064c4f1 100644
--- a/src/Domain/Messaging/Model/Message.php
+++ b/src/Domain/Messaging/Model/Message.php
@@ -11,6 +11,7 @@
use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
use PhpList\Core\Domain\Common\Model\Interfaces\Identity;
use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate;
+use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface;
use PhpList\Core\Domain\Identity\Model\Administrator;
use PhpList\Core\Domain\Messaging\Model\Message\MessageContent;
use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat;
@@ -23,7 +24,7 @@
#[ORM\Table(name: 'phplist_message')]
#[ORM\Index(name: 'uuididx', columns: ['uuid'])]
#[ORM\HasLifecycleCallbacks]
-class Message implements DomainModel, Identity, ModificationDate
+class Message implements DomainModel, Identity, ModificationDate, OwnableInterface
{
#[ORM\Id]
#[ORM\Column(type: 'integer')]
diff --git a/src/Domain/Messaging/Model/UserMessageBounce.php b/src/Domain/Messaging/Model/UserMessageBounce.php
index ccb05597..5da0d139 100644
--- a/src/Domain/Messaging/Model/UserMessageBounce.php
+++ b/src/Domain/Messaging/Model/UserMessageBounce.php
@@ -31,15 +31,15 @@ class UserMessageBounce implements DomainModel, Identity
private int $messageId;
#[ORM\Column(name: 'bounce', type: 'integer')]
- private int $bounce;
+ private int $bounceId;
#[ORM\Column(name: 'time', type: 'datetime', options: ['default' => 'CURRENT_TIMESTAMP'])]
private DateTime $createdAt;
- public function __construct(int $bounce)
+ public function __construct(int $bounceId, DateTime $createdAt)
{
- $this->bounce = $bounce;
- $this->createdAt = new DateTime();
+ $this->bounceId = $bounceId;
+ $this->createdAt = $createdAt;
}
public function getId(): ?int
@@ -57,9 +57,9 @@ public function getMessageId(): int
return $this->messageId;
}
- public function getBounce(): int
+ public function getBounceId(): int
{
- return $this->bounce;
+ return $this->bounceId;
}
public function getCreatedAt(): DateTime
@@ -79,9 +79,9 @@ public function setMessageId(int $messageId): self
return $this;
}
- public function setBounce(int $bounce): self
+ public function setBounceId(int $bounceId): self
{
- $this->bounce = $bounce;
+ $this->bounceId = $bounceId;
return $this;
}
}
diff --git a/src/Domain/Messaging/Repository/BounceRegexRepository.php b/src/Domain/Messaging/Repository/BounceRegexRepository.php
index a08f65c0..9aecde78 100644
--- a/src/Domain/Messaging/Repository/BounceRegexRepository.php
+++ b/src/Domain/Messaging/Repository/BounceRegexRepository.php
@@ -7,8 +7,26 @@
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Messaging\Model\BounceRegex;
class BounceRegexRepository extends AbstractRepository implements PaginatableRepositoryInterface
{
use CursorPaginationTrait;
+
+ public function findOneByRegexHash(string $regexHash): ?BounceRegex
+ {
+ return $this->findOneBy(['regexHash' => $regexHash]);
+ }
+
+ /** @return BounceRegex[] */
+ public function fetchAllOrdered(): array
+ {
+ return $this->findBy([], ['listOrder' => 'ASC']);
+ }
+
+ /** @return BounceRegex[] */
+ public function fetchActiveOrdered(): array
+ {
+ return $this->findBy(['active' => true], ['listOrder' => 'ASC']);
+ }
}
diff --git a/src/Domain/Messaging/Repository/BounceRepository.php b/src/Domain/Messaging/Repository/BounceRepository.php
index fa691a28..410f5da1 100644
--- a/src/Domain/Messaging/Repository/BounceRepository.php
+++ b/src/Domain/Messaging/Repository/BounceRepository.php
@@ -7,8 +7,15 @@
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Messaging\Model\Bounce;
class BounceRepository extends AbstractRepository implements PaginatableRepositoryInterface
{
use CursorPaginationTrait;
+
+ /** @return Bounce[] */
+ public function findByStatus(string $status): array
+ {
+ return $this->findBy(['status' => $status]);
+ }
}
diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php
index cf802300..3da7ebf3 100644
--- a/src/Domain/Messaging/Repository/MessageRepository.php
+++ b/src/Domain/Messaging/Repository/MessageRepository.php
@@ -63,4 +63,15 @@ public function getMessagesByList(SubscriberList $list): array
->getQuery()
->getResult();
}
+
+ public function incrementBounceCount(int $messageId): void
+ {
+ $this->createQueryBuilder('m')
+ ->update()
+ ->set('m.bounceCount', 'm.bounceCount + 1')
+ ->where('m.id = :messageId')
+ ->setParameter('messageId', $messageId)
+ ->getQuery()
+ ->execute();
+ }
}
diff --git a/src/Domain/Messaging/Repository/SendProcessRepository.php b/src/Domain/Messaging/Repository/SendProcessRepository.php
index 496adf9b..2a234a5a 100644
--- a/src/Domain/Messaging/Repository/SendProcessRepository.php
+++ b/src/Domain/Messaging/Repository/SendProcessRepository.php
@@ -7,8 +7,75 @@
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Messaging\Model\SendProcess;
class SendProcessRepository extends AbstractRepository implements PaginatableRepositoryInterface
{
use CursorPaginationTrait;
+
+ public function deleteByPage(string $page): void
+ {
+ $this->createQueryBuilder('sp')
+ ->delete()
+ ->where('sp.page = :page')
+ ->setParameter('page', $page)
+ ->getQuery()
+ ->execute();
+ }
+
+ public function countAliveByPage(string $page): int
+ {
+ return (int)$this->createQueryBuilder('sp')
+ ->select('COUNT(sp.id)')
+ ->where('sp.page = :page')
+ ->andWhere('sp.alive > 0')
+ ->setParameter('page', $page)
+ ->getQuery()
+ ->getSingleScalarResult();
+ }
+
+ public function findNewestAlive(string $page): ?SendProcess
+ {
+ return $this->createQueryBuilder('sp')
+ ->where('sp.page = :page')
+ ->andWhere('sp.alive > 0')
+ ->setParameter('page', $page)
+ ->orderBy('sp.started', 'DESC')
+ ->setMaxResults(1)
+ ->getQuery()
+ ->getOneOrNullResult();
+ }
+
+ public function markDeadById(int $id): void
+ {
+ $this->createQueryBuilder('sp')
+ ->update()
+ ->set('sp.alive', ':zero')
+ ->where('sp.id = :id')
+ ->setParameter('zero', 0)
+ ->setParameter('id', $id)
+ ->getQuery()
+ ->execute();
+ }
+
+ public function incrementAlive(int $id): void
+ {
+ $this->createQueryBuilder('sp')
+ ->update()
+ ->set('sp.alive', 'sp.alive + 1')
+ ->where('sp.id = :id')
+ ->setParameter('id', $id)
+ ->getQuery()
+ ->execute();
+ }
+
+ public function getAliveValue(int $id): int
+ {
+ return (int)$this->createQueryBuilder('sp')
+ ->select('sp.alive')
+ ->where('sp.id = :id')
+ ->setParameter('id', $id)
+ ->getQuery()
+ ->getSingleScalarResult();
+ }
}
diff --git a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php
index 16f07f79..1b315f5e 100644
--- a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php
+++ b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php
@@ -7,6 +7,10 @@
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Messaging\Model\Bounce;
+use PhpList\Core\Domain\Messaging\Model\UserMessage;
+use PhpList\Core\Domain\Messaging\Model\UserMessageBounce;
+use PhpList\Core\Domain\Subscription\Model\Subscriber;
class UserMessageBounceRepository extends AbstractRepository implements PaginatableRepositoryInterface
{
@@ -21,4 +25,69 @@ public function getCountByMessageId(int $messageId): int
->getQuery()
->getSingleScalarResult();
}
+
+ public function existsByMessageIdAndUserId(int $messageId, int $subscriberId): bool
+ {
+ return (bool) $this->createQueryBuilder('umb')
+ ->select('1')
+ ->where('umb.messageId = :messageId')
+ ->andWhere('umb.userId = :userId')
+ ->setParameter('messageId', $messageId)
+ ->setParameter('userId', $subscriberId)
+ ->setMaxResults(1)
+ ->getQuery()
+ ->getOneOrNullResult();
+ }
+
+ /**
+ * @return array
+ */
+ public function getPaginatedWithJoinNoRelation(int $fromId, int $limit): array
+ {
+ return $this->getEntityManager()
+ ->createQueryBuilder()
+ ->select('umb', 'bounce')
+ ->from(UserMessageBounce::class, 'umb')
+ ->innerJoin(Bounce::class, 'bounce', 'WITH', 'bounce.id = umb.bounce')
+ ->where('umb.id > :id')
+ ->setParameter('id', $fromId)
+ ->orderBy('umb.id', 'ASC')
+ ->setMaxResults($limit)
+ ->getQuery()
+ ->getResult();
+ }
+
+ /**
+ * @return array
+ */
+ public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array
+ {
+ return $this->getEntityManager()
+ ->createQueryBuilder()
+ ->select('um', 'umb', 'b')
+ ->from(UserMessage::class, 'um')
+ ->leftJoin(
+ join: UserMessageBounce::class,
+ alias: 'umb',
+ conditionType: 'WITH',
+ condition: 'umb.messageId = IDENTITY(um.message) AND umb.userId = IDENTITY(um.user)'
+ )
+ ->leftJoin(
+ join: Bounce::class,
+ alias: 'b',
+ conditionType: 'WITH',
+ condition: 'b.id = umb.bounceId'
+ )
+ ->where('um.user = :userId')
+ ->andWhere('um.status = :status')
+ ->setParameter('userId', $subscriber->getId())
+ ->setParameter('status', 'sent')
+ ->orderBy('um.entered', 'DESC')
+ ->getQuery()
+ ->getResult();
+ }
}
diff --git a/src/Domain/Messaging/Service/BounceActionResolver.php b/src/Domain/Messaging/Service/BounceActionResolver.php
new file mode 100644
index 00000000..93d432dd
--- /dev/null
+++ b/src/Domain/Messaging/Service/BounceActionResolver.php
@@ -0,0 +1,65 @@
+ */
+ private array $cache = [];
+
+ /**
+ * @param iterable $handlers
+ */
+ public function __construct(iterable $handlers)
+ {
+ foreach ($handlers as $handler) {
+ $this->handlers[] = $handler;
+ }
+ }
+
+ public function has(string $action): bool
+ {
+ return isset($this->cache[$action]) || $this->find($action) !== null;
+ }
+
+ public function resolve(string $action): BounceActionHandlerInterface
+ {
+ if (isset($this->cache[$action])) {
+ return $this->cache[$action];
+ }
+
+ $handler = $this->find($action);
+ if ($handler === null) {
+ throw new RuntimeException(sprintf('No handler found for action "%s".', $action));
+ }
+
+ $this->cache[$action] = $handler;
+
+ return $handler;
+ }
+
+ /** Convenience: resolve + execute */
+ public function handle(string $action, array $context): void
+ {
+ $this->resolve($action)->handle($context);
+ }
+
+ private function find(string $action): ?BounceActionHandlerInterface
+ {
+ foreach ($this->handlers as $handler) {
+ if ($handler->supports($action)) {
+ return $handler;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php b/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php
new file mode 100644
index 00000000..9d16702f
--- /dev/null
+++ b/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php
@@ -0,0 +1,10 @@
+bounceManager = $bounceManager;
+ $this->subscriberRepository = $subscriberRepository;
+ $this->subscriberHistoryManager = $subscriberHistoryManager;
+ $this->blacklistService = $blacklistService;
+ $this->unsubscribeThreshold = $unsubscribeThreshold;
+ $this->blacklistThreshold = $blacklistThreshold;
+ }
+
+ public function handle(SymfonyStyle $io): void
+ {
+ $io->section('Identifying consecutive bounces');
+
+ $users = $this->subscriberRepository->distinctUsersWithBouncesConfirmedNotBlacklisted();
+ $total = count($users);
+
+ if ($total === 0) {
+ $io->writeln('Nothing to do');
+ return;
+ }
+
+ $processed = 0;
+ foreach ($users as $user) {
+ $this->processUser($user);
+ $processed++;
+
+ if ($processed % 5 === 0) {
+ $io->writeln(\sprintf('processed %d out of %d subscribers', $processed, $total));
+ }
+ }
+
+ $io->writeln(\sprintf('total of %d subscribers processed', $total));
+ }
+
+ private function processUser(Subscriber $user): void
+ {
+ $history = $this->bounceManager->getUserMessageHistoryWithBounces($user);
+ if (count($history) === 0) {
+ return;
+ }
+
+ $consecutive = 0;
+ $unsubscribed = false;
+
+ foreach ($history as $row) {
+ /** @var array{um: UserMessage, umb: UserMessageBounce|null, b: Bounce|null} $row */
+ $bounce = $row['b'] ?? null;
+
+ if ($this->isDuplicate($bounce)) {
+ continue;
+ }
+
+ if (!$this->hasRealId($bounce)) {
+ break;
+ }
+
+ $consecutive++;
+
+ if ($this->applyThresholdActions($user, $consecutive, $unsubscribed)) {
+ break;
+ }
+
+ if (!$unsubscribed && $consecutive >= $this->unsubscribeThreshold) {
+ $unsubscribed = true;
+ }
+ }
+ }
+
+ private function isDuplicate(?Bounce $bounce): bool
+ {
+ if ($bounce === null) {
+ return false;
+ }
+ $status = strtolower($bounce->getStatus() ?? '');
+ $comment = strtolower($bounce->getComment() ?? '');
+
+ return str_contains($status, 'duplicate') || str_contains($comment, 'duplicate');
+ }
+
+ private function hasRealId(?Bounce $bounce): bool
+ {
+ return $bounce !== null && (int) $bounce->getId() > 0;
+ }
+
+ /**
+ * Returns true if processing should stop for this user (e.g., blacklisted).
+ */
+ private function applyThresholdActions($user, int $consecutive, bool $alreadyUnsubscribed): bool
+ {
+ if ($consecutive >= $this->unsubscribeThreshold && !$alreadyUnsubscribed) {
+ $this->subscriberRepository->markUnconfirmed($user->getId());
+ $this->subscriberHistoryManager->addHistory(
+ subscriber: $user,
+ message: 'Auto Unconfirmed',
+ details: sprintf('Subscriber auto unconfirmed for %d consecutive bounces', $consecutive)
+ );
+ }
+
+ if ($this->blacklistThreshold > 0 && $consecutive >= $this->blacklistThreshold) {
+ $this->blacklistService->blacklist(
+ subscriber: $user,
+ reason: sprintf('%d consecutive bounces, threshold reached', $consecutive)
+ );
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/Domain/Messaging/Service/EmailService.php b/src/Domain/Messaging/Service/EmailService.php
index 86b17ec5..2a45b0fd 100644
--- a/src/Domain/Messaging/Service/EmailService.php
+++ b/src/Domain/Messaging/Service/EmailService.php
@@ -6,6 +6,7 @@
use PhpList\Core\Domain\Messaging\Message\AsyncEmailMessage;
use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\Address;
@@ -13,17 +14,20 @@
class EmailService
{
private MailerInterface $mailer;
- private string $defaultFromEmail;
private MessageBusInterface $messageBus;
+ private string $defaultFromEmail;
+ private string $bounceEmail;
public function __construct(
MailerInterface $mailer,
+ MessageBusInterface $messageBus,
string $defaultFromEmail,
- MessageBusInterface $messageBus
+ string $bounceEmail,
) {
$this->mailer = $mailer;
- $this->defaultFromEmail = $defaultFromEmail;
$this->messageBus = $messageBus;
+ $this->defaultFromEmail = $defaultFromEmail;
+ $this->bounceEmail = $bounceEmail;
}
public function sendEmail(
@@ -68,7 +72,12 @@ public function sendEmailSync(
$email->attachFromPath($attachment);
}
- $this->mailer->send($email);
+ $envelope = new Envelope(
+ sender: new Address($this->bounceEmail, 'PHPList Bounce'),
+ recipients: [new Address($email->getTo()[0]->getAddress())]
+ );
+
+ $this->mailer->send(message: $email, envelope: $envelope);
}
public function sendBulkEmail(
diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php
new file mode 100644
index 00000000..d32cf68b
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php
@@ -0,0 +1,47 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+ $this->bounceManager = $bounceManager;
+ $this->blacklistService = $blacklistService;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'blacklistemailanddeletebounce';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber'])) {
+ $this->blacklistService->blacklist(
+ subscriber: $closureData['subscriber'],
+ reason: 'Email address auto blacklisted by bounce rule '.$closureData['ruleId']
+ );
+ $this->subscriberHistoryManager->addHistory(
+ $closureData['subscriber'],
+ 'Auto Unsubscribed',
+ 'User auto unsubscribed for bounce rule '.$closureData['ruleId']
+ );
+ }
+ $this->bounceManager->delete($closureData['bounce']);
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php
new file mode 100644
index 00000000..9a92088c
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php
@@ -0,0 +1,42 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+ $this->blacklistService = $blacklistService;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'blacklistemail';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber'])) {
+ $this->blacklistService->blacklist(
+ $closureData['subscriber'],
+ 'Email address auto blacklisted by bounce rule '.$closureData['ruleId']
+ );
+ $this->subscriberHistoryManager->addHistory(
+ $closureData['subscriber'],
+ 'Auto Unsubscribed',
+ 'email auto unsubscribed for bounce rule '.$closureData['ruleId']
+ );
+ }
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php
new file mode 100644
index 00000000..b017fe9c
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php
@@ -0,0 +1,47 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+ $this->bounceManager = $bounceManager;
+ $this->blacklistService = $blacklistService;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'blacklistuseranddeletebounce';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber']) && !$closureData['blacklisted']) {
+ $this->blacklistService->blacklist(
+ subscriber: $closureData['subscriber'],
+ reason: 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId']
+ );
+ $this->subscriberHistoryManager->addHistory(
+ subscriber: $closureData['subscriber'],
+ message: 'Auto Unsubscribed',
+ details: 'User auto unsubscribed for bounce rule '.$closureData['ruleId']
+ );
+ }
+ $this->bounceManager->delete($closureData['bounce']);
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php
new file mode 100644
index 00000000..75c8b810
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php
@@ -0,0 +1,42 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+ $this->blacklistService = $blacklistService;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'blacklistuser';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber']) && !$closureData['blacklisted']) {
+ $this->blacklistService->blacklist(
+ subscriber: $closureData['subscriber'],
+ reason: 'Subscriber auto blacklisted by bounce rule '.$closureData['ruleId']
+ );
+ $this->subscriberHistoryManager->addHistory(
+ subscriber: $closureData['subscriber'],
+ message: 'Auto Unsubscribed',
+ details: 'User auto unsubscribed for bounce rule '.$closureData['ruleId']
+ );
+ }
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php b/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php
new file mode 100644
index 00000000..6b90cb49
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php
@@ -0,0 +1,11 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+ $this->subscriberManager = $subscriberManager;
+ $this->bounceManager = $bounceManager;
+ $this->subscriberRepository = $subscriberRepository;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'decreasecountconfirmuseranddeletebounce';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber'])) {
+ $this->subscriberManager->decrementBounceCount($closureData['subscriber']);
+ if (!$closureData['confirmed']) {
+ $this->subscriberRepository->markConfirmed($closureData['userId']);
+ $this->subscriberHistoryManager->addHistory(
+ subscriber: $closureData['subscriber'],
+ message: 'Auto confirmed',
+ details: 'Subscriber auto confirmed for bounce rule '.$closureData['ruleId']
+ );
+ }
+ }
+ $this->bounceManager->delete($closureData['bounce']);
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php
new file mode 100644
index 00000000..80c881a1
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php
@@ -0,0 +1,27 @@
+bounceManager = $bounceManager;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'deletebounce';
+ }
+
+ public function handle(array $closureData): void
+ {
+ $this->bounceManager->delete($closureData['bounce']);
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php b/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php
new file mode 100644
index 00000000..d8887545
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php
@@ -0,0 +1,33 @@
+bounceManager = $bounceManager;
+ $this->subscriberManager = $subscriberManager;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'deleteuserandbounce';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber'])) {
+ $this->subscriberManager->deleteSubscriber($closureData['subscriber']);
+ }
+ $this->bounceManager->delete($closureData['bounce']);
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php b/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php
new file mode 100644
index 00000000..64b1a073
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php
@@ -0,0 +1,36 @@
+subscriberManager = $subscriberManager;
+ $this->logger = $logger;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'deleteuser';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber'])) {
+ $this->logger->info('User deleted by bounce rule', [
+ 'user' => $closureData['subscriber']->getEmail(),
+ 'rule' => $closureData['ruleId'],
+ ]);
+ $this->subscriberManager->deleteSubscriber($closureData['subscriber']);
+ }
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php
new file mode 100644
index 00000000..7ca39be8
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php
@@ -0,0 +1,44 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+ $this->subscriberRepository = $subscriberRepository;
+ $this->bounceManager = $bounceManager;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'unconfirmuseranddeletebounce';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber']) && $closureData['confirmed']) {
+ $this->subscriberRepository->markUnconfirmed($closureData['userId']);
+ $this->subscriberHistoryManager->addHistory(
+ subscriber: $closureData['subscriber'],
+ message: 'Auto unconfirmed',
+ details: 'Subscriber auto unconfirmed for bounce rule '.$closureData['ruleId']
+ );
+ }
+ $this->bounceManager->delete($closureData['bounce']);
+ }
+}
diff --git a/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php
new file mode 100644
index 00000000..a5bdd0fe
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php
@@ -0,0 +1,39 @@
+subscriberRepository = $subscriberRepository;
+ $this->subscriberHistoryManager = $subscriberHistoryManager;
+ }
+
+ public function supports(string $action): bool
+ {
+ return $action === 'unconfirmuser';
+ }
+
+ public function handle(array $closureData): void
+ {
+ if (!empty($closureData['subscriber']) && $closureData['confirmed']) {
+ $this->subscriberRepository->markUnconfirmed($closureData['userId']);
+ $this->subscriberHistoryManager->addHistory(
+ $closureData['subscriber'],
+ 'Auto Unconfirmed',
+ 'Subscriber auto unconfirmed for bounce rule '.$closureData['ruleId']
+ );
+ }
+ }
+}
diff --git a/src/Domain/Messaging/Service/LockService.php b/src/Domain/Messaging/Service/LockService.php
new file mode 100644
index 00000000..d2f1eb34
--- /dev/null
+++ b/src/Domain/Messaging/Service/LockService.php
@@ -0,0 +1,172 @@
+repo = $repo;
+ $this->manager = $manager;
+ $this->logger = $logger;
+ $this->staleAfterSeconds = $staleAfterSeconds;
+ $this->sleepSeconds = $sleepSeconds;
+ $this->maxWaitCycles = $maxWaitCycles;
+ }
+
+ /**
+ * @SuppressWarnings("BooleanArgumentFlag")
+ */
+ public function acquirePageLock(
+ string $page,
+ bool $force = false,
+ bool $isCli = false,
+ bool $multiSend = false,
+ int $maxSendProcesses = 1,
+ ?string $clientIp = null,
+ ): ?int {
+ $page = $this->sanitizePage($page);
+ $max = $this->resolveMax($isCli, $multiSend, $maxSendProcesses);
+
+ if ($force) {
+ $this->logger->info('Force set, killing other send processes (deleting lock rows).');
+ $this->repo->deleteByPage($page);
+ }
+
+ $waited = 0;
+
+ while (true) {
+ $count = $this->repo->countAliveByPage($page);
+ $running = $this->manager->findNewestAliveWithAge($page);
+
+ if ($count >= $max) {
+ if ($this->tryStealIfStale($running)) {
+ continue;
+ }
+
+ $this->logAliveAge($running);
+
+ if ($isCli) {
+ $this->logger->info("Running commandline, quitting. We'll find out what to do in the next run.");
+
+ return null;
+ }
+
+ if (!$this->waitOrGiveUp($waited)) {
+ $this->logger->info('We have been waiting too long, I guess the other process is still going ok');
+
+ return null;
+ }
+
+ continue;
+ }
+
+ $processIdentifier = $this->buildProcessIdentifier($isCli, $clientIp);
+ $sendProcess = $this->manager->create($page, $processIdentifier);
+
+ return $sendProcess->getId();
+ }
+ }
+
+ public function keepLock(int $processId): void
+ {
+ $this->repo->incrementAlive($processId);
+ }
+
+ public function checkLock(int $processId): int
+ {
+ return $this->repo->getAliveValue($processId);
+ }
+
+ public function release(int $processId): void
+ {
+ $this->repo->markDeadById($processId);
+ }
+
+ private function sanitizePage(string $page): string
+ {
+ $unicodeString = new UnicodeString($page);
+ $clean = preg_replace('/\W/', '', (string) $unicodeString);
+
+ return $clean === '' ? 'default' : $clean;
+ }
+
+ private function resolveMax(bool $isCli, bool $multiSend, int $maxSendProcesses): int
+ {
+ if (!$isCli) {
+ return 1;
+ }
+ return $multiSend ? \max(1, $maxSendProcesses) : 1;
+ }
+
+ /**
+ * Returns true if it detected a stale process and killed it (so caller should loop again).
+ *
+ * @param array{id?: int, age?: int}|null $running
+ */
+ private function tryStealIfStale(?array $running): bool
+ {
+ $age = (int)($running['age'] ?? 0);
+ if ($age > $this->staleAfterSeconds && isset($running['id'])) {
+ $this->repo->markDeadById((int)$running['id']);
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param array{id?: int, age?: int}|null $running
+ */
+ private function logAliveAge(?array $running): void
+ {
+ $age = (int)($running['age'] ?? 0);
+ $this->logger->info(
+ \sprintf(
+ 'A process for this page is already running and it was still alive %d seconds ago',
+ $age
+ )
+ );
+ }
+
+ /**
+ * Sleeps once and increments $waited. Returns false if we exceeded max wait cycles.
+ */
+ private function waitOrGiveUp(int &$waited): bool
+ {
+ $this->logger->info(\sprintf('Sleeping for %d seconds, aborting will quit', $this->sleepSeconds));
+ \sleep($this->sleepSeconds);
+ $waited++;
+ return $waited <= $this->maxWaitCycles;
+ }
+
+ private function buildProcessIdentifier(bool $isCli, ?string $clientIp): string
+ {
+ if ($isCli) {
+ $host = \php_uname('n') ?: 'localhost';
+ return $host . ':' . \getmypid();
+ }
+ return $clientIp ?? '0.0.0.0';
+ }
+}
diff --git a/src/Domain/Messaging/Service/Manager/BounceManager.php b/src/Domain/Messaging/Service/Manager/BounceManager.php
new file mode 100644
index 00000000..f13c46ff
--- /dev/null
+++ b/src/Domain/Messaging/Service/Manager/BounceManager.php
@@ -0,0 +1,138 @@
+bounceRepository = $bounceRepository;
+ $this->userMessageBounceRepo = $userMessageBounceRepo;
+ $this->entityManager = $entityManager;
+ $this->logger = $logger;
+ }
+
+ public function create(
+ ?DateTimeImmutable $date = null,
+ ?string $header = null,
+ ?string $data = null,
+ ?string $status = null,
+ ?string $comment = null
+ ): Bounce {
+ $bounce = new Bounce(
+ date: new DateTime($date->format('Y-m-d H:i:s')),
+ header: $header,
+ data: $data,
+ status: $status,
+ comment: $comment
+ );
+
+ $this->bounceRepository->save($bounce);
+
+ return $bounce;
+ }
+
+ public function update(Bounce $bounce, ?string $status = null, ?string $comment = null): Bounce
+ {
+ $bounce->setStatus($status);
+ $bounce->setComment($comment);
+ $this->bounceRepository->save($bounce);
+
+ return $bounce;
+ }
+
+ public function delete(Bounce $bounce): void
+ {
+ $this->bounceRepository->remove($bounce);
+ }
+
+ /** @return Bounce[] */
+ public function getAll(): array
+ {
+ return $this->bounceRepository->findAll();
+ }
+
+ public function getById(int $id): ?Bounce
+ {
+ /** @var Bounce|null $found */
+ $found = $this->bounceRepository->find($id);
+ return $found;
+ }
+
+ public function linkUserMessageBounce(
+ Bounce $bounce,
+ DateTimeImmutable $date,
+ int $subscriberId,
+ ?int $messageId = -1
+ ): UserMessageBounce {
+ $userMessageBounce = new UserMessageBounce($bounce->getId(), new DateTime($date->format('Y-m-d H:i:s')));
+ $userMessageBounce->setUserId($subscriberId);
+ $userMessageBounce->setMessageId($messageId);
+ $this->entityManager->flush();
+
+ return $userMessageBounce;
+ }
+
+ public function existsUserMessageBounce(int $subscriberId, int $messageId): bool
+ {
+ return $this->userMessageBounceRepo->existsByMessageIdAndUserId($messageId, $subscriberId);
+ }
+
+ /** @return Bounce[] */
+ public function findByStatus(string $status): array
+ {
+ return $this->bounceRepository->findByStatus($status);
+ }
+
+ public function getUserMessageBounceCount(): int
+ {
+ return $this->userMessageBounceRepo->count();
+ }
+
+ /**
+ * @return array
+ */
+ public function fetchUserMessageBounceBatch(int $fromId, int $batchSize): array
+ {
+ return $this->userMessageBounceRepo->getPaginatedWithJoinNoRelation($fromId, $batchSize);
+ }
+
+ /**
+ * @return array
+ */
+ public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array
+ {
+ return $this->userMessageBounceRepo->getUserMessageHistoryWithBounces($subscriber);
+ }
+
+ public function announceDeletionMode(bool $testMode): void
+ {
+ $message = $testMode ? self::TEST_MODE_MESSAGE : self::LIVE_MODE_MESSAGE;
+ $this->logger->info($message);
+ }
+}
diff --git a/src/Domain/Messaging/Service/Manager/BounceRegexManager.php b/src/Domain/Messaging/Service/Manager/BounceRegexManager.php
new file mode 100644
index 00000000..c9d60580
--- /dev/null
+++ b/src/Domain/Messaging/Service/Manager/BounceRegexManager.php
@@ -0,0 +1,99 @@
+bounceRegexRepository = $bounceRegexRepository;
+ $this->entityManager = $entityManager;
+ }
+
+ /**
+ * Creates or updates (if exists) a BounceRegex from a raw regex pattern.
+ */
+ public function createOrUpdateFromPattern(
+ string $regex,
+ ?string $action = null,
+ ?int $listOrder = 0,
+ ?int $adminId = null,
+ ?string $comment = null,
+ ?string $status = null
+ ): BounceRegex {
+ $regexHash = md5($regex);
+
+ $existing = $this->bounceRegexRepository->findOneByRegexHash($regexHash);
+
+ if ($existing !== null) {
+ $existing->setRegex($regex)
+ ->setAction($action ?? $existing->getAction())
+ ->setListOrder($listOrder ?? $existing->getListOrder())
+ ->setAdminId($adminId ?? $existing->getAdminId())
+ ->setComment($comment ?? $existing->getComment())
+ ->setStatus($status ?? $existing->getStatus());
+
+ $this->bounceRegexRepository->save($existing);
+
+ return $existing;
+ }
+
+ $bounceRegex = new BounceRegex(
+ regex: $regex,
+ regexHash: $regexHash,
+ action: $action,
+ listOrder: $listOrder,
+ adminId: $adminId,
+ comment: $comment,
+ status: $status,
+ count: 0
+ );
+
+ $this->bounceRegexRepository->save($bounceRegex);
+
+ return $bounceRegex;
+ }
+
+ /** @return BounceRegex[] */
+ public function getAll(): array
+ {
+ return $this->bounceRegexRepository->findAll();
+ }
+
+ public function getByHash(string $regexHash): ?BounceRegex
+ {
+ return $this->bounceRegexRepository->findOneByRegexHash($regexHash);
+ }
+
+ public function delete(BounceRegex $bounceRegex): void
+ {
+ $this->bounceRegexRepository->remove($bounceRegex);
+ }
+
+ /**
+ * Associates a bounce with the regex it matched and increments usage count.
+ */
+ public function associateBounce(BounceRegex $regex, Bounce $bounce): BounceRegexBounce
+ {
+ $relation = new BounceRegexBounce($regex->getId() ?? 0, $bounce->getId() ?? 0);
+ $this->entityManager->persist($relation);
+
+ $regex->setCount(($regex->getCount() ?? 0) + 1);
+ $this->entityManager->flush();
+
+ return $relation;
+ }
+}
diff --git a/src/Domain/Messaging/Service/Manager/BounceRuleManager.php b/src/Domain/Messaging/Service/Manager/BounceRuleManager.php
new file mode 100644
index 00000000..70a750a9
--- /dev/null
+++ b/src/Domain/Messaging/Service/Manager/BounceRuleManager.php
@@ -0,0 +1,110 @@
+
+ */
+ public function loadActiveRules(): array
+ {
+ return $this->mapRows($this->repository->fetchActiveOrdered());
+ }
+
+ /**
+ * @return array
+ */
+ public function loadAllRules(): array
+ {
+ return $this->mapRows($this->repository->fetchAllOrdered());
+ }
+
+ /**
+ * Internal helper to normalize repository rows into the legacy shape.
+ *
+ * @param BounceRegex[] $rows
+ * @return array
+ */
+ private function mapRows(array $rows): array
+ {
+ $result = [];
+
+ foreach ($rows as $row) {
+ $regex = $row->getRegex();
+ $action = $row->getAction();
+ $id = $row->getId();
+
+ if (!is_string($regex)
+ || $regex === ''
+ || !is_string($action)
+ || $action === ''
+ || !is_int($id)
+ ) {
+ continue;
+ }
+
+ $result[$regex] = $row;
+ }
+
+ return $result;
+ }
+
+
+ /**
+ * @param array $rules
+ */
+ public function matchBounceRules(string $text, array $rules): ?BounceRegex
+ {
+ foreach ($rules as $pattern => $rule) {
+ $quoted = '/'.preg_quote(str_replace(' ', '\s+', $pattern)).'/iUm';
+ if ($this->safePregMatch($quoted, $text)) {
+ return $rule;
+ }
+ $raw = '/'.str_replace(' ', '\s+', $pattern).'/iUm';
+ if ($this->safePregMatch($raw, $text)) {
+ return $rule;
+ }
+ }
+
+ return null;
+ }
+
+ private function safePregMatch(string $pattern, string $subject): bool
+ {
+ set_error_handler(static fn() => true);
+ $result = preg_match($pattern, $subject) === 1;
+ restore_error_handler();
+
+ return $result;
+ }
+
+ public function incrementCount(BounceRegex $rule): void
+ {
+ $rule->setCount($rule->getCount() + 1);
+
+ $this->repository->save($rule);
+ }
+
+ public function linkRuleToBounce(BounceRegex $rule, Bounce $bounce): BounceregexBounce
+ {
+ $relation = new BounceRegexBounce($rule->getId(), $bounce->getId());
+ $this->bounceRelationRepository->save($relation);
+
+ return $relation;
+ }
+}
diff --git a/src/Domain/Messaging/Service/MessageManager.php b/src/Domain/Messaging/Service/Manager/MessageManager.php
similarity index 96%
rename from src/Domain/Messaging/Service/MessageManager.php
rename to src/Domain/Messaging/Service/Manager/MessageManager.php
index 9af4df0b..7b263083 100644
--- a/src/Domain/Messaging/Service/MessageManager.php
+++ b/src/Domain/Messaging/Service/Manager/MessageManager.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace PhpList\Core\Domain\Messaging\Service;
+namespace PhpList\Core\Domain\Messaging\Service\Manager;
use PhpList\Core\Domain\Identity\Model\Administrator;
use PhpList\Core\Domain\Messaging\Model\Dto\MessageContext;
diff --git a/src/Domain/Messaging/Service/Manager/SendProcessManager.php b/src/Domain/Messaging/Service/Manager/SendProcessManager.php
new file mode 100644
index 00000000..0100ed29
--- /dev/null
+++ b/src/Domain/Messaging/Service/Manager/SendProcessManager.php
@@ -0,0 +1,57 @@
+repository = $repository;
+ $this->entityManager = $entityManager;
+ }
+
+ public function create(string $page, string $processIdentifier): SendProcess
+ {
+ $sendProcess = new SendProcess();
+ $sendProcess->setStartedDate(new DateTime('now'));
+ $sendProcess->setAlive(1);
+ $sendProcess->setIpaddress($processIdentifier);
+ $sendProcess->setPage($page);
+
+ $this->entityManager->persist($sendProcess);
+ $this->entityManager->flush();
+
+ return $sendProcess;
+ }
+
+
+ /**
+ * @return array{id:int, age:int}|null
+ */
+ public function findNewestAliveWithAge(string $page): ?array
+ {
+ $row = $this->repository->findNewestAlive($page);
+
+ if (!$row instanceof SendProcess) {
+ return null;
+ }
+
+ $modified = $row->getUpdatedAt();
+ $age = $modified ? max(0, time() - (int)$modified->format('U')) : 0;
+
+ return [
+ 'id' => $row->getId(),
+ 'age' => $age,
+ ];
+ }
+}
diff --git a/src/Domain/Messaging/Service/TemplateImageManager.php b/src/Domain/Messaging/Service/Manager/TemplateImageManager.php
similarity index 98%
rename from src/Domain/Messaging/Service/TemplateImageManager.php
rename to src/Domain/Messaging/Service/Manager/TemplateImageManager.php
index c5ebd3f4..30705715 100644
--- a/src/Domain/Messaging/Service/TemplateImageManager.php
+++ b/src/Domain/Messaging/Service/Manager/TemplateImageManager.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace PhpList\Core\Domain\Messaging\Service;
+namespace PhpList\Core\Domain\Messaging\Service\Manager;
use Doctrine\ORM\EntityManagerInterface;
use DOMDocument;
diff --git a/src/Domain/Messaging/Service/TemplateManager.php b/src/Domain/Messaging/Service/Manager/TemplateManager.php
similarity index 98%
rename from src/Domain/Messaging/Service/TemplateManager.php
rename to src/Domain/Messaging/Service/Manager/TemplateManager.php
index 35678484..7de31843 100644
--- a/src/Domain/Messaging/Service/TemplateManager.php
+++ b/src/Domain/Messaging/Service/Manager/TemplateManager.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace PhpList\Core\Domain\Messaging\Service;
+namespace PhpList\Core\Domain\Messaging\Service\Manager;
use Doctrine\ORM\EntityManagerInterface;
use PhpList\Core\Domain\Common\Model\ValidationContext;
diff --git a/src/Domain/Messaging/Service/MessageParser.php b/src/Domain/Messaging/Service/MessageParser.php
new file mode 100644
index 00000000..14b4f952
--- /dev/null
+++ b/src/Domain/Messaging/Service/MessageParser.php
@@ -0,0 +1,102 @@
+subscriberRepository = $subscriberRepository;
+ }
+
+ public function decodeBody(string $header, string $body): string
+ {
+ $transferEncoding = '';
+ if (preg_match('/Content-Transfer-Encoding: ([\w-]+)/i', $header, $regs)) {
+ $transferEncoding = strtolower($regs[1]);
+ }
+
+ return match ($transferEncoding) {
+ 'quoted-printable' => quoted_printable_decode($body),
+ 'base64' => base64_decode($body) ?: '',
+ default => $body,
+ };
+ }
+
+ public function findMessageId(string $text): ?string
+ {
+ if (preg_match('/(?:X-MessageId|X-Message): (.*)\r\n/iU', $text, $match)) {
+ return trim($match[1]);
+ }
+
+ return null;
+ }
+
+ public function findUserId(string $text): ?int
+ {
+ $candidate = $this->extractUserHeader($text);
+ if ($candidate) {
+ $id = $this->resolveUserIdentifier($candidate);
+ if ($id) {
+ return $id;
+ }
+ }
+
+ $emails = $this->extractEmails($text);
+
+ return $this->findFirstSubscriberId($emails);
+ }
+
+ private function extractUserHeader(string $text): ?string
+ {
+ if (preg_match('/^(?:X-ListMember|X-User):\s*(?P[^\r\n]+)/mi', $text, $matches)) {
+ $user = trim($matches['user']);
+
+ return $user !== '' ? $user : null;
+ }
+
+ return null;
+ }
+
+ private function resolveUserIdentifier(string $user): ?int
+ {
+ if (filter_var($user, FILTER_VALIDATE_EMAIL)) {
+ return $this->subscriberRepository->findOneByEmail($user)?->getId();
+ }
+
+ if (ctype_digit($user)) {
+ return (int) $user;
+ }
+
+ return $this->subscriberRepository->findOneByEmail($user)?->getId();
+ }
+
+ private function extractEmails(string $text): array
+ {
+ preg_match_all('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+/i', $text, $matches);
+ if (empty($matches[0])) {
+ return [];
+ }
+ $norm = array_map('strtolower', $matches[0]);
+
+ return array_values(array_unique($norm));
+ }
+
+ private function findFirstSubscriberId(array $emails): ?int
+ {
+ foreach ($emails as $email) {
+ $id = $this->subscriberRepository->findOneByEmail($email)?->getId();
+ if ($id !== null) {
+ return $id;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/Domain/Messaging/Service/NativeBounceProcessingService.php b/src/Domain/Messaging/Service/NativeBounceProcessingService.php
new file mode 100644
index 00000000..eee5bb98
--- /dev/null
+++ b/src/Domain/Messaging/Service/NativeBounceProcessingService.php
@@ -0,0 +1,138 @@
+bounceManager = $bounceManager;
+ $this->mailReader = $mailReader;
+ $this->messageParser = $messageParser;
+ $this->bounceDataProcessor = $bounceDataProcessor;
+ $this->logger = $logger;
+ $this->purgeProcessed = $purgeProcessed;
+ $this->purgeUnprocessed = $purgeUnprocessed;
+ }
+
+ public function processMailbox(
+ string $mailbox,
+ int $max,
+ bool $testMode
+ ): string {
+ $link = $this->openOrFail($mailbox, $testMode);
+
+ $num = $this->prepareAndCapCount($link, $max);
+ if ($num === 0) {
+ $this->mailReader->close($link, false);
+
+ return '';
+ }
+
+ $this->bounceManager->announceDeletionMode($testMode);
+
+ for ($messageNumber = 1; $messageNumber <= $num; $messageNumber++) {
+ $this->handleMessage($link, $messageNumber, $testMode);
+ }
+
+ $this->finalize($link, $testMode);
+
+ return '';
+ }
+
+ private function openOrFail(string $mailbox, bool $testMode): Connection
+ {
+ try {
+ return $this->mailReader->open($mailbox, $testMode ? 0 : CL_EXPUNGE);
+ } catch (Throwable $e) {
+ $this->logger->error('Cannot open mailbox file: '.$e->getMessage());
+ throw new RuntimeException('Cannot open mbox file');
+ }
+ }
+
+ private function prepareAndCapCount(Connection $link, int $max): int
+ {
+ $num = $this->mailReader->numMessages($link);
+ $this->logger->info(sprintf('%d bounces to fetch from the mailbox', $num));
+ if ($num === 0) {
+ return 0;
+ }
+
+ $this->logger->info('Please do not interrupt this process');
+ if ($num > $max) {
+ $this->logger->info(sprintf('Processing first %d bounces', $max));
+ $num = $max;
+ }
+
+ return $num;
+ }
+
+ private function handleMessage(Connection $link, int $messageNumber, bool $testMode): void
+ {
+ $header = $this->mailReader->fetchHeader($link, $messageNumber);
+ $processed = $this->processImapBounce($link, $messageNumber, $header);
+
+ if ($testMode) {
+ return;
+ }
+
+ if ($processed && $this->purgeProcessed) {
+ $this->mailReader->delete($link, $messageNumber);
+ return;
+ }
+
+ if (!$processed && $this->purgeUnprocessed) {
+ $this->mailReader->delete($link, $messageNumber);
+ }
+ }
+
+ private function finalize(Connection $link, bool $testMode): void
+ {
+ $this->logger->info('Closing mailbox, and purging messages');
+ $this->mailReader->close($link, !$testMode);
+ }
+
+ private function processImapBounce($link, int $num, string $header): bool
+ {
+ $bounceDate = $this->mailReader->headerDate($link, $num);
+ $body = $this->mailReader->body($link, $num);
+ $body = $this->messageParser->decodeBody($header, $body);
+
+ // Quick hack: ignore MsExchange delayed notices (as in original)
+ if (preg_match('/Action: delayed\s+Status: 4\.4\.7/im', $body)) {
+ return true;
+ }
+
+ $msgId = $this->messageParser->findMessageId($body);
+ $userId = $this->messageParser->findUserId($body);
+
+ $bounce = $this->bounceManager->create($bounceDate, $header, $body);
+
+ return $this->bounceDataProcessor->process($bounce, $msgId, $userId, $bounceDate);
+ }
+}
diff --git a/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php
new file mode 100644
index 00000000..568bf874
--- /dev/null
+++ b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php
@@ -0,0 +1,120 @@
+section('Processing bounces based on active bounce rules');
+
+ $rules = $this->ruleManager->loadActiveRules();
+ if (!$rules) {
+ $io->writeln('No active rules');
+ return;
+ }
+
+ $total = $this->bounceManager->getUserMessageBounceCount();
+ $fromId = 0;
+ $matched = 0;
+ $notMatched = 0;
+ $processed = 0;
+
+ while ($processed < $total) {
+ $batch = $this->bounceManager->fetchUserMessageBounceBatch($fromId, $batchSize);
+ if (!$batch) {
+ break;
+ }
+
+ foreach ($batch as $row) {
+ $fromId = $row['umb']->getId();
+
+ $bounce = $row['bounce'];
+ $userId = (int) $row['umb']->getUserId();
+ $text = $this->composeText($bounce);
+ $rule = $this->ruleManager->matchBounceRules($text, $rules);
+
+ if ($rule) {
+ $this->incrementRuleCounters($rule, $bounce);
+
+ $subscriber = $userId ? $this->subscriberManager->getSubscriberById($userId) : null;
+ $ctx = $this->makeContext($subscriber, $bounce, (int)$rule->getId());
+
+ $action = (string) $rule->getAction();
+ $this->actionResolver->handle($action, $ctx);
+
+ $matched++;
+ } else {
+ $notMatched++;
+ }
+
+ $processed++;
+ }
+
+ $io->writeln(sprintf(
+ 'processed %d out of %d bounces for advanced bounce rules',
+ min($processed, $total),
+ $total
+ ));
+ }
+
+ $io->writeln(sprintf('%d bounces processed by advanced processing', $matched));
+ $io->writeln(sprintf('%d bounces were not matched by advanced processing rules', $notMatched));
+ }
+
+ private function composeText(Bounce $bounce): string
+ {
+ return $bounce->getHeader() . "\n\n" . $bounce->getData();
+ }
+
+ private function incrementRuleCounters($rule, Bounce $bounce): void
+ {
+ $this->ruleManager->incrementCount($rule);
+ $rule->setCount($rule->getCount() + 1);
+ $this->ruleManager->linkRuleToBounce($rule, $bounce);
+ }
+
+ /**
+ * @return array{
+ * subscriber: ?Subscriber,
+ * bounce: Bounce,
+ * userId: int,
+ * confirmed: bool,
+ * blacklisted: bool,
+ * ruleId: int
+ * }
+ */
+ private function makeContext(?Subscriber $subscriber, Bounce $bounce, int $ruleId): array
+ {
+ $userId = $subscriber?->getId() ?? 0;
+ $confirmed = $subscriber?->isConfirmed() ?? false;
+ $blacklisted = $subscriber?->isBlacklisted() ?? false;
+
+ return [
+ 'subscriber' => $subscriber,
+ 'bounce' => $bounce,
+ 'userId' => $userId,
+ 'confirmed' => $confirmed,
+ 'blacklisted' => $blacklisted,
+ 'ruleId' => $ruleId,
+ ];
+ }
+}
diff --git a/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php
new file mode 100644
index 00000000..6f502a8c
--- /dev/null
+++ b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php
@@ -0,0 +1,155 @@
+bounceManager = $bounceManager;
+ $this->subscriberRepository = $subscriberRepository;
+ $this->messageRepository = $messageRepository;
+ $this->logger = $logger;
+ $this->subscriberManager = $subscriberManager;
+ $this->subscriberHistoryManager = $subscriberHistoryManager;
+ }
+
+ public function process(Bounce $bounce, ?string $msgId, ?int $userId, DateTimeImmutable $bounceDate): bool
+ {
+ $user = $userId ? $this->subscriberManager->getSubscriberById($userId) : null;
+
+ if ($msgId === 'systemmessage') {
+ return $userId ? $this->handleSystemMessageWithUser(
+ $bounce,
+ $bounceDate,
+ $userId,
+ $user
+ ) : $this->handleSystemMessageUnknownUser($bounce);
+ }
+
+ if ($msgId && $userId) {
+ return $this->handleKnownMessageAndUser($bounce, $bounceDate, (int)$msgId, $userId);
+ }
+
+ if ($userId) {
+ return $this->handleUserOnly($bounce, $userId);
+ }
+
+ if ($msgId) {
+ return $this->handleMessageOnly($bounce, (int)$msgId);
+ }
+
+ $this->bounceManager->update($bounce, 'unidentified bounce', 'not processed');
+
+ return false;
+ }
+
+ private function handleSystemMessageWithUser(
+ Bounce $bounce,
+ DateTimeImmutable $date,
+ int $userId,
+ $userOrNull
+ ): bool {
+ $this->bounceManager->update(
+ bounce: $bounce,
+ status: 'bounced system message',
+ comment: sprintf('%d marked unconfirmed', $userId)
+ );
+ $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId);
+ $this->subscriberRepository->markUnconfirmed($userId);
+ $this->logger->info('system message bounced, user marked unconfirmed', ['userId' => $userId]);
+
+ if ($userOrNull) {
+ $this->subscriberHistoryManager->addHistory(
+ subscriber: $userOrNull,
+ message: 'Bounced system message',
+ details: sprintf('User marked unconfirmed. Bounce #%d', $bounce->getId())
+ );
+ }
+
+ return true;
+ }
+
+ private function handleSystemMessageUnknownUser(Bounce $bounce): bool
+ {
+ $this->bounceManager->update($bounce, 'bounced system message', 'unknown user');
+ $this->logger->info('system message bounced, but unknown user');
+
+ return true;
+ }
+
+ private function handleKnownMessageAndUser(
+ Bounce $bounce,
+ DateTimeImmutable $date,
+ int $msgId,
+ int $userId
+ ): bool {
+ if (!$this->bounceManager->existsUserMessageBounce($userId, $msgId)) {
+ $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId, $msgId);
+ $this->bounceManager->update(
+ bounce: $bounce,
+ status: sprintf('bounced list message %d', $msgId),
+ comment: sprintf('%d bouncecount increased', $userId)
+ );
+ $this->messageRepository->incrementBounceCount($msgId);
+ $this->subscriberRepository->incrementBounceCount($userId);
+ } else {
+ $this->bounceManager->linkUserMessageBounce($bounce, $date, $userId, $msgId);
+ $this->bounceManager->update(
+ bounce: $bounce,
+ status: sprintf('duplicate bounce for %d', $userId),
+ comment: sprintf('duplicate bounce for subscriber %d on message %d', $userId, $msgId)
+ );
+ }
+
+ return true;
+ }
+
+ private function handleUserOnly(Bounce $bounce, int $userId): bool
+ {
+ $this->bounceManager->update(
+ bounce: $bounce,
+ status: 'bounced unidentified message',
+ comment: sprintf('%d bouncecount increased', $userId)
+ );
+ $this->subscriberRepository->incrementBounceCount($userId);
+
+ return true;
+ }
+
+ private function handleMessageOnly(Bounce $bounce, int $msgId): bool
+ {
+ $this->bounceManager->update(
+ bounce: $bounce,
+ status: sprintf('bounced list message %d', $msgId),
+ comment: 'unknown user'
+ );
+ $this->messageRepository->incrementBounceCount($msgId);
+
+ return true;
+ }
+}
diff --git a/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php b/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php
new file mode 100644
index 00000000..a0e7d904
--- /dev/null
+++ b/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php
@@ -0,0 +1,24 @@
+processingService = $processingService;
+ }
+
+ public function getProtocol(): string
+ {
+ return 'mbox';
+ }
+
+ public function process(InputInterface $input, SymfonyStyle $inputOutput): string
+ {
+ $testMode = (bool)$input->getOption('test');
+ $max = (int)$input->getOption('maximum');
+
+ $file = (string)$input->getOption('mailbox');
+ if (!$file) {
+ $inputOutput->error('mbox file path must be provided with --mailbox.');
+ throw new RuntimeException('Missing --mailbox for mbox protocol');
+ }
+
+ $inputOutput->section('Opening mbox ' . $file);
+ $inputOutput->writeln('Please do not interrupt this process');
+
+ return $this->processingService->processMailbox(
+ mailbox: $file,
+ max: $max,
+ testMode: $testMode
+ );
+ }
+}
diff --git a/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php
new file mode 100644
index 00000000..b6f59f65
--- /dev/null
+++ b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php
@@ -0,0 +1,59 @@
+processingService = $processingService;
+ $this->host = $host;
+ $this->port = $port;
+ $this->mailboxNames = $mailboxNames;
+ }
+
+ public function getProtocol(): string
+ {
+ return 'pop';
+ }
+
+ public function process(InputInterface $input, SymfonyStyle $inputOutput): string
+ {
+ $testMode = (bool)$input->getOption('test');
+ $max = (int)$input->getOption('maximum');
+
+ $downloadReport = '';
+ foreach (explode(',', $this->mailboxNames) as $mailboxName) {
+ $mailboxName = trim($mailboxName);
+ if ($mailboxName === '') {
+ $mailboxName = 'INBOX';
+ }
+ $mailbox = sprintf('{%s:%s}%s', $this->host, $this->port, $mailboxName);
+ $inputOutput->section('Connecting to ' . $mailbox);
+ $inputOutput->writeln('Please do not interrupt this process');
+
+ $downloadReport .= $this->processingService->processMailbox(
+ mailbox: $mailbox,
+ max: $max,
+ testMode: $testMode
+ );
+ }
+
+ return $downloadReport;
+ }
+}
diff --git a/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php
new file mode 100644
index 00000000..503fc459
--- /dev/null
+++ b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php
@@ -0,0 +1,70 @@
+bounceManager = $bounceManager;
+ $this->messageParser = $messageParser;
+ $this->bounceDataProcessor = $bounceDataProcessor;
+ }
+
+ public function process(SymfonyStyle $inputOutput): void
+ {
+ $inputOutput->section('Reprocessing unidentified bounces');
+ $bounces = $this->bounceManager->findByStatus('unidentified bounce');
+ $total = count($bounces);
+ $inputOutput->writeln(sprintf('%d bounces to reprocess', $total));
+
+ $count = 0;
+ $reparsed = 0;
+ $reidentified = 0;
+ foreach ($bounces as $bounce) {
+ $count++;
+ if ($count % 25 === 0) {
+ $inputOutput->writeln(sprintf('%d out of %d processed', $count, $total));
+ }
+
+ $decodedBody = $this->messageParser->decodeBody($bounce->getHeader(), $bounce->getData());
+ $userId = $this->messageParser->findUserId($decodedBody);
+ $messageId = $this->messageParser->findMessageId($decodedBody);
+
+ if ($userId || $messageId) {
+ $reparsed++;
+ if ($this->bounceDataProcessor->process(
+ $bounce,
+ $messageId,
+ $userId,
+ new DateTimeImmutable()
+ )
+ ) {
+ $reidentified++;
+ }
+ }
+ }
+
+ $inputOutput->writeln(sprintf('%d out of %d processed', $count, $total));
+ $inputOutput->writeln(sprintf(
+ '%d bounces were re-processed and %d bounces were re-identified',
+ $reparsed,
+ $reidentified
+ ));
+ }
+}
diff --git a/src/Domain/Messaging/Service/WebklexBounceProcessingService.php b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php
new file mode 100644
index 00000000..01a94aff
--- /dev/null
+++ b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php
@@ -0,0 +1,268 @@
+bounceManager = $bounceManager;
+ $this->logger = $logger;
+ $this->messageParser = $messageParser;
+ $this->clientFactory = $clientFactory;
+ $this->bounceDataProcessor = $bounceDataProcessor;
+ $this->purgeProcessed = $purgeProcessed;
+ $this->purgeUnprocessed = $purgeUnprocessed;
+ }
+
+ /**
+ * Process unseen messages from the given mailbox using Webklex.
+ *
+ * $mailbox: IMAP host; if you pass "host#FOLDER", FOLDER will be used instead of INBOX.
+ *
+ * @throws RuntimeException If connection to the IMAP server cannot be established.
+ */
+ public function processMailbox(
+ string $mailbox,
+ int $max,
+ bool $testMode
+ ): string {
+ $client = $this->clientFactory->makeForMailbox();
+
+ try {
+ $client->connect();
+ } catch (Throwable $e) {
+ $this->logger->error('Cannot connect to mailbox: '.$e->getMessage());
+ throw new RuntimeException('Cannot connect to IMAP server');
+ }
+
+ try {
+ $folder = $client->getFolder($this->clientFactory->getFolderName());
+ $query = $folder->query()->unseen()->limit($max);
+
+ $messages = $query->get();
+ $num = $messages->count();
+
+ $this->logger->info(sprintf('%d bounces to fetch from the mailbox', $num));
+ if ($num === 0) {
+ return '';
+ }
+
+ $this->bounceManager->announceDeletionMode($testMode);
+
+ foreach ($messages as $message) {
+ $header = $this->headerToStringSafe($message);
+ $body = $this->bodyBestEffort($message);
+ $body = $this->messageParser->decodeBody($header, $body);
+
+ if (\preg_match('/Action: delayed\s+Status: 4\.4\.7/im', $body)) {
+ if (!$testMode && $this->purgeProcessed) {
+ $this->safeDelete($message);
+ }
+ continue;
+ }
+
+ $messageId = $this->messageParser->findMessageId($body."\r\n".$header);
+ $userId = $this->messageParser->findUserId($body."\r\n".$header);
+
+ $bounceDate = $this->extractDate($message);
+ $bounce = $this->bounceManager->create($bounceDate, $header, $body);
+
+ $processed = $this->bounceDataProcessor->process($bounce, $messageId, $userId, $bounceDate);
+
+ $this->processDelete($testMode, $processed, $message);
+ }
+
+ $this->logger->info('Closing mailbox, and purging messages');
+ $this->processExpunge($testMode, $folder, $client);
+
+ return '';
+ } finally {
+ try {
+ $client->disconnect();
+ } catch (Throwable $e) {
+ $this->logger->warning('Disconnect failed', ['error' => $e->getMessage()]);
+ }
+ }
+ }
+
+ private function headerToStringSafe(mixed $message): string
+ {
+ $raw = $this->tryRawHeader($message);
+ if ($raw !== null) {
+ return $raw;
+ }
+
+ $lines = [];
+ $subj = $message->getSubject() ?? '';
+ $from = $this->addrFirstToString($message->getFrom());
+ $messageTo = $this->addrManyToString($message->getTo());
+ $date = $this->extractDate($message)->format(\DATE_RFC2822);
+
+ if ($subj !== '') {
+ $lines[] = 'Subject: ' . $subj;
+ }
+ if ($from !== '') {
+ $lines[] = 'From: ' . $from;
+ }
+ if ($messageTo !== '') {
+ $lines[] = 'To: ' . $messageTo;
+ }
+ $lines[] = 'Date: ' . $date;
+
+ $mid = $message->getMessageId() ?? '';
+ if ($mid !== '') {
+ $lines[] = 'Message-ID: ' . $mid;
+ }
+
+ return implode("\r\n", $lines) . "\r\n";
+ }
+
+ private function tryRawHeader(mixed $message): ?string
+ {
+ if (!method_exists($message, 'getHeader')) {
+ return null;
+ }
+
+ try {
+ $headerObj = $message->getHeader();
+ if ($headerObj && method_exists($headerObj, 'toString')) {
+ $raw = (string) $headerObj->toString();
+ if ($raw !== '') {
+ return $raw;
+ }
+ }
+ } catch (Throwable $e) {
+ return null;
+ }
+
+ return null;
+ }
+
+ private function bodyBestEffort($message): string
+ {
+ $text = ($message->getTextBody() ?? '');
+ if ($text !== '') {
+ return $text;
+ }
+ $html = ($message->getHTMLBody() ?? '');
+ if ($html !== '') {
+ return trim(strip_tags($html));
+ }
+
+ return '';
+ }
+
+ private function extractDate(mixed $message): DateTimeImmutable
+ {
+ $date = $message->getDate();
+ if ($date instanceof DateTimeInterface) {
+ return new DateTimeImmutable($date->format('Y-m-d H:i:s'));
+ }
+
+ if (method_exists($message, 'getInternalDate')) {
+ $internalDate = (int) $message->getInternalDate();
+ if ($internalDate > 0) {
+ return new DateTimeImmutable('@'.$internalDate);
+ }
+ }
+
+ return new DateTimeImmutable();
+ }
+
+ private function addrFirstToString($addresses): string
+ {
+ $many = $this->addrManyToArray($addresses);
+ return $many[0] ?? '';
+ }
+
+ private function addrManyToString($addresses): string
+ {
+ $arr = $this->addrManyToArray($addresses);
+ return implode(', ', $arr);
+ }
+
+ private function addrManyToArray($addresses): array
+ {
+ if ($addresses === null) {
+ return [];
+ }
+ $out = [];
+ foreach ($addresses as $addr) {
+ $email = ($addr->mail ?? $addr->getAddress() ?? '');
+ $name = ($addr->personal ?? $addr->getName() ?? '');
+ $out[] = $name !== '' ? sprintf('%s <%s>', $name, $email) : $email;
+ }
+
+ return $out;
+ }
+
+ private function processDelete(bool $testMode, bool $processed, mixed $message): void
+ {
+ if (!$testMode) {
+ if ($processed && $this->purgeProcessed) {
+ $this->safeDelete($message);
+ } elseif (!$processed && $this->purgeUnprocessed) {
+ $this->safeDelete($message);
+ }
+ }
+ }
+
+ private function safeDelete($message): void
+ {
+ try {
+ if (method_exists($message, 'delete')) {
+ $message->delete();
+ } elseif (method_exists($message, 'setFlag')) {
+ $message->setFlag('DELETED');
+ }
+ } catch (Throwable $e) {
+ $this->logger->warning('Failed to delete message', ['error' => $e->getMessage()]);
+ }
+ }
+
+ private function processExpunge(bool $testMode, ?Folder $folder, Client $client): void
+ {
+ if (!$testMode) {
+ try {
+ if (method_exists($folder, 'expunge')) {
+ $folder->expunge();
+ } elseif (method_exists($client, 'expunge')) {
+ $client->expunge();
+ }
+ } catch (Throwable $e) {
+ $this->logger->warning('EXPUNGE failed', ['error' => $e->getMessage()]);
+ }
+ }
+ }
+}
diff --git a/src/Domain/Messaging/Service/WebklexImapClientFactory.php b/src/Domain/Messaging/Service/WebklexImapClientFactory.php
new file mode 100644
index 00000000..10271e4c
--- /dev/null
+++ b/src/Domain/Messaging/Service/WebklexImapClientFactory.php
@@ -0,0 +1,79 @@
+clientManager = $clientManager;
+ $this->mailbox = $mailbox;
+ $this->host = $host;
+ $this->username = $username;
+ $this->password = $password;
+ $this->protocol = $protocol;
+ $this->port = $port;
+ $this->encryption = $encryption;
+ }
+
+ /**
+ * @param array $config
+ * @throws MaskNotFoundException
+ */
+ public function make(array $config): Client
+ {
+ return $this->clientManager->make($config);
+ }
+
+ public function makeForMailbox(): Client
+ {
+ return $this->make([
+ 'host' => $this->host,
+ 'port' => $this->port,
+ 'encryption' => $this->encryption,
+ 'validate_cert' => true,
+ 'username' => $this->username,
+ 'password' => $this->password,
+ 'protocol' => $this->protocol,
+ ]);
+ }
+
+ public function getFolderName(): string
+ {
+ return $this->parseMailbox($this->mailbox)[1];
+ }
+
+ private function parseMailbox(string $mailbox): array
+ {
+ if (str_contains($mailbox, '#')) {
+ [$host, $folder] = explode('#', $mailbox, 2);
+ $host = trim($host);
+ $folder = trim($folder) ?: 'INBOX';
+ return [$host, $folder];
+ }
+ return [trim($mailbox), 'INBOX'];
+ }
+}
diff --git a/src/Domain/Subscription/Model/SubscribePage.php b/src/Domain/Subscription/Model/SubscribePage.php
index 7ec518b2..979b3c4c 100644
--- a/src/Domain/Subscription/Model/SubscribePage.php
+++ b/src/Domain/Subscription/Model/SubscribePage.php
@@ -7,11 +7,13 @@
use Doctrine\ORM\Mapping as ORM;
use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
use PhpList\Core\Domain\Common\Model\Interfaces\Identity;
+use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface;
+use PhpList\Core\Domain\Identity\Model\Administrator;
use PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository;
#[ORM\Entity(repositoryClass: SubscriberPageRepository::class)]
#[ORM\Table(name: 'phplist_subscribepage')]
-class SubscribePage implements DomainModel, Identity
+class SubscribePage implements DomainModel, Identity, OwnableInterface
{
#[ORM\Id]
#[ORM\Column(type: 'integer')]
@@ -24,8 +26,9 @@ class SubscribePage implements DomainModel, Identity
#[ORM\Column(name: 'active', type: 'boolean', options: ['default' => 0])]
private bool $active = false;
- #[ORM\Column(name: 'owner', type: 'integer', nullable: true)]
- private ?int $owner = null;
+ #[ORM\ManyToOne(targetEntity: Administrator::class)]
+ #[ORM\JoinColumn(name: 'owner', referencedColumnName: 'id', nullable: true)]
+ private ?Administrator $owner = null;
public function getId(): ?int
{
@@ -42,7 +45,7 @@ public function isActive(): bool
return $this->active;
}
- public function getOwner(): ?int
+ public function getOwner(): ?Administrator
{
return $this->owner;
}
@@ -59,7 +62,7 @@ public function setActive(bool $active): self
return $this;
}
- public function setOwner(?int $owner): self
+ public function setOwner(?Administrator $owner): self
{
$this->owner = $owner;
return $this;
diff --git a/src/Domain/Subscription/Model/SubscriberList.php b/src/Domain/Subscription/Model/SubscriberList.php
index 947cbe26..32f85f5d 100644
--- a/src/Domain/Subscription/Model/SubscriberList.php
+++ b/src/Domain/Subscription/Model/SubscriberList.php
@@ -12,6 +12,7 @@
use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
use PhpList\Core\Domain\Common\Model\Interfaces\Identity;
use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate;
+use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface;
use PhpList\Core\Domain\Identity\Model\Administrator;
use PhpList\Core\Domain\Messaging\Model\ListMessage;
use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository;
@@ -28,7 +29,7 @@
#[ORM\Index(name: 'nameidx', columns: ['name'])]
#[ORM\Index(name: 'listorderidx', columns: ['listorder'])]
#[ORM\HasLifecycleCallbacks]
-class SubscriberList implements DomainModel, Identity, CreationDate, ModificationDate
+class SubscriberList implements DomainModel, Identity, CreationDate, ModificationDate, OwnableInterface
{
#[ORM\Id]
#[ORM\Column(type: 'integer')]
diff --git a/src/Domain/Identity/Model/UserBlacklist.php b/src/Domain/Subscription/Model/UserBlacklist.php
similarity index 72%
rename from src/Domain/Identity/Model/UserBlacklist.php
rename to src/Domain/Subscription/Model/UserBlacklist.php
index 1c9d0a30..eb24ded3 100644
--- a/src/Domain/Identity/Model/UserBlacklist.php
+++ b/src/Domain/Subscription/Model/UserBlacklist.php
@@ -2,12 +2,12 @@
declare(strict_types=1);
-namespace PhpList\Core\Domain\Identity\Model;
+namespace PhpList\Core\Domain\Subscription\Model;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
-use PhpList\Core\Domain\Identity\Repository\UserBlacklistRepository;
+use PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository;
#[ORM\Entity(repositoryClass: UserBlacklistRepository::class)]
#[ORM\Table(name: 'phplist_user_blacklist')]
@@ -21,6 +21,9 @@ class UserBlacklist implements DomainModel
#[ORM\Column(name: 'added', type: 'datetime', nullable: true)]
private ?DateTime $added = null;
+ #[ORM\OneToOne(targetEntity: UserBlacklistData::class, mappedBy: 'email')]
+ private ?UserBlacklistData $blacklistData = null;
+
public function getEmail(): string
{
return $this->email;
@@ -42,4 +45,9 @@ public function setAdded(?DateTime $added): self
$this->added = $added;
return $this;
}
+
+ public function getBlacklistData(): ?UserBlacklistData
+ {
+ return $this->blacklistData;
+ }
}
diff --git a/src/Domain/Identity/Model/UserBlacklistData.php b/src/Domain/Subscription/Model/UserBlacklistData.php
similarity index 90%
rename from src/Domain/Identity/Model/UserBlacklistData.php
rename to src/Domain/Subscription/Model/UserBlacklistData.php
index 09697616..f8d78c59 100644
--- a/src/Domain/Identity/Model/UserBlacklistData.php
+++ b/src/Domain/Subscription/Model/UserBlacklistData.php
@@ -2,11 +2,11 @@
declare(strict_types=1);
-namespace PhpList\Core\Domain\Identity\Model;
+namespace PhpList\Core\Domain\Subscription\Model;
use Doctrine\ORM\Mapping as ORM;
use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
-use PhpList\Core\Domain\Identity\Repository\UserBlacklistDataRepository;
+use PhpList\Core\Domain\Subscription\Repository\UserBlacklistDataRepository;
#[ORM\Entity(repositoryClass: UserBlacklistDataRepository::class)]
#[ORM\Table(name: 'phplist_user_blacklist_data')]
diff --git a/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php b/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php
index 565930d4..68d0d6bc 100644
--- a/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php
+++ b/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php
@@ -7,8 +7,21 @@
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Subscription\Model\SubscribePage;
+use PhpList\Core\Domain\Subscription\Model\SubscribePageData;
class SubscriberPageDataRepository extends AbstractRepository implements PaginatableRepositoryInterface
{
use CursorPaginationTrait;
+
+ public function findByPageAndName(SubscribePage $page, string $name): ?SubscribePageData
+ {
+ return $this->findOneBy(['id' => $page->getId(), 'name' => $name]);
+ }
+
+ /** @return SubscribePageData[] */
+ public function getByPage(SubscribePage $page): array
+ {
+ return $this->findBy(['id' => $page->getId()]);
+ }
}
diff --git a/src/Domain/Subscription/Repository/SubscriberPageRepository.php b/src/Domain/Subscription/Repository/SubscriberPageRepository.php
index 2a8383c0..136b589c 100644
--- a/src/Domain/Subscription/Repository/SubscriberPageRepository.php
+++ b/src/Domain/Subscription/Repository/SubscriberPageRepository.php
@@ -7,8 +7,24 @@
use PhpList\Core\Domain\Common\Repository\AbstractRepository;
use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Subscription\Model\SubscribePage;
+use PhpList\Core\Domain\Subscription\Model\SubscribePageData;
class SubscriberPageRepository extends AbstractRepository implements PaginatableRepositoryInterface
{
use CursorPaginationTrait;
+
+ /** @return array{page: SubscribePage, data: SubscribePageData}[] */
+ public function findPagesWithData(int $pageId): array
+ {
+ return $this->createQueryBuilder('p')
+ ->select('p AS page, d AS data')
+ ->from(SubscribePage::class, 'p')
+ ->from(SubscribePageData::class, 'd')
+ ->where('p.id = :id')
+ ->andWhere('d.id = p.id')
+ ->setParameter('id', $pageId)
+ ->getQuery()
+ ->getResult();
+ }
}
diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php
index 762096a0..3c3583b4 100644
--- a/src/Domain/Subscription/Repository/SubscriberRepository.php
+++ b/src/Domain/Subscription/Repository/SubscriberRepository.php
@@ -127,4 +127,65 @@ public function findSubscriberWithSubscriptions(int $id): ?Subscriber
->getQuery()
->getOneOrNullResult();
}
+
+ public function isEmailBlacklisted(string $email): bool
+ {
+ $queryBuilder = $this->getEntityManager()->createQueryBuilder();
+
+ $queryBuilder->select('u.email')
+ ->from(Subscriber::class, 'u')
+ ->where('u.email = :email')
+ ->andWhere('u.blacklisted = 1')
+ ->setParameter('email', $email)
+ ->setMaxResults(1);
+
+ return !($queryBuilder->getQuery()->getOneOrNullResult() === null);
+ }
+
+ public function incrementBounceCount(int $subscriberId): void
+ {
+ $this->createQueryBuilder('s')
+ ->update()
+ ->set('s.bounceCount', 's.bounceCount + 1')
+ ->where('s.id = :subscriberId')
+ ->setParameter('subscriberId', $subscriberId)
+ ->getQuery()
+ ->execute();
+ }
+
+ public function markUnconfirmed(int $subscriberId): void
+ {
+ $this->createQueryBuilder('s')
+ ->update()
+ ->set('s.confirmed', ':confirmed')
+ ->where('s.id = :id')
+ ->setParameter('confirmed', false)
+ ->setParameter('id', $subscriberId)
+ ->getQuery()
+ ->execute();
+ }
+
+ public function markConfirmed(int $subscriberId): void
+ {
+ $this->createQueryBuilder('s')
+ ->update()
+ ->set('s.confirmed', ':confirmed')
+ ->where('s.id = :id')
+ ->setParameter('confirmed', true)
+ ->setParameter('id', $subscriberId)
+ ->getQuery()
+ ->execute();
+ }
+
+ /** @return Subscriber[] */
+ public function distinctUsersWithBouncesConfirmedNotBlacklisted(): array
+ {
+ return $this->createQueryBuilder('s')
+ ->select('s.id')
+ ->where('s.bounceCount > 0')
+ ->andWhere('s.confirmed = 1')
+ ->andWhere('s.blacklisted = 0')
+ ->getQuery()
+ ->getScalarResult();
+ }
}
diff --git a/src/Domain/Subscription/Repository/UserBlacklistDataRepository.php b/src/Domain/Subscription/Repository/UserBlacklistDataRepository.php
new file mode 100644
index 00000000..a64525b9
--- /dev/null
+++ b/src/Domain/Subscription/Repository/UserBlacklistDataRepository.php
@@ -0,0 +1,16 @@
+findOneBy(['email' => $email]);
+ }
+}
diff --git a/src/Domain/Subscription/Repository/UserBlacklistRepository.php b/src/Domain/Subscription/Repository/UserBlacklistRepository.php
new file mode 100644
index 00000000..665deb64
--- /dev/null
+++ b/src/Domain/Subscription/Repository/UserBlacklistRepository.php
@@ -0,0 +1,33 @@
+getEntityManager()->createQueryBuilder();
+
+ $queryBuilder->select('ub.email, ub.added, ubd.data AS reason')
+ ->from(UserBlacklist::class, 'ub')
+ ->innerJoin(UserBlacklistData::class, 'ubd', 'WITH', 'ub.email = ubd.email')
+ ->where('ub.email = :email')
+ ->setParameter('email', $email)
+ ->setMaxResults(1);
+
+ return $queryBuilder->getQuery()->getOneOrNullResult();
+ }
+
+ public function findOneByEmail(string $email): ?UserBlacklist
+ {
+ return $this->findOneBy([
+ 'email' => $email,
+ ]);
+ }
+}
diff --git a/src/Domain/Subscription/Service/Manager/SubscribePageManager.php b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php
new file mode 100644
index 00000000..8e429dc4
--- /dev/null
+++ b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php
@@ -0,0 +1,105 @@
+setTitle($title)
+ ->setActive($active)
+ ->setOwner($owner);
+
+ $this->pageRepository->save($page);
+
+ return $page;
+ }
+
+ public function getPage(int $id): SubscribePage
+ {
+ /** @var SubscribePage|null $page */
+ $page = $this->pageRepository->find($id);
+ if (!$page) {
+ throw new NotFoundHttpException('Subscribe page not found');
+ }
+
+ return $page;
+ }
+
+ public function updatePage(
+ SubscribePage $page,
+ ?string $title = null,
+ ?bool $active = null,
+ ?Administrator $owner = null
+ ): SubscribePage {
+ if ($title !== null) {
+ $page->setTitle($title);
+ }
+ if ($active !== null) {
+ $page->setActive($active);
+ }
+ if ($owner !== null) {
+ $page->setOwner($owner);
+ }
+
+ $this->entityManager->flush();
+
+ return $page;
+ }
+
+ public function setActive(SubscribePage $page, bool $active): void
+ {
+ $page->setActive($active);
+ $this->entityManager->flush();
+ }
+
+ public function deletePage(SubscribePage $page): void
+ {
+ $this->pageRepository->remove($page);
+ }
+
+ /** @return SubscribePageData[] */
+ public function getPageData(SubscribePage $page): array
+ {
+ return $this->pageDataRepository->getByPage($page,);
+ }
+
+ public function setPageData(SubscribePage $page, string $name, ?string $value): SubscribePageData
+ {
+ /** @var SubscribePageData|null $data */
+ $data = $this->pageDataRepository->findByPageAndName($page, $name);
+
+ if (!$data) {
+ $data = (new SubscribePageData())
+ ->setId((int)$page->getId())
+ ->setName($name);
+ $this->entityManager->persist($data);
+ }
+
+ $data->setData($value);
+ $this->entityManager->flush();
+
+ return $data;
+ }
+}
diff --git a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php
new file mode 100644
index 00000000..d5828c2f
--- /dev/null
+++ b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php
@@ -0,0 +1,96 @@
+subscriberRepository->isEmailBlacklisted($email);
+ }
+
+ public function getBlacklistInfo(string $email): ?UserBlacklist
+ {
+ return $this->userBlacklistRepository->findBlacklistInfoByEmail($email);
+ }
+
+ public function addEmailToBlacklist(string $email, ?string $reasonData = null): UserBlacklist
+ {
+ $existing = $this->subscriberRepository->isEmailBlacklisted($email);
+ if ($existing) {
+ return $this->getBlacklistInfo($email);
+ }
+
+ $blacklistEntry = new UserBlacklist();
+ $blacklistEntry->setEmail($email);
+ $blacklistEntry->setAdded(new DateTime());
+
+ $this->entityManager->persist($blacklistEntry);
+
+ if ($reasonData !== null) {
+ $blacklistData = new UserBlacklistData();
+ $blacklistData->setEmail($email);
+ $blacklistData->setName('reason');
+ $blacklistData->setData($reasonData);
+ $this->entityManager->persist($blacklistData);
+ }
+
+ $this->entityManager->flush();
+
+ return $blacklistEntry;
+ }
+
+ public function addBlacklistData(string $email, string $name, string $data): void
+ {
+ $blacklistData = new UserBlacklistData();
+ $blacklistData->setEmail($email);
+ $blacklistData->setName($name);
+ $blacklistData->setData($data);
+ $this->entityManager->persist($blacklistData);
+ $this->entityManager->flush();
+ }
+
+ public function removeEmailFromBlacklist(string $email): void
+ {
+ $blacklistEntry = $this->userBlacklistRepository->findOneByEmail($email);
+ if ($blacklistEntry) {
+ $this->entityManager->remove($blacklistEntry);
+ }
+
+ $blacklistData = $this->blacklistDataRepository->findOneByEmail($email);
+ if ($blacklistData) {
+ $this->entityManager->remove($blacklistData);
+ }
+
+ $subscriber = $this->subscriberRepository->findOneByEmail($email);
+ if ($subscriber) {
+ $subscriber->setBlacklisted(false);
+ }
+
+ $this->entityManager->flush();
+ }
+
+ public function getBlacklistReason(string $email): ?string
+ {
+ $data = $this->blacklistDataRepository->findOneByEmail($email);
+ return $data ? $data->getData() : null;
+ }
+}
diff --git a/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php b/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php
index 4760acd8..bac2ef8d 100644
--- a/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php
@@ -4,20 +4,44 @@
namespace PhpList\Core\Domain\Subscription\Service\Manager;
+use PhpList\Core\Domain\Common\ClientIpResolver;
+use PhpList\Core\Domain\Common\SystemInfoCollector;
use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberHistoryFilter;
+use PhpList\Core\Domain\Subscription\Model\Subscriber;
+use PhpList\Core\Domain\Subscription\Model\SubscriberHistory;
use PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository;
class SubscriberHistoryManager
{
private SubscriberHistoryRepository $repository;
+ private ClientIpResolver $clientIpResolver;
+ private SystemInfoCollector $systemInfoCollector;
- public function __construct(SubscriberHistoryRepository $repository)
- {
+ public function __construct(
+ SubscriberHistoryRepository $repository,
+ ClientIpResolver $clientIpResolver,
+ SystemInfoCollector $systemInfoCollector,
+ ) {
$this->repository = $repository;
+ $this->clientIpResolver = $clientIpResolver;
+ $this->systemInfoCollector = $systemInfoCollector;
}
public function getHistory(int $lastId, int $limit, SubscriberHistoryFilter $filter): array
{
return $this->repository->getFilteredAfterId($lastId, $limit, $filter);
}
+
+ public function addHistory(Subscriber $subscriber, string $message, ?string $details = null): SubscriberHistory
+ {
+ $subscriberHistory = new SubscriberHistory($subscriber);
+ $subscriberHistory->setSummary($message);
+ $subscriberHistory->setDetail($details ?? $message);
+ $subscriberHistory->setSystemInfo($this->systemInfoCollector->collectAsString());
+ $subscriberHistory->setIp($this->clientIpResolver->resolve());
+
+ $this->repository->save($subscriberHistory);
+
+ return $subscriberHistory;
+ }
}
diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php
index e036f195..73531fbb 100644
--- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php
@@ -11,6 +11,7 @@
use PhpList\Core\Domain\Subscription\Model\Dto\UpdateSubscriberDto;
use PhpList\Core\Domain\Subscription\Model\Subscriber;
use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
+use PhpList\Core\Domain\Subscription\Service\SubscriberBlacklistService;
use PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
@@ -26,7 +27,7 @@ public function __construct(
SubscriberRepository $subscriberRepository,
EntityManagerInterface $entityManager,
MessageBusInterface $messageBus,
- SubscriberDeletionService $subscriberDeletionService
+ SubscriberDeletionService $subscriberDeletionService,
) {
$this->subscriberRepository = $subscriberRepository;
$this->entityManager = $entityManager;
@@ -64,15 +65,9 @@ private function sendConfirmationEmail(Subscriber $subscriber): void
$this->messageBus->dispatch($message);
}
- public function getSubscriber(int $subscriberId): Subscriber
+ public function getSubscriberById(int $subscriberId): ?Subscriber
{
- $subscriber = $this->subscriberRepository->findSubscriberWithSubscriptions($subscriberId);
-
- if (!$subscriber) {
- throw new NotFoundHttpException('Subscriber not found');
- }
-
- return $subscriber;
+ return $this->subscriberRepository->find($subscriberId);
}
public function updateSubscriber(UpdateSubscriberDto $subscriberDto): Subscriber
@@ -140,4 +135,10 @@ public function updateFromImport(Subscriber $existingSubscriber, ImportSubscribe
return $existingSubscriber;
}
+
+ public function decrementBounceCount(Subscriber $subscriber): void
+ {
+ $subscriber->addToBounceCount(-1);
+ $this->entityManager->flush();
+ }
}
diff --git a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php
index bb3a0e14..764106ec 100644
--- a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php
@@ -4,6 +4,7 @@
namespace PhpList\Core\Domain\Subscription\Service\Manager;
+use PhpList\Core\Domain\Common\I18n\Messages;
use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException;
use PhpList\Core\Domain\Subscription\Model\Subscriber;
use PhpList\Core\Domain\Subscription\Model\SubscriberList;
@@ -11,21 +12,25 @@
use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository;
use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
use PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository;
+use Symfony\Contracts\Translation\TranslatorInterface;
class SubscriptionManager
{
private SubscriptionRepository $subscriptionRepository;
private SubscriberRepository $subscriberRepository;
private SubscriberListRepository $subscriberListRepository;
+ private TranslatorInterface $translator;
public function __construct(
SubscriptionRepository $subscriptionRepository,
SubscriberRepository $subscriberRepository,
- SubscriberListRepository $subscriberListRepository
+ SubscriberListRepository $subscriberListRepository,
+ TranslatorInterface $translator
) {
$this->subscriptionRepository = $subscriptionRepository;
$this->subscriberRepository = $subscriberRepository;
$this->subscriberListRepository = $subscriberListRepository;
+ $this->translator = $translator;
}
public function addSubscriberToAList(Subscriber $subscriber, int $listId): Subscription
@@ -37,7 +42,8 @@ public function addSubscriberToAList(Subscriber $subscriber, int $listId): Subsc
}
$subscriberList = $this->subscriberListRepository->find($listId);
if (!$subscriberList) {
- throw new SubscriptionCreationException('Subscriber list not found.', 404);
+ $message = $this->translator->trans(Messages::SUBSCRIPTION_LIST_NOT_FOUND);
+ throw new SubscriptionCreationException($message, 404);
}
$subscription = new Subscription();
@@ -64,7 +70,8 @@ private function createSubscription(SubscriberList $subscriberList, string $emai
{
$subscriber = $this->subscriberRepository->findOneBy(['email' => $email]);
if (!$subscriber) {
- throw new SubscriptionCreationException('Subscriber does not exists.', 404);
+ $message = $this->translator->trans(Messages::SUBSCRIPTION_SUBSCRIBER_NOT_FOUND);
+ throw new SubscriptionCreationException($message, 404);
}
$existingSubscription = $this->subscriptionRepository
@@ -101,7 +108,8 @@ private function deleteSubscription(SubscriberList $subscriberList, string $emai
->findOneBySubscriberEmailAndListId($subscriberList->getId(), $email);
if (!$subscription) {
- throw new SubscriptionCreationException('Subscription not found for this subscriber and list.', 404);
+ $message = $this->translator->trans(Messages::SUBSCRIPTION_NOT_FOUND_FOR_LIST_AND_SUBSCRIBER);
+ throw new SubscriptionCreationException($message, 404);
}
$this->subscriptionRepository->remove($subscription);
diff --git a/src/Domain/Subscription/Service/SubscriberBlacklistService.php b/src/Domain/Subscription/Service/SubscriberBlacklistService.php
new file mode 100644
index 00000000..d9ca5ea6
--- /dev/null
+++ b/src/Domain/Subscription/Service/SubscriberBlacklistService.php
@@ -0,0 +1,69 @@
+entityManager = $entityManager;
+ $this->blacklistManager = $blacklistManager;
+ $this->historyManager = $historyManager;
+ $this->requestStack = $requestStack;
+ }
+
+ /**
+ * @SuppressWarnings(PHPMD.Superglobals)
+ */
+ public function blacklist(Subscriber $subscriber, string $reason): void
+ {
+ $subscriber->setBlacklisted(true);
+ $this->entityManager->flush();
+ $this->blacklistManager->addEmailToBlacklist($subscriber->getEmail(), $reason);
+
+ foreach (['REMOTE_ADDR','HTTP_X_FORWARDED_FOR'] as $item) {
+ $request = $this->requestStack->getCurrentRequest();
+ if (!$request) {
+ return;
+ }
+ if ($request->server->get($item)) {
+ $this->blacklistManager->addBlacklistData(
+ email: $subscriber->getEmail(),
+ name: $item,
+ data: $request->server->get($item)
+ );
+ }
+ }
+
+ $this->historyManager->addHistory(
+ subscriber: $subscriber,
+ message: 'Added to blacklist',
+ details: sprintf('Added to blacklist for reason %s', $reason)
+ );
+
+ if (isset($GLOBALS['plugins']) && is_array($GLOBALS['plugins'])) {
+ foreach ($GLOBALS['plugins'] as $plugin) {
+ if (method_exists($plugin, 'blacklistEmail')) {
+ $plugin->blacklistEmail($subscriber->getEmail(), $reason);
+ }
+ }
+ }
+ }
+}
diff --git a/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php b/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php
new file mode 100644
index 00000000..50820026
--- /dev/null
+++ b/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php
@@ -0,0 +1,35 @@
+checker = self::getContainer()->get(PermissionChecker::class);
+ }
+
+ public function testServiceIsRegisteredInContainer(): void
+ {
+ self::assertInstanceOf(PermissionChecker::class, $this->checker);
+ self::assertSame($this->checker, self::getContainer()->get(PermissionChecker::class));
+ }
+
+ public function testSuperUserCanManageAnyResource(): void
+ {
+ $admin = new Administrator();
+ $admin->setSuperUser(true);
+ $resource = $this->createMock(SubscriberList::class);
+ $this->assertTrue($this->checker->canManage($admin, $resource));
+ }
+}
diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php
index e6d42236..b3bfda0c 100644
--- a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php
+++ b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php
@@ -4,6 +4,7 @@
namespace PhpList\Core\Tests\Integration\Domain\Subscription\Service;
+use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Exception;
@@ -94,7 +95,7 @@ public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError():
$userMessage->setStatus('sent');
$this->entityManager->persist($userMessage);
- $userMessageBounce = new UserMessageBounce(1);
+ $userMessageBounce = new UserMessageBounce(1, new DateTime());
$userMessageBounce->setUserId($subscriberId);
$userMessageBounce->setMessageId(1);
$this->entityManager->persist($userMessageBounce);
diff --git a/tests/Unit/Domain/Common/ClientIpResolverTest.php b/tests/Unit/Domain/Common/ClientIpResolverTest.php
new file mode 100644
index 00000000..e69e9f89
--- /dev/null
+++ b/tests/Unit/Domain/Common/ClientIpResolverTest.php
@@ -0,0 +1,61 @@
+requestStack = $this->createMock(RequestStack::class);
+ }
+
+ public function testResolveReturnsClientIpFromCurrentRequest(): void
+ {
+ $request = $this->createMock(Request::class);
+ $request->method('getClientIp')->willReturn('203.0.113.10');
+
+ $this->requestStack
+ ->method('getCurrentRequest')
+ ->willReturn($request);
+
+ $resolver = new ClientIpResolver($this->requestStack);
+ $this->assertSame('203.0.113.10', $resolver->resolve());
+ }
+
+ public function testResolveReturnsEmptyStringWhenClientIpIsNull(): void
+ {
+ $request = $this->createMock(Request::class);
+ $request->method('getClientIp')->willReturn(null);
+
+ $this->requestStack
+ ->method('getCurrentRequest')
+ ->willReturn($request);
+
+ $resolver = new ClientIpResolver($this->requestStack);
+ $this->assertSame('', $resolver->resolve());
+ }
+
+ public function testResolveReturnsHostAndPidWhenNoRequestAvailable(): void
+ {
+ $this->requestStack
+ ->method('getCurrentRequest')
+ ->willReturn(null);
+
+ $resolver = new ClientIpResolver($this->requestStack);
+
+ $expectedHost = gethostname() ?: 'localhost';
+ $expected = $expectedHost . ':' . getmypid();
+
+ $this->assertSame($expected, $resolver->resolve());
+ }
+}
diff --git a/tests/Unit/Domain/Common/SystemInfoCollectorTest.php b/tests/Unit/Domain/Common/SystemInfoCollectorTest.php
new file mode 100644
index 00000000..7bf964d7
--- /dev/null
+++ b/tests/Unit/Domain/Common/SystemInfoCollectorTest.php
@@ -0,0 +1,95 @@
+requestStack = $this->createMock(RequestStack::class);
+ }
+
+ public function testCollectReturnsSanitizedPairsWithDefaults(): void
+ {
+ $server = [
+ 'HTTP_USER_AGENT' => 'Agent X"',
+ 'HTTP_REFERER' => 'https://example.com/?q=',
+ 'HTTP_X_FORWARDED_FOR' => '198.51.100.5, 203.0.113.7',
+ 'REQUEST_URI' => '/path?x=1&y="z"',
+ 'REMOTE_ADDR' => '203.0.113.10',
+ ];
+ $request = new Request(query: [], request: [], attributes: [], cookies: [], files: [], server: $server);
+
+ $this->requestStack->method('getCurrentRequest')->willReturn($request);
+
+ $collector = new SystemInfoCollector($this->requestStack);
+ $result = $collector->collect();
+
+ $expected = [
+ 'HTTP_USER_AGENT' => 'Agent <b>X</b>"',
+ 'HTTP_REFERER' => 'https://example.com/?q=<script>alert(1)</script>',
+ 'REMOTE_ADDR' => '203.0.113.10',
+ 'REQUEST_URI' => '/path?x=1&y="z"<w>',
+ 'HTTP_X_FORWARDED_FOR' => '198.51.100.5, 203.0.113.7',
+ ];
+
+ $this->assertSame($expected, $result);
+ }
+
+ public function testCollectUsesConfiguredKeysAndSkipsMissing(): void
+ {
+ $server = [
+ 'HTTP_USER_AGENT' => 'UA',
+ 'REQUEST_URI' => '/only/uri',
+ 'REMOTE_ADDR' => '198.51.100.10',
+ ];
+ $request = new Request(query: [], request: [], attributes: [], cookies: [], files: [], server: $server);
+ $this->requestStack->method('getCurrentRequest')->willReturn($request);
+
+ $collector = new SystemInfoCollector($this->requestStack, ['REQUEST_URI', 'UNKNOWN', 'REMOTE_ADDR']);
+ $result = $collector->collect();
+
+ $expected = [
+ 'REQUEST_URI' => '/only/uri',
+ 'REMOTE_ADDR' => '198.51.100.10',
+ ];
+
+ $this->assertSame($expected, $result);
+ }
+
+ public function testCollectAsStringFormatsLinesWithLeadingNewline(): void
+ {
+ $server = [
+ 'HTTP_USER_AGENT' => 'UA',
+ 'HTTP_REFERER' => 'https://ref.example',
+ 'REMOTE_ADDR' => '192.0.2.5',
+ 'REQUEST_URI' => '/abc',
+ 'HTTP_X_FORWARDED_FOR' => '1.1.1.1',
+ ];
+ $request = new Request(query: [], request: [], attributes: [], cookies: [], files: [], server: $server);
+ $this->requestStack->method('getCurrentRequest')->willReturn($request);
+
+ $collector = new SystemInfoCollector($this->requestStack);
+ $string = $collector->collectAsString();
+
+ $expected = "\n" . implode("\n", [
+ 'HTTP_USER_AGENT = UA',
+ 'HTTP_REFERER = https://ref.example',
+ 'REMOTE_ADDR = 192.0.2.5',
+ 'REQUEST_URI = /abc',
+ 'HTTP_X_FORWARDED_FOR = 1.1.1.1',
+ ]);
+
+ $this->assertSame($expected, $string);
+ }
+}
diff --git a/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php b/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php
new file mode 100644
index 00000000..818b8de0
--- /dev/null
+++ b/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php
@@ -0,0 +1,94 @@
+repository = $this->createMock(EventLogRepository::class);
+ $this->manager = new EventLogManager($this->repository);
+ }
+
+ public function testLogCreatesAndPersists(): void
+ {
+ $this->repository->expects($this->once())
+ ->method('save')
+ ->with($this->isInstanceOf(EventLog::class));
+
+ $log = $this->manager->log('dashboard', 'Viewed dashboard');
+
+ $this->assertInstanceOf(EventLog::class, $log);
+ $this->assertSame('dashboard', $log->getPage());
+ $this->assertSame('Viewed dashboard', $log->getEntry());
+ $this->assertNotNull($log->getEntered());
+ $this->assertInstanceOf(DateTimeImmutable::class, $log->getEntered());
+ }
+
+ public function testDelete(): void
+ {
+ $log = new EventLog();
+ $this->repository->expects($this->once())
+ ->method('remove')
+ ->with($log);
+
+ $this->manager->delete($log);
+ }
+
+ public function testGetWithFiltersDelegatesToRepository(): void
+ {
+ $expected = [new EventLog(), new EventLog()];
+
+ $this->repository->expects($this->once())
+ ->method('getFilteredAfterId')
+ ->with(
+ 100,
+ 25,
+ $this->callback(function (EventLogFilter $filter) {
+ // Use getters to validate
+ return method_exists($filter, 'getPage')
+ && $filter->getPage() === 'settings'
+ && $filter->getDateFrom() instanceof DateTimeImmutable
+ && $filter->getDateTo() instanceof DateTimeImmutable
+ && $filter->getDateFrom() <= $filter->getDateTo();
+ })
+ )
+ ->willReturn($expected);
+
+ $from = new DateTimeImmutable('-2 days');
+ $to = new DateTimeImmutable('now');
+ $result = $this->manager->get(lastId: 100, limit: 25, page: 'settings', dateFrom: $from, dateTo: $to);
+
+ $this->assertSame($expected, $result);
+ }
+
+ public function testGetWithoutFiltersDefaults(): void
+ {
+ $expected = [];
+
+ $this->repository->expects($this->once())
+ ->method('getFilteredAfterId')
+ ->with(
+ 0,
+ 50,
+ $this->anything()
+ )
+ ->willReturn($expected);
+
+ $result = $this->manager->get();
+ $this->assertSame($expected, $result);
+ }
+}
diff --git a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php
index 85e02f81..59ace13d 100644
--- a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php
+++ b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php
@@ -17,6 +17,7 @@
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Contracts\Translation\TranslatorInterface;
class PasswordManagerTest extends TestCase
{
@@ -36,7 +37,8 @@ protected function setUp(): void
passwordRequestRepository: $this->passwordRequestRepository,
administratorRepository: $this->administratorRepository,
hashGenerator: $this->hashGenerator,
- messageBus: $this->messageBus
+ messageBus: $this->messageBus,
+ translator: $this->createMock(TranslatorInterface::class)
);
}
diff --git a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php
index 44072452..14419b0e 100644
--- a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php
+++ b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php
@@ -4,16 +4,19 @@
namespace PhpList\Core\Tests\Unit\Domain\Identity\Service;
+use PhpList\Core\Domain\Common\I18n\Messages;
+use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager;
use PhpList\Core\Domain\Identity\Model\AdministratorToken;
use PhpList\Core\Domain\Identity\Repository\AdministratorRepository;
use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository;
use PhpList\Core\Domain\Identity\Service\SessionManager;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
+use Symfony\Contracts\Translation\TranslatorInterface;
class SessionManagerTest extends TestCase
{
- public function testCreateSessionWithInvalidCredentialsThrowsException(): void
+ public function testCreateSessionWithInvalidCredentialsThrowsExceptionAndLogs(): void
{
$adminRepo = $this->createMock(AdministratorRepository::class);
$adminRepo->expects(self::once())
@@ -24,7 +27,24 @@ public function testCreateSessionWithInvalidCredentialsThrowsException(): void
$tokenRepo = $this->createMock(AdministratorTokenRepository::class);
$tokenRepo->expects(self::never())->method('save');
- $manager = new SessionManager($tokenRepo, $adminRepo);
+ $eventLogManager = $this->createMock(EventLogManager::class);
+ $eventLogManager->expects(self::once())
+ ->method('log')
+ ->with('login', $this->stringContains('admin'));
+
+ $translator = $this->createMock(TranslatorInterface::class);
+ $translator->expects(self::exactly(2))
+ ->method('trans')
+ ->withConsecutive(
+ [Messages::AUTH_LOGIN_FAILED, ['login' => 'admin']],
+ [Messages::AUTH_NOT_AUTHORIZED, []]
+ )
+ ->willReturnOnConsecutiveCalls(
+ "Failed admin login attempt for 'admin'",
+ 'Not authorized'
+ );
+
+ $manager = new SessionManager($tokenRepo, $adminRepo, $eventLogManager, $translator);
$this->expectException(UnauthorizedHttpException::class);
$this->expectExceptionMessage('Not authorized');
@@ -42,8 +62,10 @@ public function testDeleteSessionCallsRemove(): void
->with($token);
$adminRepo = $this->createMock(AdministratorRepository::class);
+ $eventLogManager = $this->createMock(EventLogManager::class);
+ $translator = $this->createMock(TranslatorInterface::class);
- $manager = new SessionManager($tokenRepo, $adminRepo);
+ $manager = new SessionManager($tokenRepo, $adminRepo, $eventLogManager, $translator);
$manager->deleteSession($token);
}
}
diff --git a/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php
new file mode 100644
index 00000000..50cce9fa
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php
@@ -0,0 +1,197 @@
+lockService = $this->createMock(LockService::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->protocolProcessor = $this->createMock(BounceProtocolProcessor::class);
+ $this->advancedRulesProcessor = $this->createMock(AdvancedBounceRulesProcessor::class);
+ $this->unidentifiedReprocessor = $this->createMock(UnidentifiedBounceReprocessor::class);
+ $this->consecutiveBounceHandler = $this->createMock(ConsecutiveBounceHandler::class);
+
+ $command = new ProcessBouncesCommand(
+ lockService: $this->lockService,
+ logger: $this->logger,
+ protocolProcessors: [$this->protocolProcessor],
+ advancedRulesProcessor: $this->advancedRulesProcessor,
+ unidentifiedReprocessor: $this->unidentifiedReprocessor,
+ consecutiveBounceHandler: $this->consecutiveBounceHandler,
+ );
+
+ $this->commandTester = new CommandTester($command);
+ }
+
+ public function testExecuteWhenLockNotAcquired(): void
+ {
+ $this->lockService->expects($this->once())
+ ->method('acquirePageLock')
+ ->with('bounce_processor', false)
+ ->willReturn(null);
+
+ $this->protocolProcessor->expects($this->never())->method('getProtocol');
+ $this->protocolProcessor->expects($this->never())->method('process');
+ $this->unidentifiedReprocessor->expects($this->never())->method('process');
+ $this->advancedRulesProcessor->expects($this->never())->method('process');
+ $this->consecutiveBounceHandler->expects($this->never())->method('handle');
+
+ $this->commandTester->execute([]);
+
+ $output = $this->commandTester->getDisplay();
+ $this->assertStringContainsString('Another bounce processing is already running. Aborting.', $output);
+ $this->assertSame(0, $this->commandTester->getStatusCode());
+ }
+
+ public function testExecuteWithUnsupportedProtocol(): void
+ {
+ $this->lockService
+ ->expects($this->once())
+ ->method('acquirePageLock')
+ ->with('bounce_processor', false)
+ ->willReturn(123);
+ $this->lockService
+ ->expects($this->once())
+ ->method('release')
+ ->with(123);
+
+ $this->protocolProcessor->method('getProtocol')->willReturn('pop');
+ $this->protocolProcessor->expects($this->never())->method('process');
+
+ $this->commandTester->execute([
+ '--protocol' => 'mbox',
+ ]);
+
+ $output = $this->commandTester->getDisplay();
+ $this->assertStringContainsString('Unsupported protocol: mbox', $output);
+ $this->assertSame(1, $this->commandTester->getStatusCode());
+ }
+
+ public function testSuccessfulProcessingFlow(): void
+ {
+ $this->lockService
+ ->expects($this->once())
+ ->method('acquirePageLock')
+ ->with('bounce_processor', false)
+ ->willReturn(456);
+ $this->lockService
+ ->expects($this->once())
+ ->method('release')
+ ->with(456);
+
+ $this->protocolProcessor->method('getProtocol')->willReturn('pop');
+ $this->protocolProcessor
+ ->expects($this->once())
+ ->method('process')
+ ->with(
+ $this->callback(function ($input) {
+ return $input->getOption('protocol') === 'pop'
+ && $input->getOption('test') === false
+ && $input->getOption('purge-unprocessed') === false;
+ }),
+ $this->anything()
+ )
+ ->willReturn('downloaded 10 messages');
+
+ $this->unidentifiedReprocessor
+ ->expects($this->once())
+ ->method('process')
+ ->with($this->anything());
+
+ $this->advancedRulesProcessor
+ ->expects($this->once())
+ ->method('process')
+ ->with($this->anything(), 1000);
+
+ $this->consecutiveBounceHandler
+ ->expects($this->once())
+ ->method('handle')
+ ->with($this->anything());
+
+ $this->logger
+ ->expects($this->once())
+ ->method('info')
+ ->with('Bounce processing completed', $this->arrayHasKey('downloadReport'));
+
+ $this->commandTester->execute([]);
+
+ $output = $this->commandTester->getDisplay();
+ $this->assertStringContainsString('Bounce processing completed.', $output);
+ $this->assertSame(0, $this->commandTester->getStatusCode());
+ }
+
+ public function testProcessingFlowWhenProcessorThrowsException(): void
+ {
+ $this->lockService
+ ->expects($this->once())
+ ->method('acquirePageLock')
+ ->with('bounce_processor', false)
+ ->willReturn(42);
+ $this->lockService
+ ->expects($this->once())
+ ->method('release')
+ ->with(42);
+
+ $this->protocolProcessor->method('getProtocol')->willReturn('pop');
+
+ $this->protocolProcessor
+ ->expects($this->once())
+ ->method('process')
+ ->willThrowException(new Exception('boom'));
+
+ $this->unidentifiedReprocessor->expects($this->never())->method('process');
+ $this->advancedRulesProcessor->expects($this->never())->method('process');
+ $this->consecutiveBounceHandler->expects($this->never())->method('handle');
+
+ $this->logger
+ ->expects($this->once())
+ ->method('error')
+ ->with('Bounce processing failed', $this->arrayHasKey('exception'));
+
+ $this->commandTester->execute([]);
+
+ $output = $this->commandTester->getDisplay();
+ $this->assertStringContainsString('Error: boom', $output);
+ $this->assertSame(1, $this->commandTester->getStatusCode());
+ }
+
+ public function testForceOptionIsPassedToLockService(): void
+ {
+ $this->lockService->expects($this->once())
+ ->method('acquirePageLock')
+ ->with('bounce_processor', true)
+ ->willReturn(1);
+ $this->protocolProcessor->method('getProtocol')->willReturn('pop');
+
+ $this->commandTester->execute([
+ '--force' => true,
+ ]);
+
+ $this->assertSame(0, $this->commandTester->getStatusCode());
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php
index 489b5d60..79ece9bd 100644
--- a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php
+++ b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php
@@ -8,8 +8,8 @@
use PhpList\Core\Domain\Messaging\Command\ProcessQueueCommand;
use PhpList\Core\Domain\Messaging\Model\Message;
use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
-use PhpList\Core\Domain\Messaging\Service\CampaignProcessor;
use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator;
+use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
diff --git a/tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php b/tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php
new file mode 100644
index 00000000..49d4aadb
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php
@@ -0,0 +1,66 @@
+fooHandler = $this->createMock(BounceActionHandlerInterface::class);
+ $this->barHandler = $this->createMock(BounceActionHandlerInterface::class);
+ $this->fooHandler->method('supports')->willReturnCallback(fn ($action) => $action === 'foo');
+ $this->barHandler->method('supports')->willReturnCallback(fn ($action) => $action === 'bar');
+
+ $this->resolver = new BounceActionResolver(
+ [
+ $this->fooHandler,
+ $this->barHandler,
+ ]
+ );
+ }
+
+ public function testHasReturnsTrueWhenHandlerSupportsAction(): void
+ {
+ $this->assertTrue($this->resolver->has('foo'));
+ $this->assertTrue($this->resolver->has('bar'));
+ $this->assertFalse($this->resolver->has('baz'));
+ }
+
+ public function testResolveReturnsSameInstanceAndCaches(): void
+ {
+ $first = $this->resolver->resolve('foo');
+ $second = $this->resolver->resolve('foo');
+
+ $this->assertSame($first, $second);
+
+ $this->assertInstanceOf(BounceActionHandlerInterface::class, $first);
+ }
+
+ public function testResolveThrowsWhenNoHandlerFound(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('No handler found for action "baz".');
+
+ $this->resolver->resolve('baz');
+ }
+
+ public function testHandleDelegatesToResolvedHandler(): void
+ {
+ $context = ['key' => 'value', 'n' => 42];
+ $this->fooHandler->expects($this->once())->method('handle');
+ $this->barHandler->expects($this->never())->method('handle');
+ $this->resolver->handle('foo', $context);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php
new file mode 100644
index 00000000..1cb1b6d2
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php
@@ -0,0 +1,212 @@
+bounceManager = $this->createMock(BounceManager::class);
+ $this->subscriberRepository = $this->createMock(SubscriberRepository::class);
+ $this->subscriberHistoryManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->blacklistService = $this->createMock(SubscriberBlacklistService::class);
+ $this->io = $this->createMock(SymfonyStyle::class);
+
+ $this->io->method('section');
+ $this->io->method('writeln');
+
+ $unsubscribeThreshold = 2;
+ $blacklistThreshold = 3;
+
+ $this->handler = new ConsecutiveBounceHandler(
+ bounceManager: $this->bounceManager,
+ subscriberRepository: $this->subscriberRepository,
+ subscriberHistoryManager: $this->subscriberHistoryManager,
+ blacklistService: $this->blacklistService,
+ unsubscribeThreshold: $unsubscribeThreshold,
+ blacklistThreshold: $blacklistThreshold,
+ );
+ }
+
+ public function testHandleWithNoUsers(): void
+ {
+ $this->subscriberRepository
+ ->expects($this->once())
+ ->method('distinctUsersWithBouncesConfirmedNotBlacklisted')
+ ->willReturn([]);
+
+ $this->io->expects($this->once())->method('section')->with('Identifying consecutive bounces');
+ $this->io->expects($this->once())->method('writeln')->with('Nothing to do');
+
+ $this->handler->handle($this->io);
+ }
+
+ public function testUnsubscribeAtThresholdAddsHistoryAndMarksUnconfirmedOnce(): void
+ {
+ $user = $this->makeSubscriber(123);
+ $this->subscriberRepository
+ ->method('distinctUsersWithBouncesConfirmedNotBlacklisted')
+ ->willReturn([$user]);
+
+ $history = [
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(1)],
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(2)],
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(0)],
+ ];
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('getUserMessageHistoryWithBounces')
+ ->with($user)
+ ->willReturn($history);
+
+ $this->subscriberRepository
+ ->expects($this->once())
+ ->method('markUnconfirmed')
+ ->with(123);
+
+ $this->subscriberHistoryManager
+ ->expects($this->once())
+ ->method('addHistory')
+ ->with(
+ $user,
+ 'Auto Unconfirmed',
+ $this->stringContains('2 consecutive bounces')
+ );
+
+ $this->blacklistService->expects($this->never())->method('blacklist');
+
+ $this->io->expects($this->once())->method('section')->with('Identifying consecutive bounces');
+ $this->io->expects($this->once())->method('writeln')->with('total of 1 subscribers processed');
+
+ $this->handler->handle($this->io);
+ }
+
+ public function testBlacklistAtThresholdStopsProcessingAndAlsoUnsubscribesIfReached(): void
+ {
+ $user = $this->makeSubscriber(7);
+ $this->subscriberRepository
+ ->method('distinctUsersWithBouncesConfirmedNotBlacklisted')
+ ->willReturn([$user]);
+
+ $history = [
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(11)],
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(12)],
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(13)],
+ // Any further entries should be ignored after blacklist stop
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(14)],
+ ];
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('getUserMessageHistoryWithBounces')
+ ->with($user)
+ ->willReturn($history);
+
+ // Unsubscribe reached at 2
+ $this->subscriberRepository
+ ->expects($this->once())
+ ->method('markUnconfirmed')
+ ->with(7);
+
+ $this->subscriberHistoryManager
+ ->expects($this->once())
+ ->method('addHistory')
+ ->with(
+ $user,
+ 'Auto Unconfirmed',
+ $this->stringContains('consecutive bounces')
+ );
+
+ // Blacklist at 3
+ $this->blacklistService
+ ->expects($this->once())
+ ->method('blacklist')
+ ->with(
+ $user,
+ $this->stringContains('3 consecutive bounces')
+ );
+
+ $this->handler->handle($this->io);
+ }
+
+ public function testDuplicateBouncesAreIgnoredInCounting(): void
+ {
+ $user = $this->makeSubscriber(55);
+ $this->subscriberRepository->method('distinctUsersWithBouncesConfirmedNotBlacklisted')->willReturn([$user]);
+
+ // First is duplicate (by status), ignored; then two real => unsubscribe triggered once
+ $history = [
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(101, status: 'DUPLICATE bounce')],
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(102, comment: 'ok')],
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(103)],
+ ];
+ $this->bounceManager->method('getUserMessageHistoryWithBounces')->willReturn($history);
+
+ $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(55);
+ $this->subscriberHistoryManager->expects($this->once())->method('addHistory')->with(
+ $user,
+ 'Auto Unconfirmed',
+ $this->stringContains('2 consecutive bounces')
+ );
+ $this->blacklistService->expects($this->never())->method('blacklist');
+
+ $this->handler->handle($this->io);
+ }
+
+ public function testBreaksOnBounceWithoutRealId(): void
+ {
+ $user = $this->makeSubscriber(77);
+ $this->subscriberRepository->method('distinctUsersWithBouncesConfirmedNotBlacklisted')->willReturn([$user]);
+
+ // The first entry has null bounce (no real id) => processing for the user stops immediately; no actions
+ $history = [
+ ['um' => null, 'umb' => null, 'b' => null],
+ // should not be reached
+ ['um' => null, 'umb' => null, 'b' => $this->makeBounce(1)],
+ ];
+ $this->bounceManager->method('getUserMessageHistoryWithBounces')->willReturn($history);
+
+ $this->subscriberRepository->expects($this->never())->method('markUnconfirmed');
+ $this->subscriberHistoryManager->expects($this->never())->method('addHistory');
+ $this->blacklistService->expects($this->never())->method('blacklist');
+
+ $this->handler->handle($this->io);
+ }
+
+ private function makeSubscriber(int $id): Subscriber
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $subscriber->method('getId')->willReturn($id);
+
+ return $subscriber;
+ }
+
+ private function makeBounce(int $id, ?string $status = null, ?string $comment = null): Bounce
+ {
+ $bounce = $this->createMock(Bounce::class);
+ $bounce->method('getId')->willReturn($id);
+ $bounce->method('getStatus')->willReturn($status);
+ $bounce->method('getComment')->willReturn($comment);
+
+ return $bounce;
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php b/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php
index 9409320b..950f1021 100644
--- a/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php
+++ b/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php
@@ -19,12 +19,18 @@ class EmailServiceTest extends TestCase
private MailerInterface&MockObject $mailer;
private MessageBusInterface&MockObject $messageBus;
private string $defaultFromEmail = 'default@example.com';
+ private string $bounceEmail = 'bounce@example.com';
protected function setUp(): void
{
$this->mailer = $this->createMock(MailerInterface::class);
$this->messageBus = $this->createMock(MessageBusInterface::class);
- $this->emailService = new EmailService($this->mailer, $this->defaultFromEmail, $this->messageBus);
+ $this->emailService = new EmailService(
+ mailer: $this->mailer,
+ messageBus: $this->messageBus,
+ defaultFromEmail: $this->defaultFromEmail,
+ bounceEmail: $this->bounceEmail,
+ );
}
public function testSendEmailWithDefaultFrom(): void
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php
new file mode 100644
index 00000000..8f5cdb11
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php
@@ -0,0 +1,78 @@
+historyManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->bounceManager = $this->createMock(BounceManager::class);
+ $this->blacklistService = $this->createMock(SubscriberBlacklistService::class);
+ $this->handler = new BlacklistEmailAndDeleteBounceHandler(
+ subscriberHistoryManager: $this->historyManager,
+ bounceManager: $this->bounceManager,
+ blacklistService: $this->blacklistService,
+ );
+ }
+
+ public function testSupportsOnlyBlacklistEmailAndDeleteBounce(): void
+ {
+ $this->assertTrue($this->handler->supports('blacklistemailanddeletebounce'));
+ $this->assertFalse($this->handler->supports('blacklistemail'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleBlacklistsAddsHistoryAndDeletesBounceWhenSubscriberPresent(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->blacklistService->expects($this->once())->method('blacklist')->with(
+ $subscriber,
+ $this->stringContains('Email address auto blacklisted by bounce rule 9')
+ );
+ $this->historyManager->expects($this->once())->method('addHistory')->with(
+ $subscriber,
+ 'Auto Unsubscribed',
+ $this->stringContains('User auto unsubscribed for bounce rule 9')
+ );
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'ruleId' => 9,
+ 'bounce' => $bounce,
+ ]);
+ }
+
+ public function testHandleSkipsBlacklistAndHistoryWhenNoSubscriberButDeletesBounce(): void
+ {
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->blacklistService->expects($this->never())->method('blacklist');
+ $this->historyManager->expects($this->never())->method('addHistory');
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'ruleId' => 9,
+ 'bounce' => $bounce,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php
new file mode 100644
index 00000000..54f7362b
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php
@@ -0,0 +1,73 @@
+historyManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->blacklistService = $this->createMock(SubscriberBlacklistService::class);
+ $this->handler = new BlacklistEmailHandler(
+ subscriberHistoryManager: $this->historyManager,
+ blacklistService: $this->blacklistService,
+ );
+ }
+
+ public function testSupportsOnlyBlacklistEmail(): void
+ {
+ $this->assertTrue($this->handler->supports('blacklistemail'));
+ $this->assertFalse($this->handler->supports('blacklistuser'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleBlacklistsAndAddsHistoryWhenSubscriberPresent(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+
+ $this->blacklistService
+ ->expects($this->once())
+ ->method('blacklist')
+ ->with(
+ $subscriber,
+ $this->stringContains('Email address auto blacklisted by bounce rule 42')
+ );
+
+ $this->historyManager
+ ->expects($this->once())
+ ->method('addHistory')
+ ->with(
+ $subscriber,
+ 'Auto Unsubscribed',
+ $this->stringContains('email auto unsubscribed for bounce rule 42')
+ );
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'ruleId' => 42,
+ ]);
+ }
+
+ public function testHandleDoesNothingWhenNoSubscriber(): void
+ {
+ $this->blacklistService->expects($this->never())->method('blacklist');
+ $this->historyManager->expects($this->never())->method('addHistory');
+
+ $this->handler->handle([
+ 'ruleId' => 1,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php
new file mode 100644
index 00000000..af1df32e
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php
@@ -0,0 +1,90 @@
+historyManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->bounceManager = $this->createMock(BounceManager::class);
+ $this->blacklistService = $this->createMock(SubscriberBlacklistService::class);
+ $this->handler = new BlacklistUserAndDeleteBounceHandler(
+ subscriberHistoryManager: $this->historyManager,
+ bounceManager: $this->bounceManager,
+ blacklistService: $this->blacklistService,
+ );
+ }
+
+ public function testSupportsOnlyBlacklistUserAndDeleteBounce(): void
+ {
+ $this->assertTrue($this->handler->supports('blacklistuseranddeletebounce'));
+ $this->assertFalse($this->handler->supports('blacklistuser'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleBlacklistsAddsHistoryAndDeletesBounceWhenSubscriberPresentAndNotBlacklisted(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->blacklistService->expects($this->once())->method('blacklist')->with(
+ $subscriber,
+ $this->stringContains('Subscriber auto blacklisted by bounce rule 13')
+ );
+ $this->historyManager->expects($this->once())->method('addHistory')->with(
+ $subscriber,
+ 'Auto Unsubscribed',
+ $this->stringContains('User auto unsubscribed for bounce rule 13')
+ );
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'blacklisted' => false,
+ 'ruleId' => 13,
+ 'bounce' => $bounce,
+ ]);
+ }
+
+ public function testHandleSkipsBlacklistAndHistoryWhenNoSubscriberOrAlreadyBlacklistedButDeletesBounce(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->blacklistService->expects($this->never())->method('blacklist');
+ $this->historyManager->expects($this->never())->method('addHistory');
+ $this->bounceManager->expects($this->exactly(2))->method('delete')->with($bounce);
+
+ // Already blacklisted
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'blacklisted' => true,
+ 'ruleId' => 13,
+ 'bounce' => $bounce,
+ ]);
+
+ // No subscriber
+ $this->handler->handle([
+ 'blacklisted' => false,
+ 'ruleId' => 13,
+ 'bounce' => $bounce,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php
new file mode 100644
index 00000000..72fe4584
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php
@@ -0,0 +1,84 @@
+historyManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->blacklistService = $this->createMock(SubscriberBlacklistService::class);
+ $this->handler = new BlacklistUserHandler(
+ subscriberHistoryManager: $this->historyManager,
+ blacklistService: $this->blacklistService
+ );
+ }
+
+ public function testSupportsOnlyBlacklistUser(): void
+ {
+ $this->assertTrue($this->handler->supports('blacklistuser'));
+ $this->assertFalse($this->handler->supports('unconfirmuser'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleBlacklistsAndAddsHistoryWhenSubscriberPresentAndNotBlacklisted(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+
+ $this->blacklistService
+ ->expects($this->once())
+ ->method('blacklist')
+ ->with(
+ $subscriber,
+ $this->stringContains('bounce rule 17')
+ );
+
+ $this->historyManager
+ ->expects($this->once())
+ ->method('addHistory')
+ ->with(
+ $subscriber,
+ 'Auto Unsubscribed',
+ $this->stringContains('bounce rule 17')
+ );
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'blacklisted' => false,
+ 'ruleId' => 17,
+ ]);
+ }
+
+ public function testHandleDoesNothingWhenAlreadyBlacklistedOrNoSubscriber(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $this->blacklistService->expects($this->never())->method('blacklist');
+ $this->historyManager->expects($this->never())->method('addHistory');
+
+ // Already blacklisted
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'blacklisted' => true,
+ 'ruleId' => 5,
+ ]);
+
+ // No subscriber provided
+ $this->handler->handle([
+ 'blacklisted' => false,
+ 'ruleId' => 5,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php
new file mode 100644
index 00000000..7d82336f
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php
@@ -0,0 +1,103 @@
+historyManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->subscriberManager = $this->createMock(SubscriberManager::class);
+ $this->bounceManager = $this->createMock(BounceManager::class);
+ $this->subscriberRepository = $this->createMock(SubscriberRepository::class);
+ $this->handler = new DecreaseCountConfirmUserAndDeleteBounceHandler(
+ subscriberHistoryManager: $this->historyManager,
+ subscriberManager: $this->subscriberManager,
+ bounceManager: $this->bounceManager,
+ subscriberRepository: $this->subscriberRepository,
+ );
+ }
+
+ public function testSupportsOnlyDecreaseCountConfirmUserAndDeleteBounce(): void
+ {
+ $this->assertTrue($this->handler->supports('decreasecountconfirmuseranddeletebounce'));
+ $this->assertFalse($this->handler->supports('deleteuser'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleDecrementsMarksConfirmedAddsHistoryAndDeletesWhenNotConfirmed(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->subscriberManager->expects($this->once())->method('decrementBounceCount')->with($subscriber);
+ $this->subscriberRepository->expects($this->once())->method('markConfirmed')->with(11);
+ $this->historyManager->expects($this->once())->method('addHistory')->with(
+ $subscriber,
+ 'Auto confirmed',
+ $this->stringContains('bounce rule 77')
+ );
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'userId' => 11,
+ 'confirmed' => false,
+ 'ruleId' => 77,
+ 'bounce' => $bounce,
+ ]);
+ }
+
+ public function testHandleOnlyDecrementsAndDeletesWhenAlreadyConfirmed(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->subscriberManager->expects($this->once())->method('decrementBounceCount')->with($subscriber);
+ $this->subscriberRepository->expects($this->never())->method('markConfirmed');
+ $this->historyManager->expects($this->never())->method('addHistory');
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'userId' => 11,
+ 'confirmed' => true,
+ 'ruleId' => 77,
+ 'bounce' => $bounce,
+ ]);
+ }
+
+ public function testHandleDeletesBounceEvenWithoutSubscriber(): void
+ {
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->subscriberManager->expects($this->never())->method('decrementBounceCount');
+ $this->subscriberRepository->expects($this->never())->method('markConfirmed');
+ $this->historyManager->expects($this->never())->method('addHistory');
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'confirmed' => true,
+ 'ruleId' => 1,
+ 'bounce' => $bounce,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php
new file mode 100644
index 00000000..25028345
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php
@@ -0,0 +1,40 @@
+bounceManager = $this->createMock(BounceManager::class);
+ $this->handler = new DeleteBounceHandler($this->bounceManager);
+ }
+
+ public function testSupportsOnlyDeleteBounce(): void
+ {
+ $this->assertTrue($this->handler->supports('deletebounce'));
+ $this->assertFalse($this->handler->supports('deleteuser'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleDeletesBounce(): void
+ {
+ $bounce = $this->createMock(Bounce::class);
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'bounce' => $bounce,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php
new file mode 100644
index 00000000..0d68b631
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php
@@ -0,0 +1,63 @@
+bounceManager = $this->createMock(BounceManager::class);
+ $this->subscriberManager = $this->createMock(SubscriberManager::class);
+ $this->handler = new DeleteUserAndBounceHandler(
+ bounceManager: $this->bounceManager,
+ subscriberManager: $this->subscriberManager
+ );
+ }
+
+ public function testSupportsOnlyDeleteUserAndBounce(): void
+ {
+ $this->assertTrue($this->handler->supports('deleteuserandbounce'));
+ $this->assertFalse($this->handler->supports('deleteuser'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleDeletesUserWhenPresentAndAlwaysDeletesBounce(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->subscriberManager->expects($this->once())->method('deleteSubscriber')->with($subscriber);
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'bounce' => $bounce,
+ ]);
+ }
+
+ public function testHandleSkipsUserDeletionWhenNoSubscriberButDeletesBounce(): void
+ {
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->subscriberManager->expects($this->never())->method('deleteSubscriber');
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'bounce' => $bounce,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php
new file mode 100644
index 00000000..427f8146
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php
@@ -0,0 +1,71 @@
+subscriberManager = $this->createMock(SubscriberManager::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->handler = new DeleteUserHandler(subscriberManager: $this->subscriberManager, logger: $this->logger);
+ }
+
+ public function testSupportsOnlyDeleteUser(): void
+ {
+ $this->assertTrue($this->handler->supports('deleteuser'));
+ $this->assertFalse($this->handler->supports('deleteuserandbounce'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleLogsAndDeletesWhenSubscriberPresent(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $subscriber->method('getEmail')->willReturn('user@example.com');
+
+ $this->logger
+ ->expects($this->once())
+ ->method('info')
+ ->with(
+ 'User deleted by bounce rule',
+ $this->callback(function ($context) {
+ return isset($context['user'], $context['rule'])
+ && $context['user'] === 'user@example.com'
+ && $context['rule'] === 42;
+ })
+ );
+
+ $this->subscriberManager
+ ->expects($this->once())
+ ->method('deleteSubscriber')
+ ->with($subscriber);
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'ruleId' => 42,
+ ]);
+ }
+
+ public function testHandleDoesNothingWhenNoSubscriber(): void
+ {
+ $this->logger->expects($this->never())->method('info');
+ $this->subscriberManager->expects($this->never())->method('deleteSubscriber');
+
+ $this->handler->handle([
+ 'ruleId' => 1,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php
new file mode 100644
index 00000000..7a4ac245
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php
@@ -0,0 +1,90 @@
+historyManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->subscriberRepository = $this->createMock(SubscriberRepository::class);
+ $this->bounceManager = $this->createMock(BounceManager::class);
+ $this->handler = new UnconfirmUserAndDeleteBounceHandler(
+ subscriberHistoryManager: $this->historyManager,
+ subscriberRepository: $this->subscriberRepository,
+ bounceManager: $this->bounceManager,
+ );
+ }
+
+ public function testSupportsOnlyUnconfirmUserAndDeleteBounce(): void
+ {
+ $this->assertTrue($this->handler->supports('unconfirmuseranddeletebounce'));
+ $this->assertFalse($this->handler->supports('unconfirmuser'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleUnconfirmsAndAddsHistoryAndDeletesBounce(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(10);
+ $this->historyManager->expects($this->once())->method('addHistory')->with(
+ $subscriber,
+ 'Auto unconfirmed',
+ $this->stringContains('bounce rule 3')
+ );
+ $this->bounceManager->expects($this->once())->method('delete')->with($bounce);
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'userId' => 10,
+ 'confirmed' => true,
+ 'ruleId' => 3,
+ 'bounce' => $bounce,
+ ]);
+ }
+
+ public function testHandleDeletesBounceAndSkipsUnconfirmWhenNotConfirmedOrNoSubscriber(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $bounce = $this->createMock(Bounce::class);
+
+ $this->subscriberRepository->expects($this->never())->method('markUnconfirmed');
+ $this->historyManager->expects($this->never())->method('addHistory');
+ $this->bounceManager->expects($this->exactly(2))->method('delete')->with($bounce);
+
+ // Not confirmed
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'userId' => 10,
+ 'confirmed' => false,
+ 'ruleId' => 3,
+ 'bounce' => $bounce,
+ ]);
+
+ // No subscriber
+ $this->handler->handle([
+ 'userId' => 10,
+ 'confirmed' => true,
+ 'ruleId' => 3,
+ 'bounce' => $bounce,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php
new file mode 100644
index 00000000..a395e110
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php
@@ -0,0 +1,77 @@
+subscriberRepository = $this->createMock(SubscriberRepository::class);
+ $this->historyManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->handler = new UnconfirmUserHandler(
+ subscriberRepository: $this->subscriberRepository,
+ subscriberHistoryManager: $this->historyManager
+ );
+ }
+
+ public function testSupportsOnlyUnconfirmUser(): void
+ {
+ $this->assertTrue($this->handler->supports('unconfirmuser'));
+ $this->assertFalse($this->handler->supports('blacklistuser'));
+ $this->assertFalse($this->handler->supports(''));
+ }
+
+ public function testHandleMarksUnconfirmedAndAddsHistoryWhenSubscriberPresentAndConfirmed(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+
+ $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(123);
+ $this->historyManager->expects($this->once())->method('addHistory')->with(
+ $subscriber,
+ 'Auto Unconfirmed',
+ $this->stringContains('bounce rule 9')
+ );
+
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'userId' => 123,
+ 'confirmed' => true,
+ 'ruleId' => 9,
+ ]);
+ }
+
+ public function testHandleDoesNothingWhenNotConfirmedOrNoSubscriber(): void
+ {
+ $subscriber = $this->createMock(Subscriber::class);
+ $this->subscriberRepository->expects($this->never())->method('markUnconfirmed');
+ $this->historyManager->expects($this->never())->method('addHistory');
+
+ // Not confirmed
+ $this->handler->handle([
+ 'subscriber' => $subscriber,
+ 'userId' => 44,
+ 'confirmed' => false,
+ 'ruleId' => 1,
+ ]);
+
+ // No subscriber
+ $this->handler->handle([
+ 'userId' => 44,
+ 'confirmed' => true,
+ 'ruleId' => 1,
+ ]);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/LockServiceTest.php b/tests/Unit/Domain/Messaging/Service/LockServiceTest.php
new file mode 100644
index 00000000..8851d7de
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/LockServiceTest.php
@@ -0,0 +1,88 @@
+repo = $this->createMock(SendProcessRepository::class);
+ $this->manager = $this->createMock(SendProcessManager::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ }
+
+ public function testAcquirePageLockCreatesProcessWhenBelowMax(): void
+ {
+ $service = new LockService($this->repo, $this->manager, $this->logger, 600, 0, 0);
+
+ $this->repo->method('countAliveByPage')->willReturn(0);
+ $this->manager->method('findNewestAliveWithAge')->willReturn(null);
+
+ $sendProcess = $this->createConfiguredMock(SendProcess::class, ['getId' => 42]);
+ $this->manager->expects($this->once())
+ ->method('create')
+ ->with('mypage', $this->callback(fn(string $id) => $id !== ''))
+ ->willReturn($sendProcess);
+
+ $id = $service->acquirePageLock('my page');
+ $this->assertSame(42, $id);
+ }
+
+ public function testAcquirePageLockReturnsNullWhenAtMaxInCli(): void
+ {
+ $service = new LockService($this->repo, $this->manager, $this->logger, 600, 0, 0);
+
+ $this->repo->method('countAliveByPage')->willReturn(1);
+ $this->manager->method('findNewestAliveWithAge')->willReturn(['age' => 1, 'id' => 10]);
+
+ $this->logger->expects($this->atLeastOnce())->method('info');
+ $id = $service->acquirePageLock('page', false, true, false, 1);
+ $this->assertNull($id);
+ }
+
+ public function testAcquirePageLockStealsStale(): void
+ {
+ $service = new LockService($this->repo, $this->manager, $this->logger, 1, 0, 0);
+
+ $this->repo->expects($this->exactly(2))->method('countAliveByPage')->willReturnOnConsecutiveCalls(1, 0);
+ $this->manager
+ ->expects($this->exactly(2))
+ ->method('findNewestAliveWithAge')
+ ->willReturnOnConsecutiveCalls(['age' => 5, 'id' => 10], null);
+ $this->repo->expects($this->once())->method('markDeadById')->with(10);
+
+ $sendProcess = $this->createConfiguredMock(SendProcess::class, ['getId' => 99]);
+ $this->manager->method('create')->willReturn($sendProcess);
+
+ $id = $service->acquirePageLock('page', false, true);
+ $this->assertSame(99, $id);
+ }
+
+ public function testKeepCheckReleaseDelegatesToRepo(): void
+ {
+ $service = new LockService($this->repo, $this->manager, $this->logger);
+
+ $this->repo->expects($this->once())->method('incrementAlive')->with(5);
+ $service->keepLock(5);
+
+ $this->repo->expects($this->once())->method('getAliveValue')->with(5)->willReturn(7);
+ $this->assertSame(7, $service->checkLock(5));
+
+ $this->repo->expects($this->once())->method('markDeadById')->with(5);
+ $service->release(5);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php
new file mode 100644
index 00000000..bd1a4a68
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php
@@ -0,0 +1,205 @@
+repository = $this->createMock(BounceRepository::class);
+ $this->userMessageBounceRepository = $this->createMock(UserMessageBounceRepository::class);
+ $this->entityManager = $this->createMock(EntityManagerInterface::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->manager = new BounceManager(
+ bounceRepository: $this->repository,
+ userMessageBounceRepo: $this->userMessageBounceRepository,
+ entityManager: $this->entityManager,
+ logger: $this->logger,
+ );
+ }
+
+ public function testCreatePersistsAndReturnsBounce(): void
+ {
+ $date = new DateTimeImmutable('2020-01-01 00:00:00');
+ $header = 'X-Test: Header';
+ $data = 'raw bounce';
+ $status = 'new';
+ $comment = 'created by test';
+
+ $this->repository->expects($this->once())
+ ->method('save')
+ ->with($this->isInstanceOf(Bounce::class));
+
+ $bounce = $this->manager->create(
+ date: $date,
+ header: $header,
+ data: $data,
+ status: $status,
+ comment: $comment
+ );
+
+ $this->assertInstanceOf(Bounce::class, $bounce);
+ $this->assertSame($date->format('Y-m-d h:m:s'), $bounce->getDate()->format('Y-m-d h:m:s'));
+ $this->assertSame($header, $bounce->getHeader());
+ $this->assertSame($data, $bounce->getData());
+ $this->assertSame($status, $bounce->getStatus());
+ $this->assertSame($comment, $bounce->getComment());
+ }
+
+ public function testDeleteDelegatesToRepository(): void
+ {
+ $model = new Bounce();
+
+ $this->repository->expects($this->once())
+ ->method('remove')
+ ->with($model);
+
+ $this->manager->delete($model);
+ }
+
+ public function testGetAllReturnsArray(): void
+ {
+ $expected = [new Bounce(), new Bounce()];
+
+ $this->repository->expects($this->once())
+ ->method('findAll')
+ ->willReturn($expected);
+
+ $this->assertSame($expected, $this->manager->getAll());
+ }
+
+ public function testGetByIdReturnsBounce(): void
+ {
+ $expected = new Bounce();
+
+ $this->repository->expects($this->once())
+ ->method('find')
+ ->with(123)
+ ->willReturn($expected);
+
+ $this->assertSame($expected, $this->manager->getById(123));
+ }
+
+ public function testGetByIdReturnsNullWhenNotFound(): void
+ {
+ $this->repository->expects($this->once())
+ ->method('find')
+ ->with(999)
+ ->willReturn(null);
+
+ $this->assertNull($this->manager->getById(999));
+ }
+
+ public function testUpdateChangesFieldsAndSaves(): void
+ {
+ $bounce = new Bounce();
+ $this->repository->expects($this->once())
+ ->method('save')
+ ->with($bounce);
+
+ $updated = $this->manager->update($bounce, 'processed', 'done');
+ $this->assertSame($bounce, $updated);
+ $this->assertSame('processed', $bounce->getStatus());
+ $this->assertSame('done', $bounce->getComment());
+ }
+
+ public function testLinkUserMessageBounceFlushesAndSetsFields(): void
+ {
+ $bounce = $this->createMock(Bounce::class);
+ $bounce->method('getId')->willReturn(77);
+
+ $this->entityManager->expects($this->once())->method('flush');
+
+ $dt = new DateTimeImmutable('2024-05-01 12:34:56');
+ $umb = $this->manager->linkUserMessageBounce($bounce, $dt, 123, 456);
+
+ $this->assertSame(77, $umb->getBounceId());
+ $this->assertSame(123, $umb->getUserId());
+ $this->assertSame(456, $umb->getMessageId());
+ }
+
+ public function testExistsUserMessageBounceDelegatesToRepo(): void
+ {
+ $this->userMessageBounceRepository->expects($this->once())
+ ->method('existsByMessageIdAndUserId')
+ ->with(456, 123)
+ ->willReturn(true);
+
+ $this->assertTrue($this->manager->existsUserMessageBounce(123, 456));
+ }
+
+ public function testFindByStatusDelegatesToRepository(): void
+ {
+ $b1 = new Bounce();
+ $b2 = new Bounce();
+ $this->repository->expects($this->once())
+ ->method('findByStatus')
+ ->with('new')
+ ->willReturn([$b1, $b2]);
+
+ $this->assertSame([$b1, $b2], $this->manager->findByStatus('new'));
+ }
+
+ public function testGetUserMessageBounceCount(): void
+ {
+ $this->userMessageBounceRepository->expects($this->once())
+ ->method('count')
+ ->willReturn(5);
+ $this->assertSame(5, $this->manager->getUserMessageBounceCount());
+ }
+
+ public function testFetchUserMessageBounceBatchDelegates(): void
+ {
+ $expected = [['umb' => new UserMessageBounce(1, new \DateTime()), 'bounce' => new Bounce()]];
+ $this->userMessageBounceRepository->expects($this->once())
+ ->method('getPaginatedWithJoinNoRelation')
+ ->with(10, 50)
+ ->willReturn($expected);
+ $this->assertSame($expected, $this->manager->fetchUserMessageBounceBatch(10, 50));
+ }
+
+ public function testGetUserMessageHistoryWithBouncesDelegates(): void
+ {
+ $subscriber = new Subscriber();
+ $expected = [];
+ $this->userMessageBounceRepository->expects($this->once())
+ ->method('getUserMessageHistoryWithBounces')
+ ->with($subscriber)
+ ->willReturn($expected);
+ $this->assertSame($expected, $this->manager->getUserMessageHistoryWithBounces($subscriber));
+ }
+
+ public function testAnnounceDeletionModeLogsCorrectMessage(): void
+ {
+ $this->logger->expects($this->exactly(2))
+ ->method('info')
+ ->withConsecutive([
+ 'Running in test mode, not deleting messages from mailbox'
+ ], [
+ 'Processed messages will be deleted from the mailbox'
+ ]);
+
+ $this->manager->announceDeletionMode(true);
+ $this->manager->announceDeletionMode(false);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php
new file mode 100644
index 00000000..fd526a64
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php
@@ -0,0 +1,144 @@
+regexRepository = $this->createMock(BounceRegexRepository::class);
+ $this->entityManager = $this->createMock(EntityManagerInterface::class);
+
+ $this->manager = new BounceRegexManager(
+ bounceRegexRepository: $this->regexRepository,
+ entityManager: $this->entityManager
+ );
+ }
+
+ public function testCreateNewRegex(): void
+ {
+ $pattern = 'user unknown';
+ $expectedHash = md5($pattern);
+
+ $this->regexRepository->expects($this->once())
+ ->method('findOneByRegexHash')
+ ->with($expectedHash)
+ ->willReturn(null);
+
+ $this->regexRepository->expects($this->once())
+ ->method('save')
+ ->with($this->isInstanceOf(BounceRegex::class));
+
+ $regex = $this->manager->createOrUpdateFromPattern(
+ regex: $pattern,
+ action: 'delete',
+ listOrder: 5,
+ adminId: 1,
+ comment: 'test',
+ status: 'active'
+ );
+
+ $this->assertInstanceOf(BounceRegex::class, $regex);
+ $this->assertSame($pattern, $regex->getRegex());
+ $this->assertSame($expectedHash, $regex->getRegexHash());
+ $this->assertSame('delete', $regex->getAction());
+ $this->assertSame(5, $regex->getListOrder());
+ $this->assertSame(1, $regex->getAdminId());
+ $this->assertSame('test', $regex->getComment());
+ $this->assertSame('active', $regex->getStatus());
+ }
+
+ public function testUpdateExistingRegex(): void
+ {
+ $pattern = 'mailbox full';
+ $hash = md5($pattern);
+
+ $existing = new BounceRegex(
+ regex: $pattern,
+ regexHash: $hash,
+ action: 'keep',
+ listOrder: 0,
+ adminId: null,
+ comment: null,
+ status: 'inactive',
+ count: 3
+ );
+
+ $this->regexRepository->expects($this->once())
+ ->method('findOneByRegexHash')
+ ->with($hash)
+ ->willReturn($existing);
+
+ $this->regexRepository->expects($this->once())
+ ->method('save')
+ ->with($existing);
+
+ $updated = $this->manager->createOrUpdateFromPattern(
+ regex: $pattern,
+ action: 'delete',
+ listOrder: 10,
+ adminId: 2,
+ comment: 'upd',
+ status: 'active'
+ );
+
+ $this->assertSame('delete', $updated->getAction());
+ $this->assertSame(10, $updated->getListOrder());
+ $this->assertSame(2, $updated->getAdminId());
+ $this->assertSame('upd', $updated->getComment());
+ $this->assertSame('active', $updated->getStatus());
+ $this->assertSame($hash, $updated->getRegexHash());
+ }
+
+ public function testDeleteRegex(): void
+ {
+ $model = $this->createMock(BounceRegex::class);
+
+ $this->regexRepository->expects($this->once())
+ ->method('remove')
+ ->with($model);
+
+ $this->manager->delete($model);
+ }
+
+ public function testAssociateBounceIncrementsCountAndPersistsRelation(): void
+ {
+ $regex = new BounceRegex(regex: 'x', regexHash: md5('x'));
+
+ $refRegex = new ReflectionProperty(BounceRegex::class, 'id');
+ $refRegex->setValue($regex, 7);
+
+ $bounce = $this->createMock(Bounce::class);
+ $bounce->method('getId')->willReturn(11);
+
+ $this->entityManager->expects($this->once())
+ ->method('persist')
+ ->with($this->callback(function ($entity) use ($regex) {
+ return $entity instanceof BounceRegexBounce
+ && $entity->getRegexId() === $regex->getId();
+ }));
+
+ $this->entityManager->expects($this->once())
+ ->method('flush');
+
+ $this->assertSame(0, $regex->getCount());
+ $this->manager->associateBounce($regex, $bounce);
+ $this->assertSame(1, $regex->getCount());
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php
new file mode 100644
index 00000000..040f98a8
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php
@@ -0,0 +1,143 @@
+regexRepository = $this->createMock(BounceRegexRepository::class);
+ $this->relationRepository = $this->createMock(BounceRegexBounceRepository::class);
+ $this->manager = new BounceRuleManager(
+ repository: $this->regexRepository,
+ bounceRelationRepository: $this->relationRepository,
+ );
+ }
+
+ public function testLoadActiveRulesMapsRowsAndSkipsInvalid(): void
+ {
+ $valid = $this->createMock(BounceRegex::class);
+ $valid->method('getId')->willReturn(1);
+ $valid->method('getAction')->willReturn('delete');
+ $valid->method('getRegex')->willReturn('user unknown');
+ $valid->method('getRegexHash')->willReturn(md5('user unknown'));
+
+ $noRegex = $this->createMock(BounceRegex::class);
+ $noRegex->method('getId')->willReturn(2);
+
+ $noAction = $this->createMock(BounceRegex::class);
+ $noAction->method('getId')->willReturn(3);
+ $noAction->method('getRegex')->willReturn('pattern');
+ $noAction->method('getRegexHash')->willReturn(md5('pattern'));
+
+ $noId = $this->createMock(BounceRegex::class);
+ $noId->method('getRegex')->willReturn('has no id');
+ $noId->method('getRegexHash')->willReturn(md5('has no id'));
+ $noId->method('getAction')->willReturn('keep');
+
+ $this->regexRepository->expects($this->once())
+ ->method('fetchActiveOrdered')
+ ->willReturn([$valid, $noRegex, $noAction, $noId]);
+
+ $result = $this->manager->loadActiveRules();
+
+ $this->assertSame(['user unknown' => $valid], $result);
+ }
+
+ public function testLoadAllRulesDelegatesToRepository(): void
+ {
+ $rule1 = $this->createMock(BounceRegex::class);
+ $rule1->method('getId')->willReturn(10);
+ $rule1->method('getAction')->willReturn('keep');
+ $rule1->method('getRegex')->willReturn('a');
+ $rule1->method('getRegexHash')->willReturn(md5('a'));
+
+ $rule2 = $this->createMock(BounceRegex::class);
+ $rule2->method('getId')->willReturn(11);
+ $rule2->method('getAction')->willReturn('delete');
+ $rule2->method('getRegex')->willReturn('b');
+ $rule2->method('getRegexHash')->willReturn(md5('b'));
+
+ $this->regexRepository->expects($this->once())
+ ->method('fetchAllOrdered')
+ ->willReturn([$rule1, $rule2]);
+
+ $result = $this->manager->loadAllRules();
+ $this->assertSame(['a' => $rule1, 'b' => $rule2], $result);
+ }
+
+ public function testMatchBounceRulesMatchesQuotedAndRawAndHandlesInvalidPatterns(): void
+ {
+ $valid = $this->createMock(BounceRegex::class);
+ $valid->method('getId')->willReturn(1);
+ $valid->method('getAction')->willReturn('delete');
+ $valid->method('getRegex')->willReturn('user unknown');
+ $valid->method('getRegexHash')->willReturn(md5('user unknown'));
+
+ $invalid = $this->createMock(BounceRegex::class);
+ $invalid->method('getId')->willReturn(2);
+ $invalid->method('getAction')->willReturn('keep');
+ $invalid->method('getRegex')->willReturn('([a-z');
+ $invalid->method('getRegexHash')->willReturn(md5('([a-z'));
+
+ $rules = ['user unknown' => $valid, '([a-z' => $invalid];
+
+ $matched = $this->manager->matchBounceRules('Delivery failed: user unknown at example', $rules);
+ $this->assertSame($valid, $matched);
+
+ // Ensure an invalid pattern does not throw and simply not match
+ $matchedInvalid = $this->manager->matchBounceRules('something else', ['([a-z' => $invalid]);
+ $this->assertNull($matchedInvalid);
+ }
+
+ public function testIncrementCountPersists(): void
+ {
+ $rule = new BounceRegex(regex: 'x', regexHash: md5('x'), action: 'keep', count: 0);
+ $this->setId($rule, 5);
+
+ $this->regexRepository->expects($this->once())
+ ->method('save')
+ ->with($rule);
+
+ $this->manager->incrementCount($rule);
+ $this->assertSame(1, $rule->getCount());
+ }
+
+ public function testLinkRuleToBounceCreatesRelationAndSaves(): void
+ {
+ $rule = new BounceRegex(regex: 'y', regexHash: md5('y'), action: 'delete');
+ $bounce = new Bounce();
+ $this->setId($rule, 9);
+ $this->setId($bounce, 20);
+
+ $this->relationRepository->expects($this->once())
+ ->method('save')
+ ->with($this->isInstanceOf(BounceRegexBounce::class));
+
+ $relation = $this->manager->linkRuleToBounce($rule, $bounce);
+
+ $this->assertInstanceOf(BounceRegexBounce::class, $relation);
+ $this->assertSame(9, $relation->getRegexId());
+ }
+
+ private function setId(object $entity, int $id): void
+ {
+ $ref = new \ReflectionProperty($entity, 'id');
+ $ref->setValue($entity, $id);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/ListMessageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.php
similarity index 98%
rename from tests/Unit/Domain/Messaging/Service/ListMessageManagerTest.php
rename to tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.php
index 2ec4180f..2f1af5fe 100644
--- a/tests/Unit/Domain/Messaging/Service/ListMessageManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
diff --git a/tests/Unit/Domain/Messaging/Service/MessageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php
similarity index 97%
rename from tests/Unit/Domain/Messaging/Service/MessageManagerTest.php
rename to tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php
index 8ee85915..aa1a47e0 100644
--- a/tests/Unit/Domain/Messaging/Service/MessageManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager;
use PhpList\Core\Domain\Identity\Model\Administrator;
use PhpList\Core\Domain\Messaging\Model\Dto\CreateMessageDto;
@@ -15,7 +15,7 @@
use PhpList\Core\Domain\Messaging\Model\Message;
use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
use PhpList\Core\Domain\Messaging\Service\Builder\MessageBuilder;
-use PhpList\Core\Domain\Messaging\Service\MessageManager;
+use PhpList\Core\Domain\Messaging\Service\Manager\MessageManager;
use PHPUnit\Framework\TestCase;
class MessageManagerTest extends TestCase
diff --git a/tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php
new file mode 100644
index 00000000..e56f11ca
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php
@@ -0,0 +1,86 @@
+repository = $this->createMock(SendProcessRepository::class);
+ $this->em = $this->createMock(EntityManagerInterface::class);
+ $this->manager = new SendProcessManager($this->repository, $this->em);
+ }
+
+ public function testCreatePersistsEntityAndSetsFields(): void
+ {
+ $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(SendProcess::class));
+ $this->em->expects($this->once())->method('flush');
+
+ $sp = $this->manager->create('pageA', 'proc-1');
+ $this->assertInstanceOf(SendProcess::class, $sp);
+ $this->assertSame('pageA', $sp->getPage());
+ $this->assertSame('proc-1', $sp->getIpaddress());
+ $this->assertSame(1, $sp->getAlive());
+ $this->assertInstanceOf(DateTime::class, $sp->getStartedDate());
+ }
+
+ public function testFindNewestAliveWithAgeReturnsNullWhenNotFound(): void
+ {
+ $this->repository->expects($this->once())
+ ->method('findNewestAlive')
+ ->with('pageX')
+ ->willReturn(null);
+
+ $this->assertNull($this->manager->findNewestAliveWithAge('pageX'));
+ }
+
+ public function testFindNewestAliveWithAgeReturnsIdAndAge(): void
+ {
+ $model = new SendProcess();
+ // set id
+ $this->setId($model, 42);
+ // set updatedAt to now - 5 seconds
+ $updated = new \DateTime('now');
+ $updated->sub(new DateInterval('PT5S'));
+ $this->setUpdatedAt($model, $updated);
+
+ $this->repository->expects($this->once())
+ ->method('findNewestAlive')
+ ->with('pageY')
+ ->willReturn($model);
+
+ $result = $this->manager->findNewestAliveWithAge('pageY');
+
+ $this->assertIsArray($result);
+ $this->assertSame(42, $result['id']);
+ $this->assertGreaterThanOrEqual(0, $result['age']);
+ $this->assertLessThan(60, $result['age']);
+ }
+
+ private function setId(object $entity, int $id): void
+ {
+ $ref = new \ReflectionProperty($entity, 'id');
+ $ref->setValue($entity, $id);
+ }
+
+ private function setUpdatedAt(SendProcess $entity, \DateTime $dt): void
+ {
+ $ref = new \ReflectionProperty($entity, 'updatedAt');
+ $ref->setValue($entity, $dt);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php
similarity index 91%
rename from tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php
rename to tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php
index bde3569a..93907f02 100644
--- a/tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php
@@ -2,13 +2,13 @@
declare(strict_types=1);
-namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager;
use Doctrine\ORM\EntityManagerInterface;
use PhpList\Core\Domain\Messaging\Model\Template;
use PhpList\Core\Domain\Messaging\Model\TemplateImage;
use PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository;
-use PhpList\Core\Domain\Messaging\Service\TemplateImageManager;
+use PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
@@ -24,8 +24,8 @@ protected function setUp(): void
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->manager = new TemplateImageManager(
- $this->templateImageRepository,
- $this->entityManager
+ templateImageRepository: $this->templateImageRepository,
+ entityManager: $this->entityManager
);
}
diff --git a/tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php
similarity index 93%
rename from tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php
rename to tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php
index fbbb4831..d3748244 100644
--- a/tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php
@@ -2,14 +2,14 @@
declare(strict_types=1);
-namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager;
use Doctrine\ORM\EntityManagerInterface;
use PhpList\Core\Domain\Messaging\Model\Dto\CreateTemplateDto;
use PhpList\Core\Domain\Messaging\Model\Template;
use PhpList\Core\Domain\Messaging\Repository\TemplateRepository;
-use PhpList\Core\Domain\Messaging\Service\TemplateImageManager;
-use PhpList\Core\Domain\Messaging\Service\TemplateManager;
+use PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager;
+use PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager;
use PhpList\Core\Domain\Messaging\Validator\TemplateImageValidator;
use PhpList\Core\Domain\Messaging\Validator\TemplateLinkValidator;
use PHPUnit\Framework\MockObject\MockObject;
diff --git a/tests/Unit/Domain/Messaging/Service/MessageParserTest.php b/tests/Unit/Domain/Messaging/Service/MessageParserTest.php
new file mode 100644
index 00000000..49b38615
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/MessageParserTest.php
@@ -0,0 +1,76 @@
+repo = $this->createMock(SubscriberRepository::class);
+ }
+
+ public function testDecodeBodyQuotedPrintable(): void
+ {
+ $parser = new MessageParser($this->repo);
+ $header = "Content-Transfer-Encoding: quoted-printable\r\n";
+ $body = 'Hello=20World';
+ $this->assertSame('Hello World', $parser->decodeBody($header, $body));
+ }
+
+ public function testDecodeBodyBase64(): void
+ {
+ $parser = new MessageParser($this->repo);
+ $header = "Content-Transfer-Encoding: base64\r\n";
+ $body = base64_encode('hi there');
+ $this->assertSame('hi there', $parser->decodeBody($header, $body));
+ }
+
+ public function testFindMessageId(): void
+ {
+ $parser = new MessageParser($this->repo);
+ $text = "X-MessageId: abc-123\r\nOther: x\r\n";
+ $this->assertSame('abc-123', $parser->findMessageId($text));
+ }
+
+ public function testFindUserIdWithHeaderNumeric(): void
+ {
+ $parser = new MessageParser($this->repo);
+ $text = "X-User: 77\r\n";
+ $this->assertSame(77, $parser->findUserId($text));
+ }
+
+ public function testFindUserIdWithHeaderEmailAndLookup(): void
+ {
+ $parser = new MessageParser($this->repo);
+ $subscriber = $this->createConfiguredMock(Subscriber::class, ['getId' => 55]);
+ $this->repo->method('findOneByEmail')->with('john@example.com')->willReturn($subscriber);
+ $text = "X-User: john@example.com\r\n";
+ $this->assertSame(55, $parser->findUserId($text));
+ }
+
+ public function testFindUserIdByScanningEmails(): void
+ {
+ $parser = new MessageParser($this->repo);
+ $subscriber = $this->createConfiguredMock(Subscriber::class, ['getId' => 88]);
+ $this->repo->method('findOneByEmail')->with('user@acme.com')->willReturn($subscriber);
+ $text = 'Hello bounce for user@acme.com, thanks';
+ $this->assertSame(88, $parser->findUserId($text));
+ }
+
+ public function testFindUserReturnsNullWhenNoMatches(): void
+ {
+ $parser = new MessageParser($this->repo);
+ $this->repo->method('findOneByEmail')->willReturn(null);
+ $this->assertNull($parser->findUserId('no users here'));
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php
new file mode 100644
index 00000000..209fb583
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php
@@ -0,0 +1,177 @@
+bounceManager = $this->createMock(BounceManager::class);
+ $this->ruleManager = $this->createMock(BounceRuleManager::class);
+ $this->actionResolver = $this->createMock(BounceActionResolver::class);
+ $this->subscriberManager = $this->createMock(SubscriberManager::class);
+ $this->io = $this->createMock(SymfonyStyle::class);
+ }
+
+ public function testNoActiveRules(): void
+ {
+ $this->io->expects($this->once())->method('section')->with('Processing bounces based on active bounce rules');
+ $this->ruleManager->method('loadActiveRules')->willReturn([]);
+ $this->io->expects($this->once())->method('writeln')->with('No active rules');
+
+ $processor = new AdvancedBounceRulesProcessor(
+ bounceManager: $this->bounceManager,
+ ruleManager: $this->ruleManager,
+ actionResolver: $this->actionResolver,
+ subscriberManager: $this->subscriberManager,
+ );
+
+ $processor->process($this->io, 100);
+ }
+
+ public function testProcessingWithMatchesAndNonMatches(): void
+ {
+ $rule1 = $this->createMock(BounceRegex::class);
+ $rule1->method('getId')->willReturn(10);
+ $rule1->method('getAction')->willReturn('blacklist');
+ $rule1->method('getCount')->willReturn(0);
+
+ $rule2 = $this->createMock(BounceRegex::class);
+ $rule2->method('getId')->willReturn(20);
+ $rule2->method('getAction')->willReturn('notify');
+ $rule2->method('getCount')->willReturn(0);
+
+ $rules = [$rule1, $rule2];
+ $this->ruleManager->method('loadActiveRules')->willReturn($rules);
+
+ $this->bounceManager->method('getUserMessageBounceCount')->willReturn(3);
+
+ $bounce1 = $this->createMock(Bounce::class);
+ $bounce1->method('getHeader')->willReturn('H1');
+ $bounce1->method('getData')->willReturn('D1');
+
+ $bounce2 = $this->createMock(Bounce::class);
+ $bounce2->method('getHeader')->willReturn('H2');
+ $bounce2->method('getData')->willReturn('D2');
+
+ $bounce3 = $this->createMock(Bounce::class);
+ $bounce3->method('getHeader')->willReturn('H3');
+ $bounce3->method('getData')->willReturn('D3');
+
+ $umb1 = $this->createMock(UserMessageBounce::class);
+ $umb1->method('getId')->willReturn(1);
+ $umb1->method('getUserId')->willReturn(111);
+
+ $umb2 = $this->createMock(UserMessageBounce::class);
+ $umb2->method('getId')->willReturn(2);
+ $umb2->method('getUserId')->willReturn(0);
+
+ $umb3 = $this->createMock(UserMessageBounce::class);
+ $umb3->method('getId')->willReturn(3);
+ $umb3->method('getUserId')->willReturn(222);
+
+ $this->bounceManager->method('fetchUserMessageBounceBatch')->willReturnOnConsecutiveCalls(
+ [ ['umb' => $umb1, 'bounce' => $bounce1], ['umb' => $umb2, 'bounce' => $bounce2] ],
+ [ ['umb' => $umb3, 'bounce' => $bounce3] ]
+ );
+
+ // Rule matches for first and third, not for second
+ $this->ruleManager->expects($this->exactly(3))
+ ->method('matchBounceRules')
+ ->willReturnCallback(function (string $text, array $r) use ($rules) {
+ $this->assertSame($rules, $r);
+ if ($text === 'H1' . "\n\n" . 'D1') {
+ return $rules[0];
+ }
+ if ($text === 'H2' . "\n\n" . 'D2') {
+ return null;
+ }
+ if ($text === 'H3' . "\n\n" . 'D3') {
+ return $rules[1];
+ }
+ $this->fail('Unexpected arguments to matchBounceRules: ' . $text);
+ });
+
+ $this->ruleManager->expects($this->exactly(2))->method('incrementCount');
+ $this->ruleManager->expects($this->exactly(2))->method('linkRuleToBounce');
+
+ // subscriber lookups for umb1 and umb3 (111 and 222). umb2 has 0 user id so skip.
+ $subscriber111 = $this->createMock(Subscriber::class);
+ $subscriber111->method('getId')->willReturn(111);
+ $subscriber111->method('isConfirmed')->willReturn(true);
+ $subscriber111->method('isBlacklisted')->willReturn(false);
+
+ $subscriber222 = $this->createMock(Subscriber::class);
+ $subscriber222->method('getId')->willReturn(222);
+ $subscriber222->method('isConfirmed')->willReturn(false);
+ $subscriber222->method('isBlacklisted')->willReturn(true);
+
+ $this->subscriberManager->expects($this->exactly(2))
+ ->method('getSubscriberById')
+ ->willReturnCallback(function (int $id) use ($subscriber111, $subscriber222) {
+ if ($id === 111) {
+ return $subscriber111;
+ }
+ if ($id === 222) {
+ return $subscriber222;
+ }
+ $this->fail('Unexpected subscriber id: ' . $id);
+ });
+
+ $this->actionResolver->expects($this->exactly(2))
+ ->method('handle')
+ ->willReturnCallback(function (string $action, array $ctx) {
+ if ($action === 'blacklist') {
+ $this->assertSame(111, $ctx['userId']);
+ $this->assertTrue($ctx['confirmed']);
+ $this->assertFalse($ctx['blacklisted']);
+ $this->assertSame(10, $ctx['ruleId']);
+ $this->assertInstanceOf(Bounce::class, $ctx['bounce']);
+ } elseif ($action === 'notify') {
+ $this->assertSame(222, $ctx['userId']);
+ $this->assertFalse($ctx['confirmed']);
+ $this->assertTrue($ctx['blacklisted']);
+ $this->assertSame(20, $ctx['ruleId']);
+ } else {
+ $this->fail('Unexpected action: ' . $action);
+ }
+ return null;
+ });
+
+ $this->io
+ ->expects($this->once())
+ ->method('section')
+ ->with('Processing bounces based on active bounce rules');
+ $this->io->expects($this->exactly(4))->method('writeln');
+
+ $processor = new AdvancedBounceRulesProcessor(
+ bounceManager: $this->bounceManager,
+ ruleManager: $this->ruleManager,
+ actionResolver: $this->actionResolver,
+ subscriberManager: $this->subscriberManager,
+ );
+
+ $processor->process($this->io, 2);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php
new file mode 100644
index 00000000..b7009cd9
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php
@@ -0,0 +1,168 @@
+bounceManager = $this->createMock(BounceManager::class);
+ $this->subscriberRepository = $this->createMock(SubscriberRepository::class);
+ $this->messageRepository = $this->createMock(MessageRepository::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->subscriberManager = $this->createMock(SubscriberManager::class);
+ $this->historyManager = $this->createMock(SubscriberHistoryManager::class);
+ $this->bounce = $this->createMock(Bounce::class);
+ }
+
+ private function makeProcessor(): BounceDataProcessor
+ {
+ return new BounceDataProcessor(
+ bounceManager: $this->bounceManager,
+ subscriberRepository: $this->subscriberRepository,
+ messageRepository: $this->messageRepository,
+ logger: $this->logger,
+ subscriberManager: $this->subscriberManager,
+ subscriberHistoryManager: $this->historyManager,
+ );
+ }
+
+ public function testSystemMessageWithUserAddsHistory(): void
+ {
+ $processor = $this->makeProcessor();
+ $date = new DateTimeImmutable('2020-01-01');
+
+ $this->bounce->method('getId')->willReturn(77);
+
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('update')
+ ->with($this->bounce, 'bounced system message', '123 marked unconfirmed');
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('linkUserMessageBounce')
+ ->with($this->bounce, $date, 123);
+ $this->subscriberRepository->expects($this->once())->method('markUnconfirmed')->with(123);
+ $this->logger
+ ->expects($this->once())
+ ->method('info')
+ ->with('system message bounced, user marked unconfirmed', ['userId' => 123]);
+
+ $subscriber = $this->createMock(Subscriber::class);
+ $subscriber->method('getId')->willReturn(123);
+ $this->subscriberManager->method('getSubscriberById')->with(123)->willReturn($subscriber);
+ $this->historyManager
+ ->expects($this->once())
+ ->method('addHistory')
+ ->with($subscriber, 'Bounced system message', 'User marked unconfirmed. Bounce #77');
+
+ $res = $processor->process($this->bounce, 'systemmessage', 123, $date);
+ $this->assertTrue($res);
+ }
+
+ public function testSystemMessageUnknownUser(): void
+ {
+ $processor = $this->makeProcessor();
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('update')
+ ->with($this->bounce, 'bounced system message', 'unknown user');
+ $this->logger->expects($this->once())->method('info')->with('system message bounced, but unknown user');
+ $res = $processor->process($this->bounce, 'systemmessage', null, new DateTimeImmutable());
+ $this->assertTrue($res);
+ }
+
+ public function testKnownMessageAndUserNew(): void
+ {
+ $processor = $this->makeProcessor();
+ $date = new DateTimeImmutable();
+ $this->bounceManager->method('existsUserMessageBounce')->with(5, 10)->willReturn(false);
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('linkUserMessageBounce')
+ ->with($this->bounce, $date, 5, 10);
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('update')
+ ->with($this->bounce, 'bounced list message 10', '5 bouncecount increased');
+ $this->messageRepository->expects($this->once())->method('incrementBounceCount')->with(10);
+ $this->subscriberRepository->expects($this->once())->method('incrementBounceCount')->with(5);
+ $res = $processor->process($this->bounce, '10', 5, $date);
+ $this->assertTrue($res);
+ }
+
+ public function testKnownMessageAndUserDuplicate(): void
+ {
+ $processor = $this->makeProcessor();
+ $date = new DateTimeImmutable();
+ $this->bounceManager->method('existsUserMessageBounce')->with(5, 10)->willReturn(true);
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('linkUserMessageBounce')
+ ->with($this->bounce, $date, 5, 10);
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('update')
+ ->with($this->bounce, 'duplicate bounce for 5', 'duplicate bounce for subscriber 5 on message 10');
+ $res = $processor->process($this->bounce, '10', 5, $date);
+ $this->assertTrue($res);
+ }
+
+ public function testUserOnly(): void
+ {
+ $processor = $this->makeProcessor();
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('update')
+ ->with($this->bounce, 'bounced unidentified message', '5 bouncecount increased');
+ $this->subscriberRepository->expects($this->once())->method('incrementBounceCount')->with(5);
+ $res = $processor->process($this->bounce, null, 5, new DateTimeImmutable());
+ $this->assertTrue($res);
+ }
+
+ public function testMessageOnly(): void
+ {
+ $processor = $this->makeProcessor();
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('update')
+ ->with($this->bounce, 'bounced list message 10', 'unknown user');
+ $this->messageRepository->expects($this->once())->method('incrementBounceCount')->with(10);
+ $res = $processor->process($this->bounce, '10', null, new DateTimeImmutable());
+ $this->assertTrue($res);
+ }
+
+ public function testNeitherMessageNorUser(): void
+ {
+ $processor = $this->makeProcessor();
+ $this->bounceManager
+ ->expects($this->once())
+ ->method('update')
+ ->with($this->bounce, 'unidentified bounce', 'not processed');
+ $res = $processor->process($this->bounce, null, null, new DateTimeImmutable());
+ $this->assertFalse($res);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php
similarity index 98%
rename from tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php
rename to tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php
index f8bb28d3..b2c51c71 100644
--- a/tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Processor/CampaignProcessorTest.php
@@ -2,15 +2,15 @@
declare(strict_types=1);
-namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Processor;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use PhpList\Core\Domain\Messaging\Model\Message;
use PhpList\Core\Domain\Messaging\Model\Message\MessageContent;
use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata;
-use PhpList\Core\Domain\Messaging\Service\CampaignProcessor;
use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator;
+use PhpList\Core\Domain\Messaging\Service\Processor\CampaignProcessor;
use PhpList\Core\Domain\Subscription\Model\Subscriber;
use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider;
use PHPUnit\Framework\MockObject\MockObject;
diff --git a/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php
new file mode 100644
index 00000000..210e000c
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php
@@ -0,0 +1,76 @@
+service = $this->createMock(BounceProcessingServiceInterface::class);
+ $this->input = $this->createMock(InputInterface::class);
+ $this->io = $this->createMock(SymfonyStyle::class);
+ }
+
+ public function testGetProtocol(): void
+ {
+ $processor = new MboxBounceProcessor($this->service);
+ $this->assertSame('mbox', $processor->getProtocol());
+ }
+
+ public function testProcessThrowsWhenMailboxMissing(): void
+ {
+ $processor = new MboxBounceProcessor($this->service);
+
+ $this->input->method('getOption')->willReturnMap([
+ ['test', false],
+ ['maximum', 0],
+ ['mailbox', ''],
+ ]);
+
+ $this->io
+ ->expects($this->once())
+ ->method('error')
+ ->with('mbox file path must be provided with --mailbox.');
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Missing --mailbox for mbox protocol');
+
+ $processor->process($this->input, $this->io);
+ }
+
+ public function testProcessSuccess(): void
+ {
+ $processor = new MboxBounceProcessor($this->service);
+
+ $this->input->method('getOption')->willReturnMap([
+ ['test', true],
+ ['maximum', 50],
+ ['mailbox', '/var/mail/bounce.mbox'],
+ ]);
+
+ $this->io->expects($this->once())->method('section')->with('Opening mbox /var/mail/bounce.mbox');
+ $this->io->expects($this->once())->method('writeln')->with('Please do not interrupt this process');
+
+ $this->service->expects($this->once())
+ ->method('processMailbox')
+ ->with('/var/mail/bounce.mbox', 50, true)
+ ->willReturn('OK');
+
+ $result = $processor->process($this->input, $this->io);
+ $this->assertSame('OK', $result);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php
new file mode 100644
index 00000000..fad4cfbe
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php
@@ -0,0 +1,64 @@
+service = $this->createMock(BounceProcessingServiceInterface::class);
+ $this->input = $this->createMock(InputInterface::class);
+ $this->io = $this->createMock(SymfonyStyle::class);
+ }
+
+ public function testGetProtocol(): void
+ {
+ $processor = new PopBounceProcessor($this->service, 'mail.example.com', 995, 'INBOX');
+ $this->assertSame('pop', $processor->getProtocol());
+ }
+
+ public function testProcessWithMultipleMailboxesAndDefaults(): void
+ {
+ $processor = new PopBounceProcessor($this->service, 'pop.example.com', 110, 'INBOX, ,Custom');
+
+ $this->input->method('getOption')->willReturnMap([
+ ['test', true],
+ ['maximum', 100],
+ ]);
+
+ $this->io->expects($this->exactly(3))->method('section');
+ $this->io->expects($this->exactly(3))->method('writeln');
+
+ $this->service->expects($this->exactly(3))
+ ->method('processMailbox')
+ ->willReturnCallback(function (string $mailbox, int $max, bool $test) {
+ $expectedThird = '{pop.example.com:110}Custom';
+ $expectedFirst = '{pop.example.com:110}INBOX';
+ $this->assertSame(100, $max);
+ $this->assertTrue($test);
+ if ($mailbox === $expectedFirst) {
+ return 'A';
+ }
+ if ($mailbox === $expectedThird) {
+ return 'C';
+ }
+ $this->fail('Unexpected mailbox: ' . $mailbox);
+ });
+
+ $result = $processor->process($this->input, $this->io);
+ $this->assertSame('AAC', $result);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php
new file mode 100644
index 00000000..a671e74c
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php
@@ -0,0 +1,75 @@
+bounceManager = $this->createMock(BounceManager::class);
+ $this->messageParser = $this->createMock(MessageParser::class);
+ $this->dataProcessor = $this->createMock(BounceDataProcessor::class);
+ $this->io = $this->createMock(SymfonyStyle::class);
+ }
+
+ public function testProcess(): void
+ {
+ $bounce1 = $this->createBounce('H1', 'D1');
+ $bounce2 = $this->createBounce('H2', 'D2');
+ $bounce3 = $this->createBounce('H3', 'D3');
+ $this->bounceManager
+ ->method('findByStatus')
+ ->with('unidentified bounce')
+ ->willReturn([$bounce1, $bounce2, $bounce3]);
+
+ $this->io->expects($this->once())->method('section')->with('Reprocessing unidentified bounces');
+ $this->io->expects($this->exactly(3))->method('writeln');
+
+ // For b1: only userId found -> should process
+ $this->messageParser->expects($this->exactly(3))->method('decodeBody');
+ $this->messageParser->method('findUserId')->willReturnOnConsecutiveCalls(111, null, 222);
+ $this->messageParser->method('findMessageId')->willReturnOnConsecutiveCalls(null, '555', '666');
+
+ // process called for b1 and b3 (two calls return true and true),
+ // and also for b2 since it has messageId -> should be called too -> total 3 calls
+ $this->dataProcessor->expects($this->exactly(3))
+ ->method('process')
+ ->with(
+ $this->anything(),
+ $this->callback(fn($messageId) => $messageId === null || is_string($messageId)),
+ $this->callback(fn($messageId) => $messageId === null || is_int($messageId)),
+ $this->isInstanceOf(DateTimeImmutable::class)
+ )
+ ->willReturnOnConsecutiveCalls(true, false, true);
+
+ $processor = new UnidentifiedBounceReprocessor(
+ bounceManager: $this->bounceManager,
+ messageParser: $this->messageParser,
+ bounceDataProcessor: $this->dataProcessor
+ );
+ $processor->process($this->io);
+ }
+
+ private function createBounce(string $header, string $data): Bounce
+ {
+ // Bounce constructor: (DateTime|null, header, data, status, comment)
+ return new Bounce(null, $header, $data, null, null);
+ }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php b/tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php
new file mode 100644
index 00000000..e75766f5
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php
@@ -0,0 +1,70 @@
+manager = $this->createMock(ClientManager::class);
+ }
+
+ public function testMakeForMailboxBuildsClientWithConfiguredParams(): void
+ {
+ $factory = new WebklexImapClientFactory(
+ clientManager: $this->manager,
+ mailbox: 'imap.example.com#BOUNCES',
+ host: 'imap.example.com',
+ username: 'user',
+ password: 'pass',
+ protocol: 'imap',
+ port: 993,
+ encryption: 'ssl'
+ );
+
+ $client = $this->createMock(Client::class);
+
+ $this->manager
+ ->expects($this->once())
+ ->method('make')
+ ->with($this->callback(function (array $cfg) {
+ $this->assertSame('imap.example.com', $cfg['host']);
+ $this->assertSame(993, $cfg['port']);
+ $this->assertSame('ssl', $cfg['encryption']);
+ $this->assertTrue($cfg['validate_cert']);
+ $this->assertSame('user', $cfg['username']);
+ $this->assertSame('pass', $cfg['password']);
+ $this->assertSame('imap', $cfg['protocol']);
+ return true;
+ }))
+ ->willReturn($client);
+
+ $out = $factory->makeForMailbox();
+ $this->assertSame($client, $out);
+ $this->assertSame('BOUNCES', $factory->getFolderName());
+ }
+
+ public function testGetFolderNameDefaultsToInbox(): void
+ {
+ $factory = new WebklexImapClientFactory(
+ clientManager: $this->manager,
+ mailbox: 'imap.example.com',
+ host: 'imap.example.com',
+ username: 'u',
+ password: 'p',
+ protocol: 'imap',
+ port: 993
+ );
+ $this->assertSame('INBOX', $factory->getFolderName());
+ }
+}
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php
new file mode 100644
index 00000000..422c78a7
--- /dev/null
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php
@@ -0,0 +1,234 @@
+pageRepository = $this->createMock(SubscriberPageRepository::class);
+ $this->pageDataRepository = $this->createMock(SubscriberPageDataRepository::class);
+ $this->entityManager = $this->createMock(EntityManagerInterface::class);
+
+ $this->manager = new SubscribePageManager(
+ pageRepository: $this->pageRepository,
+ pageDataRepository: $this->pageDataRepository,
+ entityManager: $this->entityManager,
+ );
+ }
+
+ public function testCreatePageCreatesAndSaves(): void
+ {
+ $owner = new Administrator();
+ $this->pageRepository
+ ->expects($this->once())
+ ->method('save')
+ ->with($this->isInstanceOf(SubscribePage::class));
+
+ $page = $this->manager->createPage('My Page', true, $owner);
+
+ $this->assertInstanceOf(SubscribePage::class, $page);
+ $this->assertSame('My Page', $page->getTitle());
+ $this->assertTrue($page->isActive());
+ $this->assertSame($owner, $page->getOwner());
+ }
+
+ public function testGetPageReturnsPage(): void
+ {
+ $page = new SubscribePage();
+ $this->pageRepository
+ ->expects($this->once())
+ ->method('find')
+ ->with(123)
+ ->willReturn($page);
+
+ $result = $this->manager->getPage(123);
+
+ $this->assertSame($page, $result);
+ }
+
+ public function testGetPageThrowsWhenNotFound(): void
+ {
+ $this->pageRepository
+ ->expects($this->once())
+ ->method('find')
+ ->with(999)
+ ->willReturn(null);
+
+ $this->expectException(NotFoundHttpException::class);
+ $this->expectExceptionMessage('Subscribe page not found');
+
+ $this->manager->getPage(999);
+ }
+
+ public function testUpdatePageUpdatesProvidedFieldsAndFlushes(): void
+ {
+ $originalOwner = new Administrator();
+ $newOwner = new Administrator();
+ $page = (new SubscribePage())
+ ->setTitle('Old Title')
+ ->setActive(false)
+ ->setOwner($originalOwner);
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('flush');
+
+ $updated = $this->manager->updatePage($page, title: 'New Title', active: true, owner: $newOwner);
+
+ $this->assertSame($page, $updated);
+ $this->assertSame('New Title', $updated->getTitle());
+ $this->assertTrue($updated->isActive());
+ $this->assertSame($newOwner, $updated->getOwner());
+ }
+
+ public function testUpdatePageLeavesNullFieldsUntouched(): void
+ {
+ $owner = new Administrator();
+ $page = (new SubscribePage())
+ ->setTitle('Keep Title')
+ ->setActive(true)
+ ->setOwner($owner);
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('flush');
+
+ $updated = $this->manager->updatePage(page: $page, title: null, active: null, owner: null);
+
+ $this->assertSame('Keep Title', $updated->getTitle());
+ $this->assertTrue($updated->isActive());
+ $this->assertSame($owner, $updated->getOwner());
+ }
+
+ public function testSetActiveSetsFlagAndFlushes(): void
+ {
+ $page = (new SubscribePage())
+ ->setTitle('Any')
+ ->setActive(false);
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('flush');
+
+ $this->manager->setActive($page, true);
+ $this->assertTrue($page->isActive());
+ }
+
+ public function testDeletePageCallsRepositoryRemove(): void
+ {
+ $page = new SubscribePage();
+
+ $this->pageRepository
+ ->expects($this->once())
+ ->method('remove')
+ ->with($page);
+
+ $this->manager->deletePage($page);
+ }
+
+ public function testGetPageDataReturnsStringWhenFound(): void
+ {
+ $page = new SubscribePage();
+ $data = $this->createMock(SubscribePageData::class);
+ $data->expects($this->once())->method('getData')->willReturn('value');
+
+ $this->pageDataRepository
+ ->expects($this->once())
+ ->method('getByPage')
+ ->with($page)
+ ->willReturn([$data]);
+
+ $result = $this->manager->getPageData($page);
+ $this->assertIsArray($result);
+ $this->assertSame('value', $result[0]->getData());
+ }
+
+ public function testGetPageDataReturnsNullWhenNotFound(): void
+ {
+ $page = new SubscribePage();
+
+ $this->pageDataRepository
+ ->expects($this->once())
+ ->method('getByPage')
+ ->with($page)
+ ->willReturn([]);
+
+ $result = $this->manager->getPageData($page);
+ $this->assertEmpty($result);
+ }
+
+ public function testSetPageDataUpdatesExistingDataAndFlushes(): void
+ {
+ $page = new SubscribePage();
+ $existing = new SubscribePageData();
+ $existing->setId(5)->setName('color')->setData('red');
+
+ $this->pageDataRepository
+ ->expects($this->once())
+ ->method('findByPageAndName')
+ ->with($page, 'color')
+ ->willReturn($existing);
+
+ $this->entityManager
+ ->expects($this->never())
+ ->method('persist');
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('flush');
+
+ $result = $this->manager->setPageData($page, 'color', 'blue');
+
+ $this->assertSame($existing, $result);
+ $this->assertSame('blue', $result->getData());
+ }
+
+ public function testSetPageDataCreatesNewWhenMissingAndPersistsAndFlushes(): void
+ {
+ $page = $this->getMockBuilder(SubscribePage::class)
+ ->onlyMethods(['getId'])
+ ->getMock();
+ $page->method('getId')->willReturn(123);
+
+ $this->pageDataRepository
+ ->expects($this->once())
+ ->method('findByPageAndName')
+ ->with($page, 'greeting')
+ ->willReturn(null);
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('persist')
+ ->with($this->isInstanceOf(SubscribePageData::class));
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('flush');
+
+ $result = $this->manager->setPageData($page, 'greeting', 'hello');
+
+ $this->assertInstanceOf(SubscribePageData::class, $result);
+ $this->assertSame(123, $result->getId());
+ $this->assertSame('greeting', $result->getName());
+ $this->assertSame('hello', $result->getData());
+ }
+}
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php
new file mode 100644
index 00000000..25fdf5ca
--- /dev/null
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php
@@ -0,0 +1,202 @@
+subscriberRepository = $this->createMock(SubscriberRepository::class);
+ $this->userBlacklistRepository = $this->createMock(UserBlacklistRepository::class);
+ $this->userBlacklistDataRepository = $this->createMock(UserBlacklistDataRepository::class);
+ $this->entityManager = $this->createMock(EntityManagerInterface::class);
+
+ $this->manager = new SubscriberBlacklistManager(
+ subscriberRepository: $this->subscriberRepository,
+ userBlacklistRepository: $this->userBlacklistRepository,
+ blacklistDataRepository: $this->userBlacklistDataRepository,
+ entityManager: $this->entityManager,
+ );
+ }
+
+ public function testIsEmailBlacklistedReturnsValueFromRepository(): void
+ {
+ $this->subscriberRepository
+ ->expects($this->once())
+ ->method('isEmailBlacklisted')
+ ->with('test@example.com')
+ ->willReturn(true);
+
+ $result = $this->manager->isEmailBlacklisted('test@example.com');
+
+ $this->assertTrue($result);
+ }
+
+ public function testGetBlacklistInfoReturnsResultFromRepository(): void
+ {
+ $userBlacklist = $this->createMock(UserBlacklist::class);
+
+ $this->userBlacklistRepository
+ ->expects($this->once())
+ ->method('findBlacklistInfoByEmail')
+ ->with('foo@bar.com')
+ ->willReturn($userBlacklist);
+
+ $result = $this->manager->getBlacklistInfo('foo@bar.com');
+
+ $this->assertSame($userBlacklist, $result);
+ }
+
+ public function testAddEmailToBlacklistDoesNotAddIfAlreadyBlacklisted(): void
+ {
+ $this->subscriberRepository
+ ->expects($this->once())
+ ->method('isEmailBlacklisted')
+ ->with('already@blacklisted.com')
+ ->willReturn(true);
+
+ $this->userBlacklistRepository
+ ->expects($this->once())
+ ->method('findBlacklistInfoByEmail')
+ ->willReturn($this->createMock(UserBlacklist::class));
+
+ $this->entityManager
+ ->expects($this->never())
+ ->method('persist');
+
+ $this->entityManager
+ ->expects($this->never())
+ ->method('flush');
+
+ $this->manager->addEmailToBlacklist('already@blacklisted.com', 'reason');
+ }
+
+ public function testAddEmailToBlacklistAddsEntryAndReason(): void
+ {
+ $this->subscriberRepository
+ ->expects($this->once())
+ ->method('isEmailBlacklisted')
+ ->with('new@blacklist.com')
+ ->willReturn(false);
+
+ $this->entityManager
+ ->expects($this->exactly(2))
+ ->method('persist')
+ ->withConsecutive(
+ [$this->isInstanceOf(UserBlacklist::class)],
+ [$this->isInstanceOf(UserBlacklistData::class)]
+ );
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('flush');
+
+ $this->manager->addEmailToBlacklist('new@blacklist.com', 'test reason');
+ }
+
+ public function testAddEmailToBlacklistAddsEntryWithoutReason(): void
+ {
+ $this->subscriberRepository
+ ->expects($this->once())
+ ->method('isEmailBlacklisted')
+ ->with('noreason@blacklist.com')
+ ->willReturn(false);
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('persist')
+ ->with($this->isInstanceOf(UserBlacklist::class));
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('flush');
+
+ $this->manager->addEmailToBlacklist('noreason@blacklist.com');
+ }
+
+ public function testRemoveEmailFromBlacklistRemovesAllRelatedData(): void
+ {
+ $blacklist = $this->createMock(UserBlacklist::class);
+ $blacklistData = $this->createMock(UserBlacklistData::class);
+ $subscriber = $this->getMockBuilder(Subscriber::class)
+ ->onlyMethods(['setBlacklisted'])
+ ->getMock();
+
+ $this->userBlacklistRepository
+ ->expects($this->once())
+ ->method('findOneByEmail')
+ ->with('remove@me.com')
+ ->willReturn($blacklist);
+
+ $this->userBlacklistDataRepository
+ ->expects($this->once())
+ ->method('findOneByEmail')
+ ->with('remove@me.com')
+ ->willReturn($blacklistData);
+
+ $this->subscriberRepository
+ ->expects($this->once())
+ ->method('findOneByEmail')
+ ->with('remove@me.com')
+ ->willReturn($subscriber);
+
+ $this->entityManager
+ ->expects($this->exactly(2))
+ ->method('remove')
+ ->withConsecutive([$blacklist], [$blacklistData]);
+
+ $subscriber->expects($this->once())->method('setBlacklisted')->with(false);
+
+ $this->entityManager
+ ->expects($this->once())
+ ->method('flush');
+
+ $this->manager->removeEmailFromBlacklist('remove@me.com');
+ }
+
+ public function testGetBlacklistReasonReturnsReasonOrNull(): void
+ {
+ $blacklistData = $this->createMock(UserBlacklistData::class);
+ $blacklistData->expects($this->once())->method('getData')->willReturn('my reason');
+
+ $this->userBlacklistDataRepository
+ ->expects($this->once())
+ ->method('findOneByEmail')
+ ->with('why@blacklist.com')
+ ->willReturn($blacklistData);
+
+ $result = $this->manager->getBlacklistReason('why@blacklist.com');
+ $this->assertSame('my reason', $result);
+ }
+
+ public function testGetBlacklistReasonReturnsNullIfNoData(): void
+ {
+ $this->userBlacklistDataRepository
+ ->expects($this->once())
+ ->method('findOneByEmail')
+ ->with('none@blacklist.com')
+ ->willReturn(null);
+
+ $result = $this->manager->getBlacklistReason('none@blacklist.com');
+ $this->assertNull($result);
+ }
+}
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php
index 8df0f4d8..43ae2fcc 100644
--- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php
@@ -4,6 +4,8 @@
namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service\Manager;
+use PhpList\Core\Domain\Common\ClientIpResolver;
+use PhpList\Core\Domain\Common\SystemInfoCollector;
use PhpList\Core\Domain\Subscription\Model\Filter\SubscriberHistoryFilter;
use PhpList\Core\Domain\Subscription\Model\SubscriberHistory;
use PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository;
@@ -20,7 +22,9 @@ protected function setUp(): void
{
$this->subscriberHistoryRepository = $this->createMock(SubscriberHistoryRepository::class);
$this->subscriptionHistoryService = new SubscriberHistoryManager(
- repository: $this->subscriberHistoryRepository
+ repository: $this->subscriberHistoryRepository,
+ clientIpResolver: $this->createMock(ClientIpResolver::class),
+ systemInfoCollector: $this->createMock(SystemInfoCollector::class),
);
}
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php
index 9a177312..b7a99366 100644
--- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php
@@ -34,7 +34,7 @@ protected function setUp(): void
subscriberRepository: $this->subscriberRepository,
entityManager: $this->entityManager,
messageBus: $this->messageBus,
- subscriberDeletionService: $subscriberDeletionService
+ subscriberDeletionService: $subscriberDeletionService,
);
}
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php
index e535a7fe..f0c1d3af 100644
--- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php
@@ -14,11 +14,13 @@
use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use Symfony\Contracts\Translation\TranslatorInterface;
class SubscriptionManagerTest extends TestCase
{
private SubscriptionRepository&MockObject $subscriptionRepository;
private SubscriberRepository&MockObject $subscriberRepository;
+ private TranslatorInterface&MockObject $translator;
private SubscriptionManager $manager;
protected function setUp(): void
@@ -26,10 +28,12 @@ protected function setUp(): void
$this->subscriptionRepository = $this->createMock(SubscriptionRepository::class);
$this->subscriberRepository = $this->createMock(SubscriberRepository::class);
$subscriberListRepository = $this->createMock(SubscriberListRepository::class);
+ $this->translator = $this->createMock(TranslatorInterface::class);
$this->manager = new SubscriptionManager(
- $this->subscriptionRepository,
- $this->subscriberRepository,
- $subscriberListRepository
+ subscriptionRepository: $this->subscriptionRepository,
+ subscriberRepository: $this->subscriberRepository,
+ subscriberListRepository: $subscriberListRepository,
+ translator: $this->translator,
);
}
@@ -51,6 +55,7 @@ public function testCreateSubscriptionWhenSubscriberExists(): void
public function testCreateSubscriptionThrowsWhenSubscriberMissing(): void
{
+ $this->translator->method('trans')->willReturn('Subscriber does not exists.');
$this->expectException(SubscriptionCreationException::class);
$this->expectExceptionMessage('Subscriber does not exists.');