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/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 0f6bb119..5ef215b3 100644
--- a/config/services/managers.yml
+++ b/config/services/managers.yml
@@ -72,6 +72,10 @@ services:
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
@@ -79,3 +83,11 @@ services:
PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager:
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/providers.yml b/config/services/providers.yml
index 226c4e81..cb784988 100644
--- a/config/services/providers.yml
+++ b/config/services/providers.yml
@@ -2,3 +2,7 @@ services:
PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider:
autowire: true
autoconfigure: true
+
+ PhpList\Core\Domain\Messaging\Service\Provider\BounceActionProvider:
+ autowire: true
+ autoconfigure: true
diff --git a/config/services/repositories.yml b/config/services/repositories.yml
index 69bdb6ce..82ae6a82 100644
--- a/config/services/repositories.yml
+++ b/config/services/repositories.yml
@@ -1,137 +1,152 @@
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\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\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\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..19caddd8 100644
--- a/config/services/services.yml
+++ b/config/services/services.yml
@@ -1,36 +1,109 @@
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' }
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/Mail/NativeImapMailReader.php b/src/Domain/Common/Mail/NativeImapMailReader.php
new file mode 100644
index 00000000..472fea54
--- /dev/null
+++ b/src/Domain/Common/Mail/NativeImapMailReader.php
@@ -0,0 +1,65 @@
+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/SystemInfoCollector.php b/src/Domain/Common/SystemInfoCollector.php
new file mode 100644
index 00000000..e66d27b1
--- /dev/null
+++ b/src/Domain/Common/SystemInfoCollector.php
@@ -0,0 +1,77 @@
+ 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/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/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/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 f5088376..9aecde78 100644
--- a/src/Domain/Messaging/Repository/BounceRegexRepository.php
+++ b/src/Domain/Messaging/Repository/BounceRegexRepository.php
@@ -17,4 +17,16 @@ 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/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/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/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/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php
index 6ebaee70..3c3583b4 100644
--- a/src/Domain/Subscription/Repository/SubscriberRepository.php
+++ b/src/Domain/Subscription/Repository/SubscriberRepository.php
@@ -141,4 +141,51 @@ public function isEmailBlacklisted(string $email): bool
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/Service/Manager/SubscriberBlacklistManager.php b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php
index d30bae2d..d5828c2f 100644
--- a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php
@@ -58,6 +58,16 @@ public function addEmailToBlacklist(string $email, ?string $reasonData = null):
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);
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/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/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/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
index 1cd432bc..fd526a64 100644
--- a/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php
@@ -131,7 +131,7 @@ public function testAssociateBounceIncrementsCountAndPersistsRelation(): void
->method('persist')
->with($this->callback(function ($entity) use ($regex) {
return $entity instanceof BounceRegexBounce
- && $entity->getRegex() === $regex->getId();
+ && $entity->getRegexId() === $regex->getId();
}));
$this->entityManager->expects($this->once())
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/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/Manager/TemplateImageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php
index 7eb6afe7..93907f02 100644
--- a/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php
@@ -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/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/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,
);
}