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, ); }