diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 00000000..3cc5119b
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,9 @@
+language: "en-US"
+reviews:
+  profile: "chill"
+  high_level_summary: true
+  auto_review:
+    enabled: true
+    base_branches:
+      - ".*"
+    drafts: false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 43dea8d7..8e0d5fbe 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -75,4 +75,4 @@ jobs:
       - name: Running PHPMD
         run: vendor/bin/phpmd src/ text config/PHPMD/rules.xml;
       - name: Running PHP_CodeSniffer
-        run: vendor/bin/phpcs --standard=config/PhpCodeSniffer/ bin/ src/ tests/ public/;
+        run: vendor/bin/phpcs --standard=config/PhpCodeSniffer/ --ignore=*/Migrations/* bin/ src/ tests/ public/;
diff --git a/.github/workflows/i18n-validate.yml b/.github/workflows/i18n-validate.yml
new file mode 100644
index 00000000..4e49efa8
--- /dev/null
+++ b/.github/workflows/i18n-validate.yml
@@ -0,0 +1,69 @@
+name: I18n Validate
+
+on:
+  pull_request:
+    paths:
+      - 'resources/translations/**/*.xlf'
+      - 'composer.lock'
+      - 'composer.json'
+
+jobs:
+  validate-xliff:
+    runs-on: ubuntu-22.04
+
+    strategy:
+      fail-fast: false
+      matrix:
+        php: ['8.1']
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup PHP
+        uses: shivammathur/setup-php@v2
+        with:
+          php-version: ${{ matrix.php }}
+          extensions: imap, zip
+          tools: composer:v2
+          coverage: none
+
+      - name: Cache Composer packages
+        uses: actions/cache@v4
+        with:
+          path: |
+            ~/.composer/cache/files
+          key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
+          restore-keys: |
+            ${{ runner.os }}-composer-${{ matrix.php }}-
+
+      - name: Install dependencies (no dev autoloader scripts)
+        run: |
+          set -euo pipefail
+          composer install --no-interaction --no-progress --prefer-dist
+
+      - name: Lint XLIFF with Symfony
+        run: |
+          set -euo pipefail
+          # Adjust the directory to match your repo layout
+          php bin/console lint:xliff resources/translations
+
+      - name: Validate XLIFF XML with xmllint
+        run: |
+          set -euo pipefail
+          sudo apt-get update
+          sudo apt-get install -y --no-install-recommends libxml2-utils
+          # Adjust root dir; prune vendor; accept spaces/newlines safely
+          find resources/translations -type f -name '*.xlf' -not -path '*/vendor/*' -print0 \
+            | xargs -0 -n1 xmllint --noout
+
+      - name: Symfony translation sanity (extract dry-run)
+        run: |
+          set -euo pipefail
+          # Show what would be created/updated without writing files
+          php bin/console translation:extract en \
+            --format=xlf \
+            --domain=messages \
+            --dump-messages \
+            --no-interaction
+          # Note: omit --force to keep this a dry-run
diff --git a/.weblate b/.weblate
new file mode 100644
index 00000000..5917a8b8
--- /dev/null
+++ b/.weblate
@@ -0,0 +1,23 @@
+# .weblate
+---
+projects:
+  - slug: phplist-core
+    name: phpList core
+    components:
+      - slug: messages
+        name: Messages
+        files:
+          # {language} is Weblate’s placeholder (e.g., fr, de, es)
+          - src: resources/translations/messages.en.xlf
+            template: true
+            # Where localized files live (mirrors Symfony layout)
+            target: resources/translations/messages.{language}.xlf
+        file_format: xliff
+        language_code_style: bcp
+        # Ensure placeholders like %name% are preserved
+        parse_file_headers: true
+        check_flags:
+          - xml-invalid
+          - placeholders
+          - urls
+          - accelerated
diff --git a/README.md b/README.md
index ffe011ca..2d2bc213 100755
--- a/README.md
+++ b/README.md
@@ -214,3 +214,11 @@ For detailed configuration instructions, see the [Mailer Transports documentatio
 ## Copyright
 
 phpList is copyright (C) 2000-2025 [phpList Ltd](https://www.phplist.com/).
+
+
+### Translations
+command to extract translation strings
+
+```bash
+php bin/console translation:extract --force en --format=xlf
+```
diff --git a/composer.json b/composer.json
index be974681..4ea09327 100644
--- a/composer.json
+++ b/composer.json
@@ -32,6 +32,12 @@
             "role": "Maintainer"
         }
     ],
+    "repositories": [
+      {
+        "type": "vcs",
+        "url": "https://github.com/TatevikGr/rss-bundle.git"
+      }
+    ],
     "support": {
         "issues": "https://github.com/phpList/core/issues",
         "forum": "https://discuss.phplist.org/",
@@ -68,7 +74,10 @@
         "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": "*",
+        "tatevikgr/rss-feed": "dev-main"
     },
     "require-dev": {
         "phpunit/phpunit": "^9.5",
@@ -140,7 +149,8 @@
                 "Doctrine\\Bundle\\DoctrineBundle\\DoctrineBundle",
                 "Doctrine\\Bundle\\MigrationsBundle\\DoctrineMigrationsBundle",
                 "PhpList\\Core\\EmptyStartPageBundle\\EmptyStartPageBundle",
-                "FOS\\RestBundle\\FOSRestBundle"
+                "FOS\\RestBundle\\FOSRestBundle",
+                "TatevikGr\\RssFeedBundle\\RssFeedBundle"
             ],
             "routes": {
                 "homepage": {
@@ -149,5 +159,10 @@
                 }
             }
         }
+    },
+    "config": {
+        "allow-plugins": {
+            "php-http/discovery": true
+        }
     }
 }
diff --git a/config/PHPMD/rules.xml b/config/PHPMD/rules.xml
index 2d88410b..b8b0c3df 100644
--- a/config/PHPMD/rules.xml
+++ b/config/PHPMD/rules.xml
@@ -4,6 +4,8 @@
         PHPMD rules for phpList
     
 
+    */Migrations/* 
+
     
      
     
@@ -45,13 +47,17 @@
      
      
      
-     
+    
+        
+             
+         
+     
      
 
     
     
         
-             
+             
          
      
     
@@ -67,5 +73,4 @@
      
      
      
-     
 
diff --git a/config/PhpCodeSniffer/ruleset.xml b/config/PhpCodeSniffer/ruleset.xml
index d0258304..fdba2edf 100644
--- a/config/PhpCodeSniffer/ruleset.xml
+++ b/config/PhpCodeSniffer/ruleset.xml
@@ -15,7 +15,6 @@
     
      
      
-     
      
      
 
@@ -41,9 +40,6 @@
     
      
 
-    
-     
-
     
      
      
@@ -54,7 +50,6 @@
      
 
     
-     
      
      
 
@@ -66,9 +61,6 @@
      
      
 
-    
-     
-
     
      
      
@@ -110,6 +102,5 @@
      
      
      
-     
      
 
diff --git a/config/config.yml b/config/config.yml
index e235f999..7de6dca6 100644
--- a/config/config.yml
+++ b/config/config.yml
@@ -10,7 +10,10 @@ parameters:
 
 framework:
     #esi: ~
-    #translator: { fallbacks: ['%locale%'] }
+    translator:
+      default_path: '%kernel.project_dir%/resources/translations'
+      fallbacks: ['%locale%']
+
     secret: '%secret%'
     router:
         resource: '%kernel.project_dir%/config/routing.yml'
diff --git a/config/doctrine.yml b/config/doctrine.yml
index 327cf305..caaaec71 100644
--- a/config/doctrine.yml
+++ b/config/doctrine.yml
@@ -11,6 +11,7 @@ doctrine:
     user: '%database_user%'
     password: '%database_password%'
     charset: UTF8
+    use_savepoints: true
 
   orm:
     auto_generate_proxy_classes: '%kernel.debug%'
diff --git a/config/doctrine_migrations.yml b/config/doctrine_migrations.yml
index cd5f8ff6..97e3bd6f 100644
--- a/config/doctrine_migrations.yml
+++ b/config/doctrine_migrations.yml
@@ -1,8 +1,10 @@
 doctrine_migrations:
     migrations_paths:
         'PhpList\Core\Migrations': '%kernel.project_dir%/src/Migrations'
+#        'TatevikGr\RssBundle\RssFeedBundle\Migrations': '%kernel.project_dir%/vendor/tatevikgr/rss-bundle/src/RssFeedBundle/Migrations'
     all_or_nothing: true
     organize_migrations: false
+    custom_template: '%kernel.project_dir%/src/Migrations/_template_migration.php.tpl'
     storage:
         table_storage:
             table_name: 'doctrine_migration_versions'
diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml
index 6841eed5..38a75a1b 100644
--- a/config/packages/messenger.yaml
+++ b/config/packages/messenger.yaml
@@ -6,6 +6,7 @@ framework:
 
         transports:
             # https://symfony.com/doc/current/messenger.html#transport-configuration
+            sync: 'sync://'
             async_email:
                 dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                 options:
@@ -25,3 +26,8 @@ framework:
             # Route your messages to the transports
             'PhpList\Core\Domain\Messaging\Message\AsyncEmailMessage': async_email
             'PhpList\Core\Domain\Messaging\Message\SubscriberConfirmationMessage': async_email
+            'PhpList\Core\Domain\Messaging\Message\SubscriptionConfirmationMessage': async_email
+            'PhpList\Core\Domain\Messaging\Message\PasswordResetMessage': async_email
+            'PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage': async_email
+            'PhpList\Core\Domain\Messaging\Message\SyncCampaignProcessorMessage': sync
+
diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist
index 621a8b81..4e9e0cff 100644
--- a/config/parameters.yml.dist
+++ b/config/parameters.yml.dist
@@ -21,6 +21,8 @@ parameters:
     env(PHPLIST_DATABASE_USER): 'phplist'
     database_password: '%%env(PHPLIST_DATABASE_PASSWORD)%%'
     env(PHPLIST_DATABASE_PASSWORD): 'phplist'
+    database_prefix: '%%env(DATABASE_PREFIX)%%'
+    env(DATABASE_PREFIX): 'phplist_'
 
     # Email configuration
     app.mailer_from: '%%env(MAILER_FROM)%%'
@@ -28,10 +30,38 @@ parameters:
     app.mailer_dsn: '%%env(MAILER_DSN)%%'
     env(MAILER_DSN): 'null://null'
     app.confirmation_url: '%%env(CONFIRMATION_URL)%%'
-    env(CONFIRMATION_URL): 'https://example.com/confirm/'
+    env(CONFIRMATION_URL): 'https://example.com/subscriber/confirm/'
+    app.subscription_confirmation_url: '%%env(SUBSCRIPTION_CONFIRMATION_URL)%%'
+    env(SUBSCRIPTION_CONFIRMATION_URL): 'https://example.com/subscription/confirm/'
     app.password_reset_url: '%%env(PASSWORD_RESET_URL)%%'
     env(PASSWORD_RESET_URL): 'https://example.com/reset/'
 
+    # 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'
@@ -42,3 +72,16 @@ parameters:
 
     graylog_host: 'graylog.example.com'
     graylog_port: 12201
+
+    app.phplist_isp_conf_path: '%%env(APP_PHPLIST_ISP_CONF_PATH)%%'
+    env(APP_PHPLIST_ISP_CONF_PATH): '/etc/phplist.conf'
+
+    # Message sending
+    messaging.mail_queue_batch_size: '%%env(MAILQUEUE_BATCH_SIZE)%%'
+    env(MAILQUEUE_BATCH_SIZE): '5'
+    messaging.mail_queue_period: '%%env(MAILQUEUE_BATCH_PERIOD)%%'
+    env(MAILQUEUE_BATCH_PERIOD): '5'
+    messaging.mail_queue_throttle: '%%env(MAILQUEUE_THROTTLE)%%'
+    env(MAILQUEUE_THROTTLE): '5'
+    messaging.max_process_time: '%%env(MESSAGING_MAX_PROCESS_TIME)%%'
+    env(MESSAGING_MAX_PROCESS_TIME): '600'
diff --git a/config/services.yml b/config/services.yml
index b83adce3..59f32e7a 100644
--- a/config/services.yml
+++ b/config/services.yml
@@ -1,51 +1,52 @@
 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\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/'
+
+  PhpList\Core\Core\Doctrine\OnlyOrmTablesFilter:
+    lazy: true
+    tags:
+      - { name: 'doctrine.dbal.schema_filter', connection: 'default' }
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..7e16ac87 100644
--- a/config/services/commands.yml
+++ b/config/services/commands.yml
@@ -11,3 +11,10 @@ services:
   PhpList\Core\Domain\Identity\Command\:
     resource: '../../src/Domain/Identity/Command'
     tags: ['console.command']
+
+  PhpList\Core\Bounce\Command\ProcessBouncesCommand:
+    arguments:
+      $protocolProcessors: !tagged_iterator 'phplist.bounce_protocol_processor'
+
+  TatevikGr\RssFeedBundle\Command\RssDispatchCommand:
+    tags: ['console.command']
diff --git a/config/services/managers.yml b/config/services/managers.yml
index 4f57fc11..2f72bd7e 100644
--- a/config/services/managers.yml
+++ b/config/services/managers.yml
@@ -4,43 +4,43 @@ services:
     autoconfigure: true
     public: false
 
-  PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager:
+  PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager:
     autowire: true
     autoconfigure: true
 
-  PhpList\Core\Domain\Identity\Service\SessionManager:
+  PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager:
     autowire: true
     autoconfigure: true
 
-  PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager:
+  PhpList\Core\Domain\Identity\Service\SessionManager:
     autowire: true
     autoconfigure: true
 
-  PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager:
+  PhpList\Core\Domain\Identity\Service\AdministratorManager:
     autowire: true
     autoconfigure: true
 
-  PhpList\Core\Domain\Messaging\Service\MessageManager:
+  PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager:
     autowire: true
     autoconfigure: true
 
-  PhpList\Core\Domain\Messaging\Service\TemplateManager:
+  PhpList\Core\Domain\Identity\Service\AdminAttributeManager:
     autowire: true
     autoconfigure: true
 
-  PhpList\Core\Domain\Messaging\Service\TemplateImageManager:
+  PhpList\Core\Domain\Identity\Service\PasswordManager:
     autowire: true
     autoconfigure: true
 
-  PhpList\Core\Domain\Identity\Service\AdministratorManager:
+  PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager:
     autowire: true
     autoconfigure: true
 
-  PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager:
+  PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager:
     autowire: true
     autoconfigure: true
 
-  PhpList\Core\Domain\Identity\Service\AdminAttributeManager:
+  PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager:
     autowire: true
     autoconfigure: true
 
@@ -56,14 +56,42 @@ services:
     autowire: true
     autoconfigure: true
 
-  PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager:
+  PhpList\Core\Domain\Subscription\Service\Manager\SubscriberBlacklistManager:
     autowire: true
     autoconfigure: true
 
-  PhpList\Core\Domain\Identity\Service\PasswordManager:
+  PhpList\Core\Domain\Subscription\Service\Manager\SubscribePageManager:
+    autowire: true
+    autoconfigure: true
+
+  PhpList\Core\Domain\Messaging\Service\Manager\MessageManager:
+    autowire: true
+    autoconfigure: true
+
+  PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager:
+    autowire: true
+    autoconfigure: true
+
+  PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager:
+    autowire: true
+    autoconfigure: true
+
+  PhpList\Core\Domain\Messaging\Service\Manager\BounceRegexManager:
+    autowire: true
+    autoconfigure: true
+
+  PhpList\Core\Bounce\Service\Manager\BounceManager:
     autowire: true
     autoconfigure: true
 
   PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager:
     autowire: true
     autoconfigure: true
+
+  PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager:
+    autowire: true
+    autoconfigure: true
+
+  PhpList\Core\Domain\Messaging\Service\Manager\SendProcessManager:
+    autowire: true
+    autoconfigure: true
diff --git a/config/services/messenger.yml b/config/services/messenger.yml
index 3d1e59b2..214a4c86 100644
--- a/config/services/messenger.yml
+++ b/config/services/messenger.yml
@@ -1,6 +1,7 @@
 services:
     # Register message handlers for Symfony Messenger
     PhpList\Core\Domain\Messaging\MessageHandler\:
+        autowire: true
         resource: '../../src/Domain/Messaging/MessageHandler'
         tags: [ 'messenger.message_handler' ]
 
@@ -22,3 +23,9 @@ services:
         tags: [ 'messenger.message_handler' ]
         arguments:
             $passwordResetUrl: '%app.password_reset_url%'
+
+    PhpList\Core\Domain\Messaging\MessageHandler\SubscriptionConfirmationMessageHandler:
+        autowire: true
+        autoconfigure: true
+        tags: [ 'messenger.message_handler' ]
+
diff --git a/config/services/processor.yml b/config/services/processor.yml
new file mode 100644
index 00000000..8ef01135
--- /dev/null
+++ b/config/services/processor.yml
@@ -0,0 +1,21 @@
+services:
+  _defaults:
+    autowire: true
+    autoconfigure: true
+    public: false
+
+  PhpList\Core\Bounce\Service\Processor\PopBounceProcessor:
+    arguments:
+      $host: '%imap_bounce.host%'
+      $port: '%imap_bounce.port%'
+      $mailboxNames: '%imap_bounce.mailbox_name%'
+    tags: ['phplist.bounce_protocol_processor']
+
+  PhpList\Core\Bounce\Service\Processor\MboxBounceProcessor:
+    tags: ['phplist.bounce_protocol_processor']
+
+  PhpList\Core\Bounce\Service\Processor\AdvancedBounceRulesProcessor: ~
+
+  PhpList\Core\Bounce\Service\Processor\UnidentifiedBounceReprocessor: ~
+
+  PhpList\Core\Bounce\Service\Processor\BounceDataProcessor: ~
diff --git a/config/services/providers.yml b/config/services/providers.yml
index 226c4e81..b7b66be8 100644
--- a/config/services/providers.yml
+++ b/config/services/providers.yml
@@ -2,3 +2,31 @@ services:
   PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider:
     autowire: true
     autoconfigure: true
+
+  PhpList\Core\Core\ParameterProvider:
+    arguments:
+      $config: '%app.config%'
+
+  PhpList\Core\Domain\Common\IspRestrictionsProvider:
+    autowire: true
+    autoconfigure: true
+    arguments:
+      $confPath: '%app.phplist_isp_conf_path%'
+
+  PhpList\Core\Domain\Subscription\Service\Provider\CheckboxGroupValueProvider:
+    autowire: true
+  PhpList\Core\Domain\Subscription\Service\Provider\SelectOrRadioValueProvider:
+    autowire: true
+  PhpList\Core\Domain\Subscription\Service\Provider\ScalarValueProvider:
+    autowire: true
+
+  PhpList\Core\Domain\Configuration\Service\Provider\DefaultConfigProvider:
+    autowire: true
+
+  PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider:
+    autowire: true
+    arguments:
+      $cache: '@Psr\SimpleCache\CacheInterface'
+
+  PhpList\Core\Domain\Subscription\Service\Provider\SubscriberAttributeChangeSetProvider:
+    autowire: true
diff --git a/config/services/repositories.yml b/config/services/repositories.yml
index eca3a31c..e9d4d8c6 100644
--- a/config/services/repositories.yml
+++ b/config/services/repositories.yml
@@ -1,112 +1,139 @@
 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\Analytics\Repository\LinkTrackRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Analytics\Model\LinkTrack
+  PhpList\Core\Domain\Analytics\Repository\UserMessageViewRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Analytics\Model\UserMessageView
+  PhpList\Core\Domain\Analytics\Repository\LinkTrackUmlClickRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Analytics\Model\LinkTrackUmlClick
+
+
+  PhpList\Core\Domain\Configuration\Repository\ConfigRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Configuration\Model\Config
+  PhpList\Core\Domain\Configuration\Repository\EventLogRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Configuration\Model\EventLog
+
+
+  PhpList\Core\Domain\Identity\Repository\AdministratorRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Identity\Model\Administrator
+      - Doctrine\ORM\Mapping\ClassMetadata\ClassMetadata
+      - PhpList\Core\Security\HashGenerator
+  PhpList\Core\Domain\Identity\Repository\AdminAttributeValueRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Identity\Model\AdminAttributeValue
+  PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition
+  PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Identity\Model\AdministratorToken
+  PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Identity\Model\AdminPasswordRequest
+
+
+  PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Subscription\Model\SubscriberList
+  PhpList\Core\Domain\Subscription\Repository\SubscriberRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Subscription\Model\Subscriber
+  PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue
+  PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition
+  PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Subscription\Model\Subscription
+  PhpList\Core\Domain\Subscription\Repository\DynamicListAttrRepository:
+    autowire: true
+    arguments:
+      $prefix: '%database_prefix%'
+  PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Subscription\Model\SubscriberHistory
+  PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Subscription\Model\UserBlacklist
+  PhpList\Core\Domain\Subscription\Repository\UserBlacklistDataRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Subscription\Model\UserBlacklistData
+  PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Subscription\Model\SubscribePage
+  PhpList\Core\Domain\Subscription\Repository\SubscriberPageDataRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Subscription\Model\SubscribePageData
+
+
+  PhpList\Core\Domain\Messaging\Repository\MessageRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Messaging\Model\Message
+  PhpList\Core\Domain\Messaging\Repository\TemplateRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Messaging\Model\Template
+  PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Messaging\Model\TemplateImage
+  PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Messaging\Model\UserMessageBounce
+  PhpList\Core\Domain\Messaging\Repository\UserMessageForwardRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Messaging\Model\UserMessageForward
+  PhpList\Core\Domain\Messaging\Repository\UserMessageRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Messaging\Model\UserMessage
+  PhpList\Core\Domain\Messaging\Repository\ListMessageRepository:
+    parent: PhpList\Core\Domain\Common\Repository\AbstractRepository
+    arguments:
+      - PhpList\Core\Domain\Messaging\Model\ListMessage
+  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/resolvers.yml b/config/services/resolvers.yml
new file mode 100644
index 00000000..99c08356
--- /dev/null
+++ b/config/services/resolvers.yml
@@ -0,0 +1,15 @@
+services:
+  PhpList\Core\Domain\Subscription\Service\Resolver\AttributeValueResolver:
+    arguments:
+      $providers:
+        - '@PhpList\Core\Domain\Subscription\Service\Provider\CheckboxGroupValueProvider'
+        - '@PhpList\Core\Domain\Subscription\Service\Provider\SelectOrRadioValueProvider'
+        - '@PhpList\Core\Domain\Subscription\Service\Provider\ScalarValueProvider'
+
+  PhpList\Core\Domain\Common\ClientIpResolver:
+    autowire: true
+    autoconfigure: true
+
+  PhpList\Core\Bounce\Service\BounceActionResolver:
+    arguments:
+      - !tagged_iterator { tag: 'phplist.bounce_action_handler' }
diff --git a/config/services/services.yml b/config/services/services.yml
index 7b9f921c..65ede6b7 100644
--- a/config/services/services.yml
+++ b/config/services/services.yml
@@ -1,36 +1,135 @@
 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\SendRateLimiter:
+    autowire: true
+    autoconfigure: true
+    arguments:
+      $mailqueueBatchSize: '%messaging.mail_queue_batch_size%'
+      $mailqueueBatchPeriod: '%messaging.mail_queue_period%'
+      $mailqueueThrottle: '%messaging.mail_queue_throttle%'
+
+  PhpList\Core\Domain\Common\SystemInfoCollector:
+    autowire: true
+    autoconfigure: true
+
+  PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer:
+    autowire: true
+    autoconfigure: true
+
+  PhpList\Core\Bounce\Service\ConsecutiveBounceHandler:
+    autowire: true
+    autoconfigure: true
+    arguments:
+      $unsubscribeThreshold: '%imap_bounce.unsubscribe_threshold%'
+      $blacklistThreshold: '%imap_bounce.blacklist_threshold%'
+
+  Webklex\PHPIMAP\ClientManager: ~
+
+  PhpList\Core\Bounce\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\Bounce\Service\NativeBounceProcessingService:
+    autowire: true
+    autoconfigure: true
+    arguments:
+      $purgeProcessed: '%imap_bounce.purge%'
+      $purgeUnprocessed: '%imap_bounce.purge_unprocessed%'
+
+  PhpList\Core\Bounce\Service\WebklexBounceProcessingService:
+    autowire: true
+    autoconfigure: true
+    arguments:
+      $purgeProcessed: '%imap_bounce.purge%'
+      $purgeUnprocessed: '%imap_bounce.purge_unprocessed%'
+
+  PhpList\Core\Bounce\Service\LockService:
+    autowire: true
+    autoconfigure: true
+
+  PhpList\Core\Bounce\Service\SubscriberBlacklistService:
+    autowire: true
+    autoconfigure: true
+
+  PhpList\Core\Bounce\Service\MessageParser:
+    autowire: true
+    autoconfigure: true
+
+  _instanceof:
+    PhpList\Core\Bounce\Service\Handler\BounceActionHandlerInterface:
+      tags:
+        - { name: 'phplist.bounce_action_handler' }
+
+  PhpList\Core\Domain\Messaging\Service\Handler\:
+    autowire: true
+    autoconfigure: true
+    resource: '../../src/Domain/Messaging/Service/Handler/*Handler.php'
+
+  PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter:
+    autowire: true
+    autoconfigure: true
+    arguments:
+      $maxSeconds: '%messaging.max_process_time%'
+
+  PhpList\Core\Domain\Identity\Service\PermissionChecker:
+    autowire: true
+    autoconfigure: true
+    public: true
+
+  PhpList\Core\Domain\Configuration\Service\UserPersonalizer:
+    autowire: true
+    autoconfigure: true
+
+  PhpList\Core\Domain\Configuration\Service\LegacyUrlBuilder:
+    autowire: true
+    autoconfigure: true
+
+  cache.app.simple:
+    class: Symfony\Component\Cache\Psr16Cache
+    arguments: [ '@cache.app' ]
+
+  Psr\SimpleCache\CacheInterface: '@cache.app.simple'
diff --git a/docs/ClassStructure.md b/docs/ClassStructure.md
index e7cf6801..8b3d9516 100644
--- a/docs/ClassStructure.md
+++ b/docs/ClassStructure.md
@@ -1,32 +1,49 @@
 # Class structure
 
-All production classes are located in `src/`, and all unit and integration
-tests are located in `tests/`.
-
+All production classes live under `src/`, and all unit/integration tests live under `tests/`.
 
 ## Core/
+Core runtime and DI wiring.
+- Bootstrap: entry point that bootstraps the phpList core system and configures the application.
+- ApplicationKernel, ApplicationStructure: Symfony kernel and structure configuration.
+- Compiler passes (e.g., BounceProcessorPass, DoctrineMappingPass), environment helpers, parameter providers.
 
-### Bootstrap
-
-This class bootstraps the phpList core system.
+## Bounce/
+Bounce processing feature. (This module continuously updates the database throughout the bounce-processing workflow; therefore, it is separated into its own feature block.)
+- Command/: Console commands related to processing bounces.
+- Service/: Services that parse, classify and handle bounces.
+- Exception/: Bounce‑related exceptions.
 
+## Composer/
+Integration with Composer.
+- ScriptHandler, ModuleFinder, PackageRepository: helpers invoked by Composer scripts and for module discovery.
 
 ## Domain/
-
-### Model/
-
-These classes are the domain models, which map to some of the database tables,
-and where the model-related business logic can be found. There must be no
-database access code in these classes.
-
-### Repository/
-
-These classes are responsible for reading domain models from the database,
-for writing them there, and for other database queries.
-
-
-## Security
-
-These classes deal with security-related concerns, e.g., password hashing.
+Domain logic organized by sub‑domains (e.g., Analytics, Common, Configuration, Identity, Messaging, Subscription).
+Each sub‑domain follows similar conventions:
+- Model/: Domain entities/value objects. Contains business logic; no direct DB access.
+- Repository/: Reading/writing models and other DB queries.
+- Service/: Domain services and orchestration.
+- Exception/: Domain‑specific exceptions.
+
+## EmptyStartPageBundle/
+A minimal Symfony bundle providing an empty start page.
+- Controller/: Controllers for the bundle.
+
+## Migrations/
+Holds database migration files (Doctrine Migrations). May be empty until migrations are generated.
+
+## Routing/
+Routing extensions and loaders.
+- ExtraLoader: additional/dynamic route loading.
+
+## Security/
+Security‑related concerns.
+- Authentication: authentication helpers/integration.
+- HashGenerator: password hashing utilities.
+
+## TestingSupport/
+Utilities to support tests.
+- Traits/: Reusable traits and helpers used in the test suite.
 
 
diff --git a/resources/Database/Schema.sql b/resources/Database/Schema.sql
index d14d73b3..fa9e6966 100644
--- a/resources/Database/Schema.sql
+++ b/resources/Database/Schema.sql
@@ -1,207 +1,358 @@
+-- MySQL dump 10.13  Distrib 8.0.43, for Linux (x86_64)
+--
+-- Host: localhost    Database: phplistdb
+-- ------------------------------------------------------
+-- Server version	8.0.43-0ubuntu0.20.04.1+esm1
+
+/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
+/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
+/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
+/*!50503 SET NAMES utf8mb4 */;
+/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
+/*!40103 SET TIME_ZONE='+00:00' */;
+/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
+/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
+/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
+/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
+
+--
+-- Table structure for table `phplist_admin`
+--
+
 DROP TABLE IF EXISTS `phplist_admin`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_admin` (
-    `id` int(11) NOT NULL AUTO_INCREMENT,
-    `loginname` varchar(25) NOT NULL,
-    `namelc` varchar(255) DEFAULT NULL,
-    `email` varchar(255) NOT NULL,
-    `created` datetime DEFAULT NULL,
-    `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-    `modifiedby` varchar(25) DEFAULT NULL,
-    `password` varchar(255) DEFAULT NULL,
-    `passwordchanged` date DEFAULT NULL,
-    `superuser` tinyint(4) DEFAULT '0',
-    `disabled` tinyint(4) DEFAULT '0',
-    `privileges` text,
-    PRIMARY KEY (`id`),
-    UNIQUE KEY `loginnameidx` (`loginname`)
-) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
+  `id` int NOT NULL AUTO_INCREMENT,
+  `loginname` varchar(66) NOT NULL,
+  `namelc` varchar(255) DEFAULT NULL,
+  `email` varchar(255) NOT NULL,
+  `created` datetime DEFAULT NULL,
+  `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `modifiedby` varchar(66) DEFAULT NULL,
+  `password` varchar(255) DEFAULT NULL,
+  `passwordchanged` date DEFAULT NULL,
+  `superuser` tinyint DEFAULT '0',
+  `disabled` tinyint DEFAULT '0',
+  `privileges` text,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `loginnameidx` (`loginname`)
+) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_admin_attribute`
+--
 
 DROP TABLE IF EXISTS `phplist_admin_attribute`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_admin_attribute` (
-    `adminattributeid` int(11) NOT NULL,
-    `adminid` int(11) NOT NULL,
-    `value` varchar(255) DEFAULT NULL,
-    PRIMARY KEY (`adminattributeid`,`adminid`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+  `adminattributeid` int NOT NULL,
+  `adminid` int NOT NULL,
+  `value` varchar(255) DEFAULT NULL,
+  PRIMARY KEY (`adminattributeid`,`adminid`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_admin_login`
+--
+
+DROP TABLE IF EXISTS `phplist_admin_login`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_admin_login` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `moment` bigint NOT NULL,
+  `adminid` int NOT NULL,
+  `remote_ip4` varchar(32) NOT NULL,
+  `remote_ip6` varchar(50) NOT NULL,
+  `sessionid` varchar(50) NOT NULL,
+  `active` tinyint NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=414 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_admin_password_request`
+--
 
 DROP TABLE IF EXISTS `phplist_admin_password_request`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_admin_password_request` (
-    `id_key` int(11) NOT NULL AUTO_INCREMENT,
-    `date` datetime DEFAULT NULL,
-    `admin` int(11) DEFAULT NULL,
-    `key_value` varchar(32) NOT NULL,
-    PRIMARY KEY (`id_key`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+  `id_key` int NOT NULL AUTO_INCREMENT,
+  `date` datetime DEFAULT NULL,
+  `admin` int DEFAULT NULL,
+  `key_value` varchar(32) NOT NULL,
+  PRIMARY KEY (`id_key`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_adminattribute`
+--
 
 DROP TABLE IF EXISTS `phplist_adminattribute`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_adminattribute` (
-    `id` int(11) NOT NULL AUTO_INCREMENT,
-    `name` varchar(255) NOT NULL,
-    `type` varchar(30) DEFAULT NULL,
-    `listorder` int(11) DEFAULT NULL,
-    `default_value` varchar(255) DEFAULT NULL,
-    `required` tinyint(4) DEFAULT NULL,
-    `tablename` varchar(255) DEFAULT NULL,
-    PRIMARY KEY (`id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+  `id` int NOT NULL AUTO_INCREMENT,
+  `name` varchar(255) NOT NULL,
+  `type` varchar(30) DEFAULT NULL,
+  `listorder` int DEFAULT NULL,
+  `default_value` varchar(255) DEFAULT NULL,
+  `required` tinyint DEFAULT NULL,
+  `tablename` varchar(255) DEFAULT NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_admintoken`
+--
 
 DROP TABLE IF EXISTS `phplist_admintoken`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_admintoken` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
-  `adminid` int(11) NOT NULL,
+  `id` int NOT NULL AUTO_INCREMENT,
+  `adminid` int NOT NULL,
   `value` varchar(255) DEFAULT NULL,
-  `entered` int(11) NOT NULL,
+  `entered` int NOT NULL,
   `expires` datetime NOT NULL,
   PRIMARY KEY (`id`)
-) ENGINE=InnoDB AUTO_INCREMENT=51 DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB AUTO_INCREMENT=3670 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_attachment`
+--
 
 DROP TABLE IF EXISTS `phplist_attachment`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_attachment` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `id` int NOT NULL AUTO_INCREMENT,
   `filename` varchar(255) DEFAULT NULL,
   `remotefile` varchar(255) DEFAULT NULL,
   `mimetype` varchar(255) DEFAULT NULL,
   `description` text,
-  `size` int(11) DEFAULT NULL,
+  `size` int DEFAULT NULL,
   PRIMARY KEY (`id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_bounce`
+--
 
 DROP TABLE IF EXISTS `phplist_bounce`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_bounce` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `id` int NOT NULL AUTO_INCREMENT,
   `date` datetime DEFAULT NULL,
   `header` text,
   `data` mediumblob,
   `status` varchar(255) DEFAULT NULL,
   `comment` text,
   PRIMARY KEY (`id`),
-  KEY `dateindex` (`date`)
-) ENGINE=InnoDB AUTO_INCREMENT=2168 DEFAULT CHARSET=utf8;
-
-DROP TABLE IF EXISTS phplist_bounceregex;
-CREATE TABLE phplist_bounceregex (
-  id int(11) NOT NULL AUTO_INCREMENT,
-  regex varchar(2083) DEFAULT NULL,
-  regexhash char(32) DEFAULT NULL,
-  action varchar(255) DEFAULT NULL,
-  listorder int(11) DEFAULT '0',
-  admin int(11) DEFAULT NULL,
-  comment text,
-  status varchar(255) DEFAULT NULL,
-  count int(11) DEFAULT '0',
-  PRIMARY KEY (id),
-  UNIQUE KEY regex (regexhash)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+  KEY `dateindex` (`date`),
+  KEY `statusidx` (`status`(20))
+) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_bounceregex`
+--
+
+DROP TABLE IF EXISTS `phplist_bounceregex`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_bounceregex` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `regex` varchar(2083) DEFAULT NULL,
+  `regexhash` char(32) DEFAULT NULL,
+  `action` varchar(255) DEFAULT NULL,
+  `listorder` int DEFAULT '0',
+  `admin` int DEFAULT NULL,
+  `comment` text,
+  `status` varchar(255) DEFAULT NULL,
+  `count` int DEFAULT '0',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `regex` (`regexhash`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_bounceregex_bounce`
+--
 
 DROP TABLE IF EXISTS `phplist_bounceregex_bounce`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_bounceregex_bounce` (
-  `regex` int(11) NOT NULL,
-  `bounce` int(11) NOT NULL,
+  `regex` int NOT NULL,
+  `bounce` int NOT NULL,
   PRIMARY KEY (`regex`,`bounce`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_config`
+--
 
 DROP TABLE IF EXISTS `phplist_config`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_config` (
   `item` varchar(35) NOT NULL,
   `value` longtext,
-  `editable` tinyint(4) DEFAULT '1',
+  `editable` tinyint DEFAULT '1',
   `type` varchar(25) DEFAULT NULL,
   PRIMARY KEY (`item`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_eventlog`
+--
 
 DROP TABLE IF EXISTS `phplist_eventlog`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_eventlog` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `id` int NOT NULL AUTO_INCREMENT,
   `entered` datetime DEFAULT NULL,
   `page` varchar(100) DEFAULT NULL,
   `entry` text,
   PRIMARY KEY (`id`),
   KEY `enteredidx` (`entered`),
   KEY `pageidx` (`page`)
-) ENGINE=InnoDB AUTO_INCREMENT=204119 DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB AUTO_INCREMENT=343 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_i18n`
+--
 
 DROP TABLE IF EXISTS `phplist_i18n`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_i18n` (
-  `lan` varchar(255) NOT NULL,
+  `lan` varchar(10) NOT NULL,
   `original` text NOT NULL,
   `translation` text NOT NULL,
-  KEY `lanorigidx` (`lan`(50),`original`(200))
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+  UNIQUE KEY `lanorigunq` (`lan`,`original`(200)),
+  KEY `lanorigidx` (`lan`,`original`(200))
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
 
-DROP TABLE IF EXISTS `phplist_language`;
-CREATE TABLE `phplist_language` (
-  `iso` varchar(10) DEFAULT NULL,
-  `name` varchar(255) DEFAULT NULL,
-  `charset` varchar(100) DEFAULT NULL
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+--
+-- Table structure for table `phplist_linktrack`
+--
 
 DROP TABLE IF EXISTS `phplist_linktrack`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_linktrack` (
-  `linkid` int(11) NOT NULL AUTO_INCREMENT,
-  `messageid` int(11) NOT NULL,
-  `userid` int(11) NOT NULL,
+  `linkid` int NOT NULL AUTO_INCREMENT,
+  `messageid` int NOT NULL,
+  `userid` int NOT NULL,
   `url` varchar(255) DEFAULT NULL,
-  `forward` text,
+  `forward` varchar(255) DEFAULT NULL,
   `firstclick` datetime DEFAULT NULL,
-  `latestclick` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-  `clicked` int(11) DEFAULT '0',
+  `latestclick` timestamp NULL DEFAULT NULL,
+  `clicked` int DEFAULT '0',
   PRIMARY KEY (`linkid`),
   UNIQUE KEY `miduidurlindex` (`messageid`,`userid`,`url`),
   KEY `midindex` (`messageid`),
   KEY `uidindex` (`userid`),
   KEY `urlindex` (`url`),
   KEY `miduidindex` (`messageid`,`userid`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
-DROP TABLE IF EXISTS phplist_linktrack_forward;
-CREATE TABLE phplist_linktrack_forward (
-  id int(11) NOT NULL AUTO_INCREMENT,
-  url varchar(2083) DEFAULT NULL,
-  urlhash char(32) DEFAULT NULL,
-  personalise tinyint(4) DEFAULT '0',
-  PRIMARY KEY (id),
-  UNIQUE KEY urlunique (urlhash),
-  KEY urlindex (url(255))
-) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_linktrack_forward`
+--
+
+DROP TABLE IF EXISTS `phplist_linktrack_forward`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_linktrack_forward` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `url` varchar(2083) DEFAULT NULL,
+  `urlhash` char(32) DEFAULT NULL,
+  `uuid` varchar(36) DEFAULT '',
+  `personalise` tinyint DEFAULT '0',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `urlunique` (`urlhash`),
+  KEY `urlindex` (`url`(255)),
+  KEY `uuididx` (`uuid`)
+) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_linktrack_ml`
+--
 
 DROP TABLE IF EXISTS `phplist_linktrack_ml`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_linktrack_ml` (
-  `messageid` int(11) NOT NULL,
-  `forwardid` int(11) NOT NULL,
+  `messageid` int NOT NULL,
+  `forwardid` int NOT NULL,
   `firstclick` datetime DEFAULT NULL,
   `latestclick` datetime DEFAULT NULL,
-  `total` int(11) DEFAULT '0',
-  `clicked` int(11) DEFAULT '0',
-  `htmlclicked` int(11) DEFAULT '0',
-  `textclicked` int(11) DEFAULT '0',
+  `total` int DEFAULT '0',
+  `clicked` int DEFAULT '0',
+  `htmlclicked` int DEFAULT '0',
+  `textclicked` int DEFAULT '0',
   PRIMARY KEY (`messageid`,`forwardid`),
   KEY `midindex` (`messageid`),
   KEY `fwdindex` (`forwardid`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_linktrack_uml_click`
+--
 
 DROP TABLE IF EXISTS `phplist_linktrack_uml_click`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_linktrack_uml_click` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
-  `messageid` int(11) NOT NULL,
-  `userid` int(11) NOT NULL,
-  `forwardid` int(11) DEFAULT NULL,
+  `id` int NOT NULL AUTO_INCREMENT,
+  `messageid` int NOT NULL,
+  `userid` int NOT NULL,
+  `forwardid` int DEFAULT NULL,
   `firstclick` datetime DEFAULT NULL,
   `latestclick` datetime DEFAULT NULL,
-  `clicked` int(11) DEFAULT '0',
-  `htmlclicked` int(11) DEFAULT '0',
-  `textclicked` int(11) DEFAULT '0',
+  `clicked` int DEFAULT '0',
+  `htmlclicked` int DEFAULT '0',
+  `textclicked` int DEFAULT '0',
   PRIMARY KEY (`id`),
   UNIQUE KEY `miduidfwdid` (`messageid`,`userid`,`forwardid`),
   KEY `midindex` (`messageid`),
   KEY `uidindex` (`userid`),
   KEY `miduidindex` (`messageid`,`userid`)
-) ENGINE=InnoDB AUTO_INCREMENT=58434 DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_linktrack_userclick`
+--
 
 DROP TABLE IF EXISTS `phplist_linktrack_userclick`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_linktrack_userclick` (
-  `linkid` int(11) NOT NULL,
-  `userid` int(11) NOT NULL,
-  `messageid` int(11) NOT NULL,
+  `linkid` int NOT NULL,
+  `userid` int NOT NULL,
+  `messageid` int NOT NULL,
   `name` varchar(255) DEFAULT NULL,
   `data` text,
   `date` datetime DEFAULT NULL,
@@ -210,52 +361,127 @@ CREATE TABLE `phplist_linktrack_userclick` (
   KEY `midindex` (`messageid`),
   KEY `linkuserindex` (`linkid`,`userid`),
   KEY `linkusermessageindex` (`linkid`,`userid`,`messageid`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_list`
+--
 
 DROP TABLE IF EXISTS `phplist_list`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_list` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `id` int NOT NULL AUTO_INCREMENT,
   `name` varchar(255) NOT NULL,
   `description` text,
   `entered` datetime DEFAULT NULL,
-  `listorder` int(11) DEFAULT NULL,
+  `listorder` int DEFAULT NULL,
   `prefix` varchar(10) DEFAULT NULL,
   `rssfeed` varchar(255) DEFAULT NULL,
   `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-  `active` tinyint(4) DEFAULT NULL,
-  `owner` int(11) DEFAULT NULL,
+  `active` tinyint DEFAULT NULL,
+  `owner` int DEFAULT NULL,
   `category` varchar(255) DEFAULT '',
   PRIMARY KEY (`id`),
   KEY `nameidx` (`name`),
   KEY `listorderidx` (`listorder`)
-) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_listattr_becities`
+--
+
+DROP TABLE IF EXISTS `phplist_listattr_becities`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_listattr_becities` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `name` varchar(255) DEFAULT NULL,
+  `listorder` int DEFAULT '0',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `name` (`name`(150))
+) ENGINE=InnoDB AUTO_INCREMENT=2680 DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_listattr_termsofservice`
+--
+
+DROP TABLE IF EXISTS `phplist_listattr_termsofservice`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_listattr_termsofservice` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `name` varchar(255) DEFAULT NULL,
+  `listorder` int DEFAULT '0',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `name` (`name`(150))
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_listattr_ukcounties`
+--
+
+DROP TABLE IF EXISTS `phplist_listattr_ukcounties`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_listattr_ukcounties` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `name` varchar(255) DEFAULT NULL,
+  `listorder` int DEFAULT '0',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `name` (`name`(150))
+) ENGINE=InnoDB AUTO_INCREMENT=184 DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_listattr_ukcounties1`
+--
+
+DROP TABLE IF EXISTS `phplist_listattr_ukcounties1`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_listattr_ukcounties1` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `name` varchar(255) DEFAULT NULL,
+  `listorder` int DEFAULT '0',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `name` (`name`(150))
+) ENGINE=InnoDB AUTO_INCREMENT=184 DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_listmessage`
+--
 
 DROP TABLE IF EXISTS `phplist_listmessage`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_listmessage` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
-  `messageid` int(11) NOT NULL,
-  `listid` int(11) NOT NULL,
+  `id` int NOT NULL AUTO_INCREMENT,
+  `messageid` int NOT NULL,
+  `listid` int NOT NULL,
   `entered` datetime DEFAULT NULL,
   `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
   PRIMARY KEY (`id`),
   UNIQUE KEY `messageid` (`messageid`,`listid`),
   KEY `listmessageidx` (`listid`,`messageid`)
-) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB AUTO_INCREMENT=36 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
 
-DROP TABLE IF EXISTS `phplist_listrss`;
-CREATE TABLE `phplist_listrss` (
-  `listid` int(11) NOT NULL,
-  `type` varchar(255) NOT NULL,
-  `entered` datetime NOT NULL,
-  `info` text,
-  KEY `listididx` (`listid`),
-  KEY `enteredidx` (`entered`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+--
+-- Table structure for table `phplist_listuser`
+--
 
 DROP TABLE IF EXISTS `phplist_listuser`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_listuser` (
-  `userid` int(11) NOT NULL,
-  `listid` int(11) NOT NULL,
+  `userid` int NOT NULL,
+  `listid` int NOT NULL,
   `entered` datetime DEFAULT NULL,
   `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
   PRIMARY KEY (`userid`,`listid`),
@@ -263,192 +489,235 @@ CREATE TABLE `phplist_listuser` (
   KEY `userlistenteredidx` (`userid`,`listid`,`entered`),
   KEY `useridx` (`userid`),
   KEY `listidx` (`listid`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_message`
+--
 
 DROP TABLE IF EXISTS `phplist_message`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_message` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `id` int NOT NULL AUTO_INCREMENT,
+  `uuid` varchar(36) DEFAULT '',
   `subject` varchar(255) NOT NULL DEFAULT '(no subject)',
   `fromfield` varchar(255) NOT NULL DEFAULT '',
   `tofield` varchar(255) NOT NULL DEFAULT '',
   `replyto` varchar(255) NOT NULL DEFAULT '',
-  `message` mediumtext,
-  `textmessage` mediumtext,
+  `message` longtext,
+  `textmessage` longtext,
   `footer` text,
   `entered` datetime DEFAULT NULL,
   `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
   `embargo` datetime DEFAULT NULL,
-  `repeatinterval` int(11) DEFAULT '0',
+  `repeatinterval` int DEFAULT '0',
   `repeatuntil` datetime DEFAULT NULL,
-  `requeueinterval` int(11) DEFAULT '0',
+  `requeueinterval` int DEFAULT '0',
   `requeueuntil` datetime DEFAULT NULL,
   `status` varchar(255) DEFAULT NULL,
   `userselection` text,
   `sent` datetime DEFAULT NULL,
-  `htmlformatted` tinyint(4) DEFAULT '0',
+  `htmlformatted` tinyint DEFAULT '0',
   `sendformat` varchar(20) DEFAULT NULL,
-  `template` int(11) DEFAULT NULL,
-  `processed` mediumint(8) unsigned DEFAULT '0',
-  `astext` int(11) DEFAULT '0',
-  `ashtml` int(11) DEFAULT '0',
-  `astextandhtml` int(11) DEFAULT '0',
-  `aspdf` int(11) DEFAULT '0',
-  `astextandpdf` int(11) DEFAULT '0',
-  `viewed` int(11) DEFAULT '0',
-  `bouncecount` int(11) DEFAULT '0',
+  `template` int DEFAULT NULL,
+  `processed` int unsigned DEFAULT '0',
+  `astext` int DEFAULT '0',
+  `ashtml` int DEFAULT '0',
+  `astextandhtml` int DEFAULT '0',
+  `aspdf` int DEFAULT '0',
+  `astextandpdf` int DEFAULT '0',
+  `viewed` int DEFAULT '0',
+  `bouncecount` int DEFAULT '0',
   `sendstart` datetime DEFAULT NULL,
   `rsstemplate` varchar(100) DEFAULT NULL,
-  `owner` int(11) DEFAULT NULL,
-  PRIMARY KEY (`id`)
-) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8;
+  `owner` int DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  KEY `uuididx` (`uuid`)
+) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_message_attachment`
+--
 
 DROP TABLE IF EXISTS `phplist_message_attachment`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_message_attachment` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
-  `messageid` int(11) NOT NULL,
-  `attachmentid` int(11) NOT NULL,
+  `id` int NOT NULL AUTO_INCREMENT,
+  `messageid` int NOT NULL,
+  `attachmentid` int NOT NULL,
   PRIMARY KEY (`id`),
   KEY `messageidx` (`messageid`),
   KEY `messageattidx` (`messageid`,`attachmentid`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_messagedata`
+--
 
 DROP TABLE IF EXISTS `phplist_messagedata`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_messagedata` (
   `name` varchar(100) NOT NULL,
-  `id` int(11) NOT NULL,
-  `data` text,
+  `id` int NOT NULL,
+  `data` longtext,
   PRIMARY KEY (`name`,`id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
-DROP TABLE IF EXISTS `phplist_rssitem`;
-CREATE TABLE `phplist_rssitem` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
-  `title` varchar(100) NOT NULL,
-  `link` varchar(100) NOT NULL,
-  `source` varchar(255) DEFAULT NULL,
-  `list` int(11) NOT NULL,
-  `added` datetime DEFAULT NULL,
-  `processed` mediumint(8) unsigned DEFAULT '0',
-  `astext` int(11) DEFAULT '0',
-  `ashtml` int(11) DEFAULT '0',
-  PRIMARY KEY (`id`),
-  KEY `titlelinkidx` (`title`,`link`),
-  KEY `titleidx` (`title`),
-  KEY `listidx` (`list`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
-DROP TABLE IF EXISTS `phplist_rssitem_data`;
-CREATE TABLE `phplist_rssitem_data` (
-  `itemid` int(11) NOT NULL,
-  `tag` varchar(100) NOT NULL,
-  `data` text,
-  PRIMARY KEY (`itemid`,`tag`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
 
-DROP TABLE IF EXISTS `phplist_rssitem_user`;
-CREATE TABLE `phplist_rssitem_user` (
-  `itemid` int(11) NOT NULL,
-  `userid` int(11) NOT NULL,
-  `entered` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-  PRIMARY KEY (`itemid`,`userid`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+--
+-- Table structure for table `phplist_sendprocess`
+--
 
 DROP TABLE IF EXISTS `phplist_sendprocess`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_sendprocess` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `id` int NOT NULL AUTO_INCREMENT,
   `started` datetime DEFAULT NULL,
   `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-  `alive` int(11) DEFAULT '1',
+  `alive` int DEFAULT '1',
   `ipaddress` varchar(50) DEFAULT NULL,
   `page` varchar(100) DEFAULT NULL,
   PRIMARY KEY (`id`)
-) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB AUTO_INCREMENT=58 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_subscribepage`
+--
 
 DROP TABLE IF EXISTS `phplist_subscribepage`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_subscribepage` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `id` int NOT NULL AUTO_INCREMENT,
   `title` varchar(255) NOT NULL,
-  `active` tinyint(4) DEFAULT '0',
-  `owner` int(11) DEFAULT NULL,
+  `active` tinyint DEFAULT '0',
+  `owner` int DEFAULT NULL,
   PRIMARY KEY (`id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_subscribepage_data`
+--
 
 DROP TABLE IF EXISTS `phplist_subscribepage_data`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_subscribepage_data` (
-  `id` int(11) NOT NULL,
+  `id` int NOT NULL,
   `name` varchar(100) NOT NULL,
   `data` text,
   PRIMARY KEY (`id`,`name`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_template`
+--
 
 DROP TABLE IF EXISTS `phplist_template`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_template` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `id` int NOT NULL AUTO_INCREMENT,
   `title` varchar(255) NOT NULL,
   `template` longblob,
-  `listorder` int(11) DEFAULT NULL,
+  `template_text` longblob,
+  `listorder` int DEFAULT NULL,
   PRIMARY KEY (`id`),
   UNIQUE KEY `title` (`title`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_templateimage`
+--
 
 DROP TABLE IF EXISTS `phplist_templateimage`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_templateimage` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
-  `template` int(11) NOT NULL DEFAULT '0',
+  `id` int NOT NULL AUTO_INCREMENT,
+  `template` int NOT NULL DEFAULT '0',
   `mimetype` varchar(100) DEFAULT NULL,
   `filename` varchar(100) DEFAULT NULL,
   `data` longblob,
-  `width` int(11) DEFAULT NULL,
-  `height` int(11) DEFAULT NULL,
+  `width` int DEFAULT NULL,
+  `height` int DEFAULT NULL,
   PRIMARY KEY (`id`),
   KEY `templateidx` (`template`)
-) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_urlcache`
+--
+
+DROP TABLE IF EXISTS `phplist_urlcache`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_urlcache` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `url` varchar(2083) NOT NULL,
+  `lastmodified` int DEFAULT NULL,
+  `added` datetime DEFAULT NULL,
+  `content` longblob,
+  PRIMARY KEY (`id`),
+  KEY `urlindex` (`url`(255))
+) ENGINE=InnoDB AUTO_INCREMENT=207 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
 
-DROP TABLE IF EXISTS `phplist_translation`;
-CREATE TABLE `phplist_translation` (
-  `tag` varchar(255) NOT NULL,
-  `page` varchar(100) NOT NULL,
-  `lan` varchar(10) NOT NULL,
-  `translation` text,
-  KEY `tagidx` (`tag`),
-  KEY `pageidx` (`page`),
-  KEY `lanidx` (`lan`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
-DROP TABLE IF EXISTS phplist_urlcache;
-CREATE TABLE phplist_urlcache (
-  id int(11) NOT NULL AUTO_INCREMENT,
-  url varchar(2083) NOT NULL,
-  lastmodified int(11) DEFAULT NULL,
-  added datetime DEFAULT NULL,
-  content mediumtext,
-  PRIMARY KEY (id),
-  KEY urlindex (url(255))
-) ENGINE=InnoDB AUTO_INCREMENT=20 DEFAULT CHARSET=utf8;
+--
+-- Table structure for table `phplist_user_attribute`
+--
 
 DROP TABLE IF EXISTS `phplist_user_attribute`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_user_attribute` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `id` int NOT NULL AUTO_INCREMENT,
   `name` varchar(255) NOT NULL,
   `type` varchar(30) DEFAULT NULL,
-  `listorder` int(11) DEFAULT NULL,
+  `listorder` int DEFAULT NULL,
   `default_value` varchar(255) DEFAULT NULL,
-  `required` tinyint(4) DEFAULT NULL,
+  `required` tinyint DEFAULT NULL,
   `tablename` varchar(255) DEFAULT NULL,
   PRIMARY KEY (`id`),
   KEY `nameindex` (`name`),
   KEY `idnameindex` (`id`,`name`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_user_blacklist`
+--
 
 DROP TABLE IF EXISTS `phplist_user_blacklist`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_user_blacklist` (
   `email` varchar(255) NOT NULL,
   `added` datetime DEFAULT NULL,
   UNIQUE KEY `email` (`email`),
   KEY `emailidx` (`email`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_user_blacklist_data`
+--
 
 DROP TABLE IF EXISTS `phplist_user_blacklist_data`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_user_blacklist_data` (
   `email` varchar(150) NOT NULL,
   `name` varchar(25) NOT NULL,
@@ -456,27 +725,41 @@ CREATE TABLE `phplist_user_blacklist_data` (
   UNIQUE KEY `email` (`email`),
   KEY `emailidx` (`email`),
   KEY `emailnameidx` (`email`,`name`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_user_message_bounce`
+--
 
 DROP TABLE IF EXISTS `phplist_user_message_bounce`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_user_message_bounce` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
-  `user` int(11) NOT NULL,
-  `message` int(11) NOT NULL,
-  `bounce` int(11) NOT NULL,
+  `id` int NOT NULL AUTO_INCREMENT,
+  `user` int NOT NULL,
+  `message` int NOT NULL,
+  `bounce` int NOT NULL,
   `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
   PRIMARY KEY (`id`),
   KEY `umbindex` (`user`,`message`,`bounce`),
   KEY `useridx` (`user`),
   KEY `msgidx` (`message`),
   KEY `bounceidx` (`bounce`)
-) ENGINE=InnoDB AUTO_INCREMENT=2168 DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_user_message_forward`
+--
 
 DROP TABLE IF EXISTS `phplist_user_message_forward`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_user_message_forward` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
-  `user` int(11) NOT NULL,
-  `message` int(11) NOT NULL,
+  `id` int NOT NULL AUTO_INCREMENT,
+  `user` int NOT NULL,
+  `message` int NOT NULL,
   `forward` varchar(255) DEFAULT NULL,
   `status` varchar(255) DEFAULT NULL,
   `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -484,32 +767,54 @@ CREATE TABLE `phplist_user_message_forward` (
   KEY `usermessageidx` (`user`,`message`),
   KEY `useridx` (`user`),
   KEY `messageidx` (`message`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_user_message_view`
+--
+
+DROP TABLE IF EXISTS `phplist_user_message_view`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_user_message_view` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `messageid` int NOT NULL,
+  `userid` int NOT NULL,
+  `viewed` datetime DEFAULT NULL,
+  `ip` varchar(255) DEFAULT NULL,
+  `data` longtext,
+  PRIMARY KEY (`id`),
+  KEY `usermsgidx` (`userid`,`messageid`),
+  KEY `msgidx` (`messageid`),
+  KEY `useridx` (`userid`)
+) ENGINE=InnoDB AUTO_INCREMENT=25 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
 
-DROP TABLE IF EXISTS `phplist_user_rss`;
-CREATE TABLE `phplist_user_rss` (
-  `userid` int(11) NOT NULL,
-  `last` datetime DEFAULT NULL,
-  PRIMARY KEY (`userid`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+--
+-- Table structure for table `phplist_user_user`
+--
 
 DROP TABLE IF EXISTS `phplist_user_user`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_user_user` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `id` int NOT NULL AUTO_INCREMENT,
   `email` varchar(255) NOT NULL,
-  `confirmed` tinyint(4) DEFAULT '0',
-  `blacklisted` tinyint(4) DEFAULT '0',
-  `optedin` tinyint(4) DEFAULT '0',
-  `bouncecount` int(11) DEFAULT '0',
+  `confirmed` tinyint DEFAULT '0',
+  `blacklisted` tinyint DEFAULT '0',
+  `optedin` tinyint DEFAULT '0',
+  `bouncecount` int DEFAULT '0',
   `entered` datetime DEFAULT NULL,
   `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
   `uniqid` varchar(255) DEFAULT NULL,
-  `htmlemail` tinyint(4) DEFAULT '0',
-  `subscribepage` int(11) DEFAULT NULL,
+  `uuid` varchar(36) DEFAULT '',
+  `htmlemail` tinyint DEFAULT '0',
+  `subscribepage` int DEFAULT NULL,
   `rssfrequency` varchar(100) DEFAULT NULL,
   `password` varchar(255) DEFAULT NULL,
   `passwordchanged` date DEFAULT NULL,
-  `disabled` tinyint(4) DEFAULT '0',
+  `disabled` tinyint DEFAULT '0',
   `extradata` text,
   `foreignkey` varchar(100) DEFAULT NULL,
   PRIMARY KEY (`id`),
@@ -519,24 +824,39 @@ CREATE TABLE `phplist_user_user` (
   KEY `enteredindex` (`entered`),
   KEY `confidx` (`confirmed`),
   KEY `blidx` (`blacklisted`),
-  KEY `optidx` (`optedin`)
-) ENGINE=InnoDB AUTO_INCREMENT=102306 DEFAULT CHARSET=utf8;
+  KEY `optidx` (`optedin`),
+  KEY `uuididx` (`uuid`)
+) ENGINE=InnoDB AUTO_INCREMENT=46 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_user_user_attribute`
+--
 
 DROP TABLE IF EXISTS `phplist_user_user_attribute`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_user_user_attribute` (
-  `attributeid` int(11) NOT NULL,
-  `userid` int(11) NOT NULL,
-  `value` varchar(255) DEFAULT NULL,
+  `attributeid` int NOT NULL,
+  `userid` int NOT NULL,
+  `value` text,
   PRIMARY KEY (`attributeid`,`userid`),
   KEY `userindex` (`userid`),
   KEY `attindex` (`attributeid`),
   KEY `attuserid` (`userid`,`attributeid`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_user_user_history`
+--
 
 DROP TABLE IF EXISTS `phplist_user_user_history`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_user_user_history` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
-  `userid` int(11) NOT NULL,
+  `id` int NOT NULL AUTO_INCREMENT,
+  `userid` int NOT NULL,
   `ip` varchar(255) DEFAULT NULL,
   `date` datetime DEFAULT NULL,
   `summary` varchar(255) DEFAULT NULL,
@@ -545,12 +865,19 @@ CREATE TABLE `phplist_user_user_history` (
   PRIMARY KEY (`id`),
   KEY `userididx` (`userid`),
   KEY `dateidx` (`date`)
-) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_usermessage`
+--
 
 DROP TABLE IF EXISTS `phplist_usermessage`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_usermessage` (
-  `messageid` int(11) NOT NULL,
-  `userid` int(11) NOT NULL,
+  `messageid` int NOT NULL,
+  `userid` int NOT NULL,
   `entered` datetime NOT NULL,
   `viewed` datetime DEFAULT NULL,
   `status` varchar(255) DEFAULT NULL,
@@ -560,19 +887,38 @@ CREATE TABLE `phplist_usermessage` (
   KEY `enteredindex` (`entered`),
   KEY `statusidx` (`status`),
   KEY `viewedidx` (`viewed`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_userstats`
+--
 
 DROP TABLE IF EXISTS `phplist_userstats`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
 CREATE TABLE `phplist_userstats` (
-  `id` int(11) NOT NULL AUTO_INCREMENT,
-  `unixdate` int(11) DEFAULT NULL,
+  `id` int NOT NULL AUTO_INCREMENT,
+  `unixdate` int DEFAULT NULL,
   `item` varchar(255) DEFAULT NULL,
-  `listid` int(11) DEFAULT '0',
-  `value` int(11) DEFAULT '0',
+  `listid` int DEFAULT '0',
+  `value` int DEFAULT '0',
   PRIMARY KEY (`id`),
   UNIQUE KEY `entry` (`unixdate`,`item`,`listid`),
   KEY `dateindex` (`unixdate`),
   KEY `itemindex` (`item`),
   KEY `listindex` (`listid`),
   KEY `listdateindex` (`listid`,`unixdate`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
+
+/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
+/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
+/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
+/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
+/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
+/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
+/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
+
+-- Dump completed on 2025-10-28 14:22:41
diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf
new file mode 100644
index 00000000..b3204742
--- /dev/null
+++ b/resources/translations/messages.en.xlf
@@ -0,0 +1,735 @@
+
+
+  
+    
+    
+      
+        Not authorized 
+        Not authorized 
+       
+      
+        Failed admin login attempt for '%login%' 
+        Failed admin login attempt for '%login%' 
+       
+      
+        Login attempt for disabled admin '%login%' 
+        Login attempt for disabled admin '%login%' 
+       
+      
+        Administrator not found 
+        Administrator not found 
+       
+      
+        Attribute definition already exists. 
+        Attribute definition already exists. 
+       
+      
+        Password Reset Request 
+        Password Reset Request 
+       
+      
+        Hello,
+
+                    A password reset has been requested for your account.
+                    Please use the following token to reset your password:
+
+                    %token%
+
+                    If you did not request this password reset, please ignore this email.
+
+                    Thank you.
+                 
+        
+                    Hello,
+
+                    A password reset has been requested for your account.
+                    Please use the following token to reset your password:
+
+                    %token%
+
+                    If you did not request this password reset, please ignore this email.
+
+                    Thank you.
+                 
+       
+      
+        <p>Password Reset Request!</p>
+<p>Hello! A password reset has been requested for your account.</p>
+<p>Please use the following token to reset your password:</p>
+<p><a href="%confirmation_link%">Reset Password</a></p>
+<p>If you did not request this password reset, please ignore this email.</p>
+<p>Thank you.</p> 
+        Password Reset Request!      
+                        Hello! A password reset has been requested for your account.
+                        Please use the following token to reset your password:
+                        Reset Password 
+                        If you did not request this password reset, please ignore this email.
+                        Thank you.
+                    
+                ]]>
+      
+      
+        Request for confirmation 
+        Request for confirmation 
+       
+      
+        Thank you for subscribing!
+
+                    Please confirm your subscription by clicking the link below:
+
+                    %confirmation_link%
+
+                    If you did not request this subscription, please ignore this email.
+                 
+        Thank you for subscribing!
+
+                    Please confirm your subscription by clicking the link below:
+
+                    %confirmation_link%
+
+                    If you did not request this subscription, please ignore this email.
+                 
+       
+      
+        <p>Thank you for subscribing!</p>
+<p>Please confirm your subscription by clicking the link below:</p>
+<p><a href="%confirmation_link%">Confirm Subscription</a></p>
+<p>If you did not request this subscription, please ignore this email.</p>
+                 
+        Thank you for subscribing!
+Please confirm your subscription by clicking the link below:
+Confirm Subscription 
+If you did not request this subscription, please ignore this email.
+                ]]> 
+       
+      
+        PHP IMAP extension not available. Falling back to Webklex IMAP. 
+        PHP IMAP extension not available. Falling back to Webklex IMAP. 
+       
+      
+        Could not apply force lock. Aborting. 
+        Could not apply force lock. Aborting. 
+       
+      
+        Another bounce processing is already running. Aborting. 
+        Another bounce processing is already running. Aborting. 
+       
+      
+        Queue is already being processed by another instance. 
+        Queue is already being processed by another instance. 
+       
+      
+        The system is in maintenance mode, stopping. Try again later. 
+        The system is in maintenance mode, stopping. Try again later. 
+       
+      
+        Bounce processing completed. 
+        Bounce processing completed. 
+       
+      
+        Recipient email address not provided 
+        Recipient email address not provided 
+       
+      
+        Invalid email address: %email% 
+        Invalid email address: %email% 
+       
+      
+        Sending test email synchronously to %email% 
+        Sending test email synchronously to %email% 
+       
+      
+        Queuing test email for %email% 
+        Queuing test email for %email% 
+       
+      
+        Test email sent successfully! 
+        Test email sent successfully! 
+       
+      
+        Test email queued successfully! It will be sent asynchronously. 
+        Test email queued successfully! It will be sent asynchronously. 
+       
+      
+        Failed to send test email: %error% 
+        Failed to send test email: %error% 
+       
+      
+        Email address auto blacklisted by bounce rule %rule_id% 
+        Email address auto blacklisted by bounce rule %rule_id% 
+       
+      
+        Auto Unsubscribed 
+        Auto Unsubscribed 
+       
+      
+        User auto unsubscribed for bounce rule %rule_id% 
+        User auto unsubscribed for bounce rule %rule_id% 
+       
+      
+        email auto unsubscribed for bounce rule %rule_id% 
+        email auto unsubscribed for bounce rule %rule_id% 
+       
+      
+        Subscriber auto blacklisted by bounce rule %rule_id% 
+        Subscriber auto blacklisted by bounce rule %rule_id% 
+       
+      
+        User auto unsubscribed for bounce rule %%rule_id% 
+        User auto unsubscribed for bounce rule %%rule_id% 
+       
+      
+        Auto confirmed 
+        Auto confirmed 
+       
+      
+        Auto unconfirmed 
+        Auto unconfirmed 
+       
+      
+        Subscriber auto confirmed for bounce rule %rule_id% 
+        Subscriber auto confirmed for bounce rule %rule_id% 
+       
+      
+        Requeued campaign; next embargo at %time% 
+        Requeued campaign; next embargo at %time% 
+       
+      
+        Subscriber auto unconfirmed for bounce rule %rule_id% 
+        Subscriber auto unconfirmed for bounce rule %rule_id% 
+       
+      
+        Running in test mode, not deleting messages from mailbox 
+        Running in test mode, not deleting messages from mailbox 
+       
+      
+        Processed messages will be deleted from the mailbox 
+        Processed messages will be deleted from the mailbox 
+       
+      
+        Processing bounces based on active bounce rules 
+        Processing bounces based on active bounce rules 
+       
+      
+        No active rules 
+        No active rules 
+       
+      
+        Processed %processed% out of %total% bounces for advanced bounce rules 
+        Processed %processed% out of %total% bounces for advanced bounce rules 
+       
+      
+        %processed% bounces processed by advanced processing 
+        %processed% bounces processed by advanced processing 
+       
+      
+        %not_processed% bounces were not matched by advanced processing rules 
+        %not_processed% bounces were not matched by advanced processing rules 
+       
+      
+        Opening mbox %file% 
+        Opening mbox %file% 
+       
+      
+        Connecting to %mailbox% 
+        Connecting to %mailbox% 
+       
+      
+        Please do not interrupt this process 
+        Please do not interrupt this process 
+       
+      
+        mbox file path must be provided with --mailbox. 
+        mbox file path must be provided with --mailbox. 
+       
+      
+        Invalid email, marking unconfirmed: %email% 
+        Invalid email, marking unconfirmed: %email% 
+       
+      
+        Failed to send to: %email% 
+        Failed to send to: %email% 
+       
+      
+        Reprocessing unidentified bounces 
+        Reprocessing unidentified bounces 
+       
+      
+        %total% bounces to reprocess 
+        %total% bounces to reprocess 
+       
+      
+        %count% out of %total% processed 
+        %count% out of %total% processed 
+       
+      
+        %reparsed% bounces were re-processed and %reidentified% bounces were re-identified 
+        %reparsed% bounces were re-processed and %reidentified% bounces were re-identified 
+       
+      
+        Identifying consecutive bounces 
+        Identifying consecutive bounces 
+       
+      
+        Nothing to do 
+        Nothing to do 
+       
+      
+        Processed %processed% out of %total% subscribers 
+        Processed %processed% out of %total% subscribers 
+       
+      
+        Total of %total% subscribers processed 
+        Total of %total% subscribers processed 
+       
+      
+        Subscriber auto unconfirmed for %count% consecutive bounces 
+        Subscriber auto unconfirmed for %count% consecutive bounces 
+       
+      
+        %count% consecutive bounces, threshold reached 
+        %count% consecutive bounces, threshold reached 
+       
+      
+        Reached max processing time; stopping cleanly. 
+        Reached max processing time; stopping cleanly. 
+       
+      
+        Giving a UUID to %count% subscribers, this may take a while 
+        Giving a UUID to %count% subscribers, this may take a while 
+       
+      
+        Giving a UUID to %count% campaigns 
+        Giving a UUID to %count% campaigns 
+       
+      
+        Batch limit reached, sleeping %sleep%s to respect MAILQUEUE_BATCH_PERIOD 
+        Batch limit reached, sleeping %sleep%s to respect MAILQUEUE_BATCH_PERIOD 
+       
+      
+        Value must be an array of image URLs. 
+        Value must be an array of image URLs. 
+       
+      
+        Image "%url%" is not a full URL. 
+        Image "%url%" is not a full URL. 
+       
+      
+        Image "%url%" does not exist (HTTP %code%) 
+        Image "%url%" does not exist (HTTP %code%) 
+       
+      
+        Image "%url%" could not be validated: %message% 
+        Image "%url%" could not be validated: %message% 
+       
+      
+        Not full URLs: %urls% 
+        Not full URLs: %urls% 
+       
+      
+        Subscriber list not found. 
+        Subscriber list not found. 
+       
+      
+        Subscriber does not exists. 
+        Subscriber does not exists. 
+       
+      
+        Subscription not found for this subscriber and list. 
+        Subscription not found for this subscriber and list. 
+       
+      
+        Attribute definition already exists 
+        Attribute definition already exists 
+       
+      
+        Another attribute with this name already exists. 
+        Another attribute with this name already exists. 
+       
+      
+        Subscribe page not found 
+        Subscribe page not found 
+       
+      
+        Value is required 
+        Value is required 
+       
+      
+        Subscriber not found 
+        Subscriber not found 
+       
+      
+        Unexpected error: %error% 
+        Unexpected error: %error% 
+       
+      
+        Added to blacklist for reason %reason% 
+        Added to blacklist for reason %reason% 
+       
+      
+        Could not read the uploaded file. 
+        Could not read the uploaded file. 
+       
+      
+        Error processing %email%: %error% 
+        Error processing %email%: %error% 
+       
+      
+        General import error: %error% 
+        General import error: %error% 
+       
+      
+        Value must be a string. 
+        Value must be a string. 
+       
+      
+        Invalid attribute type: "%type%". Valid types are: %valid_types% 
+        Invalid attribute type: "%type%". Valid types are: %valid_types% 
+       
+      
+        Thank you for subscribing!
+
+Please confirm your subscription by clicking the link below:
+
+%confirmation_link%
+
+If you did not request this subscription, please ignore this email. 
+        __Thank you for subscribing!
+
+Please confirm your subscription by clicking the link below:
+
+%confirmation_link%
+
+If you did not request this subscription, please ignore this email. 
+       
+      
+        <p>Thank you for subscribing!</p><p>Please confirm your subscription by clicking the link below:</p><p><a href="%confirmation_link%">Confirm Subscription</a></p><p>If you did not request this subscription, please ignore this email.</p> 
+        Thank you for subscribing!Please confirm your subscription by clicking the link below:
Confirm Subscription 
If you did not request this subscription, please ignore this email.
]]> 
+       
+      
+        Hello,
+
+A password reset has been requested for your account.
+Please use the following token to reset your password:
+
+%token%
+
+If you did not request this password reset, please ignore this email.
+
+Thank you. 
+        __Hello,
+
+A password reset has been requested for your account.
+Please use the following token to reset your password:
+
+%token%
+
+If you did not request this password reset, please ignore this email.
+
+Thank you. 
+       
+      
+        <p>Password Reset Request!</p><p>Hello! A password reset has been requested for your account.</p><p>Please use the following token to reset your password:</p><p><a href="%confirmation_link%">Reset Password</a></p><p>If you did not request this password reset, please ignore this email.</p><p>Thank you.</p> 
+        Password Reset Request!Hello! A password reset has been requested for your account.
Please use the following token to reset your password:
Reset Password 
If you did not request this password reset, please ignore this email.
Thank you.
]]> 
+       
+      
+        Person in charge of this system (one email address) 
+        __Person in charge of this system (one email address) 
+       
+      
+        Name of the organisation 
+        __Name of the organisation 
+       
+      
+        Logo of the organisation 
+        __Logo of the organisation 
+       
+      
+        Date format 
+        __Date format 
+       
+      
+        Show notification for Release Candidates 
+        __Show notification for Release Candidates 
+       
+      
+        Secret for remote processing 
+        __Secret for remote processing 
+       
+      
+        Notify admin on login from new location 
+        __Notify admin on login from new location 
+       
+      
+        List of email addresses to CC in system messages (separate by commas) 
+        __List of email addresses to CC in system messages (separate by commas) 
+       
+      
+        Default for 'From:' in a campaign 
+        __Default for 'From:' in a campaign 
+       
+      
+        Default for 'address to alert when sending starts' 
+        __Default for 'address to alert when sending starts' 
+       
+      
+        Default for 'address to alert when sending finishes' 
+        __Default for 'address to alert when sending finishes' 
+       
+      
+        Always add analytics tracking code to campaigns 
+        __Always add analytics tracking code to campaigns 
+       
+      
+        Analytics tracking code to add to campaign URLs 
+        __Analytics tracking code to add to campaign URLs 
+       
+      
+        Who gets the reports (email address, separate multiple emails with a comma) 
+        __Who gets the reports (email address, separate multiple emails with a comma) 
+       
+      
+        From email address for system messages 
+        __From email address for system messages 
+       
+      
+        Webmaster 
+        __Webmaster 
+       
+      
+        Name for system messages 
+        __Name for system messages 
+       
+      
+        Reply-to email address for system messages 
+        __Reply-to email address for system messages 
+       
+      
+        If there is only one visible list, should it be hidden in the page and automatically subscribe users who sign up 
+        __If there is only one visible list, should it be hidden in the page and automatically subscribe users who sign up 
+       
+      
+        Categories for lists. Separate with commas. 
+        __Categories for lists. Separate with commas. 
+       
+      
+        Display list categories on subscribe page 
+        __Display list categories on subscribe page 
+       
+      
+        Width of a textline field (numerical) 
+        __Width of a textline field (numerical) 
+       
+      
+        Dimensions of a textarea field (rows,columns) 
+        __Dimensions of a textarea field (rows,columns) 
+       
+      
+        Send notifications about subscribe, update and unsubscribe 
+        __Send notifications about subscribe, update and unsubscribe 
+       
+      
+        The default subscribe page when there are multiple 
+        __The default subscribe page when there are multiple 
+       
+      
+        The default HTML template to use when sending a message 
+        __The default HTML template to use when sending a message 
+       
+      
+        The HTML wrapper template for system messages 
+        __The HTML wrapper template for system messages 
+       
+      
+        URL where subscribers can sign up 
+        __URL where subscribers can sign up 
+       
+      
+        URL where subscribers can unsubscribe 
+        __URL where subscribers can unsubscribe 
+       
+      
+        URL where unknown users can unsubscribe (do-not-send-list) 
+        __URL where unknown users can unsubscribe (do-not-send-list) 
+       
+      
+        URL where subscribers have to confirm their subscription 
+        __URL where subscribers have to confirm their subscription 
+       
+      
+        URL where subscribers can update their details 
+        __URL where subscribers can update their details 
+       
+      
+        URL for forwarding messages 
+        __URL for forwarding messages 
+       
+      
+        URL for downloading vcf card 
+        __URL for downloading vcf card 
+       
+      
+        <h3>Thanks, you have been added to our newsletter</h3><p>You will receive an email to confirm your subscription. Please click the link in the email to confirm</p> 
+        Thanks, you have been added to our newsletterYou will receive an email to confirm your subscription. Please click the link in the email to confirm
]]> 
+       
+      
+        Text to display when subscription with an AJAX request was successful 
+        __Text to display when subscription with an AJAX request was successful 
+       
+      
+        Subject of the message subscribers receive when they sign up 
+        __Subject of the message subscribers receive when they sign up 
+       
+      
+        Message subscribers receive when they sign up 
+        __Message subscribers receive when they sign up 
+       
+      
+        Goodbye from our Newsletter 
+        __Goodbye from our Newsletter 
+       
+      
+        Subject of the message subscribers receive when they unsubscribe 
+        __Subject of the message subscribers receive when they unsubscribe 
+       
+      
+        Message subscribers receive when they unsubscribe 
+        __Message subscribers receive when they unsubscribe 
+       
+      
+        Welcome to our Newsletter 
+        __Welcome to our Newsletter 
+       
+      
+        Subject of the message subscribers receive after confirming their email address 
+        __Subject of the message subscribers receive after confirming their email address 
+       
+      
+        Message subscribers receive after confirming their email address 
+        __Message subscribers receive after confirming their email address 
+       
+      
+        [notify] Change of List-Membership details 
+        __[notify] Change of List-Membership details 
+       
+      
+        Subject of the message subscribers receive when they have changed their details 
+        __Subject of the message subscribers receive when they have changed their details 
+       
+      
+        Message subscribers receive when they have changed their details 
+        __Message subscribers receive when they have changed their details 
+       
+      
+        Part of the message that is sent to their new email address when subscribers change their information, and the email address has changed 
+        __Part of the message that is sent to their new email address when subscribers change their information, and the email address has changed 
+       
+      
+        Part of the message that is sent to their old email address when subscribers change their information, and the email address has changed 
+        __Part of the message that is sent to their old email address when subscribers change their information, and the email address has changed 
+       
+      
+        Your personal location 
+        __Your personal location 
+       
+      
+        Subject of message when subscribers request their personal location 
+        __Subject of message when subscribers request their personal location 
+       
+      
+        Default footer for sending a campaign 
+        __Default footer for sending a campaign 
+       
+      
+        Footer used when a message has been forwarded 
+        __Footer used when a message has been forwarded 
+       
+      
+        Message to send when they request their personal location 
+        __Message to send when they request their personal location 
+       
+      
+        String to always append to remote URL when using send-a-webpage 
+        __String to always append to remote URL when using send-a-webpage 
+       
+      
+        Width for Wordwrap of Text messages 
+        __Width for Wordwrap of Text messages 
+       
+      
+        CSS for HTML messages without a template 
+        __CSS for HTML messages without a template 
+       
+      
+        Domains that only accept text emails, one per line 
+        __Domains that only accept text emails, one per line 
+       
+      
+        last time TLDs were fetched 
+        __last time TLDs were fetched 
+       
+      
+        Top level domains 
+        __Top level domains 
+       
+      
+        Header of public pages. 
+        __Header of public pages. 
+       
+      
+        Footer of public pages 
+        __Footer of public pages 
+       
+      
+        Please confirm your subscription 
+        __Please confirm your subscription 
+       
+      
+        No user details changed 
+        __No user details changed 
+       
+      
+        %field% = %new% *changed* from %old% 
+        __%field% = %new% *changed* from %old% 
+       
+      
+        Subscribed to %list% 
+        __Subscribed to %list% 
+       
+      
+        Subscriber marked unconfirmed for invalid email address 
+        __Subscriber marked unconfirmed for invalid email address 
+       
+      
+        Marked unconfirmed while sending campaign %message_id% 
+        __Marked unconfirmed while sending campaign %message_id% 
+       
+      
+        Update by %admin% 
+        __Update by %admin% 
+       
+      
+        (no data) 
+        __(no data) 
+       
+      
+        %attribute% = %new_value% 
+ changed from %old_value% 
+        __%attribute% = %new_value% 
+ changed from %old_value% 
+       
+      
+        No data changed 
+        __No data changed 
+       
+      
+        Campaign not found or not in submitted status 
+        __Campaign not found or not in submitted status 
+       
+    
+  
+
diff --git a/resources/translations/security.en.xlf b/resources/translations/security.en.xlf
new file mode 100644
index 00000000..d053cd60
--- /dev/null
+++ b/resources/translations/security.en.xlf
@@ -0,0 +1,86 @@
+
+
+  
+    
+    
+      
+        An authentication exception occurred. 
+        An authentication exception occurred. 
+       
+      
+        Authentication credentials could not be found. 
+        Authentication credentials could not be found. 
+       
+      
+        Authentication request could not be processed due to a system problem. 
+        Authentication request could not be processed due to a system problem. 
+       
+      
+        Invalid credentials. 
+        Invalid credentials. 
+       
+      
+        Cookie has already been used by someone else. 
+        Cookie has already been used by someone else. 
+       
+      
+        Not privileged to request the resource. 
+        Not privileged to request the resource. 
+       
+      
+        Invalid CSRF token. 
+        Invalid CSRF token. 
+       
+      
+        No authentication provider found to support the authentication token. 
+        No authentication provider found to support the authentication token. 
+       
+      
+        No session available, it either timed out or cookies are not enabled. 
+        No session available, it either timed out or cookies are not enabled. 
+       
+      
+        No token could be found. 
+        No token could be found. 
+       
+      
+        Username could not be found. 
+        Username could not be found. 
+       
+      
+        Account has expired. 
+        Account has expired. 
+       
+      
+        Credentials have expired. 
+        Credentials have expired. 
+       
+      
+      
+        Account is locked. 
+        Account is locked. 
+       
+      
+        Too many failed login attempts, please try again later. 
+        Too many failed login attempts, please try again later. 
+       
+      
+        Invalid or expired login link. 
+        Invalid or expired login link. 
+       
+      
+        Too many failed login attempts, please try again in %minutes% minute. 
+        Too many failed login attempts, please try again in %minutes% minute. 
+       
+      
+        Too many failed login attempts, please try again in %minutes% minutes. 
+        Too many failed login attempts, please try again in %minutes% minutes. 
+       
+    
+   
+ 
diff --git a/resources/translations/validators.en.xlf b/resources/translations/validators.en.xlf
new file mode 100644
index 00000000..41617e3b
--- /dev/null
+++ b/resources/translations/validators.en.xlf
@@ -0,0 +1,694 @@
+
+
+  
+    
+    
+      
+        This value should be false. 
+        This value should be false. 
+       
+      
+        This value should be true. 
+        This value should be true. 
+       
+      
+        This value should be of type {{ type }}. 
+        This value should be of type {{ type }}. 
+       
+      
+        This value should be blank. 
+        This value should be blank. 
+       
+      
+        The value you selected is not a valid choice. 
+        The value you selected is not a valid choice. 
+       
+      
+        You must select at least {{ limit }} choice.|You must select at least {{ limit }} choices. 
+        You must select at least {{ limit }} choice.|You must select at least {{ limit }} choices. 
+       
+      
+        You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices. 
+        You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices. 
+       
+      
+        One or more of the given values is invalid. 
+        One or more of the given values is invalid. 
+       
+      
+        This field was not expected. 
+        This field was not expected. 
+       
+      
+        This field is missing. 
+        This field is missing. 
+       
+      
+        This value is not a valid date. 
+        This value is not a valid date. 
+       
+      
+        This value is not a valid datetime. 
+        This value is not a valid datetime. 
+       
+      
+        This value is not a valid email address. 
+        This value is not a valid email address. 
+       
+      
+        The file could not be found. 
+        The file could not be found. 
+       
+      
+        The file is not readable. 
+        The file is not readable. 
+       
+      
+        The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}. 
+        The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}. 
+       
+      
+        The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}. 
+        The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}. 
+       
+      
+        This value should be {{ limit }} or less. 
+        This value should be {{ limit }} or less. 
+       
+      
+        This value is too long. It should have {{ limit }} character or less.|This value is too long. It should have {{ limit }} characters or less. 
+        This value is too long. It should have {{ limit }} character or less.|This value is too long. It should have {{ limit }} characters or less. 
+       
+      
+        This value should be {{ limit }} or more. 
+        This value should be {{ limit }} or more. 
+       
+      
+        This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more. 
+        This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more. 
+       
+      
+        This value should not be blank. 
+        This value should not be blank. 
+       
+      
+        This value should not be null. 
+        This value should not be null. 
+       
+      
+        This value should be null. 
+        This value should be null. 
+       
+      
+        This value is not valid. 
+        This value is not valid. 
+       
+      
+        This value is not a valid time. 
+        This value is not a valid time. 
+       
+      
+        This value is not a valid URL. 
+        This value is not a valid URL. 
+       
+      
+        The two values should be equal. 
+        The two values should be equal. 
+       
+      
+        The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}. 
+        The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}. 
+       
+      
+        The file is too large. 
+        The file is too large. 
+       
+      
+        The file could not be uploaded. 
+        The file could not be uploaded. 
+       
+      
+        This value should be a valid number. 
+        This value should be a valid number. 
+       
+      
+        This file is not a valid image. 
+        This file is not a valid image. 
+       
+      
+        This is not a valid IP address. 
+        This value is not a valid IP address. 
+       
+      
+        This value is not a valid language. 
+        This value is not a valid language. 
+       
+      
+        This value is not a valid locale. 
+        This value is not a valid locale. 
+       
+      
+        This value is not a valid country. 
+        This value is not a valid country. 
+       
+      
+        This value is already used. 
+        This value is already used. 
+       
+      
+        The size of the image could not be detected. 
+        The size of the image could not be detected. 
+       
+      
+        The image width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. 
+        The image width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. 
+       
+      
+        The image width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. 
+        The image width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. 
+       
+      
+        The image height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. 
+        The image height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. 
+       
+      
+        The image height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. 
+        The image height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. 
+       
+      
+        This value should be the user's current password. 
+        This value should be the user's current password. 
+       
+      
+        This value should have exactly {{ limit }} character.|This value should have exactly {{ limit }} characters. 
+        This value should have exactly {{ limit }} character.|This value should have exactly {{ limit }} characters. 
+       
+      
+        The file was only partially uploaded. 
+        The file was only partially uploaded. 
+       
+      
+        No file was uploaded. 
+        No file was uploaded. 
+       
+      
+        No temporary folder was configured in php.ini. 
+        No temporary folder was configured in php.ini, or the configured folder does not exist. 
+       
+      
+        Cannot write temporary file to disk. 
+        Cannot write temporary file to disk. 
+       
+      
+        A PHP extension caused the upload to fail. 
+        A PHP extension caused the upload to fail. 
+       
+      
+        This collection should contain {{ limit }} element or more.|This collection should contain {{ limit }} elements or more. 
+        This collection should contain {{ limit }} element or more.|This collection should contain {{ limit }} elements or more. 
+       
+      
+        This collection should contain {{ limit }} element or less.|This collection should contain {{ limit }} elements or less. 
+        This collection should contain {{ limit }} element or less.|This collection should contain {{ limit }} elements or less. 
+       
+      
+        This collection should contain exactly {{ limit }} element.|This collection should contain exactly {{ limit }} elements. 
+        This collection should contain exactly {{ limit }} element.|This collection should contain exactly {{ limit }} elements. 
+       
+      
+        Invalid card number. 
+        Invalid card number. 
+       
+      
+        Unsupported card type or invalid card number. 
+        Unsupported card type or invalid card number. 
+       
+      
+        This is not a valid International Bank Account Number (IBAN). 
+        This value is not a valid International Bank Account Number (IBAN). 
+       
+      
+        This value is not a valid ISBN-10. 
+        This value is not a valid ISBN-10. 
+       
+      
+        This value is not a valid ISBN-13. 
+        This value is not a valid ISBN-13. 
+       
+      
+        This value is neither a valid ISBN-10 nor a valid ISBN-13. 
+        This value is neither a valid ISBN-10 nor a valid ISBN-13. 
+       
+      
+        This value is not a valid ISSN. 
+        This value is not a valid ISSN. 
+       
+      
+        This value is not a valid currency. 
+        This value is not a valid currency. 
+       
+      
+        This value should be equal to {{ compared_value }}. 
+        This value should be equal to {{ compared_value }}. 
+       
+      
+        This value should be greater than {{ compared_value }}. 
+        This value should be greater than {{ compared_value }}. 
+       
+      
+        This value should be greater than or equal to {{ compared_value }}. 
+        This value should be greater than or equal to {{ compared_value }}. 
+       
+      
+        This value should be identical to {{ compared_value_type }} {{ compared_value }}. 
+        This value should be identical to {{ compared_value_type }} {{ compared_value }}. 
+       
+      
+        This value should be less than {{ compared_value }}. 
+        This value should be less than {{ compared_value }}. 
+       
+      
+        This value should be less than or equal to {{ compared_value }}. 
+        This value should be less than or equal to {{ compared_value }}. 
+       
+      
+        This value should not be equal to {{ compared_value }}. 
+        This value should not be equal to {{ compared_value }}. 
+       
+      
+        This value should not be identical to {{ compared_value_type }} {{ compared_value }}. 
+        This value should not be identical to {{ compared_value_type }} {{ compared_value }}. 
+       
+      
+        The image ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. 
+        The image ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. 
+       
+      
+        The image ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. 
+        The image ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. 
+       
+      
+        The image is square ({{ width }}x{{ height }}px). Square images are not allowed. 
+        The image is square ({{ width }}x{{ height }}px). Square images are not allowed. 
+       
+      
+        The image is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented images are not allowed. 
+        The image is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented images are not allowed. 
+       
+      
+        The image is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented images are not allowed. 
+        The image is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented images are not allowed. 
+       
+      
+        An empty file is not allowed. 
+        An empty file is not allowed. 
+       
+      
+        The host could not be resolved. 
+        The host could not be resolved. 
+       
+      
+        This value does not match the expected {{ charset }} charset. 
+        This value does not match the expected {{ charset }} charset. 
+       
+      
+        This is not a valid Business Identifier Code (BIC). 
+        This value is not a valid Business Identifier Code (BIC). 
+       
+      
+        Error 
+        Error 
+       
+      
+        This is not a valid UUID. 
+        This value is not a valid UUID. 
+       
+      
+        This value should be a multiple of {{ compared_value }}. 
+        This value should be a multiple of {{ compared_value }}. 
+       
+      
+        This Business Identifier Code (BIC) is not associated with IBAN {{ iban }}. 
+        This Business Identifier Code (BIC) is not associated with IBAN {{ iban }}. 
+       
+      
+        This value should be valid JSON. 
+        This value should be valid JSON. 
+       
+      
+        This collection should contain only unique elements. 
+        This collection should contain only unique elements. 
+       
+      
+        This value should be positive. 
+        This value should be positive. 
+       
+      
+        This value should be either positive or zero. 
+        This value should be either positive or zero. 
+       
+      
+        This value should be negative. 
+        This value should be negative. 
+       
+      
+        This value should be either negative or zero. 
+        This value should be either negative or zero. 
+       
+      
+        This value is not a valid timezone. 
+        This value is not a valid timezone. 
+       
+      
+        This password has been leaked in a data breach, it must not be used. Please use another password. 
+        This password has been leaked in a data breach, it must not be used. Please use another password. 
+       
+      
+        This value should be between {{ min }} and {{ max }}. 
+        This value should be between {{ min }} and {{ max }}. 
+       
+      
+        This value is not a valid hostname. 
+        This value is not a valid hostname. 
+       
+      
+        The number of elements in this collection should be a multiple of {{ compared_value }}. 
+        The number of elements in this collection should be a multiple of {{ compared_value }}. 
+       
+      
+        This value should satisfy at least one of the following constraints: 
+        This value should satisfy at least one of the following constraints: 
+       
+      
+        Each element of this collection should satisfy its own set of constraints. 
+        Each element of this collection should satisfy its own set of constraints. 
+       
+      
+        This value is not a valid International Securities Identification Number (ISIN). 
+        This value is not a valid International Securities Identification Number (ISIN). 
+       
+      
+        This value should be a valid expression. 
+        This value should be a valid expression. 
+       
+      
+        This value is not a valid CSS color. 
+        This value is not a valid CSS color. 
+       
+      
+        This value is not a valid CIDR notation. 
+        This value is not a valid CIDR notation. 
+       
+      
+        The value of the netmask should be between {{ min }} and {{ max }}. 
+        The value of the netmask should be between {{ min }} and {{ max }}. 
+       
+      
+        The filename is too long. It should have {{ filename_max_length }} character or less.|The filename is too long. It should have {{ filename_max_length }} characters or less. 
+        The filename is too long. It should have {{ filename_max_length }} character or less.|The filename is too long. It should have {{ filename_max_length }} characters or less. 
+       
+      
+        The password strength is too low. Please use a stronger password. 
+        The password strength is too low. Please use a stronger password. 
+       
+      
+        This value contains characters that are not allowed by the current restriction-level. 
+        This value contains characters that are not allowed by the current restriction-level. 
+       
+      
+        Using invisible characters is not allowed. 
+        Using invisible characters is not allowed. 
+       
+      
+        Mixing numbers from different scripts is not allowed. 
+        Mixing numbers from different scripts is not allowed. 
+       
+      
+        Using hidden overlay characters is not allowed. 
+        Using hidden overlay characters is not allowed. 
+       
+      
+        The extension of the file is invalid ({{ extension }}). Allowed extensions are {{ extensions }}. 
+        The extension of the file is invalid ({{ extension }}). Allowed extensions are {{ extensions }}. 
+       
+      
+        The detected character encoding is invalid ({{ detected }}). Allowed encodings are {{ encodings }}. 
+        The detected character encoding is invalid ({{ detected }}). Allowed encodings are {{ encodings }}. 
+       
+      
+        This value is not a valid MAC address. 
+        This value is not a valid MAC address. 
+       
+      
+        This URL is missing a top-level domain. 
+        This URL is missing a top-level domain. 
+       
+      
+        This value is too short. It should contain at least one word.|This value is too short. It should contain at least {{ min }} words. 
+        This value is too short. It should contain at least one word.|This value is too short. It should contain at least {{ min }} words. 
+       
+      
+        This value is too long. It should contain one word.|This value is too long. It should contain {{ max }} words or less. 
+        This value is too long. It should contain one word.|This value is too long. It should contain {{ max }} words or less. 
+       
+      
+        This value does not represent a valid week in the ISO 8601 format. 
+        This value does not represent a valid week in the ISO 8601 format. 
+       
+      
+        This value is not a valid week. 
+        This value is not a valid week. 
+       
+      
+        This value should not be before week "{{ min }}". 
+        This value should not be before week "{{ min }}". 
+       
+      
+        This value should not be after week "{{ max }}". 
+        This value should not be after week "{{ max }}". 
+       
+      
+        This value is not a valid Twig template. 
+        This value is not a valid Twig template. 
+       
+      
+        This file is not a valid video. 
+        This file is not a valid video. 
+       
+      
+        The size of the video could not be detected. 
+        The size of the video could not be detected. 
+       
+      
+        The video width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. 
+        The video width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. 
+       
+      
+        The video width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. 
+        The video width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. 
+       
+      
+        The video height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. 
+        The video height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. 
+       
+      
+        The video height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. 
+        The video height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. 
+       
+      
+        The video has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. 
+        The video has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. 
+       
+      
+        The video has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. 
+        The video has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. 
+       
+      
+        The video ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. 
+        The video ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. 
+       
+      
+        The video ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. 
+        The video ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. 
+       
+      
+        The video is square ({{ width }}x{{ height }}px). Square videos are not allowed. 
+        The video is square ({{ width }}x{{ height }}px). Square videos are not allowed. 
+       
+      
+        The video is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented videos are not allowed. 
+        The video is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented videos are not allowed. 
+       
+      
+        The video is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented videos are not allowed. 
+        The video is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented videos are not allowed. 
+       
+      
+        The video file is corrupted. 
+        The video file is corrupted. 
+       
+      
+        The video contains multiple streams. Only one stream is allowed. 
+        The video contains multiple streams. Only one stream is allowed. 
+       
+      
+        Unsupported video codec "{{ codec }}". 
+        Unsupported video codec "{{ codec }}". 
+       
+      
+        Unsupported video container "{{ container }}". 
+        Unsupported video container "{{ container }}". 
+       
+      
+        The image file is corrupted. 
+        The image file is corrupted. 
+       
+      
+        The image has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. 
+        The image has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. 
+       
+      
+        The image has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. 
+        The image has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. 
+       
+      
+        This filename does not match the expected charset. 
+        This filename does not match the expected charset. 
+       
+      
+        This form should not contain extra fields. 
+        This form should not contain extra fields. 
+       
+      
+        The uploaded file was too large. Please try to upload a smaller file. 
+        The uploaded file was too large. Please try to upload a smaller file. 
+       
+      
+        The CSRF token is invalid. Please try to resubmit the form. 
+        The CSRF token is invalid. Please try to resubmit the form. 
+       
+      
+        This value is not a valid HTML5 color. 
+        This value is not a valid HTML5 color. 
+       
+      
+        Please enter a valid birthdate. 
+        Please enter a valid birthdate. 
+       
+      
+        The selected choice is invalid. 
+        The selected choice is invalid. 
+       
+      
+        The collection is invalid. 
+        The collection is invalid. 
+       
+      
+        Please select a valid color. 
+        Please select a valid color. 
+       
+      
+        Please select a valid country. 
+        Please select a valid country. 
+       
+      
+        Please select a valid currency. 
+        Please select a valid currency. 
+       
+      
+        Please choose a valid date interval. 
+        Please choose a valid date interval. 
+       
+      
+        Please enter a valid date and time. 
+        Please enter a valid date and time. 
+       
+      
+        Please enter a valid date. 
+        Please enter a valid date. 
+       
+      
+        Please select a valid file. 
+        Please select a valid file. 
+       
+      
+        The hidden field is invalid. 
+        The hidden field is invalid. 
+       
+      
+        Please enter an integer. 
+        Please enter an integer. 
+       
+      
+        Please select a valid language. 
+        Please select a valid language. 
+       
+      
+        Please select a valid locale. 
+        Please select a valid locale. 
+       
+      
+        Please enter a valid money amount. 
+        Please enter a valid money amount. 
+       
+      
+        Please enter a number. 
+        Please enter a number. 
+       
+      
+        The password is invalid. 
+        The password is invalid. 
+       
+      
+        Please enter a percentage value. 
+        Please enter a percentage value. 
+       
+      
+        The values do not match. 
+        The values do not match. 
+       
+      
+        Please enter a valid time. 
+        Please enter a valid time. 
+       
+      
+        Please select a valid timezone. 
+        Please select a valid timezone. 
+       
+      
+        Please enter a valid URL. 
+        Please enter a valid URL. 
+       
+      
+        Please enter a valid search term. 
+        Please enter a valid search term. 
+       
+      
+        Please provide a valid phone number. 
+        Please provide a valid phone number. 
+       
+      
+        The checkbox has an invalid value. 
+        The checkbox has an invalid value. 
+       
+      
+        Please enter a valid email address. 
+        Please enter a valid email address. 
+       
+      
+        Please select a valid option. 
+        Please select a valid option. 
+       
+      
+        Please select a valid range. 
+        Please select a valid range. 
+       
+      
+        Please enter a valid week. 
+        Please enter a valid week. 
+       
+    
+   
+ 
diff --git a/src/Bounce/Command/ProcessBouncesCommand.php b/src/Bounce/Command/ProcessBouncesCommand.php
new file mode 100644
index 00000000..fcb37ba2
--- /dev/null
+++ b/src/Bounce/Command/ProcessBouncesCommand.php
@@ -0,0 +1,120 @@
+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,
+        private readonly TranslatorInterface $translator,
+        private readonly EntityManagerInterface $entityManager,
+    ) {
+        parent::__construct();
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        $inputOutput = new SymfonyStyle($input, $output);
+
+        if (!function_exists('imap_open')) {
+            $inputOutput->note($this->translator->trans(
+                'PHP IMAP extension not available. Falling back to Webklex IMAP.'
+            ));
+        }
+
+        $force = (bool)$input->getOption('force');
+        $lock = $this->lockService->acquirePageLock('bounce_processor', $force);
+        $this->entityManager->flush();
+
+        if (($lock ?? 0) === 0) {
+            $forceLockFailed = $this->translator->trans('Could not apply force lock. Aborting.');
+            $lockFailed = $this->translator->trans('Another bounce processing is already running. Aborting.');
+
+            $inputOutput->warning($force ? $forceLockFailed : $lockFailed);
+
+            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($this->translator->trans('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/Bounce/Exception/ImapConnectionException.php b/src/Bounce/Exception/ImapConnectionException.php
new file mode 100644
index 00000000..58d3495d
--- /dev/null
+++ b/src/Bounce/Exception/ImapConnectionException.php
@@ -0,0 +1,16 @@
+ */
+    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/Bounce/Service/BounceProcessingServiceInterface.php b/src/Bounce/Service/BounceProcessingServiceInterface.php
new file mode 100644
index 00000000..8050a400
--- /dev/null
+++ b/src/Bounce/Service/BounceProcessingServiceInterface.php
@@ -0,0 +1,10 @@
+bounceManager = $bounceManager;
+        $this->subscriberRepository = $subscriberRepository;
+        $this->subscriberHistoryManager = $subscriberHistoryManager;
+        $this->blacklistService = $blacklistService;
+        $this->translator = $translator;
+        $this->unsubscribeThreshold = $unsubscribeThreshold;
+        $this->blacklistThreshold = $blacklistThreshold;
+    }
+
+    public function handle(SymfonyStyle $io): void
+    {
+        $io->section($this->translator->trans('Identifying consecutive bounces'));
+
+        $users = $this->subscriberRepository->distinctUsersWithBouncesConfirmedNotBlacklisted();
+        $total = count($users);
+
+        if ($total === 0) {
+            $io->writeln($this->translator->trans('Nothing to do'));
+
+            return;
+        }
+
+        $processed = 0;
+        foreach ($users as $user) {
+            $this->processUser($user);
+            $processed++;
+
+            if ($processed % 5 === 0) {
+                $io->writeln($this->translator->trans('Processed %processed% out of %total% subscribers', [
+                    '%processed%' => $processed,
+                    '%total%' => $total,
+                ]));
+            }
+        }
+
+        $io->writeln($this->translator->trans('Total of %total% subscribers processed', ['%total%' => $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: $this->translator->trans('Auto unconfirmed'),
+                details: $this->translator->trans('Subscriber auto unconfirmed for %count% consecutive bounces', [
+                    '%count%' => $consecutive
+                ])
+            );
+        }
+
+        if ($this->blacklistThreshold > 0 && $consecutive >= $this->blacklistThreshold) {
+            $this->blacklistService->blacklist(
+                subscriber: $user,
+                reason: $this->translator->trans('%count% consecutive bounces, threshold reached', [
+                    '%count%' => $consecutive
+                ])
+            );
+            return true;
+        }
+
+        return false;
+    }
+}
diff --git a/src/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php b/src/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php
new file mode 100644
index 00000000..9f224007
--- /dev/null
+++ b/src/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php
@@ -0,0 +1,57 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+        $this->bounceManager = $bounceManager;
+        $this->blacklistService = $blacklistService;
+        $this->translator = $translator;
+    }
+
+    public function supports(string $action): bool
+    {
+        return $action === 'blacklistemailanddeletebounce';
+    }
+
+    public function handle(array $closureData): void
+    {
+        if (!empty($closureData['subscriber'])) {
+            $reason = $this->translator->trans('Email address auto blacklisted by bounce rule %rule_id%', [
+                '%rule_id%' => $closureData['ruleId']
+            ]);
+            $this->blacklistService->blacklist(
+                subscriber: $closureData['subscriber'],
+                reason: $reason
+            );
+            $details = $this->translator->trans('User auto unsubscribed for bounce rule %rule_id%', [
+                '%rule_id%' => $closureData['ruleId']
+            ]);
+            $this->subscriberHistoryManager->addHistory(
+                subscriber: $closureData['subscriber'],
+                message: $this->translator->trans('Auto Unsubscribed'),
+                details: $details
+            );
+        }
+        $this->bounceManager->delete($closureData['bounce']);
+    }
+}
diff --git a/src/Bounce/Service/Handler/BlacklistEmailHandler.php b/src/Bounce/Service/Handler/BlacklistEmailHandler.php
new file mode 100644
index 00000000..f2bd12b9
--- /dev/null
+++ b/src/Bounce/Service/Handler/BlacklistEmailHandler.php
@@ -0,0 +1,50 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+        $this->blacklistService = $blacklistService;
+        $this->translator = $translator;
+    }
+
+    public function supports(string $action): bool
+    {
+        return $action === 'blacklistemail';
+    }
+
+    public function handle(array $closureData): void
+    {
+        if (!empty($closureData['subscriber'])) {
+            $this->blacklistService->blacklist(
+                subscriber: $closureData['subscriber'],
+                reason: $this->translator->trans('Email address auto blacklisted by bounce rule %rule_id%', [
+                    '%rule_id%' => $closureData['ruleId']
+                ]),
+            );
+            $this->subscriberHistoryManager->addHistory(
+                subscriber: $closureData['subscriber'],
+                message: $this->translator->trans('Auto Unsubscribed'),
+                details: $this->translator->trans('email auto unsubscribed for bounce rule %rule_id%', [
+                    '%rule_id%' => $closureData['ruleId']
+                ])
+            );
+        }
+    }
+}
diff --git a/src/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandler.php b/src/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandler.php
new file mode 100644
index 00000000..7c997982
--- /dev/null
+++ b/src/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandler.php
@@ -0,0 +1,55 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+        $this->bounceManager = $bounceManager;
+        $this->blacklistService = $blacklistService;
+        $this->translator = $translator;
+    }
+
+    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: $this->translator->trans('Subscriber auto blacklisted by bounce rule %rule_id%', [
+                    '%rule_id%' => $closureData['ruleId']
+                ])
+            );
+            $this->subscriberHistoryManager->addHistory(
+                subscriber: $closureData['subscriber'],
+                message: $this->translator->trans('Auto Unsubscribed'),
+                details: $this->translator->trans('User auto unsubscribed for bounce rule %rule_id%', [
+                    '%rule_id%' => $closureData['ruleId']
+                ])
+            );
+        }
+        $this->bounceManager->delete($closureData['bounce']);
+    }
+}
diff --git a/src/Bounce/Service/Handler/BlacklistUserHandler.php b/src/Bounce/Service/Handler/BlacklistUserHandler.php
new file mode 100644
index 00000000..c5dd2fd5
--- /dev/null
+++ b/src/Bounce/Service/Handler/BlacklistUserHandler.php
@@ -0,0 +1,50 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+        $this->blacklistService = $blacklistService;
+        $this->translator = $translator;
+    }
+
+    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: $this->translator->trans('Subscriber auto blacklisted by bounce rule %rule_id%', [
+                    '%rule_id%' => $closureData['ruleId']
+                ])
+            );
+            $this->subscriberHistoryManager->addHistory(
+                subscriber: $closureData['subscriber'],
+                message: $this->translator->trans('Auto Unsubscribed'),
+                details: $this->translator->trans('User auto unsubscribed for bounce rule %rule_id%', [
+                    '%rule_id%' => $closureData['ruleId']
+                ])
+            );
+        }
+    }
+}
diff --git a/src/Bounce/Service/Handler/BounceActionHandlerInterface.php b/src/Bounce/Service/Handler/BounceActionHandlerInterface.php
new file mode 100644
index 00000000..ce43f7c7
--- /dev/null
+++ b/src/Bounce/Service/Handler/BounceActionHandlerInterface.php
@@ -0,0 +1,11 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+        $this->bounceManager = $bounceManager;
+        $this->subscriberRepository = $subscriberRepository;
+        $this->translator = $translator;
+    }
+
+    public function supports(string $action): bool
+    {
+        return $action === 'decreasecountconfirmuseranddeletebounce';
+    }
+
+    public function handle(array $closureData): void
+    {
+        if (!empty($closureData['subscriber'])) {
+            $this->subscriberRepository->decrementBounceCount($closureData['subscriber']);
+            if (!$closureData['confirmed']) {
+                $this->subscriberRepository->markConfirmed($closureData['userId']);
+                $this->subscriberHistoryManager->addHistory(
+                    subscriber: $closureData['subscriber'],
+                    message: $this->translator->trans('Auto confirmed'),
+                    details: $this->translator->trans('Subscriber auto confirmed for bounce rule %rule_id%', [
+                        '%rule_id%' => $closureData['ruleId']
+                    ])
+                );
+            }
+        }
+        $this->bounceManager->delete($closureData['bounce']);
+    }
+}
diff --git a/src/Bounce/Service/Handler/DeleteBounceHandler.php b/src/Bounce/Service/Handler/DeleteBounceHandler.php
new file mode 100644
index 00000000..9bf0d93e
--- /dev/null
+++ b/src/Bounce/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/Bounce/Service/Handler/DeleteUserAndBounceHandler.php b/src/Bounce/Service/Handler/DeleteUserAndBounceHandler.php
new file mode 100644
index 00000000..526f6968
--- /dev/null
+++ b/src/Bounce/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/Bounce/Service/Handler/DeleteUserHandler.php b/src/Bounce/Service/Handler/DeleteUserHandler.php
new file mode 100644
index 00000000..88f9da8f
--- /dev/null
+++ b/src/Bounce/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/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php b/src/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php
new file mode 100644
index 00000000..e13afb7a
--- /dev/null
+++ b/src/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php
@@ -0,0 +1,50 @@
+subscriberHistoryManager = $subscriberHistoryManager;
+        $this->subscriberRepository = $subscriberRepository;
+        $this->bounceManager = $bounceManager;
+        $this->translator = $translator;
+    }
+
+    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: $this->translator->trans('Auto unconfirmed'),
+                details: $this->translator->trans('Subscriber auto unconfirmed for bounce rule %rule_id%', [
+                    '%rule_id%' => $closureData['ruleId']
+                ])
+            );
+        }
+        $this->bounceManager->delete($closureData['bounce']);
+    }
+}
diff --git a/src/Bounce/Service/Handler/UnconfirmUserHandler.php b/src/Bounce/Service/Handler/UnconfirmUserHandler.php
new file mode 100644
index 00000000..bef028a6
--- /dev/null
+++ b/src/Bounce/Service/Handler/UnconfirmUserHandler.php
@@ -0,0 +1,45 @@
+subscriberRepository = $subscriberRepository;
+        $this->subscriberHistoryManager = $subscriberHistoryManager;
+        $this->translator = $translator;
+    }
+
+    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(
+                subscriber: $closureData['subscriber'],
+                message: $this->translator->trans('Auto unconfirmed'),
+                details: $this->translator->trans('Subscriber auto unconfirmed for bounce rule %rule_id%', [
+                    '%rule_id%' => $closureData['ruleId']
+                ])
+            );
+        }
+    }
+}
diff --git a/src/Bounce/Service/LockService.php b/src/Bounce/Service/LockService.php
new file mode 100644
index 00000000..c3948c1f
--- /dev/null
+++ b/src/Bounce/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/Bounce/Service/Manager/BounceManager.php b/src/Bounce/Service/Manager/BounceManager.php
new file mode 100644
index 00000000..230ad279
--- /dev/null
+++ b/src/Bounce/Service/Manager/BounceManager.php
@@ -0,0 +1,142 @@
+bounceRepository = $bounceRepository;
+        $this->userMessageBounceRepo = $userMessageBounceRepo;
+        $this->entityManager = $entityManager;
+        $this->logger = $logger;
+        $this->translator = $translator;
+    }
+
+    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->persist($bounce);
+        $this->entityManager->flush();
+
+        return $bounce;
+    }
+
+    public function update(Bounce $bounce, ?string $status = null, ?string $comment = null): Bounce
+    {
+        $bounce->setStatus($status);
+        $bounce->setComment($comment);
+        $this->entityManager->flush();
+
+        return $bounce;
+    }
+
+    public function delete(Bounce $bounce): void
+    {
+        $this->bounceRepository->remove($bounce);
+        $this->entityManager->flush();
+    }
+
+    /** @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);
+
+        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
+    {
+        $testModeMessage = $this->translator->trans('Running in test mode, not deleting messages from mailbox');
+        $liveModeMessage = $this->translator->trans('Processed messages will be deleted from the mailbox');
+
+        $this->logger->info($testMode ? $testModeMessage : $liveModeMessage);
+    }
+}
diff --git a/src/Bounce/Service/MessageParser.php b/src/Bounce/Service/MessageParser.php
new file mode 100644
index 00000000..336cbe02
--- /dev/null
+++ b/src/Bounce/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/Bounce/Service/NativeBounceProcessingService.php b/src/Bounce/Service/NativeBounceProcessingService.php
new file mode 100644
index 00000000..887aa94d
--- /dev/null
+++ b/src/Bounce/Service/NativeBounceProcessingService.php
@@ -0,0 +1,145 @@
+bounceManager = $bounceManager;
+        $this->mailReader = $mailReader;
+        $this->messageParser = $messageParser;
+        $this->bounceDataProcessor = $bounceDataProcessor;
+        $this->logger = $logger;
+        $this->entityManager = $entityManager;
+        $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 $throwable) {
+            $this->logger->error('Cannot open mailbox file', [
+                'mailbox' => $mailbox,
+                'error' => $throwable->getMessage(),
+            ]);
+            throw new OpenMboxFileException($throwable);
+        }
+    }
+
+    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);
+        $this->entityManager->flush();
+        return $this->bounceDataProcessor->process($bounce, $msgId, $userId, $bounceDate);
+    }
+}
diff --git a/src/Bounce/Service/Processor/AdvancedBounceRulesProcessor.php b/src/Bounce/Service/Processor/AdvancedBounceRulesProcessor.php
new file mode 100644
index 00000000..3d6fb116
--- /dev/null
+++ b/src/Bounce/Service/Processor/AdvancedBounceRulesProcessor.php
@@ -0,0 +1,128 @@
+section($this->translator->trans('Processing bounces based on active bounce rules'));
+
+        $rules = $this->ruleManager->loadActiveRules();
+        if (!$rules) {
+            $io->writeln($this->translator->trans('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($this->translator->trans(
+                'Processed %processed% out of %total% bounces for advanced bounce rules',
+                ['%processed%' => min($processed, $total), '%total%' => $total]
+            ));
+        }
+
+        $io->writeln($this->translator->trans(
+            '%processed% bounces processed by advanced processing',
+            ['%processed%' => $matched]
+        ));
+        $io->writeln($this->translator->trans(
+            '%not_processed% bounces were not matched by advanced processing rules',
+            ['%not_processed%' => $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/Bounce/Service/Processor/BounceDataProcessor.php b/src/Bounce/Service/Processor/BounceDataProcessor.php
new file mode 100644
index 00000000..d40707b3
--- /dev/null
+++ b/src/Bounce/Service/Processor/BounceDataProcessor.php
@@ -0,0 +1,186 @@
+bounceManager = $bounceManager;
+        $this->subscriberRepository = $subscriberRepository;
+        $this->messageRepository = $messageRepository;
+        $this->logger = $logger;
+        $this->subscriberManager = $subscriberManager;
+        $this->subscriberHistoryManager = $subscriberHistoryManager;
+        $this->entityManager = $entityManager;
+    }
+
+    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: $bounce,
+                date: $bounceDate,
+                userId: $userId,
+                userOrNull: $user
+            ) : $this->handleSystemMessageUnknownUser(bounce: $bounce);
+        }
+
+        if ($msgId && $userId) {
+            return $this->handleKnownMessageAndUser(
+                bounce: $bounce,
+                date: $bounceDate,
+                msgId: (int)$msgId,
+                userId: $userId
+            );
+        }
+
+        if ($userId) {
+            return $this->handleUserOnly(bounce: $bounce, userId: $userId);
+        }
+
+        if ($msgId) {
+            return $this->handleMessageOnly(bounce: $bounce, msgId: (int)$msgId);
+        }
+
+        $this->bounceManager->update(
+            bounce: $bounce,
+            status: BounceStatus::UnidentifiedBounce->value,
+            comment: 'not processed'
+        );
+
+        return false;
+    }
+
+    private function handleSystemMessageWithUser(
+        Bounce $bounce,
+        DateTimeImmutable $date,
+        int $userId,
+        $userOrNull
+    ): bool {
+        $this->bounceManager->update(
+            bounce: $bounce,
+            status: BounceStatus::SystemMessage->value,
+            comment: sprintf('%d marked unconfirmed', $userId)
+        );
+        $this->bounceManager->linkUserMessageBounce(bounce: $bounce, date: $date, subscriberId: $userId);
+        $this->entityManager->flush();
+        $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:$bounce,
+            status: BounceStatus::SystemMessage->value,
+            comment: '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(subscriberId: $userId, messageId: $msgId)) {
+            $this->bounceManager->linkUserMessageBounce(
+                bounce: $bounce,
+                date: $date,
+                subscriberId: $userId,
+                messageId: $msgId
+            );
+            $this->entityManager->flush();
+            $this->bounceManager->update(
+                bounce: $bounce,
+                status: BounceStatus::BouncedList->format($msgId),
+                comment: sprintf('%d bouncecount increased', $userId)
+            );
+            $this->messageRepository->incrementBounceCount($msgId);
+            $this->subscriberRepository->incrementBounceCount($userId);
+        } else {
+            $this->bounceManager->linkUserMessageBounce(
+                bounce: $bounce,
+                date: $date,
+                subscriberId: $userId,
+                messageId: $msgId
+            );
+            $this->entityManager->flush();
+            $this->bounceManager->update(
+                bounce: $bounce,
+                status: BounceStatus::DuplicateBounce->format($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: BounceStatus::UnidentifiedMessage->value,
+            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: BounceStatus::BouncedList->format($msgId),
+            comment: 'unknown user'
+        );
+        $this->messageRepository->incrementBounceCount($msgId);
+
+        return true;
+    }
+}
diff --git a/src/Bounce/Service/Processor/BounceProtocolProcessor.php b/src/Bounce/Service/Processor/BounceProtocolProcessor.php
new file mode 100644
index 00000000..6bb77a49
--- /dev/null
+++ b/src/Bounce/Service/Processor/BounceProtocolProcessor.php
@@ -0,0 +1,24 @@
+processingService = $processingService;
+        $this->translator = $translator;
+    }
+
+    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($this->translator->trans('mbox file path must be provided with --mailbox.'));
+            throw new RuntimeException('Missing --mailbox for mbox protocol');
+        }
+
+        $inputOutput->section($this->translator->trans('Opening mbox %file%', ['%file%' => $file]));
+        $inputOutput->writeln($this->translator->trans('Please do not interrupt this process'));
+
+        return $this->processingService->processMailbox(
+            mailbox: $file,
+            max: $max,
+            testMode: $testMode
+        );
+    }
+}
diff --git a/src/Bounce/Service/Processor/PopBounceProcessor.php b/src/Bounce/Service/Processor/PopBounceProcessor.php
new file mode 100644
index 00000000..9ebb26c4
--- /dev/null
+++ b/src/Bounce/Service/Processor/PopBounceProcessor.php
@@ -0,0 +1,63 @@
+processingService = $processingService;
+        $this->host = $host;
+        $this->port = $port;
+        $this->mailboxNames = $mailboxNames;
+        $this->translator = $translator;
+    }
+
+    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($this->translator->trans('Connecting to %mailbox%', ['%mailbox%' => $mailbox]));
+            $inputOutput->writeln($this->translator->trans('Please do not interrupt this process'));
+
+            $downloadReport .= $this->processingService->processMailbox(
+                mailbox: $mailbox,
+                max: $max,
+                testMode: $testMode
+            );
+        }
+
+        return $downloadReport;
+    }
+}
diff --git a/src/Bounce/Service/Processor/UnidentifiedBounceReprocessor.php b/src/Bounce/Service/Processor/UnidentifiedBounceReprocessor.php
new file mode 100644
index 00000000..416684b2
--- /dev/null
+++ b/src/Bounce/Service/Processor/UnidentifiedBounceReprocessor.php
@@ -0,0 +1,79 @@
+bounceManager = $bounceManager;
+        $this->messageParser = $messageParser;
+        $this->bounceDataProcessor = $bounceDataProcessor;
+        $this->translator = $translator;
+    }
+
+    public function process(SymfonyStyle $inputOutput): void
+    {
+        $inputOutput->section($this->translator->trans('Reprocessing unidentified bounces'));
+        $bounces = $this->bounceManager->findByStatus(BounceStatus::UnidentifiedBounce->value);
+        $total = count($bounces);
+        $inputOutput->writeln($this->translator->trans('%total% bounces to reprocess', ['%total%' => $total]));
+
+        $count = 0;
+        $reparsed = 0;
+        $reidentified = 0;
+        foreach ($bounces as $bounce) {
+            $count++;
+            if ($count % 25 === 0) {
+                $inputOutput->writeln($this->translator->trans('%count% out of %total% processed', [
+                    '%count%' => $count,
+                    '%total%' => $total
+                ]));
+            }
+
+            $decodedBody = $this->messageParser->decodeBody(header: $bounce->getHeader(), body: $bounce->getData());
+            $userId = $this->messageParser->findUserId($decodedBody);
+            $messageId = $this->messageParser->findMessageId($decodedBody);
+
+            if ($userId || $messageId) {
+                $reparsed++;
+                if ($this->bounceDataProcessor->process(
+                    bounce: $bounce,
+                    msgId: $messageId,
+                    userId: $userId,
+                    bounceDate: new DateTimeImmutable()
+                )
+                ) {
+                    $reidentified++;
+                }
+            }
+        }
+
+        $inputOutput->writeln($this->translator->trans('%count% out of %total% processed', [
+            '%count%' => $count,
+            '%total%' => $total
+        ]));
+        $inputOutput->writeln($this->translator->trans(
+            '%reparsed% bounces were re-processed and %reidentified% bounces were re-identified',
+            ['%reparsed%' => $reparsed, '%reidentified%' => $reidentified]
+        ));
+    }
+}
diff --git a/src/Bounce/Service/SubscriberBlacklistService.php b/src/Bounce/Service/SubscriberBlacklistService.php
new file mode 100644
index 00000000..38d37e7d
--- /dev/null
+++ b/src/Bounce/Service/SubscriberBlacklistService.php
@@ -0,0 +1,75 @@
+entityManager = $entityManager;
+        $this->blacklistManager = $blacklistManager;
+        $this->historyManager = $historyManager;
+        $this->requestStack = $requestStack;
+        $this->translator = $translator;
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.Superglobals)
+     */
+    public function blacklist(Subscriber $subscriber, string $reason): void
+    {
+        $subscriber->setBlacklisted(true);
+        $this->entityManager->flush();
+        $userBlacklist = $this->blacklistManager->addEmailToBlacklist($subscriber->getEmail(), $reason);
+        $this->entityManager->flush();
+
+        foreach (['REMOTE_ADDR','HTTP_X_FORWARDED_FOR'] as $item) {
+            $request = $this->requestStack->getCurrentRequest();
+            if (!$request) {
+                return;
+            }
+            if ($request->server->get($item)) {
+                $this->blacklistManager->addBlacklistData(
+                    userBlacklist: $userBlacklist,
+                    name: $item,
+                    data: $request->server->get($item)
+                );
+                $this->entityManager->flush();
+            }
+        }
+
+        $this->historyManager->addHistory(
+            subscriber: $subscriber,
+            message: 'Added to blacklist',
+            details: $this->translator->trans('Added to blacklist for reason %reason%', ['%reason%' => $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/src/Bounce/Service/WebklexBounceProcessingService.php b/src/Bounce/Service/WebklexBounceProcessingService.php
new file mode 100644
index 00000000..4ca20461
--- /dev/null
+++ b/src/Bounce/Service/WebklexBounceProcessingService.php
@@ -0,0 +1,271 @@
+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 ImapConnectionException 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 $throwable) {
+            $this->logger->error('Cannot connect to mailbox', [
+                'mailbox' => $mailbox,
+                'error' => $throwable->getMessage()
+            ]);
+            throw new ImapConnectionException($throwable);
+        }
+
+        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/Bounce/Service/WebklexImapClientFactory.php b/src/Bounce/Service/WebklexImapClientFactory.php
new file mode 100644
index 00000000..48fc26bc
--- /dev/null
+++ b/src/Bounce/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/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..6ec27dae
--- /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/Core/Doctrine/OnlyOrmTablesFilter.php b/src/Core/Doctrine/OnlyOrmTablesFilter.php
new file mode 100644
index 00000000..69097fcd
--- /dev/null
+++ b/src/Core/Doctrine/OnlyOrmTablesFilter.php
@@ -0,0 +1,84 @@
+getName();
+        $pos = strrpos($name, '.');
+        if (false !== $pos) {
+            $name = substr($name, $pos + 1);
+        }
+        $nameLower = strtolower($name);
+
+        [$allow, $allowPrefixes] = $this->buildAllowOnce();
+
+        if (\is_string($asset) || $asset instanceof Table) {
+            return \in_array($nameLower, $allow, true);
+        }
+
+        // PostgreSQL sequences: allow those that belong to our ORM tables
+        // (default naming: {table}_{column}_seq, so we check table_ prefix)
+        if ($asset instanceof Sequence) {
+            foreach ($allowPrefixes as $prefix) {
+                if (str_starts_with($nameLower, $prefix)) {
+                    return true;
+                }
+            }
+            // Disallow unrelated sequences
+            return false;
+        }
+
+        // Other dependent assets (indexes, FKs) are tied to allowed tables → allow
+        return true;
+    }
+
+    private function buildAllowOnce(): array
+    {
+        if ($this->allow !== null) {
+            return [$this->allow, $this->allowPrefixes];
+        }
+
+        $tables = [];
+        foreach ($this->entityManager->getMetadataFactory()->getAllMetadata() as $metadatum) {
+            $tableName = $metadatum->getTableName();
+            if ($tableName) {
+                $tables[] = strtolower($tableName);
+            }
+            // many-to-many join tables
+            foreach ($metadatum->getAssociationMappings() as $assoc) {
+                if (!empty($assoc['joinTable']['name'])) {
+                    $tables[] = strtolower($assoc['joinTable']['name']);
+                }
+            }
+        }
+
+        $tables[] = 'doctrine_migration_versions';
+
+        $tables = array_values(array_unique($tables));
+        $prefixes = array_map(static fn($table) => $table . '_', $tables);
+
+        $this->allow = $tables;
+        $this->allowPrefixes = $prefixes;
+
+        return [$this->allow, $this->allowPrefixes];
+    }
+}
diff --git a/src/Core/ConfigProvider.php b/src/Core/ParameterProvider.php
similarity index 93%
rename from src/Core/ConfigProvider.php
rename to src/Core/ParameterProvider.php
index b78f365f..ac278984 100644
--- a/src/Core/ConfigProvider.php
+++ b/src/Core/ParameterProvider.php
@@ -4,7 +4,7 @@
 
 namespace PhpList\Core\Core;
 
-class ConfigProvider
+class ParameterProvider
 {
     public function __construct(private readonly array $config)
     {
diff --git a/src/Domain/Analytics/Exception/MissingMessageIdException.php b/src/Domain/Analytics/Exception/MissingMessageIdException.php
new file mode 100644
index 00000000..71479ff0
--- /dev/null
+++ b/src/Domain/Analytics/Exception/MissingMessageIdException.php
@@ -0,0 +1,15 @@
+ 'CURRENT_TIMESTAMP'])]
+    #[ORM\Column(name: 'latestclick', type: 'datetime')]
     private ?DateTimeInterface $latestClick = null;
 
     #[ORM\Column(type: 'integer', nullable: true, options: ['default' => 0])]
     private int $clicked = 0;
 
+    public function __construct()
+    {
+        $this->latestClick = new DateTime();
+    }
+
     public function getId(): int
     {
         return $this->id;
diff --git a/src/Domain/Analytics/Model/LinkTrackForward.php b/src/Domain/Analytics/Model/LinkTrackForward.php
index 17049dfc..0e03c017 100644
--- a/src/Domain/Analytics/Model/LinkTrackForward.php
+++ b/src/Domain/Analytics/Model/LinkTrackForward.php
@@ -11,9 +11,9 @@
 
 #[ORM\Entity(repositoryClass: LinkTrackForwardRepository::class)]
 #[ORM\Table(name: 'phplist_linktrack_forward')]
-#[ORM\UniqueConstraint(name: 'urlunique', columns: ['urlhash'])]
-#[ORM\Index(name: 'urlindex', columns: ['url'])]
-#[ORM\Index(name: 'uuididx', columns: ['uuid'])]
+#[ORM\UniqueConstraint(name: 'phplist_linktrack_forward_urlunique', columns: ['urlhash'])]
+#[ORM\Index(name: 'phplist_linktrack_forward_urlindex', columns: ['url'])]
+#[ORM\Index(name: 'phplist_linktrack_forward_uuididx', columns: ['uuid'])]
 class LinkTrackForward implements DomainModel, Identity
 {
     #[ORM\Id]
diff --git a/src/Domain/Analytics/Model/LinkTrackMl.php b/src/Domain/Analytics/Model/LinkTrackMl.php
index 8569fa14..419c7911 100644
--- a/src/Domain/Analytics/Model/LinkTrackMl.php
+++ b/src/Domain/Analytics/Model/LinkTrackMl.php
@@ -11,8 +11,8 @@
 
 #[ORM\Entity(repositoryClass: LinkTrackMlRepository::class)]
 #[ORM\Table(name: 'phplist_linktrack_ml')]
-#[ORM\Index(name: 'fwdindex', columns: ['forwardid'])]
-#[ORM\Index(name: 'midindex', columns: ['messageid'])]
+#[ORM\Index(name: 'phplist_linktrack_ml_fwdindex', columns: ['forwardid'])]
+#[ORM\Index(name: 'phplist_linktrack_ml_midindex', columns: ['messageid'])]
 class LinkTrackMl implements DomainModel
 {
     #[ORM\Id]
diff --git a/src/Domain/Analytics/Model/LinkTrackUmlClick.php b/src/Domain/Analytics/Model/LinkTrackUmlClick.php
index 2b8c8068..3faf811d 100644
--- a/src/Domain/Analytics/Model/LinkTrackUmlClick.php
+++ b/src/Domain/Analytics/Model/LinkTrackUmlClick.php
@@ -12,10 +12,10 @@
 
 #[ORM\Entity(repositoryClass: LinkTrackUmlClickRepository::class)]
 #[ORM\Table(name: 'phplist_linktrack_uml_click')]
-#[ORM\UniqueConstraint(name: 'miduidfwdid', columns: ['messageid', 'userid', 'forwardid'])]
-#[ORM\Index(name: 'midindex', columns: ['messageid'])]
-#[ORM\Index(name: 'miduidindex', columns: ['messageid', 'userid'])]
-#[ORM\Index(name: 'uidindex', columns: ['userid'])]
+#[ORM\UniqueConstraint(name: 'phplist_linktrack_uml_click_miduidfwdid', columns: ['messageid', 'userid', 'forwardid'])]
+#[ORM\Index(name: 'phplist_linktrack_uml_click_midindex', columns: ['messageid'])]
+#[ORM\Index(name: 'phplist_linktrack_uml_click_miduidindex', columns: ['messageid', 'userid'])]
+#[ORM\Index(name: 'phplist_linktrack_uml_click_uidindex', columns: ['userid'])]
 class LinkTrackUmlClick implements DomainModel, Identity
 {
     #[ORM\Id]
diff --git a/src/Domain/Analytics/Model/LinkTrackUserClick.php b/src/Domain/Analytics/Model/LinkTrackUserClick.php
index 464ae3e0..27205cbb 100644
--- a/src/Domain/Analytics/Model/LinkTrackUserClick.php
+++ b/src/Domain/Analytics/Model/LinkTrackUserClick.php
@@ -11,11 +11,11 @@
 
 #[ORM\Entity(repositoryClass: LinkTrackUserClickRepository::class)]
 #[ORM\Table(name: 'phplist_linktrack_userclick')]
-#[ORM\Index(name: 'linkindex', columns: ['linkid'])]
-#[ORM\Index(name: 'linkuserindex', columns: ['linkid', 'userid'])]
-#[ORM\Index(name: 'linkusermessageindex', columns: ['linkid', 'userid', 'messageid'])]
-#[ORM\Index(name: 'midindex', columns: ['messageid'])]
-#[ORM\Index(name: 'uidindex', columns: ['userid'])]
+#[ORM\Index(name: 'phplist_linktrack_userclick_linkindex', columns: ['linkid'])]
+#[ORM\Index(name: 'phplist_linktrack_userclick_linkuserindex', columns: ['linkid', 'userid'])]
+#[ORM\Index(name: 'phplist_linktrack_userclick_linkusermessageindex', columns: ['linkid', 'userid', 'messageid'])]
+#[ORM\Index(name: 'phplist_linktrack_userclick_midindex', columns: ['messageid'])]
+#[ORM\Index(name: 'phplist_linktrack_userclick_uidindex', columns: ['userid'])]
 class LinkTrackUserClick implements DomainModel
 {
     #[ORM\Id]
diff --git a/src/Domain/Analytics/Model/UserMessageView.php b/src/Domain/Analytics/Model/UserMessageView.php
index 6b80b75e..846c8e97 100644
--- a/src/Domain/Analytics/Model/UserMessageView.php
+++ b/src/Domain/Analytics/Model/UserMessageView.php
@@ -12,9 +12,9 @@
 
 #[ORM\Entity(repositoryClass: UserMessageViewRepository::class)]
 #[ORM\Table(name: 'phplist_user_message_view')]
-#[ORM\Index(name: 'msgidx', columns: ['messageid'])]
-#[ORM\Index(name: 'useridx', columns: ['userid'])]
-#[ORM\Index(name: 'usermsgidx', columns: ['userid', 'messageid'])]
+#[ORM\Index(name: 'phplist_user_message_view_msgidx', columns: ['messageid'])]
+#[ORM\Index(name: 'phplist_user_message_view_useridx', columns: ['userid'])]
+#[ORM\Index(name: 'phplist_user_message_view_usermsgidx', columns: ['userid', 'messageid'])]
 class UserMessageView implements DomainModel, Identity
 {
     #[ORM\Id]
diff --git a/src/Domain/Analytics/Model/UserStats.php b/src/Domain/Analytics/Model/UserStats.php
index a2f835bf..c7b4b97e 100644
--- a/src/Domain/Analytics/Model/UserStats.php
+++ b/src/Domain/Analytics/Model/UserStats.php
@@ -11,11 +11,11 @@
 
 #[ORM\Entity(repositoryClass: UserStatsRepository::class)]
 #[ORM\Table(name: 'phplist_userstats')]
-#[ORM\UniqueConstraint(name: 'entry', columns: ['unixdate', 'item', 'listid'])]
-#[ORM\Index(name: 'dateindex', columns: ['unixdate'])]
-#[ORM\Index(name: 'itemindex', columns: ['item'])]
-#[ORM\Index(name: 'listdateindex', columns: ['listid', 'unixdate'])]
-#[ORM\Index(name: 'listindex', columns: ['listid'])]
+#[ORM\UniqueConstraint(name: 'phplist_userstats_entry', columns: ['unixdate', 'item', 'listid'])]
+#[ORM\Index(name: 'phplist_userstats_dateindex', columns: ['unixdate'])]
+#[ORM\Index(name: 'phplist_userstats_itemindex', columns: ['item'])]
+#[ORM\Index(name: 'phplist_userstats_listdateindex', columns: ['listid', 'unixdate'])]
+#[ORM\Index(name: 'phplist_userstats_listindex', columns: ['listid'])]
 class UserStats implements DomainModel, Identity
 {
     #[ORM\Id]
@@ -29,11 +29,11 @@ class UserStats implements DomainModel, Identity
     #[ORM\Column(name: 'item', type: 'string', length: 255, nullable: true)]
     private ?string $item = null;
 
-    #[ORM\Column(name: 'listid', type: 'integer', options: ['default' => 0])]
+    #[ORM\Column(name: 'listid', type: 'integer', nullable: true, options: ['default' => 0])]
     private int $listId = 0;
 
-    #[ORM\Column(name: 'value', type: 'integer', options: ['default' => 0])]
-    private int $value = 0;
+    #[ORM\Column(name: 'value', type: 'integer', nullable: true, options: ['default' => 0])]
+    private ?int $value = null;
 
     public function getId(): ?int
     {
diff --git a/src/Domain/Analytics/Service/LinkTrackService.php b/src/Domain/Analytics/Service/LinkTrackService.php
index b84a9bc6..ad2df206 100644
--- a/src/Domain/Analytics/Service/LinkTrackService.php
+++ b/src/Domain/Analytics/Service/LinkTrackService.php
@@ -4,8 +4,8 @@
 
 namespace PhpList\Core\Domain\Analytics\Service;
 
-use InvalidArgumentException;
-use PhpList\Core\Core\ConfigProvider;
+use PhpList\Core\Core\ParameterProvider;
+use PhpList\Core\Domain\Analytics\Exception\MissingMessageIdException;
 use PhpList\Core\Domain\Analytics\Model\LinkTrack;
 use PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository;
 use PhpList\Core\Domain\Messaging\Model\Message;
@@ -13,12 +13,12 @@
 class LinkTrackService
 {
     private LinkTrackRepository $linkTrackRepository;
-    private ConfigProvider $configProvider;
+    private ParameterProvider $paramProvider;
 
-    public function __construct(LinkTrackRepository $linkTrackRepository, ConfigProvider $configProvider)
+    public function __construct(LinkTrackRepository $linkTrackRepository, ParameterProvider $paramProvider)
     {
         $this->linkTrackRepository = $linkTrackRepository;
-        $this->configProvider = $configProvider;
+        $this->paramProvider = $paramProvider;
     }
 
     public function getUrlById(int $id): ?string
@@ -29,14 +29,14 @@ public function getUrlById(int $id): ?string
 
     public function isExtractAndSaveLinksApplicable(): bool
     {
-        return (bool)$this->configProvider->get('click_track', false);
+        return (bool)$this->paramProvider->get('click_track', false);
     }
 
     /**
      * Extract links from message content and save them to the LinkTrackRepository
      *
      * @return LinkTrack[] The saved LinkTrack entities
-     * @throws InvalidArgumentException if the message doesn't have an ID
+     * @throws MissingMessageIdException
      */
     public function extractAndSaveLinks(Message $message, int $userId): array
     {
@@ -48,7 +48,7 @@ public function extractAndSaveLinks(Message $message, int $userId): array
         $messageId = $message->getId();
 
         if ($messageId === null) {
-            throw new InvalidArgumentException('Message must have an ID');
+            throw new MissingMessageIdException();
         }
 
         $links = $this->extractLinksFromHtml($content->getText() ?? '');
@@ -72,7 +72,7 @@ public function extractAndSaveLinks(Message $message, int $userId): array
             $linkTrack->setUserId($userId);
             $linkTrack->setUrl($url);
 
-            $this->linkTrackRepository->save($linkTrack);
+            $this->linkTrackRepository->persist($linkTrack);
             $savedLinks[] = $linkTrack;
         }
 
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/Exception/MailboxConnectionException.php b/src/Domain/Common/Exception/MailboxConnectionException.php
new file mode 100644
index 00000000..20a927b5
--- /dev/null
+++ b/src/Domain/Common/Exception/MailboxConnectionException.php
@@ -0,0 +1,23 @@
+readConfigFile();
+        if ($contents === null) {
+            return new IspRestrictions(null, null, null);
+        }
+
+        [$raw, $maxBatch, $minBatchPeriod, $lockFile] = $this->parseContents($contents);
+
+        $this->logIfDetected($maxBatch, $minBatchPeriod, $lockFile);
+
+        return new IspRestrictions($maxBatch, $minBatchPeriod, $lockFile, $raw);
+    }
+
+    private function readConfigFile(): ?string
+    {
+        if (!is_file($this->confPath) || !is_readable($this->confPath)) {
+            return null;
+        }
+        $contents = file_get_contents($this->confPath);
+        if ($contents === false) {
+            $this->logger->warning('Cannot read ISP restrictions file', ['path' => $this->confPath]);
+
+            return null;
+        }
+
+        return $contents;
+    }
+
+    /**
+     * @return array{0: array, 1: ?int, 2: ?int, 3: ?string}
+     */
+    private function parseContents(string $contents): array
+    {
+        $maxBatch = null;
+        $minBatchPeriod = null;
+        $lockFile = null;
+        $raw = [];
+
+        foreach (preg_split('/\R/', $contents) as $line) {
+            [$key, $val] = $this->parseLine($line);
+            if ($key === null) {
+                continue;
+            }
+            $raw[$key] = $val;
+            [$maxBatch, $minBatchPeriod, $lockFile] = $this->applyKeyValue(
+                $key,
+                $val,
+                $maxBatch,
+                $minBatchPeriod,
+                $lockFile
+            );
+        }
+
+        return [$raw, $maxBatch, $minBatchPeriod, $lockFile];
+    }
+
+    /**
+     * @return array{0: ?string, 1: string}
+     */
+    private function parseLine(string $line): array
+    {
+        $line = trim($line);
+        if ($line === '' || str_starts_with($line, '#') || str_starts_with($line, ';')) {
+            return [null, ''];
+        }
+        $parts = explode('=', $line, 2);
+        if (\count($parts) !== 2) {
+            return [null, ''];
+        }
+
+        return array_map('trim', $parts);
+    }
+
+    /**
+     * @param string $key
+     * @param string $val
+     * @param ?int $maxBatch
+     * @param ?int $minBatchPeriod
+     * @param ?string $lockFile
+     * @return array{0: ?int, 1: ?int, 2: ?string}
+     */
+    private function applyKeyValue(
+        string $key,
+        string $val,
+        ?int $maxBatch,
+        ?int $minBatchPeriod,
+        ?string $lockFile
+    ): array {
+        if ($key === 'maxbatch') {
+            if ($val !== '' && ctype_digit($val)) {
+                $maxBatch = (int) $val;
+            }
+
+            return [$maxBatch, $minBatchPeriod, $lockFile];
+        }
+        if ($key === 'minbatchperiod') {
+            if ($val !== '' && ctype_digit($val)) {
+                $minBatchPeriod = (int) $val;
+            }
+
+            return [$maxBatch, $minBatchPeriod, $lockFile];
+        }
+        if ($key === 'lockfile') {
+            if ($val !== '') {
+                $lockFile = $val;
+            }
+
+            return [$maxBatch, $minBatchPeriod, $lockFile];
+        }
+
+        return [$maxBatch, $minBatchPeriod, $lockFile];
+    }
+
+    private function logIfDetected(?int $maxBatch, ?int $minBatchPeriod, ?string $lockFile): void
+    {
+        if ($maxBatch !== null || $minBatchPeriod !== null || $lockFile !== null) {
+            $this->logger->info('ISP restrictions detected', [
+                'path' => $this->confPath,
+                'maxbatch' => $maxBatch,
+                'minbatchperiod' => $minBatchPeriod,
+                'lockfile' => $lockFile,
+            ]);
+        }
+    }
+}
diff --git a/src/Domain/Common/Mail/NativeImapMailReader.php b/src/Domain/Common/Mail/NativeImapMailReader.php
new file mode 100644
index 00000000..ba90151d
--- /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 MailboxConnectionException($mailbox);
+        }
+
+        return $link;
+    }
+
+    public function numMessages(Connection $link): int
+    {
+        return imap_num_msg($link);
+    }
+
+    public function fetchHeader(Connection $link, int $msgNo): string
+    {
+        return imap_fetchheader($link, $msgNo) ?: '';
+    }
+
+    public function headerDate(Connection $link, int $msgNo): DateTimeImmutable
+    {
+        $info = imap_headerinfo($link, $msgNo);
+        $date = $info->date ?? null;
+
+        return $date ? new DateTimeImmutable($date) : new DateTimeImmutable();
+    }
+
+    public function body(Connection $link, int $msgNo): string
+    {
+        return imap_body($link, $msgNo) ?: '';
+    }
+
+    public function delete(Connection $link, int $msgNo): void
+    {
+        imap_delete($link, (string)$msgNo);
+    }
+
+    public function close(Connection $link, bool $expunge): void
+    {
+        $expunge ? imap_close($link, CL_EXPUNGE) : imap_close($link);
+    }
+}
diff --git a/src/Domain/Common/Model/Interfaces/ModificationDate.php b/src/Domain/Common/Model/Interfaces/ModificationDate.php
index 22432b2f..11c383e2 100644
--- a/src/Domain/Common/Model/Interfaces/ModificationDate.php
+++ b/src/Domain/Common/Model/Interfaces/ModificationDate.php
@@ -20,14 +20,4 @@ interface ModificationDate
      * @return DateTime|null
      */
     public function getUpdatedAt(): ?DateTime;
-
-    /**
-     * Updates the modification date to be now.
-     *
-     * @Mapping\PrePersist
-     * @Mapping\PreUpdate
-     *
-     * @return DomainModel
-     */
-    public function updateUpdatedAt(): DomainModel;
 }
diff --git a/src/Domain/Common/Model/Interfaces/OwnableInterface.php b/src/Domain/Common/Model/Interfaces/OwnableInterface.php
new file mode 100644
index 00000000..16e54e40
--- /dev/null
+++ b/src/Domain/Common/Model/Interfaces/OwnableInterface.php
@@ -0,0 +1,12 @@
+maxBatch === null && $this->minBatchPeriod === null && $this->lockFile === null;
+    }
+}
diff --git a/src/Domain/Common/Repository/AbstractRepository.php b/src/Domain/Common/Repository/AbstractRepository.php
index 284ef544..bfefd054 100644
--- a/src/Domain/Common/Repository/AbstractRepository.php
+++ b/src/Domain/Common/Repository/AbstractRepository.php
@@ -31,6 +31,11 @@ public function save(DomainModel $model): void
         $this->getEntityManager()->flush();
     }
 
+    public function persist(DomainModel $model): void
+    {
+        $this->getEntityManager()->persist($model);
+    }
+
     /**
      * Removes $model and flushes the entity manager change list.
      *
@@ -41,9 +46,14 @@ public function save(DomainModel $model): void
      *
      * @return void
      */
-    public function remove(DomainModel $model): void
+    public function delete(DomainModel $model): void
     {
         $this->getEntityManager()->remove($model);
         $this->getEntityManager()->flush();
     }
+
+    public function remove(DomainModel $model): void
+    {
+        $this->getEntityManager()->remove($model);
+    }
 }
diff --git a/src/Domain/Common/Repository/CursorPaginationTrait.php b/src/Domain/Common/Repository/CursorPaginationTrait.php
index 8be64ee2..3cf67a72 100644
--- a/src/Domain/Common/Repository/CursorPaginationTrait.php
+++ b/src/Domain/Common/Repository/CursorPaginationTrait.php
@@ -4,9 +4,9 @@
 
 namespace PhpList\Core\Domain\Common\Repository;
 
+use BadMethodCallException;
 use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface;
 use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
-use RuntimeException;
 
 trait CursorPaginationTrait
 {
@@ -30,14 +30,14 @@ public function getAfterId(int $lastId, int $limit): array
      * Get filtered + paginated messages for a given owner and status.
      *
      * @return DomainModel[]
-     * @throws RuntimeException
-     */
+     * @throws BadMethodCallException
+     * */
     public function getFilteredAfterId(int $lastId, int $limit, ?FilterRequestInterface $filter = null): array
     {
         if ($filter === null) {
             return $this->getAfterId($lastId, $limit);
         }
 
-        throw new RuntimeException('Filter method not implemented');
+        throw new BadMethodCallException('getFilteredAfterId method not implemented');
     }
 }
diff --git a/src/Domain/Common/SystemInfoCollector.php b/src/Domain/Common/SystemInfoCollector.php
new file mode 100644
index 00000000..56b30579
--- /dev/null
+++ b/src/Domain/Common/SystemInfoCollector.php
@@ -0,0 +1,76 @@
+ use defaults)
+     */
+    public function __construct(RequestStack $requestStack, array $configuredKeys = [])
+    {
+        $this->requestStack = $requestStack;
+        $this->configuredKeys = $configuredKeys;
+    }
+
+    /**
+     * Return key=>value pairs (already sanitized for safe logging/HTML display).
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     * @return array
+     */
+    public function collect(): array
+    {
+        $request = $this->requestStack->getCurrentRequest() ?? Request::createFromGlobals();
+        $data = [];
+        $headers = $request->headers;
+
+        $data['HTTP_USER_AGENT'] = (string) $headers->get('User-Agent', '');
+        $data['HTTP_REFERER'] = (string) $headers->get('Referer', '');
+        $data['HTTP_X_FORWARDED_FOR'] = (string) $headers->get('X-Forwarded-For', '');
+        $data['REQUEST_URI'] = $request->getRequestUri();
+        $data['REMOTE_ADDR'] = $request->getClientIp() ?? '';
+
+        $keys = $this->configuredKeys ?: $this->defaultKeys;
+
+        $out = [];
+        foreach ($keys as $key) {
+            if (!array_key_exists($key, $data)) {
+                continue;
+            }
+            $val = $data[$key];
+
+            $safeKey = strip_tags($key);
+            $safeVal = htmlspecialchars((string) $val, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+
+            $out[$safeKey] = $safeVal;
+        }
+
+        return $out;
+    }
+
+    /**
+     * Convenience to match the legacy multi-line string format.
+     */
+    public function collectAsString(): string
+    {
+        $pairs = $this->collect();
+        if (!$pairs) {
+            return '';
+        }
+        $lines = [];
+        foreach ($pairs as $k => $v) {
+            $lines[] = sprintf('%s = %s', $k, $v);
+        }
+
+        return "\n" . implode("\n", $lines);
+    }
+}
diff --git a/src/Domain/Configuration/Model/ConfigOption.php b/src/Domain/Configuration/Model/ConfigOption.php
new file mode 100644
index 00000000..86b9286e
--- /dev/null
+++ b/src/Domain/Configuration/Model/ConfigOption.php
@@ -0,0 +1,18 @@
+id;
     }
 
+    #[ORM\PrePersist]
+    public function setCreatedTimestamps(): void
+    {
+        $now = new DateTimeImmutable();
+        $this->entered = $now;
+    }
+
     public function getEntered(): ?DateTimeInterface
     {
         return $this->entered;
diff --git a/src/Domain/Configuration/Model/Filter/EventLogFilter.php b/src/Domain/Configuration/Model/Filter/EventLogFilter.php
new file mode 100644
index 00000000..12a824ca
--- /dev/null
+++ b/src/Domain/Configuration/Model/Filter/EventLogFilter.php
@@ -0,0 +1,33 @@
+page;
+    }
+
+    public function getDateFrom(): ?DateTimeInterface
+    {
+        return $this->dateFrom;
+    }
+
+    public function getDateTo(): ?DateTimeInterface
+    {
+        return $this->dateTo;
+    }
+}
diff --git a/src/Domain/Configuration/Model/I18n.php b/src/Domain/Configuration/Model/I18n.php
index bffed897..72397bb4 100644
--- a/src/Domain/Configuration/Model/I18n.php
+++ b/src/Domain/Configuration/Model/I18n.php
@@ -8,10 +8,15 @@
 use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
 use PhpList\Core\Domain\Configuration\Repository\I18nRepository;
 
+/**
+ * @deprecated
+ *
+ * Symfony\Contracts\Translation will be used instead.
+ */
 #[ORM\Entity(repositoryClass: I18nRepository::class)]
 #[ORM\Table(name: 'phplist_i18n')]
-#[ORM\UniqueConstraint(name: 'lanorigunq', columns: ['lan', 'original'])]
-#[ORM\Index(name: 'lanorigidx', columns: ['lan', 'original'])]
+#[ORM\UniqueConstraint(name: 'phplist_i18n_lanorigunq', columns: ['lan', 'original'])]
+#[ORM\Index(name: 'phplist_i18n_lanorigidx', columns: ['lan', 'original'])]
 class I18n implements DomainModel
 {
     #[ORM\Id]
diff --git a/src/Domain/Configuration/Model/UrlCache.php b/src/Domain/Configuration/Model/UrlCache.php
index 5d460104..b6d032b9 100644
--- a/src/Domain/Configuration/Model/UrlCache.php
+++ b/src/Domain/Configuration/Model/UrlCache.php
@@ -12,7 +12,8 @@
 
 #[ORM\Entity(repositoryClass: UrlCacheRepository::class)]
 #[ORM\Table(name: 'phplist_urlcache')]
-#[ORM\Index(name: 'urlindex', columns: ['url'])]
+#[ORM\Index(name: 'phplist_urlcache_urlindex', columns: ['url'])]
+#[ORM\HasLifecycleCallbacks]
 class UrlCache implements DomainModel, Identity
 {
     #[ORM\Id]
@@ -71,10 +72,11 @@ public function setLastModified(?int $lastModified): self
         return $this;
     }
 
-    public function setAdded(?DateTime $added): self
+    #[ORM\PrePersist]
+    public function setCreatedTimestamps(): void
     {
-        $this->added = $added;
-        return $this;
+        $now = new DateTime();
+        $this->added = $now;
     }
 
     public function setContent(?string $content): self
diff --git a/src/Domain/Configuration/Repository/ConfigRepository.php b/src/Domain/Configuration/Repository/ConfigRepository.php
index ad74ff51..ea4a0680 100644
--- a/src/Domain/Configuration/Repository/ConfigRepository.php
+++ b/src/Domain/Configuration/Repository/ConfigRepository.php
@@ -8,4 +8,8 @@
 
 class ConfigRepository extends AbstractRepository
 {
+    public function findValueByItem(string $name): ?string
+    {
+        return $this->findOneBy(['key' => $name])?->getValue();
+    }
 }
diff --git a/src/Domain/Configuration/Repository/EventLogRepository.php b/src/Domain/Configuration/Repository/EventLogRepository.php
index 7caf5462..47640007 100644
--- a/src/Domain/Configuration/Repository/EventLogRepository.php
+++ b/src/Domain/Configuration/Repository/EventLogRepository.php
@@ -4,11 +4,48 @@
 
 namespace PhpList\Core\Domain\Configuration\Repository;
 
+use InvalidArgumentException;
+use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface;
 use PhpList\Core\Domain\Common\Repository\AbstractRepository;
 use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
 use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Configuration\Model\Filter\EventLogFilter;
+use PhpList\Core\Domain\Configuration\Model\EventLog;
 
 class EventLogRepository extends AbstractRepository implements PaginatableRepositoryInterface
 {
     use CursorPaginationTrait;
+
+    /**
+     * @return EventLog[]
+     * @throws InvalidArgumentException
+     */
+    public function getFilteredAfterId(int $lastId, int $limit, ?FilterRequestInterface $filter = null): array
+    {
+        $queryBuilder = $this->createQueryBuilder('e')
+            ->andWhere('e.id > :lastId')
+            ->setParameter('lastId', $lastId)
+            ->orderBy('e.id', 'ASC')
+            ->setMaxResults($limit);
+
+        if ($filter === null) {
+            return $queryBuilder->getQuery()->getResult();
+        }
+
+        if (!$filter instanceof EventLogFilter) {
+            throw new InvalidArgumentException('Expected EventLogFilter.');
+        }
+
+        if ($filter->getPage() !== null) {
+            $queryBuilder->andWhere('e.page = :page')->setParameter('page', $filter->getPage());
+        }
+        if ($filter->getDateFrom() !== null) {
+            $queryBuilder->andWhere('e.entered >= :dateFrom')->setParameter('dateFrom', $filter->getDateFrom());
+        }
+        if ($filter->getDateTo() !== null) {
+            $queryBuilder->andWhere('e.entered <= :dateTo')->setParameter('dateTo', $filter->getDateTo());
+        }
+
+        return $queryBuilder->getQuery()->getResult();
+    }
 }
diff --git a/src/Domain/Configuration/Repository/I18nRepository.php b/src/Domain/Configuration/Repository/I18nRepository.php
index f4465103..33fa599a 100644
--- a/src/Domain/Configuration/Repository/I18nRepository.php
+++ b/src/Domain/Configuration/Repository/I18nRepository.php
@@ -6,6 +6,7 @@
 
 use PhpList\Core\Domain\Common\Repository\AbstractRepository;
 
+/** @deprecated */
 class I18nRepository extends AbstractRepository
 {
 }
diff --git a/src/Domain/Configuration/Service/LegacyUrlBuilder.php b/src/Domain/Configuration/Service/LegacyUrlBuilder.php
new file mode 100644
index 00000000..4bc6366f
--- /dev/null
+++ b/src/Domain/Configuration/Service/LegacyUrlBuilder.php
@@ -0,0 +1,29 @@
+getKey());
         }
         $config->setValue($value);
-
-        $this->configRepository->save($config);
     }
 
     public function create(string $key, string $value, bool $editable, ?string $type = null): void
@@ -57,7 +55,7 @@ public function create(string $key, string $value, bool $editable, ?string $type
             ->setEditable($editable)
             ->setType($type);
 
-        $this->configRepository->save($config);
+        $this->configRepository->persist($config);
     }
 
     public function delete(Config $config): void
diff --git a/src/Domain/Configuration/Service/Manager/EventLogManager.php b/src/Domain/Configuration/Service/Manager/EventLogManager.php
new file mode 100644
index 00000000..d896a8f1
--- /dev/null
+++ b/src/Domain/Configuration/Service/Manager/EventLogManager.php
@@ -0,0 +1,55 @@
+repository = $repository;
+    }
+
+    public function log(string $page, string $entry): EventLog
+    {
+        $log = (new EventLog())
+            ->setEntered(new DateTimeImmutable())
+            ->setPage($page)
+            ->setEntry($entry);
+
+        $this->repository->save($log);
+
+        return $log;
+    }
+
+    /**
+     * Get event logs with optional filters (page and date range) and cursor pagination.
+     *
+     * @return EventLog[]
+     */
+    public function get(
+        int $lastId = 0,
+        int $limit = 50,
+        ?string $page = null,
+        ?DateTimeInterface $dateFrom = null,
+        ?DateTimeInterface $dateTo = null
+    ): array {
+        $filter = new EventLogFilter($page, $dateFrom, $dateTo);
+
+        return $this->repository->getFilteredAfterId($lastId, $limit, $filter);
+    }
+
+    public function delete(EventLog $log): void
+    {
+        $this->repository->remove($log);
+    }
+}
diff --git a/src/Domain/Configuration/Service/PlaceholderResolver.php b/src/Domain/Configuration/Service/PlaceholderResolver.php
new file mode 100644
index 00000000..3a0a3464
--- /dev/null
+++ b/src/Domain/Configuration/Service/PlaceholderResolver.php
@@ -0,0 +1,33 @@
+ */
+    private array $providers = [];
+
+    public function register(string $token, callable $provider): void
+    {
+        // tokens like [UNSUBSCRIBEURL] (case-insensitive)
+        $this->providers[strtoupper($token)] = $provider;
+    }
+
+    public function resolve(?string $input): ?string
+    {
+        if ($input === null || $input === '') {
+            return $input;
+        }
+
+        // Replace [TOKEN] (case-insensitive)
+        return preg_replace_callback('/\[(\w+)\]/i', function ($map) {
+            $key = strtoupper($map[1]);
+            if (!isset($this->providers[$key])) {
+                return $map[0];
+            }
+            return (string) ($this->providers[$key])();
+        }, $input);
+    }
+}
diff --git a/src/Domain/Configuration/Service/Provider/ConfigProvider.php b/src/Domain/Configuration/Service/Provider/ConfigProvider.php
new file mode 100644
index 00000000..a1db70fc
--- /dev/null
+++ b/src/Domain/Configuration/Service/Provider/ConfigProvider.php
@@ -0,0 +1,85 @@
+booleanValues)) {
+            throw new InvalidArgumentException('Invalid boolean value key');
+        }
+        $config = $this->configRepository->findOneBy(['item' => $key->value]);
+
+        if ($config !== null) {
+            return $config->getValue() === '1';
+        }
+
+        return $this->defaultConfigs->has($key->value) && $this->defaultConfigs->get($key->value)['value'] === '1';
+    }
+
+    /**
+     * Get configuration value by its key, from settings or default configs or default value (if provided)
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     * @throws InvalidArgumentException
+     */
+    public function getValue(ConfigOption $key): ?string
+    {
+        if (in_array($key, $this->booleanValues)) {
+            throw new InvalidArgumentException('Key is a boolean value, use isEnabled instead');
+        }
+        $cacheKey = 'cfg:' . $key->value;
+        $value = $this->cache->get($cacheKey);
+        if ($value === null) {
+            $value = $this->configRepository->findValueByItem($key->value);
+            $this->cache->set($cacheKey, $value, $this->ttlSeconds);
+        }
+
+        if ($value !== null) {
+            return $value;
+        }
+
+        return $this->defaultConfigs->has($key->value) ? $this->defaultConfigs->get($key->value)['value'] : null;
+    }
+
+    /** @SuppressWarnings(PHPMD.StaticAccess) */
+    public function getValueWithNamespace(ConfigOption $key): ?string
+    {
+        $full = $this->getValue($key);
+        if ($full !== null && $full !== '') {
+            return $full;
+        }
+
+        if (str_contains($key->value, ':')) {
+            [$parent] = explode(':', $key->value, 2);
+            $parentKey = ConfigOption::from($parent);
+
+            return $this->getValue($parentKey);
+        }
+
+        return null;
+    }
+}
diff --git a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php
new file mode 100644
index 00000000..bbe14a46
--- /dev/null
+++ b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php
@@ -0,0 +1,587 @@
+defaults)) {
+            return;
+        }
+
+        $publicSchema = 'http';
+        $pageRoot = '/api/v2';
+
+        $this->defaults = [
+            'admin_address' => [
+                'value'       => 'webmaster@[DOMAIN]',
+                'description' => $this->translator->trans('Person in charge of this system (one email address)'),
+                'type'        => 'email',
+                'allowempty'  => false,
+                'category'    => 'general',
+            ],
+            'organisation_name' => [
+                'value'       => '',
+                'description' => $this->translator->trans('Name of the organisation'),
+                'type'        => 'text',
+                'allowempty'  => true,
+                'allowtags'   => '',
+                'allowJS'     => false,
+                'category'    => 'general',
+            ],
+            'organisation_logo' => [
+                'value'       => '',
+                'description' => $this->translator->trans('Logo of the organisation'),
+                'infoicon'    => true,
+                'type'        => 'image',
+                'allowempty'  => true,
+                'category'    => 'general',
+            ],
+            'date_format' => [
+                'value'       => 'j F Y',
+                'description' => $this->translator->trans('Date format'),
+                'infoicon'    => true,
+                'type'        => 'text',
+                'allowempty'  => false,
+                'category'    => 'general',
+            ],
+            'rc_notification' => [
+                'value'       => 0,
+                'description' => $this->translator->trans('Show notification for Release Candidates'),
+                'type'        => 'boolean',
+                'allowempty'  => true,
+                'category'    => 'security',
+            ],
+            'remote_processing_secret' => [
+                'value'       => bin2hex(random_bytes(10)),
+                'description' => $this->translator->trans('Secret for remote processing'),
+                'type'        => 'text',
+                'category'    => 'security',
+            ],
+            'notify_admin_login' => [
+                'value'       => 1,
+                'description' => $this->translator->trans('Notify admin on login from new location'),
+                'type'        => 'boolean',
+                'category'    => 'security',
+                'allowempty'  => true,
+            ],
+            'admin_addresses' => [
+                'value'       => '',
+                'description' => $this->translator->trans(
+                    'List of email addresses to CC in system messages (separate by commas)'
+                ),
+                'type'        => 'emaillist',
+                'allowempty'  => true,
+                'category'    => 'reporting',
+            ],
+            'campaignfrom_default' => [
+                'value'       => '',
+                'description' => $this->translator->trans("Default for 'From:' in a campaign"),
+                'type'        => 'text',
+                'allowempty'  => true,
+                'category'    => 'campaign',
+            ],
+            'notifystart_default' => [
+                'value'       => '',
+                'description' => $this->translator->trans("Default for 'address to alert when sending starts'"),
+                'type'        => 'email',
+                'allowempty'  => true,
+                'category'    => 'campaign',
+            ],
+            'notifyend_default' => [
+                'value'       => '',
+                'description' => $this->translator->trans("Default for 'address to alert when sending finishes'"),
+                'type'        => 'email',
+                'allowempty'  => true,
+                'category'    => 'campaign',
+            ],
+            'always_add_googletracking' => [
+                'value'       => '0',
+                'description' => $this->translator->trans('Always add analytics tracking code to campaigns'),
+                'type'        => 'boolean',
+                'allowempty'  => true,
+                'category'    => 'campaign',
+            ],
+            'analytic_tracker' => [
+                'values'       => ['google' => 'Google Analytics', 'matomo' => 'Matomo'],
+                'value'        => 'google',
+                'description'  => $this->translator->trans('Analytics tracking code to add to campaign URLs'),
+                'type'         => 'select',
+                'allowempty'   => false,
+                'category'     => 'campaign',
+            ],
+            'report_address' => [
+                'value'       => 'listreports@[DOMAIN]',
+                'description' => $this->translator->trans(
+                    'Who gets the reports (email address, separate multiple emails with a comma)'
+                ),
+                'type'        => 'emaillist',
+                'allowempty'  => true,
+                'category'    => 'reporting',
+            ],
+            'message_from_address' => [
+                'value'       => 'noreply@[DOMAIN]',
+                'description' => $this->translator->trans('From email address for system messages'),
+                'type'        => 'email',
+                'allowempty'  => 0,
+                'category'    => 'transactional',
+            ],
+            'message_from_name' => [
+                'value'       => $this->translator->trans('Webmaster'),
+                'description' => $this->translator->trans('Name for system messages'),
+                'type'        => 'text',
+                'allowempty'  => 0,
+                'category'    => 'transactional',
+            ],
+            'message_replyto_address' => [
+                'value'       => 'noreply@[DOMAIN]',
+                'description' => $this->translator->trans('Reply-to email address for system messages'),
+                'type'        => 'email',
+                'allowempty'  => 0,
+                'category'    => 'transactional',
+            ],
+            'hide_single_list' => [
+                'value'       => '1',
+                'description' => $this->translator->trans('If there is only one visible list, should it be hidden in the page and automatically subscribe users who sign up'),
+                'type'        => 'boolean',
+                'allowempty'  => true,
+                'category'    => 'subscription-ui',
+            ],
+            'list_categories' => [
+                'value'       => '',
+                'description' => $this->translator->trans('Categories for lists. Separate with commas.'),
+                'infoicon'    => true,
+                'type'        => 'text',
+                'allowempty'  => true,
+                'category'    => 'list-organisation',
+            ],
+            'displaycategories' => [
+                'value'       => 0,
+                'description' => $this->translator->trans('Display list categories on subscribe page'),
+                'type'        => 'boolean',
+                'allowempty'  => false,
+                'category'    => 'list-organisation',
+            ],
+            'textline_width' => [
+                'value'       => '40',
+                'description' => $this->translator->trans('Width of a textline field (numerical)'),
+                'type'        => 'integer',
+                'min'         => 20,
+                'max'         => 150,
+                'category'    => 'subscription-ui',
+            ],
+            'textarea_dimensions' => [
+                'value'       => '10,40',
+                'description' => $this->translator->trans('Dimensions of a textarea field (rows,columns)'),
+                'type'        => 'text',
+                'allowempty'  => 0,
+                'category'    => 'subscription-ui',
+            ],
+            'send_admin_copies' => [
+                'value'       => '0',
+                'description' => $this->translator->trans('Send notifications about subscribe, update and unsubscribe'),
+                'type'        => 'boolean',
+                'allowempty'  => true,
+                'category'    => 'reporting',
+            ],
+            'defaultsubscribepage' => [
+                'value'       => 1,
+                'description' => $this->translator->trans('The default subscribe page when there are multiple'),
+                'type'        => 'integer',
+                'min'         => 1,
+                'max'         => 999,
+                'allowempty'  => true,
+                'category'    => 'subscription',
+            ],
+            'defaultmessagetemplate' => [
+                'value'       => 0,
+                'description' => $this->translator->trans('The default HTML template to use when sending a message'),
+                'type'        => 'text',
+                'allowempty'  => true,
+                'category'    => 'campaign',
+            ],
+            'systemmessagetemplate' => [
+                'value'       => 0,
+                'description' => $this->translator->trans('The HTML wrapper template for system messages'),
+                'type'        => 'integer',
+                'min'         => 0,
+                'max'         => 999,
+                'allowempty'  => true,
+                'category'    => 'transactional',
+            ],
+            'subscribeurl' => [
+                'value'       => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=subscribe',
+                'description' => $this->translator->trans('URL where subscribers can sign up'),
+                'type'        => 'url',
+                'allowempty'  => 0,
+                'category'    => 'subscription',
+            ],
+            'unsubscribeurl' => [
+                'value'       => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=unsubscribe',
+                'description' => $this->translator->trans('URL where subscribers can unsubscribe'),
+                'type'        => 'url',
+                'allowempty'  => 0,
+                'category'    => 'subscription',
+            ],
+            'blacklisturl' => [
+                'value'       => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=donotsend',
+                'description' => $this->translator->trans('URL where unknown users can unsubscribe (do-not-send-list)'),
+                'type'        => 'url',
+                'allowempty'  => 0,
+                'category'    => 'subscription',
+            ],
+            'confirmationurl' => [
+                'value'       => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=confirm',
+                'description' => $this->translator->trans('URL where subscribers have to confirm their subscription'),
+                'type'        => 'text',
+                'allowempty'  => 0,
+                'category'    => 'subscription',
+            ],
+            'preferencesurl' => [
+                'value'       => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=preferences',
+                'description' => $this->translator->trans('URL where subscribers can update their details'),
+                'type'        => 'text',
+                'allowempty'  => 0,
+                'category'    => 'subscription',
+            ],
+            'forwardurl' => [
+                'value'       => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=forward',
+                'description' => $this->translator->trans('URL for forwarding messages'),
+                'type'        => 'text',
+                'allowempty'  => 0,
+                'category'    => 'subscription',
+            ],
+            'vcardurl' => [
+                'value'       => $publicSchema . '://[WEBSITE]' . $pageRoot . '/?p=vcard',
+                'description' => $this->translator->trans('URL for downloading vcf card'),
+                'type'        => 'text',
+                'allowempty'  => 0,
+                'category'    => 'subscription',
+            ],
+            'ajax_subscribeconfirmation' => [
+                'value'       => $this->translator->trans('Thanks, you have been added to our newsletter  You will receive an email to confirm your subscription. Please click the link in the email to confirm
'),
+                'description' => $this->translator->trans('Text to display when subscription with an AJAX request was successful'),
+                'type'        => 'textarea',
+                'allowempty'  => true,
+                'category'    => 'subscription',
+            ],
+            'subscribesubject' => [
+                'value'       => $this->translator->trans('Request for confirmation'),
+                'description' => $this->translator->trans(
+                    'Subject of the message subscribers receive when they sign up'
+                ),
+                'infoicon'        => true,
+                'type'        => 'text',
+                'allowempty'  => 0,
+                'category'    => 'transactional',
+            ],
+            'subscribemessage' => [
+                'value' => ' You have been subscribed to the following newsletters:
+
+[LISTS]
+
+
+Please click the following link to confirm it\'s really you:
+
+[CONFIRMATIONURL]
+
+
+In order to provide you with this service we\'ll need to
+
+Transfer your contact information to [DOMAIN]
+Store your contact information in your [DOMAIN] account
+Send you emails from [DOMAIN]
+Track your interactions with these emails for marketing purposes
+
+If this is not correct, or you do not agree, simply take no action and delete this message.'
+            ,
+                'description' => $this->translator->trans('Message subscribers receive when they sign up'),
+                'type'        => 'textarea',
+                'allowempty'  => 0,
+                'category'    => 'transactional',
+            ],
+            'unsubscribesubject' => [
+                'value'       => $this->translator->trans('Goodbye from our Newsletter'),
+                'description' => $this->translator->trans(
+                    'Subject of the message subscribers receive when they unsubscribe'
+                ),
+                'type'        => 'text',
+                'allowempty'  => 0,
+                'category'    => 'transactional',
+            ],
+            'unsubscribemessage' => [
+                'value' => 'Goodbye from our Newsletter, sorry to see you go.
+
+You have been unsubscribed from our newsletters.
+
+This is the last email you will receive from us. Our newsletter system, phpList,
+will refuse to send you any further messages, without manual intervention by our administrator.
+
+If there is an error in this information, you can re-subscribe:
+please go to [SUBSCRIBEURL] and follow the steps.
+
+Thank you'
+            ,
+                'description' => $this->translator->trans('Message subscribers receive when they unsubscribe'),
+                'type'        => 'textarea',
+                'allowempty'  => 0,
+                'category'    => 'transactional',
+            ],
+            'confirmationsubject' => [
+                'value'       => $this->translator->trans('Welcome to our Newsletter'),
+                'description' => $this->translator->trans(
+                    'Subject of the message subscribers receive after confirming their email address'
+                ),
+                'type'        => 'text',
+                'allowempty'  => 0,
+                'category'    => 'transactional',
+            ],
+            'confirmationmessage' => [
+                'value' => 'Welcome to our Newsletter
+
+Please keep this message for later reference.
+
+Your email address has been added to the following newsletter(s):
+[LISTS]
+
+To update your details and preferences please go to [PREFERENCESURL].
+If you do not want to receive any more messages, please go to [UNSUBSCRIBEURL].
+
+Thank you'
+            ,
+                'description' => $this->translator->trans(
+                    'Message subscribers receive after confirming their email address'
+                ),
+                'type'        => 'textarea',
+                'allowempty'  => 0,
+                'category'    => 'transactional',
+            ],
+            'updatesubject' => [
+                'value'       => $this->translator->trans('[notify] Change of List-Membership details'),
+                'description' => $this->translator->trans(
+                    'Subject of the message subscribers receive when they have changed their details'
+                ),
+                'type'        => 'text',
+                'allowempty'  => 0,
+                'category'    => 'transactional',
+            ],
+            // the message that is sent when a user updates their information.
+            // just to make sure they approve of it.
+            // confirmationinfo is replaced by one of the options below
+            // userdata is replaced by the information in the database
+            'updatemessage' => [
+                'value' => 'This message is to inform you of a change of your details on our newsletter database
+
+You are currently member of the following newsletters:
+
+[LISTS]
+
+[CONFIRMATIONINFO]
+
+The information on our system for you is as follows:
+
+[USERDATA]
+
+If this is not correct, please update your information at the following location:
+
+[PREFERENCESURL]
+
+Thank you'
+            ,
+                'description' => $this->translator->trans(
+                    'Message subscribers receive when they have changed their details'
+                ),
+                'type'        => 'textarea',
+                'allowempty'  => 0,
+                'category'    => 'transactional',
+            ],
+            // this is the text that is placed in the [!-- confirmation --] location of the above
+            // message, in case the email is sent to their new email address and they have changed
+            // their email address
+            'emailchanged_text' => [
+                'value' => '
+  When updating your details, your email address has changed.
+  Please confirm your new email address by visiting this webpage:
+
+  [CONFIRMATIONURL]
+
+  ',
+                'description' => $this->translator->trans('Part of the message that is sent to their new email address when subscribers change their information, and the email address has changed'),
+                'type'        => 'textarea',
+                'allowempty'  => 0,
+                'category'    => 'transactional',
+            ],
+            // this is the text that is placed in the [!-- confirmation --] location of the above
+            // message, in case the email is sent to their old email address and they have changed
+            // their email address
+            'emailchanged_text_oldaddress' => [
+                'value' => 'Please Note: when updating your details, your email address has changed.
+
+A message has been sent to your new email address with a URL
+to confirm this change. Please visit this website to activate
+your membership.'
+            ,
+                'description' => $this->translator->trans('Part of the message that is sent to their old email address when subscribers change their information, and the email address has changed'),
+                'type'        => 'textarea',
+                'allowempty'  => 0,
+                'category'    => 'transactional',
+            ],
+            'personallocation_subject' => [
+                'value'       => $this->translator->trans('Your personal location'),
+                'description' => $this->translator->trans(
+                    'Subject of message when subscribers request their personal location'
+                ),
+                'type'        => 'text',
+                'allowempty'  => 0,
+                'category'    => 'transactional',
+            ],
+            'messagefooter' => [
+                'value' => '--
+
+    
+
+  ',
+                'description' => $this->translator->trans('Default footer for sending a campaign'),
+                'type'        => 'textarea',
+                'allowempty'  => 0,
+                'category'    => 'campaign',
+            ],
+            'forwardfooter' => [
+                'value' => '
+     
+  ',
+                'description' => $this->translator->trans('Footer used when a message has been forwarded'),
+                'type'        => 'textarea',
+                'allowempty'  => 0,
+                'category'    => 'campaign',
+            ],
+            'personallocation_message' => [
+                'value' => 'You have requested your personal location to update your details from our website.
+The location is below. Please make sure that you use the full line as mentioned below.
+Sometimes email programmes can wrap the line into multiple lines.
+
+Your personal location is:
+[PREFERENCESURL]
+
+Thank you.'
+            ,
+                'description' => $this->translator->trans('Message to send when they request their personal location'),
+                'type'        => 'textarea',
+                'allowempty'  => 0,
+                'category'    => 'transactional',
+            ],
+            'remoteurl_append' => [
+                'value'       => '',
+                'description' => $this->translator->trans(
+                    'String to always append to remote URL when using send-a-webpage'
+                ),
+                'type'        => 'text',
+                'allowempty'  => true,
+                'category'    => 'campaign',
+            ],
+            'wordwrap' => [
+                'value'       => '75',
+                'description' => $this->translator->trans('Width for Wordwrap of Text messages'),
+                'type'        => 'text',
+                'allowempty'  => true,
+                'category'    => 'campaign',
+            ],
+            'html_email_style' => [
+                'value'       => '',
+                'description' => $this->translator->trans('CSS for HTML messages without a template'),
+                'type'        => 'textarea',
+                'allowempty'  => true,
+                'category'    => 'campaign',
+            ],
+            'alwayssendtextto' => [
+                'value'       => '',
+                'description' => $this->translator->trans('Domains that only accept text emails, one per line'),
+                'type'        => 'textarea',
+                'allowempty'  => true,
+                'category'    => 'campaign',
+            ],
+            'tld_last_sync' => [
+                'value'       => '0',
+                'description' => $this->translator->trans('last time TLDs were fetched'),
+                'type'        => 'text',
+                'allowempty'  => true,
+                'category'    => 'system',
+                'hidden'      => true,
+            ],
+            'internet_tlds' => [
+                'value'       => '',
+                'description' => $this->translator->trans('Top level domains'),
+                'type'        => 'textarea',
+                'allowempty'  => true,
+                'category'    => 'system',
+                'hidden'      => true,
+            ],
+            'pageheader' => [
+                'value'       => 'Welcome ',
+                'description' => $this->translator->trans('Header of public pages.'),
+                'type'        => 'textarea',
+                'allowempty'  => 0,
+                'category'    => 'subscription-ui',
+            ],
+            'pagefooter' => [
+                'value'       => 'Footer text
',
+                'description' => $this->translator->trans('Footer of public pages'),
+                'type'        => 'textarea',
+                'allowempty'  => 0,
+                'category'    => 'subscription-ui',
+            ],
+        ];
+    }
+
+    /**
+     * Get a single default config item by key
+     *
+     * @param string $key
+     * @param mixed|null $default
+     * @return mixed
+     */
+    public function get(string $key, mixed $default = null): mixed
+    {
+        $this->init();
+
+        return $this->defaults[$key] ?? $default;
+    }
+
+    /**
+     * Check if a config key exists
+     */
+    public function has(string $key): bool
+    {
+        $this->init();
+
+        return isset($this->defaults[$key]);
+    }
+}
diff --git a/src/Domain/Configuration/Service/UserPersonalizer.php b/src/Domain/Configuration/Service/UserPersonalizer.php
new file mode 100644
index 00000000..7aedf1d8
--- /dev/null
+++ b/src/Domain/Configuration/Service/UserPersonalizer.php
@@ -0,0 +1,69 @@
+subscriberRepository->findOneByEmail($email);
+        if (!$user) {
+            return $value;
+        }
+
+        $resolver = new PlaceholderResolver();
+        $resolver->register('EMAIL', fn() => $user->getEmail());
+
+        $resolver->register('UNSUBSCRIBEURL', function () use ($user) {
+            $base = $this->config->getValue(ConfigOption::UnsubscribeUrl) ?? '';
+            return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE;
+        });
+
+        $resolver->register('CONFIRMATIONURL', function () use ($user) {
+            $base = $this->config->getValue(ConfigOption::ConfirmationUrl) ?? '';
+            return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE;
+        });
+        $resolver->register('PREFERENCESURL', function () use ($user) {
+            $base = $this->config->getValue(ConfigOption::PreferencesUrl) ?? '';
+            return $this->urlBuilder->withUid($base, $user->getUniqueId()) . self::PHP_SPACE;
+        });
+
+        $resolver->register(
+            'SUBSCRIBEURL',
+            fn() => ($this->config->getValue(ConfigOption::SubscribeUrl) ?? '') . self::PHP_SPACE
+        );
+        $resolver->register('DOMAIN', fn() => $this->config->getValue(ConfigOption::Domain) ?? '');
+        $resolver->register('WEBSITE', fn() => $this->config->getValue(ConfigOption::Website) ?? '');
+
+        $userAttributes = $this->attributesRepository->getForSubscriber($user);
+        foreach ($userAttributes as $userAttribute) {
+            $resolver->register(
+                strtoupper($userAttribute->getAttributeDefinition()->getName()),
+                fn() => $this->attributeValueResolver->resolve($userAttribute)
+            );
+        }
+
+        $out = $resolver->resolve($value);
+
+        return (string) $out;
+    }
+}
diff --git a/src/Domain/Identity/Command/CleanUpOldSessionTokens.php b/src/Domain/Identity/Command/CleanUpOldSessionTokens.php
index 348ea025..981a5210 100644
--- a/src/Domain/Identity/Command/CleanUpOldSessionTokens.php
+++ b/src/Domain/Identity/Command/CleanUpOldSessionTokens.php
@@ -4,6 +4,7 @@
 
 namespace PhpList\Core\Domain\Identity\Command;
 
+use Doctrine\ORM\EntityManagerInterface;
 use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository;
 use Symfony\Component\Console\Attribute\AsCommand;
 use Symfony\Component\Console\Command\Command;
@@ -18,23 +19,33 @@
 class CleanUpOldSessionTokens extends Command
 {
     private AdministratorTokenRepository $tokenRepository;
+    private EntityManagerInterface $entityManager;
 
-    public function __construct(AdministratorTokenRepository $tokenRepository)
+    public function __construct(AdministratorTokenRepository $tokenRepository, EntityManagerInterface $entityManager)
     {
         parent::__construct();
         $this->tokenRepository = $tokenRepository;
+        $this->entityManager = $entityManager;
     }
 
-    /**
-     * @SuppressWarnings("PHPMD.UnusedFormalParameter")
-     */
     protected function execute(InputInterface $input, OutputInterface $output): int
     {
         try {
-            $deletedCount = $this->tokenRepository->removeExpired();
+            $expiredTokens = $this->tokenRepository->getExpired();
+
+            $deletedCount = 0;
+
+            foreach ($expiredTokens as $token) {
+                $this->entityManager->remove($token);
+                $deletedCount++;
+            }
+
+            $this->entityManager->flush();
+
             $output->writeln(sprintf('Successfully removed %d expired session token(s).', $deletedCount));
         } catch (Throwable $throwable) {
             $output->writeln(sprintf('Error removing expired session tokens: %s', $throwable->getMessage()));
+
             return Command::FAILURE;
         }
 
diff --git a/src/Domain/Identity/Model/AdminLogin.php b/src/Domain/Identity/Model/AdminLogin.php
index b7c30e58..91be3331 100644
--- a/src/Domain/Identity/Model/AdminLogin.php
+++ b/src/Domain/Identity/Model/AdminLogin.php
@@ -37,7 +37,7 @@ class AdminLogin implements DomainModel, Identity
     #[ORM\Column(name: 'sessionid', type: 'string', length: 50)]
     private string $sessionId;
 
-    #[ORM\Column(name: 'active', type: 'boolean')]
+    #[ORM\Column(name: 'active', type: 'boolean', nullable: false)]
     private bool $active = false;
 
     public function __construct(
diff --git a/src/Domain/Identity/Model/AdminPasswordRequest.php b/src/Domain/Identity/Model/AdminPasswordRequest.php
index c06a45eb..0d761adf 100644
--- a/src/Domain/Identity/Model/AdminPasswordRequest.php
+++ b/src/Domain/Identity/Model/AdminPasswordRequest.php
@@ -12,7 +12,6 @@
 
 #[ORM\Entity(repositoryClass: AdminPasswordRequestRepository::class)]
 #[ORM\Table(name: 'phplist_admin_password_request')]
-#[ORM\HasLifecycleCallbacks]
 class AdminPasswordRequest implements DomainModel, Identity
 {
     #[ORM\Id]
diff --git a/src/Domain/Identity/Model/Administrator.php b/src/Domain/Identity/Model/Administrator.php
index 34cfc9e8..09ba46e0 100644
--- a/src/Domain/Identity/Model/Administrator.php
+++ b/src/Domain/Identity/Model/Administrator.php
@@ -11,6 +11,7 @@
 use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
 use PhpList\Core\Domain\Common\Model\Interfaces\Identity;
 use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate;
+use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface;
 use PhpList\Core\Domain\Identity\Repository\AdministratorRepository;
 
 /**
@@ -22,6 +23,7 @@
  */
 #[ORM\Entity(repositoryClass: AdministratorRepository::class)]
 #[ORM\Table(name: 'phplist_admin')]
+#[ORM\UniqueConstraint(name: 'phplist_admin_loginnameidx', columns: ['loginname'])]
 #[ORM\HasLifecycleCallbacks]
 class Administrator implements DomainModel, Identity, CreationDate, ModificationDate
 {
@@ -30,50 +32,59 @@ class Administrator implements DomainModel, Identity, CreationDate, Modification
     #[ORM\GeneratedValue]
     private ?int $id = null;
 
-    #[ORM\Column(name: 'created', type: 'datetime')]
-    protected DateTime $createdAt;
+    #[ORM\Column(name: 'created', type: 'datetime', nullable: false)]
+    protected ?DateTime $createdAt = null;
 
-    #[ORM\Column(name: 'modified', type: 'datetime')]
-    private ?DateTime $updatedAt;
+    #[ORM\Column(name: 'modified', type: 'datetime', nullable: false)]
+    private DateTime $updatedAt;
 
-    #[ORM\Column(name: 'loginname')]
+    #[ORM\Column(name: 'loginname', type: 'string', length: 66, nullable: false)]
     private string $loginName;
 
-    #[ORM\Column(name: 'namelc', nullable: true)]
-    private string $namelc;
+    #[ORM\Column(name: 'namelc', type: 'string', length: 255, nullable: true)]
+    private ?string $namelc = null;
 
-    #[ORM\Column(name: 'email')]
+    #[ORM\Column(name: 'email', type: 'string', length: 255, nullable: false)]
     private string $email;
 
     #[ORM\Column(name: 'modifiedby', type: 'string', length: 66, nullable: true)]
-    private ?string $modifiedBy;
+    private ?string $modifiedBy = null;
 
-    #[ORM\Column(name: 'password')]
-    private string $passwordHash;
+    #[ORM\Column(name: 'password', type: 'string', length: 255, nullable: true)]
+    private ?string $passwordHash = null;
 
     #[ORM\Column(name: 'passwordchanged', type: 'date', nullable: true)]
-    private ?DateTime $passwordChangeDate;
+    private ?DateTime $passwordChangeDate = null;
 
-    #[ORM\Column(type: 'boolean')]
-    private bool $disabled;
+    #[ORM\Column(name: 'disabled', type: 'boolean', nullable: false)]
+    private bool $disabled = false;
 
-    #[ORM\Column(name: 'superuser', type: 'boolean')]
-    private bool $superUser;
+    #[ORM\Column(name: 'superuser', type: 'boolean', nullable: false)]
+    private bool $superUser = false;
 
     #[ORM\Column(name: 'privileges', type: 'text', nullable: true)]
-    private ?string $privileges;
+    private ?string $privileges = null;
 
     public function __construct()
     {
-        $this->disabled = false;
-        $this->superUser = false;
-        $this->passwordChangeDate = null;
-        $this->loginName = '';
-        $this->passwordHash = '';
         $this->createdAt = new DateTime();
-        $this->updatedAt = null;
+        $this->updatedAt = new DateTime();
         $this->email = '';
-        $this->privileges = null;
+    }
+
+    public function getId(): ?int
+    {
+        return $this->id;
+    }
+
+    public function getCreatedAt(): ?DateTime
+    {
+        return $this->createdAt;
+    }
+
+    public function getUpdatedAt(): DateTime
+    {
+        return $this->updatedAt;
     }
 
     public function getLoginName(): string
@@ -84,7 +95,6 @@ public function getLoginName(): string
     public function setLoginName(string $loginName): self
     {
         $this->loginName = $loginName;
-
         return $this;
     }
 
@@ -96,20 +106,29 @@ public function getEmail(): string
     public function setEmail(string $email): self
     {
         $this->email = $email;
+        return $this;
+    }
 
+    public function getNameLc(): ?string
+    {
+        return $this->namelc;
+    }
+
+    public function setNameLc(?string $nameLc): self
+    {
+        $this->namelc = $nameLc;
         return $this;
     }
 
-    public function getPasswordHash(): string
+    public function getPasswordHash(): ?string
     {
         return $this->passwordHash;
     }
 
-    public function setPasswordHash(string $passwordHash): self
+    public function setPasswordHash(?string $passwordHash): self
     {
         $this->passwordHash = $passwordHash;
-        $this->passwordChangeDate = new DateTime();
-
+        $this->passwordChangeDate = $passwordHash !== null ? new DateTime() : null;
         return $this;
     }
 
@@ -126,7 +145,6 @@ public function isDisabled(): bool
     public function setDisabled(bool $disabled): self
     {
         $this->disabled = $disabled;
-
         return $this;
     }
 
@@ -138,32 +156,19 @@ public function isSuperUser(): bool
     public function setSuperUser(bool $superUser): self
     {
         $this->superUser = $superUser;
-
         return $this;
     }
 
-    public function setNameLc(string $nameLc): self
-    {
-        $this->namelc = $nameLc;
-
-        return $this;
-    }
-
-    public function getNameLc(): string
-    {
-        return $this->namelc;
-    }
-
     public function setPrivileges(Privileges $privileges): self
     {
         $this->privileges = $privileges->toSerialized();
-
         return $this;
     }
 
     /**
-     * @SuppressWarnings(PHPMD.StaticAccess)
      * @throws InvalidArgumentException
+     *
+     * @SuppressWarnings(PHPMD.StaticAccess)
      */
     public function setPrivilegesFromArray(array $privilegesData): void
     {
@@ -171,54 +176,42 @@ public function setPrivilegesFromArray(array $privilegesData): void
         foreach ($privilegesData as $key => $value) {
             $flag = PrivilegeFlag::tryFrom($key);
             if (!$flag) {
-                throw new InvalidArgumentException('Unknown privilege key: '. $key);
+                throw new InvalidArgumentException('Unknown privilege key: ' . $key);
             }
-
             $privileges = $value ? $privileges->grant($flag) : $privileges->revoke($flag);
         }
         $this->setPrivileges($privileges);
     }
 
-    /**
-     * @SuppressWarnings(PHPMD.StaticAccess)
-     */
+    /** @SuppressWarnings(PHPMD.StaticAccess) */
     public function getPrivileges(): Privileges
     {
         return Privileges::fromSerialized($this->privileges);
     }
 
-    public function getCreatedAt(): ?DateTime
+    public function setModifiedBy(?string $modifiedBy): self
     {
-        return $this->createdAt;
+        $this->modifiedBy = $modifiedBy;
+        return $this;
     }
 
-    public function getId(): ?int
+    public function getModifiedBy(): ?string
     {
-        return $this->id;
+        return $this->modifiedBy;
     }
 
-    public function getUpdatedAt(): ?DateTime
+    public function owns(OwnableInterface $resource): bool
     {
-        return $this->updatedAt;
+        if ($this->getId() === null) {
+            return false;
+        }
+
+        return $resource->getOwner()->getId() === $this->getId();
     }
 
-    #[ORM\PrePersist]
     #[ORM\PreUpdate]
-    public function updateUpdatedAt(): DomainModel
+    public function setUpdatedAt(): void
     {
         $this->updatedAt = new DateTime();
-
-        return $this;
-    }
-
-    public function setModifiedBy(?string $modifiedBy): self
-    {
-        $this->modifiedBy = $modifiedBy;
-        return $this;
-    }
-
-    public function getModifiedBy(): ?string
-    {
-        return $this->modifiedBy;
     }
 }
diff --git a/src/Domain/Identity/Model/AdministratorToken.php b/src/Domain/Identity/Model/AdministratorToken.php
index 1a6c511f..4e37b2b5 100644
--- a/src/Domain/Identity/Model/AdministratorToken.php
+++ b/src/Domain/Identity/Model/AdministratorToken.php
@@ -7,7 +7,6 @@
 use DateTime;
 use DateTimeZone;
 use Doctrine\ORM\Mapping as ORM;
-use Doctrine\Persistence\Proxy;
 use PhpList\Core\Domain\Common\Model\Interfaces\CreationDate;
 use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
 use PhpList\Core\Domain\Common\Model\Interfaces\Identity;
@@ -44,11 +43,13 @@ class AdministratorToken implements DomainModel, Identity, CreationDate
 
     #[ORM\ManyToOne(targetEntity: Administrator::class)]
     #[ORM\JoinColumn(name: 'adminid', referencedColumnName: 'id', onDelete: 'CASCADE')]
-    private ?Administrator $administrator = null;
+    private Administrator $administrator;
 
-    public function __construct()
+    public function __construct(Administrator $administrator)
     {
-        $this->setExpiry(new DateTime());
+        $this->generateExpiry();
+        $this->generateKey();
+        $this->administrator = $administrator;
     }
 
     public function getId(): ?int
@@ -112,14 +113,8 @@ public function generateKey(): self
         return $this;
     }
 
-    public function getAdministrator(): Administrator|Proxy|null
+    public function getAdministrator(): Administrator
     {
         return $this->administrator;
     }
-
-    public function setAdministrator(Administrator $administrator): self
-    {
-        $this->administrator = $administrator;
-        return $this;
-    }
 }
diff --git a/src/Domain/Identity/Repository/AdministratorTokenRepository.php b/src/Domain/Identity/Repository/AdministratorTokenRepository.php
index fef697cd..811f3bd1 100644
--- a/src/Domain/Identity/Repository/AdministratorTokenRepository.php
+++ b/src/Domain/Identity/Repository/AdministratorTokenRepository.php
@@ -8,6 +8,7 @@
 use DateTimeImmutable;
 use DateTimeZone;
 use Doctrine\Common\Collections\Criteria;
+use Exception;
 use PhpList\Core\Domain\Common\Repository\AbstractRepository;
 use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
 use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
@@ -45,31 +46,19 @@ public function findOneUnexpiredByKey(string $key): ?AdministratorToken
     }
 
     /**
-     * Removes all expired tokens.
+     * Get all expired tokens.
      *
-     * This method should be called regularly to clean up the tokens.
-     *
-     * @return int the number of removed tokens
+     * @return AdministratorToken[]
+     * @throws Exception
      */
-    public function removeExpired(): int
+    public function getExpired(): array
     {
         $now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
 
-        $expiredTokens = $this->createQueryBuilder('at')
+        return $this->createQueryBuilder('at')
             ->where('at.expiry <= :date')
             ->setParameter('date', $now)
             ->getQuery()
             ->getResult();
-
-        $deletedCount = 0;
-
-        foreach ($expiredTokens as $token) {
-            $this->getEntityManager()->remove($token);
-            $deletedCount++;
-        }
-
-        $this->getEntityManager()->flush();
-
-        return $deletedCount;
     }
 }
diff --git a/src/Domain/Identity/Repository/UserBlacklistDataRepository.php b/src/Domain/Identity/Repository/UserBlacklistDataRepository.php
deleted file mode 100644
index 0f06722b..00000000
--- a/src/Domain/Identity/Repository/UserBlacklistDataRepository.php
+++ /dev/null
@@ -1,11 +0,0 @@
-definitionRepository = $definitionRepository;
         $this->attributeTypeValidator = $attributeTypeValidator;
+        $this->translator = $translator;
     }
 
     public function create(AdminAttributeDefinitionDto $attributeDefinitionDto): AdminAttributeDefinition
     {
         $existingAttribute = $this->definitionRepository->findOneByName($attributeDefinitionDto->name);
         if ($existingAttribute) {
-            throw new AttributeDefinitionCreationException('Attribute definition already exists', 409);
+            throw new AttributeDefinitionCreationException(
+                $this->translator->trans('Attribute definition already exists.'),
+                409
+            );
         }
         $this->attributeTypeValidator->validate($attributeDefinitionDto->type);
 
@@ -38,7 +45,7 @@ public function create(AdminAttributeDefinitionDto $attributeDefinitionDto): Adm
             ->setDefaultValue($attributeDefinitionDto->defaultValue)
             ->setTableName($attributeDefinitionDto->tableName);
 
-        $this->definitionRepository->save($attributeDefinition);
+        $this->definitionRepository->persist($attributeDefinition);
 
         return $attributeDefinition;
     }
@@ -61,8 +68,6 @@ public function update(
             ->setDefaultValue($attributeDefinitionDto->defaultValue)
             ->setTableName($attributeDefinitionDto->tableName);
 
-        $this->definitionRepository->save($attributeDefinition);
-
         return $attributeDefinition;
     }
 
diff --git a/src/Domain/Identity/Service/AdminAttributeManager.php b/src/Domain/Identity/Service/AdminAttributeManager.php
index b5cb13f5..53fa330e 100644
--- a/src/Domain/Identity/Service/AdminAttributeManager.php
+++ b/src/Domain/Identity/Service/AdminAttributeManager.php
@@ -39,7 +39,7 @@ public function createOrUpdate(
         }
 
         $adminAttribute->setValue($value);
-        $this->attributeRepository->save($adminAttribute);
+        $this->attributeRepository->persist($adminAttribute);
 
         return $adminAttribute;
     }
diff --git a/src/Domain/Identity/Service/AdministratorManager.php b/src/Domain/Identity/Service/AdministratorManager.php
index 82d3d36f..814557a1 100644
--- a/src/Domain/Identity/Service/AdministratorManager.php
+++ b/src/Domain/Identity/Service/AdministratorManager.php
@@ -32,7 +32,6 @@ public function createAdministrator(CreateAdministratorDto $dto): Administrator
         $administrator->setPrivilegesFromArray($dto->privileges);
 
         $this->entityManager->persist($administrator);
-        $this->entityManager->flush();
 
         return $administrator;
     }
@@ -53,13 +52,10 @@ public function updateAdministrator(Administrator $administrator, UpdateAdminist
             $administrator->setPasswordHash($hashedPassword);
         }
         $administrator->setPrivilegesFromArray($dto->privileges);
-
-        $this->entityManager->flush();
     }
 
     public function deleteAdministrator(Administrator $administrator): void
     {
         $this->entityManager->remove($administrator);
-        $this->entityManager->flush();
     }
 }
diff --git a/src/Domain/Identity/Service/PasswordManager.php b/src/Domain/Identity/Service/PasswordManager.php
index f6ad2a9e..35d5d1ff 100644
--- a/src/Domain/Identity/Service/PasswordManager.php
+++ b/src/Domain/Identity/Service/PasswordManager.php
@@ -13,6 +13,7 @@
 use PhpList\Core\Security\HashGenerator;
 use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
 class PasswordManager
 {
@@ -22,17 +23,20 @@ class PasswordManager
     private AdministratorRepository $administratorRepository;
     private HashGenerator $hashGenerator;
     private MessageBusInterface $messageBus;
+    private TranslatorInterface $translator;
 
     public function __construct(
         AdminPasswordRequestRepository $passwordRequestRepository,
         AdministratorRepository $administratorRepository,
         HashGenerator $hashGenerator,
-        MessageBusInterface $messageBus
+        MessageBusInterface $messageBus,
+        TranslatorInterface $translator
     ) {
         $this->passwordRequestRepository = $passwordRequestRepository;
         $this->administratorRepository = $administratorRepository;
         $this->hashGenerator = $hashGenerator;
         $this->messageBus = $messageBus;
+        $this->translator = $translator;
     }
 
     /**
@@ -47,7 +51,8 @@ public function generatePasswordResetToken(string $email): string
     {
         $administrator = $this->administratorRepository->findOneBy(['email' => $email]);
         if ($administrator === null) {
-            throw new NotFoundHttpException('Administrator not found', null, 1500567100);
+            $message = $this->translator->trans('Administrator not found');
+            throw new NotFoundHttpException($message, null, 1500567100);
         }
 
         $existingRequests = $this->passwordRequestRepository->findByAdmin($administrator);
@@ -60,7 +65,7 @@ public function generatePasswordResetToken(string $email): string
         $expiryDate = new DateTime(self::TOKEN_EXPIRY);
         $passwordRequest = new AdminPasswordRequest(date: $expiryDate, admin: $administrator, keyValue: $token);
 
-        $this->passwordRequestRepository->save($passwordRequest);
+        $this->passwordRequestRepository->persist($passwordRequest);
 
         $message = new PasswordResetMessage(email: $email, token: $token);
         $this->messageBus->dispatch($message);
@@ -108,7 +113,7 @@ public function updatePasswordWithToken(string $token, string $newPassword): boo
 
         $passwordHash = $this->hashGenerator->createPasswordHash($newPassword);
         $administrator->setPasswordHash($passwordHash);
-        $this->administratorRepository->save($administrator);
+        $this->administratorRepository->persist($administrator);
 
         $passwordRequest = $this->passwordRequestRepository->findOneByToken($token);
         $this->passwordRequestRepository->remove($passwordRequest);
diff --git a/src/Domain/Identity/Service/PermissionChecker.php b/src/Domain/Identity/Service/PermissionChecker.php
new file mode 100644
index 00000000..8fc241b7
--- /dev/null
+++ b/src/Domain/Identity/Service/PermissionChecker.php
@@ -0,0 +1,89 @@
+ PrivilegeFlag::Subscribers,
+        SubscriberList::class => PrivilegeFlag::Subscribers,
+        Message::class => PrivilegeFlag::Campaigns,
+    ];
+
+    private const OWNERSHIP_MAP = [
+        Subscriber::class => SubscriberList::class,
+        Message::class => SubscriberList::class
+    ];
+
+    public function canManage(Administrator $actor, DomainModel $resource): bool
+    {
+        if ($actor->isSuperUser()) {
+            return true;
+        }
+
+        $required = $this->resolveRequiredPrivilege($resource);
+        if ($required !== null && !$actor->getPrivileges()->has($required)) {
+            return false;
+        }
+
+        if ($resource instanceof OwnableInterface) {
+            return $actor->owns($resource);
+        }
+
+        $notRestricted = true;
+        foreach (self::OWNERSHIP_MAP as $resourceClass => $relatedClass) {
+            if ($resource instanceof $resourceClass) {
+                $related = $this->resolveRelatedEntity($resource, $relatedClass);
+                $notRestricted = $this->checkRelatedResources($related, $actor);
+            }
+        }
+
+        return $notRestricted;
+    }
+
+    private function resolveRequiredPrivilege(DomainModel $resource): ?PrivilegeFlag
+    {
+        foreach (self::REQUIRED_PRIVILEGE_MAP as $class => $flag) {
+            if ($resource instanceof $class) {
+                return $flag;
+            }
+        }
+
+        return null;
+    }
+
+    /** @return OwnableInterface[] */
+    private function resolveRelatedEntity(DomainModel $resource, string $relatedClass): array
+    {
+        if ($resource instanceof Subscriber && $relatedClass === SubscriberList::class) {
+            return $resource->getSubscribedLists()->toArray();
+        }
+
+        if ($resource instanceof Message && $relatedClass === SubscriberList::class) {
+            return $resource->getListMessages()->map(fn($lm) => $lm->getSubscriberList())->toArray();
+        }
+
+        return [];
+    }
+
+    private function checkRelatedResources(array $related, Administrator $actor): bool
+    {
+        foreach ($related as $relatedResource) {
+            if ($actor->owns($relatedResource)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+}
diff --git a/src/Domain/Identity/Service/SessionManager.php b/src/Domain/Identity/Service/SessionManager.php
index 52daafa3..cfeaa706 100644
--- a/src/Domain/Identity/Service/SessionManager.php
+++ b/src/Domain/Identity/Service/SessionManager.php
@@ -4,6 +4,8 @@
 
 namespace PhpList\Core\Domain\Identity\Service;
 
+use Symfony\Contracts\Translation\TranslatorInterface;
+use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager;
 use PhpList\Core\Domain\Identity\Model\AdministratorToken;
 use PhpList\Core\Domain\Identity\Repository\AdministratorRepository;
 use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository;
@@ -13,31 +15,40 @@ class SessionManager
 {
     private AdministratorTokenRepository $tokenRepository;
     private AdministratorRepository $administratorRepository;
+    private EventLogManager $eventLogManager;
+    private TranslatorInterface $translator;
 
     public function __construct(
         AdministratorTokenRepository $tokenRepository,
-        AdministratorRepository $administratorRepository
+        AdministratorRepository $administratorRepository,
+        EventLogManager $eventLogManager,
+        TranslatorInterface $translator
     ) {
         $this->tokenRepository = $tokenRepository;
         $this->administratorRepository = $administratorRepository;
+        $this->eventLogManager = $eventLogManager;
+        $this->translator = $translator;
     }
 
     public function createSession(string $loginName, string $password): AdministratorToken
     {
         $administrator = $this->administratorRepository->findOneByLoginCredentials($loginName, $password);
         if ($administrator === null) {
-            throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567098);
+            $entry = $this->translator->trans("Failed admin login attempt for '%login%'", ['login' => $loginName]);
+            $this->eventLogManager->log('login', $entry);
+            $message = $this->translator->trans('Not authorized');
+            throw new UnauthorizedHttpException('', $message, null, 1500567098);
         }
 
         if ($administrator->isDisabled()) {
-            throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567099);
+            $entry = $this->translator->trans("Login attempt for disabled admin '%login%'", ['login' => $loginName]);
+            $this->eventLogManager->log('login', $entry);
+            $message = $this->translator->trans('Not authorized');
+            throw new UnauthorizedHttpException('', $message, null, 1500567099);
         }
 
-        $token = new AdministratorToken();
-        $token->setAdministrator($administrator);
-        $token->generateExpiry();
-        $token->generateKey();
-        $this->tokenRepository->save($token);
+        $token = new AdministratorToken($administrator);
+        $this->tokenRepository->persist($token);
 
         return $token;
     }
diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php
index 43937f91..ec2d1af8 100644
--- a/src/Domain/Messaging/Command/ProcessQueueCommand.php
+++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php
@@ -4,16 +4,26 @@
 
 namespace PhpList\Core\Domain\Messaging\Command;
 
-use PhpList\Core\Domain\Messaging\Service\CampaignProcessor;
+use DateTimeImmutable;
+use Doctrine\ORM\EntityManagerInterface;
+use PhpList\Core\Domain\Configuration\Model\ConfigOption;
+use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider;
+use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage;
+use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus;
+use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
+use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator;
+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 Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
 use Throwable;
 
+/**
+ * @SuppressWarnings("PHPMD.CouplingBetweenObjects")
+ */
 #[AsCommand(
     name: 'phplist:process-queue',
     description: 'Processes the email campaign queue.'
@@ -23,29 +33,43 @@ class ProcessQueueCommand extends Command
     private MessageRepository $messageRepository;
     private LockFactory $lockFactory;
     private MessageProcessingPreparator $messagePreparator;
-    private CampaignProcessor $campaignProcessor;
+    private MessageBusInterface $messageBus;
+    private ConfigProvider $configProvider;
+    private TranslatorInterface $translator;
+    private EntityManagerInterface $entityManager;
 
     public function __construct(
         MessageRepository $messageRepository,
         LockFactory $lockFactory,
         MessageProcessingPreparator $messagePreparator,
-        CampaignProcessor $campaignProcessor,
+        MessageBusInterface $messageBus,
+        ConfigProvider $configProvider,
+        TranslatorInterface $translator,
+        EntityManagerInterface $entityManager,
     ) {
         parent::__construct();
         $this->messageRepository = $messageRepository;
         $this->lockFactory = $lockFactory;
         $this->messagePreparator = $messagePreparator;
-        $this->campaignProcessor = $campaignProcessor;
+        $this->messageBus = $messageBus;
+        $this->configProvider = $configProvider;
+        $this->translator = $translator;
+        $this->entityManager = $entityManager;
     }
 
-    /**
-     * @SuppressWarnings("PHPMD.UnusedFormalParameter")
-     */
     protected function execute(InputInterface $input, OutputInterface $output): int
     {
         $lock = $this->lockFactory->createLock('queue_processor');
         if (!$lock->acquire()) {
-            $output->writeln('Queue is already being processed by another instance.');
+            $output->writeln($this->translator->trans('Queue is already being processed by another instance.'));
+
+            return Command::FAILURE;
+        }
+
+        if ($this->configProvider->isEnabled(ConfigOption::MaintenanceMode)) {
+            $output->writeln(
+                $this->translator->trans('The system is in maintenance mode, stopping. Try again later.')
+            );
 
             return Command::FAILURE;
         }
@@ -54,10 +78,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int
             $this->messagePreparator->ensureSubscribersHaveUuid($output);
             $this->messagePreparator->ensureCampaignsHaveUuid($output);
 
-            $campaigns = $this->messageRepository->findBy(['status' => 'submitted']);
+            $this->entityManager->flush();
+        } catch (Throwable $throwable) {
+            $output->writeln($throwable->getMessage());
+            $lock->release();
 
+            return Command::FAILURE;
+        }
+
+        $campaigns = $this->messageRepository->getByStatusAndEmbargo(
+            status: MessageStatus::Submitted,
+            embargo: new DateTimeImmutable()
+        );
+
+        try {
             foreach ($campaigns as $campaign) {
-                $this->campaignProcessor->process($campaign, $output);
+                $this->messageBus->dispatch(new CampaignProcessorMessage(messageId: $campaign->getId()));
             }
         } catch (Throwable $throwable) {
             $output->writeln($throwable->getMessage());
diff --git a/src/Domain/Messaging/Command/SendTestEmailCommand.php b/src/Domain/Messaging/Command/SendTestEmailCommand.php
index bb6ba06b..e9670239 100644
--- a/src/Domain/Messaging/Command/SendTestEmailCommand.php
+++ b/src/Domain/Messaging/Command/SendTestEmailCommand.php
@@ -12,6 +12,7 @@
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Mime\Address;
+use Symfony\Contracts\Translation\TranslatorInterface;
 use Symfony\Component\Mime\Email;
 
 #[AsCommand(
@@ -21,11 +22,13 @@
 class SendTestEmailCommand extends Command
 {
     private EmailService $emailService;
+    private TranslatorInterface $translator;
 
-    public function __construct(EmailService $emailService)
+    public function __construct(EmailService $emailService, TranslatorInterface $translator)
     {
         parent::__construct();
         $this->emailService = $emailService;
+        $this->translator = $translator;
     }
 
     protected function configure(): void
@@ -48,13 +51,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int
     {
         $recipient = $input->getArgument('recipient');
         if (!$recipient) {
-            $output->writeln('Recipient email address not provided');
+            $output->writeln($this->translator->trans('Recipient email address not provided'));
 
             return Command::FAILURE;
         }
 
         if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
-            $output->writeln('Invalid email address: ' . $recipient);
+            $output->writeln($this->translator->trans('Invalid email address: %email%', ['%email%' => $recipient]));
 
             return Command::FAILURE;
         }
@@ -63,9 +66,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int
             $syncMode = $input->getOption('sync');
 
             if ($syncMode) {
-                $output->writeln('Sending test email synchronously to ' . $recipient);
+                $output->writeln($this->translator->trans(
+                    'Sending test email synchronously to %email%',
+                    ['%email%' => $recipient]
+                ));
             } else {
-                $output->writeln('Queuing test email for ' . $recipient);
+                $output->writeln($this->translator->trans(
+                    'Queuing test email for %email%',
+                    ['%email%' => $recipient]
+                ));
             }
 
             $email = (new Email())
@@ -77,15 +86,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int
 
             if ($syncMode) {
                 $this->emailService->sendEmailSync($email);
-                $output->writeln('Test email sent successfully!');
+                $output->writeln($this->translator->trans('Test email sent successfully!'));
             } else {
                 $this->emailService->sendEmail($email);
-                $output->writeln('Test email queued successfully! It will be sent asynchronously.');
+                $output->writeln($this->translator->trans(
+                    'Test email queued successfully! It will be sent asynchronously.'
+                ));
             }
 
             return Command::SUCCESS;
         } catch (Exception $e) {
-            $output->writeln('Failed to send test email: ' . $e->getMessage());
+            $output->writeln($this->translator->trans(
+                'Failed to send test email: %error%',
+                ['%error%' => $e->getMessage()]
+            ));
 
             return Command::FAILURE;
         }
diff --git a/src/Domain/Messaging/Exception/InvalidContextTypeException.php b/src/Domain/Messaging/Exception/InvalidContextTypeException.php
new file mode 100644
index 00000000..0732355a
--- /dev/null
+++ b/src/Domain/Messaging/Exception/InvalidContextTypeException.php
@@ -0,0 +1,15 @@
+messageId = $messageId;
+    }
+
+    public function getMessageId(): int
+    {
+        return $this->messageId;
+    }
+}
diff --git a/src/Domain/Messaging/Message/SubscriptionConfirmationMessage.php b/src/Domain/Messaging/Message/SubscriptionConfirmationMessage.php
new file mode 100644
index 00000000..22515145
--- /dev/null
+++ b/src/Domain/Messaging/Message/SubscriptionConfirmationMessage.php
@@ -0,0 +1,51 @@
+email = $email;
+        $this->uniqueId = $uniqueId;
+        $this->listIds = $listIds;
+        $this->htmlEmail = $htmlEmail;
+    }
+
+    public function getEmail(): string
+    {
+        return $this->email;
+    }
+
+    public function getUniqueId(): string
+    {
+        return $this->uniqueId;
+    }
+
+    public function getListIds(): array
+    {
+        return $this->listIds;
+    }
+
+    public function hasHtmlEmail(): bool
+    {
+        return $this->htmlEmail;
+    }
+}
diff --git a/src/Domain/Messaging/Message/SyncCampaignProcessorMessage.php b/src/Domain/Messaging/Message/SyncCampaignProcessorMessage.php
new file mode 100644
index 00000000..05be675f
--- /dev/null
+++ b/src/Domain/Messaging/Message/SyncCampaignProcessorMessage.php
@@ -0,0 +1,20 @@
+messageId = $messageId;
+    }
+
+    public function getMessageId(): int
+    {
+        return $this->messageId;
+    }
+}
diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
new file mode 100644
index 00000000..82e1e664
--- /dev/null
+++ b/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php
@@ -0,0 +1,182 @@
+mailer = $mailer;
+        $this->entityManager = $entityManager;
+        $this->subscriberProvider = $subscriberProvider;
+        $this->messagePreparator = $messagePreparator;
+        $this->logger = $logger;
+        $this->userMessageRepository = $userMessageRepository;
+        $this->timeLimiter = $timeLimiter;
+        $this->requeueHandler = $requeueHandler;
+        $this->translator = $translator;
+        $this->subscriberHistoryManager = $subscriberHistoryManager;
+        $this->messageRepository = $messageRepository;
+    }
+
+    public function __invoke(CampaignProcessorMessage|SyncCampaignProcessorMessage $message): void
+    {
+        $campaign = $this->messageRepository->findByIdAndStatus($message->getMessageId(), MessageStatus::Submitted);
+        if (!$campaign) {
+            $this->logger->warning(
+                $this->translator->trans('Campaign not found or not in submitted status'),
+                ['campaign_id' => $message->getMessageId()]
+            );
+
+            return;
+        }
+
+        $this->updateMessageStatus($campaign, MessageStatus::Prepared);
+        $subscribers = $this->subscriberProvider->getSubscribersForMessage($campaign);
+
+        $this->updateMessageStatus($campaign, MessageStatus::InProcess);
+
+        $this->timeLimiter->start();
+        $stoppedEarly = false;
+
+        foreach ($subscribers as $subscriber) {
+            if ($this->timeLimiter->shouldStop()) {
+                $stoppedEarly = true;
+                break;
+            }
+
+            $existing = $this->userMessageRepository->findOneByUserAndMessage($subscriber, $campaign);
+            if ($existing && $existing->getStatus() !== UserMessageStatus::Todo) {
+                continue;
+            }
+
+            $userMessage = $existing ?? new UserMessage($subscriber, $campaign);
+            $userMessage->setStatus(UserMessageStatus::Active);
+            $this->userMessageRepository->save($userMessage);
+
+            if (!filter_var($subscriber->getEmail(), FILTER_VALIDATE_EMAIL)) {
+                $this->handleInvalidEmail($userMessage, $subscriber, $campaign);
+                $this->entityManager->flush();
+                continue;
+            }
+
+            $this->handleEmailSending($campaign, $subscriber, $userMessage);
+        }
+
+        if ($stoppedEarly && $this->requeueHandler->handle($campaign)) {
+            $this->entityManager->flush();
+            return;
+        }
+
+        $this->updateMessageStatus($campaign, MessageStatus::Sent);
+    }
+
+    private function unconfirmSubscriber(Subscriber $subscriber): void
+    {
+        if ($subscriber->isConfirmed()) {
+            $subscriber->setConfirmed(false);
+            $this->entityManager->flush();
+        }
+    }
+
+    private function updateMessageStatus(Message $message, MessageStatus $status): void
+    {
+        $message->getMetadata()->setStatus($status);
+        $this->entityManager->flush();
+    }
+
+    private function updateUserMessageStatus(UserMessage $userMessage, UserMessageStatus $status): void
+    {
+        $userMessage->setStatus($status);
+        $this->entityManager->flush();
+    }
+
+    private function handleInvalidEmail(UserMessage $userMessage, Subscriber $subscriber, mixed $campaign): void
+    {
+        $this->updateUserMessageStatus($userMessage, UserMessageStatus::InvalidEmailAddress);
+        $this->unconfirmSubscriber($subscriber);
+        $this->logger->warning($this->translator->trans('Invalid email, marking unconfirmed: %email%', [
+            '%email%' => $subscriber->getEmail(),
+        ]));
+        $this->subscriberHistoryManager->addHistory(
+            subscriber: $subscriber,
+            message: $this->translator->trans('Subscriber marked unconfirmed for invalid email address'),
+            details: $this->translator->trans(
+                'Marked unconfirmed while sending campaign %message_id%',
+                ['%message_id%' => $campaign->getId()]
+            )
+        );
+    }
+
+    private function handleEmailSending(mixed $campaign, Subscriber $subscriber, UserMessage $userMessage): void
+    {
+        $processed = $this->messagePreparator->processMessageLinks($campaign, $subscriber->getId());
+
+        try {
+            $email = $this->mailer->composeEmail($processed, $subscriber);
+            $this->mailer->send($email);
+            $this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent);
+        } catch (Throwable $e) {
+            $this->updateUserMessageStatus($userMessage, UserMessageStatus::NotSent);
+            $this->logger->error($e->getMessage(), [
+                'subscriber_id' => $subscriber->getId(),
+                'campaign_id' => $campaign->getId(),
+            ]);
+            $this->logger->warning($this->translator->trans('Failed to send to: %email%', [
+                '%email%' => $subscriber->getEmail(),
+            ]));
+        }
+    }
+}
diff --git a/src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php b/src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php
index acd0e22d..7d2a3096 100644
--- a/src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php
+++ b/src/Domain/Messaging/MessageHandler/PasswordResetMessageHandler.php
@@ -8,16 +8,19 @@
 use PhpList\Core\Domain\Messaging\Service\EmailService;
 use Symfony\Component\Messenger\Attribute\AsMessageHandler;
 use Symfony\Component\Mime\Email;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
 #[AsMessageHandler]
 class PasswordResetMessageHandler
 {
     private EmailService $emailService;
+    private TranslatorInterface $translator;
     private string $passwordResetUrl;
 
-    public function __construct(EmailService $emailService, string $passwordResetUrl)
+    public function __construct(EmailService $emailService, TranslatorInterface $translator, string $passwordResetUrl)
     {
         $this->emailService = $emailService;
+        $this->translator = $translator;
         $this->passwordResetUrl = $passwordResetUrl;
     }
 
@@ -28,19 +31,30 @@ public function __invoke(PasswordResetMessage $message): void
     {
         $confirmationLink = $this->generateLink($message->getToken());
 
-        $subject = 'Password Reset Request';
-        $textContent = "Hello,\n\n"
-            . "A password reset has been requested for your account.\n"
-            . "Please use the following token to reset your password:\n\n"
-            . $message->getToken()
-            . "\n\nIf you did not request this password reset, please ignore this email.\n\nThank you.";
-
-        $htmlContent = 'Password Reset Request!
'
-            . 'Hello! A password reset has been requested for your account.
'
-            . 'Please use the following token to reset your password:
'
-            . 'Reset Password 
'
-            . 'If you did not request this password reset, please ignore this email.
'
-            . 'Thank you.
';
+        $subject = $this->translator->trans('Password Reset Request');
+
+        $textContent = $this->translator->trans(
+            "Hello,\n\n" .
+            "A password reset has been requested for your account.\n" .
+            "Please use the following token to reset your password:\n\n" .
+            "%token%\n\n" .
+            "If you did not request this password reset, please ignore this email.\n\n" .
+            'Thank you.',
+            ['%token%' => $message->getToken()]
+        );
+
+        $htmlContent = $this->translator->trans(
+            'Password Reset Request!
' .
+            'Hello! A password reset has been requested for your account.
' .
+            'Please use the following token to reset your password:
' .
+            'Reset Password 
' .
+            'If you did not request this password reset, please ignore this email.
' .
+            'Thank you.
',
+            [
+                '%confirmation_link%' => $confirmationLink,
+            ]
+        );
+
 
         $email = (new Email())
             ->to($message->getEmail())
diff --git a/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php b/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php
index 8c487849..69ec42cb 100644
--- a/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php
+++ b/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php
@@ -8,6 +8,7 @@
 use PhpList\Core\Domain\Messaging\Service\EmailService;
 use Symfony\Component\Messenger\Attribute\AsMessageHandler;
 use Symfony\Component\Mime\Email;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
 /**
  * Handler for processing asynchronous subscriber confirmation email messages
@@ -16,11 +17,13 @@
 class SubscriberConfirmationMessageHandler
 {
     private EmailService $emailService;
+    private TranslatorInterface $translator;
     private string $confirmationUrl;
 
-    public function __construct(EmailService $emailService, string $confirmationUrl)
+    public function __construct(EmailService $emailService, TranslatorInterface $translator, string $confirmationUrl)
     {
         $this->emailService = $emailService;
+        $this->translator = $translator;
         $this->confirmationUrl = $confirmationUrl;
     }
 
@@ -31,18 +34,29 @@ public function __invoke(SubscriberConfirmationMessage $message): void
     {
         $confirmationLink = $this->generateConfirmationLink($message->getUniqueId());
 
-        $subject = 'Please confirm your subscription';
-        $textContent = "Thank you for subscribing!\n\n"
-            . "Please confirm your subscription by clicking the link below:\n"
-            . $confirmationLink . "\n\n"
-            . 'If you did not request this subscription, please ignore this email.';
+        $subject = $this->translator->trans('Please confirm your subscription');
+
+        $textContent = $this->translator->trans(
+            "Thank you for subscribing!\n\n" .
+            "Please confirm your subscription by clicking the link below:\n\n" .
+            "%confirmation_link%\n\n" .
+            'If you did not request this subscription, please ignore this email.',
+            [
+                '%confirmation_link%' => $confirmationLink
+            ]
+        );
 
         $htmlContent = '';
         if ($message->hasHtmlEmail()) {
-            $htmlContent = 'Thank you for subscribing!
'
-                . 'Please confirm your subscription by clicking the link below:
'
-                . 'Confirm Subscription 
'
-                . 'If you did not request this subscription, please ignore this email.
';
+            $htmlContent = $this->translator->trans(
+                'Thank you for subscribing!
' .
+                'Please confirm your subscription by clicking the link below:
' .
+                'Confirm Subscription 
' .
+                'If you did not request this subscription, please ignore this email.
',
+                [
+                    '%confirmation_link%' => $confirmationLink,
+                ]
+            );
         }
 
         $email = (new Email())
diff --git a/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php b/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php
new file mode 100644
index 00000000..6ecb965b
--- /dev/null
+++ b/src/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandler.php
@@ -0,0 +1,76 @@
+emailService = $emailService;
+        $this->configProvider = $configProvider;
+        $this->logger = $logger;
+        $this->userPersonalizer = $userPersonalizer;
+        $this->subscriberListRepository = $subscriberListRepository;
+    }
+
+    /**
+     * Process a subscription confirmation message by sending the confirmation email
+     */
+    public function __invoke(SubscriptionConfirmationMessage $message): void
+    {
+        $subject = $this->configProvider->getValue(ConfigOption::SubscribeEmailSubject);
+        $textContent = $this->configProvider->getValue(ConfigOption::SubscribeMessage);
+        $personalizedTextContent = $this->userPersonalizer->personalize($textContent, $message->getUniqueId());
+        $listOfLists = $this->getListNames($message->getListIds());
+        $replacedTextContent = str_replace('[LISTS]', $listOfLists, $personalizedTextContent);
+
+        $email = (new Email())
+            ->to($message->getEmail())
+            ->subject($subject)
+            ->text($replacedTextContent);
+
+        $this->emailService->sendEmail($email);
+
+        $this->logger->info('Subscription confirmation email sent to {email}', ['email' => $message->getEmail()]);
+    }
+
+    private function getListNames(array $listIds): string
+    {
+        $listNames = [];
+        foreach ($listIds as $id) {
+            $list = $this->subscriberListRepository->find($id);
+            if ($list) {
+                $listNames[] = $list->getName();
+            }
+        }
+
+        return implode(', ', $listNames);
+    }
+}
diff --git a/src/Domain/Messaging/Model/Bounce.php b/src/Domain/Messaging/Model/Bounce.php
index 8d665d72..dbcb5106 100644
--- a/src/Domain/Messaging/Model/Bounce.php
+++ b/src/Domain/Messaging/Model/Bounce.php
@@ -12,8 +12,8 @@
 
 #[ORM\Entity(repositoryClass: BounceRepository::class)]
 #[ORM\Table(name: 'phplist_bounce')]
-#[ORM\Index(name: 'dateindex', columns: ['date'])]
-#[ORM\Index(name: 'statusidx', columns: ['status'])]
+#[ORM\Index(name: 'phplist_bounce_dateindex', columns: ['date'])]
+#[ORM\Index(name: 'phplist_bounce_statusidx', columns: ['status'])]
 class Bounce implements DomainModel, Identity
 {
     #[ORM\Id]
diff --git a/src/Domain/Messaging/Model/BounceRegex.php b/src/Domain/Messaging/Model/BounceRegex.php
index 510aaad8..c54ca7c0 100644
--- a/src/Domain/Messaging/Model/BounceRegex.php
+++ b/src/Domain/Messaging/Model/BounceRegex.php
@@ -11,7 +11,7 @@
 
 #[ORM\Entity(repositoryClass: BounceRegexRepository::class)]
 #[ORM\Table(name: 'phplist_bounceregex')]
-#[ORM\UniqueConstraint(name: 'regex', columns: ['regexhash'])]
+#[ORM\UniqueConstraint(name: 'phplist_bounceregex_regex', columns: ['regexhash'])]
 class BounceRegex implements DomainModel, Identity
 {
     #[ORM\Id]
@@ -31,8 +31,8 @@ class BounceRegex implements DomainModel, Identity
     #[ORM\Column(name: 'listorder', type: 'integer', nullable: true, options: ['default' => 0])]
     private ?int $listOrder = 0;
 
-    #[ORM\Column(type: 'integer', nullable: true)]
-    private ?int $admin;
+    #[ORM\Column(name: 'admin', type: 'integer', nullable: true)]
+    private ?int $adminId;
 
     #[ORM\Column(type: 'text', nullable: true)]
     private ?string $comment;
@@ -48,7 +48,7 @@ public function __construct(
         ?string $regexHash = null,
         ?string $action = null,
         ?int $listOrder = 0,
-        ?int $admin = null,
+        ?int $adminId = null,
         ?string $comment = null,
         ?string $status = null,
         ?int $count = 0
@@ -57,7 +57,7 @@ public function __construct(
         $this->regexHash = $regexHash;
         $this->action = $action;
         $this->listOrder = $listOrder;
-        $this->admin = $admin;
+        $this->adminId = $adminId;
         $this->comment = $comment;
         $this->status = $status;
         $this->count = $count;
@@ -112,14 +112,14 @@ public function setListOrder(?int $listOrder): self
         return $this;
     }
 
-    public function getAdmin(): ?int
+    public function getAdminId(): ?int
     {
-        return $this->admin;
+        return $this->adminId;
     }
 
-    public function setAdmin(?int $admin): self
+    public function setAdminId(?int $adminId): self
     {
-        $this->admin = $admin;
+        $this->adminId = $adminId;
         return $this;
     }
 
diff --git a/src/Domain/Messaging/Model/BounceRegexBounce.php b/src/Domain/Messaging/Model/BounceRegexBounce.php
index 9dbd3168..e815cd1f 100644
--- a/src/Domain/Messaging/Model/BounceRegexBounce.php
+++ b/src/Domain/Messaging/Model/BounceRegexBounce.php
@@ -13,38 +13,38 @@
 class BounceRegexBounce implements DomainModel
 {
     #[ORM\Id]
-    #[ORM\Column(type: 'integer')]
-    private int $regex;
+    #[ORM\Column(name: 'regex', type: 'integer')]
+    private int $regexId;
 
     #[ORM\Id]
-    #[ORM\Column(type: 'integer')]
-    private int $bounce;
+    #[ORM\Column(name: 'bounce', type: 'integer')]
+    private int $bounceId;
 
-    public function __construct(int $regex, int $bounce)
+    public function __construct(int $regexId, int $bounceId)
     {
-        $this->regex = $regex;
-        $this->bounce = $bounce;
+        $this->regexId = $regexId;
+        $this->bounceId = $bounceId;
     }
 
-    public function getRegex(): int
+    public function getRegexId(): int
     {
-        return $this->regex;
+        return $this->regexId;
     }
 
-    public function setRegex(int $regex): self
+    public function setRegexId(int $regexId): self
     {
-        $this->regex = $regex;
+        $this->regexId = $regexId;
         return $this;
     }
 
-    public function getBounce(): int
+    public function getBounceId(): int
     {
-        return $this->bounce;
+        return $this->bounceId;
     }
 
-    public function setBounce(int $bounce): self
+    public function setBounceId(int $bounceId): self
     {
-        $this->bounce = $bounce;
+        $this->bounceId = $bounceId;
         return $this;
     }
 }
diff --git a/src/Domain/Messaging/Model/BounceStatus.php b/src/Domain/Messaging/Model/BounceStatus.php
new file mode 100644
index 00000000..be77473f
--- /dev/null
+++ b/src/Domain/Messaging/Model/BounceStatus.php
@@ -0,0 +1,19 @@
+value, $userId);
+    }
+}
diff --git a/src/Domain/Messaging/Model/Dto/Message/MessageMetadataDto.php b/src/Domain/Messaging/Model/Dto/Message/MessageMetadataDto.php
index 0776c1d1..91802df2 100644
--- a/src/Domain/Messaging/Model/Dto/Message/MessageMetadataDto.php
+++ b/src/Domain/Messaging/Model/Dto/Message/MessageMetadataDto.php
@@ -4,10 +4,12 @@
 
 namespace PhpList\Core\Domain\Messaging\Model\Dto\Message;
 
+use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus;
+
 class MessageMetadataDto
 {
     public function __construct(
-        public readonly string $status,
+        public readonly MessageStatus $status,
     ) {
     }
 }
diff --git a/src/Domain/Messaging/Model/ListMessage.php b/src/Domain/Messaging/Model/ListMessage.php
index a9751e7f..0a488bdd 100644
--- a/src/Domain/Messaging/Model/ListMessage.php
+++ b/src/Domain/Messaging/Model/ListMessage.php
@@ -15,8 +15,8 @@
 
 #[ORM\Entity(repositoryClass: ListMessageRepository::class)]
 #[ORM\Table(name: 'phplist_listmessage')]
-#[ORM\UniqueConstraint(name: 'messageid', columns: ['messageid', 'listid'])]
-#[ORM\Index(name: 'listmessageidx', columns: ['listid', 'messageid'])]
+#[ORM\UniqueConstraint(name: 'phplist_listmessage_messageid', columns: ['messageid', 'listid'])]
+#[ORM\Index(name: 'phplist_listmessage_listmessageidx', columns: ['listid', 'messageid'])]
 #[ORM\HasLifecycleCallbacks]
 class ListMessage implements DomainModel, Identity, ModificationDate
 {
@@ -39,6 +39,12 @@ class ListMessage implements DomainModel, Identity, ModificationDate
     #[ORM\Column(name: 'modified', type: 'datetime')]
     private ?DateTime $updatedAt = null;
 
+    public function __construct()
+    {
+        $this->updatedAt = new DateTime();
+        $this->entered = new DateTime();
+    }
+
     public function getId(): ?int
     {
         return $this->id;
@@ -71,23 +77,14 @@ public function getEntered(): ?DateTimeInterface
         return $this->entered;
     }
 
-    public function setEntered(?DateTimeInterface $entered): self
-    {
-        $this->entered = $entered;
-        return $this;
-    }
-
     public function getUpdatedAt(): ?DateTime
     {
         return $this->updatedAt;
     }
 
-    #[ORM\PrePersist]
     #[ORM\PreUpdate]
-    public function updateUpdatedAt(): DomainModel
+    public function updateUpdatedAt(): void
     {
         $this->updatedAt = new DateTime();
-
-        return $this;
     }
 }
diff --git a/src/Domain/Messaging/Model/Message.php b/src/Domain/Messaging/Model/Message.php
index fbbfec8a..90f5036c 100644
--- a/src/Domain/Messaging/Model/Message.php
+++ b/src/Domain/Messaging/Model/Message.php
@@ -5,12 +5,14 @@
 namespace PhpList\Core\Domain\Messaging\Model;
 
 use DateTime;
+use DateTimeImmutable;
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
 use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
 use PhpList\Core\Domain\Common\Model\Interfaces\Identity;
 use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate;
+use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface;
 use PhpList\Core\Domain\Identity\Model\Administrator;
 use PhpList\Core\Domain\Messaging\Model\Message\MessageContent;
 use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat;
@@ -21,17 +23,17 @@
 
 #[ORM\Entity(repositoryClass: MessageRepository::class)]
 #[ORM\Table(name: 'phplist_message')]
-#[ORM\Index(name: 'uuididx', columns: ['uuid'])]
+#[ORM\Index(name: 'phplist_message_uuididx', columns: ['uuid'])]
 #[ORM\HasLifecycleCallbacks]
-class Message implements DomainModel, Identity, ModificationDate
+class Message implements DomainModel, Identity, ModificationDate, OwnableInterface
 {
     #[ORM\Id]
     #[ORM\Column(type: 'integer')]
     #[ORM\GeneratedValue]
     private ?int $id = null;
 
-    #[ORM\Column(name: 'modified', type: 'datetime')]
-    private ?DateTime $updatedAt = null;
+    #[ORM\Column(name: 'modified', type: 'datetime', nullable: false)]
+    private DateTime $updatedAt;
 
     #[ORM\Embedded(class: MessageFormat::class, columnPrefix: false)]
     private MessageFormat $format;
@@ -80,6 +82,8 @@ public function __construct(
         $this->owner = $owner;
         $this->template = $template;
         $this->listMessages = new ArrayCollection();
+        $this->updatedAt = new DateTime();
+        $this->metadata->setEntered(new DateTime());
     }
 
     public function getId(): ?int
@@ -87,18 +91,15 @@ public function getId(): ?int
         return $this->id;
     }
 
-    public function getUpdatedAt(): ?DateTime
+    public function getUpdatedAt(): DateTime
     {
         return $this->updatedAt;
     }
 
-    #[ORM\PrePersist]
     #[ORM\PreUpdate]
-    public function updateUpdatedAt(): DomainModel
+    public function touchUpdatedTimestamp(): void
     {
-        $this->updatedAt = new DateTime();
-
-        return $this;
+        $this->updatedAt = new DateTime;
     }
 
     public function getFormat(): MessageFormat
diff --git a/src/Domain/Messaging/Model/Message/MessageFormat.php b/src/Domain/Messaging/Model/Message/MessageFormat.php
index 00af6df0..5deedefb 100644
--- a/src/Domain/Messaging/Model/Message/MessageFormat.php
+++ b/src/Domain/Messaging/Model/Message/MessageFormat.php
@@ -11,25 +11,25 @@
 #[ORM\Embeddable]
 class MessageFormat implements EmbeddableInterface
 {
-    #[ORM\Column(name: 'htmlformatted', type: 'boolean', options: ['default' => false])]
+    #[ORM\Column(name: 'htmlformatted', type: 'boolean')]
     private bool $htmlFormatted = false;
 
     #[ORM\Column(name: 'sendformat', type: 'string', length: 20, nullable: true)]
     private ?string $sendFormat = null;
 
-    #[ORM\Column(name: 'astext', type: 'boolean', options: ['default' => false])]
+    #[ORM\Column(name: 'astext', type: 'boolean')]
     private bool $asText = false;
 
-    #[ORM\Column(name: 'ashtml', type: 'boolean', options: ['default' => false])]
+    #[ORM\Column(name: 'ashtml', type: 'boolean')]
     private bool $asHtml = false;
 
-    #[ORM\Column(name: 'aspdf', type: 'boolean', options: ['default' => false])]
+    #[ORM\Column(name: 'aspdf', type: 'boolean')]
     private bool $asPdf = false;
 
-    #[ORM\Column(name: 'astextandhtml', type: 'boolean', options: ['default' => false])]
+    #[ORM\Column(name: 'astextandhtml', type: 'boolean')]
     private bool $asTextAndHtml = false;
 
-    #[ORM\Column(name: 'astextandpdf', type: 'boolean', options: ['default' => false])]
+    #[ORM\Column(name: 'astextandpdf', type: 'boolean')]
     private bool $asTextAndPdf = false;
 
     public const FORMAT_TEXT = 'text';
diff --git a/src/Domain/Messaging/Model/Message/MessageMetadata.php b/src/Domain/Messaging/Model/Message/MessageMetadata.php
index 123103ff..e2c43c72 100644
--- a/src/Domain/Messaging/Model/Message/MessageMetadata.php
+++ b/src/Domain/Messaging/Model/Message/MessageMetadata.php
@@ -6,6 +6,7 @@
 
 use DateTime;
 use Doctrine\ORM\Mapping as ORM;
+use InvalidArgumentException;
 use PhpList\Core\Domain\Common\Model\Interfaces\EmbeddableInterface;
 
 #[ORM\Embeddable]
@@ -15,7 +16,7 @@ class MessageMetadata implements EmbeddableInterface
     private ?string $status = null;
 
     #[ORM\Column(type: 'boolean', options: ['unsigned' => true, 'default' => false])]
-    private bool $processed;
+    private bool $processed = false;
 
     #[ORM\Column(type: 'integer', options: ['default' => 0])]
     private int $viewed = 0;
@@ -33,13 +34,13 @@ class MessageMetadata implements EmbeddableInterface
     private ?DateTime $sendStart;
 
     public function __construct(
-        ?string $status = null,
+        ?MessageStatus $status = null,
         int $bounceCount = 0,
         ?DateTime $entered = null,
         ?DateTime $sent = null,
         ?DateTime $sendStart = null,
     ) {
-        $this->status = $status;
+        $this->status = $status->value ?? null;
         $this->processed = false;
         $this->viewed = 0;
         $this->bounceCount = $bounceCount;
@@ -48,14 +49,21 @@ public function __construct(
         $this->sendStart = $sendStart;
     }
 
-    public function getStatus(): ?string
+    /**
+     * @SuppressWarnings("PHPMD.StaticAccess")
+     */
+    public function getStatus(): ?MessageStatus
     {
-        return $this->status;
+        return MessageStatus::from($this->status);
     }
 
-    public function setStatus(string $status): self
+    public function setStatus(MessageStatus $status): self
     {
-        $this->status = $status;
+        if (!$this->getStatus()->canTransitionTo($status)) {
+            throw new InvalidArgumentException('Invalid status transition');
+        }
+        $this->status = $status->value;
+
         return $this;
     }
 
diff --git a/src/Domain/Messaging/Model/Message/MessageStatus.php b/src/Domain/Messaging/Model/Message/MessageStatus.php
new file mode 100644
index 00000000..90b7f987
--- /dev/null
+++ b/src/Domain/Messaging/Model/Message/MessageStatus.php
@@ -0,0 +1,38 @@
+ [self::Submitted],
+            self::Submitted => [self::Prepared, self::InProcess],
+            self::Prepared => [self::InProcess],
+            self::InProcess => [self::Sent, self::Suspended, self::Submitted],
+            self::Requeued => [self::InProcess, self::Suspended],
+            self::Sent => [],
+        };
+    }
+
+    public function canTransitionTo(self $next): bool
+    {
+        return in_array($next, $this->allowedTransitions(), true);
+    }
+}
diff --git a/src/Domain/Messaging/Model/Message/UserMessageStatus.php b/src/Domain/Messaging/Model/Message/UserMessageStatus.php
new file mode 100644
index 00000000..1237cfe8
--- /dev/null
+++ b/src/Domain/Messaging/Model/Message/UserMessageStatus.php
@@ -0,0 +1,16 @@
+updatedAt = new DateTime();
-
-        return $this;
     }
 
     public function getStartedDate(): ?DateTime
diff --git a/src/Domain/Messaging/Model/Template.php b/src/Domain/Messaging/Model/Template.php
index f7e3f5d0..efff0de4 100644
--- a/src/Domain/Messaging/Model/Template.php
+++ b/src/Domain/Messaging/Model/Template.php
@@ -13,7 +13,7 @@
 
 #[ORM\Entity(repositoryClass: TemplateRepository::class)]
 #[ORM\Table(name: 'phplist_template')]
-#[ORM\UniqueConstraint(name: 'title', columns: ['title'])]
+#[ORM\UniqueConstraint(name: 'phplist_template_title', columns: ['title'])]
 class Template implements DomainModel, Identity
 {
     #[ORM\Id]
diff --git a/src/Domain/Messaging/Model/TemplateImage.php b/src/Domain/Messaging/Model/TemplateImage.php
index 1ef7d7b5..2cc5288f 100644
--- a/src/Domain/Messaging/Model/TemplateImage.php
+++ b/src/Domain/Messaging/Model/TemplateImage.php
@@ -11,7 +11,7 @@
 
 #[ORM\Entity(repositoryClass: TemplateImageRepository::class)]
 #[ORM\Table(name: 'phplist_templateimage')]
-#[ORM\Index(name: 'templateidx', columns: ['template'])]
+#[ORM\Index(name: 'phplist_templateimage_templateidx', columns: ['template'])]
 class TemplateImage implements DomainModel, Identity
 {
     #[ORM\Id]
diff --git a/src/Domain/Messaging/Model/UserMessage.php b/src/Domain/Messaging/Model/UserMessage.php
index 9be52903..cdb6b7c2 100644
--- a/src/Domain/Messaging/Model/UserMessage.php
+++ b/src/Domain/Messaging/Model/UserMessage.php
@@ -7,16 +7,17 @@
 use DateTime;
 use Doctrine\ORM\Mapping as ORM;
 use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
+use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus;
 use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository;
 use PhpList\Core\Domain\Subscription\Model\Subscriber;
 
 #[ORM\Entity(repositoryClass: UserMessageRepository::class)]
 #[ORM\Table(name: 'phplist_usermessage')]
-#[ORM\Index(name: 'enteredindex', columns: ['entered'])]
-#[ORM\Index(name: 'messageidindex', columns: ['messageid'])]
-#[ORM\Index(name: 'statusidx', columns: ['status'])]
-#[ORM\Index(name: 'useridindex', columns: ['userid'])]
-#[ORM\Index(name: 'viewedidx', columns: ['viewed'])]
+#[ORM\Index(name: 'phplist_usermessage_enteredindex', columns: ['entered'])]
+#[ORM\Index(name: 'phplist_usermessage_messageidindex', columns: ['messageid'])]
+#[ORM\Index(name: 'phplist_usermessage_statusidx', columns: ['status'])]
+#[ORM\Index(name: 'phplist_usermessage_useridindex', columns: ['userid'])]
+#[ORM\Index(name: 'phplist_usermessage_viewedidx', columns: ['viewed'])]
 class UserMessage implements DomainModel
 {
     #[ORM\Id]
@@ -65,9 +66,12 @@ public function getViewed(): ?DateTime
         return $this->viewed;
     }
 
-    public function getStatus(): ?string
+    /**
+     * @SuppressWarnings("PHPMD.StaticAccess")
+     */
+    public function getStatus(): ?UserMessageStatus
     {
-        return $this->status;
+        return UserMessageStatus::from($this->status);
     }
 
     public function setViewed(?DateTime $viewed): self
@@ -76,9 +80,9 @@ public function setViewed(?DateTime $viewed): self
         return $this;
     }
 
-    public function setStatus(?string $status): self
+    public function setStatus(?UserMessageStatus $status): self
     {
-        $this->status = $status;
+        $this->status = $status->value;
         return $this;
     }
 }
diff --git a/src/Domain/Messaging/Model/UserMessageBounce.php b/src/Domain/Messaging/Model/UserMessageBounce.php
index ccb05597..3b58bf47 100644
--- a/src/Domain/Messaging/Model/UserMessageBounce.php
+++ b/src/Domain/Messaging/Model/UserMessageBounce.php
@@ -12,11 +12,10 @@
 
 #[ORM\Entity(repositoryClass: UserMessageBounceRepository::class)]
 #[ORM\Table(name: 'phplist_user_message_bounce')]
-#[ORM\Index(name: 'bounceidx', columns: ['bounce'])]
-#[ORM\Index(name: 'msgidx', columns: ['message'])]
-#[ORM\Index(name: 'umbindex', columns: ['user', 'message', 'bounce'])]
-#[ORM\Index(name: 'useridx', columns: ['user'])]
-#[ORM\HasLifecycleCallbacks]
+#[ORM\Index(name: 'phplist_user_message_bounce_bounceidx', columns: ['bounce'])]
+#[ORM\Index(name: 'phplist_user_message_bounce_msgidx', columns: ['message'])]
+#[ORM\Index(name: 'phplist_user_message_bounce_umbindex', columns: ['user', 'message', 'bounce'])]
+#[ORM\Index(name: 'phplist_user_message_bounce_useridx', columns: ['user'])]
 class UserMessageBounce implements DomainModel, Identity
 {
     #[ORM\Id]
@@ -31,15 +30,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 +56,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 +78,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/Model/UserMessageForward.php b/src/Domain/Messaging/Model/UserMessageForward.php
index 9b2a8ef4..3b920189 100644
--- a/src/Domain/Messaging/Model/UserMessageForward.php
+++ b/src/Domain/Messaging/Model/UserMessageForward.php
@@ -12,9 +12,9 @@
 
 #[ORM\Entity(repositoryClass: UserMessageForwardRepository::class)]
 #[ORM\Table(name: 'phplist_user_message_forward')]
-#[ORM\Index(name: 'messageidx', columns: ['message'])]
-#[ORM\Index(name: 'useridx', columns: ['user'])]
-#[ORM\Index(name: 'usermessageidx', columns: ['user', 'message'])]
+#[ORM\Index(name: 'phplist_user_message_forward_messageidx', columns: ['message'])]
+#[ORM\Index(name: 'phplist_user_message_forward_useridx', columns: ['user'])]
+#[ORM\Index(name: 'phplist_user_message_forward_usermessageidx', columns: ['user', 'message'])]
 class UserMessageForward implements DomainModel, Identity
 {
     #[ORM\Id]
diff --git a/src/Domain/Messaging/Repository/BounceRegexRepository.php b/src/Domain/Messaging/Repository/BounceRegexRepository.php
index a08f65c0..9aecde78 100644
--- a/src/Domain/Messaging/Repository/BounceRegexRepository.php
+++ b/src/Domain/Messaging/Repository/BounceRegexRepository.php
@@ -7,8 +7,26 @@
 use PhpList\Core\Domain\Common\Repository\AbstractRepository;
 use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
 use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Messaging\Model\BounceRegex;
 
 class BounceRegexRepository extends AbstractRepository implements PaginatableRepositoryInterface
 {
     use CursorPaginationTrait;
+
+    public function findOneByRegexHash(string $regexHash): ?BounceRegex
+    {
+        return $this->findOneBy(['regexHash' => $regexHash]);
+    }
+
+    /** @return BounceRegex[] */
+    public function fetchAllOrdered(): array
+    {
+        return $this->findBy([], ['listOrder' => 'ASC']);
+    }
+
+    /** @return BounceRegex[] */
+    public function fetchActiveOrdered(): array
+    {
+        return $this->findBy(['active' => true], ['listOrder' => 'ASC']);
+    }
 }
diff --git a/src/Domain/Messaging/Repository/BounceRepository.php b/src/Domain/Messaging/Repository/BounceRepository.php
index fa691a28..410f5da1 100644
--- a/src/Domain/Messaging/Repository/BounceRepository.php
+++ b/src/Domain/Messaging/Repository/BounceRepository.php
@@ -7,8 +7,15 @@
 use PhpList\Core\Domain\Common\Repository\AbstractRepository;
 use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
 use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Messaging\Model\Bounce;
 
 class BounceRepository extends AbstractRepository implements PaginatableRepositoryInterface
 {
     use CursorPaginationTrait;
+
+    /** @return Bounce[] */
+    public function findByStatus(string $status): array
+    {
+        return $this->findBy(['status' => $status]);
+    }
 }
diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php
index cf802300..30d5f1fd 100644
--- a/src/Domain/Messaging/Repository/MessageRepository.php
+++ b/src/Domain/Messaging/Repository/MessageRepository.php
@@ -4,6 +4,7 @@
 
 namespace PhpList\Core\Domain\Messaging\Repository;
 
+use DateTimeImmutable;
 use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface;
 use PhpList\Core\Domain\Common\Repository\AbstractRepository;
 use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
@@ -63,4 +64,37 @@ public function getMessagesByList(SubscriberList $list): array
             ->getQuery()
             ->getResult();
     }
+
+    public function incrementBounceCount(int $messageId): void
+    {
+        $this->createQueryBuilder('m')
+            ->update()
+            ->set('m.metadata.bounceCount', 'm.bounceCount + 1')
+            ->where('m.id = :messageId')
+            ->setParameter('messageId', $messageId)
+            ->getQuery()
+            ->execute();
+    }
+
+    public function getByStatusAndEmbargo(Message\MessageStatus $status, DateTimeImmutable $embargo): array
+    {
+        return $this->createQueryBuilder('m')
+            ->where('m.metadata.status = :status')
+            ->andWhere('m.schedule.embargo IS NULL OR m.embargo <= :embargo')
+            ->setParameter('status', $status->value)
+            ->setParameter('embargo', $embargo)
+            ->getQuery()
+            ->getResult();
+    }
+
+    public function findByIdAndStatus(int $id, Message\MessageStatus $status)
+    {
+        return $this->createQueryBuilder('m')
+            ->where('m.id = :id')
+            ->andWhere('m.metadata.status = :status')
+            ->setParameter('id', $id)
+            ->setParameter('status', $status->value)
+            ->getQuery()
+            ->getOneOrNullResult();
+    }
 }
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/Repository/UserMessageRepository.php b/src/Domain/Messaging/Repository/UserMessageRepository.php
index a19c5823..e8268025 100644
--- a/src/Domain/Messaging/Repository/UserMessageRepository.php
+++ b/src/Domain/Messaging/Repository/UserMessageRepository.php
@@ -4,8 +4,32 @@
 
 namespace PhpList\Core\Domain\Messaging\Repository;
 
+use DateTimeInterface;
 use PhpList\Core\Domain\Common\Repository\AbstractRepository;
+use PhpList\Core\Domain\Messaging\Model\Message;
+use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus;
+use PhpList\Core\Domain\Messaging\Model\UserMessage;
+use PhpList\Core\Domain\Subscription\Model\Subscriber;
 
 class UserMessageRepository extends AbstractRepository
 {
+    public function findOneByUserAndMessage(Subscriber $subscriber, Message $campaign): ?UserMessage
+    {
+        return $this->findOneBy(['user' => $subscriber, 'message' => $campaign]);
+    }
+
+    /**
+     * Counts how many user messages have status "sent" since the given time.
+     */
+    public function countSentSince(DateTimeInterface $since): int
+    {
+        $queryBuilder = $this->createQueryBuilder('um');
+        $queryBuilder->select('COUNT(um)')
+            ->where('um.createdAt > :since')
+            ->andWhere('um.status = :status')
+            ->setParameter('since', $since)
+            ->setParameter('status', UserMessageStatus::Sent->value);
+
+        return (int) $queryBuilder->getQuery()->getSingleScalarResult();
+    }
 }
diff --git a/src/Domain/Messaging/Service/Builder/MessageBuilder.php b/src/Domain/Messaging/Service/Builder/MessageBuilder.php
index e8807170..bb7fd852 100644
--- a/src/Domain/Messaging/Service/Builder/MessageBuilder.php
+++ b/src/Domain/Messaging/Service/Builder/MessageBuilder.php
@@ -4,7 +4,7 @@
 
 namespace PhpList\Core\Domain\Messaging\Service\Builder;
 
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidContextTypeException;
 use PhpList\Core\Domain\Messaging\Model\Dto\MessageContext;
 use PhpList\Core\Domain\Messaging\Model\Dto\MessageDtoInterface;
 use PhpList\Core\Domain\Messaging\Model\Message;
@@ -24,7 +24,7 @@ public function __construct(
     public function build(MessageDtoInterface $createMessageDto, object $context = null): Message
     {
         if (!$context instanceof MessageContext) {
-            throw new InvalidArgumentException('Invalid context type');
+            throw new InvalidContextTypeException(get_debug_type($context));
         }
 
         $format = $this->messageFormatBuilder->build($createMessageDto->getFormat());
@@ -45,8 +45,16 @@ public function build(MessageDtoInterface $createMessageDto, object $context = n
             return $context->getExisting();
         }
 
-        $metadata = new Message\MessageMetadata($createMessageDto->getMetadata()->status);
-
-        return new Message($format, $schedule, $metadata, $content, $options, $context->getOwner(), $template);
+        $metadata = new Message\MessageMetadata(Message\MessageStatus::Draft);
+
+        return new Message(
+            format: $format,
+            schedule: $schedule,
+            metadata: $metadata,
+            content: $content,
+            options: $options,
+            owner: $context->getOwner(),
+            template: $template
+        );
     }
 }
diff --git a/src/Domain/Messaging/Service/Builder/MessageContentBuilder.php b/src/Domain/Messaging/Service/Builder/MessageContentBuilder.php
index 1e9e442d..806afe00 100644
--- a/src/Domain/Messaging/Service/Builder/MessageContentBuilder.php
+++ b/src/Domain/Messaging/Service/Builder/MessageContentBuilder.php
@@ -4,7 +4,7 @@
 
 namespace PhpList\Core\Domain\Messaging\Service\Builder;
 
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException;
 use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageContentDto;
 use PhpList\Core\Domain\Messaging\Model\Message\MessageContent;
 
@@ -13,14 +13,14 @@ class MessageContentBuilder
     public function build(object $dto): MessageContent
     {
         if (!$dto instanceof MessageContentDto) {
-            throw new InvalidArgumentException('Invalid request dto type: ' . get_class($dto));
+            throw new InvalidDtoTypeException(get_debug_type($dto));
         }
 
         return new MessageContent(
-            $dto->subject,
-            $dto->text,
-            $dto->textMessage,
-            $dto->footer
+            subject: $dto->subject,
+            text: $dto->text,
+            textMessage: $dto->textMessage,
+            footer: $dto->footer
         );
     }
 }
diff --git a/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php b/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php
index 7bf9be8b..c6b05fc2 100644
--- a/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php
+++ b/src/Domain/Messaging/Service/Builder/MessageFormatBuilder.php
@@ -4,7 +4,7 @@
 
 namespace PhpList\Core\Domain\Messaging\Service\Builder;
 
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException;
 use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageFormatDto;
 use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat;
 
@@ -13,13 +13,13 @@ class MessageFormatBuilder
     public function build(object $dto): MessageFormat
     {
         if (!$dto instanceof MessageFormatDto) {
-            throw new InvalidArgumentException('Invalid request dto type: ' . get_class($dto));
+            throw new InvalidDtoTypeException(get_debug_type($dto));
         }
 
         return new MessageFormat(
-            $dto->htmlFormated,
-            $dto->sendFormat,
-            $dto->formatOptions
+            htmlFormatted: $dto->htmlFormated,
+            sendFormat: $dto->sendFormat,
+            formatOptions: $dto->formatOptions
         );
     }
 }
diff --git a/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php b/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php
index 0a241f0f..91689d1e 100644
--- a/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php
+++ b/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php
@@ -4,7 +4,7 @@
 
 namespace PhpList\Core\Domain\Messaging\Service\Builder;
 
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException;
 use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageOptionsDto;
 use PhpList\Core\Domain\Messaging\Model\Message\MessageOptions;
 
@@ -13,15 +13,15 @@ class MessageOptionsBuilder
     public function build(object $dto): MessageOptions
     {
         if (!$dto instanceof MessageOptionsDto) {
-            throw new InvalidArgumentException('Invalid request dto type: ' . get_class($dto));
+            throw new InvalidDtoTypeException(get_debug_type($dto));
         }
 
         return new MessageOptions(
-            $dto->fromField ?? '',
-            $dto->toField ?? '',
-            $dto->replyTo ?? '',
-            $dto->userSelection,
-            null,
+            fromField: $dto->fromField ?? '',
+            toField: $dto->toField ?? '',
+            replyTo: $dto->replyTo ?? '',
+            userSelection: $dto->userSelection,
+            rssTemplate: null,
         );
     }
 }
diff --git a/src/Domain/Messaging/Service/Builder/MessageScheduleBuilder.php b/src/Domain/Messaging/Service/Builder/MessageScheduleBuilder.php
index df847eaf..dbe86731 100644
--- a/src/Domain/Messaging/Service/Builder/MessageScheduleBuilder.php
+++ b/src/Domain/Messaging/Service/Builder/MessageScheduleBuilder.php
@@ -5,7 +5,7 @@
 namespace PhpList\Core\Domain\Messaging\Service\Builder;
 
 use DateTime;
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException;
 use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageScheduleDto;
 use PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule;
 
@@ -14,15 +14,15 @@ class MessageScheduleBuilder
     public function build(object $dto): MessageSchedule
     {
         if (!$dto instanceof MessageScheduleDto) {
-            throw new InvalidArgumentException('Invalid request dto type: ' . get_class($dto));
+            throw new InvalidDtoTypeException(get_debug_type($dto));
         }
 
         return new MessageSchedule(
-            $dto->repeatInterval,
-            new DateTime($dto->repeatUntil),
-            $dto->requeueInterval,
-            new DateTime($dto->requeueUntil),
-            new DateTime($dto->embargo)
+            repeatInterval: $dto->repeatInterval,
+            repeatUntil: new DateTime($dto->repeatUntil),
+            requeueInterval: $dto->requeueInterval,
+            requeueUntil: new DateTime($dto->requeueUntil),
+            embargo: new DateTime($dto->embargo)
         );
     }
 }
diff --git a/src/Domain/Messaging/Service/CampaignProcessor.php b/src/Domain/Messaging/Service/CampaignProcessor.php
deleted file mode 100644
index 48e9acc5..00000000
--- a/src/Domain/Messaging/Service/CampaignProcessor.php
+++ /dev/null
@@ -1,74 +0,0 @@
-mailer = $mailer;
-        $this->entityManager = $entityManager;
-        $this->subscriberProvider = $subscriberProvider;
-        $this->messagePreparator = $messagePreparator;
-        $this->logger = $logger;
-    }
-
-    public function process(Message $campaign, ?OutputInterface $output = null): void
-    {
-        $subscribers = $this->subscriberProvider->getSubscribersForMessage($campaign);
-        // phpcs:ignore Generic.Commenting.Todo
-        // @todo check $ISPrestrictions logic
-        foreach ($subscribers as $subscriber) {
-            if (!filter_var($subscriber->getEmail(), FILTER_VALIDATE_EMAIL)) {
-                continue;
-            }
-            $this->messagePreparator->processMessageLinks($campaign, $subscriber->getId());
-            $email = (new Email())
-                ->from('news@example.com')
-                ->to($subscriber->getEmail())
-                ->subject($campaign->getContent()->getSubject())
-                ->text($campaign->getContent()->getTextMessage())
-                ->html($campaign->getContent()->getText());
-
-            try {
-                $this->mailer->send($email);
-
-                // phpcs:ignore Generic.Commenting.Todo
-                // @todo log somewhere that this subscriber got email
-            } catch (Throwable $e) {
-                $this->logger->error($e->getMessage(), [
-                    'subscriber_id' => $subscriber->getId(),
-                    'campaign_id' => $campaign->getId(),
-                ]);
-                $output?->writeln('Failed to send to: ' . $subscriber->getEmail());
-            }
-
-            usleep(100000);
-        }
-
-        $campaign->getMetadata()->setStatus('sent');
-        $this->entityManager->flush();
-    }
-}
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/RequeueHandler.php b/src/Domain/Messaging/Service/Handler/RequeueHandler.php
new file mode 100644
index 00000000..3fbca634
--- /dev/null
+++ b/src/Domain/Messaging/Service/Handler/RequeueHandler.php
@@ -0,0 +1,59 @@
+getSchedule();
+        $interval = $schedule->getRequeueInterval() ?? 0;
+        $until = $schedule->getRequeueUntil();
+
+        if ($interval <= 0) {
+            return false;
+        }
+        $now = new DateTime();
+        if ($until instanceof DateTime && $now > $until) {
+            return false;
+        }
+
+        $embargoIsInFuture = $schedule->getEmbargo() instanceof DateTime && $schedule->getEmbargo() > new DateTime();
+        $base = $embargoIsInFuture ? clone $schedule->getEmbargo() : new DateTime();
+        $next = (clone $base)->add(new DateInterval('PT' . max(1, $interval) . 'M'));
+        if ($until instanceof DateTime && $next > $until) {
+            return false;
+        }
+
+        $schedule->setEmbargo($next);
+        $campaign->setSchedule($schedule);
+        $campaign->getMetadata()->setStatus(MessageStatus::Submitted);
+
+        $output?->writeln($this->translator->trans(
+            'Requeued campaign; next embargo at %time%',
+            ['%time%' => $next->format(DateTime::ATOM)],
+        ));
+        $this->logger->info('Campaign requeued with new embargo', [
+            'campaign_id' => $campaign->getId(),
+            'embargo' => $next->format(DateTime::ATOM),
+        ]);
+
+        return true;
+    }
+}
diff --git a/src/Domain/Messaging/Service/Manager/BounceRegexManager.php b/src/Domain/Messaging/Service/Manager/BounceRegexManager.php
new file mode 100644
index 00000000..3ebcc539
--- /dev/null
+++ b/src/Domain/Messaging/Service/Manager/BounceRegexManager.php
@@ -0,0 +1,96 @@
+bounceRegexRepository = $bounceRegexRepository;
+        $this->entityManager = $entityManager;
+    }
+
+    /**
+     * Creates or updates (if exists) a BounceRegex from a raw regex pattern.
+     */
+    public function createOrUpdateFromPattern(
+        string $regex,
+        ?string $action = null,
+        ?int $listOrder = 0,
+        ?int $adminId = null,
+        ?string $comment = null,
+        ?string $status = null
+    ): BounceRegex {
+        $regexHash = md5($regex);
+
+        $existing = $this->bounceRegexRepository->findOneByRegexHash($regexHash);
+
+        if ($existing !== null) {
+            $existing->setRegex($regex)
+                ->setAction($action ?? $existing->getAction())
+                ->setListOrder($listOrder ?? $existing->getListOrder())
+                ->setAdminId($adminId ?? $existing->getAdminId())
+                ->setComment($comment ?? $existing->getComment())
+                ->setStatus($status ?? $existing->getStatus());
+
+            return $existing;
+        }
+
+        $bounceRegex = new BounceRegex(
+            regex: $regex,
+            regexHash: $regexHash,
+            action: $action,
+            listOrder: $listOrder,
+            adminId: $adminId,
+            comment: $comment,
+            status: $status,
+            count: 0
+        );
+
+        $this->bounceRegexRepository->persist($bounceRegex);
+
+        return $bounceRegex;
+    }
+
+    /** @return BounceRegex[] */
+    public function getAll(): array
+    {
+        return $this->bounceRegexRepository->findAll();
+    }
+
+    public function getByHash(string $regexHash): ?BounceRegex
+    {
+        return $this->bounceRegexRepository->findOneByRegexHash($regexHash);
+    }
+
+    public function delete(BounceRegex $bounceRegex): void
+    {
+        $this->bounceRegexRepository->remove($bounceRegex);
+    }
+
+    /**
+     * Associates a bounce with the regex it matched and increments usage count.
+     */
+    public function associateBounce(BounceRegex $regex, Bounce $bounce): BounceRegexBounce
+    {
+        $relation = new BounceRegexBounce($regex->getId() ?? 0, $bounce->getId() ?? 0);
+        $this->entityManager->persist($relation);
+
+        $regex->setCount(($regex->getCount() ?? 0) + 1);
+
+        return $relation;
+    }
+}
diff --git a/src/Domain/Messaging/Service/Manager/BounceRuleManager.php b/src/Domain/Messaging/Service/Manager/BounceRuleManager.php
new file mode 100644
index 00000000..70a750a9
--- /dev/null
+++ b/src/Domain/Messaging/Service/Manager/BounceRuleManager.php
@@ -0,0 +1,110 @@
+
+     */
+    public function loadActiveRules(): array
+    {
+        return $this->mapRows($this->repository->fetchActiveOrdered());
+    }
+
+    /**
+     * @return array
+     */
+    public function loadAllRules(): array
+    {
+        return $this->mapRows($this->repository->fetchAllOrdered());
+    }
+
+    /**
+     * Internal helper to normalize repository rows into the legacy shape.
+     *
+     * @param BounceRegex[] $rows
+     * @return array
+     */
+    private function mapRows(array $rows): array
+    {
+        $result = [];
+
+        foreach ($rows as $row) {
+            $regex = $row->getRegex();
+            $action = $row->getAction();
+            $id = $row->getId();
+
+            if (!is_string($regex)
+                || $regex === ''
+                || !is_string($action)
+                || $action === ''
+                || !is_int($id)
+            ) {
+                continue;
+            }
+
+            $result[$regex] = $row;
+        }
+
+        return $result;
+    }
+
+
+    /**
+     * @param array $rules
+     */
+    public function matchBounceRules(string $text, array $rules): ?BounceRegex
+    {
+        foreach ($rules as $pattern => $rule) {
+            $quoted = '/'.preg_quote(str_replace(' ', '\s+', $pattern)).'/iUm';
+            if ($this->safePregMatch($quoted, $text)) {
+                return $rule;
+            }
+            $raw = '/'.str_replace(' ', '\s+', $pattern).'/iUm';
+            if ($this->safePregMatch($raw, $text)) {
+                return $rule;
+            }
+        }
+
+        return null;
+    }
+
+    private function safePregMatch(string $pattern, string $subject): bool
+    {
+        set_error_handler(static fn() => true);
+        $result = preg_match($pattern, $subject) === 1;
+        restore_error_handler();
+
+        return $result;
+    }
+
+    public function incrementCount(BounceRegex $rule): void
+    {
+        $rule->setCount($rule->getCount() + 1);
+
+        $this->repository->save($rule);
+    }
+
+    public function linkRuleToBounce(BounceRegex $rule, Bounce $bounce): BounceregexBounce
+    {
+        $relation = new BounceRegexBounce($rule->getId(), $bounce->getId());
+        $this->bounceRelationRepository->save($relation);
+
+        return $relation;
+    }
+}
diff --git a/src/Domain/Messaging/Service/Manager/ListMessageManager.php b/src/Domain/Messaging/Service/Manager/ListMessageManager.php
index 22176113..bc6a6bfe 100644
--- a/src/Domain/Messaging/Service/Manager/ListMessageManager.php
+++ b/src/Domain/Messaging/Service/Manager/ListMessageManager.php
@@ -40,10 +40,8 @@ public function associateMessageWithList(Message $message, SubscriberList $subsc
         $listMessage = new ListMessage();
         $listMessage->setMessage($message);
         $listMessage->setList($subscriberList);
-        $listMessage->setEntered(new DateTime());
 
         $this->entityManager->persist($listMessage);
-        $this->entityManager->flush();
 
         return $listMessage;
     }
diff --git a/src/Domain/Messaging/Service/MessageManager.php b/src/Domain/Messaging/Service/Manager/MessageManager.php
similarity index 81%
rename from src/Domain/Messaging/Service/MessageManager.php
rename to src/Domain/Messaging/Service/Manager/MessageManager.php
index 9af4df0b..c11d34d9 100644
--- a/src/Domain/Messaging/Service/MessageManager.php
+++ b/src/Domain/Messaging/Service/Manager/MessageManager.php
@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace PhpList\Core\Domain\Messaging\Service;
+namespace PhpList\Core\Domain\Messaging\Service\Manager;
 
 use PhpList\Core\Domain\Identity\Model\Administrator;
 use PhpList\Core\Domain\Messaging\Model\Dto\MessageContext;
@@ -26,7 +26,7 @@ public function createMessage(MessageDtoInterface $createMessageDto, Administrat
     {
         $context = new MessageContext($authUser);
         $message = $this->messageBuilder->build($createMessageDto, $context);
-        $this->messageRepository->save($message);
+        $this->messageRepository->persist($message);
 
         return $message;
     }
@@ -37,8 +37,12 @@ public function updateMessage(
         Administrator $authUser
     ): Message {
         $context = new MessageContext($authUser, $message);
-        $message = $this->messageBuilder->build($updateMessageDto, $context);
-        $this->messageRepository->save($message);
+        return $this->messageBuilder->build($updateMessageDto, $context);
+    }
+
+    public function updateStatus(Message $message, Message\MessageStatus $status): Message
+    {
+        $message->getMetadata()->setStatus($status);
 
         return $message;
     }
diff --git a/src/Domain/Messaging/Service/Manager/SendProcessManager.php b/src/Domain/Messaging/Service/Manager/SendProcessManager.php
new file mode 100644
index 00000000..23d16761
--- /dev/null
+++ b/src/Domain/Messaging/Service/Manager/SendProcessManager.php
@@ -0,0 +1,56 @@
+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);
+
+        return $sendProcess;
+    }
+
+
+    /**
+     * @return array{id:int, age:int}|null
+     */
+    public function findNewestAliveWithAge(string $page): ?array
+    {
+        $row = $this->repository->findNewestAlive($page);
+
+        if (!$row instanceof SendProcess) {
+            return null;
+        }
+
+        $modified = $row->getUpdatedAt();
+        $age = $modified ? max(0, time() - (int)$modified->format('U')) : 0;
+
+        return [
+            'id'  => $row->getId(),
+            'age' => $age,
+        ];
+    }
+}
diff --git a/src/Domain/Messaging/Service/TemplateImageManager.php b/src/Domain/Messaging/Service/Manager/TemplateImageManager.php
similarity index 97%
rename from src/Domain/Messaging/Service/TemplateImageManager.php
rename to src/Domain/Messaging/Service/Manager/TemplateImageManager.php
index c5ebd3f4..68b04f40 100644
--- a/src/Domain/Messaging/Service/TemplateImageManager.php
+++ b/src/Domain/Messaging/Service/Manager/TemplateImageManager.php
@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace PhpList\Core\Domain\Messaging\Service;
+namespace PhpList\Core\Domain\Messaging\Service\Manager;
 
 use Doctrine\ORM\EntityManagerInterface;
 use DOMDocument;
@@ -50,8 +50,6 @@ public function createImagesFromImagePaths(array $imagePaths, Template $template
             $templateImages[] = $image;
         }
 
-        $this->entityManager->flush();
-
         return $templateImages;
     }
 
diff --git a/src/Domain/Messaging/Service/TemplateManager.php b/src/Domain/Messaging/Service/Manager/TemplateManager.php
similarity index 90%
rename from src/Domain/Messaging/Service/TemplateManager.php
rename to src/Domain/Messaging/Service/Manager/TemplateManager.php
index 35678484..80447d8b 100644
--- a/src/Domain/Messaging/Service/TemplateManager.php
+++ b/src/Domain/Messaging/Service/Manager/TemplateManager.php
@@ -2,9 +2,8 @@
 
 declare(strict_types=1);
 
-namespace PhpList\Core\Domain\Messaging\Service;
+namespace PhpList\Core\Domain\Messaging\Service\Manager;
 
-use Doctrine\ORM\EntityManagerInterface;
 use PhpList\Core\Domain\Common\Model\ValidationContext;
 use PhpList\Core\Domain\Messaging\Model\Dto\CreateTemplateDto;
 use PhpList\Core\Domain\Messaging\Model\Template;
@@ -17,20 +16,17 @@
 class TemplateManager
 {
     private TemplateRepository $templateRepository;
-    private EntityManagerInterface $entityManager;
     private TemplateImageManager $templateImageManager;
     private TemplateLinkValidator $templateLinkValidator;
     private TemplateImageValidator $templateImageValidator;
 
     public function __construct(
         TemplateRepository $templateRepository,
-        EntityManagerInterface $entityManager,
         TemplateImageManager $templateImageManager,
         TemplateLinkValidator $templateLinkValidator,
         TemplateImageValidator $templateImageValidator
     ) {
         $this->templateRepository = $templateRepository;
-        $this->entityManager = $entityManager;
         $this->templateImageManager = $templateImageManager;
         $this->templateLinkValidator = $templateLinkValidator;
         $this->templateImageValidator = $templateImageValidator;
@@ -56,7 +52,7 @@ public function create(CreateTemplateDto $createTemplateDto): Template
         $imageUrls = $this->templateImageManager->extractAllImages($template->getContent() ?? '');
         $this->templateImageValidator->validate($imageUrls, $context);
 
-        $this->templateRepository->save($template);
+        $this->templateRepository->persist($template);
 
         $this->templateImageManager->createImagesFromImagePaths($imageUrls, $template);
 
@@ -75,8 +71,6 @@ public function update(UpdateSubscriberDto $updateSubscriberDto): Subscriber
         $subscriber->setDisabled($updateSubscriberDto->disabled);
         $subscriber->setExtraData($updateSubscriberDto->additionalData);
 
-        $this->entityManager->flush();
-
         return $subscriber;
     }
 
diff --git a/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php b/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php
new file mode 100644
index 00000000..b3de16f9
--- /dev/null
+++ b/src/Domain/Messaging/Service/MaxProcessTimeLimiter.php
@@ -0,0 +1,50 @@
+maxSeconds = $maxSeconds ?? 600;
+    }
+
+    public function start(): void
+    {
+        $this->startedAt = microtime(true);
+    }
+
+    public function shouldStop(?OutputInterface $output = null): bool
+    {
+        if ($this->maxSeconds <= 0) {
+            return false;
+        }
+        if ($this->startedAt <= 0.0) {
+            $this->start();
+        }
+        $elapsed = microtime(true) - $this->startedAt;
+        if ($elapsed >= $this->maxSeconds) {
+            $this->logger->warning(sprintf('Reached max processing time of %d seconds', $this->maxSeconds));
+            $output?->writeln($this->translator->trans('Reached max processing time; stopping cleanly.'));
+
+            return true;
+        }
+
+        return false;
+    }
+}
diff --git a/src/Domain/Messaging/Service/MessageProcessingPreparator.php b/src/Domain/Messaging/Service/MessageProcessingPreparator.php
index c602f7d4..5255914d 100644
--- a/src/Domain/Messaging/Service/MessageProcessingPreparator.php
+++ b/src/Domain/Messaging/Service/MessageProcessingPreparator.php
@@ -4,33 +4,33 @@
 
 namespace PhpList\Core\Domain\Messaging\Service;
 
-use Doctrine\ORM\EntityManagerInterface;
 use PhpList\Core\Domain\Analytics\Service\LinkTrackService;
 use PhpList\Core\Domain\Messaging\Model\Message;
 use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
 use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
 use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
 class MessageProcessingPreparator
 {
     // phpcs:ignore Generic.Commenting.Todo
     // @todo: create functionality to track
     public const LINT_TRACK_ENDPOINT = '/api/v2/link-track';
-    private EntityManagerInterface $entityManager;
     private SubscriberRepository $subscriberRepository;
     private MessageRepository $messageRepository;
     private LinkTrackService $linkTrackService;
+    private TranslatorInterface $translator;
 
     public function __construct(
-        EntityManagerInterface $entityManager,
         SubscriberRepository $subscriberRepository,
         MessageRepository $messageRepository,
-        LinkTrackService $linkTrackService
+        LinkTrackService $linkTrackService,
+        TranslatorInterface $translator,
     ) {
-        $this->entityManager = $entityManager;
         $this->subscriberRepository = $subscriberRepository;
         $this->messageRepository = $messageRepository;
         $this->linkTrackService = $linkTrackService;
+        $this->translator = $translator;
     }
 
     public function ensureSubscribersHaveUuid(OutputInterface $output): void
@@ -39,11 +39,12 @@ public function ensureSubscribersHaveUuid(OutputInterface $output): void
 
         $numSubscribers = count($subscribersWithoutUuid);
         if ($numSubscribers > 0) {
-            $output->writeln(sprintf('Giving a UUID to %d subscribers, this may take a while', $numSubscribers));
+            $output->writeln($this->translator->trans('Giving a UUID to %count% subscribers, this may take a while', [
+                '%count%' => $numSubscribers
+            ]));
             foreach ($subscribersWithoutUuid as $subscriber) {
                 $subscriber->setUniqueId(bin2hex(random_bytes(16)));
             }
-            $this->entityManager->flush();
         }
     }
 
@@ -53,11 +54,12 @@ public function ensureCampaignsHaveUuid(OutputInterface $output): void
 
         $numCampaigns = count($campaignsWithoutUuid);
         if ($numCampaigns > 0) {
-            $output->writeln(sprintf('Giving a UUID to %d campaigns', $numCampaigns));
+            $output->writeln($this->translator->trans('Giving a UUID to %count% campaigns', [
+                '%count%' => $numCampaigns
+            ]));
             foreach ($campaignsWithoutUuid as $campaign) {
                 $campaign->setUuid(bin2hex(random_bytes(18)));
             }
-            $this->entityManager->flush();
         }
     }
 
diff --git a/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php b/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php
new file mode 100644
index 00000000..7691f970
--- /dev/null
+++ b/src/Domain/Messaging/Service/RateLimitedCampaignMailer.php
@@ -0,0 +1,50 @@
+mailer = $mailer;
+        $this->limiter = $limiter;
+    }
+
+    public function composeEmail(Message $processed, Subscriber $subscriber): Email
+    {
+        $email = new Email();
+        if ($processed->getOptions()->getFromField() !== '') {
+            $email->from($processed->getOptions()->getFromField());
+        }
+
+        if ($processed->getOptions()->getReplyTo() !== '') {
+            $email->replyTo($processed->getOptions()->getReplyTo());
+        }
+
+        return $email
+            ->to($subscriber->getEmail())
+            ->subject($processed->getContent()->getSubject())
+            ->text($processed->getContent()->getTextMessage())
+            ->html($processed->getContent()->getText());
+    }
+
+    /**
+     * @throws TransportExceptionInterface
+     */
+    public function send(Email $email): void
+    {
+        $this->limiter->awaitTurn();
+        $this->mailer->send($email);
+        $this->limiter->afterSend();
+    }
+}
diff --git a/src/Domain/Messaging/Service/SendRateLimiter.php b/src/Domain/Messaging/Service/SendRateLimiter.php
new file mode 100644
index 00000000..2590e721
--- /dev/null
+++ b/src/Domain/Messaging/Service/SendRateLimiter.php
@@ -0,0 +1,105 @@
+initializeLimits();
+    }
+
+    private function initializeLimits(): void
+    {
+        $isp = $this->ispRestrictionsProvider->load();
+
+        $cfgBatch = $this->mailqueueBatchSize ?? 0;
+        $ispMax = isset($isp->maxBatch) ? (int)$isp->maxBatch : null;
+
+        $cfgPeriod = $this->mailqueueBatchPeriod ?? 0;
+        $ispMinPeriod = $isp->minBatchPeriod ?? 0;
+
+        $cfgThrottle = $this->mailqueueThrottle ?? 0;
+        $ispMinThrottle = (int)($isp->minThrottle ?? 0);
+
+        if ($cfgBatch <= 0) {
+            $this->batchSize = $ispMax !== null ? max(0, $ispMax) : 0;
+        } else {
+            $this->batchSize = $ispMax !== null ? min($cfgBatch, max(1, $ispMax)) : $cfgBatch;
+        }
+        $this->batchPeriod = max(0, $cfgPeriod, $ispMinPeriod);
+        $this->throttleSec = max(0, $cfgThrottle, $ispMinThrottle);
+
+        $this->sentInBatch = 0;
+        $this->batchStart = microtime(true);
+        $this->initializedFromHistory = false;
+    }
+
+    /**
+     * Call before attempting to send another message. It will sleep if needed to
+     * respect batch limits. Returns true when it's okay to proceed.
+     */
+    public function awaitTurn(?OutputInterface $output = null): bool
+    {
+        if (!$this->initializedFromHistory && $this->batchSize > 0 && $this->batchPeriod > 0) {
+            $since = (new DateTimeImmutable())->sub(new DateInterval('PT' . $this->batchPeriod . 'S'));
+            $alreadySent = $this->userMessageRepository->countSentSince($since);
+            $this->sentInBatch = max($this->sentInBatch, $alreadySent);
+            $this->initializedFromHistory = true;
+        }
+
+        if ($this->batchSize > 0 && $this->batchPeriod > 0 && $this->sentInBatch >= $this->batchSize) {
+            $elapsed = microtime(true) - $this->batchStart;
+            $remaining = (int)ceil($this->batchPeriod - $elapsed);
+            if ($remaining > 0) {
+                $output?->writeln($this->translator->trans(
+                    'Batch limit reached, sleeping %sleep%s to respect MAILQUEUE_BATCH_PERIOD',
+                    ['%sleep%' => $remaining]
+                ));
+                sleep($remaining);
+            }
+            $this->batchStart = microtime(true);
+            $this->sentInBatch = 0;
+            $this->initializedFromHistory = false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Call after a successful sending to update counters and apply per-message throttle.
+     */
+    public function afterSend(): void
+    {
+        $this->sentInBatch++;
+        if ($this->throttleSec > 0) {
+            sleep($this->throttleSec);
+        }
+    }
+}
diff --git a/src/Domain/Messaging/Validator/TemplateImageValidator.php b/src/Domain/Messaging/Validator/TemplateImageValidator.php
index 11bcc329..5e50e075 100644
--- a/src/Domain/Messaging/Validator/TemplateImageValidator.php
+++ b/src/Domain/Messaging/Validator/TemplateImageValidator.php
@@ -9,18 +9,21 @@
 use PhpList\Core\Domain\Common\Model\ValidationContext;
 use PhpList\Core\Domain\Common\Validator\ValidatorInterface;
 use Symfony\Component\Validator\Exception\ValidatorException;
+use Symfony\Contracts\Translation\TranslatorInterface;
 use Throwable;
 
 class TemplateImageValidator implements ValidatorInterface
 {
-    public function __construct(private readonly ClientInterface $httpClient)
-    {
+    public function __construct(
+        private readonly ClientInterface $httpClient,
+        private readonly TranslatorInterface $translator,
+    ) {
     }
 
     public function validate(mixed $value, ValidationContext $context = null): void
     {
         if (!is_array($value)) {
-            throw new InvalidArgumentException('Value must be an array of image URLs.');
+            throw new InvalidArgumentException($this->translator->trans('Value must be an array of image URLs.'));
         }
 
         $checkFull = $context?->get('checkImages', false);
@@ -42,7 +45,7 @@ private function validateFullUrls(array $urls): array
 
         foreach ($urls as $url) {
             if (!preg_match('#^https?://#i', $url)) {
-                $errors[] = sprintf('Image "%s" is not a full URL.', $url);
+                $errors[] = $this->translator->trans('Image "%url%" is not a full URL.', ['%url%' => $url]);
             }
         }
 
@@ -61,10 +64,16 @@ private function validateExistence(array $urls): array
             try {
                 $response = $this->httpClient->request('HEAD', $url);
                 if ($response->getStatusCode() !== 200) {
-                    $errors[] = sprintf('Image "%s" does not exist (HTTP %s)', $url, $response->getStatusCode());
+                    $errors[] = $this->translator->trans('Image "%url%" does not exist (HTTP %code%)', [
+                        '%url%' => $url,
+                        '%code%' => $response->getStatusCode()
+                    ]);
                 }
             } catch (Throwable $e) {
-                $errors[] = sprintf('Image "%s" could not be validated: %s', $url, $e->getMessage());
+                $errors[] = $this->translator->trans('Image "%url%" could not be validated: %message%', [
+                    '%url%' => $url,
+                    '%message%' => $e->getMessage()
+                ]);
             }
         }
 
diff --git a/src/Domain/Messaging/Validator/TemplateLinkValidator.php b/src/Domain/Messaging/Validator/TemplateLinkValidator.php
index 18c772df..621f35a7 100644
--- a/src/Domain/Messaging/Validator/TemplateLinkValidator.php
+++ b/src/Domain/Messaging/Validator/TemplateLinkValidator.php
@@ -8,9 +8,14 @@
 use PhpList\Core\Domain\Common\Model\ValidationContext;
 use PhpList\Core\Domain\Common\Validator\ValidatorInterface;
 use Symfony\Component\Validator\Exception\ValidatorException;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
 class TemplateLinkValidator implements ValidatorInterface
 {
+    public function __construct(private readonly TranslatorInterface $translator)
+    {
+    }
+
     private const PLACEHOLDERS = [
         '[PREFERENCESURL]',
         '[UNSUBSCRIBEURL]',
@@ -37,10 +42,9 @@ public function validate(mixed $value, ValidationContext $context = null): void
         }
 
         if (!empty($invalid)) {
-            throw new ValidatorException(sprintf(
-                'Not full URLs: %s',
-                implode(', ', $invalid)
-            ));
+            throw new ValidatorException(
+                $this->translator->trans('Not full URLs: %urls%', ['%urls%' => implode(', ', $invalid)]),
+            );
         }
     }
 
diff --git a/src/Domain/Subscription/Exception/CouldNotReadUploadedFileException.php b/src/Domain/Subscription/Exception/CouldNotReadUploadedFileException.php
new file mode 100644
index 00000000..9a9d9c8b
--- /dev/null
+++ b/src/Domain/Subscription/Exception/CouldNotReadUploadedFileException.php
@@ -0,0 +1,12 @@
+statusCode = $statusCode;
diff --git a/src/Domain/Subscription/Model/Dto/ChangeSetDto.php b/src/Domain/Subscription/Model/Dto/ChangeSetDto.php
new file mode 100644
index 00000000..ef3cc2da
--- /dev/null
+++ b/src/Domain/Subscription/Model/Dto/ChangeSetDto.php
@@ -0,0 +1,81 @@
+
+     *
+     * Example:
+     * [
+     *     'email' => [null, 'newemail@example.com'],
+     *     'isActive' => [true, false]
+     * ]
+     */
+    private array $changes = [];
+
+    /**
+     * @param array $changes
+     */
+    public function __construct(array $changes = [])
+    {
+        $this->changes = $changes;
+    }
+
+    /**
+     * @return array
+     */
+    public function getChanges(): array
+    {
+        return $this->changes;
+    }
+
+    public function hasChanges(): bool
+    {
+        return !empty($this->changes);
+    }
+
+    public function hasField(string $field): bool
+    {
+        return array_key_exists($field, $this->changes);
+    }
+
+    /**
+     * @return array{0: mixed, 1: mixed}|null
+     */
+    public function getFieldChange(string $field): ?array
+    {
+        return $this->changes[$field] ?? null;
+    }
+
+    /**
+     * @return mixed|null
+     */
+    public function getOldValue(string $field): mixed
+    {
+        return $this->changes[$field][0] ?? null;
+    }
+
+    /**
+     * @return mixed|null
+     */
+    public function getNewValue(string $field): mixed
+    {
+        return $this->changes[$field][1] ?? null;
+    }
+
+    public function toArray(): array
+    {
+        return $this->changes;
+    }
+
+    public static function fromDoctrineChangeSet(array $changeSet): self
+    {
+        return new self($changeSet);
+    }
+}
diff --git a/src/Domain/Subscription/Model/SubscribePage.php b/src/Domain/Subscription/Model/SubscribePage.php
index 7ec518b2..979b3c4c 100644
--- a/src/Domain/Subscription/Model/SubscribePage.php
+++ b/src/Domain/Subscription/Model/SubscribePage.php
@@ -7,11 +7,13 @@
 use Doctrine\ORM\Mapping as ORM;
 use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
 use PhpList\Core\Domain\Common\Model\Interfaces\Identity;
+use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface;
+use PhpList\Core\Domain\Identity\Model\Administrator;
 use PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository;
 
 #[ORM\Entity(repositoryClass: SubscriberPageRepository::class)]
 #[ORM\Table(name: 'phplist_subscribepage')]
-class SubscribePage implements DomainModel, Identity
+class SubscribePage implements DomainModel, Identity, OwnableInterface
 {
     #[ORM\Id]
     #[ORM\Column(type: 'integer')]
@@ -24,8 +26,9 @@ class SubscribePage implements DomainModel, Identity
     #[ORM\Column(name: 'active', type: 'boolean', options: ['default' => 0])]
     private bool $active = false;
 
-    #[ORM\Column(name: 'owner', type: 'integer', nullable: true)]
-    private ?int $owner = null;
+    #[ORM\ManyToOne(targetEntity: Administrator::class)]
+    #[ORM\JoinColumn(name: 'owner', referencedColumnName: 'id', nullable: true)]
+    private ?Administrator $owner = null;
 
     public function getId(): ?int
     {
@@ -42,7 +45,7 @@ public function isActive(): bool
         return $this->active;
     }
 
-    public function getOwner(): ?int
+    public function getOwner(): ?Administrator
     {
         return $this->owner;
     }
@@ -59,7 +62,7 @@ public function setActive(bool $active): self
         return $this;
     }
 
-    public function setOwner(?int $owner): self
+    public function setOwner(?Administrator $owner): self
     {
         $this->owner = $owner;
         return $this;
diff --git a/src/Domain/Subscription/Model/Subscriber.php b/src/Domain/Subscription/Model/Subscriber.php
index d48e5730..a0c35d73 100644
--- a/src/Domain/Subscription/Model/Subscriber.php
+++ b/src/Domain/Subscription/Model/Subscriber.php
@@ -19,13 +19,18 @@
  * campaigns for those subscriber lists.
  * @author Oliver Klee 
  * @author Tatevik Grigoryan 
+ * @SuppressWarnings(TooManyFields)
  */
 #[ORM\Entity(repositoryClass: SubscriberRepository::class)]
 #[ORM\Table(name: 'phplist_user_user')]
-#[ORM\Index(name: 'idxuniqid', columns: ['uniqid'])]
-#[ORM\Index(name: 'enteredindex', columns: ['entered'])]
-#[ORM\Index(name: 'confidx', columns: ['confirmed'])]
-#[ORM\Index(name: 'blidx', columns: ['blacklisted'])]
+#[ORM\Index(name: 'phplist_user_user_idxuniqid', columns: ['uniqid'])]
+#[ORM\Index(name: 'phplist_user_user_enteredindex', columns: ['entered'])]
+#[ORM\Index(name: 'phplist_user_user_confidx', columns: ['confirmed'])]
+#[ORM\Index(name: 'phplist_user_user_blidx', columns: ['blacklisted'])]
+#[ORM\Index(name: 'phplist_user_user_optidx', columns: ['optedin'])]
+#[ORM\Index(name: 'phplist_user_user_uuididx', columns: ['uuid'])]
+#[ORM\Index(name: 'phplist_user_user_foreignkey', columns: ['foreignkey'])]
+#[ORM\UniqueConstraint(name: 'phplist_user_user_email', columns: ['email'])]
 #[ORM\HasLifecycleCallbacks]
 class Subscriber implements DomainModel, Identity, CreationDate, ModificationDate
 {
@@ -37,8 +42,8 @@ class Subscriber implements DomainModel, Identity, CreationDate, ModificationDat
     #[ORM\Column(name: 'entered', type: 'datetime', nullable: true)]
     protected ?DateTime $createdAt = null;
 
-    #[ORM\Column(name: 'modified', type: 'datetime')]
-    private ?DateTime $updatedAt = null;
+    #[ORM\Column(name: 'modified', type: 'datetime', nullable: false)]
+    private DateTime $updatedAt;
 
     #[ORM\Column(unique: true)]
     private string $email = '';
@@ -52,7 +57,7 @@ class Subscriber implements DomainModel, Identity, CreationDate, ModificationDat
     #[ORM\Column(name: 'bouncecount', type: 'integer')]
     private int $bounceCount = 0;
 
-    #[ORM\Column(name: 'uniqid', unique: true)]
+    #[ORM\Column(name: 'uniqid', type: 'string', length: 255, nullable: true)]
     private string $uniqueId = '';
 
     #[ORM\Column(name: 'htmlemail', type: 'boolean')]
@@ -61,8 +66,8 @@ class Subscriber implements DomainModel, Identity, CreationDate, ModificationDat
     #[ORM\Column(type: 'boolean')]
     private bool $disabled = false;
 
-    #[ORM\Column(name: 'extradata', type: 'text')]
-    private ?string $extraData;
+    #[ORM\Column(name: 'extradata', type: 'text', nullable: true)]
+    private ?string $extraData = null;
 
     #[ORM\OneToMany(
         targetEntity: Subscription::class,
@@ -83,12 +88,34 @@ class Subscriber implements DomainModel, Identity, CreationDate, ModificationDat
     )]
     private Collection $attributes;
 
+    #[ORM\Column(name: 'optedin', type: 'boolean')]
+    private bool $optedIn = false;
+
+    #[ORM\Column(name: 'uuid', type: 'string', length: 36)]
+    private string $uuid = '';
+
+    #[ORM\Column(name: 'subscribepage', type: 'integer', nullable: true)]
+    private ?int $subscribePage = null;
+
+    #[ORM\Column(name: 'rssfrequency', type: 'string', length: 100, nullable: true)]
+    private ?string $rssFrequency = null;
+
+    #[ORM\Column(name: 'password', type: 'string', length: 255, nullable: true)]
+    private ?string $password = null;
+
+    #[ORM\Column(name: 'passwordchanged', type: 'datetime', nullable: true)]
+    private ?DateTime $passwordChanged = null;
+
+    #[ORM\Column(name: 'foreignkey', type: 'string', length: 100, nullable: true)]
+    private ?string $foreignKey = null;
+
     public function __construct()
     {
         $this->subscriptions = new ArrayCollection();
         $this->attributes = new ArrayCollection();
         $this->extraData = '';
         $this->createdAt = new DateTime();
+        $this->updatedAt = new DateTime();
     }
 
     public function getId(): ?int
@@ -101,18 +128,15 @@ public function getCreatedAt(): ?DateTime
         return $this->createdAt;
     }
 
-    public function getUpdatedAt(): ?DateTime
+    public function getUpdatedAt(): DateTime
     {
         return $this->updatedAt;
     }
 
-    #[ORM\PrePersist]
     #[ORM\PreUpdate]
-    public function updateUpdatedAt(): DomainModel
+    public function updateUpdatedAt(): void
     {
         $this->updatedAt = new DateTime();
-
-        return $this;
     }
 
     public function isConfirmed(): bool
@@ -281,4 +305,74 @@ public function removeAttribute(SubscriberAttributeValue $attribute): self
         $this->attributes->removeElement($attribute);
         return $this;
     }
+
+    public function isOptedIn(): bool
+    {
+        return $this->optedIn;
+    }
+
+    public function setOptedIn(bool $optedIn): void
+    {
+        $this->optedIn = $optedIn;
+    }
+
+    public function getUuid(): string
+    {
+        return $this->uuid;
+    }
+
+    public function setUuid(string $uuid): void
+    {
+        $this->uuid = $uuid;
+    }
+
+    public function getSubscribePage(): ?int
+    {
+        return $this->subscribePage;
+    }
+
+    public function setSubscribePage(?int $subscribePage): void
+    {
+        $this->subscribePage = $subscribePage;
+    }
+
+    public function getRssFrequency(): ?string
+    {
+        return $this->rssFrequency;
+    }
+
+    public function setRssFrequency(?string $rssFrequency): void
+    {
+        $this->rssFrequency = $rssFrequency;
+    }
+
+    public function getPassword(): ?string
+    {
+        return $this->password;
+    }
+
+    public function setPassword(?string $password): void
+    {
+        $this->password = $password;
+    }
+
+    public function getPasswordChanged(): ?DateTime
+    {
+        return $this->passwordChanged;
+    }
+
+    public function setPasswordChanged(?DateTime $passwordChanged): void
+    {
+        $this->passwordChanged = $passwordChanged;
+    }
+
+    public function getForeignKey(): ?string
+    {
+        return $this->foreignKey;
+    }
+
+    public function setForeignKey(?string $foreignKey): void
+    {
+        $this->foreignKey = $foreignKey;
+    }
 }
diff --git a/src/Domain/Subscription/Model/SubscriberAttributeDefinition.php b/src/Domain/Subscription/Model/SubscriberAttributeDefinition.php
index dc7259d6..7b943b5c 100644
--- a/src/Domain/Subscription/Model/SubscriberAttributeDefinition.php
+++ b/src/Domain/Subscription/Model/SubscriberAttributeDefinition.php
@@ -11,8 +11,8 @@
 
 #[ORM\Entity(repositoryClass: SubscriberAttributeDefinitionRepository::class)]
 #[ORM\Table(name: 'phplist_user_attribute')]
-#[ORM\Index(name: 'idnameindex', columns: ['id', 'name'])]
-#[ORM\Index(name: 'nameindex', columns: ['name'])]
+#[ORM\Index(name: 'phplist_user_attribute_idnameindex', columns: ['id', 'name'])]
+#[ORM\Index(name: 'phplist_user_attribute_nameindex', columns: ['name'])]
 class SubscriberAttributeDefinition implements DomainModel, Identity
 {
     #[ORM\Id]
diff --git a/src/Domain/Subscription/Model/SubscriberAttributeValue.php b/src/Domain/Subscription/Model/SubscriberAttributeValue.php
index 05209709..d2144f95 100644
--- a/src/Domain/Subscription/Model/SubscriberAttributeValue.php
+++ b/src/Domain/Subscription/Model/SubscriberAttributeValue.php
@@ -10,9 +10,9 @@
 
 #[ORM\Entity(repositoryClass: SubscriberAttributeValueRepository::class)]
 #[ORM\Table(name: 'phplist_user_user_attribute')]
-#[ORM\Index(name: 'attindex', columns: ['attributeid'])]
-#[ORM\Index(name: 'attuserid', columns: ['userid', 'attributeid'])]
-#[ORM\Index(name: 'userindex', columns: ['userid'])]
+#[ORM\Index(name: 'phplist_user_user_attribute_attindex', columns: ['attributeid'])]
+#[ORM\Index(name: 'phplist_user_user_attribute_attuserid', columns: ['userid', 'attributeid'])]
+#[ORM\Index(name: 'phplist_user_user_attribute_userindex', columns: ['userid'])]
 class SubscriberAttributeValue implements DomainModel
 {
     #[ORM\Id]
diff --git a/src/Domain/Subscription/Model/SubscriberHistory.php b/src/Domain/Subscription/Model/SubscriberHistory.php
index 0eaa3f7a..1799c01b 100644
--- a/src/Domain/Subscription/Model/SubscriberHistory.php
+++ b/src/Domain/Subscription/Model/SubscriberHistory.php
@@ -12,8 +12,8 @@
 
 #[ORM\Entity(repositoryClass: SubscriberHistoryRepository::class)]
 #[ORM\Table(name: 'phplist_user_user_history')]
-#[ORM\Index(name: 'dateidx', columns: ['date'])]
-#[ORM\Index(name: 'userididx', columns: ['userid'])]
+#[ORM\Index(name: 'phplist_user_user_history_dateidx', columns: ['date'])]
+#[ORM\Index(name: 'phplist_user_user_history_userididx', columns: ['userid'])]
 class SubscriberHistory implements DomainModel, Identity
 {
     #[ORM\Id]
diff --git a/src/Domain/Subscription/Model/SubscriberList.php b/src/Domain/Subscription/Model/SubscriberList.php
index 947cbe26..e23e8735 100644
--- a/src/Domain/Subscription/Model/SubscriberList.php
+++ b/src/Domain/Subscription/Model/SubscriberList.php
@@ -12,6 +12,7 @@
 use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
 use PhpList\Core\Domain\Common\Model\Interfaces\Identity;
 use PhpList\Core\Domain\Common\Model\Interfaces\ModificationDate;
+use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface;
 use PhpList\Core\Domain\Identity\Model\Administrator;
 use PhpList\Core\Domain\Messaging\Model\ListMessage;
 use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository;
@@ -25,10 +26,10 @@
  */
 #[ORM\Entity(repositoryClass: SubscriberListRepository::class)]
 #[ORM\Table(name: 'phplist_list')]
-#[ORM\Index(name: 'nameidx', columns: ['name'])]
-#[ORM\Index(name: 'listorderidx', columns: ['listorder'])]
+#[ORM\Index(name: 'phplist_list_nameidx', columns: ['name'])]
+#[ORM\Index(name: 'phplist_list_listorderidx', columns: ['listorder'])]
 #[ORM\HasLifecycleCallbacks]
-class SubscriberList implements DomainModel, Identity, CreationDate, ModificationDate
+class SubscriberList implements DomainModel, Identity, CreationDate, ModificationDate, OwnableInterface
 {
     #[ORM\Id]
     #[ORM\Column(type: 'integer')]
@@ -38,6 +39,9 @@ class SubscriberList implements DomainModel, Identity, CreationDate, Modificatio
     #[ORM\Column]
     private string $name = '';
 
+    #[ORM\Column(name: 'rssfeed', type: 'string', length: 255, nullable: true)]
+    private ?string $rssFeed = null;
+
     #[ORM\Column]
     private string $description = '';
 
@@ -50,7 +54,7 @@ class SubscriberList implements DomainModel, Identity, CreationDate, Modificatio
     #[ORM\Column(name: 'listorder', type: 'integer', nullable: true)]
     private ?int $listPosition;
 
-    #[ORM\Column(name: 'prefix')]
+    #[ORM\Column(name: 'prefix', length: 10, nullable: true)]
     private ?string $subjectPrefix;
 
     #[ORM\Column(name: 'active', type: 'boolean')]
@@ -91,6 +95,17 @@ public function getId(): ?int
         return $this->id;
     }
 
+    public function getRssFeed(): ?string
+    {
+        return $this->rssFeed;
+    }
+
+    public function setRssFeed(?string $rssFeed): self
+    {
+        $this->rssFeed = $rssFeed;
+        return $this;
+    }
+
     public function getName(): string
     {
         return $this->name;
@@ -102,12 +117,12 @@ public function setName(string $name): self
         return $this;
     }
 
-    public function getDescription(): string
+    public function getDescription(): ?string
     {
         return $this->description;
     }
 
-    public function setDescription(string $description): self
+    public function setDescription(?string $description): self
     {
         $this->description = $description;
 
@@ -216,10 +231,9 @@ public function getUpdatedAt(): ?DateTime
 
     #[ORM\PrePersist]
     #[ORM\PreUpdate]
-    public function updateUpdatedAt(): DomainModel
+    public function updateUpdatedAt(): void
     {
         $this->updatedAt = new DateTime();
-        return $this;
     }
 
     public function getListMessages(): Collection
diff --git a/src/Domain/Subscription/Model/Subscription.php b/src/Domain/Subscription/Model/Subscription.php
index 94e79965..fe4b5e2a 100644
--- a/src/Domain/Subscription/Model/Subscription.php
+++ b/src/Domain/Subscription/Model/Subscription.php
@@ -23,10 +23,10 @@
  */
 #[ORM\Entity(repositoryClass: SubscriptionRepository::class)]
 #[ORM\Table(name: 'phplist_listuser')]
-#[ORM\Index(name: 'userenteredidx', columns: ['userid', 'entered'])]
-#[ORM\Index(name: 'userlistenteredidx', columns: ['userid', 'entered', 'listid'])]
-#[ORM\Index(name: 'useridx', columns: ['userid'])]
-#[ORM\Index(name: 'listidx', columns: ['listid'])]
+#[ORM\Index(name: 'phplist_listuser_userenteredidx', columns: ['userid', 'entered'])]
+#[ORM\Index(name: 'phplist_listuser_userlistenteredidx', columns: ['userid', 'entered', 'listid'])]
+#[ORM\Index(name: 'phplist_listuser_useridx', columns: ['userid'])]
+#[ORM\Index(name: 'phplist_listuser_listidx', columns: ['listid'])]
 #[ORM\HasLifecycleCallbacks]
 class Subscription implements DomainModel, CreationDate, ModificationDate
 {
@@ -95,10 +95,8 @@ public function getUpdatedAt(): ?DateTime
 
     #[ORM\PrePersist]
     #[ORM\PreUpdate]
-    public function updateUpdatedAt(): DomainModel
+    public function updateUpdatedAt(): void
     {
         $this->updatedAt = new DateTime();
-
-        return $this;
     }
 }
diff --git a/src/Domain/Identity/Model/UserBlacklist.php b/src/Domain/Subscription/Model/UserBlacklist.php
similarity index 52%
rename from src/Domain/Identity/Model/UserBlacklist.php
rename to src/Domain/Subscription/Model/UserBlacklist.php
index 1c9d0a30..9b150686 100644
--- a/src/Domain/Identity/Model/UserBlacklist.php
+++ b/src/Domain/Subscription/Model/UserBlacklist.php
@@ -2,16 +2,16 @@
 
 declare(strict_types=1);
 
-namespace PhpList\Core\Domain\Identity\Model;
+namespace PhpList\Core\Domain\Subscription\Model;
 
 use DateTime;
 use Doctrine\ORM\Mapping as ORM;
 use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
-use PhpList\Core\Domain\Identity\Repository\UserBlacklistRepository;
+use PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository;
 
 #[ORM\Entity(repositoryClass: UserBlacklistRepository::class)]
 #[ORM\Table(name: 'phplist_user_blacklist')]
-#[ORM\Index(name: 'emailidx', columns: ['email'])]
+#[ORM\Index(name: 'phplist_user_blacklist_emailidx', columns: ['email'])]
 class UserBlacklist implements DomainModel
 {
     #[ORM\Id]
@@ -21,6 +21,15 @@ class UserBlacklist implements DomainModel
     #[ORM\Column(name: 'added', type: 'datetime', nullable: true)]
     private ?DateTime $added = null;
 
+    #[ORM\OneToOne(targetEntity: UserBlacklistData::class, mappedBy: 'blacklist', cascade: ['persist', 'remove'])]
+    private ?UserBlacklistData $blacklistData = null;
+
+    public function __construct(string $email)
+    {
+        $this->email = $email;
+        $this->added = new DateTime();
+    }
+
     public function getEmail(): string
     {
         return $this->email;
@@ -31,15 +40,23 @@ public function getAdded(): ?DateTime
         return $this->added;
     }
 
-    public function setEmail(string $email): self
+    public function setAdded(?DateTime $added): self
     {
-        $this->email = $email;
+        $this->added = $added;
         return $this;
     }
 
-    public function setAdded(?DateTime $added): self
+    public function getBlacklistData(): ?UserBlacklistData
     {
-        $this->added = $added;
+        return $this->blacklistData;
+    }
+
+    public function setBlacklistData(?UserBlacklistData $data): self
+    {
+        $this->blacklistData = $data;
+        if ($data && $data->getBlacklist() !== $this) {
+            $data->setBlacklist($this);
+        }
         return $this;
     }
 }
diff --git a/src/Domain/Identity/Model/UserBlacklistData.php b/src/Domain/Subscription/Model/UserBlacklistData.php
similarity index 55%
rename from src/Domain/Identity/Model/UserBlacklistData.php
rename to src/Domain/Subscription/Model/UserBlacklistData.php
index 09697616..4ac769e9 100644
--- a/src/Domain/Identity/Model/UserBlacklistData.php
+++ b/src/Domain/Subscription/Model/UserBlacklistData.php
@@ -2,21 +2,22 @@
 
 declare(strict_types=1);
 
-namespace PhpList\Core\Domain\Identity\Model;
+namespace PhpList\Core\Domain\Subscription\Model;
 
 use Doctrine\ORM\Mapping as ORM;
 use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel;
-use PhpList\Core\Domain\Identity\Repository\UserBlacklistDataRepository;
+use PhpList\Core\Domain\Subscription\Repository\UserBlacklistDataRepository;
 
 #[ORM\Entity(repositoryClass: UserBlacklistDataRepository::class)]
 #[ORM\Table(name: 'phplist_user_blacklist_data')]
-#[ORM\Index(name: 'emailidx', columns: ['email'])]
-#[ORM\Index(name: 'emailnameidx', columns: ['email', 'name'])]
+#[ORM\Index(name: 'phplist_user_blacklist_data_emailidx', columns: ['email'])]
+#[ORM\Index(name: 'phplist_user_blacklist_data_emailnameidx', columns: ['email', 'name'])]
 class UserBlacklistData implements DomainModel
 {
     #[ORM\Id]
-    #[ORM\Column(name: 'email', type: 'string', length: 150)]
-    private string $email;
+    #[ORM\OneToOne(targetEntity: UserBlacklist::class, inversedBy: 'blacklistData')]
+    #[ORM\JoinColumn(name: 'email', referencedColumnName: 'email', nullable: false, onDelete: 'CASCADE')]
+    private UserBlacklist $blacklist;
 
     #[ORM\Column(name: 'name', type: 'string', length: 25)]
     private string $name;
@@ -24,9 +25,20 @@ class UserBlacklistData implements DomainModel
     #[ORM\Column(name: 'data', type: 'text', nullable: true)]
     private ?string $data = null;
 
+    public function getBlacklist(): UserBlacklist
+    {
+        return $this->blacklist;
+    }
+
+    public function setBlacklist(UserBlacklist $blacklist): self
+    {
+        $this->blacklist = $blacklist;
+        return $this;
+    }
+
     public function getEmail(): string
     {
-        return $this->email;
+        return $this->blacklist->getEmail();
     }
 
     public function getName(): string
@@ -39,12 +51,6 @@ public function getData(): ?string
         return $this->data;
     }
 
-    public function setEmail(string $email): self
-    {
-        $this->email = $email;
-        return $this;
-    }
-
     public function setName(string $name): self
     {
         $this->name = $name;
diff --git a/src/Domain/Subscription/Repository/DynamicListAttrRepository.php b/src/Domain/Subscription/Repository/DynamicListAttrRepository.php
new file mode 100644
index 00000000..104938b0
--- /dev/null
+++ b/src/Domain/Subscription/Repository/DynamicListAttrRepository.php
@@ -0,0 +1,62 @@
+
+     * @throws InvalidArgumentException
+     */
+    public function fetchOptionNames(string $listTable, array $ids): array
+    {
+        if (empty($ids)) {
+            return [];
+        }
+
+        if (!preg_match('/^[A-Za-z0-9_]+$/', $listTable)) {
+            throw new InvalidArgumentException('Invalid list table');
+        }
+
+        $table = $this->prefix . 'listattr_' . $listTable;
+
+        $queryBuilder = $this->connection->createQueryBuilder();
+        $queryBuilder->select('name')
+            ->from($table)
+            ->where('id IN (:ids)')
+            ->setParameter('ids', array_map('intval', $ids), ArrayParameterType::INTEGER);
+
+        return $queryBuilder->executeQuery()->fetchFirstColumn();
+    }
+
+    public function fetchSingleOptionName(string $listTable, int $id): ?string
+    {
+        if (!preg_match('/^[A-Za-z0-9_]+$/', $listTable)) {
+            throw new InvalidArgumentException('Invalid list table');
+        }
+
+        $table = $this->prefix . 'listattr_' . $listTable;
+
+        $queryBuilder = $this->connection->createQueryBuilder();
+        $queryBuilder->select('name')
+            ->from($table)
+            ->where('id = :id')
+            ->setParameter('id', $id);
+
+        $val = $queryBuilder->executeQuery()->fetchOne();
+
+        return $val === false ? null : (string) $val;
+    }
+}
diff --git a/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php b/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php
index d29d56ff..6da037fd 100644
--- a/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php
+++ b/src/Domain/Subscription/Repository/SubscriberAttributeValueRepository.php
@@ -64,4 +64,16 @@ public function getFilteredAfterId(int $lastId, int $limit, ?FilterRequestInterf
             ->getQuery()
             ->getResult();
     }
+
+    /** @return SubscriberAttributeValue[] */
+    public function getForSubscriber(Subscriber $subscriber): array
+    {
+        return $this->createQueryBuilder('sa')
+            ->join('sa.subscriber', 's')
+            ->join('sa.attributeDefinition', 'ad')
+            ->where('s = :subscriber')
+            ->setParameter('subscriber', $subscriber)
+            ->getQuery()
+            ->getResult();
+    }
 }
diff --git a/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php b/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php
index 565930d4..68d0d6bc 100644
--- a/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php
+++ b/src/Domain/Subscription/Repository/SubscriberPageDataRepository.php
@@ -7,8 +7,21 @@
 use PhpList\Core\Domain\Common\Repository\AbstractRepository;
 use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
 use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Subscription\Model\SubscribePage;
+use PhpList\Core\Domain\Subscription\Model\SubscribePageData;
 
 class SubscriberPageDataRepository extends AbstractRepository implements PaginatableRepositoryInterface
 {
     use CursorPaginationTrait;
+
+    public function findByPageAndName(SubscribePage $page, string $name): ?SubscribePageData
+    {
+        return $this->findOneBy(['id' => $page->getId(), 'name' => $name]);
+    }
+
+    /** @return SubscribePageData[] */
+    public function getByPage(SubscribePage $page): array
+    {
+        return $this->findBy(['id' => $page->getId()]);
+    }
 }
diff --git a/src/Domain/Subscription/Repository/SubscriberPageRepository.php b/src/Domain/Subscription/Repository/SubscriberPageRepository.php
index 2a8383c0..136b589c 100644
--- a/src/Domain/Subscription/Repository/SubscriberPageRepository.php
+++ b/src/Domain/Subscription/Repository/SubscriberPageRepository.php
@@ -7,8 +7,24 @@
 use PhpList\Core\Domain\Common\Repository\AbstractRepository;
 use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait;
 use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface;
+use PhpList\Core\Domain\Subscription\Model\SubscribePage;
+use PhpList\Core\Domain\Subscription\Model\SubscribePageData;
 
 class SubscriberPageRepository extends AbstractRepository implements PaginatableRepositoryInterface
 {
     use CursorPaginationTrait;
+
+    /** @return array{page: SubscribePage, data: SubscribePageData}[] */
+    public function findPagesWithData(int $pageId): array
+    {
+        return $this->createQueryBuilder('p')
+            ->select('p AS page, d AS data')
+            ->from(SubscribePage::class, 'p')
+            ->from(SubscribePageData::class, 'd')
+            ->where('p.id = :id')
+            ->andWhere('d.id = p.id')
+            ->setParameter('id', $pageId)
+            ->getQuery()
+            ->getResult();
+    }
 }
diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php
index 762096a0..2ea02474 100644
--- a/src/Domain/Subscription/Repository/SubscriberRepository.php
+++ b/src/Domain/Subscription/Repository/SubscriberRepository.php
@@ -127,4 +127,76 @@ public function findSubscriberWithSubscriptions(int $id): ?Subscriber
             ->getQuery()
             ->getOneOrNullResult();
     }
+
+    public function isEmailBlacklisted(string $email): bool
+    {
+        $queryBuilder = $this->getEntityManager()->createQueryBuilder();
+
+        $queryBuilder->select('u.email')
+            ->from(Subscriber::class, 'u')
+            ->where('u.email = :email')
+            ->andWhere('u.blacklisted = 1')
+            ->setParameter('email', $email)
+            ->setMaxResults(1);
+
+        return !($queryBuilder->getQuery()->getOneOrNullResult() === null);
+    }
+
+    public function incrementBounceCount(int $subscriberId): void
+    {
+        $this->createQueryBuilder('s')
+            ->update()
+            ->set('s.bounceCount', 's.bounceCount + 1')
+            ->where('s.id = :subscriberId')
+            ->setParameter('subscriberId', $subscriberId)
+            ->getQuery()
+            ->execute();
+    }
+
+    public function markUnconfirmed(int $subscriberId): void
+    {
+        $this->createQueryBuilder('s')
+            ->update()
+            ->set('s.confirmed', ':confirmed')
+            ->where('s.id = :id')
+            ->setParameter('confirmed', false)
+            ->setParameter('id', $subscriberId)
+            ->getQuery()
+            ->execute();
+    }
+
+    public function markConfirmed(int $subscriberId): void
+    {
+        $this->createQueryBuilder('s')
+            ->update()
+            ->set('s.confirmed', ':confirmed')
+            ->where('s.id = :id')
+            ->setParameter('confirmed', true)
+            ->setParameter('id', $subscriberId)
+            ->getQuery()
+            ->execute();
+    }
+
+    /** @return Subscriber[] */
+    public function distinctUsersWithBouncesConfirmedNotBlacklisted(): array
+    {
+        return $this->createQueryBuilder('s')
+            ->select('s.id')
+            ->where('s.bounceCount > 0')
+            ->andWhere('s.confirmed = 1')
+            ->andWhere('s.blacklisted = 0')
+            ->getQuery()
+            ->getScalarResult();
+    }
+
+    public function decrementBounceCount(Subscriber $subscriber): void
+    {
+        $this->createQueryBuilder('s')
+            ->update()
+            ->set('s.bounceCount', 's.bounceCount - 1')
+            ->where('s.id = :subscriberId')
+            ->setParameter('subscriberId', $subscriber->getId())
+            ->getQuery()
+            ->execute();
+    }
 }
diff --git a/src/Domain/Subscription/Repository/UserBlacklistDataRepository.php b/src/Domain/Subscription/Repository/UserBlacklistDataRepository.php
new file mode 100644
index 00000000..a64525b9
--- /dev/null
+++ b/src/Domain/Subscription/Repository/UserBlacklistDataRepository.php
@@ -0,0 +1,16 @@
+findOneBy(['email' => $email]);
+    }
+}
diff --git a/src/Domain/Subscription/Repository/UserBlacklistRepository.php b/src/Domain/Subscription/Repository/UserBlacklistRepository.php
new file mode 100644
index 00000000..665deb64
--- /dev/null
+++ b/src/Domain/Subscription/Repository/UserBlacklistRepository.php
@@ -0,0 +1,33 @@
+getEntityManager()->createQueryBuilder();
+
+        $queryBuilder->select('ub.email, ub.added, ubd.data AS reason')
+            ->from(UserBlacklist::class, 'ub')
+            ->innerJoin(UserBlacklistData::class, 'ubd', 'WITH', 'ub.email = ubd.email')
+            ->where('ub.email = :email')
+            ->setParameter('email', $email)
+            ->setMaxResults(1);
+
+        return $queryBuilder->getQuery()->getOneOrNullResult();
+    }
+
+    public function findOneByEmail(string $email): ?UserBlacklist
+    {
+        return $this->findOneBy([
+            'email' => $email,
+        ]);
+    }
+}
diff --git a/src/Domain/Subscription/Service/CsvImporter.php b/src/Domain/Subscription/Service/CsvImporter.php
index 3b3729e3..01fb51ea 100644
--- a/src/Domain/Subscription/Service/CsvImporter.php
+++ b/src/Domain/Subscription/Service/CsvImporter.php
@@ -8,6 +8,7 @@
 use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto;
 use Symfony\Component\Validator\Validator\ValidatorInterface;
 use League\Csv\Exception as CsvException;
+use Symfony\Contracts\Translation\TranslatorInterface;
 use Throwable;
 
 class CsvImporter
@@ -15,6 +16,7 @@ class CsvImporter
     public function __construct(
         private readonly CsvRowToDtoMapper $rowMapper,
         private readonly ValidatorInterface $validator,
+        private readonly TranslatorInterface $translator,
     ) {
     }
 
@@ -46,7 +48,9 @@ public function import(string $csvFilePath): array
 
                 $validDtos[] = $dto;
             } catch (Throwable $e) {
-                $errors[$index + 1][] = 'Unexpected error: ' . $e->getMessage();
+                $errors[$index + 1][] = $this->translator->trans('Unexpected error: %error%', [
+                    '%error%' => $e->getMessage()
+                ]);
             }
         }
 
diff --git a/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php b/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php
index d8983e65..5f95640d 100644
--- a/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php
+++ b/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php
@@ -9,25 +9,32 @@
 use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition;
 use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository;
 use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
 class AttributeDefinitionManager
 {
     private SubscriberAttributeDefinitionRepository $definitionRepository;
     private AttributeTypeValidator $attributeTypeValidator;
+    private TranslatorInterface $translator;
 
     public function __construct(
         SubscriberAttributeDefinitionRepository $definitionRepository,
-        AttributeTypeValidator $attributeTypeValidator
+        AttributeTypeValidator $attributeTypeValidator,
+        TranslatorInterface $translator,
     ) {
         $this->definitionRepository = $definitionRepository;
         $this->attributeTypeValidator = $attributeTypeValidator;
+        $this->translator = $translator;
     }
 
     public function create(AttributeDefinitionDto $attributeDefinitionDto): SubscriberAttributeDefinition
     {
         $existingAttribute = $this->definitionRepository->findOneByName($attributeDefinitionDto->name);
         if ($existingAttribute) {
-            throw new AttributeDefinitionCreationException('Attribute definition already exists', 409);
+            throw new AttributeDefinitionCreationException(
+                message: $this->translator->trans('Attribute definition already exists'),
+                statusCode: 409
+            );
         }
         $this->attributeTypeValidator->validate($attributeDefinitionDto->type);
 
@@ -39,7 +46,7 @@ public function create(AttributeDefinitionDto $attributeDefinitionDto): Subscrib
             ->setDefaultValue($attributeDefinitionDto->defaultValue)
             ->setTableName($attributeDefinitionDto->tableName);
 
-        $this->definitionRepository->save($attributeDefinition);
+        $this->definitionRepository->persist($attributeDefinition);
 
         return $attributeDefinition;
     }
@@ -50,7 +57,10 @@ public function update(
     ): SubscriberAttributeDefinition {
         $existingAttribute = $this->definitionRepository->findOneByName($attributeDefinitionDto->name);
         if ($existingAttribute && $existingAttribute->getId() !== $attributeDefinition->getId()) {
-            throw new AttributeDefinitionCreationException('Another attribute with this name already exists.', 409);
+            throw new AttributeDefinitionCreationException(
+                message: $this->translator->trans('Another attribute with this name already exists.'),
+                statusCode: 409
+            );
         }
         $this->attributeTypeValidator->validate($attributeDefinitionDto->type);
 
@@ -62,8 +72,6 @@ public function update(
             ->setDefaultValue($attributeDefinitionDto->defaultValue)
             ->setTableName($attributeDefinitionDto->tableName);
 
-        $this->definitionRepository->save($attributeDefinition);
-
         return $attributeDefinition;
     }
 
diff --git a/src/Domain/Subscription/Service/Manager/SubscribePageManager.php b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php
new file mode 100644
index 00000000..485041a5
--- /dev/null
+++ b/src/Domain/Subscription/Service/Manager/SubscribePageManager.php
@@ -0,0 +1,103 @@
+setTitle($title)
+            ->setActive($active)
+            ->setOwner($owner);
+
+        $this->pageRepository->persist($page);
+
+        return $page;
+    }
+
+    public function getPage(int $id): SubscribePage
+    {
+        /** @var SubscribePage|null $page */
+        $page = $this->pageRepository->find($id);
+        if (!$page) {
+            throw new NotFoundHttpException($this->translator->trans('Subscribe page not found'));
+        }
+
+        return $page;
+    }
+
+    public function updatePage(
+        SubscribePage $page,
+        ?string $title = null,
+        ?bool $active = null,
+        ?Administrator $owner = null
+    ): SubscribePage {
+        if ($title !== null) {
+            $page->setTitle($title);
+        }
+        if ($active !== null) {
+            $page->setActive($active);
+        }
+        if ($owner !== null) {
+            $page->setOwner($owner);
+        }
+
+        return $page;
+    }
+
+    public function setActive(SubscribePage $page, bool $active): void
+    {
+        $page->setActive($active);
+    }
+
+    public function deletePage(SubscribePage $page): void
+    {
+        $this->pageRepository->remove($page);
+    }
+
+    /** @return SubscribePageData[] */
+    public function getPageData(SubscribePage $page): array
+    {
+        return $this->pageDataRepository->getByPage($page,);
+    }
+
+    public function setPageData(SubscribePage $page, string $name, ?string $value): SubscribePageData
+    {
+        /** @var SubscribePageData|null $data */
+        $data = $this->pageDataRepository->findByPageAndName($page, $name);
+
+        if (!$data) {
+            $data = (new SubscribePageData())
+                ->setId((int)$page->getId())
+                ->setName($name);
+            $this->entityManager->persist($data);
+        }
+
+        $data->setData($value);
+
+        return $data;
+    }
+}
diff --git a/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php b/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php
index cf83ca75..75b9e3e2 100644
--- a/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscriberAttributeManager.php
@@ -6,22 +6,31 @@
 
 use Doctrine\ORM\EntityManagerInterface;
 use PhpList\Core\Domain\Subscription\Exception\SubscriberAttributeCreationException;
+use PhpList\Core\Domain\Subscription\Model\Dto\ChangeSetDto;
 use PhpList\Core\Domain\Subscription\Model\Subscriber;
 use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition;
 use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue;
+use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository;
 use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
 class SubscriberAttributeManager
 {
     private SubscriberAttributeValueRepository $attributeRepository;
+    private SubscriberAttributeDefinitionRepository $attrDefinitionRepository;
     private EntityManagerInterface $entityManager;
+    private TranslatorInterface $translator;
 
     public function __construct(
         SubscriberAttributeValueRepository $attributeRepository,
+        SubscriberAttributeDefinitionRepository $attrDefinitionRepository,
         EntityManagerInterface $entityManager,
+        TranslatorInterface $translator,
     ) {
         $this->attributeRepository = $attributeRepository;
+        $this->attrDefinitionRepository = $attrDefinitionRepository;
         $this->entityManager = $entityManager;
+        $this->translator = $translator;
     }
 
     public function createOrUpdate(
@@ -29,6 +38,8 @@ public function createOrUpdate(
         SubscriberAttributeDefinition $definition,
         ?string $value = null
     ): SubscriberAttributeValue {
+        // phpcs:ignore Generic.Commenting.Todo
+        // todo: clarify which attributes can be created/updated
         $subscriberAttribute = $this->attributeRepository
             ->findOneBySubscriberAndAttribute($subscriber, $definition);
 
@@ -38,7 +49,7 @@ public function createOrUpdate(
 
         $value = $value ?? $definition->getDefaultValue();
         if ($value === null) {
-            throw new SubscriberAttributeCreationException('Value is required', 400);
+            throw new SubscriberAttributeCreationException($this->translator->trans('Value is required'));
         }
 
         $subscriberAttribute->setValue($value);
@@ -56,4 +67,23 @@ public function delete(SubscriberAttributeValue $attribute): void
     {
         $this->attributeRepository->remove($attribute);
     }
+
+    public function processAttributes(Subscriber $subscriber, array $attributeData): void
+    {
+        foreach ($attributeData as $key => $value) {
+            $lowerKey = strtolower((string)$key);
+            if (in_array($lowerKey, ChangeSetDto::IGNORED_ATTRIBUTES, true)) {
+                continue;
+            }
+
+            $attributeDefinition = $this->attrDefinitionRepository->findOneByName($key);
+            if ($attributeDefinition !== null) {
+                $this->createOrUpdate(
+                    subscriber: $subscriber,
+                    definition: $attributeDefinition,
+                    value: $value
+                );
+            }
+        }
+    }
 }
diff --git a/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php
new file mode 100644
index 00000000..c51d867f
--- /dev/null
+++ b/src/Domain/Subscription/Service/Manager/SubscriberBlacklistManager.php
@@ -0,0 +1,88 @@
+subscriberRepository->isEmailBlacklisted($email);
+    }
+
+    public function getBlacklistInfo(string $email): ?UserBlacklist
+    {
+        return $this->userBlacklistRepository->findBlacklistInfoByEmail($email);
+    }
+
+    public function addEmailToBlacklist(string $email, ?string $reasonData = null): UserBlacklist
+    {
+        $existing = $this->subscriberRepository->isEmailBlacklisted($email);
+        if ($existing) {
+            return $this->getBlacklistInfo($email);
+        }
+
+        $blacklistEntry = new UserBlacklist($email);
+        $this->entityManager->persist($blacklistEntry);
+
+        if ($reasonData !== null) {
+            $blacklistData = new UserBlacklistData();
+            $blacklistData->setBlacklist($blacklistEntry);
+            $blacklistData->setName('reason');
+            $blacklistData->setData($reasonData);
+            $this->entityManager->persist($blacklistData);
+        }
+
+        return $blacklistEntry;
+    }
+
+    public function addBlacklistData(UserBlacklist $userBlacklist, string $name, string $data): void
+    {
+        $blacklistData = new UserBlacklistData();
+        $blacklistData->setBlacklist($userBlacklist);
+        $blacklistData->setName($name);
+        $blacklistData->setData($data);
+        $this->entityManager->persist($blacklistData);
+    }
+
+    public function removeEmailFromBlacklist(string $email): void
+    {
+        $blacklistEntry = $this->userBlacklistRepository->findOneByEmail($email);
+        if ($blacklistEntry) {
+            $this->entityManager->remove($blacklistEntry);
+        }
+
+        $blacklistData = $this->blacklistDataRepository->findOneByEmail($email);
+        if ($blacklistData) {
+            $this->entityManager->remove($blacklistData);
+        }
+
+        $subscriber = $this->subscriberRepository->findOneByEmail($email);
+        if ($subscriber) {
+            $subscriber->setBlacklisted(false);
+        }
+    }
+
+    public function getBlacklistReason(string $email): ?string
+    {
+        $data = $this->blacklistDataRepository->findOneByEmail($email);
+        return $data ? $data->getData() : null;
+    }
+}
diff --git a/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php b/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php
index 4760acd8..f8d35c0a 100644
--- a/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php
@@ -4,20 +4,140 @@
 
 namespace PhpList\Core\Domain\Subscription\Service\Manager;
 
+use Doctrine\ORM\EntityManagerInterface;
+use PhpList\Core\Domain\Common\ClientIpResolver;
+use PhpList\Core\Domain\Common\SystemInfoCollector;
+use PhpList\Core\Domain\Identity\Model\Administrator;
+use PhpList\Core\Domain\Subscription\Model\Dto\ChangeSetDto;
 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;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
 class SubscriberHistoryManager
 {
     private SubscriberHistoryRepository $repository;
+    private ClientIpResolver $clientIpResolver;
+    private SystemInfoCollector $systemInfoCollector;
+    private TranslatorInterface $translator;
+    private EntityManagerInterface $entityManager;
 
-    public function __construct(SubscriberHistoryRepository $repository)
-    {
+    public function __construct(
+        SubscriberHistoryRepository $repository,
+        ClientIpResolver $clientIpResolver,
+        SystemInfoCollector $systemInfoCollector,
+        TranslatorInterface $translator,
+        EntityManagerInterface $entityManager,
+    ) {
         $this->repository = $repository;
+        $this->clientIpResolver = $clientIpResolver;
+        $this->systemInfoCollector = $systemInfoCollector;
+        $this->translator = $translator;
+        $this->entityManager = $entityManager;
     }
 
     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->entityManager->persist($subscriberHistory);
+
+        return $subscriberHistory;
+    }
+
+    public function addHistoryFromChangeSet(
+        Subscriber $subscriber,
+        string $message,
+        ChangeSetDto $changeSet,
+    ): SubscriberHistory {
+        $details = '';
+        foreach ($changeSet->getChanges() as $attribute => [$old, $new]) {
+            if (in_array($attribute, ChangeSetDto::IGNORED_ATTRIBUTES, true) || $new === null) {
+                continue;
+            }
+            $details .= $this->translator->trans(
+                "%attribute% = %new_value% \n changed from %old_value%",
+                [
+                    '%attribute%' => $attribute,
+                    '%new_value%' => $new,
+                    '%old_value%' => $old ?? $this->translator->trans('(no data)'),
+                ]
+            ) . PHP_EOL;
+        }
+
+        if ($details === '') {
+            $details .= $this->translator->trans('No data changed') . PHP_EOL;
+        }
+
+        return $this->addHistory($subscriber, $message, $details);
+    }
+
+    public function addHistoryFromImport(
+        Subscriber $subscriber,
+        array $listLines,
+        ChangeSetDto $changeSetDto,
+        ?Administrator $admin = null,
+    ): void {
+        $headerLine = sprintf('API-v2-import - %s: %s%s', $admin ? 'Admin' : 'CLI', $admin?->getId(), "\n\n");
+
+        $lines = $this->getHistoryLines($changeSetDto, $listLines);
+
+        $this->addHistory(
+            subscriber: $subscriber,
+            message: 'Import by ' . $admin?->getLoginName(),
+            details: $headerLine . implode(PHP_EOL, $lines) . PHP_EOL
+        );
+    }
+
+    public function addHistoryFromApi(
+        Subscriber $subscriber,
+        array $listLines,
+        ChangeSetDto $updatedData,
+        ?Administrator $admin = null,
+    ): void {
+        $lines = $this->getHistoryLines($updatedData, $listLines);
+
+        $this->addHistory(
+            subscriber: $subscriber,
+            message: $this->translator->trans('Update by %admin%', ['%admin%' => $admin->getLoginName()]),
+            details: implode(PHP_EOL, $lines) . PHP_EOL
+        );
+    }
+
+    private function getHistoryLines(ChangeSetDto $updatedData, array $listLines): array
+    {
+        $lines = [];
+        if (!$updatedData->hasChanges() && empty($listLines)) {
+            $lines[] = $this->translator->trans('No user details changed');
+        } else {
+            foreach ($updatedData->getChanges() as $field => [$old, $new]) {
+                if (in_array($field, ChangeSetDto::IGNORED_ATTRIBUTES, true)) {
+                    continue;
+                }
+                $lines[] = $this->translator->trans(
+                    '%field% = %new% *changed* from %old%',
+                    [
+                        '%field' => $field,
+                        '%new%' => json_encode($new),
+                        '%old%' => json_encode($old)
+                    ],
+                );
+            }
+            foreach ($listLines as $line) {
+                $lines[] = $line;
+            }
+        }
+
+        return $lines;
+    }
 }
diff --git a/src/Domain/Subscription/Service/Manager/SubscriberListManager.php b/src/Domain/Subscription/Service/Manager/SubscriberListManager.php
index a56b48eb..9f51f566 100644
--- a/src/Domain/Subscription/Service/Manager/SubscriberListManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscriberListManager.php
@@ -29,7 +29,7 @@ public function createSubscriberList(
             ->setListPosition($subscriberListDto->listPosition)
             ->setPublic($subscriberListDto->isPublic);
 
-        $this->subscriberListRepository->save($subscriberList);
+        $this->subscriberListRepository->persist($subscriberList);
 
         return $subscriberList;
     }
diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php
index e036f195..1993cd9b 100644
--- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php
@@ -5,7 +5,8 @@
 namespace PhpList\Core\Domain\Subscription\Service\Manager;
 
 use Doctrine\ORM\EntityManagerInterface;
-use PhpList\Core\Domain\Messaging\Message\SubscriberConfirmationMessage;
+use PhpList\Core\Domain\Identity\Model\Administrator;
+use PhpList\Core\Domain\Subscription\Model\Dto\ChangeSetDto;
 use PhpList\Core\Domain\Subscription\Model\Dto\CreateSubscriberDto;
 use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto;
 use PhpList\Core\Domain\Subscription\Model\Dto\UpdateSubscriberDto;
@@ -13,25 +14,28 @@
 use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
 use PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService;
 use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
-use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
 class SubscriberManager
 {
     private SubscriberRepository $subscriberRepository;
     private EntityManagerInterface $entityManager;
-    private MessageBusInterface $messageBus;
     private SubscriberDeletionService $subscriberDeletionService;
+    private TranslatorInterface $translator;
+    private SubscriberHistoryManager $subscriberHistoryManager;
 
     public function __construct(
         SubscriberRepository $subscriberRepository,
         EntityManagerInterface $entityManager,
-        MessageBusInterface $messageBus,
-        SubscriberDeletionService $subscriberDeletionService
+        SubscriberDeletionService $subscriberDeletionService,
+        TranslatorInterface $translator,
+        SubscriberHistoryManager $subscriberHistoryManager,
     ) {
         $this->subscriberRepository = $subscriberRepository;
         $this->entityManager = $entityManager;
-        $this->messageBus = $messageBus;
         $this->subscriberDeletionService = $subscriberDeletionService;
+        $this->translator = $translator;
+        $this->subscriberHistoryManager = $subscriberHistoryManager;
     }
 
     public function createSubscriber(CreateSubscriberDto $subscriberDto): Subscriber
@@ -44,38 +48,18 @@ public function createSubscriber(CreateSubscriberDto $subscriberDto): Subscriber
         $subscriber->setHtmlEmail((bool)$subscriberDto->htmlEmail);
         $subscriber->setDisabled(false);
 
-        $this->subscriberRepository->save($subscriber);
-
-        if ($subscriberDto->requestConfirmation) {
-            $this->sendConfirmationEmail($subscriber);
-        }
+        $this->subscriberRepository->persist($subscriber);
 
         return $subscriber;
     }
 
-    private function sendConfirmationEmail(Subscriber $subscriber): void
-    {
-        $message = new SubscriberConfirmationMessage(
-            email: $subscriber->getEmail(),
-            uniqueId:$subscriber->getUniqueId(),
-            htmlEmail: $subscriber->hasHtmlEmail()
-        );
-
-        $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
+    /** @SuppressWarnings(PHPMD.StaticAccess) */
+    public function updateSubscriber(UpdateSubscriberDto $subscriberDto, Administrator $admin): Subscriber
     {
         /** @var Subscriber $subscriber */
         $subscriber = $this->subscriberRepository->find($subscriberDto->subscriberId);
@@ -87,7 +71,19 @@ public function updateSubscriber(UpdateSubscriberDto $subscriberDto): Subscriber
         $subscriber->setDisabled($subscriberDto->disabled);
         $subscriber->setExtraData($subscriberDto->additionalData);
 
-        $this->entityManager->flush();
+        $uow = $this->entityManager->getUnitOfWork();
+        $meta = $this->entityManager->getClassMetadata(Subscriber::class);
+        $uow->computeChangeSet($meta, $subscriber);
+        $changeSet = ChangeSetDto::fromDoctrineChangeSet($uow->getEntityChangeSet($subscriber));
+
+        $this->subscriberHistoryManager->addHistoryFromApi($subscriber, [], $changeSet, $admin);
+
+        return $subscriber;
+    }
+
+    public function resetBounceCount(Subscriber $subscriber): Subscriber
+    {
+        $subscriber->setBounceCount(0);
 
         return $subscriber;
     }
@@ -96,11 +92,10 @@ public function markAsConfirmedByUniqueId(string $uniqueId): Subscriber
     {
         $subscriber = $this->subscriberRepository->findOneByUniqueId($uniqueId);
         if (!$subscriber) {
-            throw new NotFoundHttpException('Subscriber not found');
+            throw new NotFoundHttpException($this->translator->trans('Subscriber not found'));
         }
 
         $subscriber->setConfirmed(true);
-        $this->entityManager->flush();
 
         return $subscriber;
     }
@@ -122,14 +117,11 @@ public function createFromImport(ImportSubscriberDto $subscriberDto): Subscriber
 
         $this->entityManager->persist($subscriber);
 
-        if ($subscriberDto->sendConfirmation) {
-            $this->sendConfirmationEmail($subscriber);
-        }
-
         return $subscriber;
     }
 
-    public function updateFromImport(Subscriber $existingSubscriber, ImportSubscriberDto $subscriberDto): Subscriber
+    /** @SuppressWarnings(PHPMD.StaticAccess) */
+    public function updateFromImport(Subscriber $existingSubscriber, ImportSubscriberDto $subscriberDto): ChangeSetDto
     {
         $existingSubscriber->setEmail($subscriberDto->email);
         $existingSubscriber->setConfirmed($subscriberDto->confirmed);
@@ -138,6 +130,10 @@ public function updateFromImport(Subscriber $existingSubscriber, ImportSubscribe
         $existingSubscriber->setDisabled($subscriberDto->disabled);
         $existingSubscriber->setExtraData($subscriberDto->extraData);
 
-        return $existingSubscriber;
+        $uow = $this->entityManager->getUnitOfWork();
+        $meta = $this->entityManager->getClassMetadata(Subscriber::class);
+        $uow->computeChangeSet($meta, $existingSubscriber);
+
+        return ChangeSetDto::fromDoctrineChangeSet($uow->getEntityChangeSet($existingSubscriber));
     }
 }
diff --git a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php
index bb3a0e14..b3631d91 100644
--- a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php
+++ b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php
@@ -4,6 +4,7 @@
 
 namespace PhpList\Core\Domain\Subscription\Service\Manager;
 
+use Doctrine\ORM\EntityManagerInterface;
 use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException;
 use PhpList\Core\Domain\Subscription\Model\Subscriber;
 use PhpList\Core\Domain\Subscription\Model\SubscriberList;
@@ -11,40 +12,48 @@
 use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository;
 use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
 use PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
 class SubscriptionManager
 {
     private SubscriptionRepository $subscriptionRepository;
     private SubscriberRepository $subscriberRepository;
     private SubscriberListRepository $subscriberListRepository;
+    private TranslatorInterface $translator;
+    private EntityManagerInterface $entityManager;
 
     public function __construct(
         SubscriptionRepository $subscriptionRepository,
         SubscriberRepository $subscriberRepository,
-        SubscriberListRepository $subscriberListRepository
+        SubscriberListRepository $subscriberListRepository,
+        TranslatorInterface $translator,
+        EntityManagerInterface $entityManager
     ) {
         $this->subscriptionRepository = $subscriptionRepository;
         $this->subscriberRepository = $subscriberRepository;
         $this->subscriberListRepository = $subscriberListRepository;
+        $this->translator = $translator;
+        $this->entityManager = $entityManager;
     }
 
-    public function addSubscriberToAList(Subscriber $subscriber, int $listId): Subscription
+    public function addSubscriberToAList(Subscriber $subscriber, int $listId): ?Subscription
     {
         $existingSubscription = $this->subscriptionRepository
             ->findOneBySubscriberEmailAndListId($listId, $subscriber->getEmail());
         if ($existingSubscription) {
-            return $existingSubscription;
+            return null;
         }
         $subscriberList = $this->subscriberListRepository->find($listId);
         if (!$subscriberList) {
-            throw new SubscriptionCreationException('Subscriber list not found.', 404);
+            $message = $this->translator->trans('Subscriber list not found.');
+            throw new SubscriptionCreationException($message, 404);
         }
 
         $subscription = new Subscription();
         $subscription->setSubscriber($subscriber);
         $subscription->setSubscriberList($subscriberList);
 
-        $this->subscriptionRepository->save($subscription);
+        $this->entityManager->persist($subscription);
 
         return $subscription;
     }
@@ -64,7 +73,8 @@ private function createSubscription(SubscriberList $subscriberList, string $emai
     {
         $subscriber = $this->subscriberRepository->findOneBy(['email' => $email]);
         if (!$subscriber) {
-            throw new SubscriptionCreationException('Subscriber does not exists.', 404);
+            $message = $this->translator->trans('Subscriber does not exists.');
+            throw new SubscriptionCreationException($message, 404);
         }
 
         $existingSubscription = $this->subscriptionRepository
@@ -77,7 +87,7 @@ private function createSubscription(SubscriberList $subscriberList, string $emai
         $subscription->setSubscriber($subscriber);
         $subscription->setSubscriberList($subscriberList);
 
-        $this->subscriptionRepository->save($subscription);
+        $this->entityManager->persist($subscription);
 
         return $subscription;
     }
@@ -101,10 +111,11 @@ private function deleteSubscription(SubscriberList $subscriberList, string $emai
             ->findOneBySubscriberEmailAndListId($subscriberList->getId(), $email);
 
         if (!$subscription) {
-            throw new SubscriptionCreationException('Subscription not found for this subscriber and list.', 404);
+            $message = $this->translator->trans('Subscription not found for this subscriber and list.');
+            throw new SubscriptionCreationException($message, 404);
         }
 
-        $this->subscriptionRepository->remove($subscription);
+        $this->entityManager->remove($subscription);
     }
 
     /** @return Subscriber[] */
diff --git a/src/Domain/Subscription/Service/Provider/AttributeValueProvider.php b/src/Domain/Subscription/Service/Provider/AttributeValueProvider.php
new file mode 100644
index 00000000..e4f9f31f
--- /dev/null
+++ b/src/Domain/Subscription/Service/Provider/AttributeValueProvider.php
@@ -0,0 +1,16 @@
+getType() === 'checkboxgroup';
+    }
+
+    public function getValue(SubscriberAttributeDefinition $attribute, SubscriberAttributeValue $userValue): string
+    {
+        $csv = $userValue->getValue() ?? '';
+        if ($csv === '') {
+            return '';
+        }
+
+        $ids = array_values(array_filter(array_map(function ($value) {
+            $index = (int) trim($value);
+            return $index > 0 ? $index : null;
+        }, explode(',', $csv))));
+
+        if (empty($ids) || !$attribute->getTableName()) {
+            return '';
+        }
+
+        $names = $this->repo->fetchOptionNames($attribute->getTableName(), $ids);
+
+        return implode('; ', $names);
+    }
+}
diff --git a/src/Domain/Subscription/Service/Provider/ScalarValueProvider.php b/src/Domain/Subscription/Service/Provider/ScalarValueProvider.php
new file mode 100644
index 00000000..427fe1db
--- /dev/null
+++ b/src/Domain/Subscription/Service/Provider/ScalarValueProvider.php
@@ -0,0 +1,21 @@
+getType() === null;
+    }
+
+    public function getValue(SubscriberAttributeDefinition $attribute, SubscriberAttributeValue $userValue): string
+    {
+        return $userValue->getValue() ?? '';
+    }
+}
diff --git a/src/Domain/Subscription/Service/Provider/SelectOrRadioValueProvider.php b/src/Domain/Subscription/Service/Provider/SelectOrRadioValueProvider.php
new file mode 100644
index 00000000..22b3ab4e
--- /dev/null
+++ b/src/Domain/Subscription/Service/Provider/SelectOrRadioValueProvider.php
@@ -0,0 +1,35 @@
+getType(), ['select','radio'], true);
+    }
+
+    public function getValue(SubscriberAttributeDefinition $attribute, SubscriberAttributeValue $userValue): string
+    {
+        if (!$attribute->getTableName()) {
+            return '';
+        }
+
+        $id = (int)($userValue->getValue() ?? 0);
+        if ($id <= 0) {
+            return '';
+        }
+
+        return $this->repo->fetchSingleOptionName($attribute->getTableName(), $id) ?? '';
+    }
+}
diff --git a/src/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProvider.php b/src/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProvider.php
new file mode 100644
index 00000000..290c6de8
--- /dev/null
+++ b/src/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProvider.php
@@ -0,0 +1,81 @@
+ $attributeData
+     * @return ChangeSetDto
+     */
+    public function getAttributeChangeSet(Subscriber $subscriber, array $attributeData): ChangeSetDto
+    {
+        $oldMap = $this->getMappedValues($subscriber);
+
+        $canon = static function (array $attributes): array {
+            $out = [];
+            foreach ($attributes as $key => $value) {
+                $out[mb_strtolower((string)$key)] = $value;
+            }
+            return $out;
+        };
+
+        $oldC = $canon($oldMap);
+        $newC = $canon($attributeData);
+
+        foreach (ChangeSetDto::IGNORED_ATTRIBUTES as $ignoredAttribute) {
+            $lowerCaseIgnoredAttribute = mb_strtolower($ignoredAttribute);
+            unset($oldC[$lowerCaseIgnoredAttribute], $newC[$lowerCaseIgnoredAttribute]);
+        }
+
+        $keys = array_values(array_unique(array_merge(array_keys($oldC), array_keys($newC))));
+
+        $changeSet = [];
+        foreach ($keys as $key) {
+            $hasOld = array_key_exists($key, $oldC);
+            $hasNew = array_key_exists($key, $newC);
+
+            if ($hasOld && !$hasNew) {
+                $changeSet[$key] = [$oldC[$key], null];
+                continue;
+            }
+
+            if (!$hasOld && $hasNew) {
+                $changeSet[$key] = [null, $newC[$key]];
+                continue;
+            }
+
+            if ($oldC[$key] !== $newC[$key]) {
+                $changeSet[$key] = [$oldC[$key], $newC[$key]];
+            }
+        }
+
+        return new ChangeSetDto($changeSet);
+    }
+
+    private function getMappedValues(Subscriber $subscriber): array
+    {
+        $userAttributes = $this->attributesRepository->getForSubscriber($subscriber);
+        foreach ($userAttributes as $userAttribute) {
+            $data[$userAttribute->getAttributeDefinition()->getName()] = $this->resolver->resolve($userAttribute);
+        }
+
+        return $data ?? [];
+    }
+}
diff --git a/src/Domain/Subscription/Service/Provider/SubscriberProvider.php b/src/Domain/Subscription/Service/Provider/SubscriberProvider.php
index 5ec6b177..72b473be 100644
--- a/src/Domain/Subscription/Service/Provider/SubscriberProvider.php
+++ b/src/Domain/Subscription/Service/Provider/SubscriberProvider.php
@@ -36,7 +36,7 @@ public function getSubscribersForMessage(Message $message): array
         foreach ($lists as $list) {
             $listSubscribers = $this->subscriberRepository->getSubscribersBySubscribedListId($list->getId());
             foreach ($listSubscribers as $subscriber) {
-                $subscribers[$subscriber->getId()] = $subscriber;
+                $subscribers[$subscriber->getEmail()] = $subscriber;
             }
         }
 
diff --git a/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php b/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php
new file mode 100644
index 00000000..c63b7f8c
--- /dev/null
+++ b/src/Domain/Subscription/Service/Resolver/AttributeValueResolver.php
@@ -0,0 +1,26 @@
+ $providers */
+    public function __construct(private readonly iterable $providers)
+    {
+    }
+
+    public function resolve(SubscriberAttributeValue $userAttr): string
+    {
+        foreach ($this->providers as $provider) {
+            if ($provider->supports($userAttr->getAttributeDefinition())) {
+                return $provider->getValue($userAttr->getAttributeDefinition(), $userAttr);
+            }
+        }
+        return '';
+    }
+}
diff --git a/src/Domain/Subscription/Service/SubscriberCsvImporter.php b/src/Domain/Subscription/Service/SubscriberCsvImporter.php
index 4c58f22c..e80db165 100644
--- a/src/Domain/Subscription/Service/SubscriberCsvImporter.php
+++ b/src/Domain/Subscription/Service/SubscriberCsvImporter.php
@@ -5,21 +5,29 @@
 namespace PhpList\Core\Domain\Subscription\Service;
 
 use Doctrine\ORM\EntityManagerInterface;
+use PhpList\Core\Domain\Identity\Model\Administrator;
+use PhpList\Core\Domain\Messaging\Message\SubscriptionConfirmationMessage;
+use PhpList\Core\Domain\Subscription\Exception\CouldNotReadUploadedFileException;
+use PhpList\Core\Domain\Subscription\Model\Dto\ChangeSetDto;
 use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto;
 use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions;
 use PhpList\Core\Domain\Subscription\Model\Subscriber;
-use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository;
 use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
 use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager;
+use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
 use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager;
 use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager;
-use RuntimeException;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
+use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
 use Throwable;
 
 /**
+ * phpcs:ignore Generic.Commenting.Todo
+ * @todo: check if dryRun will work (some function flush)
  * Service for importing subscribers from a CSV file.
  * @SuppressWarnings("CouplingBetweenObjects")
+ * @SuppressWarnings("ExcessiveParameterList")
  */
 class SubscriberCsvImporter
 {
@@ -28,8 +36,10 @@ class SubscriberCsvImporter
     private SubscriptionManager $subscriptionManager;
     private SubscriberRepository $subscriberRepository;
     private CsvImporter $csvImporter;
-    private SubscriberAttributeDefinitionRepository $attrDefinitionRepository;
     private EntityManagerInterface $entityManager;
+    private TranslatorInterface $translator;
+    private MessageBusInterface $messageBus;
+    private SubscriberHistoryManager $subscriberHistoryManager;
 
     public function __construct(
         SubscriberManager $subscriberManager,
@@ -37,51 +47,72 @@ public function __construct(
         SubscriptionManager $subscriptionManager,
         SubscriberRepository $subscriberRepository,
         CsvImporter $csvImporter,
-        SubscriberAttributeDefinitionRepository $attrDefinitionRepository,
-        EntityManagerInterface $entityManager
+        EntityManagerInterface $entityManager,
+        TranslatorInterface $translator,
+        MessageBusInterface $messageBus,
+        SubscriberHistoryManager $subscriberHistoryManager,
     ) {
         $this->subscriberManager = $subscriberManager;
         $this->attributeManager = $attributeManager;
         $this->subscriptionManager = $subscriptionManager;
         $this->subscriberRepository = $subscriberRepository;
         $this->csvImporter = $csvImporter;
-        $this->attrDefinitionRepository = $attrDefinitionRepository;
         $this->entityManager = $entityManager;
+        $this->translator = $translator;
+        $this->messageBus = $messageBus;
+        $this->subscriberHistoryManager = $subscriberHistoryManager;
     }
 
     /**
      * Import subscribers from a CSV file.
-     *
-     * @param UploadedFile $file The uploaded CSV file
-     * @param SubscriberImportOptions $options
      * @return array Import statistics
-     * @throws RuntimeException When the uploaded file cannot be read or for any other errors during import
+     * @throws CouldNotReadUploadedFileException
      */
-    public function importFromCsv(UploadedFile $file, SubscriberImportOptions $options): array
-    {
+    public function importFromCsv(
+        UploadedFile $file,
+        SubscriberImportOptions $options,
+        ?Administrator $admin = null
+    ): array {
         $stats = [
             'created' => 0,
             'updated' => 0,
             'skipped' => 0,
+            'blacklisted' => 0,
             'errors' => [],
         ];
 
         try {
             $path = $file->getRealPath();
             if ($path === false) {
-                throw new RuntimeException('Could not read the uploaded file.');
+                throw new CouldNotReadUploadedFileException(
+                    $this->translator->trans('Could not read the uploaded file.')
+                );
             }
 
             $result = $this->csvImporter->import($path);
 
             foreach ($result['valid'] as $dto) {
                 try {
-                    $this->processRow($dto, $options, $stats);
-                    if (!$options->dryRun) {
+                    $this->entityManager->beginTransaction();
+
+                    $message = $this->processRow($dto, $options, $stats, $admin);
+
+                    if ($options->dryRun) {
+                        $this->entityManager->rollback();
+                    } else {
                         $this->entityManager->flush();
+                        $this->entityManager->commit();
+                        if ($message !== null) {
+                            $this->messageBus->dispatch($message);
+                        }
                     }
                 } catch (Throwable $e) {
-                    $stats['errors'][] = 'Error processing ' . $dto->email . ': ' . $e->getMessage();
+                    $this->entityManager->rollback();
+
+                    $stats['errors'][] = $this->translator->trans(
+                        'Error processing %email%: %error%',
+                        ['%email%' => $dto->email, '%error%' => $e->getMessage()]
+                    );
                     $stats['skipped']++;
                 }
             }
@@ -91,7 +122,10 @@ public function importFromCsv(UploadedFile $file, SubscriberImportOptions $optio
                 $stats['skipped']++;
             }
         } catch (Throwable $e) {
-            $stats['errors'][] = 'General import error: ' . $e->getMessage();
+            $stats['errors'][] = $this->translator->trans(
+                'General import error: %error%',
+                ['%error%' => $e->getMessage()]
+            );
         }
 
         return $stats;
@@ -103,11 +137,16 @@ public function importFromCsv(UploadedFile $file, SubscriberImportOptions $optio
      * @param UploadedFile $file The uploaded CSV file
      * @return array Import statistics
      */
-    public function importAndUpdateFromCsv(UploadedFile $file, ?array $listIds = [], bool $dryRun = false): array
-    {
+    public function importAndUpdateFromCsv(
+        UploadedFile $file,
+        Administrator $admin,
+        ?array $listIds = [],
+        bool $dryRun = false
+    ): array {
         return $this->importFromCsv(
             file: $file,
-            options: new SubscriberImportOptions(updateExisting: true, listIds: $listIds, dryRun: $dryRun)
+            options: new SubscriberImportOptions(updateExisting: true, listIds: $listIds, dryRun: $dryRun),
+            admin: $admin,
         );
     }
 
@@ -117,75 +156,125 @@ public function importAndUpdateFromCsv(UploadedFile $file, ?array $listIds = [],
      * @param UploadedFile $file The uploaded CSV file
      * @return array Import statistics
      */
-    public function importNewFromCsv(UploadedFile $file, ?array $listIds = [], bool $dryRun = false): array
-    {
+    public function importNewFromCsv(
+        UploadedFile $file,
+        Administrator $admin,
+        ?array $listIds = [],
+        bool $dryRun = false
+    ): array {
         return $this->importFromCsv(
             file: $file,
-            options: new SubscriberImportOptions(listIds: $listIds, dryRun: $dryRun)
+            options: new SubscriberImportOptions(listIds: $listIds, dryRun: $dryRun),
+            admin: $admin,
         );
     }
 
     /**
      * Process a single row from the CSV file.
-     *
-     * @param ImportSubscriberDto $dto
-     * @param SubscriberImportOptions $options
-     * @param array $stats Statistics to update
      */
     private function processRow(
         ImportSubscriberDto $dto,
         SubscriberImportOptions $options,
         array &$stats,
-    ): void {
-        if (!filter_var($dto->email, FILTER_VALIDATE_EMAIL)) {
-            if ($options->skipInvalidEmail) {
-                $stats['skipped']++;
-                return;
-            } else {
-                $dto->email = 'invalid_' . $dto->email;
-                $dto->sendConfirmation = false;
-            }
+        ?Administrator $admin = null
+    ): ?SubscriptionConfirmationMessage {
+        if ($this->handleInvalidEmail($dto, $options, $stats)) {
+            return null;
         }
-        $subscriber = $this->subscriberRepository->findOneByEmail($dto->email);
 
-        if ($subscriber && !$options->updateExisting) {
-            $stats['skipped']++;
-            return;
+        $subscriber = $this->subscriberRepository->findOneByEmail($dto->email);
+        if ($this->handleSkipCase($subscriber, $options, $stats)) {
+            return null;
         }
+
         if ($subscriber) {
-            $this->subscriberManager->updateFromImport($subscriber, $dto);
+            $changeSet = $this->subscriberManager->updateFromImport($subscriber, $dto);
             $stats['updated']++;
         } else {
             $subscriber = $this->subscriberManager->createFromImport($dto);
             $stats['created']++;
         }
 
-        $this->processAttributes($subscriber, $dto);
+        $this->attributeManager->processAttributes($subscriber, $dto->extraAttributes);
 
-        if (count($options->listIds) > 0) {
+        $addedNewSubscriberToList = false;
+        $listLines = [];
+        if (!$subscriber->isBlacklisted() && count($options->listIds) > 0) {
             foreach ($options->listIds as $listId) {
-                $this->subscriptionManager->addSubscriberToAList($subscriber, $listId);
+                $created = $this->subscriptionManager->addSubscriberToAList($subscriber, $listId);
+                if ($created) {
+                    $addedNewSubscriberToList = true;
+                    $listLines[] = $this->translator->trans(
+                        'Subscribed to %list%',
+                        ['%list%' => $created->getSubscriberList()->getName()]
+                    );
+                }
             }
         }
+
+        if ($subscriber->isBlacklisted()) {
+            $stats['blacklisted']++;
+        }
+
+        $this->subscriberHistoryManager->addHistoryFromImport(
+            subscriber: $subscriber,
+            listLines: $listLines,
+            changeSetDto: $changeSet ?? new ChangeSetDto(),
+            admin: $admin
+        );
+
+        return $this->prepareConfirmationMessage($subscriber, $options, $dto, $addedNewSubscriberToList);
     }
 
-    /**
-     * Process subscriber attributes.
-     *
-     * @param Subscriber $subscriber The subscriber
-     * @param ImportSubscriberDto $dto
-     */
-    private function processAttributes(Subscriber $subscriber, ImportSubscriberDto $dto): void
-    {
-        foreach ($dto->extraAttributes as $key => $value) {
-            $attributeDefinition = $this->attrDefinitionRepository->findOneByName($key);
-            if ($attributeDefinition !== null) {
-                $this->attributeManager->createOrUpdate(
-                    subscriber: $subscriber,
-                    definition: $attributeDefinition,
-                    value: $value
-                );
+    private function handleInvalidEmail(
+        ImportSubscriberDto $dto,
+        SubscriberImportOptions $options,
+        array &$stats
+    ): bool {
+        if (!filter_var($dto->email, FILTER_VALIDATE_EMAIL)) {
+            if ($options->skipInvalidEmail) {
+                $stats['skipped']++;
+
+                return true;
             }
+            // phpcs:ignore Generic.Commenting.Todo
+            // @todo: check
+            $dto->email = 'invalid_' . $dto->email;
+            $dto->sendConfirmation = false;
         }
+
+        return false;
+    }
+
+    private function handleSkipCase(
+        ?Subscriber $existingSubscriber,
+        SubscriberImportOptions $options,
+        array &$stats
+    ): bool {
+        if ($existingSubscriber && !$options->updateExisting) {
+            $stats['skipped']++;
+
+            return true;
+        }
+
+        return false;
+    }
+
+    private function prepareConfirmationMessage(
+        Subscriber $subscriber,
+        SubscriberImportOptions $options,
+        ImportSubscriberDto $dto,
+        bool $addedNewSubscriberToList
+    ): ?SubscriptionConfirmationMessage {
+        if ($dto->sendConfirmation && $addedNewSubscriberToList) {
+            return new SubscriptionConfirmationMessage(
+                email: $subscriber->getEmail(),
+                uniqueId: $subscriber->getUniqueId(),
+                listIds: $options->listIds,
+                htmlEmail: $subscriber->hasHtmlEmail(),
+            );
+        }
+
+        return null;
     }
 }
diff --git a/src/Domain/Subscription/Validator/AttributeTypeValidator.php b/src/Domain/Subscription/Validator/AttributeTypeValidator.php
index 3923cdfc..36bcd45d 100644
--- a/src/Domain/Subscription/Validator/AttributeTypeValidator.php
+++ b/src/Domain/Subscription/Validator/AttributeTypeValidator.php
@@ -8,9 +8,14 @@
 use PhpList\Core\Domain\Common\Model\ValidationContext;
 use PhpList\Core\Domain\Common\Validator\ValidatorInterface;
 use Symfony\Component\Validator\Exception\ValidatorException;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
 class AttributeTypeValidator implements ValidatorInterface
 {
+    public function __construct(private readonly TranslatorInterface $translator)
+    {
+    }
+
     private const VALID_TYPES = [
         'textline',
         'checkbox',
@@ -25,15 +30,17 @@ class AttributeTypeValidator implements ValidatorInterface
     public function validate(mixed $value, ValidationContext $context = null): void
     {
         if (!is_string($value)) {
-            throw new InvalidArgumentException('Value must be a string.');
+            throw new InvalidArgumentException($this->translator->trans('Value must be a string.'));
         }
 
         $errors = [];
         if (!in_array($value, self::VALID_TYPES, true)) {
-            $errors[] = sprintf(
-                'Invalid attribute type: "%s". Valid types are: %s',
-                $value,
-                implode(', ', self::VALID_TYPES)
+            $errors[] = $this->translator->trans(
+                'Invalid attribute type: "%type%". Valid types are: %valid_types%',
+                [
+                    '%type%' => $value,
+                    '%valid_types%' => implode(', ', self::VALID_TYPES),
+                ]
             );
         }
 
diff --git a/src/Migrations/.gitkeep b/src/Migrations/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/Migrations/Version20251028092901MySqlInit.php b/src/Migrations/Version20251028092901MySqlInit.php
new file mode 100644
index 00000000..7b345dad
--- /dev/null
+++ b/src/Migrations/Version20251028092901MySqlInit.php
@@ -0,0 +1,34 @@
+connection->getDatabasePlatform();
+        $this->skipIf(
+            !$platform instanceof MySQLPlatform,
+            sprintf(
+                'This migration is only applicable for MySQL. Current platform: %s',
+                get_class($platform)
+            )
+        );
+        // this up() migration is auto-generated, please modify it to your needs
+        $this->addSql(file_get_contents(__DIR__.'/initial_schema.sql'));
+    }
+}
diff --git a/src/Migrations/Version20251028092902MySqlUpdate.php b/src/Migrations/Version20251028092902MySqlUpdate.php
new file mode 100644
index 00000000..789bc1f0
--- /dev/null
+++ b/src/Migrations/Version20251028092902MySqlUpdate.php
@@ -0,0 +1,312 @@
+connection->getDatabasePlatform();
+        $this->skipIf(!$platform instanceof MySQLPlatform, sprintf(
+            'Unsupported platform for this migration: %s',
+            get_class($platform)
+        ));
+
+        $this->addSql('ALTER TABLE phplist_admin CHANGE created created DATETIME NOT NULL, CHANGE modified modified DATETIME NOT NULL, CHANGE superuser superuser TINYINT(1) NOT NULL, CHANGE disabled disabled TINYINT(1) NOT NULL, CHANGE privileges privileges LONGTEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_admin RENAME INDEX loginnameidx TO phplist_admin_loginnameidx');
+        $this->addSql('ALTER TABLE phplist_admin_attribute ADD CONSTRAINT FK_58E07690D3B10C48 FOREIGN KEY (adminattributeid) REFERENCES phplist_adminattribute (id)');
+        $this->addSql('ALTER TABLE phplist_admin_attribute ADD CONSTRAINT FK_58E07690B8ED4D93 FOREIGN KEY (adminid) REFERENCES phplist_admin (id)');
+        $this->addSql('CREATE INDEX IDX_58E07690D3B10C48 ON phplist_admin_attribute (adminattributeid)');
+        $this->addSql('CREATE INDEX IDX_58E07690B8ED4D93 ON phplist_admin_attribute (adminid)');
+        $this->addSql('ALTER TABLE phplist_admin_login CHANGE active active TINYINT(1) NOT NULL');
+        $this->addSql('ALTER TABLE phplist_admin_login ADD CONSTRAINT FK_5FCE0842B8ED4D93 FOREIGN KEY (adminid) REFERENCES phplist_admin (id)');
+        $this->addSql('CREATE INDEX IDX_5FCE0842B8ED4D93 ON phplist_admin_login (adminid)');
+        $this->addSql('ALTER TABLE phplist_admin_password_request CHANGE id_key id_key INT UNSIGNED AUTO_INCREMENT NOT NULL');
+        $this->addSql('ALTER TABLE phplist_admin_password_request ADD CONSTRAINT FK_DC146F3B880E0D76 FOREIGN KEY (`admin`) REFERENCES phplist_admin (id)');
+        $this->addSql('CREATE INDEX IDX_DC146F3B880E0D76 ON phplist_admin_password_request (`admin`)');
+        $this->addSql('ALTER TABLE phplist_admintoken CHANGE adminid adminid INT DEFAULT NULL, CHANGE value value VARCHAR(255) NOT NULL');
+        $this->addSql('ALTER TABLE phplist_admintoken ADD CONSTRAINT FK_CB15D477B8ED4D93 FOREIGN KEY (adminid) REFERENCES phplist_admin (id) ON DELETE CASCADE');
+        $this->addSql('CREATE INDEX IDX_CB15D477B8ED4D93 ON phplist_admintoken (adminid)');
+        $this->addSql('ALTER TABLE phplist_attachment CHANGE description description LONGTEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_bounce DROP INDEX statusidx, ADD INDEX phplist_bounce_statusidx (status)');
+        $this->addSql('ALTER TABLE phplist_bounce CHANGE header header LONGTEXT DEFAULT NULL, CHANGE data data LONGBLOB DEFAULT NULL, CHANGE comment comment LONGTEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_bounce RENAME INDEX dateindex TO phplist_bounce_dateindex');
+        $this->addSql('ALTER TABLE phplist_bounceregex CHANGE regexhash regexhash VARCHAR(32) DEFAULT NULL, CHANGE comment comment LONGTEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_bounceregex RENAME INDEX regex TO phplist_bounceregex_regex');
+        $this->addSql('ALTER TABLE phplist_config CHANGE editable editable TINYINT(1) DEFAULT 1 NOT NULL');
+        $this->addSql('ALTER TABLE phplist_eventlog CHANGE entry entry LONGTEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_eventlog RENAME INDEX enteredidx TO phplist_eventlog_enteredidx');
+        $this->addSql('ALTER TABLE phplist_eventlog RENAME INDEX pageidx TO phplist_eventlog_pageidx');
+        $this->addSql('ALTER TABLE phplist_linktrack CHANGE latestclick latestclick DATETIME NOT NULL');
+        $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX midindex TO phplist_linktrack_midindex');
+        $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX miduidindex TO phplist_linktrack_miduidindex');
+        $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX uidindex TO phplist_linktrack_uidindex');
+        $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX urlindex TO phplist_linktrack_urlindex');
+        $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX miduidurlindex TO phplist_linktrack_miduidurlindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_forward RENAME INDEX urlindex TO phplist_linktrack_forward_urlindex;');
+        $this->addSql('ALTER TABLE phplist_linktrack_forward CHANGE urlhash urlhash VARCHAR(32) DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_linktrack_forward RENAME INDEX uuididx TO phplist_linktrack_forward_uuididx');
+        $this->addSql('ALTER TABLE phplist_linktrack_forward RENAME INDEX urlunique TO phplist_linktrack_forward_urlunique');
+        $this->addSql('ALTER TABLE phplist_linktrack_ml RENAME INDEX fwdindex TO phplist_linktrack_ml_fwdindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_ml RENAME INDEX midindex TO phplist_linktrack_ml_midindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX midindex TO phplist_linktrack_uml_click_midindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX miduidindex TO phplist_linktrack_uml_click_miduidindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX uidindex TO phplist_linktrack_uml_click_uidindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX miduidfwdid TO phplist_linktrack_uml_click_miduidfwdid');
+        $this->addSql('ALTER TABLE phplist_linktrack_userclick CHANGE data data LONGTEXT DEFAULT NULL, ADD PRIMARY KEY (linkid, userid, messageid)');
+        $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX linkindex TO phplist_linktrack_userclick_linkindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX linkuserindex TO phplist_linktrack_userclick_linkuserindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX linkusermessageindex TO phplist_linktrack_userclick_linkusermessageindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX midindex TO phplist_linktrack_userclick_midindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX uidindex TO phplist_linktrack_userclick_uidindex');
+        $this->addSql('ALTER TABLE phplist_list CHANGE description description VARCHAR(255) NOT NULL, CHANGE modified modified DATETIME NOT NULL, CHANGE active active TINYINT(1) NOT NULL, CHANGE category category VARCHAR(255) NOT NULL');
+        $this->addSql('ALTER TABLE phplist_list ADD CONSTRAINT FK_A4CE8621CF60E67C FOREIGN KEY (owner) REFERENCES phplist_admin (id)');
+        $this->addSql('CREATE INDEX IDX_A4CE8621CF60E67C ON phplist_list (owner)');
+        $this->addSql('ALTER TABLE phplist_list RENAME INDEX nameidx TO phplist_list_nameidx');
+        $this->addSql('ALTER TABLE phplist_list RENAME INDEX listorderidx TO phplist_list_listorderidx');
+        $this->addSql('ALTER TABLE phplist_listmessage CHANGE modified modified DATETIME NOT NULL');
+        $this->addSql('ALTER TABLE phplist_listmessage ADD CONSTRAINT FK_83B22D7A31478478 FOREIGN KEY (messageid) REFERENCES phplist_message (id)');
+        $this->addSql('ALTER TABLE phplist_listmessage ADD CONSTRAINT FK_83B22D7A8E44C1EF FOREIGN KEY (listid) REFERENCES phplist_list (id)');
+        $this->addSql('CREATE INDEX IDX_83B22D7A31478478 ON phplist_listmessage (messageid)');
+        $this->addSql('CREATE INDEX IDX_83B22D7A8E44C1EF ON phplist_listmessage (listid)');
+        $this->addSql('ALTER TABLE phplist_listmessage RENAME INDEX listmessageidx TO phplist_listmessage_listmessageidx');
+        $this->addSql('ALTER TABLE phplist_listmessage RENAME INDEX messageid TO phplist_listmessage_messageid');
+        $this->addSql('DROP INDEX userlistenteredidx ON phplist_listuser');
+        $this->addSql('ALTER TABLE phplist_listuser CHANGE modified modified DATETIME NOT NULL');
+        $this->addSql('ALTER TABLE phplist_listuser ADD CONSTRAINT FK_F467E411F132696E FOREIGN KEY (userid) REFERENCES phplist_user_user (id)');
+        $this->addSql('ALTER TABLE phplist_listuser ADD CONSTRAINT FK_F467E4118E44C1EF FOREIGN KEY (listid) REFERENCES phplist_list (id) ON DELETE CASCADE');
+        $this->addSql('CREATE INDEX phplist_listuser_userlistenteredidx ON phplist_listuser (userid, entered, listid)');
+        $this->addSql('ALTER TABLE phplist_listuser RENAME INDEX userenteredidx TO phplist_listuser_userenteredidx');
+        $this->addSql('ALTER TABLE phplist_listuser RENAME INDEX useridx TO phplist_listuser_useridx');
+        $this->addSql('ALTER TABLE phplist_listuser RENAME INDEX listidx TO phplist_listuser_listidx');
+        $this->addSql('ALTER TABLE phplist_message CHANGE footer footer LONGTEXT DEFAULT NULL, CHANGE modified modified DATETIME NOT NULL, CHANGE userselection userselection LONGTEXT DEFAULT NULL, CHANGE htmlformatted htmlformatted TINYINT(1) NOT NULL, CHANGE processed processed TINYINT(1) DEFAULT 0 NOT NULL, CHANGE astext astext TINYINT(1) NOT NULL, CHANGE ashtml ashtml TINYINT(1) NOT NULL, CHANGE astextandhtml astextandhtml TINYINT(1) NOT NULL, CHANGE aspdf aspdf TINYINT(1) NOT NULL, CHANGE astextandpdf astextandpdf TINYINT(1) NOT NULL, CHANGE viewed viewed INT DEFAULT 0 NOT NULL, CHANGE bouncecount bouncecount INT DEFAULT 0 NOT NULL');
+        $this->addSql('ALTER TABLE phplist_message ADD CONSTRAINT FK_C5D81FCDCF60E67C FOREIGN KEY (owner) REFERENCES phplist_admin (id)');
+        $this->addSql('ALTER TABLE phplist_message ADD CONSTRAINT FK_C5D81FCD97601F83 FOREIGN KEY (template) REFERENCES phplist_template (id) ON DELETE SET NULL');
+        $this->addSql('CREATE INDEX IDX_C5D81FCDCF60E67C ON phplist_message (owner)');
+        $this->addSql('CREATE INDEX IDX_C5D81FCD97601F83 ON phplist_message (template)');
+        $this->addSql('ALTER TABLE phplist_message RENAME INDEX uuididx TO phplist_message_uuididx');
+        $this->addSql('ALTER TABLE phplist_message_attachment RENAME INDEX messageattidx TO phplist_message_attachment_messageattidx');
+        $this->addSql('ALTER TABLE phplist_message_attachment RENAME INDEX messageidx TO phplist_message_attachment_messageidx');
+        $this->addSql('ALTER TABLE phplist_messagedata CHANGE data data LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_sendprocess CHANGE modified modified DATETIME NOT NULL');
+        $this->addSql('ALTER TABLE phplist_subscribepage CHANGE active active TINYINT(1) DEFAULT 0 NOT NULL');
+        $this->addSql('ALTER TABLE phplist_subscribepage ADD CONSTRAINT FK_5BAC7737CF60E67C FOREIGN KEY (owner) REFERENCES phplist_admin (id)');
+        $this->addSql('CREATE INDEX IDX_5BAC7737CF60E67C ON phplist_subscribepage (owner)');
+        $this->addSql('ALTER TABLE phplist_subscribepage_data CHANGE data data LONGTEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_template RENAME INDEX title TO phplist_template_title');
+        $this->addSql('ALTER TABLE phplist_templateimage CHANGE template template INT NOT NULL');
+        $this->addSql('ALTER TABLE phplist_templateimage ADD CONSTRAINT FK_30A85BA97601F83 FOREIGN KEY (template) REFERENCES phplist_template (id)');
+        $this->addSql('ALTER TABLE phplist_templateimage RENAME INDEX templateidx TO phplist_templateimage_templateidx');
+        $this->addSql('ALTER TABLE phplist_urlcache RENAME INDEX urlindex TO phplist_urlcache_urlindex');
+        $this->addSql('ALTER TABLE phplist_user_attribute RENAME INDEX idnameindex TO phplist_user_attribute_idnameindex');
+        $this->addSql('ALTER TABLE phplist_user_attribute RENAME INDEX nameindex TO phplist_user_attribute_nameindex');
+        $this->addSql('DROP INDEX email ON phplist_user_blacklist');
+        $this->addSql('ALTER TABLE phplist_user_blacklist ADD PRIMARY KEY (email)');
+        $this->addSql('ALTER TABLE phplist_user_blacklist RENAME INDEX emailidx TO phplist_user_blacklist_emailidx');
+        $this->addSql('DROP INDEX email ON phplist_user_blacklist_data');
+        $this->addSql('ALTER TABLE phplist_user_blacklist_data CHANGE email email VARCHAR(255) NOT NULL, CHANGE data data LONGTEXT DEFAULT NULL, ADD PRIMARY KEY (email)');
+        $this->addSql('ALTER TABLE phplist_user_blacklist_data ADD CONSTRAINT FK_6D67150CE7927C74 FOREIGN KEY (email) REFERENCES phplist_user_blacklist (email) ON DELETE CASCADE');
+        $this->addSql('ALTER TABLE phplist_user_blacklist_data RENAME INDEX emailidx TO phplist_user_blacklist_data_emailidx');
+        $this->addSql('ALTER TABLE phplist_user_blacklist_data RENAME INDEX emailnameidx TO phplist_user_blacklist_data_emailnameidx');
+        $this->addSql('ALTER TABLE phplist_user_message_bounce RENAME INDEX bounceidx TO phplist_user_message_bounce_bounceidx');
+        $this->addSql('ALTER TABLE phplist_user_message_bounce RENAME INDEX msgidx TO phplist_user_message_bounce_msgidx');
+        $this->addSql('ALTER TABLE phplist_user_message_bounce RENAME INDEX umbindex TO phplist_user_message_bounce_umbindex');
+        $this->addSql('ALTER TABLE phplist_user_message_bounce RENAME INDEX useridx TO phplist_user_message_bounce_useridx');
+        $this->addSql('ALTER TABLE phplist_user_message_forward RENAME INDEX messageidx TO phplist_user_message_forward_messageidx');
+        $this->addSql('ALTER TABLE phplist_user_message_forward RENAME INDEX useridx TO phplist_user_message_forward_useridx');
+        $this->addSql('ALTER TABLE phplist_user_message_forward RENAME INDEX usermessageidx TO phplist_user_message_forward_usermessageidx');
+        $this->addSql('ALTER TABLE phplist_user_message_view RENAME INDEX msgidx TO phplist_user_message_view_msgidx');
+        $this->addSql('ALTER TABLE phplist_user_message_view RENAME INDEX useridx TO phplist_user_message_view_useridx');
+        $this->addSql('ALTER TABLE phplist_user_message_view RENAME INDEX usermsgidx TO phplist_user_message_view_usermsgidx');
+        $this->addSql('ALTER TABLE phplist_user_user CHANGE confirmed confirmed TINYINT(1) NOT NULL, CHANGE blacklisted blacklisted TINYINT(1) NOT NULL, CHANGE optedin optedin TINYINT(1) NOT NULL, CHANGE bouncecount bouncecount INT NOT NULL, CHANGE modified modified DATETIME NOT NULL, CHANGE uuid uuid VARCHAR(36) NOT NULL, CHANGE htmlemail htmlemail TINYINT(1) NOT NULL, CHANGE passwordchanged passwordchanged DATETIME DEFAULT NULL, CHANGE disabled disabled TINYINT(1) NOT NULL, CHANGE extradata extradata LONGTEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX idxuniqid TO phplist_user_user_idxuniqid');
+        $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX enteredindex TO phplist_user_user_enteredindex');
+        $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX confidx TO phplist_user_user_confidx');
+        $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX blidx TO phplist_user_user_blidx');
+        $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX optidx TO phplist_user_user_optidx');
+        $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX uuididx TO phplist_user_user_uuididx');
+        $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX foreignkey TO phplist_user_user_foreignkey');
+        $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX email TO phplist_user_user_email');
+        $this->addSql('ALTER TABLE phplist_user_user_attribute CHANGE value value LONGTEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_user_user_attribute ADD CONSTRAINT FK_E24E310878C45AB5 FOREIGN KEY (attributeid) REFERENCES phplist_user_attribute (id) ON DELETE CASCADE');
+        $this->addSql('ALTER TABLE phplist_user_user_attribute ADD CONSTRAINT FK_E24E3108F132696E FOREIGN KEY (userid) REFERENCES phplist_user_user (id) ON DELETE CASCADE');
+        $this->addSql('ALTER TABLE phplist_user_user_attribute RENAME INDEX attindex TO phplist_user_user_attribute_attindex');
+        $this->addSql('ALTER TABLE phplist_user_user_attribute RENAME INDEX attuserid TO phplist_user_user_attribute_attuserid');
+        $this->addSql('ALTER TABLE phplist_user_user_attribute RENAME INDEX userindex TO phplist_user_user_attribute_userindex');
+        $this->addSql('ALTER TABLE phplist_user_user_history CHANGE detail detail LONGTEXT DEFAULT NULL, CHANGE systeminfo systeminfo LONGTEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_user_user_history ADD CONSTRAINT FK_6DBB605CF132696E FOREIGN KEY (userid) REFERENCES phplist_user_user (id) ON DELETE CASCADE');
+        $this->addSql('ALTER TABLE phplist_user_user_history RENAME INDEX dateidx TO phplist_user_user_history_dateidx');
+        $this->addSql('ALTER TABLE phplist_user_user_history RENAME INDEX userididx TO phplist_user_user_history_userididx');
+        $this->addSql('ALTER TABLE phplist_usermessage ADD CONSTRAINT FK_7F30F469F132696E FOREIGN KEY (userid) REFERENCES phplist_user_user (id) ON DELETE CASCADE');
+        $this->addSql('ALTER TABLE phplist_usermessage ADD CONSTRAINT FK_7F30F46931478478 FOREIGN KEY (messageid) REFERENCES phplist_message (id) ON DELETE CASCADE');
+        $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX enteredindex TO phplist_usermessage_enteredindex');
+        $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX messageidindex TO phplist_usermessage_messageidindex');
+        $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX statusidx TO phplist_usermessage_statusidx');
+        $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX useridindex TO phplist_usermessage_useridindex');
+        $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX viewedidx TO phplist_usermessage_viewedidx');
+        $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX dateindex TO phplist_userstats_dateindex');
+        $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX itemindex TO phplist_userstats_itemindex');
+        $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX listdateindex TO phplist_userstats_listdateindex');
+        $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX listindex TO phplist_userstats_listindex');
+        $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX entry TO phplist_userstats_entry');
+    }
+
+    public function down(Schema $schema): void
+    {
+        $platform = $this->connection->getDatabasePlatform();
+        $this->skipIf(!$platform instanceof MySQLPlatform, sprintf(
+            'Unsupported platform for this migration: %s',
+            get_class($platform)
+        ));
+
+        $this->addSql('ALTER TABLE phplist_admin CHANGE created created DATETIME DEFAULT NULL, CHANGE modified modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CHANGE disabled disabled TINYINT(1) DEFAULT 0, CHANGE superuser superuser TINYINT(1) DEFAULT 0, CHANGE privileges privileges TEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_admin RENAME INDEX phplist_admin_loginnameidx TO loginnameidx');
+        $this->addSql('ALTER TABLE phplist_admin_attribute DROP FOREIGN KEY FK_58E07690D3B10C48');
+        $this->addSql('ALTER TABLE phplist_admin_attribute DROP FOREIGN KEY FK_58E07690B8ED4D93');
+        $this->addSql('DROP INDEX IDX_58E07690D3B10C48 ON phplist_admin_attribute');
+        $this->addSql('DROP INDEX IDX_58E07690B8ED4D93 ON phplist_admin_attribute');
+        $this->addSql('ALTER TABLE phplist_admin_login DROP FOREIGN KEY FK_5FCE0842B8ED4D93');
+        $this->addSql('DROP INDEX IDX_5FCE0842B8ED4D93 ON phplist_admin_login');
+        $this->addSql('ALTER TABLE phplist_admin_login CHANGE active active TINYINT(1) DEFAULT 0 NOT NULL');
+        $this->addSql('ALTER TABLE phplist_admin_password_request DROP FOREIGN KEY FK_DC146F3B880E0D76');
+        $this->addSql('DROP INDEX IDX_DC146F3B880E0D76 ON phplist_admin_password_request');
+        $this->addSql('ALTER TABLE phplist_admin_password_request CHANGE id_key id_key INT AUTO_INCREMENT NOT NULL');
+        $this->addSql('ALTER TABLE phplist_admintoken DROP FOREIGN KEY FK_CB15D477B8ED4D93');
+        $this->addSql('DROP INDEX IDX_CB15D477B8ED4D93 ON phplist_admintoken');
+        $this->addSql('ALTER TABLE phplist_admintoken CHANGE adminid adminid INT NOT NULL, CHANGE value value VARCHAR(255) DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_attachment CHANGE description description TEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_bounce DROP INDEX phplist_bounce_statusidx, ADD INDEX statusidx (status(20))');
+        $this->addSql('ALTER TABLE phplist_bounce CHANGE header header TEXT DEFAULT NULL, CHANGE data data MEDIUMBLOB DEFAULT NULL, CHANGE comment comment TEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_bounce RENAME INDEX phplist_bounce_dateindex TO dateindex');
+        $this->addSql('ALTER TABLE phplist_bounceregex CHANGE regexhash regexhash CHAR(32) DEFAULT NULL, CHANGE comment comment TEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_bounceregex RENAME INDEX phplist_bounceregex_regex TO regex');
+        $this->addSql('ALTER TABLE phplist_config CHANGE editable editable TINYINT(1) DEFAULT 1');
+        $this->addSql('ALTER TABLE phplist_eventlog CHANGE entry entry TEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_eventlog RENAME INDEX phplist_eventlog_enteredidx TO enteredidx');
+        $this->addSql('ALTER TABLE phplist_eventlog RENAME INDEX phplist_eventlog_pageidx TO pageidx');
+        $this->addSql('ALTER TABLE phplist_linktrack CHANGE latestclick latestclick DATETIME DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX phplist_linktrack_miduidurlindex TO miduidurlindex');
+        $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX phplist_linktrack_midindex TO midindex');
+        $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX phplist_linktrack_uidindex TO uidindex');
+        $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX phplist_linktrack_urlindex TO urlindex');
+        $this->addSql('ALTER TABLE phplist_linktrack RENAME INDEX phplist_linktrack_miduidindex TO miduidindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_forward DROP INDEX phplist_linktrack_forward_urlindex, ADD INDEX urlindex (url(255))');
+        $this->addSql('ALTER TABLE phplist_linktrack_forward CHANGE urlhash urlhash CHAR(32) DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_linktrack_forward RENAME INDEX phplist_linktrack_forward_urlunique TO urlunique');
+        $this->addSql('ALTER TABLE phplist_linktrack_forward RENAME INDEX phplist_linktrack_forward_uuididx TO uuididx');
+        $this->addSql('ALTER TABLE phplist_linktrack_ml RENAME INDEX phplist_linktrack_ml_midindex TO midindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_ml RENAME INDEX phplist_linktrack_ml_fwdindex TO fwdindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX phplist_linktrack_uml_click_miduidfwdid TO miduidfwdid');
+        $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX phplist_linktrack_uml_click_midindex TO midindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX phplist_linktrack_uml_click_uidindex TO uidindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_uml_click RENAME INDEX phplist_linktrack_uml_click_miduidindex TO miduidindex');
+        $this->addSql('DROP INDEX `primary` ON phplist_linktrack_userclick');
+        $this->addSql('ALTER TABLE phplist_linktrack_userclick CHANGE data data TEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX phplist_linktrack_userclick_linkindex TO linkindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX phplist_linktrack_userclick_uidindex TO uidindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX phplist_linktrack_userclick_midindex TO midindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX phplist_linktrack_userclick_linkuserindex TO linkuserindex');
+        $this->addSql('ALTER TABLE phplist_linktrack_userclick RENAME INDEX phplist_linktrack_userclick_linkusermessageindex TO linkusermessageindex');
+        $this->addSql('ALTER TABLE phplist_list DROP FOREIGN KEY FK_A4CE8621CF60E67C');
+        $this->addSql('DROP INDEX IDX_A4CE8621CF60E67C ON phplist_list');
+        $this->addSql('ALTER TABLE phplist_list CHANGE description description TEXT DEFAULT NULL, CHANGE modified modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CHANGE active active TINYINT(1) DEFAULT NULL, CHANGE category category VARCHAR(255) DEFAULT \'\'');
+        $this->addSql('ALTER TABLE phplist_list RENAME INDEX phplist_list_nameidx TO nameidx');
+        $this->addSql('ALTER TABLE phplist_list RENAME INDEX phplist_list_listorderidx TO listorderidx');
+        $this->addSql('ALTER TABLE phplist_listmessage DROP FOREIGN KEY FK_83B22D7A31478478');
+        $this->addSql('ALTER TABLE phplist_listmessage DROP FOREIGN KEY FK_83B22D7A8E44C1EF');
+        $this->addSql('DROP INDEX IDX_83B22D7A31478478 ON phplist_listmessage');
+        $this->addSql('DROP INDEX IDX_83B22D7A8E44C1EF ON phplist_listmessage');
+        $this->addSql('ALTER TABLE phplist_listmessage CHANGE modified modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL');
+        $this->addSql('ALTER TABLE phplist_listmessage RENAME INDEX phplist_listmessage_messageid TO messageid');
+        $this->addSql('ALTER TABLE phplist_listmessage RENAME INDEX phplist_listmessage_listmessageidx TO listmessageidx');
+        $this->addSql('ALTER TABLE phplist_listuser DROP FOREIGN KEY FK_F467E411F132696E');
+        $this->addSql('ALTER TABLE phplist_listuser DROP FOREIGN KEY FK_F467E4118E44C1EF');
+        $this->addSql('DROP INDEX phplist_listuser_userlistenteredidx ON phplist_listuser');
+        $this->addSql('ALTER TABLE phplist_listuser CHANGE modified modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL');
+        $this->addSql('CREATE INDEX userlistenteredidx ON phplist_listuser (userid, listid, entered)');
+        $this->addSql('ALTER TABLE phplist_listuser RENAME INDEX phplist_listuser_userenteredidx TO userenteredidx');
+        $this->addSql('ALTER TABLE phplist_listuser RENAME INDEX phplist_listuser_useridx TO useridx');
+        $this->addSql('ALTER TABLE phplist_listuser RENAME INDEX phplist_listuser_listidx TO listidx');
+        $this->addSql('ALTER TABLE phplist_message DROP FOREIGN KEY FK_C5D81FCDCF60E67C');
+        $this->addSql('ALTER TABLE phplist_message DROP FOREIGN KEY FK_C5D81FCD97601F83');
+        $this->addSql('DROP INDEX IDX_C5D81FCDCF60E67C ON phplist_message');
+        $this->addSql('DROP INDEX IDX_C5D81FCD97601F83 ON phplist_message');
+        $this->addSql('ALTER TABLE phplist_message CHANGE modified modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CHANGE htmlformatted htmlformatted TINYINT(1) DEFAULT 0, CHANGE astext astext INT DEFAULT 0, CHANGE ashtml ashtml INT DEFAULT 0, CHANGE aspdf aspdf INT DEFAULT 0, CHANGE astextandhtml astextandhtml INT DEFAULT 0, CHANGE astextandpdf astextandpdf INT DEFAULT 0, CHANGE processed processed INT UNSIGNED DEFAULT 0, CHANGE viewed viewed INT DEFAULT 0, CHANGE bouncecount bouncecount INT DEFAULT 0, CHANGE footer footer TEXT DEFAULT NULL, CHANGE userselection userselection TEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_message RENAME INDEX phplist_message_uuididx TO uuididx');
+        $this->addSql('ALTER TABLE phplist_message_attachment RENAME INDEX phplist_message_attachment_messageidx TO messageidx');
+        $this->addSql('ALTER TABLE phplist_message_attachment RENAME INDEX phplist_message_attachment_messageattidx TO messageattidx');
+        $this->addSql('ALTER TABLE phplist_messagedata CHANGE data data LONGTEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_sendprocess CHANGE modified modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL');
+        $this->addSql('ALTER TABLE phplist_subscribepage DROP FOREIGN KEY FK_5BAC7737CF60E67C');
+        $this->addSql('DROP INDEX IDX_5BAC7737CF60E67C ON phplist_subscribepage');
+        $this->addSql('ALTER TABLE phplist_subscribepage CHANGE active active TINYINT(1) DEFAULT 0');
+        $this->addSql('ALTER TABLE phplist_subscribepage_data CHANGE data data TEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_template RENAME INDEX phplist_template_title TO title');
+        $this->addSql('ALTER TABLE phplist_templateimage DROP FOREIGN KEY FK_30A85BA97601F83');
+        $this->addSql('ALTER TABLE phplist_templateimage CHANGE template template INT DEFAULT 0 NOT NULL');
+        $this->addSql('ALTER TABLE phplist_templateimage RENAME INDEX phplist_templateimage_templateidx TO templateidx');
+        $this->addSql('ALTER TABLE phplist_urlcache DROP INDEX phplist_urlcache_urlindex, ADD INDEX urlindex (url(255))');
+        $this->addSql('ALTER TABLE phplist_user_attribute RENAME INDEX phplist_user_attribute_nameindex TO nameindex');
+        $this->addSql('ALTER TABLE phplist_user_attribute RENAME INDEX phplist_user_attribute_idnameindex TO idnameindex');
+        $this->addSql('ALTER TABLE phplist_user_blacklist DROP INDEX `primary`, ADD UNIQUE INDEX email (email)');
+        $this->addSql('ALTER TABLE phplist_user_blacklist RENAME INDEX phplist_user_blacklist_emailidx TO emailidx');
+        $this->addSql('ALTER TABLE phplist_user_blacklist_data DROP INDEX `primary`, ADD UNIQUE INDEX email (email)');
+        $this->addSql('ALTER TABLE phplist_user_blacklist_data DROP FOREIGN KEY FK_6D67150CE7927C74');
+        $this->addSql('ALTER TABLE phplist_user_blacklist_data CHANGE email email VARCHAR(150) NOT NULL, CHANGE data data TEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_user_blacklist_data RENAME INDEX phplist_user_blacklist_data_emailidx TO emailidx');
+        $this->addSql('ALTER TABLE phplist_user_blacklist_data RENAME INDEX phplist_user_blacklist_data_emailnameidx TO emailnameidx');
+        $this->addSql('ALTER TABLE phplist_user_message_bounce RENAME INDEX phplist_user_message_bounce_umbindex TO umbindex');
+        $this->addSql('ALTER TABLE phplist_user_message_bounce RENAME INDEX phplist_user_message_bounce_useridx TO useridx');
+        $this->addSql('ALTER TABLE phplist_user_message_bounce RENAME INDEX phplist_user_message_bounce_msgidx TO msgidx');
+        $this->addSql('ALTER TABLE phplist_user_message_bounce RENAME INDEX phplist_user_message_bounce_bounceidx TO bounceidx');
+        $this->addSql('ALTER TABLE phplist_user_message_forward RENAME INDEX phplist_user_message_forward_usermessageidx TO usermessageidx');
+        $this->addSql('ALTER TABLE phplist_user_message_forward RENAME INDEX phplist_user_message_forward_useridx TO useridx');
+        $this->addSql('ALTER TABLE phplist_user_message_forward RENAME INDEX phplist_user_message_forward_messageidx TO messageidx');
+        $this->addSql('ALTER TABLE phplist_user_message_view RENAME INDEX phplist_user_message_view_usermsgidx TO usermsgidx');
+        $this->addSql('ALTER TABLE phplist_user_message_view RENAME INDEX phplist_user_message_view_msgidx TO msgidx');
+        $this->addSql('ALTER TABLE phplist_user_message_view RENAME INDEX phplist_user_message_view_useridx TO useridx');
+        $this->addSql('ALTER TABLE phplist_user_user CHANGE modified modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CHANGE confirmed confirmed TINYINT(1) DEFAULT 0, CHANGE blacklisted blacklisted TINYINT(1) DEFAULT 0, CHANGE bouncecount bouncecount INT DEFAULT 0, CHANGE htmlemail htmlemail TINYINT(1) DEFAULT 0, CHANGE disabled disabled TINYINT(1) DEFAULT 0, CHANGE extradata extradata TEXT DEFAULT NULL, CHANGE optedin optedin TINYINT(1) DEFAULT 0, CHANGE uuid uuid VARCHAR(36) DEFAULT \'\', CHANGE passwordchanged passwordchanged DATE DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX phplist_user_user_email TO email');
+        $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX phplist_user_user_foreignkey TO foreignkey');
+        $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX phplist_user_user_idxuniqid TO idxuniqid');
+        $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX phplist_user_user_enteredindex TO enteredindex');
+        $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX phplist_user_user_confidx TO confidx');
+        $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX phplist_user_user_blidx TO blidx');
+        $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX phplist_user_user_optidx TO optidx');
+        $this->addSql('ALTER TABLE phplist_user_user RENAME INDEX phplist_user_user_uuididx TO uuididx');
+        $this->addSql('ALTER TABLE phplist_user_user_attribute DROP FOREIGN KEY FK_E24E310878C45AB5');
+        $this->addSql('ALTER TABLE phplist_user_user_attribute DROP FOREIGN KEY FK_E24E3108F132696E');
+        $this->addSql('ALTER TABLE phplist_user_user_attribute CHANGE value value TEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_user_user_attribute RENAME INDEX phplist_user_user_attribute_userindex TO userindex');
+        $this->addSql('ALTER TABLE phplist_user_user_attribute RENAME INDEX phplist_user_user_attribute_attindex TO attindex');
+        $this->addSql('ALTER TABLE phplist_user_user_attribute RENAME INDEX phplist_user_user_attribute_attuserid TO attuserid');
+        $this->addSql('ALTER TABLE phplist_user_user_history DROP FOREIGN KEY FK_6DBB605CF132696E');
+        $this->addSql('ALTER TABLE phplist_user_user_history CHANGE detail detail TEXT DEFAULT NULL, CHANGE systeminfo systeminfo TEXT DEFAULT NULL');
+        $this->addSql('ALTER TABLE phplist_user_user_history RENAME INDEX phplist_user_user_history_userididx TO userididx');
+        $this->addSql('ALTER TABLE phplist_user_user_history RENAME INDEX phplist_user_user_history_dateidx TO dateidx');
+        $this->addSql('ALTER TABLE phplist_usermessage DROP FOREIGN KEY FK_7F30F469F132696E');
+        $this->addSql('ALTER TABLE phplist_usermessage DROP FOREIGN KEY FK_7F30F46931478478');
+        $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX phplist_usermessage_messageidindex TO messageidindex');
+        $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX phplist_usermessage_useridindex TO useridindex');
+        $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX phplist_usermessage_enteredindex TO enteredindex');
+        $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX phplist_usermessage_statusidx TO statusidx');
+        $this->addSql('ALTER TABLE phplist_usermessage RENAME INDEX phplist_usermessage_viewedidx TO viewedidx');
+        $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX phplist_userstats_entry TO entry');
+        $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX phplist_userstats_dateindex TO dateindex');
+        $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX phplist_userstats_itemindex TO itemindex');
+        $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX phplist_userstats_listindex TO listindex');
+        $this->addSql('ALTER TABLE phplist_userstats RENAME INDEX phplist_userstats_listdateindex TO listdateindex');
+    }
+}
diff --git a/src/Migrations/Version20251031072945PostGreInit.php b/src/Migrations/Version20251031072945PostGreInit.php
new file mode 100644
index 00000000..206ec175
--- /dev/null
+++ b/src/Migrations/Version20251031072945PostGreInit.php
@@ -0,0 +1,311 @@
+connection->getDatabasePlatform();
+        $this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf(
+            'Unsupported platform for this migration: %s',
+            get_class($platform)
+        ));
+
+        $this->addSql('CREATE SEQUENCE phplist_admin_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_admin_login_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_admin_password_request_id_key_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_adminattribute_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_admintoken_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_attachment_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_bounce_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_bounceregex_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_eventlog_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_linktrack_linkid_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_linktrack_forward_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_linktrack_uml_click_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_list_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_listmessage_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_message_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_message_attachment_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_sendprocess_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_subscribepage_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_template_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_templateimage_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_urlcache_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_user_attribute_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_user_message_bounce_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_user_message_forward_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_user_message_view_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_user_user_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_user_user_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE SEQUENCE phplist_userstats_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
+        $this->addSql('CREATE TABLE phplist_admin (id INT NOT NULL, created TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, modified TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, loginname VARCHAR(66) NOT NULL, namelc VARCHAR(255) DEFAULT NULL, email VARCHAR(255) NOT NULL, modifiedby VARCHAR(66) DEFAULT NULL, password VARCHAR(255) DEFAULT NULL, passwordchanged DATE DEFAULT NULL, disabled BOOLEAN NOT NULL, superuser BOOLEAN NOT NULL, privileges TEXT DEFAULT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE UNIQUE INDEX phplist_admin_loginnameidx ON phplist_admin (loginname)');
+        $this->addSql('CREATE TABLE phplist_admin_attribute (adminattributeid INT NOT NULL, adminid INT NOT NULL, value VARCHAR(255) DEFAULT NULL, PRIMARY KEY(adminattributeid, adminid))');
+        $this->addSql('CREATE INDEX IDX_58E07690D3B10C48 ON phplist_admin_attribute (adminattributeid)');
+        $this->addSql('CREATE INDEX IDX_58E07690B8ED4D93 ON phplist_admin_attribute (adminid)');
+        $this->addSql('CREATE TABLE phplist_admin_login (id INT NOT NULL, adminid INT NOT NULL, moment BIGINT NOT NULL, remote_ip4 VARCHAR(32) NOT NULL, remote_ip6 VARCHAR(50) NOT NULL, sessionid VARCHAR(50) NOT NULL, active BOOLEAN NOT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX IDX_5FCE0842B8ED4D93 ON phplist_admin_login (adminid)');
+        $this->addSql('CREATE TABLE phplist_admin_password_request (id_key INT NOT NULL, admin INT DEFAULT NULL, date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, key_value VARCHAR(32) NOT NULL, PRIMARY KEY(id_key))');
+        $this->addSql('CREATE INDEX IDX_DC146F3B880E0D76 ON phplist_admin_password_request (admin)');
+        $this->addSql('CREATE TABLE phplist_adminattribute (id INT NOT NULL, name VARCHAR(255) NOT NULL, type VARCHAR(30) DEFAULT NULL, listorder INT DEFAULT NULL, default_value VARCHAR(255) DEFAULT NULL, required BOOLEAN DEFAULT NULL, tablename VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE TABLE phplist_admintoken (id INT NOT NULL, adminid INT DEFAULT NULL, entered INT NOT NULL, expires TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, value VARCHAR(255) NOT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX IDX_CB15D477B8ED4D93 ON phplist_admintoken (adminid)');
+        $this->addSql('CREATE TABLE phplist_attachment (id INT NOT NULL, filename VARCHAR(255) DEFAULT NULL, remotefile VARCHAR(255) DEFAULT NULL, mimetype VARCHAR(255) DEFAULT NULL, description TEXT DEFAULT NULL, size INT DEFAULT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE TABLE phplist_bounce (id INT NOT NULL, date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, header TEXT DEFAULT NULL, data BYTEA DEFAULT NULL, status VARCHAR(255) DEFAULT NULL, comment TEXT DEFAULT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX phplist_bounce_dateindex ON phplist_bounce (date)');
+        $this->addSql('CREATE INDEX phplist_bounce_statusidx ON phplist_bounce (status)');
+        $this->addSql('CREATE TABLE phplist_bounceregex (id INT NOT NULL, regex VARCHAR(2083) DEFAULT NULL, regexhash VARCHAR(32) DEFAULT NULL, action VARCHAR(255) DEFAULT NULL, listorder INT DEFAULT 0, admin INT DEFAULT NULL, comment TEXT DEFAULT NULL, status VARCHAR(255) DEFAULT NULL, count INT DEFAULT 0, PRIMARY KEY(id))');
+        $this->addSql('CREATE UNIQUE INDEX phplist_bounceregex_regex ON phplist_bounceregex (regexhash)');
+        $this->addSql('CREATE TABLE phplist_bounceregex_bounce (regex INT NOT NULL, bounce INT NOT NULL, PRIMARY KEY(regex, bounce))');
+        $this->addSql('CREATE TABLE phplist_config (item VARCHAR(35) NOT NULL, value TEXT DEFAULT NULL, editable BOOLEAN DEFAULT true NOT NULL, type VARCHAR(25) DEFAULT NULL, PRIMARY KEY(item))');
+        $this->addSql('CREATE TABLE phplist_eventlog (id INT NOT NULL, entered TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, page VARCHAR(100) DEFAULT NULL, entry TEXT DEFAULT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX phplist_eventlog_enteredidx ON phplist_eventlog (entered)');
+        $this->addSql('CREATE INDEX phplist_eventlog_pageidx ON phplist_eventlog (page)');
+        $this->addSql('CREATE TABLE phplist_i18n (lan VARCHAR(10) NOT NULL, original VARCHAR(255) NOT NULL, translation TEXT NOT NULL, PRIMARY KEY(lan, original))');
+        $this->addSql('CREATE UNIQUE INDEX phplist_i18n_lanorigunq ON phplist_i18n (lan, original)');
+        $this->addSql('CREATE TABLE phplist_linktrack (linkid INT NOT NULL, messageid INT NOT NULL, userid INT NOT NULL, url VARCHAR(255) DEFAULT NULL, forward VARCHAR(255) DEFAULT NULL, firstclick TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, latestclick TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, clicked INT DEFAULT 0, PRIMARY KEY(linkid))');
+        $this->addSql('CREATE INDEX phplist_linktrack_midindex ON phplist_linktrack (messageid)');
+        $this->addSql('CREATE INDEX phplist_linktrack_miduidindex ON phplist_linktrack (messageid, userid)');
+        $this->addSql('CREATE INDEX phplist_linktrack_uidindex ON phplist_linktrack (userid)');
+        $this->addSql('CREATE INDEX phplist_linktrack_urlindex ON phplist_linktrack (url)');
+        $this->addSql('CREATE UNIQUE INDEX phplist_linktrack_miduidurlindex ON phplist_linktrack (messageid, userid, url)');
+        $this->addSql('CREATE TABLE phplist_linktrack_forward (id INT NOT NULL, url VARCHAR(2083) DEFAULT NULL, urlhash VARCHAR(32) DEFAULT NULL, uuid VARCHAR(36) DEFAULT \'\', personalise BOOLEAN DEFAULT false, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX phplist_linktrack_forward_urlindex ON phplist_linktrack_forward (url)');
+        $this->addSql('CREATE INDEX phplist_linktrack_forward_uuididx ON phplist_linktrack_forward (uuid)');
+        $this->addSql('CREATE UNIQUE INDEX phplist_linktrack_forward_urlunique ON phplist_linktrack_forward (urlhash)');
+        $this->addSql('CREATE TABLE phplist_linktrack_ml (messageid INT NOT NULL, forwardid INT NOT NULL, firstclick TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, latestclick TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, total INT DEFAULT 0, clicked INT DEFAULT 0, htmlclicked INT DEFAULT 0, textclicked INT DEFAULT 0, PRIMARY KEY(messageid, forwardid))');
+        $this->addSql('CREATE INDEX phplist_linktrack_ml_fwdindex ON phplist_linktrack_ml (forwardid)');
+        $this->addSql('CREATE INDEX phplist_linktrack_ml_midindex ON phplist_linktrack_ml (messageid)');
+        $this->addSql('CREATE TABLE phplist_linktrack_uml_click (id INT NOT NULL, messageid INT NOT NULL, userid INT NOT NULL, forwardid INT DEFAULT NULL, firstclick TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, latestclick TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, clicked INT DEFAULT 0, htmlclicked INT DEFAULT 0, textclicked INT DEFAULT 0, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX phplist_linktrack_uml_click_midindex ON phplist_linktrack_uml_click (messageid)');
+        $this->addSql('CREATE INDEX phplist_linktrack_uml_click_miduidindex ON phplist_linktrack_uml_click (messageid, userid)');
+        $this->addSql('CREATE INDEX phplist_linktrack_uml_click_uidindex ON phplist_linktrack_uml_click (userid)');
+        $this->addSql('CREATE UNIQUE INDEX phplist_linktrack_uml_click_miduidfwdid ON phplist_linktrack_uml_click (messageid, userid, forwardid)');
+        $this->addSql('CREATE TABLE phplist_linktrack_userclick (linkid INT NOT NULL, userid INT NOT NULL, messageid INT NOT NULL, name VARCHAR(255) DEFAULT NULL, data TEXT DEFAULT NULL, date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(linkid, userid, messageid))');
+        $this->addSql('CREATE INDEX phplist_linktrack_userclick_linkindex ON phplist_linktrack_userclick (linkid)');
+        $this->addSql('CREATE INDEX phplist_linktrack_userclick_linkuserindex ON phplist_linktrack_userclick (linkid, userid)');
+        $this->addSql('CREATE INDEX phplist_linktrack_userclick_linkusermessageindex ON phplist_linktrack_userclick (linkid, userid, messageid)');
+        $this->addSql('CREATE INDEX phplist_linktrack_userclick_midindex ON phplist_linktrack_userclick (messageid)');
+        $this->addSql('CREATE INDEX phplist_linktrack_userclick_uidindex ON phplist_linktrack_userclick (userid)');
+        $this->addSql('CREATE TABLE phplist_list (id INT NOT NULL, owner INT DEFAULT NULL, name VARCHAR(255) NOT NULL, rssfeed VARCHAR(255) DEFAULT NULL, description VARCHAR(255) NOT NULL, entered TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, modified TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, listorder INT DEFAULT NULL, prefix VARCHAR(10) DEFAULT NULL, active BOOLEAN NOT NULL, category VARCHAR(255) NOT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX IDX_A4CE8621CF60E67C ON phplist_list (owner)');
+        $this->addSql('CREATE INDEX phplist_list_nameidx ON phplist_list (name)');
+        $this->addSql('CREATE INDEX phplist_list_listorderidx ON phplist_list (listorder)');
+        $this->addSql('CREATE TABLE phplist_listmessage (id INT NOT NULL, messageid INT NOT NULL, listid INT NOT NULL, entered TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, modified TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX IDX_83B22D7A31478478 ON phplist_listmessage (messageid)');
+        $this->addSql('CREATE INDEX IDX_83B22D7A8E44C1EF ON phplist_listmessage (listid)');
+        $this->addSql('CREATE INDEX phplist_listmessage_listmessageidx ON phplist_listmessage (listid, messageid)');
+        $this->addSql('CREATE UNIQUE INDEX phplist_listmessage_messageid ON phplist_listmessage (messageid, listid)');
+        $this->addSql('CREATE TABLE phplist_listuser (userid INT NOT NULL, listid INT NOT NULL, entered TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, modified TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(userid, listid))');
+        $this->addSql('CREATE INDEX phplist_listuser_userenteredidx ON phplist_listuser (userid, entered)');
+        $this->addSql('CREATE INDEX phplist_listuser_userlistenteredidx ON phplist_listuser (userid, entered, listid)');
+        $this->addSql('CREATE INDEX phplist_listuser_useridx ON phplist_listuser (userid)');
+        $this->addSql('CREATE INDEX phplist_listuser_listidx ON phplist_listuser (listid)');
+        $this->addSql('CREATE TABLE phplist_message (id INT NOT NULL, owner INT DEFAULT NULL, template INT DEFAULT NULL, modified TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, uuid VARCHAR(36) DEFAULT \'\', htmlformatted BOOLEAN NOT NULL, sendformat VARCHAR(20) DEFAULT NULL, astext BOOLEAN NOT NULL, ashtml BOOLEAN NOT NULL, aspdf BOOLEAN NOT NULL, astextandhtml BOOLEAN NOT NULL, astextandpdf BOOLEAN NOT NULL, repeatinterval INT DEFAULT 0, repeatuntil TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, requeueinterval INT DEFAULT 0, requeueuntil TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, embargo TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, status VARCHAR(255) DEFAULT NULL, processed BOOLEAN DEFAULT false NOT NULL, viewed INT DEFAULT 0 NOT NULL, bouncecount INT DEFAULT 0 NOT NULL, entered TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, sent TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, sendstart TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, subject VARCHAR(255) DEFAULT \'(no subject)\' NOT NULL, message TEXT DEFAULT NULL, textmessage TEXT DEFAULT NULL, footer TEXT DEFAULT NULL, fromfield VARCHAR(255) DEFAULT \'\' NOT NULL, tofield VARCHAR(255) DEFAULT \'\' NOT NULL, replyto VARCHAR(255) DEFAULT \'\' NOT NULL, userselection TEXT DEFAULT NULL, rsstemplate VARCHAR(100) DEFAULT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX IDX_C5D81FCDCF60E67C ON phplist_message (owner)');
+        $this->addSql('CREATE INDEX IDX_C5D81FCD97601F83 ON phplist_message (template)');
+        $this->addSql('CREATE INDEX phplist_message_uuididx ON phplist_message (uuid)');
+        $this->addSql('CREATE TABLE phplist_message_attachment (id INT NOT NULL, messageid INT NOT NULL, attachmentid INT NOT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX phplist_message_attachment_messageattidx ON phplist_message_attachment (messageid, attachmentid)');
+        $this->addSql('CREATE INDEX phplist_message_attachment_messageidx ON phplist_message_attachment (messageid)');
+        $this->addSql('CREATE TABLE phplist_messagedata (name VARCHAR(100) NOT NULL, id INT NOT NULL, data TEXT  DEFAULT NULL, PRIMARY KEY(name, id))');
+        $this->addSql('CREATE TABLE phplist_sendprocess (id INT NOT NULL, modified TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, started TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, alive INT DEFAULT 1, ipaddress VARCHAR(50) DEFAULT NULL, page VARCHAR(100) DEFAULT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE TABLE phplist_subscribepage (id INT NOT NULL, owner INT DEFAULT NULL, title VARCHAR(255) NOT NULL, active BOOLEAN DEFAULT false NOT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX IDX_5BAC7737CF60E67C ON phplist_subscribepage (owner)');
+        $this->addSql('CREATE TABLE phplist_subscribepage_data (id INT NOT NULL, name VARCHAR(100) NOT NULL, data TEXT DEFAULT NULL, PRIMARY KEY(id, name))');
+        $this->addSql('CREATE TABLE phplist_template (id INT NOT NULL, title VARCHAR(255) NOT NULL, template BYTEA DEFAULT NULL, template_text BYTEA DEFAULT NULL, listorder INT DEFAULT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE UNIQUE INDEX phplist_template_title ON phplist_template (title)');
+        $this->addSql('CREATE TABLE phplist_templateimage (id INT NOT NULL, template INT NOT NULL, mimetype VARCHAR(100) DEFAULT NULL, filename VARCHAR(100) DEFAULT NULL, data BYTEA DEFAULT NULL, width INT DEFAULT NULL, height INT DEFAULT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX phplist_templateimage_templateidx ON phplist_templateimage (template)');
+        $this->addSql('CREATE TABLE phplist_urlcache (id INT NOT NULL, url VARCHAR(2083) NOT NULL, lastmodified INT DEFAULT NULL, added TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, content BYTEA DEFAULT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX phplist_urlcache_urlindex ON phplist_urlcache (url)');
+        $this->addSql('CREATE TABLE phplist_user_attribute (id INT NOT NULL, name VARCHAR(255) NOT NULL, type VARCHAR(30) DEFAULT NULL, listorder INT DEFAULT NULL, default_value VARCHAR(255) DEFAULT NULL, required BOOLEAN DEFAULT NULL, tablename VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX phplist_user_attribute_idnameindex ON phplist_user_attribute (id, name)');
+        $this->addSql('CREATE INDEX phplist_user_attribute_nameindex ON phplist_user_attribute (name)');
+        $this->addSql('CREATE TABLE phplist_user_blacklist (email VARCHAR(255) NOT NULL, added TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(email))');
+        $this->addSql('CREATE INDEX phplist_user_blacklist_emailidx ON phplist_user_blacklist (email)');
+        $this->addSql('CREATE TABLE phplist_user_blacklist_data (email VARCHAR(255) NOT NULL, name VARCHAR(25) NOT NULL, data TEXT DEFAULT NULL, PRIMARY KEY(email))');
+        $this->addSql('CREATE INDEX phplist_user_blacklist_data_emailidx ON phplist_user_blacklist_data (email)');
+        $this->addSql('CREATE INDEX phplist_user_blacklist_data_emailnameidx ON phplist_user_blacklist_data (email, name)');
+        $this->addSql('CREATE TABLE phplist_user_message_bounce (id INT NOT NULL, "user" INT NOT NULL, message INT NOT NULL, bounce INT NOT NULL, time TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX phplist_user_message_bounce_bounceidx ON phplist_user_message_bounce (bounce)');
+        $this->addSql('CREATE INDEX phplist_user_message_bounce_msgidx ON phplist_user_message_bounce (message)');
+        $this->addSql('CREATE INDEX phplist_user_message_bounce_umbindex ON phplist_user_message_bounce ("user", message, bounce)');
+        $this->addSql('CREATE INDEX phplist_user_message_bounce_useridx ON phplist_user_message_bounce ("user")');
+        $this->addSql('CREATE TABLE phplist_user_message_forward (id INT NOT NULL, "user" INT NOT NULL, message INT NOT NULL, forward VARCHAR(255) DEFAULT NULL, status VARCHAR(255) DEFAULT NULL, time TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX phplist_user_message_forward_messageidx ON phplist_user_message_forward (message)');
+        $this->addSql('CREATE INDEX phplist_user_message_forward_useridx ON phplist_user_message_forward ("user")');
+        $this->addSql('CREATE INDEX phplist_user_message_forward_usermessageidx ON phplist_user_message_forward ("user", message)');
+        $this->addSql('CREATE TABLE phplist_user_message_view (id INT NOT NULL, messageid INT NOT NULL, userid INT NOT NULL, viewed TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, ip VARCHAR(255) DEFAULT NULL, data TEXT DEFAULT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX phplist_user_message_view_msgidx ON phplist_user_message_view (messageid)');
+        $this->addSql('CREATE INDEX phplist_user_message_view_useridx ON phplist_user_message_view (userid)');
+        $this->addSql('CREATE INDEX phplist_user_message_view_usermsgidx ON phplist_user_message_view (userid, messageid)');
+        $this->addSql('CREATE TABLE phplist_user_user (id INT NOT NULL, entered TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, modified TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, email VARCHAR(255) NOT NULL, confirmed BOOLEAN NOT NULL, blacklisted BOOLEAN NOT NULL, bouncecount INT NOT NULL, uniqid VARCHAR(255) DEFAULT NULL, htmlemail BOOLEAN NOT NULL, disabled BOOLEAN NOT NULL, extradata TEXT DEFAULT NULL, optedin BOOLEAN NOT NULL, uuid VARCHAR(36) NOT NULL, subscribepage INT DEFAULT NULL, rssfrequency VARCHAR(100) DEFAULT NULL, password VARCHAR(255) DEFAULT NULL, passwordchanged TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, foreignkey VARCHAR(100) DEFAULT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX phplist_user_user_idxuniqid ON phplist_user_user (uniqid)');
+        $this->addSql('CREATE INDEX phplist_user_user_enteredindex ON phplist_user_user (entered)');
+        $this->addSql('CREATE INDEX phplist_user_user_confidx ON phplist_user_user (confirmed)');
+        $this->addSql('CREATE INDEX phplist_user_user_blidx ON phplist_user_user (blacklisted)');
+        $this->addSql('CREATE INDEX phplist_user_user_optidx ON phplist_user_user (optedin)');
+        $this->addSql('CREATE INDEX phplist_user_user_uuididx ON phplist_user_user (uuid)');
+        $this->addSql('CREATE INDEX phplist_user_user_foreignkey ON phplist_user_user (foreignkey)');
+        $this->addSql('CREATE UNIQUE INDEX phplist_user_user_email ON phplist_user_user (email)');
+        $this->addSql('CREATE TABLE phplist_user_user_attribute (attributeid INT NOT NULL, userid INT NOT NULL, value TEXT DEFAULT NULL, PRIMARY KEY(attributeid, userid))');
+        $this->addSql('CREATE INDEX phplist_user_user_attribute_attindex ON phplist_user_user_attribute (attributeid)');
+        $this->addSql('CREATE INDEX phplist_user_user_attribute_attuserid ON phplist_user_user_attribute (userid, attributeid)');
+        $this->addSql('CREATE INDEX phplist_user_user_attribute_userindex ON phplist_user_user_attribute (userid)');
+        $this->addSql('CREATE TABLE phplist_user_user_history (id INT NOT NULL, userid INT NOT NULL, ip VARCHAR(255) DEFAULT NULL, date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, summary VARCHAR(255) DEFAULT NULL, detail TEXT DEFAULT NULL, systeminfo TEXT DEFAULT NULL, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX phplist_user_user_history_dateidx ON phplist_user_user_history (date)');
+        $this->addSql('CREATE INDEX phplist_user_user_history_userididx ON phplist_user_user_history (userid)');
+        $this->addSql('CREATE TABLE phplist_usermessage (userid INT NOT NULL, messageid INT NOT NULL, entered TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, viewed TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, status VARCHAR(255) DEFAULT NULL, PRIMARY KEY(userid, messageid))');
+        $this->addSql('CREATE INDEX phplist_usermessage_enteredindex ON phplist_usermessage (entered)');
+        $this->addSql('CREATE INDEX phplist_usermessage_messageidindex ON phplist_usermessage (messageid)');
+        $this->addSql('CREATE INDEX phplist_usermessage_statusidx ON phplist_usermessage (status)');
+        $this->addSql('CREATE INDEX phplist_usermessage_useridindex ON phplist_usermessage (userid)');
+        $this->addSql('CREATE INDEX phplist_usermessage_viewedidx ON phplist_usermessage (viewed)');
+        $this->addSql('CREATE TABLE phplist_userstats (id INT NOT NULL, unixdate INT DEFAULT NULL, item VARCHAR(255) DEFAULT NULL, listid INT DEFAULT 0, value INT DEFAULT 0, PRIMARY KEY(id))');
+        $this->addSql('CREATE INDEX phplist_userstats_dateindex ON phplist_userstats (unixdate)');
+        $this->addSql('CREATE INDEX phplist_userstats_itemindex ON phplist_userstats (item)');
+        $this->addSql('CREATE INDEX phplist_userstats_listdateindex ON phplist_userstats (listid, unixdate)');
+        $this->addSql('CREATE INDEX phplist_userstats_listindex ON phplist_userstats (listid)');
+        $this->addSql('CREATE UNIQUE INDEX phplist_userstats_entry ON phplist_userstats (unixdate, item, listid)');
+        $this->addSql('ALTER TABLE phplist_admin_attribute ADD CONSTRAINT FK_58E07690D3B10C48 FOREIGN KEY (adminattributeid) REFERENCES phplist_adminattribute (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_admin_attribute ADD CONSTRAINT FK_58E07690B8ED4D93 FOREIGN KEY (adminid) REFERENCES phplist_admin (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_admin_login ADD CONSTRAINT FK_5FCE0842B8ED4D93 FOREIGN KEY (adminid) REFERENCES phplist_admin (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_admin_password_request ADD CONSTRAINT FK_DC146F3B880E0D76 FOREIGN KEY (admin) REFERENCES phplist_admin (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_admintoken ADD CONSTRAINT FK_CB15D477B8ED4D93 FOREIGN KEY (adminid) REFERENCES phplist_admin (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_list ADD CONSTRAINT FK_A4CE8621CF60E67C FOREIGN KEY (owner) REFERENCES phplist_admin (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_listmessage ADD CONSTRAINT FK_83B22D7A31478478 FOREIGN KEY (messageid) REFERENCES phplist_message (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_listmessage ADD CONSTRAINT FK_83B22D7A8E44C1EF FOREIGN KEY (listid) REFERENCES phplist_list (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_listuser ADD CONSTRAINT FK_F467E411F132696E FOREIGN KEY (userid) REFERENCES phplist_user_user (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_listuser ADD CONSTRAINT FK_F467E4118E44C1EF FOREIGN KEY (listid) REFERENCES phplist_list (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_message ADD CONSTRAINT FK_C5D81FCDCF60E67C FOREIGN KEY (owner) REFERENCES phplist_admin (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_message ADD CONSTRAINT FK_C5D81FCD97601F83 FOREIGN KEY (template) REFERENCES phplist_template (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_subscribepage ADD CONSTRAINT FK_5BAC7737CF60E67C FOREIGN KEY (owner) REFERENCES phplist_admin (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_templateimage ADD CONSTRAINT FK_30A85BA97601F83 FOREIGN KEY (template) REFERENCES phplist_template (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_user_blacklist_data ADD CONSTRAINT FK_6D67150CE7927C74 FOREIGN KEY (email) REFERENCES phplist_user_blacklist (email) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_user_user_attribute ADD CONSTRAINT FK_E24E310878C45AB5 FOREIGN KEY (attributeid) REFERENCES phplist_user_attribute (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_user_user_attribute ADD CONSTRAINT FK_E24E3108F132696E FOREIGN KEY (userid) REFERENCES phplist_user_user (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_user_user_history ADD CONSTRAINT FK_6DBB605CF132696E FOREIGN KEY (userid) REFERENCES phplist_user_user (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_usermessage ADD CONSTRAINT FK_7F30F469F132696E FOREIGN KEY (userid) REFERENCES phplist_user_user (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+        $this->addSql('ALTER TABLE phplist_usermessage ADD CONSTRAINT FK_7F30F46931478478 FOREIGN KEY (messageid) REFERENCES phplist_message (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+    }
+
+    public function down(Schema $schema): void
+    {
+        $platform = $this->connection->getDatabasePlatform();
+        $this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf(
+            'Unsupported platform for this migration: %s',
+            get_class($platform)
+        ));
+
+        $this->addSql('DROP SEQUENCE phplist_admin_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_admin_login_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_admin_password_request_id_key_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_adminattribute_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_admintoken_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_attachment_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_bounce_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_bounceregex_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_eventlog_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_linktrack_linkid_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_linktrack_forward_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_linktrack_uml_click_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_list_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_listmessage_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_message_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_message_attachment_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_sendprocess_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_subscribepage_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_template_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_templateimage_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_urlcache_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_user_attribute_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_user_message_bounce_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_user_message_forward_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_user_message_view_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_user_user_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_user_user_history_id_seq CASCADE');
+        $this->addSql('DROP SEQUENCE phplist_userstats_id_seq CASCADE');
+        $this->addSql('ALTER TABLE phplist_admin_attribute DROP CONSTRAINT FK_58E07690D3B10C48');
+        $this->addSql('ALTER TABLE phplist_admin_attribute DROP CONSTRAINT FK_58E07690B8ED4D93');
+        $this->addSql('ALTER TABLE phplist_admin_login DROP CONSTRAINT FK_5FCE0842B8ED4D93');
+        $this->addSql('ALTER TABLE phplist_admin_password_request DROP CONSTRAINT FK_DC146F3B880E0D76');
+        $this->addSql('ALTER TABLE phplist_admintoken DROP CONSTRAINT FK_CB15D477B8ED4D93');
+        $this->addSql('ALTER TABLE phplist_list DROP CONSTRAINT FK_A4CE8621CF60E67C');
+        $this->addSql('ALTER TABLE phplist_listmessage DROP CONSTRAINT FK_83B22D7A31478478');
+        $this->addSql('ALTER TABLE phplist_listmessage DROP CONSTRAINT FK_83B22D7A8E44C1EF');
+        $this->addSql('ALTER TABLE phplist_listuser DROP CONSTRAINT FK_F467E411F132696E');
+        $this->addSql('ALTER TABLE phplist_listuser DROP CONSTRAINT FK_F467E4118E44C1EF');
+        $this->addSql('ALTER TABLE phplist_message DROP CONSTRAINT FK_C5D81FCDCF60E67C');
+        $this->addSql('ALTER TABLE phplist_message DROP CONSTRAINT FK_C5D81FCD97601F83');
+        $this->addSql('ALTER TABLE phplist_subscribepage DROP CONSTRAINT FK_5BAC7737CF60E67C');
+        $this->addSql('ALTER TABLE phplist_templateimage DROP CONSTRAINT FK_30A85BA97601F83');
+        $this->addSql('ALTER TABLE phplist_user_blacklist_data DROP CONSTRAINT FK_6D67150CE7927C74');
+        $this->addSql('ALTER TABLE phplist_user_user_attribute DROP CONSTRAINT FK_E24E310878C45AB5');
+        $this->addSql('ALTER TABLE phplist_user_user_attribute DROP CONSTRAINT FK_E24E3108F132696E');
+        $this->addSql('ALTER TABLE phplist_user_user_history DROP CONSTRAINT FK_6DBB605CF132696E');
+        $this->addSql('ALTER TABLE phplist_usermessage DROP CONSTRAINT FK_7F30F469F132696E');
+        $this->addSql('ALTER TABLE phplist_usermessage DROP CONSTRAINT FK_7F30F46931478478');
+        $this->addSql('DROP TABLE phplist_admin');
+        $this->addSql('DROP TABLE phplist_admin_attribute');
+        $this->addSql('DROP TABLE phplist_admin_login');
+        $this->addSql('DROP TABLE phplist_admin_password_request');
+        $this->addSql('DROP TABLE phplist_adminattribute');
+        $this->addSql('DROP TABLE phplist_admintoken');
+        $this->addSql('DROP TABLE phplist_attachment');
+        $this->addSql('DROP TABLE phplist_bounce');
+        $this->addSql('DROP TABLE phplist_bounceregex');
+        $this->addSql('DROP TABLE phplist_bounceregex_bounce');
+        $this->addSql('DROP TABLE phplist_config');
+        $this->addSql('DROP TABLE phplist_eventlog');
+        $this->addSql('DROP TABLE phplist_i18n');
+        $this->addSql('DROP TABLE phplist_linktrack');
+        $this->addSql('DROP TABLE phplist_linktrack_forward');
+        $this->addSql('DROP TABLE phplist_linktrack_ml');
+        $this->addSql('DROP TABLE phplist_linktrack_uml_click');
+        $this->addSql('DROP TABLE phplist_linktrack_userclick');
+        $this->addSql('DROP TABLE phplist_list');
+        $this->addSql('DROP TABLE phplist_listmessage');
+        $this->addSql('DROP TABLE phplist_listuser');
+        $this->addSql('DROP TABLE phplist_message');
+        $this->addSql('DROP TABLE phplist_message_attachment');
+        $this->addSql('DROP TABLE phplist_messagedata');
+        $this->addSql('DROP TABLE phplist_sendprocess');
+        $this->addSql('DROP TABLE phplist_subscribepage');
+        $this->addSql('DROP TABLE phplist_subscribepage_data');
+        $this->addSql('DROP TABLE phplist_template');
+        $this->addSql('DROP TABLE phplist_templateimage');
+        $this->addSql('DROP TABLE phplist_urlcache');
+        $this->addSql('DROP TABLE phplist_user_attribute');
+        $this->addSql('DROP TABLE phplist_user_blacklist');
+        $this->addSql('DROP TABLE phplist_user_blacklist_data');
+        $this->addSql('DROP TABLE phplist_user_message_bounce');
+        $this->addSql('DROP TABLE phplist_user_message_forward');
+        $this->addSql('DROP TABLE phplist_user_message_view');
+        $this->addSql('DROP TABLE phplist_user_user');
+        $this->addSql('DROP TABLE phplist_user_user_attribute');
+        $this->addSql('DROP TABLE phplist_user_user_history');
+        $this->addSql('DROP TABLE phplist_usermessage');
+        $this->addSql('DROP TABLE phplist_userstats');
+    }
+}
diff --git a/src/Migrations/_template_migration.php.tpl b/src/Migrations/_template_migration.php.tpl
new file mode 100644
index 00000000..72561549
--- /dev/null
+++ b/src/Migrations/_template_migration.php.tpl
@@ -0,0 +1,47 @@
+;
+
+use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
+use Doctrine\Migrations\AbstractMigration;
+use Doctrine\DBAL\Schema\Schema;
+
+/**
+* ⚠️ Wizard warning:
+* Doctrine will `helpfully` remove url(255) prefixes and add collations 5.7 can’t read.
+* Review the SQL unless you enjoy debugging key length errors at 2 AM.
+*
+* Ex: phplist_linktrack_forward phplist_linktrack_forward_urlindex (but there are more)
+*/
+final class  extends AbstractMigration
+{
+    public function getDescription(): string
+    {
+        return '';
+    }
+
+    public function up(Schema $schema): void
+    {
+        $platform = $this->connection->getDatabasePlatform();
+        $this->skipIf(!$platform instanceof , sprintf(
+            'Unsupported platform for this migration: %s',
+            get_class($platform)
+        ));
+
+        
+    }
+
+    public function down(Schema $schema): void
+    {
+        $platform = $this->connection->getDatabasePlatform();
+        $this->skipIf(!$platform instanceof , sprintf(
+            'Unsupported platform for this migration: %s',
+            get_class($platform)
+        ));
+
+        
+    }
+}
diff --git a/src/Migrations/initial_schema.sql b/src/Migrations/initial_schema.sql
new file mode 100644
index 00000000..7f9cf70a
--- /dev/null
+++ b/src/Migrations/initial_schema.sql
@@ -0,0 +1,879 @@
+-- MySQL dump 10.13  Distrib 8.0.43, for Linux (x86_64)
+--
+-- Host: localhost    Database: phplistdb
+-- ------------------------------------------------------
+-- Server version	8.0.43-0ubuntu0.20.04.1+esm1
+
+/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
+/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
+/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
+/*!50503 SET NAMES utf8mb4 */;
+/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
+/*!40103 SET TIME_ZONE='+00:00' */;
+/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
+/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
+/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
+/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
+
+--
+-- Table structure for table `phplist_admin`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_admin` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `loginname` varchar(66) NOT NULL,
+  `namelc` varchar(255) DEFAULT NULL,
+  `email` varchar(255) NOT NULL,
+  `created` datetime DEFAULT NULL,
+  `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `modifiedby` varchar(66) DEFAULT NULL,
+  `password` varchar(255) DEFAULT NULL,
+  `passwordchanged` date DEFAULT NULL,
+  `superuser` tinyint DEFAULT '0',
+  `disabled` tinyint DEFAULT '0',
+  `privileges` text,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `loginnameidx` (`loginname`)
+) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_admin_attribute`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_admin_attribute` (
+  `adminattributeid` int NOT NULL,
+  `adminid` int NOT NULL,
+  `value` varchar(255) DEFAULT NULL,
+  PRIMARY KEY (`adminattributeid`,`adminid`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_admin_login`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_admin_login` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `moment` bigint NOT NULL,
+  `adminid` int NOT NULL,
+  `remote_ip4` varchar(32) NOT NULL,
+  `remote_ip6` varchar(50) NOT NULL,
+  `sessionid` varchar(50) NOT NULL,
+  `active` tinyint NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=414 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_admin_password_request`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_admin_password_request` (
+  `id_key` int NOT NULL AUTO_INCREMENT,
+  `date` datetime DEFAULT NULL,
+  `admin` int DEFAULT NULL,
+  `key_value` varchar(32) NOT NULL,
+  PRIMARY KEY (`id_key`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_adminattribute`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_adminattribute` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `name` varchar(255) NOT NULL,
+  `type` varchar(30) DEFAULT NULL,
+  `listorder` int DEFAULT NULL,
+  `default_value` varchar(255) DEFAULT NULL,
+  `required` tinyint DEFAULT NULL,
+  `tablename` varchar(255) DEFAULT NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_admintoken`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_admintoken` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `adminid` int NOT NULL,
+  `value` varchar(255) DEFAULT NULL,
+  `entered` int NOT NULL,
+  `expires` datetime NOT NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=3670 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_attachment`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_attachment` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `filename` varchar(255) DEFAULT NULL,
+  `remotefile` varchar(255) DEFAULT NULL,
+  `mimetype` varchar(255) DEFAULT NULL,
+  `description` text,
+  `size` int DEFAULT NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_bounce`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_bounce` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `date` datetime DEFAULT NULL,
+  `header` text,
+  `data` mediumblob,
+  `status` varchar(255) DEFAULT NULL,
+  `comment` text,
+  PRIMARY KEY (`id`),
+  KEY `dateindex` (`date`),
+  KEY `statusidx` (`status`(20))
+) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_bounceregex`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_bounceregex` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `regex` varchar(2083) DEFAULT NULL,
+  `regexhash` char(32) DEFAULT NULL,
+  `action` varchar(255) DEFAULT NULL,
+  `listorder` int DEFAULT '0',
+  `admin` int DEFAULT NULL,
+  `comment` text,
+  `status` varchar(255) DEFAULT NULL,
+  `count` int DEFAULT '0',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `regex` (`regexhash`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_bounceregex_bounce`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_bounceregex_bounce` (
+  `regex` int NOT NULL,
+  `bounce` int NOT NULL,
+  PRIMARY KEY (`regex`,`bounce`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_config`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_config` (
+  `item` varchar(35) NOT NULL,
+  `value` longtext,
+  `editable` tinyint DEFAULT '1',
+  `type` varchar(25) DEFAULT NULL,
+  PRIMARY KEY (`item`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_eventlog`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_eventlog` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `entered` datetime DEFAULT NULL,
+  `page` varchar(100) DEFAULT NULL,
+  `entry` text,
+  PRIMARY KEY (`id`),
+  KEY `enteredidx` (`entered`),
+  KEY `pageidx` (`page`)
+) ENGINE=InnoDB AUTO_INCREMENT=343 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_i18n`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_i18n` (
+  `lan` varchar(10) NOT NULL,
+  `original` text NOT NULL,
+  `translation` text NOT NULL,
+  UNIQUE KEY `lanorigunq` (`lan`,`original`(200)),
+  KEY `lanorigidx` (`lan`,`original`(200))
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_linktrack`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_linktrack` (
+  `linkid` int NOT NULL AUTO_INCREMENT,
+  `messageid` int NOT NULL,
+  `userid` int NOT NULL,
+  `url` varchar(255) DEFAULT NULL,
+  `forward` varchar(255) DEFAULT NULL,
+  `firstclick` datetime DEFAULT NULL,
+  `latestclick` timestamp NULL DEFAULT NULL,
+  `clicked` int DEFAULT '0',
+  PRIMARY KEY (`linkid`),
+  UNIQUE KEY `miduidurlindex` (`messageid`,`userid`,`url`),
+  KEY `midindex` (`messageid`),
+  KEY `uidindex` (`userid`),
+  KEY `urlindex` (`url`),
+  KEY `miduidindex` (`messageid`,`userid`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_linktrack_forward`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_linktrack_forward` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `url` varchar(2083) DEFAULT NULL,
+  `urlhash` char(32) DEFAULT NULL,
+  `uuid` varchar(36) DEFAULT '',
+  `personalise` tinyint DEFAULT '0',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `urlunique` (`urlhash`),
+  KEY `urlindex` (`url`(255)),
+  KEY `uuididx` (`uuid`)
+) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_linktrack_ml`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_linktrack_ml` (
+  `messageid` int NOT NULL,
+  `forwardid` int NOT NULL,
+  `firstclick` datetime DEFAULT NULL,
+  `latestclick` datetime DEFAULT NULL,
+  `total` int DEFAULT '0',
+  `clicked` int DEFAULT '0',
+  `htmlclicked` int DEFAULT '0',
+  `textclicked` int DEFAULT '0',
+  PRIMARY KEY (`messageid`,`forwardid`),
+  KEY `midindex` (`messageid`),
+  KEY `fwdindex` (`forwardid`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_linktrack_uml_click`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_linktrack_uml_click` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `messageid` int NOT NULL,
+  `userid` int NOT NULL,
+  `forwardid` int DEFAULT NULL,
+  `firstclick` datetime DEFAULT NULL,
+  `latestclick` datetime DEFAULT NULL,
+  `clicked` int DEFAULT '0',
+  `htmlclicked` int DEFAULT '0',
+  `textclicked` int DEFAULT '0',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `miduidfwdid` (`messageid`,`userid`,`forwardid`),
+  KEY `midindex` (`messageid`),
+  KEY `uidindex` (`userid`),
+  KEY `miduidindex` (`messageid`,`userid`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_linktrack_userclick`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_linktrack_userclick` (
+  `linkid` int NOT NULL,
+  `userid` int NOT NULL,
+  `messageid` int NOT NULL,
+  `name` varchar(255) DEFAULT NULL,
+  `data` text,
+  `date` datetime DEFAULT NULL,
+  KEY `linkindex` (`linkid`),
+  KEY `uidindex` (`userid`),
+  KEY `midindex` (`messageid`),
+  KEY `linkuserindex` (`linkid`,`userid`),
+  KEY `linkusermessageindex` (`linkid`,`userid`,`messageid`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_list`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_list` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `name` varchar(255) NOT NULL,
+  `description` text,
+  `entered` datetime DEFAULT NULL,
+  `listorder` int DEFAULT NULL,
+  `prefix` varchar(10) DEFAULT NULL,
+  `rssfeed` varchar(255) DEFAULT NULL,
+  `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `active` tinyint DEFAULT NULL,
+  `owner` int DEFAULT NULL,
+  `category` varchar(255) DEFAULT '',
+  PRIMARY KEY (`id`),
+  KEY `nameidx` (`name`),
+  KEY `listorderidx` (`listorder`)
+) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_listattr_becities`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_listattr_becities` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `name` varchar(255) DEFAULT NULL,
+  `listorder` int DEFAULT '0',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `name` (`name`(150))
+) ENGINE=InnoDB AUTO_INCREMENT=2680 DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_listattr_termsofservice`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_listattr_termsofservice` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `name` varchar(255) DEFAULT NULL,
+  `listorder` int DEFAULT '0',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `name` (`name`(150))
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_listattr_ukcounties`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_listattr_ukcounties` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `name` varchar(255) DEFAULT NULL,
+  `listorder` int DEFAULT '0',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `name` (`name`(150))
+) ENGINE=InnoDB AUTO_INCREMENT=184 DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_listattr_ukcounties1`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_listattr_ukcounties1` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `name` varchar(255) DEFAULT NULL,
+  `listorder` int DEFAULT '0',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `name` (`name`(150))
+) ENGINE=InnoDB AUTO_INCREMENT=184 DEFAULT CHARSET=utf8mb4;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_listmessage`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_listmessage` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `messageid` int NOT NULL,
+  `listid` int NOT NULL,
+  `entered` datetime DEFAULT NULL,
+  `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `messageid` (`messageid`,`listid`),
+  KEY `listmessageidx` (`listid`,`messageid`)
+) ENGINE=InnoDB AUTO_INCREMENT=36 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_listuser`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_listuser` (
+  `userid` int NOT NULL,
+  `listid` int NOT NULL,
+  `entered` datetime DEFAULT NULL,
+  `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`userid`,`listid`),
+  KEY `userenteredidx` (`userid`,`entered`),
+  KEY `userlistenteredidx` (`userid`,`listid`,`entered`),
+  KEY `useridx` (`userid`),
+  KEY `listidx` (`listid`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_message`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_message` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `uuid` varchar(36) DEFAULT '',
+  `subject` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '(no subject)',
+  `fromfield` varchar(255) NOT NULL DEFAULT '',
+  `tofield` varchar(255) NOT NULL DEFAULT '',
+  `replyto` varchar(255) NOT NULL DEFAULT '',
+  `message` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
+  `textmessage` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
+  `footer` text,
+  `entered` datetime DEFAULT NULL,
+  `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `embargo` datetime DEFAULT NULL,
+  `repeatinterval` int DEFAULT '0',
+  `repeatuntil` datetime DEFAULT NULL,
+  `requeueinterval` int DEFAULT '0',
+  `requeueuntil` datetime DEFAULT NULL,
+  `status` varchar(255) DEFAULT NULL,
+  `userselection` text,
+  `sent` datetime DEFAULT NULL,
+  `htmlformatted` tinyint DEFAULT '0',
+  `sendformat` varchar(20) DEFAULT NULL,
+  `template` int DEFAULT NULL,
+  `processed` int unsigned DEFAULT '0',
+  `astext` int DEFAULT '0',
+  `ashtml` int DEFAULT '0',
+  `astextandhtml` int DEFAULT '0',
+  `aspdf` int DEFAULT '0',
+  `astextandpdf` int DEFAULT '0',
+  `viewed` int DEFAULT '0',
+  `bouncecount` int DEFAULT '0',
+  `sendstart` datetime DEFAULT NULL,
+  `rsstemplate` varchar(100) DEFAULT NULL,
+  `owner` int DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  KEY `uuididx` (`uuid`)
+) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_message_attachment`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_message_attachment` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `messageid` int NOT NULL,
+  `attachmentid` int NOT NULL,
+  PRIMARY KEY (`id`),
+  KEY `messageidx` (`messageid`),
+  KEY `messageattidx` (`messageid`,`attachmentid`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_messagedata`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_messagedata` (
+  `name` varchar(100) NOT NULL,
+  `id` int NOT NULL,
+  `data` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
+  PRIMARY KEY (`name`,`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_sendprocess`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_sendprocess` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `started` datetime DEFAULT NULL,
+  `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `alive` int DEFAULT '1',
+  `ipaddress` varchar(50) DEFAULT NULL,
+  `page` varchar(100) DEFAULT NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=58 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_subscribepage`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_subscribepage` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `title` varchar(255) NOT NULL,
+  `active` tinyint DEFAULT '0',
+  `owner` int DEFAULT NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_subscribepage_data`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_subscribepage_data` (
+  `id` int NOT NULL,
+  `name` varchar(100) NOT NULL,
+  `data` text,
+  PRIMARY KEY (`id`,`name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_template`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_template` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `title` varchar(255) NOT NULL,
+  `template` longblob,
+  `template_text` longblob,
+  `listorder` int DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `title` (`title`)
+) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_templateimage`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_templateimage` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `template` int NOT NULL DEFAULT '0',
+  `mimetype` varchar(100) DEFAULT NULL,
+  `filename` varchar(100) DEFAULT NULL,
+  `data` longblob,
+  `width` int DEFAULT NULL,
+  `height` int DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  KEY `templateidx` (`template`)
+) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_urlcache`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_urlcache` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `url` varchar(2083) NOT NULL,
+  `lastmodified` int DEFAULT NULL,
+  `added` datetime DEFAULT NULL,
+  `content` longblob,
+  PRIMARY KEY (`id`),
+  KEY `urlindex` (`url`(255))
+) ENGINE=InnoDB AUTO_INCREMENT=207 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_user_attribute`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_user_attribute` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `name` varchar(255) NOT NULL,
+  `type` varchar(30) DEFAULT NULL,
+  `listorder` int DEFAULT NULL,
+  `default_value` varchar(255) DEFAULT NULL,
+  `required` tinyint DEFAULT NULL,
+  `tablename` varchar(255) DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  KEY `nameindex` (`name`),
+  KEY `idnameindex` (`id`,`name`)
+) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_user_blacklist`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_user_blacklist` (
+  `email` varchar(255) NOT NULL,
+  `added` datetime DEFAULT NULL,
+  UNIQUE KEY `email` (`email`),
+  KEY `emailidx` (`email`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_user_blacklist_data`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_user_blacklist_data` (
+  `email` varchar(150) NOT NULL,
+  `name` varchar(25) NOT NULL,
+  `data` text,
+  UNIQUE KEY `email` (`email`),
+  KEY `emailidx` (`email`),
+  KEY `emailnameidx` (`email`,`name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_user_message_bounce`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_user_message_bounce` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `user` int NOT NULL,
+  `message` int NOT NULL,
+  `bounce` int NOT NULL,
+  `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `umbindex` (`user`,`message`,`bounce`),
+  KEY `useridx` (`user`),
+  KEY `msgidx` (`message`),
+  KEY `bounceidx` (`bounce`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_user_message_forward`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_user_message_forward` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `user` int NOT NULL,
+  `message` int NOT NULL,
+  `forward` varchar(255) DEFAULT NULL,
+  `status` varchar(255) DEFAULT NULL,
+  `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `usermessageidx` (`user`,`message`),
+  KEY `useridx` (`user`),
+  KEY `messageidx` (`message`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_user_message_view`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_user_message_view` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `messageid` int NOT NULL,
+  `userid` int NOT NULL,
+  `viewed` datetime DEFAULT NULL,
+  `ip` varchar(255) DEFAULT NULL,
+  `data` longtext,
+  PRIMARY KEY (`id`),
+  KEY `usermsgidx` (`userid`,`messageid`),
+  KEY `msgidx` (`messageid`),
+  KEY `useridx` (`userid`)
+) ENGINE=InnoDB AUTO_INCREMENT=25 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_user_user`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_user_user` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `email` varchar(255) NOT NULL,
+  `confirmed` tinyint DEFAULT '0',
+  `blacklisted` tinyint DEFAULT '0',
+  `optedin` tinyint DEFAULT '0',
+  `bouncecount` int DEFAULT '0',
+  `entered` datetime DEFAULT NULL,
+  `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `uniqid` varchar(255) DEFAULT NULL,
+  `uuid` varchar(36) DEFAULT '',
+  `htmlemail` tinyint DEFAULT '0',
+  `subscribepage` int DEFAULT NULL,
+  `rssfrequency` varchar(100) DEFAULT NULL,
+  `password` varchar(255) DEFAULT NULL,
+  `passwordchanged` date DEFAULT NULL,
+  `disabled` tinyint DEFAULT '0',
+  `extradata` text,
+  `foreignkey` varchar(100) DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `email` (`email`),
+  KEY `foreignkey` (`foreignkey`),
+  KEY `idxuniqid` (`uniqid`),
+  KEY `enteredindex` (`entered`),
+  KEY `confidx` (`confirmed`),
+  KEY `blidx` (`blacklisted`),
+  KEY `optidx` (`optedin`),
+  KEY `uuididx` (`uuid`)
+) ENGINE=InnoDB AUTO_INCREMENT=46 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_user_user_attribute`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_user_user_attribute` (
+  `attributeid` int NOT NULL,
+  `userid` int NOT NULL,
+  `value` text,
+  PRIMARY KEY (`attributeid`,`userid`),
+  KEY `userindex` (`userid`),
+  KEY `attindex` (`attributeid`),
+  KEY `attuserid` (`userid`,`attributeid`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_user_user_history`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_user_user_history` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `userid` int NOT NULL,
+  `ip` varchar(255) DEFAULT NULL,
+  `date` datetime DEFAULT NULL,
+  `summary` varchar(255) DEFAULT NULL,
+  `detail` text,
+  `systeminfo` text,
+  PRIMARY KEY (`id`),
+  KEY `userididx` (`userid`),
+  KEY `dateidx` (`date`)
+) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_usermessage`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_usermessage` (
+  `messageid` int NOT NULL,
+  `userid` int NOT NULL,
+  `entered` datetime NOT NULL,
+  `viewed` datetime DEFAULT NULL,
+  `status` varchar(255) DEFAULT NULL,
+  PRIMARY KEY (`userid`,`messageid`),
+  KEY `messageidindex` (`messageid`),
+  KEY `useridindex` (`userid`),
+  KEY `enteredindex` (`entered`),
+  KEY `statusidx` (`status`),
+  KEY `viewedidx` (`viewed`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `phplist_userstats`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `phplist_userstats` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `unixdate` int DEFAULT NULL,
+  `item` varchar(255) DEFAULT NULL,
+  `listid` int DEFAULT '0',
+  `value` int DEFAULT '0',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `entry` (`unixdate`,`item`,`listid`),
+  KEY `dateindex` (`unixdate`),
+  KEY `itemindex` (`item`),
+  KEY `listindex` (`listid`),
+  KEY `listdateindex` (`listid`,`unixdate`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+/*!40101 SET character_set_client = @saved_cs_client */;
+/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
+
+/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
+/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
+/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
+/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
+/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
+/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
+/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
+
+-- Dump completed on 2025-10-28 14:22:41
diff --git a/src/Routing/ExtraLoader.php b/src/Routing/ExtraLoader.php
index 39853031..d5f40ec1 100644
--- a/src/Routing/ExtraLoader.php
+++ b/src/Routing/ExtraLoader.php
@@ -43,8 +43,6 @@ public function __construct(ApplicationStructure $applicationStructure)
     /**
      * Loads a resource.
      *
-     * @SuppressWarnings("PHPMD.UnusedFormalParameter")
-     *
      * @param mixed $resource the resource (unused)
      * @param string|null $type the resource type or null if unknown (unused)
      *
@@ -69,8 +67,6 @@ public function load($resource, string $type = null): RouteCollection
     /**
      * Checks whether this class supports the given resource.
      *
-     * @SuppressWarnings("PHPMD.UnusedFormalParameter")
-     *
      * @param mixed $resource a resource (unused)
      * @param string|null $type The resource type or null if unknown
      *
diff --git a/tests/Integration/Core/ConfigProviderTest.php b/tests/Integration/Core/ConfigProviderTest.php
index d2fdf896..5776bd17 100644
--- a/tests/Integration/Core/ConfigProviderTest.php
+++ b/tests/Integration/Core/ConfigProviderTest.php
@@ -4,14 +4,14 @@
 
 namespace PhpList\Core\Tests\Integration\Core;
 
-use PhpList\Core\Core\ConfigProvider;
+use PhpList\Core\Core\ParameterProvider;
 use PHPUnit\Framework\TestCase;
 
 class ConfigProviderTest extends TestCase
 {
     public function testReturnsConfigValueIfExists(): void
     {
-        $provider = new ConfigProvider([
+        $provider = new ParameterProvider([
             'site_name' => 'phpList',
             'debug' => true,
         ]);
@@ -22,7 +22,7 @@ public function testReturnsConfigValueIfExists(): void
 
     public function testReturnsDefaultIfKeyMissing(): void
     {
-        $provider = new ConfigProvider([
+        $provider = new ParameterProvider([
             'site_name' => 'phpList',
         ]);
 
@@ -33,7 +33,7 @@ public function testReturnsDefaultIfKeyMissing(): void
     public function testReturnsAllConfig(): void
     {
         $data = ['a' => 1, 'b' => 2];
-        $provider = new ConfigProvider($data);
+        $provider = new ParameterProvider($data);
 
         $this->assertSame($data, $provider->all());
     }
diff --git a/tests/Integration/Domain/Identity/Fixtures/AdministratorTokenWithAdministratorFixture.php b/tests/Integration/Domain/Identity/Fixtures/AdministratorTokenWithAdministratorFixture.php
index e382a4c7..18bbc08b 100644
--- a/tests/Integration/Domain/Identity/Fixtures/AdministratorTokenWithAdministratorFixture.php
+++ b/tests/Integration/Domain/Identity/Fixtures/AdministratorTokenWithAdministratorFixture.php
@@ -40,17 +40,16 @@ public function load(ObjectManager $manager): void
 
             $admin = $adminRepository->find($row['adminid']);
             if ($admin === null) {
-                $admin = new Administrator();
+                $admin = (new Administrator())->setLoginName($row['id']);
                 $this->setSubjectId($admin, (int)$row['adminid']);
                 $manager->persist($admin);
             }
 
-            $adminToken = new AdministratorToken();
+            $adminToken = new AdministratorToken($admin);
             $this->setSubjectId($adminToken, (int)$row['id']);
             $adminToken->setKey($row['value']);
             $this->setSubjectProperty($adminToken, 'expiry', new DateTime($row['expires']));
             $this->setSubjectProperty($adminToken, 'createdAt', (bool) $row['entered']);
-            $adminToken->setAdministrator($admin);
             $manager->persist($adminToken);
         } while (true);
 
diff --git a/tests/Integration/Domain/Identity/Fixtures/DetachedAdministratorTokenFixture.php b/tests/Integration/Domain/Identity/Fixtures/DetachedAdministratorTokenFixture.php
index a3d16ea7..4468f131 100644
--- a/tests/Integration/Domain/Identity/Fixtures/DetachedAdministratorTokenFixture.php
+++ b/tests/Integration/Domain/Identity/Fixtures/DetachedAdministratorTokenFixture.php
@@ -7,6 +7,7 @@
 use DateTime;
 use Doctrine\Bundle\FixturesBundle\Fixture;
 use Doctrine\Persistence\ObjectManager;
+use PhpList\Core\Domain\Identity\Model\Administrator;
 use PhpList\Core\Domain\Identity\Model\AdministratorToken;
 use PhpList\Core\TestingSupport\Traits\ModelTestTrait;
 use RuntimeException;
@@ -28,6 +29,9 @@ public function load(ObjectManager $manager): void
         }
 
         $headers = fgetcsv($handle);
+        $admin = (new Administrator())->setLoginName('admin');
+        $this->setSubjectId($admin, 1);
+        $manager->persist($admin);
 
         do {
             $data = fgetcsv($handle);
@@ -36,7 +40,7 @@ public function load(ObjectManager $manager): void
             }
             $row = array_combine($headers, $data);
 
-            $adminToken = new AdministratorToken();
+            $adminToken = new AdministratorToken($admin);
             $this->setSubjectId($adminToken, (int)$row['id']);
             $adminToken->setKey($row['value']);
 
diff --git a/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php b/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php
index 25ecf0f8..a69a751b 100644
--- a/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php
+++ b/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php
@@ -93,7 +93,7 @@ public function testModificationDateOfExistingModelGetsUpdatedOnUpdate(): void
 
     public function testCreationDateOfNewModelIsSetToNowOnPersist()
     {
-        $model = new Administrator();
+        $model = (new Administrator())->setLoginName('ta');
 
         $this->entityManager->persist($model);
         $this->entityManager->flush();
@@ -104,7 +104,7 @@ public function testCreationDateOfNewModelIsSetToNowOnPersist()
 
     public function testModificationDateOfNewModelIsSetToNowOnPersist()
     {
-        $model = new Administrator();
+        $model = (new Administrator())->setLoginName('tat');
 
         $this->entityManager->persist($model);
         $this->entityManager->flush();
@@ -165,7 +165,7 @@ public function testFindOneByLoginCredentialsIgnoresNonSuperUser()
 
     public function testSavePersistsAndFlushesModel(): void
     {
-        $model = new Administrator();
+        $model = (new Administrator())->setLoginName('t');
         $this->repository->save($model);
 
         $this->assertSame($model, $this->repository->find($model->getId()));
@@ -177,7 +177,7 @@ public function testRemoveRemovesModel(): void
         $this->assertNotEmpty($allModels);
 
         $model = $allModels[0];
-        $this->repository->remove($model);
+        $this->repository->delete($model);
 
         $remainingModels = $this->repository->findAll();
         $this->assertCount(count($allModels) - 1, $remainingModels);
diff --git a/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php b/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php
index 52feeb8c..015061d0 100644
--- a/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php
+++ b/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php
@@ -122,56 +122,6 @@ public function testFindOneUnexpiredByKeyNotFindsUnexpiredTokenWithNonMatchingKe
         self::assertNull($model);
     }
 
-    public function testRemoveExpiredRemovesExpiredToken()
-    {
-        $this->loadFixtures([DetachedAdministratorTokenFixture::class]);
-
-        $idOfExpiredToken = 1;
-        $this->repository->removeExpired();
-
-        $token = $this->repository->find($idOfExpiredToken);
-        self::assertNull($token);
-    }
-
-    public function testRemoveExpiredKeepsUnexpiredToken()
-    {
-        $this->assertNotYear2037Yet();
-
-        $this->loadFixtures([DetachedAdministratorTokenFixture::class]);
-
-        $idOfUnexpiredToken = 2;
-        $this->repository->removeExpired();
-
-        $token = $this->repository->find($idOfUnexpiredToken);
-        self::assertNotNull($token);
-    }
-
-    /**
-     * Asserts that it's not year 2037 yet (which is the year the "not expired" token in the fixture
-     * data set expires).
-     */
-    private function assertNotYear2037Yet(): void
-    {
-        $currentYear = (int)date('Y');
-        if ($currentYear >= 2037) {
-            self::markTestIncomplete('The tests token has an expiry in the year 2037. Please update this test.');
-        }
-    }
-
-    public function testRemoveExpiredForNoExpiredTokensReturnsZero()
-    {
-        self::assertSame(0, $this->repository->removeExpired());
-    }
-
-    public function testRemoveExpiredForOneExpiredTokenReturnsOne()
-    {
-        $this->assertNotYear2037Yet();
-
-        $this->loadFixtures([DetachedAdministratorTokenFixture::class]);
-
-        self::assertSame(1, $this->repository->removeExpired());
-    }
-
     public function testSavePersistsAndFlushesModel()
     {
         $this->loadFixtures([AdministratorFixture::class]);
@@ -180,8 +130,7 @@ public function testSavePersistsAndFlushesModel()
         /** @var Administrator $administrator */
         $administrator = $administratorRepository->find(1);
 
-        $model = new AdministratorToken();
-        $model->setAdministrator($administrator);
+        $model = new AdministratorToken($administrator);
         $this->repository->save($model);
 
         self::assertSame($model, $this->repository->find($model->getId()));
@@ -196,7 +145,7 @@ public function testRemoveRemovesModel()
         $numberOfModelsBeforeRemove = count($allModels);
         $firstModel = $allModels[0];
 
-        $this->repository->remove($firstModel);
+        $this->repository->delete($firstModel);
 
         $numberOfModelsAfterRemove = count($this->repository->findAll());
         self::assertSame(1, $numberOfModelsBeforeRemove - $numberOfModelsAfterRemove);
diff --git a/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php b/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php
new file mode 100644
index 00000000..50820026
--- /dev/null
+++ b/tests/Integration/Domain/Identity/Service/PermissionCheckerTest.php
@@ -0,0 +1,35 @@
+checker = self::getContainer()->get(PermissionChecker::class);
+    }
+
+    public function testServiceIsRegisteredInContainer(): void
+    {
+        self::assertInstanceOf(PermissionChecker::class, $this->checker);
+        self::assertSame($this->checker, self::getContainer()->get(PermissionChecker::class));
+    }
+
+    public function testSuperUserCanManageAnyResource(): void
+    {
+        $admin = new Administrator();
+        $admin->setSuperUser(true);
+        $resource = $this->createMock(SubscriberList::class);
+        $this->assertTrue($this->checker->canManage($admin, $resource));
+    }
+}
diff --git a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php
index d7435815..388fa391 100644
--- a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php
+++ b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php
@@ -42,13 +42,13 @@ protected function tearDown(): void
 
     public function testMessageIsPersistedAndFetchedCorrectly(): void
     {
-        $admin = new Administrator();
+        $admin = (new Administrator())->setLoginName('t');
         $this->entityManager->persist($admin);
 
         $message = new Message(
             new MessageFormat(true, 'text'),
             new MessageSchedule(1, null, 3, null, null),
-            new MessageMetadata('done'),
+            new MessageMetadata(Message\MessageStatus::Sent),
             new MessageContent('Hello world!'),
             new MessageOptions(),
             $admin
@@ -62,14 +62,14 @@ public function testMessageIsPersistedAndFetchedCorrectly(): void
 
         self::assertCount(1, $foundMessages);
         self::assertInstanceOf(Message::class, $foundMessages[0]);
-        self::assertSame('done', $foundMessages[0]->getMetadata()->getStatus());
+        self::assertSame(Message\MessageStatus::Sent, $foundMessages[0]->getMetadata()->getStatus());
         self::assertSame('Hello world!', $foundMessages[0]->getContent()->getSubject());
     }
 
     public function testGetByOwnerIdReturnsOnlyOwnedMessages(): void
     {
-        $admin1 = new Administrator();
-        $admin2 = new Administrator();
+        $admin1 = (new Administrator())->setLoginName('1');
+        $admin2 = (new Administrator())->setLoginName('2');
 
         $this->entityManager->persist($admin1);
         $this->entityManager->persist($admin2);
@@ -77,7 +77,7 @@ public function testGetByOwnerIdReturnsOnlyOwnedMessages(): void
         $msg1 = new Message(
             new MessageFormat(true, MessageFormat::FORMAT_TEXT),
             new MessageSchedule(1, null, 3, null, null),
-            new MessageMetadata('done'),
+            new MessageMetadata(Message\MessageStatus::Sent),
             new MessageContent('Owned by Admin 1!'),
             new MessageOptions(),
             $admin1
diff --git a/tests/Integration/Domain/Messaging/Repository/SubscriberListRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/SubscriberListRepositoryTest.php
index 0f122582..93e99a81 100644
--- a/tests/Integration/Domain/Messaging/Repository/SubscriberListRepositoryTest.php
+++ b/tests/Integration/Domain/Messaging/Repository/SubscriberListRepositoryTest.php
@@ -206,7 +206,7 @@ public function testRemoveRemovesModel()
         $numberOfModelsBeforeRemove = count($allModels);
         $firstModel = $allModels[0];
 
-        $this->subscriberListRepository->remove($firstModel);
+        $this->subscriberListRepository->delete($firstModel);
 
         $numberOfModelsAfterRemove = count($this->subscriberListRepository->findAll());
         self::assertSame(1, $numberOfModelsBeforeRemove - $numberOfModelsAfterRemove);
diff --git a/tests/Integration/Domain/Subscription/Fixtures/SubscriberListFixture.php b/tests/Integration/Domain/Subscription/Fixtures/SubscriberListFixture.php
index 133d2248..57345573 100644
--- a/tests/Integration/Domain/Subscription/Fixtures/SubscriberListFixture.php
+++ b/tests/Integration/Domain/Subscription/Fixtures/SubscriberListFixture.php
@@ -41,7 +41,8 @@ public function load(ObjectManager $manager): void
 
             $admin = $adminRepository->find($row['owner']);
             if ($admin === null) {
-                $admin = new Administrator();
+                $admin = (new Administrator())
+                    ->setLoginName($row['name']);
                 $this->setSubjectId($admin, (int)$row['owner']);
                 $manager->persist($admin);
             }
diff --git a/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php b/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php
index 91d871d5..9a2ead68 100644
--- a/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php
+++ b/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php
@@ -227,7 +227,7 @@ public function testRemoveAlsoRemovesAssociatedSubscriptions()
         $numberOfAssociatedSubscriptions = count($model->getSubscriptions());
         self::assertGreaterThan(0, $numberOfAssociatedSubscriptions);
 
-        $this->subscriberRepository->remove($model);
+        $this->subscriberRepository->delete($model);
 
         $newNumberOfSubscriptions = count($this->subscriptionRepository->findAll());
         $numberOfRemovedSubscriptions = $initialNumberOfSubscriptions - $newNumberOfSubscriptions;
@@ -246,7 +246,7 @@ public function testRemoveRemovesModel()
         $numberOfModelsBeforeRemove = count($allModels);
         $firstModel = $allModels[0];
 
-        $this->subscriberRepository->remove($firstModel);
+        $this->subscriberRepository->delete($firstModel);
 
         $numberOfModelsAfterRemove = count($this->subscriberRepository->findAll());
         self::assertSame(1, $numberOfModelsBeforeRemove - $numberOfModelsAfterRemove);
diff --git a/tests/Integration/Domain/Subscription/Repository/SubscriptionRepositoryTest.php b/tests/Integration/Domain/Subscription/Repository/SubscriptionRepositoryTest.php
index de18894c..3086b178 100644
--- a/tests/Integration/Domain/Subscription/Repository/SubscriptionRepositoryTest.php
+++ b/tests/Integration/Domain/Subscription/Repository/SubscriptionRepositoryTest.php
@@ -161,7 +161,7 @@ public function testRemoveRemovesModel()
         $numberOfModelsBeforeRemove = count($allModels);
         $firstModel = $allModels[0];
 
-        $this->subscriptionRepository->remove($firstModel);
+        $this->subscriptionRepository->delete($firstModel);
 
         $numberOfModelsAfterRemove = count($this->subscriptionRepository->findAll());
         self::assertSame(1, $numberOfModelsBeforeRemove - $numberOfModelsAfterRemove);
diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php
index 0e84fdec..c9d60ae3 100644
--- a/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php
+++ b/tests/Integration/Domain/Subscription/Service/SubscriberCsvImportManagerTest.php
@@ -4,6 +4,7 @@
 
 namespace PhpList\Core\Tests\Integration\Domain\Subscription\Service;
 
+use Doctrine\ORM\Tools\SchemaTool;
 use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions;
 use PhpList\Core\Domain\Subscription\Model\Subscriber;
 use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition;
@@ -28,6 +29,10 @@ class SubscriberCsvImportManagerTest extends KernelTestCase
     protected function setUp(): void
     {
         parent::setUp();
+        $this->setUpDatabaseTest();
+        $schemaTool = new SchemaTool($this->entityManager);
+        $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata();
+        $schemaTool->dropSchema($metadata);
         $this->loadSchema();
 
         $this->subscriberCsvImportManager = self::getContainer()->get(SubscriberCsvImporter::class);
@@ -49,16 +54,16 @@ public function testImportFromCsvCreatesNewSubscribers(): void
         file_put_contents($tempFile, $csvContent);
 
         $uploadedFile = new UploadedFile(
-            $tempFile,
-            'subscribers.csv',
-            'text/csv',
-            null,
-            true
+            path: $tempFile,
+            originalName: 'subscribers.csv',
+            mimeType: 'text/csv',
+            error: null,
+            test: true
         );
 
         $subscriberCountBefore = count($this->subscriberRepository->findAll());
 
-        $options = new SubscriberImportOptions();
+        $options = new SubscriberImportOptions(true);
         $result = $this->subscriberCsvImportManager->importFromCsv($uploadedFile, $options);
 
         $subscriberCountAfter = count($this->subscriberRepository->findAll());
diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php
index e6d42236..8fb8e31c 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;
@@ -51,13 +52,13 @@ protected function tearDown(): void
 
     public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError(): void
     {
-        $admin = new Administrator();
+        $admin = (new Administrator())->setLoginName('ta');
         $this->entityManager->persist($admin);
 
         $msg = new Message(
             format: new MessageFormat(true, MessageFormat::FORMAT_TEXT),
             schedule: new MessageSchedule(1, null, 3, null, null),
-            metadata: new MessageMetadata('done'),
+            metadata: new MessageMetadata(Message\MessageStatus::Sent),
             content: new MessageContent('Owned by Admin 1!'),
             options: new MessageOptions(),
             owner: $admin
@@ -91,10 +92,10 @@ public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError():
         $this->entityManager->persist($linkTrackUmlClick);
 
         $userMessage = new UserMessage($subscriber, $msg);
-        $userMessage->setStatus('sent');
+        $userMessage->setStatus(Message\UserMessageStatus::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/Bounce/Command/ProcessBouncesCommandTest.php b/tests/Unit/Bounce/Command/ProcessBouncesCommandTest.php
new file mode 100644
index 00000000..4ab6d556
--- /dev/null
+++ b/tests/Unit/Bounce/Command/ProcessBouncesCommandTest.php
@@ -0,0 +1,267 @@
+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);
+        $this->translator = new Translator('en');
+        $this->entityManager = $this->createMock(EntityManagerInterface::class);
+
+        $command = new ProcessBouncesCommand(
+            lockService: $this->lockService,
+            logger: $this->logger,
+            protocolProcessors: [$this->protocolProcessor],
+            advancedRulesProcessor: $this->advancedRulesProcessor,
+            unidentifiedReprocessor: $this->unidentifiedReprocessor,
+            consecutiveBounceHandler: $this->consecutiveBounceHandler,
+            translator: $this->translator,
+            entityManager: $this->entityManager,
+        );
+
+        $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());
+    }
+    public function testForceLockFailureReturnsFailureAndMessage(): void
+    {
+        $this->lockService->expects($this->once())
+            ->method('acquirePageLock')
+            ->with('bounce_processor', true)
+            ->willReturn(0);
+
+        $this->protocolProcessor->expects($this->never())->method('process');
+        $this->advancedRulesProcessor->expects($this->never())->method('process');
+        $this->consecutiveBounceHandler->expects($this->never())->method('handle');
+
+        $this->commandTester->execute([
+            '--force' => true,
+        ]);
+
+        $output = $this->commandTester->getDisplay();
+        $this->assertStringContainsString('Could not apply force lock. Aborting.', $output);
+        $this->assertSame(1, $this->commandTester->getStatusCode());
+    }
+
+    public function testRulesBatchSizeOptionIsRespected(): void
+    {
+        $this->lockService
+            ->expects($this->once())
+            ->method('acquirePageLock')
+            ->with('bounce_processor', false)
+            ->willReturn(10);
+        $this->lockService
+            ->expects($this->once())
+            ->method('release')
+            ->with(10);
+
+        $this->protocolProcessor->method('getProtocol')->willReturn('pop');
+        $this->protocolProcessor->method('process')->willReturn('');
+
+        $this->advancedRulesProcessor
+            ->expects($this->once())
+            ->method('process')
+            ->with($this->anything(), 50);
+
+        $this->commandTester->execute([
+            '--rules-batch-size' => 50,
+        ]);
+
+        $this->assertSame(0, $this->commandTester->getStatusCode());
+    }
+
+    public function testEntityManagerIsFlushedAfterLockAcquireAttempt(): void
+    {
+        $this->lockService->expects($this->once())
+            ->method('acquirePageLock')
+            ->with('bounce_processor', false)
+            ->willReturn(null);
+
+        $this->entityManager->expects($this->once())
+            ->method('flush');
+
+        $this->commandTester->execute([]);
+
+        $this->assertSame(0, $this->commandTester->getStatusCode());
+    }
+}
diff --git a/tests/Unit/Bounce/Service/BounceActionResolverTest.php b/tests/Unit/Bounce/Service/BounceActionResolverTest.php
new file mode 100644
index 00000000..92b1054d
--- /dev/null
+++ b/tests/Unit/Bounce/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/Bounce/Service/ConsecutiveBounceHandlerTest.php b/tests/Unit/Bounce/Service/ConsecutiveBounceHandlerTest.php
new file mode 100644
index 00000000..fbfdfa8a
--- /dev/null
+++ b/tests/Unit/Bounce/Service/ConsecutiveBounceHandlerTest.php
@@ -0,0 +1,214 @@
+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,
+            translator: new Translator('en'),
+            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/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php b/tests/Unit/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php
new file mode 100644
index 00000000..c7c2260d
--- /dev/null
+++ b/tests/Unit/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php
@@ -0,0 +1,80 @@
+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,
+            translator: new Translator('en')
+        );
+    }
+
+    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/Bounce/Service/Handler/BlacklistEmailHandlerTest.php b/tests/Unit/Bounce/Service/Handler/BlacklistEmailHandlerTest.php
new file mode 100644
index 00000000..b5b06e59
--- /dev/null
+++ b/tests/Unit/Bounce/Service/Handler/BlacklistEmailHandlerTest.php
@@ -0,0 +1,75 @@
+historyManager = $this->createMock(SubscriberHistoryManager::class);
+        $this->blacklistService = $this->createMock(SubscriberBlacklistService::class);
+        $this->handler = new BlacklistEmailHandler(
+            subscriberHistoryManager: $this->historyManager,
+            blacklistService: $this->blacklistService,
+            translator: new Translator('en'),
+        );
+    }
+
+    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/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php b/tests/Unit/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php
new file mode 100644
index 00000000..e2975d37
--- /dev/null
+++ b/tests/Unit/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php
@@ -0,0 +1,92 @@
+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,
+            translator: new Translator('en')
+        );
+    }
+
+    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/Bounce/Service/Handler/BlacklistUserHandlerTest.php b/tests/Unit/Bounce/Service/Handler/BlacklistUserHandlerTest.php
new file mode 100644
index 00000000..153faa1c
--- /dev/null
+++ b/tests/Unit/Bounce/Service/Handler/BlacklistUserHandlerTest.php
@@ -0,0 +1,86 @@
+historyManager = $this->createMock(SubscriberHistoryManager::class);
+        $this->blacklistService = $this->createMock(SubscriberBlacklistService::class);
+        $this->handler = new BlacklistUserHandler(
+            subscriberHistoryManager: $this->historyManager,
+            blacklistService: $this->blacklistService,
+            translator: new Translator('en')
+        );
+    }
+
+    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/Bounce/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Bounce/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php
new file mode 100644
index 00000000..9625d348
--- /dev/null
+++ b/tests/Unit/Bounce/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php
@@ -0,0 +1,101 @@
+historyManager = $this->createMock(SubscriberHistoryManager::class);
+        $this->bounceManager = $this->createMock(BounceManager::class);
+        $this->subscriberRepository = $this->createMock(SubscriberRepository::class);
+        $this->handler = new DecreaseCountConfirmUserAndDeleteBounceHandler(
+            subscriberHistoryManager: $this->historyManager,
+            bounceManager: $this->bounceManager,
+            subscriberRepository: $this->subscriberRepository,
+            translator: new Translator('en'),
+        );
+    }
+
+    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->subscriberRepository->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->subscriberRepository->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->subscriberRepository->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/Bounce/Service/Handler/DeleteBounceHandlerTest.php b/tests/Unit/Bounce/Service/Handler/DeleteBounceHandlerTest.php
new file mode 100644
index 00000000..1455ab83
--- /dev/null
+++ b/tests/Unit/Bounce/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/Bounce/Service/Handler/DeleteUserAndBounceHandlerTest.php b/tests/Unit/Bounce/Service/Handler/DeleteUserAndBounceHandlerTest.php
new file mode 100644
index 00000000..768efd0c
--- /dev/null
+++ b/tests/Unit/Bounce/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/Bounce/Service/Handler/DeleteUserHandlerTest.php b/tests/Unit/Bounce/Service/Handler/DeleteUserHandlerTest.php
new file mode 100644
index 00000000..af61b8d5
--- /dev/null
+++ b/tests/Unit/Bounce/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/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php
new file mode 100644
index 00000000..92b146fb
--- /dev/null
+++ b/tests/Unit/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php
@@ -0,0 +1,92 @@
+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,
+            translator: new Translator('en')
+        );
+    }
+
+    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/Bounce/Service/Handler/UnconfirmUserHandlerTest.php b/tests/Unit/Bounce/Service/Handler/UnconfirmUserHandlerTest.php
new file mode 100644
index 00000000..dcc0c0d8
--- /dev/null
+++ b/tests/Unit/Bounce/Service/Handler/UnconfirmUserHandlerTest.php
@@ -0,0 +1,79 @@
+subscriberRepository = $this->createMock(SubscriberRepository::class);
+        $this->historyManager = $this->createMock(SubscriberHistoryManager::class);
+        $this->handler = new UnconfirmUserHandler(
+            subscriberRepository: $this->subscriberRepository,
+            subscriberHistoryManager: $this->historyManager,
+            translator: new Translator('en')
+        );
+    }
+
+    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/Bounce/Service/LockServiceTest.php b/tests/Unit/Bounce/Service/LockServiceTest.php
new file mode 100644
index 00000000..8577ef5f
--- /dev/null
+++ b/tests/Unit/Bounce/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/Bounce/Service/MessageParserTest.php b/tests/Unit/Bounce/Service/MessageParserTest.php
new file mode 100644
index 00000000..35e60706
--- /dev/null
+++ b/tests/Unit/Bounce/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/Bounce/Service/WebklexImapClientFactoryTest.php b/tests/Unit/Bounce/Service/WebklexImapClientFactoryTest.php
new file mode 100644
index 00000000..25ad57dc
--- /dev/null
+++ b/tests/Unit/Bounce/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/Analytics/Service/LinkTrackServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php
index 613e2c1f..c768b056 100644
--- a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php
+++ b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php
@@ -4,8 +4,8 @@
 
 namespace PhpList\Core\Tests\Unit\Domain\Analytics\Service;
 
-use InvalidArgumentException;
-use PhpList\Core\Core\ConfigProvider;
+use PhpList\Core\Core\ParameterProvider;
+use PhpList\Core\Domain\Analytics\Exception\MissingMessageIdException;
 use PhpList\Core\Domain\Analytics\Model\LinkTrack;
 use PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository;
 use PhpList\Core\Domain\Analytics\Service\LinkTrackService;
@@ -22,13 +22,13 @@ class LinkTrackServiceTest extends TestCase
     protected function setUp(): void
     {
         $this->linkTrackRepository = $this->createMock(LinkTrackRepository::class);
-        $configProvider = $this->createMock(ConfigProvider::class);
+        $paramProvider = $this->createMock(ParameterProvider::class);
 
-        $configProvider->method('get')
+        $paramProvider->method('get')
             ->with('click_track', false)
             ->willReturn(true);
 
-        $this->subject = new LinkTrackService($this->linkTrackRepository, $configProvider);
+        $this->subject = new LinkTrackService($this->linkTrackRepository, $paramProvider);
     }
 
     public function testExtractAndSaveLinksWithNoLinks(): void
@@ -42,7 +42,7 @@ public function testExtractAndSaveLinksWithNoLinks(): void
         $message->method('getId')->willReturn($messageId);
         $message->method('getContent')->willReturn($messageContent);
 
-        $this->linkTrackRepository->expects(self::never())->method('save');
+        $this->linkTrackRepository->expects(self::never())->method('persist');
 
         $result = $this->subject->extractAndSaveLinks($message, $userId);
 
@@ -63,7 +63,7 @@ public function testExtractAndSaveLinksWithLinks(): void
         $message->method('getContent')->willReturn($messageContent);
 
         $this->linkTrackRepository->expects(self::exactly(2))
-            ->method('save')
+            ->method('persist')
             ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) {
                 self::assertSame($messageId, $linkTrack->getMessageId());
                 self::assertSame($userId, $linkTrack->getUserId());
@@ -92,7 +92,7 @@ public function testExtractAndSaveLinksWithFooter(): void
         $message->method('getContent')->willReturn($messageContent);
 
         $this->linkTrackRepository->expects(self::exactly(2))
-            ->method('save')
+            ->method('persist')
             ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) {
                 self::assertSame($messageId, $linkTrack->getMessageId());
                 self::assertSame($userId, $linkTrack->getUserId());
@@ -120,7 +120,7 @@ public function testExtractAndSaveLinksWithDuplicateLinks(): void
         $message->method('getContent')->willReturn($messageContent);
 
         $this->linkTrackRepository->expects(self::once())
-            ->method('save')
+            ->method('persist')
             ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) {
                 self::assertSame($messageId, $linkTrack->getMessageId());
                 self::assertSame($userId, $linkTrack->getUserId());
@@ -147,7 +147,7 @@ public function testExtractAndSaveLinksWithNullText(): void
         $message->method('getContent')->willReturn($messageContent);
 
         $this->linkTrackRepository->expects(self::once())
-            ->method('save')
+            ->method('persist')
             ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) {
                 self::assertSame($messageId, $linkTrack->getMessageId());
                 self::assertSame($userId, $linkTrack->getUserId());
@@ -172,7 +172,7 @@ public function testExtractAndSaveLinksWithMessageWithoutId(): void
         $message->method('getId')->willReturn(null);
         $message->method('getContent')->willReturn($messageContent);
 
-        $this->expectException(InvalidArgumentException::class);
+        $this->expectException(MissingMessageIdException::class);
         $this->expectExceptionMessage('Message must have an ID');
 
         $this->subject->extractAndSaveLinks($message, $userId);
@@ -185,12 +185,12 @@ public function testIsExtractAndSaveLinksApplicableWhenClickTrackIsTrue(): void
 
     public function testIsExtractAndSaveLinksApplicableWhenClickTrackIsFalse(): void
     {
-        $configProvider = $this->createMock(ConfigProvider::class);
-        $configProvider->method('get')
+        $paramProvider = $this->createMock(ParameterProvider::class);
+        $paramProvider->method('get')
             ->with('click_track', false)
             ->willReturn(false);
 
-        $subject = new LinkTrackService($this->linkTrackRepository, $configProvider);
+        $subject = new LinkTrackService($this->linkTrackRepository, $paramProvider);
 
         self::assertFalse($subject->isExtractAndSaveLinksApplicable());
     }
@@ -219,7 +219,7 @@ public function testExtractAndSaveLinksWithExistingLink(): void
             ->willReturn($existingLinkTrack);
 
         $this->linkTrackRepository->expects(self::never())
-            ->method('save');
+            ->method('persist');
 
         $result = $this->subject->extractAndSaveLinks($message, $userId);
 
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/Repository/CursorPaginationTraitTest.php b/tests/Unit/Domain/Common/Repository/CursorPaginationTraitTest.php
index 137da779..02cd5c40 100644
--- a/tests/Unit/Domain/Common/Repository/CursorPaginationTraitTest.php
+++ b/tests/Unit/Domain/Common/Repository/CursorPaginationTraitTest.php
@@ -4,12 +4,12 @@
 
 namespace PhpList\Core\Tests\Unit\Domain\Common\Repository;
 
+use BadMethodCallException;
 use Doctrine\ORM\Query;
 use Doctrine\ORM\QueryBuilder;
 use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
-use RuntimeException;
 
 final class CursorPaginationTraitTest extends TestCase
 {
@@ -59,8 +59,8 @@ public function testGetFilteredAfterIdWithFilterThrows(): void
     {
         $dummyFilter = $this->createMock(FilterRequestInterface::class);
 
-        $this->expectException(RuntimeException::class);
-        $this->expectExceptionMessage('Filter method not implemented');
+        $this->expectException(BadMethodCallException::class);
+        $this->expectExceptionMessage('getFilteredAfterId method not implemented');
 
         $this->repo->getFilteredAfterId(0, 10, $dummyFilter);
     }
diff --git a/tests/Unit/Domain/Common/SystemInfoCollectorTest.php b/tests/Unit/Domain/Common/SystemInfoCollectorTest.php
new file mode 100644
index 00000000..7bf964d7
--- /dev/null
+++ b/tests/Unit/Domain/Common/SystemInfoCollectorTest.php
@@ -0,0 +1,95 @@
+requestStack = $this->createMock(RequestStack::class);
+    }
+
+    public function testCollectReturnsSanitizedPairsWithDefaults(): void
+    {
+        $server = [
+            'HTTP_USER_AGENT' => 'Agent X "',
+            'HTTP_REFERER' => 'https://example.com/?q=',
+            'HTTP_X_FORWARDED_FOR' => '198.51.100.5, 203.0.113.7',
+            'REQUEST_URI' => '/path?x=1&y="z"',
+            'REMOTE_ADDR' => '203.0.113.10',
+        ];
+        $request = new Request(query: [], request: [], attributes: [], cookies: [], files: [], server: $server);
+
+        $this->requestStack->method('getCurrentRequest')->willReturn($request);
+
+        $collector = new SystemInfoCollector($this->requestStack);
+        $result = $collector->collect();
+
+        $expected = [
+            'HTTP_USER_AGENT' => 'Agent <b>X</b>"',
+            'HTTP_REFERER' => 'https://example.com/?q=<script>alert(1)</script>',
+            'REMOTE_ADDR' => '203.0.113.10',
+            'REQUEST_URI' => '/path?x=1&y="z"<w>',
+            'HTTP_X_FORWARDED_FOR' => '198.51.100.5, 203.0.113.7',
+        ];
+
+        $this->assertSame($expected, $result);
+    }
+
+    public function testCollectUsesConfiguredKeysAndSkipsMissing(): void
+    {
+        $server = [
+            'HTTP_USER_AGENT' => 'UA',
+            'REQUEST_URI' => '/only/uri',
+            'REMOTE_ADDR' => '198.51.100.10',
+        ];
+        $request = new Request(query: [], request: [], attributes: [], cookies: [], files: [], server: $server);
+        $this->requestStack->method('getCurrentRequest')->willReturn($request);
+
+        $collector = new SystemInfoCollector($this->requestStack, ['REQUEST_URI', 'UNKNOWN', 'REMOTE_ADDR']);
+        $result = $collector->collect();
+
+        $expected = [
+            'REQUEST_URI' => '/only/uri',
+            'REMOTE_ADDR' => '198.51.100.10',
+        ];
+
+        $this->assertSame($expected, $result);
+    }
+
+    public function testCollectAsStringFormatsLinesWithLeadingNewline(): void
+    {
+        $server = [
+            'HTTP_USER_AGENT' => 'UA',
+            'HTTP_REFERER' => 'https://ref.example',
+            'REMOTE_ADDR' => '192.0.2.5',
+            'REQUEST_URI' => '/abc',
+            'HTTP_X_FORWARDED_FOR' => '1.1.1.1',
+        ];
+        $request = new Request(query: [], request: [], attributes: [], cookies: [], files: [], server: $server);
+        $this->requestStack->method('getCurrentRequest')->willReturn($request);
+
+        $collector = new SystemInfoCollector($this->requestStack);
+        $string = $collector->collectAsString();
+
+        $expected = "\n" . implode("\n", [
+            'HTTP_USER_AGENT = UA',
+            'HTTP_REFERER = https://ref.example',
+            'REMOTE_ADDR = 192.0.2.5',
+            'REQUEST_URI = /abc',
+            'HTTP_X_FORWARDED_FOR = 1.1.1.1',
+        ]);
+
+        $this->assertSame($expected, $string);
+    }
+}
diff --git a/tests/Unit/Domain/Configuration/Service/LegacyUrlBuilderTest.php b/tests/Unit/Domain/Configuration/Service/LegacyUrlBuilderTest.php
new file mode 100644
index 00000000..9f5cfe96
--- /dev/null
+++ b/tests/Unit/Domain/Configuration/Service/LegacyUrlBuilderTest.php
@@ -0,0 +1,79 @@
+withUid($baseUrl, $uid);
+
+        $this->assertSame($expected, $actual);
+    }
+
+    public static function provideWithUidCases(): array
+    {
+        return [
+            'no query -> add uid' => [
+                'https://example.com/page',
+                'ABC123',
+                'https://example.com/page?uid=ABC123',
+            ],
+            'existing query -> append uid' => [
+                'https://example.com/page?foo=bar',
+                'ABC123',
+                'https://example.com/page?foo=bar&uid=ABC123',
+            ],
+            'existing uid -> override (uid replaced)' => [
+                'https://example.com/page?uid=OLD&x=1',
+                'ABC123',
+                'https://example.com/page?uid=ABC123&x=1',
+            ],
+            'port and fragment preserved' => [
+                'http://example.com:8080/path?x=1#frag',
+                'ABC123',
+                'http://example.com:8080/path?x=1&uid=ABC123#frag',
+            ],
+            'relative url -> defaults to https with empty host' => [
+                '/relative/path',
+                'ABC123',
+                // scheme defaults to https; empty host -> "https:///" + path
+                'https:///relative/path?uid=ABC123',
+            ],
+            'no query/fragment/port/host only' => [
+                'http://example.com',
+                'ZZZ',
+                'http://example.com?uid=ZZZ',
+            ],
+        ];
+    }
+
+    public function testQueryEncodingIsUrlEncoded(): void
+    {
+        $builder = new LegacyUrlBuilder();
+
+        $url = 'https://example.com/path?name=John+Doe&city=New+York';
+        $result = $builder->withUid($url, 'üñíčødé space');
+
+        // Ensure it is a valid URL and uid is url-encoded inside query
+        $parts = parse_url($result);
+        parse_str($parts['query'] ?? '', $query);
+
+        $this->assertSame('John Doe', $query['name']);
+        $this->assertSame('New York', $query['city']);
+        $this->assertSame('üñíčødé space', $query['uid']);
+    }
+}
diff --git a/tests/Unit/Domain/Configuration/Service/Manager/ConfigManagerTest.php b/tests/Unit/Domain/Configuration/Service/Manager/ConfigManagerTest.php
index 3b14122b..fbe5276e 100644
--- a/tests/Unit/Domain/Configuration/Service/Manager/ConfigManagerTest.php
+++ b/tests/Unit/Domain/Configuration/Service/Manager/ConfigManagerTest.php
@@ -4,6 +4,7 @@
 
 namespace PhpList\Core\Tests\Unit\Domain\Configuration\Service\Manager;
 
+use PhpList\Core\Domain\Configuration\Exception\ConfigNotEditableException;
 use PhpList\Core\Domain\Configuration\Model\Config;
 use PhpList\Core\Domain\Configuration\Repository\ConfigRepository;
 use PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager;
@@ -61,30 +62,13 @@ public function testGetAllReturnsAllConfigsFromRepository(): void
         $this->assertSame('value2', $result[1]->getValue());
     }
 
-    public function testUpdateSavesConfigToRepository(): void
-    {
-        $configRepository = $this->createMock(ConfigRepository::class);
-        $manager = new ConfigManager($configRepository);
-
-        $config = new Config();
-        $config->setKey('test_item');
-        $config->setValue('test_value');
-        $config->setEditable(true);
-
-        $configRepository->expects($this->once())
-            ->method('save')
-            ->with($config);
-
-        $manager->update($config, 'new_value');
-    }
-
     public function testCreateSavesNewConfigToRepository(): void
     {
         $configRepository = $this->createMock(ConfigRepository::class);
         $manager = new ConfigManager($configRepository);
 
         $configRepository->expects($this->once())
-            ->method('save')
+            ->method('persist')
             ->with($this->callback(function (Config $config) {
                 return $config->getKey() === 'test_key' &&
                     $config->getValue() === 'test_value' &&
@@ -119,10 +103,7 @@ public function testUpdateThrowsExceptionWhenConfigIsNotEditable(): void
         $config->setValue('test_value');
         $config->setEditable(false);
 
-        $configRepository->expects($this->never())
-            ->method('save');
-
-        $this->expectException(\PhpList\Core\Domain\Configuration\Exception\ConfigNotEditableException::class);
+        $this->expectException(ConfigNotEditableException::class);
         $this->expectExceptionMessage('Configuration item "test_item" is not editable.');
 
         $manager->update($config, 'new_value');
diff --git a/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php b/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php
new file mode 100644
index 00000000..818b8de0
--- /dev/null
+++ b/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php
@@ -0,0 +1,94 @@
+repository = $this->createMock(EventLogRepository::class);
+        $this->manager = new EventLogManager($this->repository);
+    }
+
+    public function testLogCreatesAndPersists(): void
+    {
+        $this->repository->expects($this->once())
+            ->method('save')
+            ->with($this->isInstanceOf(EventLog::class));
+
+        $log = $this->manager->log('dashboard', 'Viewed dashboard');
+
+        $this->assertInstanceOf(EventLog::class, $log);
+        $this->assertSame('dashboard', $log->getPage());
+        $this->assertSame('Viewed dashboard', $log->getEntry());
+        $this->assertNotNull($log->getEntered());
+        $this->assertInstanceOf(DateTimeImmutable::class, $log->getEntered());
+    }
+
+    public function testDelete(): void
+    {
+        $log = new EventLog();
+        $this->repository->expects($this->once())
+            ->method('remove')
+            ->with($log);
+
+        $this->manager->delete($log);
+    }
+
+    public function testGetWithFiltersDelegatesToRepository(): void
+    {
+        $expected = [new EventLog(), new EventLog()];
+
+        $this->repository->expects($this->once())
+            ->method('getFilteredAfterId')
+            ->with(
+                100,
+                25,
+                $this->callback(function (EventLogFilter $filter) {
+                    // Use getters to validate
+                    return method_exists($filter, 'getPage')
+                        && $filter->getPage() === 'settings'
+                        && $filter->getDateFrom() instanceof DateTimeImmutable
+                        && $filter->getDateTo() instanceof DateTimeImmutable
+                        && $filter->getDateFrom() <= $filter->getDateTo();
+                })
+            )
+            ->willReturn($expected);
+
+        $from = new DateTimeImmutable('-2 days');
+        $to = new DateTimeImmutable('now');
+        $result = $this->manager->get(lastId: 100, limit: 25, page: 'settings', dateFrom: $from, dateTo: $to);
+
+        $this->assertSame($expected, $result);
+    }
+
+    public function testGetWithoutFiltersDefaults(): void
+    {
+        $expected = [];
+
+        $this->repository->expects($this->once())
+            ->method('getFilteredAfterId')
+            ->with(
+                0,
+                50,
+                $this->anything()
+            )
+            ->willReturn($expected);
+
+        $result = $this->manager->get();
+        $this->assertSame($expected, $result);
+    }
+}
diff --git a/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php b/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php
new file mode 100644
index 00000000..e2a1d719
--- /dev/null
+++ b/tests/Unit/Domain/Configuration/Service/PlaceholderResolverTest.php
@@ -0,0 +1,92 @@
+assertNull($resolver->resolve(null));
+        $this->assertSame('', $resolver->resolve(''));
+    }
+
+    public function testUnregisteredTokensRemainUnchanged(): void
+    {
+        $resolver = new PlaceholderResolver();
+
+        $input = 'Hello [NAME], click [UNSUBSCRIBEURL] to opt out.';
+        $this->assertSame($input, $resolver->resolve($input));
+    }
+
+    public function testCaseInsensitiveTokenResolution(): void
+    {
+        $resolver = new PlaceholderResolver();
+        $resolver->register('unsubscribeurl', fn () => 'https://u.example/u/123');
+
+        $input  = 'Click [UnSubscribeUrl]';
+        $expect = 'Click https://u.example/u/123';
+
+        $this->assertSame($expect, $resolver->resolve($input));
+    }
+
+    public function testMultipleDifferentTokensAreResolved(): void
+    {
+        $resolver = new PlaceholderResolver();
+        $resolver->register('NAME', fn () => 'Ada');
+        $resolver->register('EMAIL', fn () => 'ada@example.com');
+
+        $input  = 'Hi [NAME] <[email]>';
+        $expect = 'Hi Ada ';
+
+        $this->assertSame($expect, $resolver->resolve($input));
+    }
+
+    public function testAdjacentAndRepeatedTokens(): void
+    {
+        $resolver = new PlaceholderResolver();
+
+        $count = 0;
+        $resolver->register('X', function () use (&$count) {
+            $count++;
+            return 'V';
+        });
+
+        $input = 'Start [x][X]-[x] End';
+        $expect = 'Start VV-V End';
+
+        $this->assertSame($expect, $resolver->resolve($input));
+        $this->assertSame(3, $count);
+    }
+
+    public function testDigitsAndUnderscoresInToken(): void
+    {
+        $resolver = new PlaceholderResolver();
+        $resolver->register('USER_2', fn () => 'Bob#2');
+
+        $input  = 'Hello [user_2]!';
+        $expect = 'Hello Bob#2!';
+
+        $this->assertSame($expect, $resolver->resolve($input));
+    }
+
+    public function testUnknownTokensArePreservedVerbatim(): void
+    {
+        $resolver = new PlaceholderResolver();
+        $resolver->register('KNOWN', fn () => 'K');
+
+        $input  = 'A[UNKNOWN]B[KNOWN]C';
+        $expect = 'A[UNKNOWN]BKC';
+
+        $this->assertSame($expect, $resolver->resolve($input));
+    }
+}
diff --git a/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php b/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php
new file mode 100644
index 00000000..12e36ed9
--- /dev/null
+++ b/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php
@@ -0,0 +1,279 @@
+repo = $this->createMock(ConfigRepository::class);
+        $this->cache = $this->createMock(CacheInterface::class);
+        $this->defaults = $this->createMock(DefaultConfigProvider::class);
+
+        $this->provider = new ConfigProvider(
+            configRepository: $this->repo,
+            cache: $this->cache,
+            defaultConfigs: $this->defaults,
+            ttlSeconds: 300
+        );
+    }
+
+    /**
+     * Utility: pick a non-boolean enum case (i.e., anything except MaintenanceMode).
+     */
+    private function pickNonBooleanCase(): ConfigOption
+    {
+        foreach (ConfigOption::cases() as $case) {
+            if ($case !== ConfigOption::MaintenanceMode) {
+                return $case;
+            }
+        }
+        $this->markTestSkipped('No non-boolean ConfigOption cases available to test.');
+    }
+
+    /**
+     * Utility: pick a namespaced case "parent:child" where parent exists as its own case.
+     */
+    private function pickNamespacedCasePair(): array
+    {
+        $byValue = [];
+        foreach (ConfigOption::cases() as $c) {
+            $byValue[$c->value] = $c;
+        }
+
+        foreach (ConfigOption::cases() as $c) {
+            if (!str_contains($c->value, ':')) {
+                continue;
+            }
+            [$parent] = explode(':', $c->value, 2);
+            if (isset($byValue[$parent])) {
+                return [$c, $byValue[$parent]];
+            }
+        }
+
+        $this->markTestSkipped('No namespaced ConfigOption (parent:child) pair found.');
+    }
+
+    public function testIsEnabledRejectsNonBooleanKeys(): void
+    {
+        $nonBoolean = $this->pickNonBooleanCase();
+
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('Invalid boolean value key');
+
+        $this->provider->isEnabled($nonBoolean);
+    }
+
+    public function testIsEnabledUsesRepositoryValueWhenPresent(): void
+    {
+        $key = ConfigOption::MaintenanceMode;
+
+        $configEntity = $this->createMock(Config::class);
+        $configEntity->method('getValue')->willReturn('1');
+
+        $this->repo
+            ->expects($this->once())
+            ->method('findOneBy')
+            ->with(['item' => $key->value])
+            ->willReturn($configEntity);
+
+        // Defaults should not be consulted if repo has value
+        $this->defaults->expects($this->never())->method('has');
+        $this->defaults->expects($this->never())->method('get');
+
+        $enabled = $this->provider->isEnabled($key);
+
+        $this->assertTrue($enabled, 'When repo has value "1", isEnabled() should return true.');
+    }
+
+    public function testIsEnabledFallsBackToDefaultsWhenRepoMissing(): void
+    {
+        $key = ConfigOption::MaintenanceMode;
+
+        $this->repo
+            ->expects($this->once())
+            ->method('findOneBy')
+            ->with(['item' => $key->value])
+            ->willReturn(null);
+
+        $this->defaults
+            ->expects($this->once())
+            ->method('has')
+            ->with($key->value)
+            ->willReturn(true);
+
+        $this->defaults
+            ->expects($this->once())
+            ->method('get')
+            ->with($key->value)
+            ->willReturn(['value' => '1']);
+
+        $this->assertTrue($this->provider->isEnabled($key));
+    }
+
+    public function testGetValueRejectsBooleanKeys(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('Key is a boolean value, use isEnabled instead');
+
+        $this->provider->getValue(ConfigOption::MaintenanceMode);
+    }
+
+    public function testGetValueReturnsFromCacheWhenPresent(): void
+    {
+        $key = $this->pickNonBooleanCase();
+        $cacheKey = 'cfg:' . $key->value;
+
+        $this->cache
+            ->expects($this->once())
+            ->method('get')
+            ->with($cacheKey)
+            ->willReturn('CACHED');
+
+        $this->repo->expects($this->never())->method('findValueByItem');
+        $this->defaults->expects($this->never())->method('has');
+        $this->defaults->expects($this->never())->method('get');
+
+        $this->assertSame('CACHED', $this->provider->getValue($key));
+    }
+
+    public function testGetValueLoadsFromRepoAndCachesWhenCacheMiss(): void
+    {
+        $key = $this->pickNonBooleanCase();
+        $cacheKey = 'cfg:' . $key->value;
+
+        $this->cache
+            ->expects($this->once())
+            ->method('get')
+            ->with($cacheKey)
+            ->willReturn(null);
+
+        $this->repo
+            ->expects($this->once())
+            ->method('findValueByItem')
+            ->with($key->value)
+            ->willReturn('DBVAL');
+
+        $this->cache
+            ->expects($this->once())
+            ->method('set')
+            ->with($cacheKey, 'DBVAL', 300);
+
+        $this->defaults->expects($this->never())->method('has');
+        $this->defaults->expects($this->never())->method('get');
+
+        $this->assertSame('DBVAL', $this->provider->getValue($key));
+    }
+
+    public function testGetValueFallsBackToDefaultConfigsWhenNoCacheAndNoRepo(): void
+    {
+        $key = $this->pickNonBooleanCase();
+        $cacheKey = 'cfg:' . $key->value;
+
+        $this->cache
+            ->expects($this->once())
+            ->method('get')
+            ->with($cacheKey)
+            ->willReturn(null);
+
+        $this->repo
+            ->expects($this->once())
+            ->method('findValueByItem')
+            ->with($key->value)
+            ->willReturn(null);
+
+        $this->cache
+            ->expects($this->once())
+            ->method('set')
+            ->with($cacheKey, null, 300);
+
+        $this->defaults
+            ->expects($this->once())
+            ->method('has')
+            ->with($key->value)
+            ->willReturn(true);
+
+        $this->defaults
+            ->expects($this->once())
+            ->method('get')
+            ->with($key->value)
+            ->willReturn(['value' => 'DEF']);
+
+        $this->assertSame('DEF', $this->provider->getValue($key));
+    }
+
+    public function testGetValueReturnsNullWhenNoCacheNoRepoNoDefault(): void
+    {
+        $key = $this->pickNonBooleanCase();
+        $cacheKey = 'cfg:' . $key->value;
+
+        $this->cache->expects($this->once())->method('get')->with($cacheKey)->willReturn(null);
+        $this->repo->expects($this->once())->method('findValueByItem')->with($key->value)->willReturn(null);
+        $this->cache->expects($this->once())->method('set')->with($cacheKey, null, 300);
+
+        $this->defaults->expects($this->once())->method('has')->with($key->value)->willReturn(false);
+        $this->defaults->expects($this->never())->method('get');
+
+        $this->assertNull($this->provider->getValue($key));
+    }
+
+    public function testGetValueWithNamespacePrefersFullValue(): void
+    {
+        $key = $this->pickNonBooleanCase();
+
+        // Force getValue($key) to return a non-empty string
+        $this->cache->method('get')->willReturn('FULL');
+        $this->repo->expects($this->never())->method('findValueByItem');
+
+        $this->assertSame('FULL', $this->provider->getValueWithNamespace($key));
+    }
+
+    public function testGetValueWithNamespaceFallsBackToParentWhenFullEmpty(): void
+    {
+        [$child, $parent] = $this->pickNamespacedCasePair();
+
+        // Simulate: child is empty (null or ''), parent has value "PARENTVAL"
+        $this->cache
+            ->method('get')
+            ->willReturnMap([
+                ['cfg:' . $child->value, null],
+                ['cfg:' . $parent->value, 'PARENTVAL'],
+            ]);
+
+        // child -> repo null; parent -> not consulted because cache returns value
+        $this->repo
+            ->method('findValueByItem')
+            ->willReturnMap([
+                [$child->value, null],
+            ]);
+
+        // child miss is cached as null, parent value is not rewritten here (already cached)
+        $this->cache
+            ->expects($this->atLeastOnce())
+            ->method('set');
+
+        $this->defaults->method('has')->willReturn(false);
+
+        $this->assertSame('PARENTVAL', $this->provider->getValueWithNamespace($child));
+    }
+}
diff --git a/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php b/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php
new file mode 100644
index 00000000..ae5b96cb
--- /dev/null
+++ b/tests/Unit/Domain/Configuration/Service/Provider/DefaultConfigProviderTest.php
@@ -0,0 +1,127 @@
+translator = $this->createMock(TranslatorInterface::class);
+        $this->provider = new DefaultConfigProvider($this->translator);
+    }
+
+    public function testHasReturnsTrueForKnownKey(): void
+    {
+        $this->assertTrue($this->provider->has('admin_address'));
+    }
+
+    public function testGetReturnsArrayShapeForKnownKey(): void
+    {
+        $item = $this->provider->get('admin_address');
+
+        $this->assertIsArray($item);
+        $this->assertArrayHasKey('value', $item);
+        $this->assertArrayHasKey('description', $item);
+        $this->assertArrayHasKey('type', $item);
+        $this->assertArrayHasKey('category', $item);
+
+        // basic sanity check
+        $this->assertSame('email', $item['type']);
+        $this->assertSame('general', $item['category']);
+        $this->assertStringContainsString('[DOMAIN]', (string) $item['value']);
+    }
+
+    public function testGetReturnsProvidedDefaultWhenUnknownKey(): void
+    {
+        $fallback = ['value' => 'X', 'type' => 'text'];
+        $this->assertSame($fallback, $this->provider->get('does_not_exist', $fallback));
+    }
+
+    public function testRemoteProcessingSecretIsRandomHexOfExpectedLength(): void
+    {
+        $item = $this->provider->get('remote_processing_secret');
+        $this->assertIsArray($item);
+        $this->assertArrayHasKey('value', $item);
+
+        $val = (string) $item['value'];
+        // bin2hex(random_bytes(10)) => 20 hex chars
+        $this->assertMatchesRegularExpression('/^[0-9a-f]{20}$/i', $val);
+    }
+
+    public function testSubscribeUrlDefaultsToHttpAndApiV2Path(): void
+    {
+        $item = $this->provider->get('subscribeurl');
+        $this->assertIsArray($item);
+        $url = (string) $item['value'];
+
+        $this->assertStringStartsWith('http://', $url);
+        $this->assertStringContainsString('[WEBSITE]', $url);
+        $this->assertStringContainsString('/api/v2/?p=subscribe', $url);
+    }
+
+    public function testUnsubscribeUrlDefaults(): void
+    {
+        $item = $this->provider->get('unsubscribeurl');
+        $url = (string) $item['value'];
+
+        $this->assertStringStartsWith('http://', $url);
+        $this->assertStringContainsString('/api/v2/?p=unsubscribe', $url);
+    }
+
+    public function testTranslatorIsUsedOnlyOnFirstInit(): void
+    {
+        $this->translator
+            ->expects($this->atLeastOnce())
+            ->method('trans')
+            ->willReturnArgument(0);
+        $this->provider->get('admin_address');
+
+        // Subsequent calls should not trigger init again
+        $translator = $this->createMock(TranslatorInterface::class);
+        $translator
+            ->expects($this->never())
+            ->method('trans');
+
+        $reflection = new ReflectionClass($this->provider);
+        $prop = $reflection->getProperty('translator');
+
+        $prop->setValue($this->provider, $translator);
+        $this->provider->get('unsubscribeurl');
+        $this->provider->has('pageheader');
+    }
+
+    public function testKnownKeysHaveReasonableTypes(): void
+    {
+        $keys = [
+            'admin_address'           => 'email',
+            'organisation_name'       => 'text',
+            'organisation_logo'       => 'image',
+            'date_format'             => 'text',
+            'rc_notification'         => 'boolean',
+            'notify_admin_login'      => 'boolean',
+            'message_from_address'    => 'email',
+            'message_from_name'       => 'text',
+            'message_replyto_address' => 'email',
+        ];
+
+        foreach ($keys as $key => $type) {
+            $item = $this->provider->get($key);
+            $this->assertIsArray($item, 'Item should be an array. Key: ' . $key);
+            $this->assertSame($type, $item['type'] ?? null, $key .': should have type ' . $type);
+        }
+    }
+}
diff --git a/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php b/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php
new file mode 100644
index 00000000..0c7f7dfd
--- /dev/null
+++ b/tests/Unit/Domain/Configuration/Service/UserPersonalizerTest.php
@@ -0,0 +1,218 @@
+config = $this->createMock(ConfigProvider::class);
+        $this->urlBuilder = $this->createMock(LegacyUrlBuilder::class);
+        $this->subRepo = $this->createMock(SubscriberRepository::class);
+        $this->attrRepo = $this->createMock(SubscriberAttributeValueRepository::class);
+        $this->attrResolver = $this->createMock(AttributeValueResolver::class);
+
+        $this->personalizer = new UserPersonalizer(
+            $this->config,
+            $this->urlBuilder,
+            $this->subRepo,
+            $this->attrRepo,
+            $this->attrResolver
+        );
+    }
+
+    public function testReturnsOriginalWhenSubscriberNotFound(): void
+    {
+        $this->subRepo
+            ->expects($this->once())
+            ->method('findOneByEmail')
+            ->with('nobody@example.com')
+            ->willReturn(null);
+
+        $result = $this->personalizer->personalize('Hello [EMAIL]', 'nobody@example.com');
+
+        $this->assertSame('Hello [EMAIL]', $result);
+    }
+
+    public function testBuiltInPlaceholdersAreResolved(): void
+    {
+        $email = 'ada@example.com';
+        $uid = 'U123';
+
+        $subscriber = $this->createMock(Subscriber::class);
+        $subscriber->method('getEmail')->willReturn($email);
+        $subscriber->method('getUniqueId')->willReturn($uid);
+
+        $this->subRepo
+            ->expects($this->once())
+            ->method('findOneByEmail')
+            ->with($email)
+            ->willReturn($subscriber);
+
+        // Config values for URLs + domain/website + subscribe url
+        $this->config->method('getValue')->willReturnCallback(function ($opt) {
+            return match ($opt) {
+                ConfigOption::UnsubscribeUrl => 'https://u.example/unsub',
+                ConfigOption::ConfirmationUrl => 'https://u.example/confirm',
+                ConfigOption::PreferencesUrl => 'https://u.example/prefs',
+                ConfigOption::SubscribeUrl => 'https://u.example/subscribe',
+                ConfigOption::Domain => 'example.org',
+                ConfigOption::Website => 'site.example.org',
+                default => null,
+            };
+        });
+
+        // LegacyUrlBuilder glue behavior
+        $this->urlBuilder
+            ->method('withUid')
+            ->willReturnCallback(fn(string $base, string $u) => $base . '?uid=' . $u);
+
+        $this->attrRepo
+            ->expects($this->once())
+            ->method('getForSubscriber')
+            ->with($subscriber)
+            ->willReturn([]);
+
+        $input = 'Email: [EMAIL]
+            Unsub: [UNSUBSCRIBEURL]
+            Conf: [confirmationurl]
+            Prefs: [PREFERENCESURL]
+            Sub: [SUBSCRIBEURL]
+            Domain: [DOMAIN]
+            Website: [WEBSITE]';
+
+
+        $result = $this->personalizer->personalize($input, $email);
+
+        $this->assertStringContainsString('Email: ada@example.com', $result);
+        // trailing space is expected after URL placeholders
+        $this->assertStringContainsString('Unsub: https://u.example/unsub?uid=U123 ', $result);
+        $this->assertStringContainsString('Conf: https://u.example/confirm?uid=U123 ', $result);
+        $this->assertStringContainsString('Prefs: https://u.example/prefs?uid=U123 ', $result);
+        $this->assertStringContainsString('Sub: https://u.example/subscribe ', $result);
+        $this->assertStringContainsString('Domain: example.org', $result);
+        $this->assertStringContainsString('Website: site.example.org', $result);
+    }
+
+    public function testDynamicUserAttributesAreResolvedCaseInsensitive(): void
+    {
+        $email = 'bob@example.com';
+        $uid = 'U999';
+
+        $subscriber = $this->createMock(Subscriber::class);
+        $subscriber->method('getEmail')->willReturn($email);
+        $subscriber->method('getUniqueId')->willReturn($uid);
+
+        $this->subRepo
+            ->expects($this->once())
+            ->method('findOneByEmail')
+            ->with($email)
+            ->willReturn($subscriber);
+
+        // Only needed so registration for URL placeholders doesn't blow up; values don't matter in this test
+        $this->config->method('getValue')->willReturnMap([
+            [ConfigOption::UnsubscribeUrl, ''],
+            [ConfigOption::ConfirmationUrl, ''],
+            [ConfigOption::PreferencesUrl, ''],
+            [ConfigOption::SubscribeUrl, ''],
+            [ConfigOption::Domain, 'example.org'],
+            [ConfigOption::Website, 'site.example.org'],
+        ]);
+
+        $this->urlBuilder->method('withUid')->willReturnCallback(fn(string $b, string $u) => $b . '?uid=' . $u);
+
+        // Build a fake attribute value entity with definition NAME => "Full Name"
+        $attrDefinition = $this->createMock(SubscriberAttributeDefinition::class);
+        $attrDefinition->method('getName')->willReturn('Full_Name2');
+        $attrValue = $this->createMock(SubscriberAttributeValue::class);
+        $attrValue->method('getAttributeDefinition')->willReturn($attrDefinition);
+
+        $this->attrRepo
+            ->expects($this->once())
+            ->method('getForSubscriber')
+            ->with($subscriber)
+            ->willReturn([$attrValue]);
+
+        // When resolver is called with our attr value, return computed string
+        $this->attrResolver
+            ->expects($this->once())
+            ->method('resolve')
+            ->with($attrValue)
+            ->willReturn('Bob #2');
+
+        $input = 'Hello [full_name2], your email is [email].';
+        $result = $this->personalizer->personalize($input, $email);
+
+        $this->assertSame('Hello Bob #2, your email is bob@example.com.', $result);
+    }
+
+    public function testMultipleOccurrencesAndAdjacency(): void
+    {
+        $email = 'eve@example.com';
+        $uid = 'UID42';
+
+        $subscriber = $this->createMock(Subscriber::class);
+        $subscriber->method('getEmail')->willReturn($email);
+        $subscriber->method('getUniqueId')->willReturn($uid);
+
+        $this->subRepo->method('findOneByEmail')->willReturn($subscriber);
+
+        $this->config->method('getValue')->willReturnMap([
+            [ConfigOption::UnsubscribeUrl, 'https://x/unsub'],
+            [ConfigOption::ConfirmationUrl, 'https://x/conf'],
+            [ConfigOption::PreferencesUrl, 'https://x/prefs'],
+            [ConfigOption::SubscribeUrl, 'https://x/sub'],
+            [ConfigOption::Domain, 'x.tld'],
+            [ConfigOption::Website, 'w.x.tld'],
+        ]);
+
+        $this->urlBuilder->method('withUid')->willReturnCallback(fn(string $b, string $u) => $b . '?uid=' . $u);
+
+        // Two attributes: FOO & BAR
+        $defFoo = $this->createMock(SubscriberAttributeDefinition::class);
+        $defFoo->method('getName')->willReturn('FOO');
+        $valFoo = $this->createMock(SubscriberAttributeValue::class);
+        $valFoo->method('getAttributeDefinition')->willReturn($defFoo);
+
+        $defBar = $this->createMock(SubscriberAttributeDefinition::class);
+        $defBar->method('getName')->willReturn('bar');
+        $valBar = $this->createMock(SubscriberAttributeValue::class);
+        $valBar->method('getAttributeDefinition')->willReturn($defBar);
+
+        $this->attrRepo->method('getForSubscriber')->willReturn([$valFoo, $valBar]);
+
+        $this->attrResolver
+            ->method('resolve')
+            ->willReturnMap([
+                [$valFoo, 'FVAL'],
+                [$valBar, 'BVAL'],
+            ]);
+
+        $input = '[foo][BAR]-[email]-[UNSUBSCRIBEURL]';
+        $out = $this->personalizer->personalize($input, $email);
+
+        $this->assertSame('FVALBVAL-eve@example.com-https://x/unsub?uid=UID42 ', $out);
+    }
+}
diff --git a/tests/Unit/Domain/Identity/Command/CleanUpOldSessionTokensTest.php b/tests/Unit/Domain/Identity/Command/CleanUpOldSessionTokensTest.php
index a358c25c..51a440d5 100644
--- a/tests/Unit/Domain/Identity/Command/CleanUpOldSessionTokensTest.php
+++ b/tests/Unit/Domain/Identity/Command/CleanUpOldSessionTokensTest.php
@@ -4,67 +4,69 @@
 
 namespace PhpList\Core\Tests\Unit\Domain\Identity\Command;
 
-use Exception;
+use Doctrine\ORM\EntityManagerInterface;
 use PhpList\Core\Domain\Identity\Command\CleanUpOldSessionTokens;
 use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository;
-use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
-use Symfony\Component\Console\Application;
+use Symfony\Component\Console\Command\Command;
 use Symfony\Component\Console\Tester\CommandTester;
 
-class CleanUpOldSessionTokensTest extends TestCase
+final class CleanUpOldSessionTokensTest extends TestCase
 {
-    private AdministratorTokenRepository&MockObject $tokenRepository;
-    private CommandTester $commandTester;
-
-    protected function setUp(): void
+    public function testItRemovesAllExpiredTokensAndOutputsSuccess(): void
     {
-        $this->tokenRepository = $this->createMock(AdministratorTokenRepository::class);
+        $repo = $this->createMock(AdministratorTokenRepository::class);
+        $em = $this->createMock(EntityManagerInterface::class);
 
-        $command = new CleanUpOldSessionTokens($this->tokenRepository);
+        $token1 = (object) ['id' => 1];
+        $token2 = (object) ['id' => 2];
+        $expired = [$token1, $token2];
 
-        $application = new Application();
-        $application->add($command);
+        $repo->expects($this->once())
+            ->method('getExpired')
+            ->willReturn($expired);
 
-        $this->commandTester = new CommandTester($command);
-    }
+        $removed = [];
+        $em->expects($this->exactly(\count($expired)))
+            ->method('remove')
+            ->willReturnCallback(function (object $o) use (&$removed) {
+                $removed[] = $o;
+            });
 
-    public function testExecuteSuccessfully(): void
-    {
-        $this->tokenRepository->expects($this->once())
-            ->method('removeExpired')
-            ->willReturn(5);
+        $em->expects($this->once())
+            ->method('flush');
 
-        $this->commandTester->execute([]);
+        $command = new CleanUpOldSessionTokens($repo, $em);
+        $tester  = new CommandTester($command);
 
-        $output = $this->commandTester->getDisplay();
-        $this->assertStringContainsString('Successfully removed 5 expired session token(s)', $output);
-        $this->assertEquals(0, $this->commandTester->getStatusCode());
-    }
+        $exitCode = $tester->execute([]);
 
-    public function testExecuteWithNoExpiredTokens(): void
-    {
-        $this->tokenRepository->expects($this->once())
-            ->method('removeExpired')
-            ->willReturn(0);
+        self::assertSame(Command::SUCCESS, $exitCode);
 
-        $this->commandTester->execute([]);
+        $display = $tester->getDisplay();
+        self::assertStringContainsString('Successfully removed 2 expired session token(s).', $display);
 
-        $output = $this->commandTester->getDisplay();
-        $this->assertStringContainsString('Successfully removed 0 expired session token(s)', $output);
-        $this->assertEquals(0, $this->commandTester->getStatusCode());
+        self::assertEqualsCanonicalizing($expired, $removed);
     }
 
-    public function testExecuteWithException(): void
+    public function testItHandlesExceptionsAndOutputsFailure(): void
     {
-        $this->tokenRepository->expects($this->once())
-            ->method('removeExpired')
-            ->willThrowException(new Exception('Test exception'));
+        $repo = $this->createMock(AdministratorTokenRepository::class);
+        $em = $this->createMock(EntityManagerInterface::class);
+
+        $repo->expects($this->once())
+            ->method('getExpired')
+            ->willThrowException(new \RuntimeException('boom'));
+
+        $em->expects($this->never())->method('remove');
+        $em->expects($this->never())->method('flush');
+
+        $command = new CleanUpOldSessionTokens($repo, $em);
+        $tester  = new CommandTester($command);
 
-        $this->commandTester->execute([]);
+        $exitCode = $tester->execute([]);
 
-        $output = $this->commandTester->getDisplay();
-        $this->assertStringContainsString('Error removing expired session tokens: Test exception', $output);
-        $this->assertEquals(1, $this->commandTester->getStatusCode());
+        self::assertSame(Command::FAILURE, $exitCode);
+        self::assertStringContainsString('Error removing expired session tokens: boom', $tester->getDisplay());
     }
 }
diff --git a/tests/Unit/Domain/Identity/Model/AdministratorTest.php b/tests/Unit/Domain/Identity/Model/AdministratorTest.php
index f721f58b..cf90e0c9 100644
--- a/tests/Unit/Domain/Identity/Model/AdministratorTest.php
+++ b/tests/Unit/Domain/Identity/Model/AdministratorTest.php
@@ -27,7 +27,7 @@ class AdministratorTest extends TestCase
 
     protected function setUp(): void
     {
-        $this->subject = new Administrator();
+        $this->subject = (new Administrator())->setLoginName('');
     }
 
     public function testIsDomainModel(): void
@@ -69,21 +69,21 @@ public function testSetEmailAddressSetsEmailAddress(): void
         self::assertSame($value, $this->subject->getEmail());
     }
 
-    public function testGetUpdatedAtInitiallyReturnsNull(): void
+    public function testGetUpdatedAtInitiallyReturnsNotNull(): void
     {
-        self::assertNull($this->subject->getUpdatedAt());
+        self::assertNotNull($this->subject->getUpdatedAt());
     }
 
     public function testUpdateModificationDateSetsModificationDateToNow(): void
     {
-        $this->subject->updateUpdatedAt();
+        $this->subject->setEmail('update@email.com');
 
         self::assertSimilarDates(new DateTime(), $this->subject->getUpdatedAt());
     }
 
-    public function testGetPasswordHashInitiallyReturnsEmptyString(): void
+    public function testGetPasswordHashInitiallyReturnsNull(): void
     {
-        self::assertSame('', $this->subject->getPasswordHash());
+        self::assertNull($this->subject->getPasswordHash());
     }
 
     public function testSetPasswordHashSetsPasswordHash(): void
diff --git a/tests/Unit/Domain/Identity/Model/AdministratorTokenTest.php b/tests/Unit/Domain/Identity/Model/AdministratorTokenTest.php
index da824845..84a98df7 100644
--- a/tests/Unit/Domain/Identity/Model/AdministratorTokenTest.php
+++ b/tests/Unit/Domain/Identity/Model/AdministratorTokenTest.php
@@ -26,7 +26,7 @@ class AdministratorTokenTest extends TestCase
 
     protected function setUp(): void
     {
-        $this->subject = new AdministratorToken();
+        $this->subject = new AdministratorToken((new Administrator())->setLoginName('admin'));
     }
 
     public function testIsDomainModel(): void
@@ -54,11 +54,6 @@ public function testUpdateCreationDateSetsCreationDateToNow(): void
         self::assertSimilarDates(new DateTime(), $this->subject->getCreatedAt());
     }
 
-    public function testGetKeyInitiallyReturnsEmptyString(): void
-    {
-        self::assertSame('', $this->subject->getKey());
-    }
-
     public function testSetKeySetsKey(): void
     {
         $value = 'Club-Mate';
@@ -74,8 +69,6 @@ public function testGetExpiryInitiallyReturnsDateTime(): void
 
     public function testGenerateExpirySetsExpiryOneHourInTheFuture(): void
     {
-        $this->subject->generateExpiry();
-
         self::assertSimilarDates(new DateTime('+1 hour'), $this->subject->getExpiry());
     }
 
@@ -97,16 +90,8 @@ public function testGenerateKeyCreatesDifferentKeysForEachCall(): void
         self::assertNotSame($firstKey, $secondKey);
     }
 
-    public function testGetAdministratorInitiallyReturnsNull(): void
+    public function testGetAdministratorReturnsConstructorProvidedAdministrator(): void
     {
-        self::assertNull($this->subject->getAdministrator());
-    }
-
-    public function testSetAdministratorSetsAdministrator(): void
-    {
-        $model = new Administrator();
-        $this->subject->setAdministrator($model);
-
-        self::assertSame($model, $this->subject->getAdministrator());
+        self::assertNotNull($this->subject->getAdministrator());
     }
 }
diff --git a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php
index e42aba74..3c3356d7 100644
--- a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php
+++ b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php
@@ -12,17 +12,24 @@
 use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
 class AdminAttributeDefinitionManagerTest extends TestCase
 {
     private AdminAttributeDefinitionRepository&MockObject $repository;
     private AdminAttributeDefinitionManager $subject;
+    private TranslatorInterface&MockObject $translator;
 
     protected function setUp(): void
     {
         $this->repository = $this->createMock(AdminAttributeDefinitionRepository::class);
         $attributeTypeValidator = $this->createMock(AttributeTypeValidator::class);
-        $this->subject = new AdminAttributeDefinitionManager($this->repository, $attributeTypeValidator);
+        $this->translator = $this->createMock(TranslatorInterface::class);
+        $this->subject = new AdminAttributeDefinitionManager(
+            definitionRepository: $this->repository,
+            attributeTypeValidator: $attributeTypeValidator,
+            translator: $this->translator,
+        );
     }
 
     public function testCreateCreatesNewAttributeDefinition(): void
@@ -42,7 +49,7 @@ public function testCreateCreatesNewAttributeDefinition(): void
             ->willReturn(null);
 
         $this->repository->expects($this->once())
-            ->method('save')
+            ->method('persist')
             ->with($this->callback(function (AdminAttributeDefinition $definition) use ($dto) {
                 return $definition->getName() === $dto->name
                     && $definition->getType() === $dto->type
@@ -76,6 +83,11 @@ public function testCreateThrowsExceptionIfAttributeAlreadyExists(): void
             ->with('test-attribute')
             ->willReturn($existingAttribute);
 
+        $this->translator->expects($this->once())
+            ->method('trans')
+            ->with('Attribute definition already exists.')
+            ->willReturn('Attribute definition already exists.');
+
         $this->expectException(AttributeDefinitionCreationException::class);
         $this->expectExceptionMessage('Attribute definition already exists');
 
@@ -131,10 +143,6 @@ public function testUpdateUpdatesAttributeDefinition(): void
             ->with('new_table')
             ->willReturnSelf();
 
-        $this->repository->expects($this->once())
-            ->method('save')
-            ->with($attributeDefinition);
-
         $result = $this->subject->update($attributeDefinition, $dto);
 
         $this->assertSame($attributeDefinition, $result);
diff --git a/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php b/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php
index 9c5cac8f..d0ea805c 100644
--- a/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php
+++ b/tests/Unit/Domain/Identity/Service/AdminAttributeManagerTest.php
@@ -41,7 +41,7 @@ public function testCreateOrUpdateCreatesNewAttributeIfNotExists(): void
             ->willReturn(null);
 
         $this->repository->expects($this->once())
-            ->method('save')
+            ->method('persist')
             ->with($this->callback(function (AdminAttributeValue $attribute) use ($value) {
                 return $attribute->getAdministrator()->getId() === 1
                     && $attribute->getAttributeDefinition()->getId() === 2
@@ -72,7 +72,7 @@ public function testCreateOrUpdateUpdatesExistingAttribute(): void
             ->with(1, 2)
             ->willReturn($existingAttribute);
 
-        $this->repository->expects($this->once())
+        $this->repository->expects($this->never())
             ->method('save')
             ->with($this->callback(function (AdminAttributeValue $attribute) use ($newValue) {
                 return $attribute->getValue() === $newValue;
@@ -98,8 +98,7 @@ public function testCreateOrUpdateUsesDefaultValueIfValueIsNull(): void
             ->method('findOneByAdminIdAndAttributeId')
             ->willReturn(null);
 
-        $this->repository->expects($this->once())
-            ->method('save');
+        // will not throw AdminAttributeCreationException
 
         $result = $this->subject->createOrUpdate($admin, $definition);
 
diff --git a/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php b/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php
index 11c3f378..0b91fb8e 100644
--- a/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php
+++ b/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php
@@ -32,7 +32,6 @@ public function testCreateAdministrator(): void
             ->willReturn('hashed_pass');
 
         $entityManager->expects($this->once())->method('persist');
-        $entityManager->expects($this->once())->method('flush');
 
         $manager = new AdministratorManager($entityManager, $hashGenerator);
         $admin = $manager->createAdministrator($dto);
@@ -67,8 +66,6 @@ public function testUpdateAdministrator(): void
             ->with('newpass')
             ->willReturn('new_hash');
 
-        $entityManager->expects($this->once())->method('flush');
-
         $manager = new AdministratorManager($entityManager, $hashGenerator);
         $manager->updateAdministrator($admin, $dto);
 
@@ -86,7 +83,6 @@ public function testDeleteAdministrator(): void
         $admin = $this->createMock(Administrator::class);
 
         $entityManager->expects($this->once())->method('remove')->with($admin);
-        $entityManager->expects($this->once())->method('flush');
 
         $manager = new AdministratorManager($entityManager, $hashGenerator);
         $manager->deleteAdministrator($admin);
diff --git a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php
index 85e02f81..b9b53039 100644
--- a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php
+++ b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php
@@ -17,6 +17,7 @@
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
 class PasswordManagerTest extends TestCase
 {
@@ -36,7 +37,8 @@ protected function setUp(): void
             passwordRequestRepository: $this->passwordRequestRepository,
             administratorRepository: $this->administratorRepository,
             hashGenerator: $this->hashGenerator,
-            messageBus: $this->messageBus
+            messageBus: $this->messageBus,
+            translator: $this->createMock(TranslatorInterface::class)
         );
     }
 
@@ -81,7 +83,7 @@ public function testGeneratePasswordResetTokenCleansUpExistingRequests(): void
             ->with($existingRequest);
 
         $this->passwordRequestRepository->expects($this->once())
-            ->method('save')
+            ->method('persist')
             ->with($this->isInstanceOf(AdminPasswordRequest::class));
 
         $this->messageBus->expects($this->once())
@@ -185,7 +187,7 @@ public function testUpdatePasswordWithTokenUpdatesPasswordAndRemovesToken(): voi
             ->willReturn($newPasswordHash);
 
         $this->administratorRepository->expects($this->once())
-            ->method('save')
+            ->method('persist')
             ->with($administrator);
 
         $this->passwordRequestRepository->expects($this->once())
diff --git a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php
index 44072452..da620f12 100644
--- a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php
+++ b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php
@@ -4,16 +4,18 @@
 
 namespace PhpList\Core\Tests\Unit\Domain\Identity\Service;
 
+use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager;
 use PhpList\Core\Domain\Identity\Model\AdministratorToken;
 use PhpList\Core\Domain\Identity\Repository\AdministratorRepository;
 use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository;
 use PhpList\Core\Domain\Identity\Service\SessionManager;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
 class SessionManagerTest extends TestCase
 {
-    public function testCreateSessionWithInvalidCredentialsThrowsException(): void
+    public function testCreateSessionWithInvalidCredentialsThrowsExceptionAndLogs(): void
     {
         $adminRepo = $this->createMock(AdministratorRepository::class);
         $adminRepo->expects(self::once())
@@ -24,7 +26,24 @@ public function testCreateSessionWithInvalidCredentialsThrowsException(): void
         $tokenRepo = $this->createMock(AdministratorTokenRepository::class);
         $tokenRepo->expects(self::never())->method('save');
 
-        $manager = new SessionManager($tokenRepo, $adminRepo);
+        $eventLogManager = $this->createMock(EventLogManager::class);
+        $eventLogManager->expects(self::once())
+            ->method('log')
+            ->with('login', $this->stringContains('admin'));
+
+        $translator = $this->createMock(TranslatorInterface::class);
+        $translator->expects(self::exactly(2))
+            ->method('trans')
+            ->withConsecutive(
+                ["Failed admin login attempt for '%login%'", ['login' => 'admin']],
+                ['Not authorized', []]
+            )
+            ->willReturnOnConsecutiveCalls(
+                "Failed admin login attempt for 'admin'",
+                'Not authorized'
+            );
+
+        $manager = new SessionManager($tokenRepo, $adminRepo, $eventLogManager, $translator);
 
         $this->expectException(UnauthorizedHttpException::class);
         $this->expectExceptionMessage('Not authorized');
@@ -42,8 +61,10 @@ public function testDeleteSessionCallsRemove(): void
             ->with($token);
 
         $adminRepo = $this->createMock(AdministratorRepository::class);
+        $eventLogManager = $this->createMock(EventLogManager::class);
+        $translator = $this->createMock(TranslatorInterface::class);
 
-        $manager = new SessionManager($tokenRepo, $adminRepo);
+        $manager = new SessionManager($tokenRepo, $adminRepo, $eventLogManager, $translator);
         $manager->deleteSession($token);
     }
 }
diff --git a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php
index 489b5d60..e4dc836e 100644
--- a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php
+++ b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php
@@ -4,11 +4,13 @@
 
 namespace PhpList\Core\Tests\Unit\Domain\Messaging\Command;
 
+use Doctrine\ORM\EntityManagerInterface;
 use Exception;
+use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider;
 use PhpList\Core\Domain\Messaging\Command\ProcessQueueCommand;
+use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage;
 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 PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
@@ -16,32 +18,40 @@
 use Symfony\Component\Console\Tester\CommandTester;
 use Symfony\Component\Lock\LockFactory;
 use Symfony\Component\Lock\LockInterface;
+use Symfony\Component\Messenger\Envelope;
+use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Translation\Translator;
 
 class ProcessQueueCommandTest extends TestCase
 {
     private MessageRepository&MockObject $messageRepository;
     private MessageProcessingPreparator&MockObject $messageProcessingPreparator;
-    private CampaignProcessor&MockObject $campaignProcessor;
+    private MessageBusInterface&MockObject $messageBus;
     private LockInterface&MockObject $lock;
     private CommandTester $commandTester;
+    private Translator&MockObject $translator;
 
     protected function setUp(): void
     {
         $this->messageRepository = $this->createMock(MessageRepository::class);
         $lockFactory = $this->createMock(LockFactory::class);
         $this->messageProcessingPreparator = $this->createMock(MessageProcessingPreparator::class);
-        $this->campaignProcessor = $this->createMock(CampaignProcessor::class);
+        $this->messageBus = $this->createMock(MessageBusInterface::class);
         $this->lock = $this->createMock(LockInterface::class);
+        $this->translator = $this->createMock(Translator::class);
 
         $lockFactory->method('createLock')
             ->with('queue_processor')
             ->willReturn($this->lock);
 
         $command = new ProcessQueueCommand(
-            $this->messageRepository,
-            $lockFactory,
-            $this->messageProcessingPreparator,
-            $this->campaignProcessor
+            messageRepository: $this->messageRepository,
+            lockFactory: $lockFactory,
+            messagePreparator: $this->messageProcessingPreparator,
+            messageBus: $this->messageBus,
+            configProvider: $this->createMock(ConfigProvider::class),
+            translator: $this->translator,
+            entityManager: $this->createMock(EntityManagerInterface::class),
         );
 
         $application = new Application();
@@ -59,10 +69,15 @@ public function testExecuteWithLockAlreadyAcquired(): void
         $this->messageProcessingPreparator->expects($this->never())
             ->method('ensureSubscribersHaveUuid');
 
+        $this->translator->expects($this->once())
+            ->method('trans')
+            ->with('Queue is already being processed by another instance.')
+            ->willReturn('Queue is already being processed by another instance.');
+
         $this->commandTester->execute([]);
 
         $output = $this->commandTester->getDisplay();
-        $this->assertStringContainsString('Queue is already being processed by another instance', $output);
+        $this->assertStringContainsString('Queue is already being processed by another instance.', $output);
         $this->assertEquals(1, $this->commandTester->getStatusCode());
     }
 
@@ -82,12 +97,12 @@ public function testExecuteWithNoCampaigns(): void
             ->method('ensureCampaignsHaveUuid');
 
         $this->messageRepository->expects($this->once())
-            ->method('findBy')
-            ->with(['status' => 'submitted'])
+            ->method('getByStatusAndEmbargo')
+            ->with($this->anything(), $this->anything())
             ->willReturn([]);
 
-        $this->campaignProcessor->expects($this->never())
-            ->method('process');
+        $this->messageBus->expects($this->never())
+            ->method('dispatch');
 
         $this->commandTester->execute([]);
 
@@ -110,22 +125,29 @@ public function testExecuteWithCampaigns(): void
             ->method('ensureCampaignsHaveUuid');
 
         $campaign = $this->createMock(Message::class);
+        $campaign->method('getId')->willReturn(1);
 
         $this->messageRepository->expects($this->once())
-            ->method('findBy')
-            ->with(['status' => 'submitted'])
+            ->method('getByStatusAndEmbargo')
+            ->with($this->anything(), $this->anything())
             ->willReturn([$campaign]);
 
-        $this->campaignProcessor->expects($this->once())
-            ->method('process')
-            ->with($campaign, $this->anything());
+        $this->messageBus->expects($this->once())
+            ->method('dispatch')
+            ->with(
+                $this->callback(function (CampaignProcessorMessage $message) use ($campaign) {
+                    $this->assertEquals($campaign->getId(), $message->getMessageId());
+                    return true;
+                }),
+                $this->equalTo([])
+            )
+            ->willReturn(new Envelope(new CampaignProcessorMessage($campaign->getId())));
 
         $this->commandTester->execute([]);
 
         $this->assertEquals(0, $this->commandTester->getStatusCode());
     }
 
-
     public function testExecuteWithMultipleCampaigns(): void
     {
         $this->lock->expects($this->once())
@@ -141,27 +163,37 @@ public function testExecuteWithMultipleCampaigns(): void
         $this->messageProcessingPreparator->expects($this->once())
             ->method('ensureCampaignsHaveUuid');
 
-        $campaign1 = $this->createMock(Message::class);
-        $campaign2 = $this->createMock(Message::class);
+        $cmp1 = $this->createMock(Message::class);
+        $cmp1->method('getId')->willReturn(1);
+        $cmp2 = $this->createMock(Message::class);
+        $cmp2->method('getId')->willReturn(2);
 
         $this->messageRepository->expects($this->once())
-            ->method('findBy')
-            ->with(['status' => 'submitted'])
-            ->willReturn([$campaign1, $campaign2]);
-
-        $this->campaignProcessor->expects($this->exactly(2))
-            ->method('process')
-            ->withConsecutive(
-                [$campaign1, $this->anything()],
-                [$campaign2, $this->anything()]
-            );
+            ->method('getByStatusAndEmbargo')
+            ->with($this->anything(), $this->anything())
+            ->willReturn([$cmp1, $cmp2]);
+
+        $this->messageBus->expects($this->exactly(2))
+            ->method('dispatch')
+            ->willReturnCallback(function (CampaignProcessorMessage $message, array $stamps) use ($cmp1, $cmp2) {
+                static $call = 0;
+                $call++;
+                if ($call === 1) {
+                    $this->assertEquals($cmp1->getId(), $message->getMessageId());
+                } else {
+                    $this->assertEquals($cmp2->getId(), $message->getMessageId());
+                }
+                $this->assertSame([], $stamps);
+
+                return new Envelope(new CampaignProcessorMessage($message->getMessageId()));
+            });
 
         $this->commandTester->execute([]);
 
         $this->assertEquals(0, $this->commandTester->getStatusCode());
     }
 
-    public function testExecuteWithProcessorException(): void
+    public function testExecuteWithDispatcherException(): void
     {
         $this->lock->expects($this->once())
             ->method('acquire')
@@ -177,16 +209,23 @@ public function testExecuteWithProcessorException(): void
             ->method('ensureCampaignsHaveUuid');
 
         $campaign = $this->createMock(Message::class);
+        $campaign->method('getId')->willReturn(1);
 
         $this->messageRepository->expects($this->once())
-            ->method('findBy')
-            ->with(['status' => 'submitted'])
+            ->method('getByStatusAndEmbargo')
+            ->with($this->anything(), $this->anything())
             ->willReturn([$campaign]);
 
-        $this->campaignProcessor->expects($this->once())
-            ->method('process')
-            ->with($campaign, $this->anything())
-            ->willThrowException(new Exception('Test exception'));
+        $this->messageBus->expects($this->once())
+            ->method('dispatch')
+            ->with(
+                $this->callback(function (CampaignProcessorMessage $message) use ($campaign) {
+                    $this->assertEquals($campaign->getId(), $message->getMessageId());
+                    return true;
+                }),
+                $this->equalTo([])
+            )
+            ->willThrowException(new Exception());
 
         $this->commandTester->execute([]);
 
diff --git a/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php b/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php
index c1b4a92c..4e8bae26 100644
--- a/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php
+++ b/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php
@@ -4,6 +4,7 @@
 
 namespace PhpList\Core\Tests\Unit\Domain\Messaging\Command;
 
+use Exception;
 use PhpList\Core\Domain\Messaging\Command\SendTestEmailCommand;
 use PhpList\Core\Domain\Messaging\Service\EmailService;
 use PHPUnit\Framework\MockObject\MockObject;
@@ -11,16 +12,20 @@
 use Symfony\Component\Console\Application;
 use Symfony\Component\Console\Tester\CommandTester;
 use Symfony\Component\Mime\Email;
+use Symfony\Component\Translation\Translator;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
 class SendTestEmailCommandTest extends TestCase
 {
     private EmailService&MockObject $emailService;
     private CommandTester $commandTester;
+    private TranslatorInterface $translator;
 
     protected function setUp(): void
     {
         $this->emailService = $this->createMock(EmailService::class);
-        $command = new SendTestEmailCommand($this->emailService);
+        $this->translator = new Translator('en');
+        $command = new SendTestEmailCommand($this->emailService, $this->translator);
 
         $application = new Application();
         $application->add($command);
@@ -165,7 +170,7 @@ public function testExecuteWithEmailServiceException(): void
     {
         $this->emailService->expects($this->once())
             ->method('sendEmail')
-            ->willThrowException(new \Exception('Test exception'));
+            ->willThrowException(new Exception('Test exception'));
 
         $this->commandTester->execute([
             'recipient' => 'test@example.com',
@@ -182,7 +187,7 @@ public function testExecuteWithEmailServiceExceptionSync(): void
     {
         $this->emailService->expects($this->once())
             ->method('sendEmailSync')
-            ->willThrowException(new \Exception('Test sync exception'));
+            ->willThrowException(new Exception('Test sync exception'));
 
         $this->commandTester->execute([
             'recipient' => 'test@example.com',
diff --git a/tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php
similarity index 50%
rename from tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php
rename to tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php
index f8bb28d3..e50d89fa 100644
--- a/tests/Unit/Domain/Messaging/Service/CampaignProcessorTest.php
+++ b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php
@@ -2,81 +2,133 @@
 
 declare(strict_types=1);
 
-namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\MessageHandler;
 
 use Doctrine\ORM\EntityManagerInterface;
 use Exception;
+use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage;
+use PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler;
 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\Model\Message\MessageStatus;
+use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
+use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository;
+use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler;
+use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter;
 use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator;
+use PhpList\Core\Domain\Messaging\Service\RateLimitedCampaignMailer;
 use PhpList\Core\Domain\Subscription\Model\Subscriber;
+use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
 use PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use Psr\Log\LoggerInterface;
-use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Mailer\MailerInterface;
 use Symfony\Component\Mime\Email;
+use Symfony\Component\Translation\Translator;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
-class CampaignProcessorTest extends TestCase
+class CampaignProcessorMessageHandlerTest extends TestCase
 {
-    private MailerInterface&MockObject $mailer;
-    private EntityManagerInterface&MockObject $entityManager;
-    private SubscriberProvider&MockObject $subscriberProvider;
-    private MessageProcessingPreparator&MockObject $messagePreparator;
-    private LoggerInterface&MockObject $logger;
-    private OutputInterface&MockObject $output;
-    private CampaignProcessor $campaignProcessor;
+    private RateLimitedCampaignMailer|MockObject $mailer;
+    private EntityManagerInterface|MockObject $entityManager;
+    private SubscriberProvider|MockObject $subscriberProvider;
+    private MessageProcessingPreparator|MockObject $messagePreparator;
+    private LoggerInterface|MockObject $logger;
+    private CampaignProcessorMessageHandler $handler;
+    private MessageRepository|MockObject $messageRepository;
+    private UserMessageRepository|MockObject $userMessageRepository;
+    private MaxProcessTimeLimiter|MockObject $timeLimiter;
+    private RequeueHandler|MockObject $requeueHandler;
+    private TranslatorInterface|MockObject $translator;
 
     protected function setUp(): void
     {
-        $this->mailer = $this->createMock(MailerInterface::class);
+        $this->mailer = $this->createMock(RateLimitedCampaignMailer::class);
         $this->entityManager = $this->createMock(EntityManagerInterface::class);
         $this->subscriberProvider = $this->createMock(SubscriberProvider::class);
         $this->messagePreparator = $this->createMock(MessageProcessingPreparator::class);
         $this->logger = $this->createMock(LoggerInterface::class);
-        $this->output = $this->createMock(OutputInterface::class);
-
-        $this->campaignProcessor = new CampaignProcessor(
-            $this->mailer,
-            $this->entityManager,
-            $this->subscriberProvider,
-            $this->messagePreparator,
-            $this->logger
+        $this->messageRepository = $this->createMock(MessageRepository::class);
+        $this->userMessageRepository = $this->createMock(UserMessageRepository::class);
+        $this->timeLimiter = $this->createMock(MaxProcessTimeLimiter::class);
+        $this->requeueHandler = $this->createMock(RequeueHandler::class);
+        $this->translator = $this->createMock(Translator::class);
+
+        $this->timeLimiter->method('start');
+        $this->timeLimiter->method('shouldStop')->willReturn(false);
+
+        $this->handler = new CampaignProcessorMessageHandler(
+            mailer: $this->mailer,
+            entityManager: $this->entityManager,
+            subscriberProvider: $this->subscriberProvider,
+            messagePreparator: $this->messagePreparator,
+            logger: $this->logger,
+            userMessageRepository: $this->userMessageRepository,
+            timeLimiter: $this->timeLimiter,
+            requeueHandler: $this->requeueHandler,
+            translator: $this->translator,
+            subscriberHistoryManager: $this->createMock(SubscriberHistoryManager::class),
+            messageRepository: $this->messageRepository,
         );
     }
 
-    public function testProcessWithNoSubscribers(): void
+    public function testInvokeWhenCampaignNotFound(): void
+    {
+        $message = new CampaignProcessorMessage(999);
+
+        $this->messageRepository->expects($this->once())
+            ->method('findByIdAndStatus')
+            ->with(999, MessageStatus::Submitted)
+            ->willReturn(null);
+
+        $this->translator->method('trans')->willReturnCallback(fn(string $msg) => $msg);
+
+        $this->logger->expects($this->once())
+            ->method('warning')
+            ->with('Campaign not found or not in submitted status', ['campaign_id' => 999]);
+
+        ($this->handler)($message);
+    }
+
+    public function testInvokeWithNoSubscribers(): void
     {
         $campaign = $this->createCampaignMock();
         $metadata = $this->createMock(MessageMetadata::class);
         $campaign->method('getMetadata')->willReturn($metadata);
+        $campaign->method('getId')->willReturn(1);
+
+        $this->messageRepository->method('findByIdAndStatus')
+            ->with(1, MessageStatus::Submitted)
+            ->willReturn($campaign);
 
         $this->subscriberProvider->expects($this->once())
             ->method('getSubscribersForMessage')
             ->with($campaign)
             ->willReturn([]);
 
-        $metadata->expects($this->once())
-            ->method('setStatus')
-            ->with('sent');
+        $metadata->expects($this->atLeastOnce())
+            ->method('setStatus');
 
-        $this->entityManager->expects($this->once())
+        $this->entityManager->expects($this->atLeastOnce())
             ->method('flush');
 
         $this->mailer->expects($this->never())
             ->method('send');
 
-        $this->campaignProcessor->process($campaign, $this->output);
+        ($this->handler)(new CampaignProcessorMessage(1));
     }
 
-    public function testProcessWithInvalidSubscriberEmail(): void
+    public function testInvokeWithInvalidSubscriberEmail(): void
     {
         $campaign = $this->createCampaignMock();
         $metadata = $this->createMock(MessageMetadata::class);
         $campaign->method('getMetadata')->willReturn($metadata);
+        $campaign->method('getId')->willReturn(1);
+
+        $this->messageRepository->method('findByIdAndStatus')
+            ->with(1, MessageStatus::Submitted)
+            ->willReturn($campaign);
 
         $subscriber = $this->createMock(Subscriber::class);
         $subscriber->method('getEmail')->willReturn('invalid-email');
@@ -87,11 +139,10 @@ public function testProcessWithInvalidSubscriberEmail(): void
             ->with($campaign)
             ->willReturn([$subscriber]);
 
-        $metadata->expects($this->once())
-            ->method('setStatus')
-            ->with('sent');
+        $metadata->expects($this->atLeastOnce())
+            ->method('setStatus');
 
-        $this->entityManager->expects($this->once())
+        $this->entityManager->expects($this->atLeastOnce())
             ->method('flush');
 
         $this->messagePreparator->expects($this->never())
@@ -100,14 +151,19 @@ public function testProcessWithInvalidSubscriberEmail(): void
         $this->mailer->expects($this->never())
             ->method('send');
 
-        $this->campaignProcessor->process($campaign, $this->output);
+        ($this->handler)(new CampaignProcessorMessage(1));
     }
 
-    public function testProcessWithValidSubscriberEmail(): void
+    public function testInvokeWithValidSubscriberEmail(): void
     {
         $campaign = $this->createCampaignMock();
         $metadata = $this->createMock(MessageMetadata::class);
         $campaign->method('getMetadata')->willReturn($metadata);
+        $campaign->method('getId')->willReturn(1);
+
+        $this->messageRepository->method('findByIdAndStatus')
+            ->with(1, MessageStatus::Submitted)
+            ->willReturn($campaign);
 
         $subscriber = $this->createMock(Subscriber::class);
         $subscriber->method('getEmail')->willReturn('test@example.com');
@@ -123,34 +179,44 @@ public function testProcessWithValidSubscriberEmail(): void
             ->with($campaign, 1)
             ->willReturn($campaign);
 
+        $this->mailer->expects($this->once())
+            ->method('composeEmail')
+            ->with($campaign, $subscriber)
+            ->willReturnCallback(function ($processed, $sub) use ($campaign, $subscriber) {
+                $this->assertSame($campaign, $processed);
+                $this->assertSame($subscriber, $sub);
+                return (new Email())
+                    ->from('news@example.com')
+                    ->to('test@example.com')
+                    ->subject('Test Subject')
+                    ->text('Test text message')
+                    ->html('Test HTML message
');
+            });
+
         $this->mailer->expects($this->once())
             ->method('send')
-            ->with($this->callback(function (Email $email) {
-                $this->assertEquals('test@example.com', $email->getTo()[0]->getAddress());
-                $this->assertEquals('news@example.com', $email->getFrom()[0]->getAddress());
-                $this->assertEquals('Test Subject', $email->getSubject());
-                $this->assertEquals('Test text message', $email->getTextBody());
-                $this->assertEquals('Test HTML message
', $email->getHtmlBody());
-                return true;
-            }));
-
-        $metadata->expects($this->once())
-            ->method('setStatus')
-            ->with('sent');
-
-        $this->entityManager->expects($this->once())
+            ->with($this->isInstanceOf(Email::class));
+
+        $metadata->expects($this->atLeastOnce())
+            ->method('setStatus');
+
+        $this->entityManager->expects($this->atLeastOnce())
             ->method('flush');
 
-        $this->campaignProcessor->process($campaign, $this->output);
+        ($this->handler)(new CampaignProcessorMessage(1));
     }
 
-    public function testProcessWithMailerException(): void
+    public function testInvokeWithMailerException(): void
     {
         $campaign = $this->createCampaignMock();
         $metadata = $this->createMock(MessageMetadata::class);
         $campaign->method('getMetadata')->willReturn($metadata);
         $campaign->method('getId')->willReturn(123);
 
+        $this->messageRepository->method('findByIdAndStatus')
+            ->with(123, MessageStatus::Submitted)
+            ->willReturn($campaign);
+
         $subscriber = $this->createMock(Subscriber::class);
         $subscriber->method('getEmail')->willReturn('test@example.com');
         $subscriber->method('getId')->willReturn(1);
@@ -177,25 +243,25 @@ public function testProcessWithMailerException(): void
                 'campaign_id' => 123,
             ]);
 
-        $this->output->expects($this->once())
-            ->method('writeln')
-            ->with('Failed to send to: test@example.com');
-
-        $metadata->expects($this->once())
-            ->method('setStatus')
-            ->with('sent');
+        $metadata->expects($this->atLeastOnce())
+            ->method('setStatus');
 
-        $this->entityManager->expects($this->once())
+        $this->entityManager->expects($this->atLeastOnce())
             ->method('flush');
 
-        $this->campaignProcessor->process($campaign, $this->output);
+        ($this->handler)(new CampaignProcessorMessage(123));
     }
 
-    public function testProcessWithMultipleSubscribers(): void
+    public function testInvokeWithMultipleSubscribers(): void
     {
         $campaign = $this->createCampaignMock();
         $metadata = $this->createMock(MessageMetadata::class);
         $campaign->method('getMetadata')->willReturn($metadata);
+        $campaign->method('getId')->willReturn(1);
+
+        $this->messageRepository->method('findByIdAndStatus')
+            ->with(1, MessageStatus::Submitted)
+            ->willReturn($campaign);
 
         $subscriber1 = $this->createMock(Subscriber::class);
         $subscriber1->method('getEmail')->willReturn('test1@example.com');
@@ -221,63 +287,19 @@ public function testProcessWithMultipleSubscribers(): void
         $this->mailer->expects($this->exactly(2))
             ->method('send');
 
-        $metadata->expects($this->once())
-            ->method('setStatus')
-            ->with('sent');
-
-        $this->entityManager->expects($this->once())
-            ->method('flush');
-
-        $this->campaignProcessor->process($campaign, $this->output);
-    }
-
-    public function testProcessWithNullOutput(): void
-    {
-        $campaign = $this->createCampaignMock();
-        $metadata = $this->createMock(MessageMetadata::class);
-        $campaign->method('getMetadata')->willReturn($metadata);
-        $campaign->method('getId')->willReturn(123);
-
-        $subscriber = $this->createMock(Subscriber::class);
-        $subscriber->method('getEmail')->willReturn('test@example.com');
-        $subscriber->method('getId')->willReturn(1);
-
-        $this->subscriberProvider->expects($this->once())
-            ->method('getSubscribersForMessage')
-            ->with($campaign)
-            ->willReturn([$subscriber]);
-
-        $this->messagePreparator->expects($this->once())
-            ->method('processMessageLinks')
-            ->with($campaign, 1)
-            ->willReturn($campaign);
-
-        $exception = new Exception('Test exception');
-        $this->mailer->expects($this->once())
-            ->method('send')
-            ->willThrowException($exception);
-
-        $this->logger->expects($this->once())
-            ->method('error')
-            ->with('Test exception', [
-                'subscriber_id' => 1,
-                'campaign_id' => 123,
-            ]);
-
-        $metadata->expects($this->once())
-            ->method('setStatus')
-            ->with('sent');
+        $metadata->expects($this->atLeastOnce())
+            ->method('setStatus');
 
-        $this->entityManager->expects($this->once())
+        $this->entityManager->expects($this->atLeastOnce())
             ->method('flush');
 
-        $this->campaignProcessor->process($campaign, null);
+        ($this->handler)(new CampaignProcessorMessage(1));
     }
 
     /**
      * Creates a mock for the Message class with content
      */
-    private function createCampaignMock(): Message&MockObject
+    private function createCampaignMock(): Message|MockObject
     {
         $campaign = $this->createMock(Message::class);
         $content = $this->createMock(MessageContent::class);
diff --git a/tests/Unit/Domain/Messaging/MessageHandler/PasswordResetMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/PasswordResetMessageHandlerTest.php
index ae7aa184..22f83bfc 100644
--- a/tests/Unit/Domain/Messaging/MessageHandler/PasswordResetMessageHandlerTest.php
+++ b/tests/Unit/Domain/Messaging/MessageHandler/PasswordResetMessageHandlerTest.php
@@ -10,6 +10,7 @@
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\Mime\Email;
+use Symfony\Component\Translation\Translator;
 
 class PasswordResetMessageHandlerTest extends TestCase
 {
@@ -20,7 +21,11 @@ class PasswordResetMessageHandlerTest extends TestCase
     protected function setUp(): void
     {
         $this->emailService = $this->createMock(EmailService::class);
-        $this->handler = new PasswordResetMessageHandler($this->emailService, $this->passwordResetUrl);
+        $this->handler = new PasswordResetMessageHandler(
+            $this->emailService,
+            new Translator('en'),
+            $this->passwordResetUrl
+        );
     }
 
     public function testInvoke(): void
diff --git a/tests/Unit/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandlerTest.php
index 4bd89243..550a6160 100644
--- a/tests/Unit/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandlerTest.php
+++ b/tests/Unit/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandlerTest.php
@@ -10,6 +10,7 @@
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\Mime\Email;
+use Symfony\Component\Translation\Translator;
 
 class SubscriberConfirmationMessageHandlerTest extends TestCase
 {
@@ -20,7 +21,11 @@ class SubscriberConfirmationMessageHandlerTest extends TestCase
     protected function setUp(): void
     {
         $this->emailService = $this->createMock(EmailService::class);
-        $this->handler = new SubscriberConfirmationMessageHandler($this->emailService, $this->confirmationUrl);
+        $this->handler = new SubscriberConfirmationMessageHandler(
+            emailService: $this->emailService,
+            translator: new Translator('en'),
+            confirmationUrl: $this->confirmationUrl
+        );
     }
 
     public function testInvokeWithTextEmail(): void
diff --git a/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php
new file mode 100644
index 00000000..6288c5f4
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/MessageHandler/SubscriptionConfirmationMessageHandlerTest.php
@@ -0,0 +1,139 @@
+createMock(EmailService::class);
+        $configProvider = $this->createMock(ConfigProvider::class);
+        $logger = $this->createMock(LoggerInterface::class);
+        $personalizer = $this->createMock(UserPersonalizer::class);
+        $listRepo = $this->createMock(SubscriberListRepository::class);
+
+        $handler = new SubscriptionConfirmationMessageHandler(
+            emailService: $emailService,
+            configProvider: $configProvider,
+            logger: $logger,
+            userPersonalizer: $personalizer,
+            subscriberListRepository: $listRepo
+        );
+        $configProvider
+            ->expects($this->exactly(2))
+            ->method('getValue')
+            ->willReturnMap([
+                [ConfigOption::SubscribeEmailSubject, 'Please confirm your subscription'],
+                [ConfigOption::SubscribeMessage, 'Hi {{name}}, you subscribed to: [LISTS]'],
+            ]);
+
+        $message = new SubscriptionConfirmationMessage('alice@example.com', 'user-123', [10, 11]);
+
+        $personalizer->expects($this->once())
+            ->method('personalize')
+            ->with('Hi {{name}}, you subscribed to: [LISTS]', 'user-123')
+            ->willReturn('Hi Alice, you subscribed to: [LISTS]');
+
+        $listA = $this->createMock(SubscriberList::class);
+        $listA->method('getName')->willReturn('Releases');
+        $listB = $this->createMock(SubscriberList::class);
+        $listB->method('getName')->willReturn('Security Advisories');
+
+        $listRepo->method('find')
+            ->willReturnCallback(function (int $id) use ($listA, $listB) {
+                return match ($id) {
+                    10 => $listA,
+                    11 => $listB,
+                    default => null
+                };
+            });
+
+        // Capture the Email object passed to EmailService
+        $emailService->expects($this->once())
+            ->method('sendEmail')
+            ->with($this->callback(function (Email $email): bool {
+                $addresses = $email->getTo();
+                if (count($addresses) !== 1 || $addresses[0]->getAddress() !== 'alice@example.com') {
+                    return false;
+                }
+                if ($email->getSubject() !== 'Please confirm your subscription') {
+                    return false;
+                }
+                $body = $email->getTextBody();
+                return $body === 'Hi Alice, you subscribed to: Releases, Security Advisories';
+            }));
+
+        $logger->expects($this->once())
+            ->method('info')
+            ->with(
+                'Subscription confirmation email sent to {email}',
+                ['email' => 'alice@example.com']
+            );
+
+        $handler($message);
+    }
+
+    public function testHandlesMissingListsGracefullyAndEmptyJoin(): void
+    {
+        $emailService = $this->createMock(EmailService::class);
+        $configProvider = $this->createMock(ConfigProvider::class);
+        $logger = $this->createMock(LoggerInterface::class);
+        $personalizer = $this->createMock(UserPersonalizer::class);
+        $listRepo = $this->createMock(SubscriberListRepository::class);
+
+        $handler = new SubscriptionConfirmationMessageHandler(
+            emailService: $emailService,
+            configProvider: $configProvider,
+            logger: $logger,
+            userPersonalizer: $personalizer,
+            subscriberListRepository: $listRepo
+        );
+
+        $configProvider->method('getValue')
+            ->willReturnMap([
+                [ConfigOption::SubscribeEmailSubject, 'Please confirm your subscription'],
+                [ConfigOption::SubscribeMessage, 'Lists: [LISTS]'],
+            ]);
+
+        $message = $this->createMock(SubscriptionConfirmationMessage::class);
+        $message->method('getEmail')->willReturn('bob@example.com');
+        $message->method('getUniqueId')->willReturn('user-456');
+        $message->method('getListIds')->willReturn([42]);
+
+        $personalizer->method('personalize')
+            ->with('Lists: [LISTS]', 'user-456')
+            ->willReturn('Lists: [LISTS]');
+
+        $listRepo->method('find')->with(42)->willReturn(null);
+
+        $emailService->expects($this->once())
+            ->method('sendEmail')
+            ->with($this->callback(function (Email $email): bool {
+                // Intended empty replacement when no lists found -> empty string
+                return $email->getTextBody() === 'Lists: ';
+            }));
+
+        $logger->expects($this->once())
+            ->method('info')
+            ->with('Subscription confirmation email sent to {email}', ['email' => 'bob@example.com']);
+
+        $handler($message);
+    }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php
index 564bd34d..d08ee9a1 100644
--- a/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageBuilderTest.php
@@ -2,11 +2,10 @@
 
 declare(strict_types=1);
 
-namespace PhpList\Core\Tests\Unit\Domain\Service\Builder;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder;
 
-use Error;
-use InvalidArgumentException;
 use PhpList\Core\Domain\Identity\Model\Administrator;
+use PhpList\Core\Domain\Messaging\Exception\InvalidContextTypeException;
 use PhpList\Core\Domain\Messaging\Model\Dto\CreateMessageDto;
 use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageContentDto;
 use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageFormatDto;
@@ -15,6 +14,8 @@
 use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageScheduleDto;
 use PhpList\Core\Domain\Messaging\Model\Dto\MessageContext;
 use PhpList\Core\Domain\Messaging\Model\Message;
+use PhpList\Core\Domain\Messaging\Model\Message\MessageContent;
+use PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule;
 use PhpList\Core\Domain\Messaging\Repository\TemplateRepository;
 use PhpList\Core\Domain\Messaging\Service\Builder\MessageBuilder;
 use PhpList\Core\Domain\Messaging\Service\Builder\MessageContentBuilder;
@@ -41,11 +42,11 @@ protected function setUp(): void
         $this->optionsBuilder = $this->createMock(MessageOptionsBuilder::class);
 
         $this->builder = new MessageBuilder(
-            $templateRepository,
-            $this->formatBuilder,
-            $this->scheduleBuilder,
-            $this->contentBuilder,
-            $this->optionsBuilder
+            templateRepository: $templateRepository,
+            messageFormatBuilder: $this->formatBuilder,
+            messageScheduleBuilder: $this->scheduleBuilder,
+            messageContentBuilder: $this->contentBuilder,
+            messageOptionsBuilder: $this->optionsBuilder
         );
     }
 
@@ -64,7 +65,7 @@ private function createRequest(): CreateMessageDto
                 formatOptions: []
             ),
             metadata: new MessageMetadataDto(
-                status: 'draft'
+                status: Message\MessageStatus::Draft
             ),
             options: new MessageOptionsDto(
                 fromField: '',
@@ -93,12 +94,12 @@ private function mockBuildCalls(CreateMessageDto $createMessageDto): void
         $this->scheduleBuilder->expects($this->once())
             ->method('build')
             ->with($createMessageDto->schedule)
-            ->willReturn($this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule::class));
+            ->willReturn($this->createMock(MessageSchedule::class));
 
         $this->contentBuilder->expects($this->once())
             ->method('build')
             ->with($createMessageDto->content)
-            ->willReturn($this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class));
+            ->willReturn($this->createMock(MessageContent::class));
 
         $this->optionsBuilder->expects($this->once())
             ->method('build')
@@ -114,22 +115,12 @@ public function testBuildsNewMessage(): void
 
         $this->mockBuildCalls($request);
 
-        $this->builder->build($request, $context);
-    }
-
-    public function testThrowsExceptionOnInvalidRequest(): void
-    {
-        $this->expectException(Error::class);
-
-        $this->builder->build(
-            $this->createMock(CreateMessageDto::class),
-            new MessageContext($this->createMock(Administrator::class))
-        );
+        $this->builder->build(createMessageDto: $request, context: $context);
     }
 
     public function testThrowsExceptionOnInvalidContext(): void
     {
-        $this->expectException(InvalidArgumentException::class);
+        $this->expectException(InvalidContextTypeException::class);
 
         $this->builder->build($this->createMock(CreateMessageDto::class), new \stdClass());
     }
@@ -150,11 +141,11 @@ public function testUpdatesExistingMessage(): void
         $existingMessage
             ->expects($this->once())
             ->method('setSchedule')
-            ->with($this->isInstanceOf(\PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule::class));
+            ->with($this->isInstanceOf(MessageSchedule::class));
         $existingMessage
             ->expects($this->once())
             ->method('setContent')
-            ->with($this->isInstanceOf(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class));
+            ->with($this->isInstanceOf(MessageContent::class));
         $existingMessage
             ->expects($this->once())
             ->method('setOptions')
diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php
index 2b1aa771..62475884 100644
--- a/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageContentBuilderTest.php
@@ -2,9 +2,9 @@
 
 declare(strict_types=1);
 
-namespace PhpList\Core\Tests\Unit\Domain\Service\Builder;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder;
 
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException;
 use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageContentDto;
 use PhpList\Core\Domain\Messaging\Service\Builder\MessageContentBuilder;
 use PHPUnit\Framework\TestCase;
@@ -37,7 +37,7 @@ public function testBuildsMessageContentSuccessfully(): void
 
     public function testThrowsExceptionOnInvalidDto(): void
     {
-        $this->expectException(InvalidArgumentException::class);
+        $this->expectException(InvalidDtoTypeException::class);
 
         $invalidDto = new \stdClass();
         $this->builder->build($invalidDto);
diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php
index 8d9320a0..17d93eae 100644
--- a/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageFormatBuilderTest.php
@@ -2,9 +2,9 @@
 
 declare(strict_types=1);
 
-namespace PhpList\Core\Tests\Unit\Domain\Service\Builder;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder;
 
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException;
 use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageFormatDto;
 use PhpList\Core\Domain\Messaging\Service\Builder\MessageFormatBuilder;
 use PHPUnit\Framework\TestCase;
@@ -30,7 +30,7 @@ public function testBuildsMessageFormatSuccessfully(): void
 
     public function testThrowsExceptionOnInvalidDto(): void
     {
-        $this->expectException(InvalidArgumentException::class);
+        $this->expectException(InvalidDtoTypeException::class);
 
         $invalidDto = new \stdClass();
         $this->builder->build($invalidDto);
diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php
index c6795d29..e2de8398 100644
--- a/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageOptionsBuilderTest.php
@@ -2,9 +2,9 @@
 
 declare(strict_types=1);
 
-namespace PhpList\Core\Tests\Unit\Domain\Service\Builder;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder;
 
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException;
 use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageOptionsDto;
 use PhpList\Core\Domain\Messaging\Service\Builder\MessageOptionsBuilder;
 use PHPUnit\Framework\TestCase;
@@ -37,7 +37,7 @@ public function testBuildsMessageOptionsSuccessfully(): void
 
     public function testThrowsExceptionOnInvalidDto(): void
     {
-        $this->expectException(InvalidArgumentException::class);
+        $this->expectException(InvalidDtoTypeException::class);
 
         $invalidDto = new \stdClass();
         $this->builder->build($invalidDto);
diff --git a/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php
index 38f04338..8e9e5fb8 100644
--- a/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Builder/MessageScheduleBuilderTest.php
@@ -2,10 +2,10 @@
 
 declare(strict_types=1);
 
-namespace PhpList\Core\Tests\Unit\Domain\Service\Builder;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder;
 
 use DateTime;
-use InvalidArgumentException;
+use PhpList\Core\Domain\Messaging\Exception\InvalidDtoTypeException;
 use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageScheduleDto;
 use PhpList\Core\Domain\Messaging\Service\Builder\MessageScheduleBuilder;
 use PHPUnit\Framework\TestCase;
@@ -40,7 +40,7 @@ public function testBuildsMessageScheduleSuccessfully(): void
 
     public function testThrowsExceptionOnInvalidDto(): void
     {
-        $this->expectException(InvalidArgumentException::class);
+        $this->expectException(InvalidDtoTypeException::class);
 
         $invalidDto = new \stdClass();
         $this->builder->build($invalidDto);
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/RequeueHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php
new file mode 100644
index 00000000..495f496e
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php
@@ -0,0 +1,148 @@
+logger = $this->createMock(LoggerInterface::class);
+        $this->output = $this->createMock(OutputInterface::class);
+    }
+
+    private function createMessage(
+        ?int $requeueInterval,
+        ?DateTime $requeueUntil,
+        ?DateTime $embargo
+    ): Message {
+        $format = new MessageFormat(htmlFormatted: false, sendFormat: null);
+        $schedule = new MessageSchedule(
+            repeatInterval: null,
+            repeatUntil: null,
+            requeueInterval: $requeueInterval,
+            requeueUntil: $requeueUntil,
+            embargo: $embargo
+        );
+        $metadata = new MessageMetadata(MessageStatus::Draft);
+        $content = new MessageContent('(no subject)');
+        $options = new MessageOptions();
+
+        return new Message($format, $schedule, $metadata, $content, $options, owner: null, template: null);
+    }
+
+    public function testReturnsFalseWhenIntervalIsZeroOrNegative(): void
+    {
+        $handler = new RequeueHandler($this->logger, new Translator('en'));
+        $message = $this->createMessage(0, null, null);
+
+        $this->output->expects($this->never())->method('writeln');
+        $this->logger->expects($this->never())->method('info');
+
+        $result = $handler->handle($message, $this->output);
+
+        $this->assertFalse($result);
+        $this->assertSame(MessageStatus::Draft, $message->getMetadata()->getStatus());
+    }
+
+    public function testReturnsFalseWhenNowIsAfterRequeueUntil(): void
+    {
+        $handler = new RequeueHandler($this->logger, new Translator('en'));
+        $past = (new DateTime())->sub(new DateInterval('PT5M'));
+        $message = $this->createMessage(5, $past, null);
+
+        $this->logger->expects($this->never())->method('info');
+
+        $result = $handler->handle($message, $this->output);
+
+        $this->assertFalse($result);
+        $this->assertSame(MessageStatus::Draft, $message->getMetadata()->getStatus());
+    }
+
+    public function testRequeuesFromFutureEmbargoAndSetsSubmittedStatus(): void
+    {
+        $handler = new RequeueHandler($this->logger, new Translator('en'));
+        $embargo = (new DateTime())->add(new DateInterval('PT5M'));
+        $interval = 10;
+        $message = $this->createMessage($interval, null, $embargo);
+
+        $this->output->expects($this->once())->method('writeln');
+        $this->logger->expects($this->once())->method('info');
+
+        $result = $handler->handle($message, $this->output);
+
+        $this->assertTrue($result);
+        $this->assertSame(MessageStatus::Submitted, $message->getMetadata()->getStatus());
+
+        $expectedNext = (clone $embargo)->add(new DateInterval('PT' . $interval . 'M'));
+        $actualNext = $message->getSchedule()->getEmbargo();
+        $this->assertInstanceOf(DateTime::class, $actualNext);
+        $this->assertEquals($expectedNext->format(DateTime::ATOM), $actualNext->format(DateTime::ATOM));
+    }
+
+    public function testRequeuesFromNowWhenEmbargoIsNullOrPast(): void
+    {
+        $handler = new RequeueHandler($this->logger, new Translator('en'));
+        $interval = 3;
+        $message = $this->createMessage($interval, null, null);
+
+        $this->logger->expects($this->once())->method('info');
+
+        $before = new DateTime();
+        $result = $handler->handle($message, $this->output);
+        $after = new DateTime();
+
+        $this->assertTrue($result);
+        $this->assertSame(MessageStatus::Submitted, $message->getMetadata()->getStatus());
+
+        $embargo = $message->getSchedule()->getEmbargo();
+        $this->assertInstanceOf(DateTime::class, $embargo);
+
+        $minExpected = (clone $before)->add(new DateInterval('PT' . $interval . 'M'));
+        $maxExpected = (clone $after)->add(new DateInterval('PT' . $interval . 'M'));
+
+        $this->assertGreaterThanOrEqual($minExpected->getTimestamp(), $embargo->getTimestamp());
+        $this->assertLessThanOrEqual($maxExpected->getTimestamp(), $embargo->getTimestamp());
+    }
+
+    public function testReturnsFalseWhenNextEmbargoExceedsUntil(): void
+    {
+        $handler = new RequeueHandler($this->logger, new Translator('en'));
+        $embargo = (new DateTime())->add(new DateInterval('PT1M'));
+        $interval = 10;
+        // next would be +10, which exceeds until
+        $until = (clone $embargo)->add(new DateInterval('PT5M'));
+        $message = $this->createMessage($interval, $until, $embargo);
+
+        $this->logger->expects($this->never())->method('info');
+
+        $result = $handler->handle($message, $this->output);
+
+        $this->assertFalse($result);
+        $this->assertSame(MessageStatus::Draft, $message->getMetadata()->getStatus());
+        $this->assertEquals(
+            $embargo->format(DateTime::ATOM),
+            $message->getSchedule()->getEmbargo()?->format(DateTime::ATOM)
+        );
+    }
+}
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..9de0df4d
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php
@@ -0,0 +1,204 @@
+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,
+            translator: new Translator('en')
+        );
+    }
+
+    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('persist')
+            ->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->entityManager->expects($this->once())
+            ->method('flush');
+
+        $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);
+
+        $dt = new DateTimeImmutable('2024-05-01 12:34:56');
+        $umb = $this->manager->linkUserMessageBounce($bounce, $dt, 123, 456);
+
+        $this->assertSame(77, $umb->getBounceId());
+        $this->assertSame(123, $umb->getUserId());
+        $this->assertSame(456, $umb->getMessageId());
+    }
+
+    public function testExistsUserMessageBounceDelegatesToRepo(): void
+    {
+        $this->userMessageBounceRepository->expects($this->once())
+            ->method('existsByMessageIdAndUserId')
+            ->with(456, 123)
+            ->willReturn(true);
+
+        $this->assertTrue($this->manager->existsUserMessageBounce(123, 456));
+    }
+
+    public function testFindByStatusDelegatesToRepository(): void
+    {
+        $b1 = new Bounce();
+        $b2 = new Bounce();
+        $this->repository->expects($this->once())
+            ->method('findByStatus')
+            ->with('new')
+            ->willReturn([$b1, $b2]);
+
+        $this->assertSame([$b1, $b2], $this->manager->findByStatus('new'));
+    }
+
+    public function testGetUserMessageBounceCount(): void
+    {
+        $this->userMessageBounceRepository->expects($this->once())
+            ->method('count')
+            ->willReturn(5);
+        $this->assertSame(5, $this->manager->getUserMessageBounceCount());
+    }
+
+    public function testFetchUserMessageBounceBatchDelegates(): void
+    {
+        $expected = [['umb' => new UserMessageBounce(1, new \DateTime()), 'bounce' => new Bounce()]];
+        $this->userMessageBounceRepository->expects($this->once())
+            ->method('getPaginatedWithJoinNoRelation')
+            ->with(10, 50)
+            ->willReturn($expected);
+        $this->assertSame($expected, $this->manager->fetchUserMessageBounceBatch(10, 50));
+    }
+
+    public function testGetUserMessageHistoryWithBouncesDelegates(): void
+    {
+        $subscriber = new Subscriber();
+        $expected = [];
+        $this->userMessageBounceRepository->expects($this->once())
+            ->method('getUserMessageHistoryWithBounces')
+            ->with($subscriber)
+            ->willReturn($expected);
+        $this->assertSame($expected, $this->manager->getUserMessageHistoryWithBounces($subscriber));
+    }
+
+    public function testAnnounceDeletionModeLogsCorrectMessage(): void
+    {
+        $this->logger->expects($this->exactly(2))
+            ->method('info')
+            ->withConsecutive([
+                'Running in test mode, not deleting messages from mailbox'
+            ], [
+                'Processed messages will be deleted from the mailbox'
+            ]);
+
+        $this->manager->announceDeletionMode(true);
+        $this->manager->announceDeletionMode(false);
+    }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php
new file mode 100644
index 00000000..4ade0040
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceRegexManagerTest.php
@@ -0,0 +1,137 @@
+regexRepository = $this->createMock(BounceRegexRepository::class);
+        $this->entityManager = $this->createMock(EntityManagerInterface::class);
+
+        $this->manager = new BounceRegexManager(
+            bounceRegexRepository: $this->regexRepository,
+            entityManager: $this->entityManager
+        );
+    }
+
+    public function testCreateNewRegex(): void
+    {
+        $pattern = 'user unknown';
+        $expectedHash = md5($pattern);
+
+        $this->regexRepository->expects($this->once())
+            ->method('findOneByRegexHash')
+            ->with($expectedHash)
+            ->willReturn(null);
+
+        $this->regexRepository->expects($this->once())
+            ->method('persist')
+            ->with($this->isInstanceOf(BounceRegex::class));
+
+        $regex = $this->manager->createOrUpdateFromPattern(
+            regex: $pattern,
+            action: 'delete',
+            listOrder: 5,
+            adminId: 1,
+            comment: 'test',
+            status: 'active'
+        );
+
+        $this->assertInstanceOf(BounceRegex::class, $regex);
+        $this->assertSame($pattern, $regex->getRegex());
+        $this->assertSame($expectedHash, $regex->getRegexHash());
+        $this->assertSame('delete', $regex->getAction());
+        $this->assertSame(5, $regex->getListOrder());
+        $this->assertSame(1, $regex->getAdminId());
+        $this->assertSame('test', $regex->getComment());
+        $this->assertSame('active', $regex->getStatus());
+    }
+
+    public function testUpdateExistingRegex(): void
+    {
+        $pattern = 'mailbox full';
+        $hash = md5($pattern);
+
+        $existing = new BounceRegex(
+            regex: $pattern,
+            regexHash: $hash,
+            action: 'keep',
+            listOrder: 0,
+            adminId: null,
+            comment: null,
+            status: 'inactive',
+            count: 3
+        );
+
+        $this->regexRepository->expects($this->once())
+            ->method('findOneByRegexHash')
+            ->with($hash)
+            ->willReturn($existing);
+
+        $updated = $this->manager->createOrUpdateFromPattern(
+            regex: $pattern,
+            action: 'delete',
+            listOrder: 10,
+            adminId: 2,
+            comment: 'upd',
+            status: 'active'
+        );
+
+        $this->assertSame('delete', $updated->getAction());
+        $this->assertSame(10, $updated->getListOrder());
+        $this->assertSame(2, $updated->getAdminId());
+        $this->assertSame('upd', $updated->getComment());
+        $this->assertSame('active', $updated->getStatus());
+        $this->assertSame($hash, $updated->getRegexHash());
+    }
+
+    public function testDeleteRegex(): void
+    {
+        $model = $this->createMock(BounceRegex::class);
+
+        $this->regexRepository->expects($this->once())
+            ->method('remove')
+            ->with($model);
+
+        $this->manager->delete($model);
+    }
+
+    public function testAssociateBounceIncrementsCountAndPersistsRelation(): void
+    {
+        $regex = new BounceRegex(regex: 'x', regexHash: md5('x'));
+
+        $refRegex = new ReflectionProperty(BounceRegex::class, 'id');
+        $refRegex->setValue($regex, 7);
+
+        $bounce = $this->createMock(Bounce::class);
+        $bounce->method('getId')->willReturn(11);
+
+        $this->entityManager->expects($this->once())
+            ->method('persist')
+            ->with($this->callback(function ($entity) use ($regex) {
+                return $entity instanceof BounceRegexBounce
+                    && $entity->getRegexId() === $regex->getId();
+            }));
+
+        $this->assertSame(0, $regex->getCount());
+        $this->manager->associateBounce($regex, $bounce);
+        $this->assertSame(1, $regex->getCount());
+    }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php
new file mode 100644
index 00000000..040f98a8
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceRuleManagerTest.php
@@ -0,0 +1,143 @@
+regexRepository = $this->createMock(BounceRegexRepository::class);
+        $this->relationRepository = $this->createMock(BounceRegexBounceRepository::class);
+        $this->manager = new BounceRuleManager(
+            repository: $this->regexRepository,
+            bounceRelationRepository: $this->relationRepository,
+        );
+    }
+
+    public function testLoadActiveRulesMapsRowsAndSkipsInvalid(): void
+    {
+        $valid = $this->createMock(BounceRegex::class);
+        $valid->method('getId')->willReturn(1);
+        $valid->method('getAction')->willReturn('delete');
+        $valid->method('getRegex')->willReturn('user unknown');
+        $valid->method('getRegexHash')->willReturn(md5('user unknown'));
+
+        $noRegex = $this->createMock(BounceRegex::class);
+        $noRegex->method('getId')->willReturn(2);
+
+        $noAction = $this->createMock(BounceRegex::class);
+        $noAction->method('getId')->willReturn(3);
+        $noAction->method('getRegex')->willReturn('pattern');
+        $noAction->method('getRegexHash')->willReturn(md5('pattern'));
+
+        $noId = $this->createMock(BounceRegex::class);
+        $noId->method('getRegex')->willReturn('has no id');
+        $noId->method('getRegexHash')->willReturn(md5('has no id'));
+        $noId->method('getAction')->willReturn('keep');
+
+        $this->regexRepository->expects($this->once())
+            ->method('fetchActiveOrdered')
+            ->willReturn([$valid, $noRegex, $noAction, $noId]);
+
+        $result = $this->manager->loadActiveRules();
+
+        $this->assertSame(['user unknown' => $valid], $result);
+    }
+
+    public function testLoadAllRulesDelegatesToRepository(): void
+    {
+        $rule1 = $this->createMock(BounceRegex::class);
+        $rule1->method('getId')->willReturn(10);
+        $rule1->method('getAction')->willReturn('keep');
+        $rule1->method('getRegex')->willReturn('a');
+        $rule1->method('getRegexHash')->willReturn(md5('a'));
+
+        $rule2 = $this->createMock(BounceRegex::class);
+        $rule2->method('getId')->willReturn(11);
+        $rule2->method('getAction')->willReturn('delete');
+        $rule2->method('getRegex')->willReturn('b');
+        $rule2->method('getRegexHash')->willReturn(md5('b'));
+
+        $this->regexRepository->expects($this->once())
+            ->method('fetchAllOrdered')
+            ->willReturn([$rule1, $rule2]);
+
+        $result = $this->manager->loadAllRules();
+        $this->assertSame(['a' => $rule1, 'b' => $rule2], $result);
+    }
+
+    public function testMatchBounceRulesMatchesQuotedAndRawAndHandlesInvalidPatterns(): void
+    {
+        $valid = $this->createMock(BounceRegex::class);
+        $valid->method('getId')->willReturn(1);
+        $valid->method('getAction')->willReturn('delete');
+        $valid->method('getRegex')->willReturn('user unknown');
+        $valid->method('getRegexHash')->willReturn(md5('user unknown'));
+
+        $invalid = $this->createMock(BounceRegex::class);
+        $invalid->method('getId')->willReturn(2);
+        $invalid->method('getAction')->willReturn('keep');
+        $invalid->method('getRegex')->willReturn('([a-z');
+        $invalid->method('getRegexHash')->willReturn(md5('([a-z'));
+
+        $rules = ['user unknown' => $valid, '([a-z' => $invalid];
+
+        $matched = $this->manager->matchBounceRules('Delivery failed: user    unknown at example', $rules);
+        $this->assertSame($valid, $matched);
+
+        // Ensure an invalid pattern does not throw and simply not match
+        $matchedInvalid = $this->manager->matchBounceRules('something else', ['([a-z' => $invalid]);
+        $this->assertNull($matchedInvalid);
+    }
+
+    public function testIncrementCountPersists(): void
+    {
+        $rule = new BounceRegex(regex: 'x', regexHash: md5('x'), action: 'keep', count: 0);
+        $this->setId($rule, 5);
+
+        $this->regexRepository->expects($this->once())
+            ->method('save')
+            ->with($rule);
+
+        $this->manager->incrementCount($rule);
+        $this->assertSame(1, $rule->getCount());
+    }
+
+    public function testLinkRuleToBounceCreatesRelationAndSaves(): void
+    {
+        $rule = new BounceRegex(regex: 'y', regexHash: md5('y'), action: 'delete');
+        $bounce = new Bounce();
+        $this->setId($rule, 9);
+        $this->setId($bounce, 20);
+
+        $this->relationRepository->expects($this->once())
+            ->method('save')
+            ->with($this->isInstanceOf(BounceRegexBounce::class));
+
+        $relation = $this->manager->linkRuleToBounce($rule, $bounce);
+
+        $this->assertInstanceOf(BounceRegexBounce::class, $relation);
+        $this->assertSame(9, $relation->getRegexId());
+    }
+
+    private function setId(object $entity, int $id): void
+    {
+        $ref = new \ReflectionProperty($entity, 'id');
+        $ref->setValue($entity, $id);
+    }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/ListMessageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.php
similarity index 95%
rename from tests/Unit/Domain/Messaging/Service/ListMessageManagerTest.php
rename to tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.php
index 2ec4180f..26b6fee3 100644
--- a/tests/Unit/Domain/Messaging/Service/ListMessageManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/ListMessageManagerTest.php
@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager;
 
 use DateTime;
 use Doctrine\ORM\EntityManagerInterface;
@@ -51,9 +51,6 @@ public function testAssociateMessageWithList(): void
                     && $listMessage->getEntered() instanceof DateTime;
             }));
             
-        $this->entityManager->expects($this->once())
-            ->method('flush');
-            
         $result = $this->manager->associateMessageWithList($message, $subscriberList);
         
         $this->assertInstanceOf(ListMessage::class, $result);
@@ -120,9 +117,6 @@ public function testAssociateMessageWithLists(): void
                     && $listMessage->getEntered() instanceof DateTime;
             }));
             
-        $this->entityManager->expects($this->exactly(2))
-            ->method('flush');
-            
         $this->manager->associateMessageWithLists($message, [$subscriberList1, $subscriberList2]);
     }
     
diff --git a/tests/Unit/Domain/Messaging/Service/MessageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php
similarity index 84%
rename from tests/Unit/Domain/Messaging/Service/MessageManagerTest.php
rename to tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php
index 8ee85915..ac41566f 100644
--- a/tests/Unit/Domain/Messaging/Service/MessageManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php
@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager;
 
 use PhpList\Core\Domain\Identity\Model\Administrator;
 use PhpList\Core\Domain\Messaging\Model\Dto\CreateMessageDto;
@@ -13,9 +13,10 @@
 use PhpList\Core\Domain\Messaging\Model\Dto\Message\MessageScheduleDto;
 use PhpList\Core\Domain\Messaging\Model\Dto\UpdateMessageDto;
 use PhpList\Core\Domain\Messaging\Model\Message;
+use PhpList\Core\Domain\Messaging\Model\Message\MessageContent;
 use PhpList\Core\Domain\Messaging\Repository\MessageRepository;
 use PhpList\Core\Domain\Messaging\Service\Builder\MessageBuilder;
-use PhpList\Core\Domain\Messaging\Service\MessageManager;
+use PhpList\Core\Domain\Messaging\Service\Manager\MessageManager;
 use PHPUnit\Framework\TestCase;
 
 class MessageManagerTest extends TestCase
@@ -34,7 +35,7 @@ public function testCreateMessageReturnsPersistedMessage(): void
             requeueInterval: 60 * 12,
             requeueUntil: '2025-04-20T00:00:00+00:00',
         );
-        $metadata = new MessageMetadataDto('draft');
+        $metadata = new MessageMetadataDto(Message\MessageStatus::Draft);
         $content = new MessageContentDto('Subject', 'Full text', 'Short text', 'Footer');
         $options = new MessageOptionsDto('from@example.com', 'to@example.com', 'reply@example.com', 'all-users');
 
@@ -50,11 +51,11 @@ public function testCreateMessageReturnsPersistedMessage(): void
         $authUser = $this->createMock(Administrator::class);
 
         $expectedMessage = $this->createMock(Message::class);
-        $expectedContent = $this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class);
+        $expectedContent = $this->createMock(MessageContent::class);
         $expectedMetadata = $this->createMock(Message\MessageMetadata::class);
 
         $expectedContent->method('getSubject')->willReturn('Subject');
-        $expectedMetadata->method('getStatus')->willReturn('draft');
+        $expectedMetadata->method('getStatus')->willReturn(Message\MessageStatus::Draft);
 
         $expectedMessage->method('getContent')->willReturn($expectedContent);
         $expectedMessage->method('getMetadata')->willReturn($expectedMetadata);
@@ -65,13 +66,13 @@ public function testCreateMessageReturnsPersistedMessage(): void
             ->willReturn($expectedMessage);
 
         $messageRepository->expects($this->once())
-            ->method('save')
+            ->method('persist')
             ->with($expectedMessage);
 
         $message = $manager->createMessage($request, $authUser);
 
         $this->assertSame('Subject', $message->getContent()->getSubject());
-        $this->assertSame('draft', $message->getMetadata()->getStatus());
+        $this->assertSame(Message\MessageStatus::Draft, $message->getMetadata()->getStatus());
     }
 
     public function testUpdateMessageReturnsUpdatedMessage(): void
@@ -88,7 +89,7 @@ public function testUpdateMessageReturnsUpdatedMessage(): void
             requeueInterval: 0,
             requeueUntil: '2025-04-20T00:00:00+00:00',
         );
-        $metadata = new MessageMetadataDto('draft');
+        $metadata = new MessageMetadataDto(Message\MessageStatus::Draft);
         $content = new MessageContentDto(
             'Updated Subject',
             'Updated Full text',
@@ -115,11 +116,11 @@ public function testUpdateMessageReturnsUpdatedMessage(): void
         $authUser = $this->createMock(Administrator::class);
 
         $existingMessage = $this->createMock(Message::class);
-        $expectedContent = $this->createMock(\PhpList\Core\Domain\Messaging\Model\Message\MessageContent::class);
+        $expectedContent = $this->createMock(MessageContent::class);
         $expectedMetadata = $this->createMock(Message\MessageMetadata::class);
 
         $expectedContent->method('getSubject')->willReturn('Updated Subject');
-        $expectedMetadata->method('getStatus')->willReturn('draft');
+        $expectedMetadata->method('getStatus')->willReturn(Message\MessageStatus::Draft);
 
         $existingMessage->method('getContent')->willReturn($expectedContent);
         $existingMessage->method('getMetadata')->willReturn($expectedMetadata);
@@ -129,13 +130,9 @@ public function testUpdateMessageReturnsUpdatedMessage(): void
             ->with($updateRequest, $this->anything())
             ->willReturn($existingMessage);
 
-        $messageRepository->expects($this->once())
-            ->method('save')
-            ->with($existingMessage);
-
         $message = $manager->updateMessage($updateRequest, $existingMessage, $authUser);
 
         $this->assertSame('Updated Subject', $message->getContent()->getSubject());
-        $this->assertSame('draft', $message->getMetadata()->getStatus());
+        $this->assertSame(Message\MessageStatus::Draft, $message->getMetadata()->getStatus());
     }
 }
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..1f8c9276
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/Manager/SendProcessManagerTest.php
@@ -0,0 +1,85 @@
+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));
+
+        $sp = $this->manager->create('pageA', 'proc-1');
+        $this->assertInstanceOf(SendProcess::class, $sp);
+        $this->assertSame('pageA', $sp->getPage());
+        $this->assertSame('proc-1', $sp->getIpaddress());
+        $this->assertSame(1, $sp->getAlive());
+        $this->assertInstanceOf(DateTime::class, $sp->getStartedDate());
+    }
+
+    public function testFindNewestAliveWithAgeReturnsNullWhenNotFound(): void
+    {
+        $this->repository->expects($this->once())
+            ->method('findNewestAlive')
+            ->with('pageX')
+            ->willReturn(null);
+
+        $this->assertNull($this->manager->findNewestAliveWithAge('pageX'));
+    }
+
+    public function testFindNewestAliveWithAgeReturnsIdAndAge(): void
+    {
+        $model = new SendProcess();
+        // set id
+        $this->setId($model, 42);
+        // set updatedAt to now - 5 seconds
+        $updated = new \DateTime('now');
+        $updated->sub(new DateInterval('PT5S'));
+        $this->setUpdatedAt($model, $updated);
+
+        $this->repository->expects($this->once())
+            ->method('findNewestAlive')
+            ->with('pageY')
+            ->willReturn($model);
+
+        $result = $this->manager->findNewestAliveWithAge('pageY');
+
+        $this->assertIsArray($result);
+        $this->assertSame(42, $result['id']);
+        $this->assertGreaterThanOrEqual(0, $result['age']);
+        $this->assertLessThan(60, $result['age']);
+    }
+
+    private function setId(object $entity, int $id): void
+    {
+        $ref = new \ReflectionProperty($entity, 'id');
+        $ref->setValue($entity, $id);
+    }
+
+    private function setUpdatedAt(SendProcess $entity, \DateTime $dt): void
+    {
+        $ref = new \ReflectionProperty($entity, 'updatedAt');
+        $ref->setValue($entity, $dt);
+    }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php
similarity index 90%
rename from tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php
rename to tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php
index bde3569a..932e0d8a 100644
--- a/tests/Unit/Domain/Messaging/Service/TemplateImageManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php
@@ -2,13 +2,13 @@
 
 declare(strict_types=1);
 
-namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager;
 
 use Doctrine\ORM\EntityManagerInterface;
 use PhpList\Core\Domain\Messaging\Model\Template;
 use PhpList\Core\Domain\Messaging\Model\TemplateImage;
 use PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository;
-use PhpList\Core\Domain\Messaging\Service\TemplateImageManager;
+use PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 
@@ -24,8 +24,8 @@ protected function setUp(): void
         $this->entityManager = $this->createMock(EntityManagerInterface::class);
 
         $this->manager = new TemplateImageManager(
-            $this->templateImageRepository,
-            $this->entityManager
+            templateImageRepository: $this->templateImageRepository,
+            entityManager: $this->entityManager
         );
     }
 
@@ -37,9 +37,6 @@ public function testCreateImagesFromImagePaths(): void
             ->method('persist')
             ->with($this->isInstanceOf(TemplateImage::class));
 
-        $this->entityManager->expects($this->once())
-            ->method('flush');
-
         $images = $this->manager->createImagesFromImagePaths(['image1.jpg', 'image2.png'], $template);
 
         $this->assertCount(2, $images);
diff --git a/tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php
similarity index 89%
rename from tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php
rename to tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php
index fbbb4831..4f6544c2 100644
--- a/tests/Unit/Domain/Messaging/Service/TemplateManagerTest.php
+++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php
@@ -2,14 +2,13 @@
 
 declare(strict_types=1);
 
-namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service;
+namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager;
 
-use Doctrine\ORM\EntityManagerInterface;
 use PhpList\Core\Domain\Messaging\Model\Dto\CreateTemplateDto;
 use PhpList\Core\Domain\Messaging\Model\Template;
 use PhpList\Core\Domain\Messaging\Repository\TemplateRepository;
-use PhpList\Core\Domain\Messaging\Service\TemplateImageManager;
-use PhpList\Core\Domain\Messaging\Service\TemplateManager;
+use PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager;
+use PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager;
 use PhpList\Core\Domain\Messaging\Validator\TemplateImageValidator;
 use PhpList\Core\Domain\Messaging\Validator\TemplateLinkValidator;
 use PHPUnit\Framework\MockObject\MockObject;
@@ -26,14 +25,12 @@ class TemplateManagerTest extends TestCase
     protected function setUp(): void
     {
         $this->templateRepository = $this->createMock(TemplateRepository::class);
-        $entityManager = $this->createMock(EntityManagerInterface::class);
         $this->templateImageManager = $this->createMock(TemplateImageManager::class);
         $this->templateLinkValidator = $this->createMock(TemplateLinkValidator::class);
         $this->templateImageValidator = $this->createMock(TemplateImageValidator::class);
 
         $this->manager = new TemplateManager(
             $this->templateRepository,
-            $entityManager,
             $this->templateImageManager,
             $this->templateLinkValidator,
             $this->templateImageValidator
@@ -66,7 +63,7 @@ public function testCreateTemplateSuccessfully(): void
             ->with([], $this->anything());
 
         $this->templateRepository->expects($this->once())
-            ->method('save')
+            ->method('persist')
             ->with($this->isInstanceOf(Template::class));
 
         $this->templateImageManager->expects($this->once())
diff --git a/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php b/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php
new file mode 100644
index 00000000..57b2f07f
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/MaxProcessTimeLimiterTest.php
@@ -0,0 +1,54 @@
+logger = $this->createMock(LoggerInterface::class);
+    }
+
+    public function testShouldNotStopWhenMaxSecondsIsZero(): void
+    {
+        $limiter = new MaxProcessTimeLimiter(logger: $this->logger, translator: new Translator('en'), maxSeconds: 0);
+
+        $output = $this->createMock(OutputInterface::class);
+        $output->expects($this->never())->method('writeln');
+        $this->logger->expects($this->never())->method('warning');
+
+        $limiter->start();
+        usleep(200_000);
+        $this->assertFalse($limiter->shouldStop($output));
+    }
+
+    public function testShouldStopAfterThresholdAndLogAndOutput(): void
+    {
+        $limiter = new MaxProcessTimeLimiter(logger: $this->logger, translator:  new Translator('en'), maxSeconds: 1);
+
+        $output = $this->createMock(OutputInterface::class);
+        $output->expects($this->once())
+            ->method('writeln')
+            ->with('Reached max processing time; stopping cleanly.');
+
+        $this->logger->expects($this->once())
+            ->method('warning')
+            ->with($this->stringContains('Reached max processing time of 1 seconds'));
+
+        $this->assertFalse($limiter->shouldStop($output));
+
+        usleep(1_200_000);
+        $this->assertTrue($limiter->shouldStop($output));
+    }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php
index c2c0d0a5..f2a29d49 100644
--- a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php
+++ b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php
@@ -4,7 +4,6 @@
 
 namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service;
 
-use Doctrine\ORM\EntityManagerInterface;
 use PhpList\Core\Domain\Analytics\Model\LinkTrack;
 use PhpList\Core\Domain\Analytics\Service\LinkTrackService;
 use PhpList\Core\Domain\Messaging\Model\Message\MessageContent;
@@ -16,10 +15,10 @@
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Translation\Translator;
 
 class MessageProcessingPreparatorTest extends TestCase
 {
-    private EntityManagerInterface&MockObject $entityManager;
     private SubscriberRepository&MockObject $subscriberRepository;
     private MessageRepository&MockObject $messageRepository;
     private LinkTrackService&MockObject $linkTrackService;
@@ -28,17 +27,16 @@ class MessageProcessingPreparatorTest extends TestCase
 
     protected function setUp(): void
     {
-        $this->entityManager = $this->createMock(EntityManagerInterface::class);
         $this->subscriberRepository = $this->createMock(SubscriberRepository::class);
         $this->messageRepository = $this->createMock(MessageRepository::class);
         $this->linkTrackService = $this->createMock(LinkTrackService::class);
         $this->output = $this->createMock(OutputInterface::class);
 
         $this->preparator = new MessageProcessingPreparator(
-            $this->entityManager,
-            $this->subscriberRepository,
-            $this->messageRepository,
-            $this->linkTrackService
+            subscriberRepository: $this->subscriberRepository,
+            messageRepository: $this->messageRepository,
+            linkTrackService: $this->linkTrackService,
+            translator: new Translator('en'),
         );
     }
 
@@ -51,9 +49,6 @@ public function testEnsureSubscribersHaveUuidWithNoSubscribers(): void
         $this->output->expects($this->never())
             ->method('writeln');
 
-        $this->entityManager->expects($this->never())
-            ->method('flush');
-
         $this->preparator->ensureSubscribersHaveUuid($this->output);
     }
 
@@ -80,9 +75,6 @@ public function testEnsureSubscribersHaveUuidWithSubscribers(): void
             ->method('setUniqueId')
             ->with($this->isType('string'));
 
-        $this->entityManager->expects($this->once())
-            ->method('flush');
-
         $this->preparator->ensureSubscribersHaveUuid($this->output);
     }
 
@@ -95,9 +87,6 @@ public function testEnsureCampaignsHaveUuidWithNoCampaigns(): void
         $this->output->expects($this->never())
             ->method('writeln');
 
-        $this->entityManager->expects($this->never())
-            ->method('flush');
-
         $this->preparator->ensureCampaignsHaveUuid($this->output);
     }
 
@@ -124,9 +113,6 @@ public function testEnsureCampaignsHaveUuidWithCampaigns(): void
             ->method('setUuid')
             ->with($this->isType('string'));
 
-        $this->entityManager->expects($this->once())
-            ->method('flush');
-
         $this->preparator->ensureCampaignsHaveUuid($this->output);
     }
 
@@ -189,7 +175,10 @@ public function testProcessMessageLinksWithLinksExtracted(): void
         $savedLinks = [$linkTrack1, $linkTrack2];
 
         $this->linkTrackService->method('isExtractAndSaveLinksApplicable')->willReturn(true);
-        $this->linkTrackService->method('extractAndSaveLinks')->with($message, $userId)->willReturn($savedLinks);
+        $this->linkTrackService
+            ->method('extractAndSaveLinks')
+            ->with($message, $userId)
+            ->willReturn($savedLinks);
 
         $message->method('getContent')->willReturn($content);
 
diff --git a/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php b/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php
new file mode 100644
index 00000000..6e2011ff
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/RateLimitedCampaignMailerTest.php
@@ -0,0 +1,134 @@
+mailer = $this->createMock(MailerInterface::class);
+        $this->limiter = $this->createMock(SendRateLimiter::class);
+        $this->sut = new RateLimitedCampaignMailer($this->mailer, $this->limiter);
+    }
+
+    public function testComposeEmailSetsHeadersAndBody(): void
+    {
+        $message = $this->buildMessage(
+            subject: 'Subject',
+            textBody: 'Plain text',
+            htmlBody: 'HTML
',
+            from: 'from@example.com',
+            replyTo: 'reply@example.com'
+        );
+
+        $subscriber = new Subscriber();
+        $this->setSubscriberEmail($subscriber, 'user@example.com');
+
+        $email = $this->sut->composeEmail($message, $subscriber);
+
+        $this->assertInstanceOf(Email::class, $email);
+        $this->assertSame('user@example.com', $email->getTo()[0]->getAddress());
+        $this->assertSame('Subject', $email->getSubject());
+        $this->assertSame('from@example.com', $email->getFrom()[0]->getAddress());
+        $this->assertSame('reply@example.com', $email->getReplyTo()[0]->getAddress());
+        $this->assertSame('Plain text', $email->getTextBody());
+        $this->assertSame('HTML
', $email->getHtmlBody());
+    }
+
+    public function testComposeEmailWithoutOptionalHeaders(): void
+    {
+        $message = $this->buildMessage(
+            subject: 'No headers',
+            textBody: 'text',
+            htmlBody: 'h ',
+            from: '',
+            replyTo: ''
+        );
+
+        $subscriber = new Subscriber();
+        $this->setSubscriberEmail($subscriber, 'user2@example.com');
+
+        $email = $this->sut->composeEmail($message, $subscriber);
+
+        $this->assertSame('user2@example.com', $email->getTo()[0]->getAddress());
+        $this->assertSame('No headers', $email->getSubject());
+        $this->assertSame([], $email->getFrom());
+        $this->assertSame([], $email->getReplyTo());
+    }
+
+    public function testSendUsesLimiterAroundMailer(): void
+    {
+        $email = (new Email())->to('someone@example.com');
+
+        $this->limiter->expects($this->once())->method('awaitTurn');
+        $this->mailer
+            ->expects($this->once())
+            ->method('send')
+            ->with($this->isInstanceOf(Email::class));
+        $this->limiter->expects($this->once())->method('afterSend');
+
+        $this->sut->send($email);
+    }
+
+    private function buildMessage(
+        string $subject,
+        string $textBody,
+        string $htmlBody,
+        string $from,
+        string $replyTo
+    ): Message {
+        $content = new MessageContent(
+            subject: $subject,
+            text: $htmlBody,
+            textMessage: $textBody,
+            footer: null,
+        );
+        $format = new MessageFormat(
+            htmlFormatted: true,
+            sendFormat: MessageFormat::FORMAT_HTML,
+            formatOptions: [MessageFormat::FORMAT_HTML]
+        );
+        $schedule = new MessageSchedule(
+            repeatInterval: 0,
+            repeatUntil: null,
+            requeueInterval: 0,
+            requeueUntil: null,
+            embargo: null
+        );
+        $metadata = new MessageMetadata();
+        $options = new MessageOptions(fromField: $from, toField: '', replyTo: $replyTo);
+
+        return new Message($format, $schedule, $metadata, $content, $options, null, null);
+    }
+
+    /**
+     * Subscriber has no public setter for email, so we use reflection.
+     */
+    private function setSubscriberEmail(Subscriber $subscriber, string $email): void
+    {
+        $ref = new ReflectionProperty($subscriber, 'email');
+        $ref->setValue($subscriber, $email);
+    }
+}
diff --git a/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php b/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php
new file mode 100644
index 00000000..e29f6929
--- /dev/null
+++ b/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php
@@ -0,0 +1,94 @@
+ispProvider = $this->createMock(IspRestrictionsProvider::class);
+    }
+
+    public function testInitializesLimitsFromConfigOnly(): void
+    {
+        $this->ispProvider->method('load')->willReturn(new IspRestrictions(null, null, null));
+        $limiter = new SendRateLimiter(
+            ispRestrictionsProvider: $this->ispProvider,
+            userMessageRepository: $this->createMock(UserMessageRepository::class),
+            translator: new Translator('en'),
+            mailqueueBatchSize: 5,
+            mailqueueBatchPeriod: 10,
+            mailqueueThrottle: 2
+        );
+
+        $output = $this->createMock(OutputInterface::class);
+        $output->expects($this->never())->method('writeln');
+
+        $this->assertTrue($limiter->awaitTurn($output));
+    }
+
+    public function testBatchLimitTriggersWaitMessageAndResetsCounters(): void
+    {
+        $this->ispProvider->method('load')->willReturn(new IspRestrictions(2, 1, null));
+        $limiter = new SendRateLimiter(
+            ispRestrictionsProvider: $this->ispProvider,
+            userMessageRepository: $this->createMock(UserMessageRepository::class),
+            translator: new Translator('en'),
+            mailqueueBatchSize: 10,
+            mailqueueBatchPeriod: 1,
+            mailqueueThrottle: 0
+        );
+
+        $limiter->afterSend();
+        $limiter->afterSend();
+
+        $output = $this->createMock(OutputInterface::class);
+        // We cannot reliably assert the exact second, but we assert a message called at least once
+        $output->expects($this->atLeast(0))->method('writeln');
+
+        // Now awaitTurn should detect batch full and attempt to sleep and reset.
+        $this->assertTrue($limiter->awaitTurn($output));
+
+        // Next afterSend should increase the counter again without exception
+        $limiter->afterSend();
+        // Reaching here means no fatal due to internal counter/reset logic
+        $this->assertTrue(true);
+    }
+
+    public function testThrottleSleepsPerMessagePathIsCallable(): void
+    {
+        $this->ispProvider->method('load')->willReturn(new IspRestrictions(null, null, null));
+        $limiter = new SendRateLimiter(
+            ispRestrictionsProvider: $this->ispProvider,
+            userMessageRepository: $this->createMock(UserMessageRepository::class),
+            translator: new Translator('en'),
+            mailqueueBatchSize: 0,
+            mailqueueBatchPeriod: 0,
+            mailqueueThrottle: 1
+        );
+
+        // We cannot speed up sleep without extensions; just call method to ensure no exceptions
+        $start = microtime(true);
+        $limiter->afterSend();
+        $elapsed = microtime(true) - $start;
+
+        // Ensure it likely slept at least ~0.5s
+        if ($elapsed < 0.3) {
+            $this->markTestIncomplete('Environment too fast to detect sleep; logic path executed.');
+        }
+        $this->assertTrue(true);
+    }
+}
diff --git a/tests/Unit/Domain/Messaging/Validator/TemplateImageValidatorTest.php b/tests/Unit/Domain/Messaging/Validator/TemplateImageValidatorTest.php
index 88af2c8c..40e1064a 100644
--- a/tests/Unit/Domain/Messaging/Validator/TemplateImageValidatorTest.php
+++ b/tests/Unit/Domain/Messaging/Validator/TemplateImageValidatorTest.php
@@ -12,6 +12,7 @@
 use PhpList\Core\Domain\Messaging\Validator\TemplateImageValidator;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
 use Symfony\Component\Validator\Exception\ValidatorException;
 
 class TemplateImageValidatorTest extends TestCase
@@ -22,7 +23,7 @@ class TemplateImageValidatorTest extends TestCase
     protected function setUp(): void
     {
         $this->httpClient = $this->createMock(ClientInterface::class);
-        $this->validator = new TemplateImageValidator($this->httpClient);
+        $this->validator = new TemplateImageValidator($this->httpClient, new Translator('en'));
     }
 
     public function testThrowsExceptionIfValueIsNotArray(): void
diff --git a/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php b/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php
index d0ab6566..5767f193 100644
--- a/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php
+++ b/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php
@@ -7,6 +7,7 @@
 use PhpList\Core\Domain\Common\Model\ValidationContext;
 use PhpList\Core\Domain\Messaging\Validator\TemplateLinkValidator;
 use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
 use Symfony\Component\Validator\Exception\ValidatorException;
 
 class TemplateLinkValidatorTest extends TestCase
@@ -15,7 +16,7 @@ class TemplateLinkValidatorTest extends TestCase
 
     protected function setUp(): void
     {
-        $this->validator = new TemplateLinkValidator();
+        $this->validator = new TemplateLinkValidator(new Translator('en'));
     }
 
     public function testSkipsValidationIfNotString(): void
diff --git a/tests/Unit/Domain/Subscription/Model/SubscriberTest.php b/tests/Unit/Domain/Subscription/Model/SubscriberTest.php
index 5a60c5de..0827f7fd 100644
--- a/tests/Unit/Domain/Subscription/Model/SubscriberTest.php
+++ b/tests/Unit/Domain/Subscription/Model/SubscriberTest.php
@@ -56,9 +56,9 @@ public function testUpdateCreationDateSetsCreationDateToNow(): void
         self::assertSimilarDates(new \DateTime(), $this->subscriber->getCreatedAt());
     }
 
-    public function testgetUpdatedAtInitiallyReturnsNull(): void
+    public function testGetUpdatedAtInitiallyReturnsNotNull(): void
     {
-        self::assertNull($this->subscriber->getUpdatedAt());
+        self::assertNotNull($this->subscriber->getUpdatedAt());
     }
 
     public function testUpdateModificationDateSetsModificationDateToNow(): void
diff --git a/tests/Unit/Domain/Subscription/Repository/DynamicListAttrRepositoryTest.php b/tests/Unit/Domain/Subscription/Repository/DynamicListAttrRepositoryTest.php
new file mode 100644
index 00000000..948c8347
--- /dev/null
+++ b/tests/Unit/Domain/Subscription/Repository/DynamicListAttrRepositoryTest.php
@@ -0,0 +1,147 @@
+createMock(Connection::class);
+        $repo = new DynamicListAttrRepository($conn, 'phplist_');
+
+        $this->assertSame([], $repo->fetchOptionNames('valid_table', []));
+        $this->assertSame([], $repo->fetchOptionNames('valid_table', []));
+    }
+
+    public function testFetchOptionNamesThrowsOnInvalidTable(): void
+    {
+        $conn = $this->createMock(Connection::class);
+        $repo = new DynamicListAttrRepository($conn, 'phplist_');
+
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('Invalid list table');
+
+        $repo->fetchOptionNames('invalid-table;', [1, 2]);
+    }
+
+    public function testFetchOptionNamesReturnsNames(): void
+    {
+        $conn = $this->createMock(Connection::class);
+
+        $qb = $this->getMockBuilder(QueryBuilder::class)
+            ->disableOriginalConstructor()
+            ->onlyMethods(['select', 'from', 'where', 'setParameter', 'executeQuery'])
+            ->getMock();
+
+        $qb->expects($this->once())
+            ->method('select')
+            ->with('name')
+            ->willReturnSelf();
+
+        $qb->expects($this->once())
+            ->method('from')
+            ->with('phplist_listattr_users')
+            ->willReturnSelf();
+
+        $qb->expects($this->once())
+            ->method('where')
+            ->with('id IN (:ids)')
+            ->willReturnSelf();
+
+        // Expect integer coercion of IDs and correct array parameter type
+        $qb->expects($this->once())
+            ->method('setParameter')
+            ->with(
+                'ids',
+                [1, 2, 3],
+                ArrayParameterType::INTEGER
+            )
+            ->willReturnSelf();
+
+        // Mock Result
+        $result = $this->createMock(Result::class);
+        $result->expects($this->once())
+            ->method('fetchFirstColumn')
+            ->willReturn(['alpha', 'beta', 'gamma']);
+
+        $qb->expects($this->once())
+            ->method('executeQuery')
+            ->willReturn($result);
+
+        $conn->method('createQueryBuilder')->willReturn($qb);
+
+        $repo = new DynamicListAttrRepository($conn, 'phplist_');
+        $names = $repo->fetchOptionNames('users', [1, '2', 3]);
+
+        $this->assertSame(['alpha', 'beta', 'gamma'], $names);
+    }
+
+    public function testFetchSingleOptionNameThrowsOnInvalidTable(): void
+    {
+        $conn = $this->createMock(Connection::class);
+        $repo = new DynamicListAttrRepository($conn, 'phplist_');
+
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('Invalid list table');
+
+        $repo->fetchSingleOptionName('bad name!', 10);
+    }
+
+    public function testFetchSingleOptionNameReturnsString(): void
+    {
+        $conn = $this->createMock(Connection::class);
+
+        $qb = $this->getMockBuilder(QueryBuilder::class)
+            ->disableOriginalConstructor()
+            ->onlyMethods(['select', 'from', 'where', 'setParameter', 'executeQuery'])
+            ->getMock();
+
+        $qb->expects($this->once())->method('select')->with('name')->willReturnSelf();
+        $qb->expects($this->once())->method('from')->with('phplist_listattr_ukcountries')->willReturnSelf();
+        $qb->expects($this->once())->method('where')->with('id = :id')->willReturnSelf();
+        $qb->expects($this->once())->method('setParameter')->with('id', 42)->willReturnSelf();
+
+        $result = $this->createMock(Result::class);
+        $result->expects($this->once())->method('fetchOne')->willReturn('Bradford');
+
+        $qb->expects($this->once())->method('executeQuery')->willReturn($result);
+        $conn->method('createQueryBuilder')->willReturn($qb);
+
+        $repo = new DynamicListAttrRepository($conn, 'phplist_');
+        $this->assertSame('Bradford', $repo->fetchSingleOptionName('ukcountries', 42));
+    }
+
+    public function testFetchSingleOptionNameReturnsNullWhenNotFound(): void
+    {
+        $conn = $this->createMock(Connection::class);
+
+        $qb = $this->getMockBuilder(QueryBuilder::class)
+            ->disableOriginalConstructor()
+            ->onlyMethods(['select', 'from', 'where', 'setParameter', 'executeQuery'])
+            ->getMock();
+
+        $qb->method('select')->with('name')->willReturnSelf();
+        $qb->method('from')->with('phplist_listattr_termsofservices')->willReturnSelf();
+        $qb->method('where')->with('id = :id')->willReturnSelf();
+        $qb->method('setParameter')->with('id', 999)->willReturnSelf();
+
+        $result = $this->createMock(Result::class);
+        $result->expects($this->once())->method('fetchOne')->willReturn(false);
+
+        $qb->method('executeQuery')->willReturn($result);
+        $conn->method('createQueryBuilder')->willReturn($qb);
+
+        $repo = new DynamicListAttrRepository($conn, 'phplist_');
+        $this->assertNull($repo->fetchSingleOptionName('termsofservices', 999));
+    }
+}
diff --git a/tests/Unit/Domain/Subscription/Service/AttributeValueResolverTest.php b/tests/Unit/Domain/Subscription/Service/AttributeValueResolverTest.php
new file mode 100644
index 00000000..515557c8
--- /dev/null
+++ b/tests/Unit/Domain/Subscription/Service/AttributeValueResolverTest.php
@@ -0,0 +1,83 @@
+createMock(SubscriberAttributeDefinition::class);
+        $userAttr = $this->createMock(SubscriberAttributeValue::class);
+        $userAttr->method('getAttributeDefinition')->willReturn($def);
+
+        $p1 = $this->createMock(AttributeValueProvider::class);
+        $p1->expects($this->once())->method('supports')->with($def)->willReturn(false);
+        $p1->expects($this->never())->method('getValue');
+
+        $p2 = $this->createMock(AttributeValueProvider::class);
+        $p2->expects($this->once())->method('supports')->with($def)->willReturn(false);
+        $p2->expects($this->never())->method('getValue');
+
+        $resolver = new AttributeValueResolver([$p1, $p2]);
+
+        self::assertSame('', $resolver->resolve($userAttr));
+    }
+
+    public function testResolveReturnsValueFromFirstSupportingProvider(): void
+    {
+        $def = $this->createMock(SubscriberAttributeDefinition::class);
+        $userAttr = $this->createMock(SubscriberAttributeValue::class);
+        $userAttr->method('getAttributeDefinition')->willReturn($def);
+
+        $nonSupporting = $this->createMock(AttributeValueProvider::class);
+        $nonSupporting->expects($this->once())->method('supports')->with($def)->willReturn(false);
+        $nonSupporting->expects($this->never())->method('getValue');
+
+        $supporting = $this->createMock(AttributeValueProvider::class);
+        $supporting->expects($this->once())->method('supports')->with($def)->willReturn(true);
+        $supporting->expects($this->once())
+            ->method('getValue')
+            ->with($def, $userAttr)
+            ->willReturn('Resolved Value');
+
+        // This provider should never be interrogated because resolver exits early.
+        $afterFirstMatch = $this->createMock(AttributeValueProvider::class);
+        $afterFirstMatch->expects($this->never())->method('supports');
+        $afterFirstMatch->expects($this->never())->method('getValue');
+
+        $resolver = new AttributeValueResolver([$nonSupporting, $supporting, $afterFirstMatch]);
+
+        self::assertSame('Resolved Value', $resolver->resolve($userAttr));
+    }
+
+    public function testResolveHonorsProviderOrderFirstMatchWins(): void
+    {
+        $def = $this->createMock(SubscriberAttributeDefinition::class);
+        $userAttr = $this->createMock(SubscriberAttributeValue::class);
+        $userAttr->method('getAttributeDefinition')->willReturn($def);
+
+        $firstSupporting = $this->createMock(AttributeValueProvider::class);
+        $firstSupporting->expects($this->once())->method('supports')->with($def)->willReturn(true);
+        $firstSupporting->expects($this->once())
+            ->method('getValue')
+            ->with($def, $userAttr)
+            ->willReturn('first');
+
+        $secondSupporting = $this->createMock(AttributeValueProvider::class);
+        // Must not be called because the first already matched
+        $secondSupporting->expects($this->never())->method('supports');
+        $secondSupporting->expects($this->never())->method('getValue');
+
+        $resolver = new AttributeValueResolver([$firstSupporting, $secondSupporting]);
+
+        self::assertSame('first', $resolver->resolve($userAttr));
+    }
+}
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php
index 279a6ff7..6a047051 100644
--- a/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php
@@ -11,6 +11,7 @@
 use PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager;
 use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator;
 use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
 
 class AttributeDefinitionManagerTest extends TestCase
 {
@@ -18,7 +19,11 @@ public function testCreateAttributeDefinition(): void
     {
         $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class);
         $validator = $this->createMock(AttributeTypeValidator::class);
-        $manager = new AttributeDefinitionManager($repository, $validator);
+        $manager = new AttributeDefinitionManager(
+            definitionRepository: $repository,
+            attributeTypeValidator: $validator,
+            translator: new Translator('en')
+        );
 
         $dto = new AttributeDefinitionDto(
             name: 'Country',
@@ -34,7 +39,7 @@ public function testCreateAttributeDefinition(): void
             ->with('Country')
             ->willReturn(null);
 
-        $repository->expects($this->once())->method('save');
+        $repository->expects($this->once())->method('persist');
 
         $attribute = $manager->create($dto);
 
@@ -51,7 +56,11 @@ public function testCreateThrowsWhenAttributeAlreadyExists(): void
     {
         $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class);
         $validator = $this->createMock(AttributeTypeValidator::class);
-        $manager = new AttributeDefinitionManager($repository, $validator);
+        $manager = new AttributeDefinitionManager(
+            definitionRepository: $repository,
+            attributeTypeValidator: $validator,
+            translator: new Translator('en'),
+        );
 
         $dto = new AttributeDefinitionDto(
             name: 'Country',
@@ -78,7 +87,11 @@ public function testUpdateAttributeDefinition(): void
     {
         $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class);
         $validator = $this->createMock(AttributeTypeValidator::class);
-        $manager = new AttributeDefinitionManager($repository, $validator);
+        $manager = new AttributeDefinitionManager(
+            definitionRepository: $repository,
+            attributeTypeValidator: $validator,
+            translator: new Translator('en'),
+        );
 
         $attribute = new SubscriberAttributeDefinition();
         $attribute->setName('Old');
@@ -97,8 +110,6 @@ public function testUpdateAttributeDefinition(): void
             ->with('New')
             ->willReturn(null);
 
-        $repository->expects($this->once())->method('save')->with($attribute);
-
         $updated = $manager->update($attribute, $dto);
 
         $this->assertSame('New', $updated->getName());
@@ -113,7 +124,11 @@ public function testUpdateThrowsWhenAnotherAttributeWithSameNameExists(): void
     {
         $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class);
         $validator = $this->createMock(AttributeTypeValidator::class);
-        $manager = new AttributeDefinitionManager($repository, $validator);
+        $manager = new AttributeDefinitionManager(
+            definitionRepository: $repository,
+            attributeTypeValidator: $validator,
+            translator: new Translator('en'),
+        );
 
         $dto = new AttributeDefinitionDto(
             name: 'Existing',
@@ -144,7 +159,11 @@ public function testDeleteAttributeDefinition(): void
     {
         $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class);
         $validator = $this->createMock(AttributeTypeValidator::class);
-        $manager = new AttributeDefinitionManager($repository, $validator);
+        $manager = new AttributeDefinitionManager(
+            definitionRepository: $repository,
+            attributeTypeValidator: $validator,
+            translator: new Translator('en'),
+        );
 
         $attribute = new SubscriberAttributeDefinition();
 
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php
new file mode 100644
index 00000000..0c29242a
--- /dev/null
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php
@@ -0,0 +1,228 @@
+pageRepository = $this->createMock(SubscriberPageRepository::class);
+        $this->pageDataRepository = $this->createMock(SubscriberPageDataRepository::class);
+        $this->entityManager = $this->createMock(EntityManagerInterface::class);
+
+        $this->manager = new SubscribePageManager(
+            pageRepository: $this->pageRepository,
+            pageDataRepository: $this->pageDataRepository,
+            entityManager: $this->entityManager,
+            translator: new Translator('en'),
+        );
+    }
+
+    public function testCreatePageCreatesAndSaves(): void
+    {
+        $owner = new Administrator();
+        $this->pageRepository
+            ->expects($this->once())
+            ->method('persist')
+            ->with($this->isInstanceOf(SubscribePage::class));
+
+        $page = $this->manager->createPage('My Page', true, $owner);
+
+        $this->assertInstanceOf(SubscribePage::class, $page);
+        $this->assertSame('My Page', $page->getTitle());
+        $this->assertTrue($page->isActive());
+        $this->assertSame($owner, $page->getOwner());
+    }
+
+    public function testGetPageReturnsPage(): void
+    {
+        $page = new SubscribePage();
+        $this->pageRepository
+            ->expects($this->once())
+            ->method('find')
+            ->with(123)
+            ->willReturn($page);
+
+        $result = $this->manager->getPage(123);
+
+        $this->assertSame($page, $result);
+    }
+
+    public function testGetPageThrowsWhenNotFound(): void
+    {
+        $this->pageRepository
+            ->expects($this->once())
+            ->method('find')
+            ->with(999)
+            ->willReturn(null);
+
+        $this->expectException(NotFoundHttpException::class);
+        $this->expectExceptionMessage('Subscribe page not found');
+
+        $this->manager->getPage(999);
+    }
+
+    public function testUpdatePageUpdatesProvidedFieldsAndFlushes(): void
+    {
+        $originalOwner = new Administrator();
+        $newOwner = new Administrator();
+        $page = (new SubscribePage())
+            ->setTitle('Old Title')
+            ->setActive(false)
+            ->setOwner($originalOwner);
+
+        $this->entityManager
+            ->expects($this->never())
+            ->method('flush');
+
+        $updated = $this->manager->updatePage($page, title: 'New Title', active: true, owner: $newOwner);
+
+        $this->assertSame($page, $updated);
+        $this->assertSame('New Title', $updated->getTitle());
+        $this->assertTrue($updated->isActive());
+        $this->assertSame($newOwner, $updated->getOwner());
+    }
+
+    public function testUpdatePageLeavesNullFieldsUntouched(): void
+    {
+        $owner = new Administrator();
+        $page = (new SubscribePage())
+            ->setTitle('Keep Title')
+            ->setActive(true)
+            ->setOwner($owner);
+
+        $this->entityManager
+            ->expects($this->never())
+            ->method('flush');
+
+        $updated = $this->manager->updatePage(page: $page, title: null, active: null, owner: null);
+
+        $this->assertSame('Keep Title', $updated->getTitle());
+        $this->assertTrue($updated->isActive());
+        $this->assertSame($owner, $updated->getOwner());
+    }
+
+    public function testSetActiveSetsFlagButNDoesotFlush(): void
+    {
+        $page = (new SubscribePage())
+            ->setTitle('Any')
+            ->setActive(false);
+
+        $this->entityManager
+            ->expects($this->never())
+            ->method('flush');
+
+        $this->manager->setActive($page, true);
+        $this->assertTrue($page->isActive());
+    }
+
+    public function testDeletePageCallsRepositoryRemove(): void
+    {
+        $page = new SubscribePage();
+
+        $this->pageRepository
+            ->expects($this->once())
+            ->method('remove')
+            ->with($page);
+
+        $this->manager->deletePage($page);
+    }
+
+    public function testGetPageDataReturnsStringWhenFound(): void
+    {
+        $page = new SubscribePage();
+        $data = $this->createMock(SubscribePageData::class);
+        $data->expects($this->once())->method('getData')->willReturn('value');
+
+        $this->pageDataRepository
+            ->expects($this->once())
+            ->method('getByPage')
+            ->with($page)
+            ->willReturn([$data]);
+
+        $result = $this->manager->getPageData($page);
+        $this->assertIsArray($result);
+        $this->assertSame('value', $result[0]->getData());
+    }
+
+    public function testGetPageDataReturnsNullWhenNotFound(): void
+    {
+        $page = new SubscribePage();
+
+        $this->pageDataRepository
+            ->expects($this->once())
+            ->method('getByPage')
+            ->with($page)
+            ->willReturn([]);
+
+        $result = $this->manager->getPageData($page);
+        $this->assertEmpty($result);
+    }
+
+    public function testSetPageDataUpdatesExistingDataAndFlushes(): void
+    {
+        $page = new SubscribePage();
+        $existing = new SubscribePageData();
+        $existing->setId(5)->setName('color')->setData('red');
+
+        $this->pageDataRepository
+            ->expects($this->once())
+            ->method('findByPageAndName')
+            ->with($page, 'color')
+            ->willReturn($existing);
+
+        $this->entityManager
+            ->expects($this->never())
+            ->method('persist');
+
+        $result = $this->manager->setPageData($page, 'color', 'blue');
+
+        $this->assertSame($existing, $result);
+        $this->assertSame('blue', $result->getData());
+    }
+
+    public function testSetPageDataCreatesNewWhenMissingAndPersistsAndFlushes(): void
+    {
+        $page = $this->getMockBuilder(SubscribePage::class)
+            ->onlyMethods(['getId'])
+            ->getMock();
+        $page->method('getId')->willReturn(123);
+
+        $this->pageDataRepository
+            ->expects($this->once())
+            ->method('findByPageAndName')
+            ->with($page, 'greeting')
+            ->willReturn(null);
+
+        $this->entityManager
+            ->expects($this->once())
+            ->method('persist')
+            ->with($this->isInstanceOf(SubscribePageData::class));
+
+        $result = $this->manager->setPageData($page, 'greeting', 'hello');
+
+        $this->assertInstanceOf(SubscribePageData::class, $result);
+        $this->assertSame(123, $result->getId());
+        $this->assertSame('greeting', $result->getName());
+        $this->assertSame('hello', $result->getData());
+    }
+}
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php
index 355de90f..332b3a7c 100644
--- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php
@@ -9,9 +9,11 @@
 use PhpList\Core\Domain\Subscription\Model\Subscriber;
 use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition;
 use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeValue;
+use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository;
 use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository;
 use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager;
 use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
 
 class SubscriberAttributeManagerTest extends TestCase
 {
@@ -34,7 +36,12 @@ public function testCreateNewSubscriberAttribute(): void
                 return $attr->getValue() === 'US';
             }));
 
-        $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager);
+        $manager = new SubscriberAttributeManager(
+            attributeRepository: $subscriberAttrRepo,
+            attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class),
+            entityManager: $entityManager,
+            translator: new Translator('en')
+        );
         $attribute = $manager->createOrUpdate($subscriber, $definition, 'US');
 
         self::assertInstanceOf(SubscriberAttributeValue::class, $attribute);
@@ -60,7 +67,12 @@ public function testUpdateExistingSubscriberAttribute(): void
             ->method('persist')
             ->with($existing);
 
-        $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager);
+        $manager = new SubscriberAttributeManager(
+            attributeRepository: $subscriberAttrRepo,
+            attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class),
+            entityManager: $entityManager,
+            translator: new Translator('en')
+        );
         $result = $manager->createOrUpdate($subscriber, $definition, 'Updated');
 
         self::assertSame('Updated', $result->getValue());
@@ -76,7 +88,12 @@ public function testCreateFailsWhenValueAndDefaultAreNull(): void
 
         $subscriberAttrRepo->method('findOneBySubscriberAndAttribute')->willReturn(null);
 
-        $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager);
+        $manager = new SubscriberAttributeManager(
+            attributeRepository: $subscriberAttrRepo,
+            attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class),
+            entityManager: $entityManager,
+            translator: new Translator('en')
+        );
 
         $this->expectException(SubscriberAttributeCreationException::class);
         $this->expectExceptionMessage('Value is required');
@@ -95,7 +112,12 @@ public function testGetSubscriberAttribute(): void
             ->with(5, 10)
             ->willReturn($expected);
 
-        $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager);
+        $manager = new SubscriberAttributeManager(
+            attributeRepository: $subscriberAttrRepo,
+            attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class),
+            entityManager: $entityManager,
+            translator: new Translator('en')
+        );
         $result = $manager->getSubscriberAttribute(5, 10);
 
         self::assertSame($expected, $result);
@@ -111,7 +133,12 @@ public function testDeleteSubscriberAttribute(): void
             ->method('remove')
             ->with($attribute);
 
-        $manager = new SubscriberAttributeManager($subscriberAttrRepo, $entityManager);
+        $manager = new SubscriberAttributeManager(
+            attributeRepository: $subscriberAttrRepo,
+            attrDefinitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class),
+            entityManager: $entityManager,
+            translator: new Translator('en')
+        );
         $manager->delete($attribute);
 
         self::assertTrue(true);
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php
new file mode 100644
index 00000000..16ca73bd
--- /dev/null
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberBlacklistManagerTest.php
@@ -0,0 +1,190 @@
+subscriberRepository = $this->createMock(SubscriberRepository::class);
+        $this->userBlacklistRepository = $this->createMock(UserBlacklistRepository::class);
+        $this->userBlacklistDataRepository = $this->createMock(UserBlacklistDataRepository::class);
+        $this->entityManager = $this->createMock(EntityManagerInterface::class);
+
+        $this->manager = new SubscriberBlacklistManager(
+            subscriberRepository: $this->subscriberRepository,
+            userBlacklistRepository: $this->userBlacklistRepository,
+            blacklistDataRepository: $this->userBlacklistDataRepository,
+            entityManager: $this->entityManager,
+        );
+    }
+
+    public function testIsEmailBlacklistedReturnsValueFromRepository(): void
+    {
+        $this->subscriberRepository
+            ->expects($this->once())
+            ->method('isEmailBlacklisted')
+            ->with('test@example.com')
+            ->willReturn(true);
+
+        $result = $this->manager->isEmailBlacklisted('test@example.com');
+
+        $this->assertTrue($result);
+    }
+
+    public function testGetBlacklistInfoReturnsResultFromRepository(): void
+    {
+        $userBlacklist = $this->createMock(UserBlacklist::class);
+
+        $this->userBlacklistRepository
+            ->expects($this->once())
+            ->method('findBlacklistInfoByEmail')
+            ->with('foo@bar.com')
+            ->willReturn($userBlacklist);
+
+        $result = $this->manager->getBlacklistInfo('foo@bar.com');
+
+        $this->assertSame($userBlacklist, $result);
+    }
+
+    public function testAddEmailToBlacklistDoesNotAddIfAlreadyBlacklisted(): void
+    {
+        $this->subscriberRepository
+            ->expects($this->once())
+            ->method('isEmailBlacklisted')
+            ->with('already@blacklisted.com')
+            ->willReturn(true);
+
+        $this->userBlacklistRepository
+            ->expects($this->once())
+            ->method('findBlacklistInfoByEmail')
+            ->willReturn($this->createMock(UserBlacklist::class));
+
+        $this->entityManager
+            ->expects($this->never())
+            ->method('persist');
+
+        $this->entityManager
+            ->expects($this->never())
+            ->method('flush');
+
+        $this->manager->addEmailToBlacklist('already@blacklisted.com', 'reason');
+    }
+
+    public function testAddEmailToBlacklistAddsEntryAndReason(): void
+    {
+        $this->subscriberRepository
+            ->expects($this->once())
+            ->method('isEmailBlacklisted')
+            ->with('new@blacklist.com')
+            ->willReturn(false);
+
+        $this->entityManager
+            ->expects($this->exactly(2))
+            ->method('persist')
+            ->withConsecutive(
+                [$this->isInstanceOf(UserBlacklist::class)],
+                [$this->isInstanceOf(UserBlacklistData::class)]
+            );
+
+        $this->manager->addEmailToBlacklist('new@blacklist.com', 'test reason');
+    }
+
+    public function testAddEmailToBlacklistAddsEntryWithoutReason(): void
+    {
+        $this->subscriberRepository
+            ->expects($this->once())
+            ->method('isEmailBlacklisted')
+            ->with('noreason@blacklist.com')
+            ->willReturn(false);
+
+        $this->entityManager
+            ->expects($this->once())
+            ->method('persist')
+            ->with($this->isInstanceOf(UserBlacklist::class));
+
+        $this->manager->addEmailToBlacklist('noreason@blacklist.com');
+    }
+
+    public function testRemoveEmailFromBlacklistRemovesAllRelatedData(): void
+    {
+        $blacklist = $this->createMock(UserBlacklist::class);
+        $blacklistData = $this->createMock(UserBlacklistData::class);
+        $subscriber = $this->getMockBuilder(Subscriber::class)
+            ->onlyMethods(['setBlacklisted'])
+            ->getMock();
+
+        $this->userBlacklistRepository
+            ->expects($this->once())
+            ->method('findOneByEmail')
+            ->with('remove@me.com')
+            ->willReturn($blacklist);
+
+        $this->userBlacklistDataRepository
+            ->expects($this->once())
+            ->method('findOneByEmail')
+            ->with('remove@me.com')
+            ->willReturn($blacklistData);
+
+        $this->subscriberRepository
+            ->expects($this->once())
+            ->method('findOneByEmail')
+            ->with('remove@me.com')
+            ->willReturn($subscriber);
+
+        $this->entityManager
+            ->expects($this->exactly(2))
+            ->method('remove')
+            ->withConsecutive([$blacklist], [$blacklistData]);
+
+        $subscriber->expects($this->once())->method('setBlacklisted')->with(false);
+
+        $this->manager->removeEmailFromBlacklist('remove@me.com');
+    }
+
+    public function testGetBlacklistReasonReturnsReasonOrNull(): void
+    {
+        $blacklistData = $this->createMock(UserBlacklistData::class);
+        $blacklistData->expects($this->once())->method('getData')->willReturn('my reason');
+
+        $this->userBlacklistDataRepository
+            ->expects($this->once())
+            ->method('findOneByEmail')
+            ->with('why@blacklist.com')
+            ->willReturn($blacklistData);
+
+        $result = $this->manager->getBlacklistReason('why@blacklist.com');
+        $this->assertSame('my reason', $result);
+    }
+
+    public function testGetBlacklistReasonReturnsNullIfNoData(): void
+    {
+        $this->userBlacklistDataRepository
+            ->expects($this->once())
+            ->method('findOneByEmail')
+            ->with('none@blacklist.com')
+            ->willReturn(null);
+
+        $result = $this->manager->getBlacklistReason('none@blacklist.com');
+        $this->assertNull($result);
+    }
+}
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php
index 8df0f4d8..85e99730 100644
--- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php
@@ -4,12 +4,16 @@
 
 namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service\Manager;
 
+use Doctrine\ORM\EntityManagerInterface;
+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;
 use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
 class SubscriberHistoryManagerTest extends TestCase
 {
@@ -20,7 +24,11 @@ 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),
+            translator: $this->createMock(TranslatorInterface::class),
+            entityManager: $this->createMock(EntityManagerInterface::class),
         );
     }
 
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberListManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberListManagerTest.php
index c913518f..cf4dbf9c 100644
--- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberListManagerTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberListManagerTest.php
@@ -36,7 +36,7 @@ public function testCreateSubscriberList(): void
 
         $this->subscriberListRepository
             ->expects($this->once())
-            ->method('save')
+            ->method('persist')
             ->with($this->isInstanceOf(SubscriberList::class));
 
         $result = $this->manager->createSubscriberList($request, $admin);
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php
index 9a177312..aaff7298 100644
--- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php
@@ -5,36 +5,34 @@
 namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service\Manager;
 
 use Doctrine\ORM\EntityManagerInterface;
-use PhpList\Core\Domain\Messaging\Message\SubscriberConfirmationMessage;
 use PhpList\Core\Domain\Subscription\Model\Dto\CreateSubscriberDto;
 use PhpList\Core\Domain\Subscription\Model\Subscriber;
 use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
+use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
 use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager;
 use PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
-use Symfony\Component\Messenger\Envelope;
-use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Translation\Translator;
 
 class SubscriberManagerTest extends TestCase
 {
     private SubscriberRepository|MockObject $subscriberRepository;
     private EntityManagerInterface|MockObject $entityManager;
-    private MessageBusInterface|MockObject $messageBus;
     private SubscriberManager $subscriberManager;
 
     protected function setUp(): void
     {
         $this->subscriberRepository = $this->createMock(SubscriberRepository::class);
         $this->entityManager = $this->createMock(EntityManagerInterface::class);
-        $this->messageBus = $this->createMock(MessageBusInterface::class);
         $subscriberDeletionService = $this->createMock(SubscriberDeletionService::class);
 
         $this->subscriberManager = new SubscriberManager(
             subscriberRepository: $this->subscriberRepository,
             entityManager: $this->entityManager,
-            messageBus: $this->messageBus,
-            subscriberDeletionService: $subscriberDeletionService
+            subscriberDeletionService: $subscriberDeletionService,
+            translator: new Translator('en'),
+            subscriberHistoryManager: $this->createMock(SubscriberHistoryManager::class)
         );
     }
 
@@ -42,7 +40,7 @@ public function testCreateSubscriberPersistsAndReturnsProperlyInitializedEntity(
     {
         $this->subscriberRepository
             ->expects($this->once())
-            ->method('save')
+            ->method('persist')
             ->with($this->callback(function (Subscriber $sub): bool {
                 return $sub->getEmail() === 'foo@bar.com'
                     && $sub->isConfirmed() === true
@@ -63,11 +61,11 @@ public function testCreateSubscriberPersistsAndReturnsProperlyInitializedEntity(
         $this->assertFalse($result->isDisabled());
     }
 
-    public function testCreateSubscriberPersistsAndSendsEmail(): void
+    public function testCreateSubscriberPersists(): void
     {
         $this->subscriberRepository
             ->expects($this->once())
-            ->method('save')
+            ->method('persist')
             ->with($this->callback(function (Subscriber $sub): bool {
                 $sub->setUniqueId('test-unique-id-456');
                 return $sub->getEmail() === 'foo@bar.com'
@@ -77,13 +75,6 @@ public function testCreateSubscriberPersistsAndSendsEmail(): void
                     && $sub->isDisabled() === false;
             }));
 
-        $this->messageBus
-            ->expects($this->once())
-            ->method('dispatch')
-            ->willReturnCallback(function ($message) {
-                return new Envelope($message);
-            });
-
         $dto = new CreateSubscriberDto(email: 'foo@bar.com', requestConfirmation: true, htmlEmail: true);
 
         $result = $this->subscriberManager->createSubscriber($dto);
@@ -95,31 +86,18 @@ public function testCreateSubscriberPersistsAndSendsEmail(): void
         $this->assertFalse($result->isDisabled());
     }
 
-    public function testCreateSubscriberWithConfirmationSendsConfirmationEmail(): void
+    public function testCreateSubscriberWithConfirmation(): void
     {
         $capturedSubscriber = null;
         $this->subscriberRepository
             ->expects($this->once())
-            ->method('save')
+            ->method('persist')
             ->with($this->callback(function (Subscriber $subscriber) use (&$capturedSubscriber) {
                 $capturedSubscriber = $subscriber;
                 $subscriber->setUniqueId('test-unique-id-123');
                 return true;
             }));
 
-        $this->messageBus
-            ->expects($this->once())
-            ->method('dispatch')
-            ->with($this->callback(function (SubscriberConfirmationMessage $message) {
-                $this->assertEquals('test@example.com', $message->getEmail());
-                $this->assertEquals('test-unique-id-123', $message->getUniqueId());
-                $this->assertTrue($message->hasHtmlEmail());
-                return true;
-            }))
-            ->willReturnCallback(function ($message) {
-                return new Envelope($message);
-            });
-
         $dto = new CreateSubscriberDto(email: 'test@example.com', requestConfirmation: true, htmlEmail: true);
         $this->subscriberManager->createSubscriber($dto);
 
@@ -129,15 +107,11 @@ public function testCreateSubscriberWithConfirmationSendsConfirmationEmail(): vo
         $this->assertFalse($capturedSubscriber->isConfirmed());
     }
 
-    public function testCreateSubscriberWithoutConfirmationDoesNotSendConfirmationEmail(): void
+    public function testCreateSubscriberWithoutConfirmation(): void
     {
         $this->subscriberRepository
             ->expects($this->once())
-            ->method('save');
-
-        $this->messageBus
-            ->expects($this->never())
-            ->method('dispatch');
+            ->method('persist');
 
         $dto = new CreateSubscriberDto(email: 'test@example.com', requestConfirmation: false, htmlEmail: true);
         $this->subscriberManager->createSubscriber($dto);
@@ -159,10 +133,6 @@ public function testMarkAsConfirmedByUniqueIdConfirmsSubscriber(): void
             ->method('setConfirmed')
             ->with(true);
 
-        $this->entityManager
-            ->expects($this->once())
-            ->method('flush');
-
         $result = $this->subscriberManager->markAsConfirmedByUniqueId($uniqueId);
 
         $this->assertSame($subscriber, $result);
diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php
index e535a7fe..edbe1c07 100644
--- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php
@@ -4,6 +4,7 @@
 
 namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service\Manager;
 
+use Doctrine\ORM\EntityManagerInterface;
 use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException;
 use PhpList\Core\Domain\Subscription\Model\Subscriber;
 use PhpList\Core\Domain\Subscription\Model\SubscriberList;
@@ -14,11 +15,14 @@
 use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
+use Symfony\Contracts\Translation\TranslatorInterface;
 
 class SubscriptionManagerTest extends TestCase
 {
     private SubscriptionRepository&MockObject $subscriptionRepository;
     private SubscriberRepository&MockObject $subscriberRepository;
+    private TranslatorInterface&MockObject $translator;
+    private EntityManagerInterface&MockObject $entityManager;
     private SubscriptionManager $manager;
 
     protected function setUp(): void
@@ -26,10 +30,14 @@ protected function setUp(): void
         $this->subscriptionRepository = $this->createMock(SubscriptionRepository::class);
         $this->subscriberRepository = $this->createMock(SubscriberRepository::class);
         $subscriberListRepository = $this->createMock(SubscriberListRepository::class);
+        $this->translator = $this->createMock(TranslatorInterface::class);
+        $this->entityManager = $this->createMock(EntityManagerInterface::class);
         $this->manager = new SubscriptionManager(
-            $this->subscriptionRepository,
-            $this->subscriberRepository,
-            $subscriberListRepository
+            subscriptionRepository: $this->subscriptionRepository,
+            subscriberRepository: $this->subscriberRepository,
+            subscriberListRepository: $subscriberListRepository,
+            translator: $this->translator,
+            entityManager: $this->entityManager,
         );
     }
 
@@ -41,7 +49,7 @@ public function testCreateSubscriptionWhenSubscriberExists(): void
 
         $this->subscriberRepository->method('findOneBy')->with(['email' => $email])->willReturn($subscriber);
         $this->subscriptionRepository->method('findOneBySubscriberListAndSubscriber')->willReturn(null);
-        $this->subscriptionRepository->expects($this->once())->method('save');
+        $this->entityManager->expects($this->once())->method('persist');
 
         $subscriptions = $this->manager->createSubscriptions($list, [$email]);
 
@@ -51,6 +59,7 @@ public function testCreateSubscriptionWhenSubscriberExists(): void
 
     public function testCreateSubscriptionThrowsWhenSubscriberMissing(): void
     {
+        $this->translator->method('trans')->willReturn('Subscriber does not exists.');
         $this->expectException(SubscriptionCreationException::class);
         $this->expectExceptionMessage('Subscriber does not exists.');
 
@@ -73,7 +82,7 @@ public function testDeleteSubscriptionSuccessfully(): void
             ->with($subscriberList->getId(), $email)
             ->willReturn($subscription);
 
-        $this->subscriptionRepository->expects($this->once())->method('remove')->with($subscription);
+        $this->entityManager->expects($this->once())->method('remove')->with($subscription);
 
         $this->manager->deleteSubscriptions($subscriberList, [$email]);
     }
diff --git a/tests/Unit/Domain/Subscription/Service/Provider/CheckboxGroupValueProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/CheckboxGroupValueProviderTest.php
new file mode 100644
index 00000000..b5be5650
--- /dev/null
+++ b/tests/Unit/Domain/Subscription/Service/Provider/CheckboxGroupValueProviderTest.php
@@ -0,0 +1,118 @@
+repo = $this->createMock(DynamicListAttrRepository::class);
+        $this->subject = new CheckboxGroupValueProvider($this->repo);
+    }
+
+    private function createAttribute(
+        string $type = 'checkboxgroup',
+        ?string $tableName = 'colors'
+    ): SubscriberAttributeDefinition {
+        $attr = new SubscriberAttributeDefinition();
+        $attr->setName('prefs')->setType($type)->setTableName($tableName);
+
+        return $attr;
+    }
+
+    private function createUserAttr(SubscriberAttributeDefinition $def, ?string $value): SubscriberAttributeValue
+    {
+        $subscriber = new Subscriber();
+        $userAttr = new SubscriberAttributeValue($def, $subscriber);
+        $userAttr->setValue($value);
+
+        return $userAttr;
+    }
+
+    public function testSupportsReturnsTrueForCheckboxgroup(): void
+    {
+        $attr = $this->createAttribute('checkboxgroup');
+        self::assertTrue($this->subject->supports($attr));
+    }
+
+    public function testSupportsReturnsFalseForOtherTypes(): void
+    {
+        $attr = $this->createAttribute('textline');
+        self::assertFalse($this->subject->supports($attr));
+    }
+
+    public function testGetValueReturnsEmptyStringForNullOrEmptyValue(): void
+    {
+        $attr = $this->createAttribute();
+
+        $uaNull = $this->createUserAttr($attr, null);
+        self::assertSame('', $this->subject->getValue($attr, $uaNull));
+
+        $uaEmpty = $this->createUserAttr($attr, '');
+        self::assertSame('', $this->subject->getValue($attr, $uaEmpty));
+    }
+
+    public function testGetValueReturnsEmptyStringWhenNoParsedIds(): void
+    {
+        $attr = $this->createAttribute();
+        $ua = $this->createUserAttr($attr, '0, -1, foo, bar');
+
+        // Repository should not be called in this case
+        $this->repo->expects(self::never())->method('fetchOptionNames');
+
+        self::assertSame('', $this->subject->getValue($attr, $ua));
+    }
+
+    public function testGetValueReturnsEmptyStringWhenNoTableName(): void
+    {
+        $attr = $this->createAttribute('checkboxgroup', null);
+        $ua = $this->createUserAttr($attr, '1,2');
+
+        $this->repo->expects(self::never())->method('fetchOptionNames');
+
+        self::assertSame('', $this->subject->getValue($attr, $ua));
+    }
+
+    public function testGetValueFetchesNamesAndJoinsWithSemicolon(): void
+    {
+        $attr = $this->createAttribute('checkboxgroup', 'colors');
+        $ua = $this->createUserAttr($attr, '1, 2,3');
+
+        $this->repo
+            ->expects(self::once())
+            ->method('fetchOptionNames')
+            ->with('colors', [1, 2, 3])
+            ->willReturn(['Red', 'Green', 'Blue']);
+
+        self::assertSame('Red; Green; Blue', $this->subject->getValue($attr, $ua));
+    }
+
+    public function testGetValueParsesAndPreservesOrderAndFiltersInvalids(): void
+    {
+        $attr = $this->createAttribute('checkboxgroup', 'sizes');
+        $ua = $this->createUserAttr($attr, '3, 0, -2, two, 1, 2 , 2');
+        // After parsing: [3,1,2,2] -> duplicates are allowed and passed through to repository
+        $this->repo
+            ->expects(self::once())
+            ->method('fetchOptionNames')
+            ->with('sizes', [3, 1, 2, 2])
+            ->willReturn(['Large', 'Small', 'Medium', 'Medium']);
+
+        self::assertSame('Large; Small; Medium; Medium', $this->subject->getValue($attr, $ua));
+    }
+}
diff --git a/tests/Unit/Domain/Subscription/Service/Provider/ScalarValueProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/ScalarValueProviderTest.php
new file mode 100644
index 00000000..2b28c295
--- /dev/null
+++ b/tests/Unit/Domain/Subscription/Service/Provider/ScalarValueProviderTest.php
@@ -0,0 +1,57 @@
+createMock(SubscriberAttributeDefinition::class);
+        $attr->method('getType')->willReturn(null);
+
+        self::assertTrue($provider->supports($attr));
+    }
+
+    public function testSupportsReturnsFalseWhenTypeIsNotNull(): void
+    {
+        $provider = new ScalarValueProvider();
+
+        $attr = $this->createMock(SubscriberAttributeDefinition::class);
+        $attr->method('getType')->willReturn('checkbox');
+
+        self::assertFalse($provider->supports($attr));
+    }
+
+    public function testGetValueReturnsUnderlyingString(): void
+    {
+        $provider = new ScalarValueProvider();
+
+        $attr = $this->createMock(SubscriberAttributeDefinition::class);
+
+        $value = $this->createMock(SubscriberAttributeValue::class);
+        $value->method('getValue')->willReturn('hello');
+
+        self::assertSame('hello', $provider->getValue($attr, $value));
+    }
+
+    public function testGetValueReturnsEmptyStringWhenNull(): void
+    {
+        $provider = new ScalarValueProvider();
+
+        $attr = $this->createMock(SubscriberAttributeDefinition::class);
+
+        $value = $this->createMock(SubscriberAttributeValue::class);
+        $value->method('getValue')->willReturn(null);
+
+        self::assertSame('', $provider->getValue($attr, $value));
+    }
+}
diff --git a/tests/Unit/Domain/Subscription/Service/Provider/SelectOrRadioValueProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/SelectOrRadioValueProviderTest.php
new file mode 100644
index 00000000..38849fd7
--- /dev/null
+++ b/tests/Unit/Domain/Subscription/Service/Provider/SelectOrRadioValueProviderTest.php
@@ -0,0 +1,115 @@
+createMock(DynamicListAttrRepository::class);
+        $provider = new SelectOrRadioValueProvider($repo);
+
+        $attrSelect = $this->createMock(SubscriberAttributeDefinition::class);
+        $attrSelect->method('getType')->willReturn('select');
+        self::assertTrue($provider->supports($attrSelect));
+
+        $attrRadio = $this->createMock(SubscriberAttributeDefinition::class);
+        $attrRadio->method('getType')->willReturn('radio');
+        self::assertTrue($provider->supports($attrRadio));
+    }
+
+    public function testSupportsReturnsFalseForOtherTypes(): void
+    {
+        $repo = $this->createMock(DynamicListAttrRepository::class);
+        $provider = new SelectOrRadioValueProvider($repo);
+
+        $attr = $this->createMock(SubscriberAttributeDefinition::class);
+        $attr->method('getType')->willReturn('checkboxgroup');
+
+        self::assertFalse($provider->supports($attr));
+    }
+
+    public function testGetValueReturnsEmptyWhenNoTableName(): void
+    {
+        $repo = $this->createMock(DynamicListAttrRepository::class);
+        $provider = new SelectOrRadioValueProvider($repo);
+
+        $attr = $this->createMock(SubscriberAttributeDefinition::class);
+        $attr->method('getTableName')->willReturn(null);
+
+        $val = $this->createMock(SubscriberAttributeValue::class);
+        $val->method('getValue')->willReturn('10');
+
+        $repo->expects($this->never())->method('fetchSingleOptionName');
+
+        self::assertSame('', $provider->getValue($attr, $val));
+    }
+
+    public function testGetValueReturnsEmptyWhenValueNullOrNonPositive(): void
+    {
+        $repo = $this->createMock(DynamicListAttrRepository::class);
+        $provider = new SelectOrRadioValueProvider($repo);
+
+        $attr = $this->createMock(SubscriberAttributeDefinition::class);
+        $attr->method('getTableName')->willReturn('products');
+
+        $valNull = $this->createMock(SubscriberAttributeValue::class);
+        $valNull->method('getValue')->willReturn(null);
+        $repo->expects($this->never())->method('fetchSingleOptionName');
+        self::assertSame('', $provider->getValue($attr, $valNull));
+
+        $valZero = $this->createMock(SubscriberAttributeValue::class);
+        $valZero->method('getValue')->willReturn('0');
+        self::assertSame('', $provider->getValue($attr, $valZero));
+
+        $valNegative = $this->createMock(SubscriberAttributeValue::class);
+        $valNegative->method('getValue')->willReturn('-5');
+        self::assertSame('', $provider->getValue($attr, $valNegative));
+    }
+
+    public function testGetValueReturnsEmptyWhenRepoReturnsNull(): void
+    {
+        $repo = $this->createMock(DynamicListAttrRepository::class);
+        $provider = new SelectOrRadioValueProvider($repo);
+
+        $attr = $this->createMock(SubscriberAttributeDefinition::class);
+        $attr->method('getTableName')->willReturn('users');
+
+        $val = $this->createMock(SubscriberAttributeValue::class);
+        $val->method('getValue')->willReturn('7');
+
+        $repo->expects($this->once())
+            ->method('fetchSingleOptionName')
+            ->with('users', 7)
+            ->willReturn(null);
+
+        self::assertSame('', $provider->getValue($attr, $val));
+    }
+
+    public function testGetValueHappyPathReturnsNameFromRepo(): void
+    {
+        $repo = $this->createMock(DynamicListAttrRepository::class);
+        $provider = new SelectOrRadioValueProvider($repo);
+
+        $attr = $this->createMock(SubscriberAttributeDefinition::class);
+        $attr->method('getTableName')->willReturn('countries');
+
+        $val = $this->createMock(SubscriberAttributeValue::class);
+        $val->method('getValue')->willReturn('  42 ');
+
+        $repo->expects($this->once())
+            ->method('fetchSingleOptionName')
+            ->with('countries', 42)
+            ->willReturn('Armenia');
+
+        self::assertSame('Armenia', $provider->getValue($attr, $val));
+    }
+}
diff --git a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProviderTest.php
new file mode 100644
index 00000000..a38baabf
--- /dev/null
+++ b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberAttributeChangeSetProviderTest.php
@@ -0,0 +1,175 @@
+resolver = $this->createMock(AttributeValueResolver::class);
+        $this->resolver
+            ->method('resolve')
+            ->willReturnCallback(function (SubscriberAttributeValue $attr) {
+                return $attr->getValue();
+            });
+
+        $this->repository = $this->createMock(SubscriberAttributeValueRepository::class);
+
+        $this->provider = new SubscriberAttributeChangeSetProvider(
+            resolver: $this->resolver,
+            attributesRepository: $this->repository,
+        );
+    }
+
+    public function testNoChangesWhenNewAndExistingAreIdenticalCaseInsensitive(): void
+    {
+        $subscriber = new Subscriber();
+        $existing = [
+            $this->attr('Name', 'John', $subscriber),
+            $this->attr('Age', '30', $subscriber),
+        ];
+
+        $this->repository->expects(self::once())
+            ->method('getForSubscriber')
+            ->with($subscriber)
+            ->willReturn($existing);
+
+        $newData = [
+            'name' => 'John',
+            'AGE' => '30',
+        ];
+
+        $changeSet = $this->provider->getAttributeChangeSet($subscriber, $newData);
+
+        self::assertInstanceOf(ChangeSetDto::class, $changeSet);
+        self::assertFalse($changeSet->hasChanges());
+        self::assertSame([], $changeSet->getChanges());
+    }
+
+    public function testAddedAttributeAppearsWithNullOldValue(): void
+    {
+        $subscriber = new Subscriber();
+        $existing = [
+            $this->attr('Name', 'John', $subscriber),
+        ];
+
+        $this->repository->method('getForSubscriber')->willReturn($existing);
+
+        $newData = [
+            'name' => 'John',
+            'city' => 'NY',
+        ];
+
+        $changeSet = $this->provider->getAttributeChangeSet($subscriber, $newData);
+
+        self::assertTrue($changeSet->hasField('city'));
+        self::assertSame([null, 'NY'], $changeSet->getFieldChange('city'));
+
+        self::assertSame(['city' => [null, 'NY']], $changeSet->getChanges());
+    }
+
+    public function testRemovedAttributeAppearsWithNullNewValue(): void
+    {
+        $subscriber = new Subscriber();
+        $existing = [
+            $this->attr('Country', 'US', $subscriber),
+        ];
+
+        $this->repository->method('getForSubscriber')->willReturn($existing);
+
+        $changeSet = $this->provider->getAttributeChangeSet($subscriber, []);
+
+        self::assertTrue($changeSet->hasField('country'));
+        self::assertSame(['US', null], $changeSet->getFieldChange('country'));
+        self::assertSame(['country' => ['US', null]], $changeSet->getChanges());
+    }
+
+    public function testChangedAttributeShowsOldAndNewValues(): void
+    {
+        $subscriber = new Subscriber();
+        $existing = [
+            $this->attr('Phone', '123', $subscriber),
+        ];
+
+        $this->repository->method('getForSubscriber')->willReturn($existing);
+
+        $newData = [
+            'phone' => '456',
+        ];
+
+        $changeSet = $this->provider->getAttributeChangeSet($subscriber, $newData);
+
+        self::assertSame(['123', '456'], $changeSet->getFieldChange('phone'));
+        self::assertSame(['phone' => ['123', '456']], $changeSet->getChanges());
+    }
+
+    public function testIgnoredAttributesAreExcluded(): void
+    {
+        $subscriber = new Subscriber();
+        $existing = [
+            $this->attr('Password', 'old', $subscriber),
+            $this->attr('Modified', 'yesterday', $subscriber),
+            $this->attr('Nickname', 'Bob', $subscriber),
+        ];
+
+        $this->repository->method('getForSubscriber')->willReturn($existing);
+
+        $newData = [
+            'password' => 'new',
+            'MODIFIED' => null,
+            'nickname' => 'Bobby',
+        ];
+
+        $changeSet = $this->provider->getAttributeChangeSet($subscriber, $newData);
+
+        self::assertFalse($changeSet->hasField('password'));
+        self::assertFalse($changeSet->hasField('modified'));
+        self::assertTrue($changeSet->hasField('nickname'));
+        self::assertSame(['Bob', 'Bobby'], $changeSet->getFieldChange('nickname'));
+        self::assertSame(['nickname' => ['Bob', 'Bobby']], $changeSet->getChanges());
+    }
+
+    public function testCaseInsensitiveKeyComparisonAndResultLowercasing(): void
+    {
+        $subscriber = new Subscriber();
+        $existing = [
+            $this->attr('FirstName', 'Ann', $subscriber),
+        ];
+
+        $this->repository->method('getForSubscriber')->willReturn($existing);
+
+        $newData = [
+            'firstname' => 'Anna',
+        ];
+
+        $changeSet = $this->provider->getAttributeChangeSet($subscriber, $newData);
+
+        self::assertTrue($changeSet->hasField('firstname'));
+        self::assertSame(['Ann', 'Anna'], $changeSet->getFieldChange('firstname'));
+        self::assertSame(['firstname' => ['Ann', 'Anna']], $changeSet->getChanges());
+    }
+
+    private function attr(string $name, ?string $value, Subscriber $subscriber): SubscriberAttributeValue
+    {
+        $def = (new SubscriberAttributeDefinition())->setName($name);
+        $attr = new SubscriberAttributeValue($def, $subscriber);
+        $attr->setValue($value);
+        return $attr;
+    }
+}
diff --git a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php
index 6adbde10..9efdeac2 100644
--- a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php
+++ b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php
@@ -26,8 +26,8 @@ protected function setUp(): void
         $this->subscriberListRepository = $this->createMock(SubscriberListRepository::class);
 
         $this->subscriberProvider = new SubscriberProvider(
-            $this->subscriberRepository,
-            $this->subscriberListRepository,
+            subscriberRepository: $this->subscriberRepository,
+            subscriberListRepository: $this->subscriberListRepository,
         );
     }
 
@@ -82,9 +82,9 @@ public function testGetSubscribersForMessageWithOneListAndSubscribersReturnsSubs
             ->willReturn([$subscriberList]);
 
         $subscriber1 = $this->createMock(Subscriber::class);
-        $subscriber1->method('getId')->willReturn(1);
+        $subscriber1->method('getEmail')->willReturn('test1@example.am');
         $subscriber2 = $this->createMock(Subscriber::class);
-        $subscriber2->method('getId')->willReturn(2);
+        $subscriber2->method('getEmail')->willReturn('test2@exsmple.am');
 
         $this->subscriberRepository
             ->expects($this->once())
@@ -114,11 +114,11 @@ public function testGetSubscribersForMessageWithMultipleListsReturnsUniqueSubscr
             ->willReturn([$subscriberList1, $subscriberList2]);
 
         $subscriber1 = $this->createMock(Subscriber::class);
-        $subscriber1->method('getId')->willReturn(1);
+        $subscriber1->method('getEmail')->willReturn('test1@example.am');
         $subscriber2 = $this->createMock(Subscriber::class);
-        $subscriber2->method('getId')->willReturn(2);
+        $subscriber2->method('getEmail')->willReturn('test2@example.am');
         $subscriber3 = $this->createMock(Subscriber::class);
-        $subscriber3->method('getId')->willReturn(3);
+        $subscriber3->method('getEmail')->willReturn('test3@example.am');
 
         $this->subscriberRepository
             ->expects($this->exactly(2))
diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php
index 0bacd756..424a7e0d 100644
--- a/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php
+++ b/tests/Unit/Domain/Subscription/Service/SubscriberCsvImporterTest.php
@@ -5,6 +5,7 @@
 namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service;
 
 use Doctrine\ORM\EntityManagerInterface;
+use PhpList\Core\Domain\Subscription\Model\Dto\ChangeSetDto;
 use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto;
 use PhpList\Core\Domain\Subscription\Model\Dto\SubscriberImportOptions;
 use PhpList\Core\Domain\Subscription\Model\Subscriber;
@@ -13,42 +14,45 @@
 use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
 use PhpList\Core\Domain\Subscription\Service\CsvImporter;
 use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager;
+use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager;
 use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager;
 use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager;
 use PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
+use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Translation\Translator;
 
 class SubscriberCsvImporterTest extends TestCase
 {
     private SubscriberManager&MockObject $subscriberManagerMock;
     private SubscriberAttributeManager&MockObject $attributeManagerMock;
-    private SubscriptionManager&MockObject $subscriptionManagerMock;
     private SubscriberRepository&MockObject $subscriberRepositoryMock;
     private CsvImporter&MockObject $csvImporterMock;
     private SubscriberAttributeDefinitionRepository&MockObject $attributeDefinitionRepositoryMock;
-    private EntityManagerInterface $entityManager;
     private SubscriberCsvImporter $subject;
 
     protected function setUp(): void
     {
         $this->subscriberManagerMock = $this->createMock(SubscriberManager::class);
         $this->attributeManagerMock = $this->createMock(SubscriberAttributeManager::class);
-        $this->subscriptionManagerMock = $this->createMock(SubscriptionManager::class);
+        $subscriptionManagerMock = $this->createMock(SubscriptionManager::class);
         $this->subscriberRepositoryMock = $this->createMock(SubscriberRepository::class);
         $this->csvImporterMock = $this->createMock(CsvImporter::class);
         $this->attributeDefinitionRepositoryMock = $this->createMock(SubscriberAttributeDefinitionRepository::class);
-        $this->entityManager = $this->createMock(EntityManagerInterface::class);
+        $entityManager = $this->createMock(EntityManagerInterface::class);
 
         $this->subject = new SubscriberCsvImporter(
             subscriberManager: $this->subscriberManagerMock,
             attributeManager: $this->attributeManagerMock,
-            subscriptionManager: $this->subscriptionManagerMock,
+            subscriptionManager: $subscriptionManagerMock,
             subscriberRepository: $this->subscriberRepositoryMock,
             csvImporter: $this->csvImporterMock,
-            attrDefinitionRepository: $this->attributeDefinitionRepositoryMock,
-            entityManager: $this->entityManager,
+            entityManager: $entityManager,
+            translator: new Translator('en'),
+            messageBus: $this->createMock(MessageBusInterface::class),
+            subscriberHistoryManager: $this->createMock(SubscriberHistoryManager::class),
         );
     }
 
@@ -118,10 +122,10 @@ public function testImportFromCsvCreatesNewSubscribers(): void
 
         $this->attributeManagerMock
             ->expects($this->exactly(2))
-            ->method('createOrUpdate')
+            ->method('processAttributes')
             ->withConsecutive(
-                [$subscriber1, $attributeDefinition, 'John'],
-                [$subscriber2, $attributeDefinition, 'Jane']
+                [$subscriber1],
+                [$subscriber2]
             );
 
         $options = new SubscriberImportOptions();
@@ -154,8 +158,6 @@ public function testImportFromCsvUpdatesExistingSubscribers(): void
             ->with('existing@example.com')
             ->willReturn($existingSubscriber);
 
-        $updatedSubscriber = $this->createMock(Subscriber::class);
-
         $importDto = new ImportSubscriberDto(
             email: 'existing@example.com',
             confirmed: true,
@@ -178,7 +180,15 @@ public function testImportFromCsvUpdatesExistingSubscribers(): void
             ->expects($this->once())
             ->method('updateFromImport')
             ->with($existingSubscriber, $importDto)
-            ->willReturn($updatedSubscriber);
+            ->willReturn(new ChangeSetDto(
+                [
+                    'extra_data' => [null, 'Updated data'],
+                    'confirmed' => [false, true],
+                    'html_email' => [false, true],
+                    'blacklisted' => [true, false],
+                    'disabled' => [true, false],
+                ]
+            ));
 
         $options = new SubscriberImportOptions(updateExisting: true);
         $result = $this->subject->importFromCsv($uploadedFile, $options);
diff --git a/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php b/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php
index c0ab3a5a..cf691324 100644
--- a/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php
+++ b/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php
@@ -7,6 +7,7 @@
 use InvalidArgumentException;
 use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator;
 use PHPUnit\Framework\TestCase;
+use Symfony\Component\Translation\Translator;
 use Symfony\Component\Validator\Exception\ValidatorException;
 
 class AttributeTypeValidatorTest extends TestCase
@@ -15,7 +16,7 @@ class AttributeTypeValidatorTest extends TestCase
 
     protected function setUp(): void
     {
-        $this->validator = new AttributeTypeValidator();
+        $this->validator = new AttributeTypeValidator(new Translator('en'));
     }
 
     public function testValidatesValidType(): void
diff --git a/tests/Unit/Processor/AdvancedBounceRulesProcessorTest.php b/tests/Unit/Processor/AdvancedBounceRulesProcessorTest.php
new file mode 100644
index 00000000..7a57980d
--- /dev/null
+++ b/tests/Unit/Processor/AdvancedBounceRulesProcessorTest.php
@@ -0,0 +1,188 @@
+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
+    {
+        $translator = new Translator('en');
+        $this->io
+            ->expects($this->once())
+            ->method('section')
+            ->with($translator->trans('Processing bounces based on active bounce rules'));
+        $this->ruleManager->method('loadActiveRules')->willReturn([]);
+        $this->io
+            ->expects($this->once())
+            ->method('writeln')
+            ->with($translator->trans('No active rules'));
+
+        $processor = new AdvancedBounceRulesProcessor(
+            bounceManager: $this->bounceManager,
+            ruleManager: $this->ruleManager,
+            actionResolver: $this->actionResolver,
+            subscriberManager: $this->subscriberManager,
+            translator: $translator,
+        );
+
+        $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;
+            });
+
+        $translator = new Translator('en');
+        $this->io
+            ->expects($this->once())
+            ->method('section')
+            ->with($translator->trans('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,
+            translator: $translator,
+        );
+
+        $processor->process($this->io, 2);
+    }
+}
diff --git a/tests/Unit/Processor/BounceDataProcessorTest.php b/tests/Unit/Processor/BounceDataProcessorTest.php
new file mode 100644
index 00000000..d5d901c0
--- /dev/null
+++ b/tests/Unit/Processor/BounceDataProcessorTest.php
@@ -0,0 +1,170 @@
+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,
+            entityManager: $this->createMock(EntityManagerInterface::class),
+        );
+    }
+
+    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/Processor/MboxBounceProcessorTest.php b/tests/Unit/Processor/MboxBounceProcessorTest.php
new file mode 100644
index 00000000..a67235dd
--- /dev/null
+++ b/tests/Unit/Processor/MboxBounceProcessorTest.php
@@ -0,0 +1,85 @@
+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, new Translator('en'));
+        $this->assertSame('mbox', $processor->getProtocol());
+    }
+
+    public function testProcessThrowsWhenMailboxMissing(): void
+    {
+        $translator = new Translator('en');
+        $processor = new MboxBounceProcessor($this->service, $translator);
+
+        $this->input->method('getOption')->willReturnMap([
+            ['test', false],
+            ['maximum', 0],
+            ['mailbox', ''],
+        ]);
+
+        $this->io
+            ->expects($this->once())
+            ->method('error')
+            ->with($translator->trans('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
+    {
+        $translator = new Translator('en');
+        $processor = new MboxBounceProcessor($this->service, $translator);
+
+        $this->input->method('getOption')->willReturnMap([
+            ['test', true],
+            ['maximum', 50],
+            ['mailbox', '/var/mail/bounce.mbox'],
+        ]);
+
+        $this->io
+            ->expects($this->once())
+            ->method('section')
+            ->with($translator->trans('Opening mbox %file%', ['%file%' => '/var/mail/bounce.mbox']));
+        $this->io
+            ->expects($this->once())
+            ->method('writeln')
+            ->with($translator->trans('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/Processor/PopBounceProcessorTest.php b/tests/Unit/Processor/PopBounceProcessorTest.php
new file mode 100644
index 00000000..d218edd0
--- /dev/null
+++ b/tests/Unit/Processor/PopBounceProcessorTest.php
@@ -0,0 +1,66 @@
+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', new Translator('en'));
+        $this->assertSame('pop', $processor->getProtocol());
+    }
+
+    public function testProcessWithMultipleMailboxesAndDefaults(): void
+    {
+        $translator = new Translator('en');
+        $processor = new PopBounceProcessor($this->service, 'pop.example.com', 110, 'INBOX, ,Custom', $translator);
+
+        $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/Processor/UnidentifiedBounceReprocessorTest.php b/tests/Unit/Processor/UnidentifiedBounceReprocessorTest.php
new file mode 100644
index 00000000..0e2d2254
--- /dev/null
+++ b/tests/Unit/Processor/UnidentifiedBounceReprocessorTest.php
@@ -0,0 +1,77 @@
+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,
+            translator: new Translator('en'),
+        );
+        $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/Security/AuthenticationTest.php b/tests/Unit/Security/AuthenticationTest.php
index 0dad0bec..58f75f61 100644
--- a/tests/Unit/Security/AuthenticationTest.php
+++ b/tests/Unit/Security/AuthenticationTest.php
@@ -34,10 +34,9 @@ public function testAuthenticateByApiKeyWithValidApiKeyInBasicAuthReturnsMatchin
         $request = new Request();
         $request->headers->add(['php-auth-pw' => $apiKey]);
 
-        $token = new AdministratorToken();
         $administrator = new Administrator();
         $administrator->setSuperUser(true);
-        $token->setAdministrator($administrator);
+        $token = new AdministratorToken($administrator);
 
         $this->tokenRepository
             ->expects($this->any())
@@ -54,7 +53,7 @@ public function testAuthenticateByApiKeyWithValidApiKeyInBasicAuthWithoutAdminis
         $request = new Request();
         $request->headers->add(['php-auth-pw' => $apiKey]);
 
-        $token = new AdministratorToken();
+        $token = $this->createMock(AdministratorToken::class);
 
         $this->tokenRepository
             ->expects($this->any())