From 8d28d3b7c87e82655971383910ed7693fbb8aec7 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 29 Jul 2026 21:37:26 +0400 Subject: [PATCH 01/39] Refactor: env (#385) * Refactor: env * feat: add support for .env configuration files and dotenv integration * feat: add parameters configuration file creation to ScriptHandler * remove legacy public app files * fix: correct environment variable naming for parallel usage with phplist3 --------- Co-authored-by: Tatevik --- .env.dist | 95 +++++++++++++++++++ .gitignore | 4 +- CHANGELOG.md | 2 + README.md | 2 +- composer.json | 5 +- config/parameters.yml | 99 +++++++++++++++++++ config/parameters.yml.dist | 168 --------------------------------- public/app.php | 11 --- public/app_dev.php | 14 --- public/app_test.php | 14 --- src/Composer/ScriptHandler.php | 38 ++++++-- src/Core/Bootstrap.php | 20 +++- 12 files changed, 252 insertions(+), 220 deletions(-) create mode 100644 .env.dist create mode 100644 config/parameters.yml delete mode 100644 config/parameters.yml.dist delete mode 100644 public/app.php delete mode 100644 public/app_dev.php delete mode 100644 public/app_test.php diff --git a/.env.dist b/.env.dist new file mode 100644 index 000000000..27298ab0f --- /dev/null +++ b/.env.dist @@ -0,0 +1,95 @@ +# This file is a "template" of what your .env file should look like. +# Set variables here that may be different on each deployment target of the app, +# e.g. development, staging, production. +# +# On `composer install`/`composer update`, this file is copied to `.env` (unless +# it already exists) and PHPLIST_SECRET is replaced with a freshly generated value. +# +# https://symfony.com/doc/current/configuration.html#configuring-environment-variables-in-env-files + +PHPLIST_DATABASE_DRIVER=pdo_mysql +PHPLIST_DATABASE_PATH= +PHPLIST_DATABASE_HOST=127.0.0.1 +PHPLIST_DATABASE_PORT=3306 +PHPLIST_DATABASE_NAME=phplistdb +PHPLIST_DATABASE_USER=phplist +PHPLIST_DATABASE_PASSWORD=phplist +DATABASE_PREFIX=phplist_ +LIST_TABLE_PREFIX=listattr_ + +APP_DEV_VERSION=0 +APP_DEV_EMAIL=dev@dev.com +APP_POWERED_BY_PHPLIST=0 +PREFERENCEPAGE_SHOW_PRIVATE_LISTS=0 + +API_BASE_URL=http://api.phplist.local/ +FRONT_END_BASE_URL=http://frontend.phplist.local + +PARALLER_USE_WITH_PHPLIST3=0 + +# Email configuration +MAILER_FROM=noreply@phplist.com +MAILER_DSN=null://null +CONFIRMATION_URL=http://api.phplist.local/api/v2/subscriber/confirm/ +SUBSCRIPTION_CONFIRMATION_URL=http://api.phplist.local/api/v2/subscription/confirm/ +PASSWORD_RESET_URL=https://example.com/reset/ +SHOW_UNSUBSCRIBELINK=1 + +# Bounce email settings +BOUNCE_EMAIL=bounce@phplist.com +BOUNCE_IMAP_PASS=bounce@phplist.com +BOUNCE_IMAP_HOST=imap.phplist.com +BOUNCE_IMAP_PORT=993 +BOUNCE_IMAP_ENCRYPTION=ssl +BOUNCE_IMAP_MAILBOX=/var/spool/mail/bounces +BOUNCE_IMAP_MAILBOX_NAME=INBOX,ONE_MORE +BOUNCE_IMAP_PROTOCOL=imap +BOUNCE_IMAP_UNSUBSCRIBE_THRESHOLD=5 +BOUNCE_IMAP_BLACKLIST_THRESHOLD=3 +BOUNCE_IMAP_PURGE=0 +BOUNCE_IMAP_PURGE_UNPROCESSED=0 + +# Messenger configuration for asynchronous processing +MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=true + +# A secret key that's used to generate certain security-related tokens +PHPLIST_SECRET=%s +VERIFY_SSL=1 + +APP_PHPLIST_ISP_CONF_PATH=/etc/phplist.conf + +# Message sending +MAILQUEUE_BATCH_SIZE=5 +MAILQUEUE_BATCH_PERIOD=5 +MAILQUEUE_THROTTLE=5 +MESSAGING_MAX_PROCESS_TIME=600 +MAX_MAILSIZE=209715200 +DEFAULT_MESSAGEAGE=691200 +USE_MANUAL_TEXT_PART=0 +MESSAGING_BLACKLIST_GRACE_TIME=600 +GOOGLE_SENDERID= +USE_AMAZONSES=0 +USE_PRECEDENCE_HEADER=0 +EMBEDEXTERNALIMAGES=0 +EMBEDUPLOADIMAGES=0 +EXTERNALIMAGE_MAXAGE=0 +EXTERNALIMAGE_TIMEOUT=30 +EXTERNALIMAGE_MAXSIZE=204800 +FORWARD_ALTERNATIVE_CONTENT=0 +EMAILTEXTCREDITS=0 +ALWAYS_ADD_USERTRACK=1 +SEND_LISTADMIN_COPY=0 + +FORWARD_EMAIL_PERIOD="1 minute" +FORWARD_EMAIL_COUNT=1 +FORWARD_PERSONAL_NOTE_SIZE=0 +FORWARD_FRIEND_COUNT_ATTRIBUTE= +KEEPFORWARDERATTRIBUTES=0 + +UPLOADIMAGES_DIR=uploadimages +PHPLIST_UPLOADS_MAX_SIZE=5M + +PUBLIC_SCHEMA=https +PHPLIST_ATTACHMENT_DOWNLOAD_URL=https://example.com/download/ +PHPLIST_ATTACHMENT_REPOSITORY_PATH=/tmp +MAX_AVATAR_SIZE=100000 diff --git a/.gitignore b/.gitignore index 25db886b5..072e5252d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,9 @@ /composer.lock /config/bundles.yml /config/config_modules.yml -/config/parameters.yml +/.env +/.env.local +/.env.*.local /config/routing_modules.yml /nbproject /var/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0254484d2..f6e2111f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,10 @@ This project adheres to [Semantic Versioning](https://semver.org/). ### Added - Graylog integration for centralized logging (#TBD) +- `symfony/dotenv` support: configuration values are now read from a `.env` file (generated from `.env.dist` on install/update), in addition to real environment variables (#TBD) ### Changed +- `config/parameters.yml.dist` no longer contains inline `env(VAR): default` fallbacks; defaults now live in `.env.dist` (#TBD) ### Deprecated diff --git a/README.md b/README.md index 2015718af..d82c81490 100755 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ this code. The phpList application is configured so that the built-in PHP web server can run in development and testing mode, while Apache can run in production mode. -Please first set the database credentials in `config/parameters.yml`. +Please first set the database credentials in `.env` (created from `.env.dist` on `composer install`/`composer update`). ### Development diff --git a/composer.json b/composer.json index 9c95fb235..2378bdeb9 100644 --- a/composer.json +++ b/composer.json @@ -87,7 +87,8 @@ "ext-fileinfo": "*", "setasign/fpdf": "^1.8", "phpdocumentor/reflection-docblock": "^5.2", - "guzzlehttp/guzzle": "^7.4.5" + "guzzlehttp/guzzle": "^7.4.5", + "symfony/dotenv": "^6.4" }, "require-dev": { "phpunit/phpunit": "^9.5", @@ -127,7 +128,7 @@ "PhpList\\Core\\Composer\\ScriptHandler::createGeneralConfiguration", "PhpList\\Core\\Composer\\ScriptHandler::createBundleConfiguration", "PhpList\\Core\\Composer\\ScriptHandler::createRoutesConfiguration", - "PhpList\\Core\\Composer\\ScriptHandler::createParametersConfiguration", + "PhpList\\Core\\Composer\\ScriptHandler::createDotenvConfiguration", "php bin/console cache:clear", "php bin/console cache:warmup" ], diff --git a/config/parameters.yml b/config/parameters.yml new file mode 100644 index 000000000..aecc30ecb --- /dev/null +++ b/config/parameters.yml @@ -0,0 +1,99 @@ +# This file is a "template" of what your parameters.yml file should look like +# Set parameters here that may be different on each deployment target of the app, e.g. development, staging, production. +# https://symfony.com/doc/current/best_practices/configuration.html#infrastructure-related-configuration +# +# These variables are read from environment variables using the "env" construct. +# The environment variables themselves are defined in the ".env" file (see ".env.dist" for the template) +# and/or in the actual environment (e.g. Apache host configuration, command line). +parameters: + database_driver: '%env(PHPLIST_DATABASE_DRIVER)%' + database_path: '%env(PHPLIST_DATABASE_PATH)%' + database_host: '%env(PHPLIST_DATABASE_HOST)%' + database_port: '%env(PHPLIST_DATABASE_PORT)%' + database_name: '%env(PHPLIST_DATABASE_NAME)%' + database_user: '%env(PHPLIST_DATABASE_USER)%' + database_password: '%env(PHPLIST_DATABASE_PASSWORD)%' + database_prefix: '%env(DATABASE_PREFIX)%' + list_table_prefix: '%env(LIST_TABLE_PREFIX)%' + app.dev_version: '%env(APP_DEV_VERSION)%' + app.dev_email: '%env(APP_DEV_EMAIL)%' + app.powered_by_phplist: '%env(APP_POWERED_BY_PHPLIST)%' + app.preference_page_show_private_lists: '%env(PREFERENCEPAGE_SHOW_PRIVATE_LISTS)%' + + app.rest_api_base_url: '%env(API_BASE_URL)%/api/v2' + app.api_base_url: '%env(API_BASE_URL)%' + app.frontend_base_url: '%env(FRONT_END_BASE_URL)%' + + parallel_use_with_phplist3: '%env(PARALLER_USE_WITH_PHPLIST3)%' + + # Email configuration + app.mailer_from: '%env(MAILER_FROM)%' + app.mailer_dsn: '%env(MAILER_DSN)%' + app.confirmation_url: '%env(CONFIRMATION_URL)%' + app.subscription_confirmation_url: '%env(SUBSCRIPTION_CONFIRMATION_URL)%' + app.password_reset_url: '%env(PASSWORD_RESET_URL)%' + app.show_unsubscribe_link: '%env(SHOW_UNSUBSCRIBELINK)%' + + # bounce email settings + imap_bounce.email: '%env(BOUNCE_EMAIL)%' + imap_bounce.password: '%env(BOUNCE_IMAP_PASS)%' + imap_bounce.host: '%env(BOUNCE_IMAP_HOST)%' + imap_bounce.port: '%env(BOUNCE_IMAP_PORT)%' + imap_bounce.encryption: '%env(BOUNCE_IMAP_ENCRYPTION)%' + imap_bounce.mailbox: '%env(BOUNCE_IMAP_MAILBOX)%' + imap_bounce.mailbox_name: '%env(BOUNCE_IMAP_MAILBOX_NAME)%' + imap_bounce.protocol: '%env(BOUNCE_IMAP_PROTOCOL)%' + imap_bounce.unsubscribe_threshold: '%env(BOUNCE_IMAP_UNSUBSCRIBE_THRESHOLD)%' + imap_bounce.blacklist_threshold: '%env(BOUNCE_IMAP_BLACKLIST_THRESHOLD)%' + imap_bounce.purge: '%env(BOUNCE_IMAP_PURGE)%' + imap_bounce.purge_unprocessed: '%env(BOUNCE_IMAP_PURGE_UNPROCESSED)%' + + # Messenger configuration for asynchronous processing + app.messenger_transport_dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + + # A secret key that's used to generate certain security-related tokens + secret: '%env(PHPLIST_SECRET)%' + phplist.verify_ssl: '%env(VERIFY_SSL)%' + + graylog_host: 'graylog.phplist.local' + graylog_port: 12201 + + app.phplist_isp_conf_path: '%env(APP_PHPLIST_ISP_CONF_PATH)%' + + # Message sending + messaging.mail_queue_batch_size: '%env(MAILQUEUE_BATCH_SIZE)%' + messaging.mail_queue_period: '%env(MAILQUEUE_BATCH_PERIOD)%' + messaging.mail_queue_throttle: '%env(MAILQUEUE_THROTTLE)%' + messaging.max_process_time: '%env(MESSAGING_MAX_PROCESS_TIME)%' + messaging.max_mail_size: '%env(MAX_MAILSIZE)%' + messaging.default_message_age: '%env(DEFAULT_MESSAGEAGE)%' + messaging.use_manual_text_part: '%env(USE_MANUAL_TEXT_PART)%' + messaging.blacklist_grace_time: '%env(MESSAGING_BLACKLIST_GRACE_TIME)%' + messaging.google_sender_id: '%env(GOOGLE_SENDERID)%' + messaging.use_amazon_ses: '%env(USE_AMAZONSES)%' + messaging.use_precedence_header: '%env(USE_PRECEDENCE_HEADER)%' + messaging.embed_external_images: '%env(EMBEDEXTERNALIMAGES)%' + messaging.embed_uploaded_images: '%env(EMBEDUPLOADIMAGES)%' + messaging.external_image_max_age: '%env(EXTERNALIMAGE_MAXAGE)%' + messaging.external_image_timeout: '%env(EXTERNALIMAGE_TIMEOUT)%' + messaging.external_image_max_size: '%env(EXTERNALIMAGE_MAXSIZE)%' + messaging.forward_alternative_content: '%env(FORWARD_ALTERNATIVE_CONTENT)%' + messaging.email_text_credits: '%env(EMAILTEXTCREDITS)%' + messaging.always_add_user_track: '%env(ALWAYS_ADD_USERTRACK)%' + messaging.send_list_admin_copy: '%env(SEND_LISTADMIN_COPY)%' + + phplist.forward_email_period: '%env(FORWARD_EMAIL_PERIOD)%' + phplist.forward_email_count: '%env(FORWARD_EMAIL_COUNT)%' + phplist.forward_personal_note_size: '%env(FORWARD_PERSONAL_NOTE_SIZE)%' + phplist.forward_friend_count_attribute: '%env(FORWARD_FRIEND_COUNT_ATTRIBUTE)%' + phplist.keep_forwarded_attributes: '%env(KEEPFORWARDERATTRIBUTES)%' + + phplist.upload_images_dir: '%env(UPLOADIMAGES_DIR)%' + phplist.uploads.allowed_mime_types: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'] + phplist.uploads.allowed_extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'] + phplist.uploads.max_size: '%env(PHPLIST_UPLOADS_MAX_SIZE)%' + + phplist.public_schema: '%env(PUBLIC_SCHEMA)%' + phplist.attachment_download_url: '%env(PHPLIST_ATTACHMENT_DOWNLOAD_URL)%' + phplist.attachment_repository_path: '%env(PHPLIST_ATTACHMENT_REPOSITORY_PATH)%' + phplist.max_avatar_size: '%env(MAX_AVATAR_SIZE)%' diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist deleted file mode 100644 index cf9a17e62..000000000 --- a/config/parameters.yml.dist +++ /dev/null @@ -1,168 +0,0 @@ -# This file is a "template" of what your parameters.yml file should look like -# Set parameters here that may be different on each deployment target of the app, e.g. development, staging, production. -# https://symfony.com/doc/current/best_practices/configuration.html#infrastructure-related-configuration -# -# These variables are read from environment variables using the "env" construct. -# You can set environment variables in the Apache host configuration and also on the command line. -# If you cannot provide any environment variables, you can also set the variables in this file -# in the lines with "env(VARIABLE_NAME)". -parameters: - database_driver: '%%env(PHPLIST_DATABASE_DRIVER)%%' - env(PHPLIST_DATABASE_DRIVER): 'pdo_mysql' - database_path: '%%env(PHPLIST_DATABASE_PATH)%%' - env(PHPLIST_DATABASE_PATH): null - database_host: '%%env(PHPLIST_DATABASE_HOST)%%' - env(PHPLIST_DATABASE_HOST): '127.0.0.1' - database_port: '%%env(PHPLIST_DATABASE_PORT)%%' - env(PHPLIST_DATABASE_PORT): '3306' - database_name: '%%env(PHPLIST_DATABASE_NAME)%%' - env(PHPLIST_DATABASE_NAME): 'phplistdb' - database_user: '%%env(PHPLIST_DATABASE_USER)%%' - 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_' - list_table_prefix: '%%env(LIST_TABLE_PREFIX)%%' - env(LIST_TABLE_PREFIX): 'listattr_' - app.dev_version: '%%env(APP_DEV_VERSION)%%' - env(APP_DEV_VERSION): '0' - app.dev_email: '%%env(APP_DEV_EMAIL)%%' - env(APP_DEV_EMAIL): 'dev@dev.com' - app.powered_by_phplist: '%%env(APP_POWERED_BY_PHPLIST)%%' - env(APP_POWERED_BY_PHPLIST): '0' - app.preference_page_show_private_lists: '%%env(PREFERENCEPAGE_SHOW_PRIVATE_LISTS)%%' - env(PREFERENCEPAGE_SHOW_PRIVATE_LISTS): '0' - app.rest_api_base_url: '%%env(REST_API_BASE_URL)%%' - env(REST_API_BASE_URL): 'http://api.phplist.local/api/v2' - api_base_url: '%%env(API_BASE_URL)%%' - env(API_BASE_URL): 'http://api.phplist.local/' - app.frontend_base_url: '%%env(FRONT_END_BASE_URL)%%' - env(FRONT_END_BASE_URL): 'http://frontend.phplist.local' - parallel_use_with_phplist3: '%%env(parallel_use_with_phplist3)%%' - env(parallel_use_with_phplist3): '0' - - # Email configuration - app.mailer_from: '%%env(MAILER_FROM)%%' - env(MAILER_FROM): 'noreply@phplist.com' - app.mailer_dsn: '%%env(MAILER_DSN)%%' - env(MAILER_DSN): 'null://null' # set local_domain on transport - app.confirmation_url: '%%env(CONFIRMATION_URL)%%' - env(CONFIRMATION_URL): 'http://api.phplist.local/api/v2/subscriber/confirm/' - app.subscription_confirmation_url: '%%env(SUBSCRIPTION_CONFIRMATION_URL)%%' - env(SUBSCRIPTION_CONFIRMATION_URL): 'http://api.phplist.local/api/v2/subscription/confirm/' - app.password_reset_url: '%%env(PASSWORD_RESET_URL)%%' - env(PASSWORD_RESET_URL): 'https://example.com/reset/' - app.show_unsubscribe_link: '%%env(SHOW_UNSUBSCRIBELINK)%%' - env(SHOW_UNSUBSCRIBELINK): '1' - - # 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' - - # A secret key that's used to generate certain security-related tokens - secret: '%%env(PHPLIST_SECRET)%%' - env(PHPLIST_SECRET): %1$s - phplist.verify_ssl: '%%env(VERIFY_SSL)%%' - env(VERIFY_SSL): '1' - - graylog_host: 'graylog.phplist.local' - 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' - messaging.max_mail_size: '%%env(MAX_MAILSIZE)%%' - env(MAX_MAILSIZE): '209715200' - messaging.default_message_age: '%%env(DEFAULT_MESSAGEAGE)%%' - env(DEFAULT_MESSAGEAGE): '691200' - messaging.use_manual_text_part: '%%env(USE_MANUAL_TEXT_PART)%%' - env(USE_MANUAL_TEXT_PART): '0' - messaging.blacklist_grace_time: '%%env(MESSAGING_BLACKLIST_GRACE_TIME)%%' - env(MESSAGING_BLACKLIST_GRACE_TIME): '600' - messaging.google_sender_id: '%%env(GOOGLE_SENDERID)%%' - env(GOOGLE_SENDERID): '' - messaging.use_amazon_ses: '%%env(USE_AMAZONSES)%%' - env(USE_AMAZONSES): '0' - messaging.use_precedence_header: '%%env(USE_PRECEDENCE_HEADER)%%' - env(USE_PRECEDENCE_HEADER): '0' - messaging.embed_external_images: '%%env(EMBEDEXTERNALIMAGES)%%' - env(EMBEDEXTERNALIMAGES): '0' - messaging.embed_uploaded_images: '%%env(EMBEDUPLOADIMAGES)%%' - env(EMBEDUPLOADIMAGES): '0' - messaging.external_image_max_age: '%%env(EXTERNALIMAGE_MAXAGE)%%' - env(EXTERNALIMAGE_MAXAGE): '0' - messaging.external_image_timeout: '%%env(EXTERNALIMAGE_TIMEOUT)%%' - env(EXTERNALIMAGE_TIMEOUT): '30' - messaging.external_image_max_size: '%%env(EXTERNALIMAGE_MAXSIZE)%%' - env(EXTERNALIMAGE_MAXSIZE): '204800' - messaging.forward_alternative_content: '%%env(FORWARD_ALTERNATIVE_CONTENT)%%' - env(FORWARD_ALTERNATIVE_CONTENT): '0' - messaging.email_text_credits: '%%env(EMAILTEXTCREDITS)%%' - env(EMAILTEXTCREDITS): '0' - messaging.always_add_user_track: '%%env(ALWAYS_ADD_USERTRACK)%%' - env(ALWAYS_ADD_USERTRACK): '1' - messaging.send_list_admin_copy: '%%env(SEND_LISTADMIN_COPY)%%' - env(SEND_LISTADMIN_COPY): '0' - - phplist.forward_email_period: '%%env(FORWARD_EMAIL_PERIOD)%%' - env(FORWARD_EMAIL_PERIOD): '1 minute' - phplist.forward_email_count: '%%env(FORWARD_EMAIL_COUNT)%%' - env(FORWARD_EMAIL_COUNT): '1' - phplist.forward_personal_note_size: '%%env(FORWARD_PERSONAL_NOTE_SIZE)%%' - env(FORWARD_PERSONAL_NOTE_SIZE): '0' - phplist.forward_friend_count_attribute: '%%env(FORWARD_FRIEND_COUNT_ATTRIBUTE)%%' - env(FORWARD_FRIEND_COUNT_ATTRIBUTE): '' - phplist.keep_forwarded_attributes: '%%env(KEEPFORWARDERATTRIBUTES)%%' - env(KEEPFORWARDERATTRIBUTES): '0' - - phplist.upload_images_dir: '%%env(UPLOADIMAGES_DIR)%%' - env(UPLOADIMAGES_DIR): 'uploadimages' - phplist.uploads.allowed_mime_types: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'] - phplist.uploads.allowed_extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'] - phplist.uploads.max_size: '%%env(PHPLIST_UPLOADS_MAX_SIZE)%%' - env(PHPLIST_UPLOADS_MAX_SIZE): '5M' - - phplist.public_schema: '%%env(PUBLIC_SCHEMA)%%' - env(PUBLIC_SCHEMA): 'https' - phplist.attachment_download_url: '%%env(PHPLIST_ATTACHMENT_DOWNLOAD_URL)%%' - env(PHPLIST_ATTACHMENT_DOWNLOAD_URL): 'https://example.com/download/' - phplist.attachment_repository_path: '%%env(PHPLIST_ATTACHMENT_REPOSITORY_PATH)%%' - env(PHPLIST_ATTACHMENT_REPOSITORY_PATH): '/tmp' - phplist.max_avatar_size: '%%env(MAX_AVATAR_SIZE)%%' - env(MAX_AVATAR_SIZE): '100000' diff --git a/public/app.php b/public/app.php deleted file mode 100644 index 8e58c4f43..000000000 --- a/public/app.php +++ /dev/null @@ -1,11 +0,0 @@ -configure() - ->dispatch(); diff --git a/public/app_dev.php b/public/app_dev.php deleted file mode 100644 index 46c49194b..000000000 --- a/public/app_dev.php +++ /dev/null @@ -1,14 +0,0 @@ -ensureDevelopmentOrTestingEnvironment() - ->setEnvironment(Environment::DEVELOPMENT) - ->configure() - ->dispatch(); diff --git a/public/app_test.php b/public/app_test.php deleted file mode 100644 index af816b870..000000000 --- a/public/app_test.php +++ /dev/null @@ -1,14 +0,0 @@ -ensureDevelopmentOrTestingEnvironment() - ->setEnvironment(Environment::TESTING) - ->configure() - ->dispatch(); diff --git a/src/Composer/ScriptHandler.php b/src/Composer/ScriptHandler.php index 55e23739c..426ac71c2 100644 --- a/src/Composer/ScriptHandler.php +++ b/src/Composer/ScriptHandler.php @@ -36,17 +36,22 @@ class ScriptHandler /** * @var string */ - const PARAMETERS_CONFIGURATION_FILE = '/config/parameters.yml'; + const GENERAL_CONFIGURATION_FILE = '/config/config_modules.yml'; /** * @var string */ - const GENERAL_CONFIGURATION_FILE = '/config/config_modules.yml'; + const DOTENV_FILE = '/.env'; + + /** + * @var string + */ + const DOTENV_TEMPLATE_FILE = '/.env.dist'; /** * @var string */ - const PARAMETERS_TEMPLATE_FILE = '/config/parameters.yml.dist'; + const PARAMETERS_CONFIGURATION_FILE = '/config/parameters.yml'; /** * @return string absolute application root directory without the trailing slash @@ -265,23 +270,40 @@ public static function clearAllCaches():void } /** - * Creates config/parameters.yml (the parameters configuration file). + * Creates the .env file (the environment variables consumed by the parameters configuration) + * by copying it from .env.dist, generating a fresh app secret in the process. * * @return void */ - public static function createParametersConfiguration(): void + public static function createDotenvConfiguration(): void { - $configurationFilePath = self::getApplicationRoot() . self::PARAMETERS_CONFIGURATION_FILE; - if (file_exists($configurationFilePath)) { + $appDotenvFilePath = self::getApplicationRoot() . self::DOTENV_FILE; + $templateFilePath = __DIR__ . '/../..' . static::DOTENV_TEMPLATE_FILE; + + if (file_exists($appDotenvFilePath)) { return; } - $templateFilePath = __DIR__ . '/../..' . static::PARAMETERS_TEMPLATE_FILE; $template = file_get_contents($templateFilePath); $secret = bin2hex(random_bytes(20)); $configuration = sprintf($template, $secret); + self::createAndWriteFile($appDotenvFilePath, $configuration); + } + + + /** + * Creates config/parameters.yml (the parameters configuration file). + * + * @return void + */ + public static function createParametersConfiguration(): void + { + $configurationFilePath = self::getApplicationRoot() . self::PARAMETERS_CONFIGURATION_FILE; + $templateFilePath = __DIR__ . '/../..' . static::PARAMETERS_CONFIGURATION_FILE; + $configuration = file_get_contents($templateFilePath); + self::createAndWriteFile($configurationFilePath, $configuration); } diff --git a/src/Core/Bootstrap.php b/src/Core/Bootstrap.php index 82ddb28f5..3b7430c25 100644 --- a/src/Core/Bootstrap.php +++ b/src/Core/Bootstrap.php @@ -7,6 +7,7 @@ use Doctrine\ORM\EntityManagerInterface; use Exception; use RuntimeException; +use Symfony\Component\Dotenv\Dotenv; use Symfony\Component\ErrorHandler\ErrorHandler; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\Request; @@ -147,10 +148,27 @@ public function configure(): Bootstrap { $this->isConfigured = true; - return $this->configureDebugging() + return $this->loadEnvironmentVariables() + ->configureDebugging() ->configureApplicationKernel(); } + /** + * Loads environment variables from the application's ".env" files (if present) using Symfony Dotenv, + * following the standard ".env" -> ".env.local" -> ".env.$environment" -> ".env.$environment.local" cascade. + * + * @return Bootstrap fluent interface + */ + private function loadEnvironmentVariables(): Bootstrap + { + $applicationRoot = $this->applicationStructure->getApplicationRoot(); + if (file_exists($applicationRoot . '/.env') || file_exists($applicationRoot . '/.env.dist')) { + (new Dotenv())->loadEnv($applicationRoot . '/.env', 'APP_ENV', $this->environment); + } + + return $this; + } + /** * Makes sure that configure has been called before. * From d24769b54975f2ac9ef9837bfdfa6cd4db34febe Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 31 Jul 2026 11:56:27 +0400 Subject: [PATCH 02/39] feat: add default admin password configuration and update ImportDefaultsCommand --- .env.dist | 1 + config/parameters.yml | 1 + src/Domain/Identity/Command/ImportDefaultsCommand.php | 9 +++++---- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.env.dist b/.env.dist index 27298ab0f..03df0e910 100644 --- a/.env.dist +++ b/.env.dist @@ -16,6 +16,7 @@ PHPLIST_DATABASE_USER=phplist PHPLIST_DATABASE_PASSWORD=phplist DATABASE_PREFIX=phplist_ LIST_TABLE_PREFIX=listattr_ +PHPLIST_ADMIN_PASSWORD=admin APP_DEV_VERSION=0 APP_DEV_EMAIL=dev@dev.com diff --git a/config/parameters.yml b/config/parameters.yml index aecc30ecb..f2793be52 100644 --- a/config/parameters.yml +++ b/config/parameters.yml @@ -14,6 +14,7 @@ parameters: database_user: '%env(PHPLIST_DATABASE_USER)%' database_password: '%env(PHPLIST_DATABASE_PASSWORD)%' database_prefix: '%env(DATABASE_PREFIX)%' + app.default_admin_password: '%env(PHPLIST_DEFAULT_ADMIN_PASSWORD)%' list_table_prefix: '%env(LIST_TABLE_PREFIX)%' app.dev_version: '%env(APP_DEV_VERSION)%' app.dev_email: '%env(APP_DEV_EMAIL)%' diff --git a/src/Domain/Identity/Command/ImportDefaultsCommand.php b/src/Domain/Identity/Command/ImportDefaultsCommand.php index 47ac42955..b00cc9794 100644 --- a/src/Domain/Identity/Command/ImportDefaultsCommand.php +++ b/src/Domain/Identity/Command/ImportDefaultsCommand.php @@ -15,6 +15,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\Question; +use Symfony\Component\DependencyInjection\Attribute\Autowire; #[AsCommand( name: 'phplist:defaults:import', @@ -22,13 +23,15 @@ )] class ImportDefaultsCommand extends Command { - private const DEFAULT_LOGIN = 'admin'; + private const DEFAULT_LOGIN = 'test1'; private const DEFAULT_EMAIL = 'admin@example.com'; public function __construct( private readonly AdministratorRepository $administratorRepository, private readonly AdministratorManager $administratorManager, private readonly EntityManagerInterface $entityManager, + #[Autowire('%app.default_admin_password%')] + private readonly string $defaultAdminPassword = '' ) { parent::__construct(); } @@ -37,15 +40,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $login = self::DEFAULT_LOGIN; $email = self::DEFAULT_EMAIL; - $envPassword = getenv('PHPLIST_ADMIN_PASSWORD'); - $envPassword = is_string($envPassword) && trim($envPassword) !== '' ? $envPassword : null; + $password = $this->defaultAdminPassword !== '' ? $this->defaultAdminPassword : null; $allPrivileges = $this->allPrivilegesGranted(); $existing = $this->administratorRepository->findOneBy(['loginName' => $login]); if ($existing === null) { // If creating the default admin, require a password. Prefer env var, else prompt for input. - $password = $envPassword; if ($password === null) { /** @var QuestionHelper $helper */ $helper = $this->getHelper('question'); From 4f0e4c21d32aed59ad83f381759d8c109c65985a Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 4 Aug 2026 15:57:24 +0400 Subject: [PATCH 03/39] fix: correct key reference in config retrieval and update embargo condition in message query --- .../Service/Provider/ConfigProvider.php | 2 +- .../Messaging/Command/ProcessQueueCommand.php | 29 +++++-------------- .../Command/SendTestEmailCommand.php | 11 +++---- .../Repository/MessageRepository.php | 2 +- 4 files changed, 13 insertions(+), 31 deletions(-) diff --git a/src/Domain/Configuration/Service/Provider/ConfigProvider.php b/src/Domain/Configuration/Service/Provider/ConfigProvider.php index 3b22285f7..2890a86d8 100644 --- a/src/Domain/Configuration/Service/Provider/ConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/ConfigProvider.php @@ -33,7 +33,7 @@ public function isEnabled(ConfigOption $key): bool if (!in_array($key, $this->booleanValues, true)) { throw new InvalidArgumentException('Invalid boolean value key'); } - $config = $this->configRepository->findOneBy(['item' => $key->value]); + $config = $this->configRepository->findOneBy(['key' => $key->value]); if ($config !== null) { return filter_var($config->getValue(), FILTER_VALIDATE_BOOLEAN); diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php index 080c24cb6..69bf967b7 100644 --- a/src/Domain/Messaging/Command/ProcessQueueCommand.php +++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php @@ -27,31 +27,16 @@ )] class ProcessQueueCommand extends Command { - private MessageRepository $messageRepository; - private LockFactory $lockFactory; - private MessageProcessingPreparator $messagePreparator; - private MessageBusInterface $messageBus; - private ConfigProvider $configProvider; - private TranslatorInterface $translator; - private EntityManagerInterface $entityManager; - public function __construct( - MessageRepository $messageRepository, - LockFactory $lockFactory, - MessageProcessingPreparator $messagePreparator, - MessageBusInterface $messageBus, - ConfigProvider $configProvider, - TranslatorInterface $translator, - EntityManagerInterface $entityManager, + private readonly MessageRepository $messageRepository, + private readonly LockFactory $lockFactory, + private readonly MessageProcessingPreparator $messagePreparator, + private readonly MessageBusInterface $messageBus, + private readonly ConfigProvider $configProvider, + private readonly TranslatorInterface $translator, + private readonly EntityManagerInterface $entityManager, ) { parent::__construct(); - $this->messageRepository = $messageRepository; - $this->lockFactory = $lockFactory; - $this->messagePreparator = $messagePreparator; - $this->messageBus = $messageBus; - $this->configProvider = $configProvider; - $this->translator = $translator; - $this->entityManager = $entityManager; } protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/src/Domain/Messaging/Command/SendTestEmailCommand.php b/src/Domain/Messaging/Command/SendTestEmailCommand.php index e96702393..2766af9d9 100644 --- a/src/Domain/Messaging/Command/SendTestEmailCommand.php +++ b/src/Domain/Messaging/Command/SendTestEmailCommand.php @@ -21,14 +21,11 @@ )] class SendTestEmailCommand extends Command { - private EmailService $emailService; - private TranslatorInterface $translator; - - public function __construct(EmailService $emailService, TranslatorInterface $translator) - { + public function __construct( + private readonly EmailService $emailService, + private readonly TranslatorInterface $translator + ) { parent::__construct(); - $this->emailService = $emailService; - $this->translator = $translator; } protected function configure(): void diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php index d18ce68be..cc22602c3 100644 --- a/src/Domain/Messaging/Repository/MessageRepository.php +++ b/src/Domain/Messaging/Repository/MessageRepository.php @@ -116,7 +116,7 @@ public function getByStatusAndEmbargo(Message\MessageStatus $status, DateTimeImm { return $this->createQueryBuilder('m') ->where('m.metadata.status = :status') - ->andWhere('m.schedule.embargo IS NULL OR m.embargo <= :embargo') + ->andWhere('m.schedule.embargo IS NULL OR m.schedule.embargo <= :embargo') ->setParameter('status', $status->value) ->setParameter('embargo', $embargo) ->getQuery() From 45813fe4ecf254566c61a44c492b48e0ba961fe9 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 4 Aug 2026 16:13:00 +0400 Subject: [PATCH 04/39] feat: load messenger configuration and update campaign processor message paths --- composer.json | 3 ++- config/packages/messenger.yaml | 4 ++-- src/Core/ApplicationKernel.php | 5 +++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 2378bdeb9..4bab2a2c6 100644 --- a/composer.json +++ b/composer.json @@ -88,7 +88,8 @@ "setasign/fpdf": "^1.8", "phpdocumentor/reflection-docblock": "^5.2", "guzzlehttp/guzzle": "^7.4.5", - "symfony/dotenv": "^6.4" + "symfony/dotenv": "^6.4", + "symfony/doctrine-messenger": "^6.4" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 4193c5019..2c32337b2 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -28,7 +28,7 @@ framework: '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 + 'PhpList\Core\Domain\Messaging\Message\CampaignProcessor\CampaignProcessorMessage': async_email + 'PhpList\Core\Domain\Messaging\Message\CampaignProcessor\SyncCampaignProcessorMessage': sync 'PhpList\Core\Domain\Subscription\Message\DynamicTableMessage': sync diff --git a/src/Core/ApplicationKernel.php b/src/Core/ApplicationKernel.php index 8f43e62be..8f67de65d 100644 --- a/src/Core/ApplicationKernel.php +++ b/src/Core/ApplicationKernel.php @@ -128,6 +128,11 @@ public function registerContainerConfiguration(LoaderInterface $loader): void if (file_exists($twigConfigFile)) { $loader->load($twigConfigFile); } + + $messengerConfigFile = $this->getApplicationDir() . '/config/packages/messenger.yaml'; + if (file_exists($messengerConfigFile)) { + $loader->load($messengerConfigFile); + } } /** From 5387bb89e42abd3ff4eb11fea104becb6750eaee Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 4 Aug 2026 16:35:21 +0400 Subject: [PATCH 05/39] fix: remove Requeued state and update allowed transitions for Suspended and Sent --- README.md | 5 +++++ src/Domain/Messaging/Model/Message/MessageStatus.php | 5 +---- .../Configuration/Service/Provider/ConfigProviderTest.php | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d82c81490..cddd934b3 100755 --- a/README.md +++ b/README.md @@ -228,3 +228,8 @@ vendor/bin/phpstan analyse -c phpstan.neon; vendor/bin/phpmd src/ text config/PHPMD/rules.xml; vendor/bin/phpcs --standard=config/PhpCodeSniffer/ --ignore=*/Migrations/* bin/ src/ tests/ public/; ``` + + +```bash +php bin/console messenger:consume async_email +``` diff --git a/src/Domain/Messaging/Model/Message/MessageStatus.php b/src/Domain/Messaging/Model/Message/MessageStatus.php index 789f07c20..7f6e0daad 100644 --- a/src/Domain/Messaging/Model/Message/MessageStatus.php +++ b/src/Domain/Messaging/Model/Message/MessageStatus.php @@ -12,7 +12,6 @@ enum MessageStatus: string case InProcess = 'inprocess'; case Sent = 'sent'; case Suspended = 'suspended'; - case Requeued = 'requeued'; /** * Allowed transitions for each state @@ -23,12 +22,10 @@ public function allowedTransitions(): array { return match ($this) { self::Draft => [self::Prepared, self::Submitted], - self::Suspended => [self::Submitted, self::Requeued], + self::Suspended, self::Sent => [self::Submitted], self::Submitted => [self::Prepared, self::InProcess, self::Suspended], self::Prepared => [self::InProcess, self::Suspended], self::InProcess => [self::Sent, self::Suspended, self::Submitted], - self::Requeued => [self::InProcess, self::Suspended], - self::Sent => [self::Requeued], }; } diff --git a/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php b/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php index ab6e90c51..bd7eee08c 100644 --- a/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php +++ b/tests/Unit/Domain/Configuration/Service/Provider/ConfigProviderTest.php @@ -71,7 +71,7 @@ public function testIsEnabledUsesRepositoryValueWhenPresent(): void $this->repo ->expects($this->once()) ->method('findOneBy') - ->with(['item' => $key->value]) + ->with(['key' => $key->value]) ->willReturn($configEntity); // Defaults should not be consulted if repo has value @@ -90,7 +90,7 @@ public function testIsEnabledFallsBackToDefaultsWhenRepoMissing(): void $this->repo ->expects($this->once()) ->method('findOneBy') - ->with(['item' => $key->value]) + ->with(['key' => $key->value]) ->willReturn(null); $this->defaults From 314c2471539735cfb4037abe55d1a9fe666168c8 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 8 Aug 2026 11:22:42 +0400 Subject: [PATCH 06/39] feat: update database table names to remove 'phplist_' prefix and add TablePrefixListener for dynamic table prefixing --- config/services.yml | 4 +++ src/Core/Doctrine/TablePrefixListener.php | 34 +++++++++++++++++++ src/Domain/Analytics/Model/LinkTrack.php | 2 +- .../Analytics/Model/LinkTrackForward.php | 2 +- src/Domain/Analytics/Model/LinkTrackMl.php | 2 +- .../Analytics/Model/LinkTrackUmlClick.php | 2 +- .../Analytics/Model/LinkTrackUserClick.php | 2 +- .../Analytics/Model/UserMessageView.php | 2 +- src/Domain/Analytics/Model/UserStats.php | 2 +- src/Domain/Configuration/Model/Config.php | 2 +- src/Domain/Configuration/Model/EventLog.php | 2 +- src/Domain/Configuration/Model/I18n.php | 2 +- src/Domain/Configuration/Model/UrlCache.php | 2 +- .../Model/AdminAttributeDefinition.php | 2 +- .../Identity/Model/AdminAttributeValue.php | 2 +- src/Domain/Identity/Model/AdminLogin.php | 2 +- .../Identity/Model/AdminPasswordRequest.php | 2 +- src/Domain/Identity/Model/Administrator.php | 2 +- .../Identity/Model/AdministratorToken.php | 2 +- src/Domain/Messaging/Model/Attachment.php | 2 +- src/Domain/Messaging/Model/Bounce.php | 2 +- src/Domain/Messaging/Model/BounceRegex.php | 2 +- .../Messaging/Model/BounceRegexBounce.php | 2 +- src/Domain/Messaging/Model/ListMessage.php | 2 +- src/Domain/Messaging/Model/Message.php | 2 +- .../Messaging/Model/MessageAttachment.php | 2 +- src/Domain/Messaging/Model/MessageData.php | 2 +- src/Domain/Messaging/Model/SendProcess.php | 2 +- src/Domain/Messaging/Model/Template.php | 2 +- src/Domain/Messaging/Model/TemplateImage.php | 2 +- src/Domain/Messaging/Model/UserMessage.php | 2 +- .../Messaging/Model/UserMessageBounce.php | 2 +- .../Messaging/Model/UserMessageForward.php | 2 +- .../Subscription/Model/SubscribePage.php | 2 +- .../Subscription/Model/SubscribePageData.php | 2 +- src/Domain/Subscription/Model/Subscriber.php | 2 +- .../Model/SubscriberAttributeDefinition.php | 2 +- .../Model/SubscriberAttributeValue.php | 2 +- .../Subscription/Model/SubscriberHistory.php | 2 +- .../Subscription/Model/SubscriberList.php | 2 +- .../Subscription/Model/Subscription.php | 2 +- .../Subscription/Model/UserBlacklist.php | 2 +- .../Subscription/Model/UserBlacklistData.php | 2 +- 43 files changed, 79 insertions(+), 41 deletions(-) create mode 100644 src/Core/Doctrine/TablePrefixListener.php diff --git a/config/services.yml b/config/services.yml index 7c053ed94..1fcc3b351 100644 --- a/config/services.yml +++ b/config/services.yml @@ -51,6 +51,10 @@ services: tags: - { name: 'doctrine.dbal.schema_filter', connection: 'default' } + PhpList\Core\Core\Doctrine\TablePrefixListener: + arguments: + $tablePrefix: '%database_prefix%' + HTMLPurifier_Config: class: HTMLPurifier_Config factory: [ 'HTMLPurifier_Config', 'createDefault' ] diff --git a/src/Core/Doctrine/TablePrefixListener.php b/src/Core/Doctrine/TablePrefixListener.php new file mode 100644 index 000000000..92eeafcd9 --- /dev/null +++ b/src/Core/Doctrine/TablePrefixListener.php @@ -0,0 +1,34 @@ +getClassMetadata(); + + if ($metadata->isMappedSuperclass || $metadata->isEmbeddedClass) { + return; + } + + if (!str_starts_with($metadata->getName(), 'PhpList\\Core\\Domain\\')) { + return; + } + + $metadata->setPrimaryTable([ + 'name' => $this->tablePrefix . $metadata->getTableName(), + ]); + } +} \ No newline at end of file diff --git a/src/Domain/Analytics/Model/LinkTrack.php b/src/Domain/Analytics/Model/LinkTrack.php index 848dde5e6..1c8b3755c 100644 --- a/src/Domain/Analytics/Model/LinkTrack.php +++ b/src/Domain/Analytics/Model/LinkTrack.php @@ -12,7 +12,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\Identity; #[ORM\Entity(repositoryClass: LinkTrackRepository::class)] -#[ORM\Table(name: 'phplist_linktrack')] +#[ORM\Table(name: 'linktrack')] #[ORM\UniqueConstraint(name: 'phplist_linktrack_miduidurlindex', columns: ['messageid', 'userid', 'url'])] #[ORM\Index(name: 'phplist_linktrack_midindex', columns: ['messageid'])] #[ORM\Index(name: 'phplist_linktrack_miduidindex', columns: ['messageid', 'userid'])] diff --git a/src/Domain/Analytics/Model/LinkTrackForward.php b/src/Domain/Analytics/Model/LinkTrackForward.php index 0e03c017f..2bc059b0b 100644 --- a/src/Domain/Analytics/Model/LinkTrackForward.php +++ b/src/Domain/Analytics/Model/LinkTrackForward.php @@ -10,7 +10,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\Identity; #[ORM\Entity(repositoryClass: LinkTrackForwardRepository::class)] -#[ORM\Table(name: 'phplist_linktrack_forward')] +#[ORM\Table(name: 'linktrack_forward')] #[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'])] diff --git a/src/Domain/Analytics/Model/LinkTrackMl.php b/src/Domain/Analytics/Model/LinkTrackMl.php index 419c79110..ff6bab0ac 100644 --- a/src/Domain/Analytics/Model/LinkTrackMl.php +++ b/src/Domain/Analytics/Model/LinkTrackMl.php @@ -10,7 +10,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; #[ORM\Entity(repositoryClass: LinkTrackMlRepository::class)] -#[ORM\Table(name: 'phplist_linktrack_ml')] +#[ORM\Table(name: 'linktrack_ml')] #[ORM\Index(name: 'phplist_linktrack_ml_fwdindex', columns: ['forwardid'])] #[ORM\Index(name: 'phplist_linktrack_ml_midindex', columns: ['messageid'])] class LinkTrackMl implements DomainModel diff --git a/src/Domain/Analytics/Model/LinkTrackUmlClick.php b/src/Domain/Analytics/Model/LinkTrackUmlClick.php index 3faf811dd..93a4b4879 100644 --- a/src/Domain/Analytics/Model/LinkTrackUmlClick.php +++ b/src/Domain/Analytics/Model/LinkTrackUmlClick.php @@ -11,7 +11,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\Identity; #[ORM\Entity(repositoryClass: LinkTrackUmlClickRepository::class)] -#[ORM\Table(name: 'phplist_linktrack_uml_click')] +#[ORM\Table(name: 'linktrack_uml_click')] #[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'])] diff --git a/src/Domain/Analytics/Model/LinkTrackUserClick.php b/src/Domain/Analytics/Model/LinkTrackUserClick.php index 27205cbbf..3725cf15f 100644 --- a/src/Domain/Analytics/Model/LinkTrackUserClick.php +++ b/src/Domain/Analytics/Model/LinkTrackUserClick.php @@ -10,7 +10,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; #[ORM\Entity(repositoryClass: LinkTrackUserClickRepository::class)] -#[ORM\Table(name: 'phplist_linktrack_userclick')] +#[ORM\Table(name: 'linktrack_userclick')] #[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'])] diff --git a/src/Domain/Analytics/Model/UserMessageView.php b/src/Domain/Analytics/Model/UserMessageView.php index b391d3f36..7c0e1b363 100644 --- a/src/Domain/Analytics/Model/UserMessageView.php +++ b/src/Domain/Analytics/Model/UserMessageView.php @@ -11,7 +11,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\Identity; #[ORM\Entity(repositoryClass: UserMessageViewRepository::class)] -#[ORM\Table(name: 'phplist_user_message_view')] +#[ORM\Table(name: 'user_message_view')] #[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'])] diff --git a/src/Domain/Analytics/Model/UserStats.php b/src/Domain/Analytics/Model/UserStats.php index c7b4b97e5..57e671f71 100644 --- a/src/Domain/Analytics/Model/UserStats.php +++ b/src/Domain/Analytics/Model/UserStats.php @@ -10,7 +10,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\Identity; #[ORM\Entity(repositoryClass: UserStatsRepository::class)] -#[ORM\Table(name: 'phplist_userstats')] +#[ORM\Table(name: 'userstats')] #[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'])] diff --git a/src/Domain/Configuration/Model/Config.php b/src/Domain/Configuration/Model/Config.php index 00f0a6c58..80f60f19c 100644 --- a/src/Domain/Configuration/Model/Config.php +++ b/src/Domain/Configuration/Model/Config.php @@ -9,7 +9,7 @@ use PhpList\Core\Domain\Configuration\Repository\ConfigRepository; #[ORM\Entity(repositoryClass: ConfigRepository::class)] -#[ORM\Table(name: 'phplist_config')] +#[ORM\Table(name: 'config')] class Config implements DomainModel { #[ORM\Id] diff --git a/src/Domain/Configuration/Model/EventLog.php b/src/Domain/Configuration/Model/EventLog.php index c0cff22b7..7e1ac3aff 100644 --- a/src/Domain/Configuration/Model/EventLog.php +++ b/src/Domain/Configuration/Model/EventLog.php @@ -12,7 +12,7 @@ use PhpList\Core\Domain\Configuration\Repository\EventLogRepository; #[ORM\Entity(repositoryClass: EventLogRepository::class)] -#[ORM\Table(name: 'phplist_eventlog')] +#[ORM\Table(name: 'eventlog')] #[ORM\Index(name: 'phplist_eventlog_enteredidx', columns: ['entered'])] #[ORM\Index(name: 'phplist_eventlog_pageidx', columns: ['page'])] #[ORM\HasLifecycleCallbacks] diff --git a/src/Domain/Configuration/Model/I18n.php b/src/Domain/Configuration/Model/I18n.php index 72397bb49..0f709259e 100644 --- a/src/Domain/Configuration/Model/I18n.php +++ b/src/Domain/Configuration/Model/I18n.php @@ -14,7 +14,7 @@ * Symfony\Contracts\Translation will be used instead. */ #[ORM\Entity(repositoryClass: I18nRepository::class)] -#[ORM\Table(name: 'phplist_i18n')] +#[ORM\Table(name: 'i18n')] #[ORM\UniqueConstraint(name: 'phplist_i18n_lanorigunq', columns: ['lan', 'original'])] #[ORM\Index(name: 'phplist_i18n_lanorigidx', columns: ['lan', 'original'])] class I18n implements DomainModel diff --git a/src/Domain/Configuration/Model/UrlCache.php b/src/Domain/Configuration/Model/UrlCache.php index b6d032b92..a83942122 100644 --- a/src/Domain/Configuration/Model/UrlCache.php +++ b/src/Domain/Configuration/Model/UrlCache.php @@ -11,7 +11,7 @@ use PhpList\Core\Domain\Configuration\Repository\UrlCacheRepository; #[ORM\Entity(repositoryClass: UrlCacheRepository::class)] -#[ORM\Table(name: 'phplist_urlcache')] +#[ORM\Table(name: 'urlcache')] #[ORM\Index(name: 'phplist_urlcache_urlindex', columns: ['url'])] #[ORM\HasLifecycleCallbacks] class UrlCache implements DomainModel, Identity diff --git a/src/Domain/Identity/Model/AdminAttributeDefinition.php b/src/Domain/Identity/Model/AdminAttributeDefinition.php index 3fe45e766..c2b20d0b1 100644 --- a/src/Domain/Identity/Model/AdminAttributeDefinition.php +++ b/src/Domain/Identity/Model/AdminAttributeDefinition.php @@ -12,7 +12,7 @@ use PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository; #[ORM\Entity(repositoryClass: AdminAttributeDefinitionRepository::class)] -#[ORM\Table(name: 'phplist_adminattribute')] +#[ORM\Table(name: 'adminattribute')] #[ORM\HasLifecycleCallbacks] class AdminAttributeDefinition implements DomainModel, Identity { diff --git a/src/Domain/Identity/Model/AdminAttributeValue.php b/src/Domain/Identity/Model/AdminAttributeValue.php index 3d99ba736..35188ec64 100644 --- a/src/Domain/Identity/Model/AdminAttributeValue.php +++ b/src/Domain/Identity/Model/AdminAttributeValue.php @@ -9,7 +9,7 @@ use PhpList\Core\Domain\Identity\Repository\AdminAttributeValueRepository; #[ORM\Entity(repositoryClass: AdminAttributeValueRepository::class)] -#[ORM\Table(name: 'phplist_admin_attribute')] +#[ORM\Table(name: 'admin_attribute')] #[ORM\HasLifecycleCallbacks] class AdminAttributeValue implements DomainModel { diff --git a/src/Domain/Identity/Model/AdminLogin.php b/src/Domain/Identity/Model/AdminLogin.php index 91be3331a..74d9abee5 100644 --- a/src/Domain/Identity/Model/AdminLogin.php +++ b/src/Domain/Identity/Model/AdminLogin.php @@ -12,7 +12,7 @@ use PhpList\Core\Domain\Identity\Repository\AdminLoginRepository; #[ORM\Entity(repositoryClass: AdminLoginRepository::class)] -#[ORM\Table(name: 'phplist_admin_login')] +#[ORM\Table(name: 'admin_login')] #[ORM\HasLifecycleCallbacks] class AdminLogin implements DomainModel, Identity { diff --git a/src/Domain/Identity/Model/AdminPasswordRequest.php b/src/Domain/Identity/Model/AdminPasswordRequest.php index 0d761adf6..230e675ad 100644 --- a/src/Domain/Identity/Model/AdminPasswordRequest.php +++ b/src/Domain/Identity/Model/AdminPasswordRequest.php @@ -11,7 +11,7 @@ use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; #[ORM\Entity(repositoryClass: AdminPasswordRequestRepository::class)] -#[ORM\Table(name: 'phplist_admin_password_request')] +#[ORM\Table(name: 'admin_password_request')] class AdminPasswordRequest implements DomainModel, Identity { #[ORM\Id] diff --git a/src/Domain/Identity/Model/Administrator.php b/src/Domain/Identity/Model/Administrator.php index 2f3de5eb3..f6c9ba050 100644 --- a/src/Domain/Identity/Model/Administrator.php +++ b/src/Domain/Identity/Model/Administrator.php @@ -25,7 +25,7 @@ * @author Tatevik Grigoryan */ #[ORM\Entity(repositoryClass: AdministratorRepository::class)] -#[ORM\Table(name: 'phplist_admin')] +#[ORM\Table(name: 'admin')] #[ORM\UniqueConstraint(name: 'phplist_admin_loginnameidx', columns: ['loginname'])] #[ORM\HasLifecycleCallbacks] class Administrator implements DomainModel, Identity, CreationDate, ModificationDate diff --git a/src/Domain/Identity/Model/AdministratorToken.php b/src/Domain/Identity/Model/AdministratorToken.php index 4e37b2b56..3d9da22dd 100644 --- a/src/Domain/Identity/Model/AdministratorToken.php +++ b/src/Domain/Identity/Model/AdministratorToken.php @@ -19,7 +19,7 @@ * @author Tateik Grigoryan */ #[ORM\Entity(repositoryClass: AdministratorTokenRepository::class)] -#[ORM\Table(name: 'phplist_admintoken')] +#[ORM\Table(name: 'admintoken')] #[ORM\HasLifecycleCallbacks] class AdministratorToken implements DomainModel, Identity, CreationDate { diff --git a/src/Domain/Messaging/Model/Attachment.php b/src/Domain/Messaging/Model/Attachment.php index d49cd3860..a8b38b4b8 100644 --- a/src/Domain/Messaging/Model/Attachment.php +++ b/src/Domain/Messaging/Model/Attachment.php @@ -10,7 +10,7 @@ use PhpList\Core\Domain\Messaging\Repository\AttachmentRepository; #[ORM\Entity(repositoryClass: AttachmentRepository::class)] -#[ORM\Table(name: 'phplist_attachment')] +#[ORM\Table(name: 'attachment')] class Attachment implements DomainModel, Identity { public const FORWARD = 'forwarded'; diff --git a/src/Domain/Messaging/Model/Bounce.php b/src/Domain/Messaging/Model/Bounce.php index 54e5895d5..071b869f4 100644 --- a/src/Domain/Messaging/Model/Bounce.php +++ b/src/Domain/Messaging/Model/Bounce.php @@ -11,7 +11,7 @@ use PhpList\Core\Domain\Messaging\Repository\BounceRepository; #[ORM\Entity(repositoryClass: BounceRepository::class)] -#[ORM\Table(name: 'phplist_bounce')] +#[ORM\Table(name: 'bounce')] #[ORM\Index(name: 'phplist_bounce_dateindex', columns: ['date'])] #[ORM\Index(name: 'phplist_bounce_statusidx', columns: ['status'])] class Bounce implements DomainModel, Identity diff --git a/src/Domain/Messaging/Model/BounceRegex.php b/src/Domain/Messaging/Model/BounceRegex.php index c54ca7c08..5d0d05216 100644 --- a/src/Domain/Messaging/Model/BounceRegex.php +++ b/src/Domain/Messaging/Model/BounceRegex.php @@ -10,7 +10,7 @@ use PhpList\Core\Domain\Messaging\Repository\BounceRegexRepository; #[ORM\Entity(repositoryClass: BounceRegexRepository::class)] -#[ORM\Table(name: 'phplist_bounceregex')] +#[ORM\Table(name: 'bounceregex')] #[ORM\UniqueConstraint(name: 'phplist_bounceregex_regex', columns: ['regexhash'])] class BounceRegex implements DomainModel, Identity { diff --git a/src/Domain/Messaging/Model/BounceRegexBounce.php b/src/Domain/Messaging/Model/BounceRegexBounce.php index e815cd1f4..c50d20d57 100644 --- a/src/Domain/Messaging/Model/BounceRegexBounce.php +++ b/src/Domain/Messaging/Model/BounceRegexBounce.php @@ -9,7 +9,7 @@ use PhpList\Core\Domain\Messaging\Repository\BounceRegexBounceRepository; #[ORM\Entity(repositoryClass: BounceRegexBounceRepository::class)] -#[ORM\Table(name: 'phplist_bounceregex_bounce')] +#[ORM\Table(name: 'bounceregex_bounce')] class BounceRegexBounce implements DomainModel { #[ORM\Id] diff --git a/src/Domain/Messaging/Model/ListMessage.php b/src/Domain/Messaging/Model/ListMessage.php index 3a5d655ae..d624b6997 100644 --- a/src/Domain/Messaging/Model/ListMessage.php +++ b/src/Domain/Messaging/Model/ListMessage.php @@ -14,7 +14,7 @@ use PhpList\Core\Domain\Subscription\Model\SubscriberList; #[ORM\Entity(repositoryClass: ListMessageRepository::class)] -#[ORM\Table(name: 'phplist_listmessage')] +#[ORM\Table(name: 'listmessage')] #[ORM\UniqueConstraint(name: 'phplist_listmessage_messageid', columns: ['messageid', 'listid'])] #[ORM\Index(name: 'phplist_listmessage_listmessageidx', columns: ['listid', 'messageid'])] #[ORM\HasLifecycleCallbacks] diff --git a/src/Domain/Messaging/Model/Message.php b/src/Domain/Messaging/Model/Message.php index 4d5f4e8fd..072661b4e 100644 --- a/src/Domain/Messaging/Model/Message.php +++ b/src/Domain/Messaging/Model/Message.php @@ -22,7 +22,7 @@ use PhpList\Core\Domain\Messaging\Repository\MessageRepository; #[ORM\Entity(repositoryClass: MessageRepository::class)] -#[ORM\Table(name: 'phplist_message')] +#[ORM\Table(name: 'message')] #[ORM\Index(name: 'phplist_message_uuididx', columns: ['uuid'])] #[ORM\HasLifecycleCallbacks] class Message implements DomainModel, Identity, ModificationDate, OwnableInterface diff --git a/src/Domain/Messaging/Model/MessageAttachment.php b/src/Domain/Messaging/Model/MessageAttachment.php index e26d0d879..2007ad5ca 100644 --- a/src/Domain/Messaging/Model/MessageAttachment.php +++ b/src/Domain/Messaging/Model/MessageAttachment.php @@ -9,7 +9,7 @@ use PhpList\Core\Domain\Messaging\Repository\MessageAttachmentRepository; #[ORM\Entity(repositoryClass: MessageAttachmentRepository::class)] -#[ORM\Table(name: 'phplist_message_attachment')] +#[ORM\Table(name: 'message_attachment')] #[ORM\Index(name: 'phplist_message_attachment_messageattidx', columns: ['messageid', 'attachmentid'])] #[ORM\Index(name: 'phplist_message_attachment_messageidx', columns: ['messageid'])] class MessageAttachment implements Identity diff --git a/src/Domain/Messaging/Model/MessageData.php b/src/Domain/Messaging/Model/MessageData.php index 567442514..d364889c2 100644 --- a/src/Domain/Messaging/Model/MessageData.php +++ b/src/Domain/Messaging/Model/MessageData.php @@ -9,7 +9,7 @@ use PhpList\Core\Domain\Messaging\Repository\MessageDataRepository; #[ORM\Entity(repositoryClass: MessageDataRepository::class)] -#[ORM\Table(name: 'phplist_messagedata')] +#[ORM\Table(name: 'messagedata')] class MessageData implements DomainModel { #[ORM\Id] diff --git a/src/Domain/Messaging/Model/SendProcess.php b/src/Domain/Messaging/Model/SendProcess.php index 5faeaf355..14abe737a 100644 --- a/src/Domain/Messaging/Model/SendProcess.php +++ b/src/Domain/Messaging/Model/SendProcess.php @@ -12,7 +12,7 @@ use PhpList\Core\Domain\Messaging\Repository\SendProcessRepository; #[ORM\Entity(repositoryClass: SendProcessRepository::class)] -#[ORM\Table(name: 'phplist_sendprocess')] +#[ORM\Table(name: 'sendprocess')] #[ORM\HasLifecycleCallbacks] class SendProcess implements DomainModel, Identity, ModificationDate { diff --git a/src/Domain/Messaging/Model/Template.php b/src/Domain/Messaging/Model/Template.php index dc1b67a0e..3bbd8c8c8 100644 --- a/src/Domain/Messaging/Model/Template.php +++ b/src/Domain/Messaging/Model/Template.php @@ -12,7 +12,7 @@ use PhpList\Core\Domain\Messaging\Repository\TemplateRepository; #[ORM\Entity(repositoryClass: TemplateRepository::class)] -#[ORM\Table(name: 'phplist_template')] +#[ORM\Table(name: 'template')] #[ORM\UniqueConstraint(name: 'phplist_template_title', columns: ['title'])] class Template implements DomainModel, Identity { diff --git a/src/Domain/Messaging/Model/TemplateImage.php b/src/Domain/Messaging/Model/TemplateImage.php index c1c5c8c42..a0da46927 100644 --- a/src/Domain/Messaging/Model/TemplateImage.php +++ b/src/Domain/Messaging/Model/TemplateImage.php @@ -10,7 +10,7 @@ use PhpList\Core\Domain\Messaging\Repository\TemplateImageRepository; #[ORM\Entity(repositoryClass: TemplateImageRepository::class)] -#[ORM\Table(name: 'phplist_templateimage')] +#[ORM\Table(name: 'templateimage')] #[ORM\Index(name: 'phplist_templateimage_templateidx', columns: ['template'])] class TemplateImage implements DomainModel, Identity { diff --git a/src/Domain/Messaging/Model/UserMessage.php b/src/Domain/Messaging/Model/UserMessage.php index d5fe202cf..93b457f34 100644 --- a/src/Domain/Messaging/Model/UserMessage.php +++ b/src/Domain/Messaging/Model/UserMessage.php @@ -12,7 +12,7 @@ use PhpList\Core\Domain\Subscription\Model\Subscriber; #[ORM\Entity(repositoryClass: UserMessageRepository::class)] -#[ORM\Table(name: 'phplist_usermessage')] +#[ORM\Table(name: 'usermessage')] #[ORM\Index(name: 'phplist_usermessage_enteredindex', columns: ['entered'])] #[ORM\Index(name: 'phplist_usermessage_messageidindex', columns: ['messageid'])] #[ORM\Index(name: 'phplist_usermessage_statusidx', columns: ['status'])] diff --git a/src/Domain/Messaging/Model/UserMessageBounce.php b/src/Domain/Messaging/Model/UserMessageBounce.php index 3b58bf476..48b97b5cb 100644 --- a/src/Domain/Messaging/Model/UserMessageBounce.php +++ b/src/Domain/Messaging/Model/UserMessageBounce.php @@ -11,7 +11,7 @@ use PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository; #[ORM\Entity(repositoryClass: UserMessageBounceRepository::class)] -#[ORM\Table(name: 'phplist_user_message_bounce')] +#[ORM\Table(name: 'user_message_bounce')] #[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'])] diff --git a/src/Domain/Messaging/Model/UserMessageForward.php b/src/Domain/Messaging/Model/UserMessageForward.php index 3b920189b..1dd32806f 100644 --- a/src/Domain/Messaging/Model/UserMessageForward.php +++ b/src/Domain/Messaging/Model/UserMessageForward.php @@ -11,7 +11,7 @@ use PhpList\Core\Domain\Messaging\Repository\UserMessageForwardRepository; #[ORM\Entity(repositoryClass: UserMessageForwardRepository::class)] -#[ORM\Table(name: 'phplist_user_message_forward')] +#[ORM\Table(name: 'user_message_forward')] #[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'])] diff --git a/src/Domain/Subscription/Model/SubscribePage.php b/src/Domain/Subscription/Model/SubscribePage.php index 3b4849200..bc4ea54f1 100644 --- a/src/Domain/Subscription/Model/SubscribePage.php +++ b/src/Domain/Subscription/Model/SubscribePage.php @@ -12,7 +12,7 @@ use PhpList\Core\Domain\Subscription\Repository\SubscriberPageRepository; #[ORM\Entity(repositoryClass: SubscriberPageRepository::class)] -#[ORM\Table(name: 'phplist_subscribepage')] +#[ORM\Table(name: 'subscribepage')] class SubscribePage implements DomainModel, Identity, OwnableInterface { #[ORM\Id] diff --git a/src/Domain/Subscription/Model/SubscribePageData.php b/src/Domain/Subscription/Model/SubscribePageData.php index 7d8dcd4eb..8b94e7295 100644 --- a/src/Domain/Subscription/Model/SubscribePageData.php +++ b/src/Domain/Subscription/Model/SubscribePageData.php @@ -9,7 +9,7 @@ use PhpList\Core\Domain\Subscription\Repository\SubscriberPageDataRepository; #[ORM\Entity(repositoryClass: SubscriberPageDataRepository::class)] -#[ORM\Table(name: 'phplist_subscribepage_data')] +#[ORM\Table(name: 'subscribepage_data')] class SubscribePageData implements DomainModel { #[ORM\Id] diff --git a/src/Domain/Subscription/Model/Subscriber.php b/src/Domain/Subscription/Model/Subscriber.php index 97d45b834..8eda5ed6a 100644 --- a/src/Domain/Subscription/Model/Subscriber.php +++ b/src/Domain/Subscription/Model/Subscriber.php @@ -24,7 +24,7 @@ * @SuppressWarnings(PHPMD.ExcessivePublicCount) */ #[ORM\Entity(repositoryClass: SubscriberRepository::class)] -#[ORM\Table(name: 'phplist_user_user')] +#[ORM\Table(name: 'user_user')] #[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'])] diff --git a/src/Domain/Subscription/Model/SubscriberAttributeDefinition.php b/src/Domain/Subscription/Model/SubscriberAttributeDefinition.php index 26b7a786d..dbe397d2e 100644 --- a/src/Domain/Subscription/Model/SubscriberAttributeDefinition.php +++ b/src/Domain/Subscription/Model/SubscriberAttributeDefinition.php @@ -12,7 +12,7 @@ use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; #[ORM\Entity(repositoryClass: SubscriberAttributeDefinitionRepository::class)] -#[ORM\Table(name: 'phplist_user_attribute')] +#[ORM\Table(name: 'user_attribute')] #[ORM\Index(name: 'phplist_user_attribute_idnameindex', columns: ['id', 'name'])] #[ORM\Index(name: 'phplist_user_attribute_nameindex', columns: ['name'])] class SubscriberAttributeDefinition implements DomainModel, Identity diff --git a/src/Domain/Subscription/Model/SubscriberAttributeValue.php b/src/Domain/Subscription/Model/SubscriberAttributeValue.php index 3af333ffb..6678b489d 100644 --- a/src/Domain/Subscription/Model/SubscriberAttributeValue.php +++ b/src/Domain/Subscription/Model/SubscriberAttributeValue.php @@ -9,7 +9,7 @@ use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeValueRepository; #[ORM\Entity(repositoryClass: SubscriberAttributeValueRepository::class)] -#[ORM\Table(name: 'phplist_user_user_attribute')] +#[ORM\Table(name: 'user_user_attribute')] #[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'])] diff --git a/src/Domain/Subscription/Model/SubscriberHistory.php b/src/Domain/Subscription/Model/SubscriberHistory.php index 1799c01b1..08f4f974b 100644 --- a/src/Domain/Subscription/Model/SubscriberHistory.php +++ b/src/Domain/Subscription/Model/SubscriberHistory.php @@ -11,7 +11,7 @@ use PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository; #[ORM\Entity(repositoryClass: SubscriberHistoryRepository::class)] -#[ORM\Table(name: 'phplist_user_user_history')] +#[ORM\Table(name: 'user_user_history')] #[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 diff --git a/src/Domain/Subscription/Model/SubscriberList.php b/src/Domain/Subscription/Model/SubscriberList.php index 621f855e8..d1d2a0713 100644 --- a/src/Domain/Subscription/Model/SubscriberList.php +++ b/src/Domain/Subscription/Model/SubscriberList.php @@ -25,7 +25,7 @@ * @author Tatevik Grigoryan */ #[ORM\Entity(repositoryClass: SubscriberListRepository::class)] -#[ORM\Table(name: 'phplist_list')] +#[ORM\Table(name: 'list')] #[ORM\Index(name: 'phplist_list_nameidx', columns: ['name'])] #[ORM\Index(name: 'phplist_list_listorderidx', columns: ['listorder'])] #[ORM\HasLifecycleCallbacks] diff --git a/src/Domain/Subscription/Model/Subscription.php b/src/Domain/Subscription/Model/Subscription.php index fe4b5e2ad..98df47030 100644 --- a/src/Domain/Subscription/Model/Subscription.php +++ b/src/Domain/Subscription/Model/Subscription.php @@ -22,7 +22,7 @@ * @author Tatevik Grigoryan */ #[ORM\Entity(repositoryClass: SubscriptionRepository::class)] -#[ORM\Table(name: 'phplist_listuser')] +#[ORM\Table(name: 'listuser')] #[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'])] diff --git a/src/Domain/Subscription/Model/UserBlacklist.php b/src/Domain/Subscription/Model/UserBlacklist.php index 9b1506863..f940f79b4 100644 --- a/src/Domain/Subscription/Model/UserBlacklist.php +++ b/src/Domain/Subscription/Model/UserBlacklist.php @@ -10,7 +10,7 @@ use PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository; #[ORM\Entity(repositoryClass: UserBlacklistRepository::class)] -#[ORM\Table(name: 'phplist_user_blacklist')] +#[ORM\Table(name: 'user_blacklist')] #[ORM\Index(name: 'phplist_user_blacklist_emailidx', columns: ['email'])] class UserBlacklist implements DomainModel { diff --git a/src/Domain/Subscription/Model/UserBlacklistData.php b/src/Domain/Subscription/Model/UserBlacklistData.php index ff1331616..52725e1bc 100644 --- a/src/Domain/Subscription/Model/UserBlacklistData.php +++ b/src/Domain/Subscription/Model/UserBlacklistData.php @@ -9,7 +9,7 @@ use PhpList\Core\Domain\Subscription\Repository\UserBlacklistDataRepository; #[ORM\Entity(repositoryClass: UserBlacklistDataRepository::class)] -#[ORM\Table(name: 'phplist_user_blacklist_data')] +#[ORM\Table(name: 'user_blacklist_data')] #[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 From 83a721692a915a8375ab62a0850bc33f7419e2cd Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 8 Aug 2026 12:09:37 +0400 Subject: [PATCH 07/39] feat: replace AbstractMigration with AbstractPrefixedMigration for dynamic table prefixing in migrations --- src/Migrations/AbstractPrefixedMigration.php | 36 +++++++++++++++++++ .../Version20251028092901MySqlInit.php | 3 +- .../Version20251028092902MySqlUpdate.php | 3 +- .../Version20251031072945PostGreInit.php | 3 +- src/Migrations/Version20260204094237.php | 3 +- src/Migrations/_template_migration.php.tpl | 3 +- 6 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 src/Migrations/AbstractPrefixedMigration.php diff --git a/src/Migrations/AbstractPrefixedMigration.php b/src/Migrations/AbstractPrefixedMigration.php new file mode 100644 index 000000000..f0f67b5c2 --- /dev/null +++ b/src/Migrations/AbstractPrefixedMigration.php @@ -0,0 +1,36 @@ +getTablePrefix(), + $sql + ), + $params, + $types + ); + } + + private function getTablePrefix(): string + { + $prefix = $_ENV['DATABASE_PREFIX'] ?? getenv('DATABASE_PREFIX'); + + return is_string($prefix) && $prefix !== '' ? $prefix : self::DEFAULT_PREFIX; + } +} diff --git a/src/Migrations/Version20251028092901MySqlInit.php b/src/Migrations/Version20251028092901MySqlInit.php index 5589fadf3..7de730c00 100644 --- a/src/Migrations/Version20251028092901MySqlInit.php +++ b/src/Migrations/Version20251028092901MySqlInit.php @@ -6,12 +6,11 @@ use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; -use Doctrine\Migrations\AbstractMigration; /** * Manual Migration */ -final class Version20251028092901MySqlInit extends AbstractMigration +final class Version20251028092901MySqlInit extends AbstractPrefixedMigration { public function getDescription(): string { diff --git a/src/Migrations/Version20251028092902MySqlUpdate.php b/src/Migrations/Version20251028092902MySqlUpdate.php index 2c0e872e7..2881be2f5 100644 --- a/src/Migrations/Version20251028092902MySqlUpdate.php +++ b/src/Migrations/Version20251028092902MySqlUpdate.php @@ -6,10 +6,9 @@ use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\DBAL\Platforms\MySQLPlatform; -use Doctrine\Migrations\AbstractMigration; use Doctrine\DBAL\Schema\Schema; -final class Version20251028092902MySqlUpdate extends AbstractMigration +final class Version20251028092902MySqlUpdate extends AbstractPrefixedMigration { public function getDescription(): string { diff --git a/src/Migrations/Version20251031072945PostGreInit.php b/src/Migrations/Version20251031072945PostGreInit.php index 80c27956d..6b2446c95 100644 --- a/src/Migrations/Version20251031072945PostGreInit.php +++ b/src/Migrations/Version20251031072945PostGreInit.php @@ -5,7 +5,6 @@ namespace PhpList\Core\Migrations; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; -use Doctrine\Migrations\AbstractMigration; use Doctrine\DBAL\Schema\Schema; /** @@ -15,7 +14,7 @@ * * Ex: phplist_linktrack_forward phplist_linktrack_forward_urlindex (but there are more) */ -final class Version20251031072945PostGreInit extends AbstractMigration +final class Version20251031072945PostGreInit extends AbstractPrefixedMigration { public function getDescription(): string { diff --git a/src/Migrations/Version20260204094237.php b/src/Migrations/Version20260204094237.php index 00e7fd918..56ab5b1a6 100644 --- a/src/Migrations/Version20260204094237.php +++ b/src/Migrations/Version20260204094237.php @@ -6,7 +6,6 @@ use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\DBAL\Platforms\MySQLPlatform; -use Doctrine\Migrations\AbstractMigration; use Doctrine\DBAL\Schema\Schema; /** @@ -16,7 +15,7 @@ * * Ex: phplist_linktrack_forward phplist_linktrack_forward_urlindex (but there are more) */ -final class Version20260204094237 extends AbstractMigration +final class Version20260204094237 extends AbstractPrefixedMigration { public function getDescription(): string { diff --git a/src/Migrations/_template_migration.php.tpl b/src/Migrations/_template_migration.php.tpl index 725615491..cd2cde8fe 100644 --- a/src/Migrations/_template_migration.php.tpl +++ b/src/Migrations/_template_migration.php.tpl @@ -6,7 +6,6 @@ namespace ; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\DBAL\Platforms\MySQLPlatform; -use Doctrine\Migrations\AbstractMigration; use Doctrine\DBAL\Schema\Schema; /** @@ -16,7 +15,7 @@ use Doctrine\DBAL\Schema\Schema; * * Ex: phplist_linktrack_forward phplist_linktrack_forward_urlindex (but there are more) */ -final class extends AbstractMigration +final class extends AbstractPrefixedMigration { public function getDescription(): string { From 5c96486fa2004aeede1fdafdde11b92e30f1df29 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 9 Aug 2026 13:35:07 +0400 Subject: [PATCH 08/39] atter review 0 --- src/Core/Bootstrap.php | 27 +++++++++++++++++-- src/Core/Doctrine/TablePrefixListener.php | 2 +- .../Command/ImportDefaultsCommand.php | 2 +- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/Core/Bootstrap.php b/src/Core/Bootstrap.php index 3b7430c25..4c7af4640 100644 --- a/src/Core/Bootstrap.php +++ b/src/Core/Bootstrap.php @@ -157,13 +157,36 @@ public function configure(): Bootstrap * Loads environment variables from the application's ".env" files (if present) using Symfony Dotenv, * following the standard ".env" -> ".env.local" -> ".env.$environment" -> ".env.$environment.local" cascade. * + * ".env.dist" is a template only and must never be used to source real configuration: Symfony Dotenv + * would otherwise silently load it (with its literal placeholder values) whenever ".env" is missing. + * * @return Bootstrap fluent interface + * + * @throws RuntimeException if ".env" does not exist, or PHPLIST_SECRET was not resolved to a real value + * @SuppressWarnings("PHPMD.Superglobals") */ private function loadEnvironmentVariables(): Bootstrap { $applicationRoot = $this->applicationStructure->getApplicationRoot(); - if (file_exists($applicationRoot . '/.env') || file_exists($applicationRoot . '/.env.dist')) { - (new Dotenv())->loadEnv($applicationRoot . '/.env', 'APP_ENV', $this->environment); + $dotenvPath = $applicationRoot . '/.env'; + if (!file_exists($dotenvPath)) { + throw new RuntimeException( + 'No ".env" file was found at "' . $dotenvPath . '". Run "composer install"/"composer update" ' . + 'to generate it from ".env.dist" (which is a template only and must not be used directly), ' . + 'or create ".env" manually with a real PHPLIST_SECRET.', + 1754766600 + ); + } + + (new Dotenv())->loadEnv($dotenvPath, 'APP_ENV', $this->environment); + + $secret = $_SERVER['PHPLIST_SECRET'] ?? $_ENV['PHPLIST_SECRET'] ?? ''; + if ($secret === '' || $secret === '%s') { + throw new RuntimeException( + 'PHPLIST_SECRET in ".env" is missing or still set to the ".env.dist" template placeholder. ' . + 'Set it to a real, unique, freshly generated secret before starting the application.', + 1754766601 + ); } return $this; diff --git a/src/Core/Doctrine/TablePrefixListener.php b/src/Core/Doctrine/TablePrefixListener.php index 92eeafcd9..eee9098fe 100644 --- a/src/Core/Doctrine/TablePrefixListener.php +++ b/src/Core/Doctrine/TablePrefixListener.php @@ -31,4 +31,4 @@ public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs): void 'name' => $this->tablePrefix . $metadata->getTableName(), ]); } -} \ No newline at end of file +} diff --git a/src/Domain/Identity/Command/ImportDefaultsCommand.php b/src/Domain/Identity/Command/ImportDefaultsCommand.php index b00cc9794..c91457c3c 100644 --- a/src/Domain/Identity/Command/ImportDefaultsCommand.php +++ b/src/Domain/Identity/Command/ImportDefaultsCommand.php @@ -23,7 +23,7 @@ )] class ImportDefaultsCommand extends Command { - private const DEFAULT_LOGIN = 'test1'; + private const DEFAULT_LOGIN = 'admin'; private const DEFAULT_EMAIL = 'admin@example.com'; public function __construct( From d3ddcbb8dc4669f967f7cd0db4100781b5fe0e13 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 12 Aug 2026 17:38:53 +0400 Subject: [PATCH 09/39] fix: update documentation --- PHPDOC.md | 36 ++++++------- README.md | 45 ++++------------- docs/AsyncEmailSending.md | 9 ++-- docs/ClassStructure.md | 1 - docs/DomainModel/Entities.md | 53 +++++++++++++------- docs/Graylog.md | 97 ++++++++++++------------------------ docs/MailerTransports.md | 4 +- 7 files changed, 100 insertions(+), 145 deletions(-) diff --git a/PHPDOC.md b/PHPDOC.md index 00ec597fa..2243e294f 100644 --- a/PHPDOC.md +++ b/PHPDOC.md @@ -1,25 +1,27 @@ -# Class Documentation with PHPDoc +# Generating class documentation -We use [phpdoc](phpdoc.org) to automatically generate documentation for our annotated classes. +We use [phpDocumentor](https://phpdoc.org) to generate API docs from the docblocks on +our classes, properties, and methods. Output settings (title, output path) are defined +in [`phpdoc.xml`](phpdoc.xml); the generated docs are written to `docs/phpdocumentor/` +and are not committed to the repository. -So to be able to generate or update our class docs you would need to download and install `phpDocumentor` globally (for system wide use) as shown below: +## Install phpDocumentor -1. `cd ~` [*Optional : it's recommended to navigate to your home dir before downloading `phpDocumentor` as shown in step 2*] -2. `wget https://phpdoc.org/phpDocumentor.phar` -3. `chmod +x phpDocumentor.phar` -4. `mv phpDocumentor.phar /usr/local/bin/phpDocumentor` +phpDocumentor ships as a standalone `.phar`. Install it once, globally: -*Possibility : In case you don't want to install `phpDocumentor` globally you can skip step 4, however you would need to run `phpDocumentor` from whatever path it was installed in.* +```bash +wget https://phpdoc.org/phpDocumentor.phar -O /usr/local/bin/phpDocumentor +chmod +x /usr/local/bin/phpDocumentor +``` -*Tip : You might need to run step four as root on some systems. That is : `sudo mv phpDocumentor.phar /usr/local/bin/phpDocumentor`* +If you'd rather not install it globally, download the `.phar` anywhere and call it +by its full path in the steps below. -## Generate Docs +## Generate the docs -If you did install `phpDocumentor` globally as specified above then you can generate class docs as follows. -Run : `composer run-php-documentor` +```bash +composer run-php-documentor +``` - - -*Note : `composer generate docs` would only work if you installed `phpDocumentor` globally, if you did not run : `custom/path/phpDocumentor -d 'src,tests' -t docs/phpdoc` to generate docs* - -*Where `custom/path/` is the location where you downloaded `phpDocumentor`* +This runs `phpDocumentor -d 'src,tests'`, using the output path from `phpdoc.xml`. +Open `docs/phpdocumentor/index.html` in a browser to view the result. \ No newline at end of file diff --git a/README.md b/README.md index cddd934b3..4b929df20 100755 --- a/README.md +++ b/README.md @@ -52,11 +52,12 @@ this code. ## Documentation -* [Class Docs](docs/phpdoc/) * [Class structure overview](docs/ClassStructure.md) -* [Graphic domain model](docs/DomainModel/DomainModel.svg) and [description of the domain entities](docs/DomainModel/Entities.md) -* [Mailer Transports](docs/mailer-transports.md) - How to use different email providers (Gmail, Amazon SES, Mailchimp, SendGrid) -* [Asynchronous Email Sending](docs/AsyncEmailSending.md) - How to use asynchronous email sending with Symfony Messenger +* [Domain model diagram](docs/DomainModel/DomainModel.svg) and [description of the domain entities](docs/DomainModel/Entities.md) +* [Mailer transports](docs/MailerTransports.md) - configuring Gmail, Amazon SES, Mailchimp, and SendGrid +* [Asynchronous email sending](docs/AsyncEmailSending.md) - queuing email delivery with Symfony Messenger +* [Graylog integration](docs/Graylog.md) - centralized log management +* [Generating class API docs](PHPDOC.md) - regenerating the phpDocumentor output ## Running the web server @@ -79,12 +80,6 @@ already in use, on the next free port after 8000). You can stop the server with CTRL + C. -#### Development and Documentation - -We use `phpDocumentor` to automatically generate documentation for classes. To make this process efficient and easier, you are required to properly "document" your `classes`,`properties`, `methods` ... by annotating them with [docblocks](https://docs.phpdoc.org/latest/guide/guides/docblocks.html). - -More about generating docs in [PHPDOC.md](PHPDOC.md) - ### Testing Create test db with name phplist in your mysql DB or uncomment sqlite part in config_test.yml file to use in memory DB for functional tests. @@ -200,36 +195,14 @@ To access the phpList data from a third-party application (i.e., not from a phpList module), please use the [REST API](https://github.com/phpList/rest-api). -## Email Configuration - -phpList supports multiple email transport providers through Symfony Mailer. The following transports are included: - -* Gmail -* Amazon SES -* Mailchimp Transactional (Mandrill) -* SendGrid - -For detailed configuration instructions, see the [Mailer Transports documentation](docs/mailer-transports.md). - -## Copyright - -phpList is copyright (C) 2000-2025 [phpList Ltd](https://www.phplist.com/). - +## Translations -### Translations -command to extract translation strings +To extract translation strings from the source into an XLIFF catalog: ```bash php bin/console translation:extract --force en --format=xlf ``` -```bash -vendor/bin/phpstan analyse -c phpstan.neon; -vendor/bin/phpmd src/ text config/PHPMD/rules.xml; -vendor/bin/phpcs --standard=config/PhpCodeSniffer/ --ignore=*/Migrations/* bin/ src/ tests/ public/; -``` - +## Copyright -```bash -php bin/console messenger:consume async_email -``` +phpList is copyright (C) 2000-2025 [phpList Ltd](https://www.phplist.com/). diff --git a/docs/AsyncEmailSending.md b/docs/AsyncEmailSending.md index da4f247c6..440267609 100644 --- a/docs/AsyncEmailSending.md +++ b/docs/AsyncEmailSending.md @@ -64,10 +64,10 @@ You can test the email functionality using the built-in command: ```bash # Queue an email for asynchronous sending -bin/console app:send-test-email recipient@example.com +bin/console phplist:test-email recipient@example.com # Send an email synchronously (immediately) -bin/console app:send-test-email recipient@example.com --sync +bin/console phplist:test-email recipient@example.com --sync ``` ## Processing the Email Queue @@ -87,9 +87,6 @@ You can monitor the queue status using the following commands: ```bash # View the number of messages in the queue bin/console messenger:stats - -# View failed messages -bin/console messenger:failed:show ``` ## Troubleshooting @@ -97,6 +94,6 @@ bin/console messenger:failed:show If emails are not being sent: 1. Make sure the messenger worker is running -2. Check for failed messages using `bin/console messenger:failed:show` +2. Check the queue with `bin/console messenger:stats` (see [Monitoring](#monitoring)) 3. Verify your mailer configuration in `config/parameters.yml` 4. Try sending an email synchronously to test the mailer configuration diff --git a/docs/ClassStructure.md b/docs/ClassStructure.md index 8b3d9516e..1f586515d 100644 --- a/docs/ClassStructure.md +++ b/docs/ClassStructure.md @@ -46,4 +46,3 @@ Security‑related concerns. Utilities to support tests. - Traits/: Reusable traits and helpers used in the test suite. - diff --git a/docs/DomainModel/Entities.md b/docs/DomainModel/Entities.md index 5b83323df..a434b9f7a 100644 --- a/docs/DomainModel/Entities.md +++ b/docs/DomainModel/Entities.md @@ -1,5 +1,8 @@ # Domain Entities +Table names below use the default `DATABASE_PREFIX` (`phplist_`, set in `.env`). The +prefix is applied dynamically at runtime, so it can be changed per installation. + ## Identity Context ### Administrator @@ -13,11 +16,23 @@ Administrators are not subscribers. If administrators would like to subscribe to subscriber lists, they need to have a separate subscriber account. ### AdministratorAttribute -Table name: `phplist_adminattribute` or `phplist_admin_attribute` +Table name: `phplist_adminattribute` + +This is similar to a subscriber attribute: It defines a field for +administrators (name and ID only, not the value). These can then be used as +placeholders in campaigns. + +### AdministratorAttributeValue +Table name: `phplist_admin_attribute` + +The value of a particular **AdministratorAttribute** for a particular +**administrator**. -This is similar to a subscriber attribute: It allows you to have details of -administrators. These can then be used in campaigns. Basically, you can add -placeholders for administrator attributes in campaigns. +### AdministratorLogin +Table name: `phplist_admin_login` + +A record of a single login session for an **administrator**: source IP +address, session ID, and whether the session is still active. ### AdministratorPasswordRequest Table name: `phplist_admin_password_request` @@ -31,15 +46,14 @@ This table contains the API tokens for **administrators**. Those API tokens are used for access to the REST API. In the web frontend, they are also used for CSRF protection. - -## SubscriptionContext +## Subscription Context ### Attribute Table name: `phplist_user_attribute` An **attribute** is a field for subscribers. This entity does not -contain the values for this attribute for each individual subscribe, but -only the name of the attribute and an ID. +contain the values for this attribute for each individual subscriber, but +only the name of the attribute and an ID. ### AttributeValue Table name: `phplist_user_user_attribute` @@ -50,7 +64,7 @@ particular **subscriber**. ### SubscribePage Table name: `phplist_subscribepage` -*subscribePages** allow setting up a selection of subscriber lists, attributes +**SubscribePages** allow setting up a selection of subscriber lists, attributes and language, and some other settings to control the content for the page that can be used to subscribe to the system. As a result, you can e.g., have different pages per language, which allows you to translate all the content @@ -97,8 +111,6 @@ multiple subscriber lists, and a campaign can be sent to multiple subscriber lists, but this association ensures that a subscriber always only receives one copy of a campaign, regardless of other associations. -Should we use a named association for this? What should it be named? - ### SuppressionList Table name: `phplist_user_blacklist` @@ -113,21 +125,25 @@ Table name: `phplist_user_blacklist_data` This is some more additional info on a SuppressionList. - ## Messaging Context - ### Attachment Table name: `phplist_attachment` An attachment represents a file attached to exactly one **campaign**. ### Bounce -Table name: `phplist_boune` +Table name: `phplist_bounce` + +A recorded bounce message: the original bounce email's header and body, plus +a classification status and comment. ### BounceRegEx Table name: `phplist_bounceregex` +A regular expression used to classify **bounces** by matching their content, +with an associated action (e.g. unsubscribe the subscriber). + ### Campaign Table name: `phplist_message` @@ -137,7 +153,9 @@ potentially multiple subscriber lists). The campaign has been created by an **subscribers**. It is stored to which subscribers a campaign has been sent. ### CampaignBounce -Table name: `phplist_message_bounce` +Table name: `phplist_user_message_bounce` + +Links a **bounce** to the **subscriber** and **campaign** it resulted from. ### CampaignData Table name: `phplist_messagedata` @@ -147,7 +165,7 @@ Google tracking IDs, special relationships to **subscriber lists**, and alias titles. ### CampaignForward -Table name: `phplist_message_forward` +Table name: `phplist_user_message_forward` This tracks details of **campaigns** which were forwarded by a recipient **subscriber** to someone else via an email message. @@ -170,7 +188,6 @@ Table name: `phplist_templateimage` This contains images used in **templates**. The blob contains the image. - ## System Context ### Configuration @@ -208,7 +225,6 @@ time they were updated), [the MD5 for that](https://phplist.com/files/tlds-alpha-by-domain.txt.md5), etc. etc. - ## Tracking Context ### LinkTrackForward @@ -229,7 +245,6 @@ Table name: `phplist_linktrack_uml_click` When a **subscriber** clicks on a link in a message, this click will be recorded here. - ## Unused entities * LinkTrack, table name: `phplist_linktrack` diff --git a/docs/Graylog.md b/docs/Graylog.md index 0abbcb57f..b5db0671f 100644 --- a/docs/Graylog.md +++ b/docs/Graylog.md @@ -1,81 +1,50 @@ # Graylog Integration -This document explains how to use the Graylog integration in the phpList core application. +phpList can send logs to [Graylog](https://graylog.org/) over GELF (Graylog Extended +Log Format) using Monolog's `gelf` handler. The handler ships **disabled by default** +in both environments. -## Overview +## Enabling it -Graylog is a log management platform that collects, indexes, and analyzes log messages from various sources. The phpList core application is configured to send logs to Graylog using the GELF (Graylog Extended Log Format) protocol. - -## Configuration - -The Graylog integration is configured in the following files: - -- `config/config_prod.yml` - Production environment configuration -- `config/config_dev.yml` - Development environment configuration - -### Default Configuration - -By default, the application is configured to: - -- In production: Send logs of level "error" and above to Graylog -- In development: Send logs of all levels to Graylog - -The default configuration points to a placeholder Graylog server at `graylog.example.com:12201`. You need to update this to point to your actual Graylog server. - -### Updating the Graylog Server Details - -To update the Graylog server details, modify the following sections in the configuration files: - -In `config/config_prod.yml`: - -```yaml -graylog: - type: gelf - publisher: - hostname: graylog.example.com # Replace with your Graylog server hostname - port: 12201 # Default GELF UDP port - level: error # Only send errors and above to Graylog -``` - -In `config/config_dev.yml`: +1. In `config/config_prod.yml`, uncomment the `graylog` handler under `monolog.handlers`. + It sends `error`-level and above logs, using the `graylog_host` and `graylog_port` + parameters from `config/parameters.yml` (defaults: `graylog.phplist.local:12201`). +2. In `config/config_dev.yml`, uncomment the `graylog` handler to also log in + development. It sends every level except the `event` channel. +3. Update `graylog_host` and `graylog_port` in `config/parameters.yml` to point at + your Graylog server. ```yaml -graylog: - type: gelf - publisher: - hostname: graylog.example.com # Replace with your Graylog server hostname - port: 12201 # Default GELF UDP port - level: debug # Send all logs to Graylog in development - channels: ['!event'] +# config/parameters.yml +parameters: + graylog_host: 'graylog.example.com' + graylog_port: 12201 ``` -Replace `graylog.example.com` with the hostname or IP address of your Graylog server, and update the port if necessary. +## Graylog server setup -## Graylog Server Setup +Your Graylog server needs a GELF UDP input to receive these logs: -To receive logs from the application, your Graylog server needs to be configured with a GELF UDP input: - -1. In the Graylog web interface, go to System > Inputs -2. Select "GELF UDP" from the dropdown and click "Launch new input" -3. Configure the input with the following settings: +1. In the Graylog web interface, go to System > Inputs. +2. Select "GELF UDP" and click "Launch new input". +3. Configure it with: - Title: phpList Core - - Bind address: 0.0.0.0 (to listen on all interfaces) - - Port: 12201 (or the port you specified in the configuration) -4. Click "Save" - -## Testing the Integration + - Bind address: `0.0.0.0` (listen on all interfaces) + - Port: `12201` (or whatever you set as `graylog_port`) +4. Click "Save". -To test if logs are being sent to Graylog: +## Testing the integration -1. Generate some log messages in the application (e.g., by triggering an error) -2. Check the Graylog web interface to see if the logs are being received -3. If logs are not appearing, check the application logs for any errors related to the Graylog connection +1. Trigger a log message in the application (e.g. an error). +2. Check the Graylog web interface for the message. +3. If nothing shows up, see Troubleshooting below. ## Troubleshooting -If logs are not appearing in Graylog: +If logs aren't appearing in Graylog: -1. Verify that the Graylog server is running and accessible from the application server -2. Check that the GELF UDP input is properly configured and running in Graylog -3. Ensure that there are no firewall rules blocking UDP traffic on port 12201 (or your configured port) -4. Check the application logs for any errors related to the Graylog connection +1. Confirm the `graylog` handler is uncommented in the config for the environment + you're testing. +2. Verify the Graylog server is running and reachable from the application server. +3. Check that the GELF UDP input is running and bound to the port you configured. +4. Check for firewall rules blocking UDP traffic on that port. \ No newline at end of file diff --git a/docs/MailerTransports.md b/docs/MailerTransports.md index cde763da4..9488923a2 100644 --- a/docs/MailerTransports.md +++ b/docs/MailerTransports.md @@ -80,7 +80,7 @@ Notes: After setting up your preferred mailer transport, you can test it using the built-in test command: ```bash -bin/console app:send-test-email recipient@example.com +bin/console phplist:test-email recipient@example.com ``` ## Switching Between Transports @@ -91,7 +91,7 @@ You can easily switch between different mailer transports by changing the `MAILE 2. Set the environment variable in your server configuration 3. Set the environment variable before running a command: ```bash - MAILER_DSN=sendgrid://API_KEY@default bin/console app:send-test-email recipient@example.com + MAILER_DSN=sendgrid://API_KEY@default bin/console phplist:test-email recipient@example.com ``` ## Additional Configuration From f15e76e8f82ab2438446e3c405933420cefecf63 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 12 Aug 2026 17:41:26 +0400 Subject: [PATCH 10/39] docs: update AsyncEmailSending documentation and clarify failed message handling --- config/packages/messenger.yaml | 5 ++--- docs/AsyncEmailSending.md | 11 ++++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 2c32337b2..930226189 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -1,8 +1,7 @@ # This file is the Symfony Messenger configuration for asynchronous processing framework: messenger: - # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling. - # failure_transport: failed + failure_transport: failed transports: # https://symfony.com/doc/current/messenger.html#transport-configuration @@ -20,7 +19,7 @@ framework: multiplier: 2 max_delay: 0 - # failed: 'doctrine://default?queue_name=failed' + failed: 'doctrine://default?queue_name=failed' routing: # Route your messages to the transports diff --git a/docs/AsyncEmailSending.md b/docs/AsyncEmailSending.md index 440267609..386eae493 100644 --- a/docs/AsyncEmailSending.md +++ b/docs/AsyncEmailSending.md @@ -87,13 +87,22 @@ You can monitor the queue status using the following commands: ```bash # View the number of messages in the queue bin/console messenger:stats + +# View failed messages +bin/console messenger:failed:show + +# Retry a failed message +bin/console messenger:failed:retry ``` +Failed messages are routed to the `failed` transport (a separate queue in the +same Doctrine table), configured in `config/packages/messenger.yaml`. + ## Troubleshooting If emails are not being sent: 1. Make sure the messenger worker is running -2. Check the queue with `bin/console messenger:stats` (see [Monitoring](#monitoring)) +2. Check for failed messages using `bin/console messenger:failed:show` 3. Verify your mailer configuration in `config/parameters.yml` 4. Try sending an email synchronously to test the mailer configuration From 8b7f95c0a42960b739139671042d18b6a00a3198 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 12 Aug 2026 17:52:36 +0400 Subject: [PATCH 11/39] feat: enhance password handling with legacy hash support and update hash generation --- .../Repository/AdministratorRepository.php | 20 +++++-- src/Security/HashGenerator.php | 33 ++++++++++-- tests/Unit/Security/HashGeneratorTest.php | 53 ++++++++++++++++--- 3 files changed, 89 insertions(+), 17 deletions(-) diff --git a/src/Domain/Identity/Repository/AdministratorRepository.php b/src/Domain/Identity/Repository/AdministratorRepository.php index 640a0a557..0bdae5b64 100644 --- a/src/Domain/Identity/Repository/AdministratorRepository.php +++ b/src/Domain/Identity/Repository/AdministratorRepository.php @@ -45,15 +45,27 @@ public function __construct( */ public function findOneByLoginCredentials(string $loginName, string $plainTextPassword): ?Administrator { - $passwordHash = $this->hashGenerator->createPasswordHash($plainTextPassword); - - return $this->findOneBy( + /** @var Administrator|null $administrator */ + $administrator = $this->findOneBy( [ 'loginName' => $loginName, - 'passwordHash' => $passwordHash, 'superUser' => true, ] ); + + $passwordHash = $administrator?->getPasswordHash(); + if ($administrator === null || $passwordHash === null + || !$this->hashGenerator->verifyPassword($plainTextPassword, $passwordHash) + ) { + return null; + } + + if ($this->hashGenerator->isLegacyHash($passwordHash)) { + $administrator->setPasswordHash($this->hashGenerator->createPasswordHash($plainTextPassword)); + $this->save($administrator); + } + + return $administrator; } /** @return Administrator[] */ diff --git a/src/Security/HashGenerator.php b/src/Security/HashGenerator.php index a70acaa31..67ab3054e 100644 --- a/src/Security/HashGenerator.php +++ b/src/Security/HashGenerator.php @@ -12,17 +12,40 @@ class HashGenerator { /** + * Legacy algorithm that older password hashes in the database may still use. + * * @var string */ - const PASSWORD_HASH_ALGORITHM = 'sha256'; + const LEGACY_PASSWORD_HASH_ALGORITHM = 'sha256'; + + public function createPasswordHash(string $plainTextPassword): string + { + return password_hash($plainTextPassword, PASSWORD_DEFAULT); + } /** - * @param string $plainTextPassword + * Checks a plaintext password against a stored hash. * - * @return string + * Hashes created by {@see createPasswordHash()} are verified with `password_verify()`. + * As a fallback, this also accepts hashes created by the old, unsalted + * sha256-based scheme, so administrators with pre-existing hashes can still log in. */ - public function createPasswordHash(string $plainTextPassword): string + public function verifyPassword(string $plainTextPassword, string $hash): bool + { + if (password_verify($plainTextPassword, $hash)) { + return true; + } + + return $this->isLegacyHash($hash) + && hash_equals(hash(static::LEGACY_PASSWORD_HASH_ALGORITHM, $plainTextPassword), $hash); + } + + /** + * Checks whether $hash was created by the old, unsalted sha256-based scheme + * rather than by {@see createPasswordHash()}. + */ + public function isLegacyHash(string $hash): bool { - return hash(static::PASSWORD_HASH_ALGORITHM, $plainTextPassword); + return preg_match('/^[0-9a-f]{64}$/', $hash) === 1; } } diff --git a/tests/Unit/Security/HashGeneratorTest.php b/tests/Unit/Security/HashGeneratorTest.php index b8bd956b2..86aac803a 100644 --- a/tests/Unit/Security/HashGeneratorTest.php +++ b/tests/Unit/Security/HashGeneratorTest.php @@ -21,27 +21,64 @@ protected function setUp(): void $this->subject = new HashGenerator(); } - public function testCreatePasswordHashCreates64CharacterHash(): void + public function testCreatePasswordHashCreatesPasswordHashCompatibleHash(): void { $hash = $this->subject->createPasswordHash('Portal'); - self::assertMatchesRegularExpression('/^[a-z0-9]{64}$/', $hash); + + self::assertNotFalse(password_get_info($hash)['algo']); } - public function testCreatePasswordHashCalledTwoTimesWithSamePasswordCreatesSameHash(): void + public function testCreatePasswordHashCalledTwoTimesWithSamePasswordCreatesDifferentHashes(): void { $password = 'Aperture Science'; $hash1 = $this->subject->createPasswordHash($password); $hash2 = $this->subject->createPasswordHash($password); - self::assertSame($hash1, $hash2); + self::assertNotSame($hash1, $hash2); } - public function testCreatePasswordHashCalledTwoTimesWithDifferentPasswordsCreatesDifferentHashes(): void + public function testVerifyPasswordForMatchingPasswordAndHashReturnsTrue(): void { - $hash1 = $this->subject->createPasswordHash('Mel'); - $hash2 = $this->subject->createPasswordHash('Cave Johnson'); + $password = 'Cave Johnson'; + $hash = $this->subject->createPasswordHash($password); - self::assertNotSame($hash1, $hash2); + self::assertTrue($this->subject->verifyPassword($password, $hash)); + } + + public function testVerifyPasswordForNonMatchingPasswordAndHashReturnsFalse(): void + { + $hash = $this->subject->createPasswordHash('Mel'); + + self::assertFalse($this->subject->verifyPassword('Cave Johnson', $hash)); + } + + public function testVerifyPasswordForMatchingPasswordAndLegacyHashReturnsTrue(): void + { + $password = 'Bazinga!'; + $legacyHash = hash(HashGenerator::LEGACY_PASSWORD_HASH_ALGORITHM, $password); + + self::assertTrue($this->subject->verifyPassword($password, $legacyHash)); + } + + public function testVerifyPasswordForNonMatchingPasswordAndLegacyHashReturnsFalse(): void + { + $legacyHash = hash(HashGenerator::LEGACY_PASSWORD_HASH_ALGORITHM, 'Bazinga!'); + + self::assertFalse($this->subject->verifyPassword('wrong-password', $legacyHash)); + } + + public function testIsLegacyHashForSha256HashReturnsTrue(): void + { + $legacyHash = hash(HashGenerator::LEGACY_PASSWORD_HASH_ALGORITHM, 'Bazinga!'); + + self::assertTrue($this->subject->isLegacyHash($legacyHash)); + } + + public function testIsLegacyHashForPasswordHashHashReturnsFalse(): void + { + $hash = $this->subject->createPasswordHash('Bazinga!'); + + self::assertFalse($this->subject->isLegacyHash($hash)); } } From c9a3b3b1ba1724904a05d50d4750a9ace7ae0732 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 12 Aug 2026 18:20:22 +0400 Subject: [PATCH 12/39] feat: add support for in-memory SQLite database in test configuration --- .env.test.local.dist | 9 +++++++++ config/config_test.yml | 9 ++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 .env.test.local.dist diff --git a/.env.test.local.dist b/.env.test.local.dist new file mode 100644 index 000000000..c9992c349 --- /dev/null +++ b/.env.test.local.dist @@ -0,0 +1,9 @@ +# Optional: copy this file to ".env.test.local" to run tests against an in-memory SQLite +# database instead of MySQL, so no database server is needed for `vendor/bin/phpunit`. +# +# Note: this file is not loaded automatically by PHPUnit CLI runs (this project's ApplicationKernel +# does not read .env files on its own); either export these as real environment variables before +# running phpunit, or wire them up via your own bootstrap/CI step. + +PHPLIST_DATABASE_DRIVER=pdo_sqlite +PHPLIST_DATABASE_PATH=:memory: \ No newline at end of file diff --git a/config/config_test.yml b/config/config_test.yml index 36ce489c4..fe97391a8 100644 --- a/config/config_test.yml +++ b/config/config_test.yml @@ -11,9 +11,12 @@ framework: doctrine: dbal: -# driver: 'pdo_sqlite' -# memory: true - driver: 'pdo_mysql' + # Defaults to pdo_mysql via PHPLIST_DATABASE_DRIVER (see .env). To run tests against an + # in-memory SQLite database instead (no MySQL server needed), set in .env.test.local: + # PHPLIST_DATABASE_DRIVER=pdo_sqlite + # PHPLIST_DATABASE_PATH=:memory: + driver: '%database_driver%' + path: '%database_path%' host: '%database_host%' port: '%database_port%' dbname: 'phplist' From 9c4f9457b08de06fc445b82083f9dadf5cf9a723 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 17 Aug 2026 12:12:54 +0400 Subject: [PATCH 13/39] feat: refactor campaign performance analytics --- src/Domain/Analytics/Model/LinkTrack.php | 1 + .../Analytics/Model/UserMessageView.php | 1 + .../Repository/LinkTrackRepository.php | 53 ++++++++++++ .../Repository/UserMessageViewRepository.php | 53 ++++++++++++ .../Analytics/Service/AnalyticsService.php | 31 +++---- .../Service/Manager/LinkTrackManager.php | 10 +++ .../Manager/UserMessageViewManager.php | 10 +++ src/Domain/Messaging/Model/Message.php | 1 + .../Messaging/Model/UserMessageBounce.php | 1 + .../Service/AnalyticsServiceTest.php | 81 +++++++++++++++++++ 10 files changed, 228 insertions(+), 14 deletions(-) diff --git a/src/Domain/Analytics/Model/LinkTrack.php b/src/Domain/Analytics/Model/LinkTrack.php index 1c8b3755c..b0d8c7bf4 100644 --- a/src/Domain/Analytics/Model/LinkTrack.php +++ b/src/Domain/Analytics/Model/LinkTrack.php @@ -18,6 +18,7 @@ #[ORM\Index(name: 'phplist_linktrack_miduidindex', columns: ['messageid', 'userid'])] #[ORM\Index(name: 'phplist_linktrack_uidindex', columns: ['userid'])] #[ORM\Index(name: 'phplist_linktrack_urlindex', columns: ['url'])] +#[ORM\Index(name: 'phplist_linktrack_latestclickindex', columns: ['latestclick'])] class LinkTrack implements DomainModel, Identity { #[ORM\Id] diff --git a/src/Domain/Analytics/Model/UserMessageView.php b/src/Domain/Analytics/Model/UserMessageView.php index 7c0e1b363..66240feea 100644 --- a/src/Domain/Analytics/Model/UserMessageView.php +++ b/src/Domain/Analytics/Model/UserMessageView.php @@ -15,6 +15,7 @@ #[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'])] +// todo: #[ORM\Index(name: 'phplist_user_message_view_viewedidx', columns: ['viewed'])] class UserMessageView implements DomainModel, Identity { #[ORM\Id] diff --git a/src/Domain/Analytics/Repository/LinkTrackRepository.php b/src/Domain/Analytics/Repository/LinkTrackRepository.php index 3d3220995..4a66b17dd 100644 --- a/src/Domain/Analytics/Repository/LinkTrackRepository.php +++ b/src/Domain/Analytics/Repository/LinkTrackRepository.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Domain\Analytics\Repository; use DateTimeInterface; +use Doctrine\DBAL\Exception; use PhpList\Core\Domain\Analytics\Model\LinkTrack; use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; @@ -53,4 +54,56 @@ public function countBetween(DateTimeInterface $start, DateTimeInterface $end): ->getQuery() ->getSingleScalarResult(); } + + /** + * @return array counts keyed by 'Y-m-d' + * @throws Exception + */ + public function countGroupedByDay(DateTimeInterface $start, DateTimeInterface $end): array + { + $connection = $this->getEntityManager()->getConnection(); + $table = $this->getClassMetadata()->getTableName(); + + $sql = sprintf( + 'SELECT DATE(latestclick) AS day, COUNT(*) AS cnt FROM %s WHERE latestclick >= :start' + . ' AND latestclick <= :end GROUP BY DATE(latestclick)', + $table + ); + + $rows = $connection->executeQuery($sql, [ + 'start' => $start->format('Y-m-d H:i:s'), + 'end' => $end->format('Y-m-d H:i:s'), + ])->fetchAllAssociative(); + + $result = []; + foreach ($rows as $row) { + $result[(string) $row['day']] = (int) $row['cnt']; + } + return $result; + } + + /** + * @param int[] $messageIds + * @return array unique-clicker counts keyed by message id + */ + public function countUniqueClickersByMessageIds(array $messageIds): array + { + if (empty($messageIds)) { + return []; + } + + $rows = $this->createQueryBuilder('lt') + ->select('lt.messageId AS messageId, COUNT(DISTINCT lt.userId) AS cnt') + ->where('lt.messageId IN (:ids)') + ->setParameter('ids', $messageIds) + ->groupBy('lt.messageId') + ->getQuery() + ->getResult(); + + $result = []; + foreach ($rows as $row) { + $result[(int) $row['messageId']] = (int) $row['cnt']; + } + return $result; + } } diff --git a/src/Domain/Analytics/Repository/UserMessageViewRepository.php b/src/Domain/Analytics/Repository/UserMessageViewRepository.php index 5a08b5690..2c232aa09 100644 --- a/src/Domain/Analytics/Repository/UserMessageViewRepository.php +++ b/src/Domain/Analytics/Repository/UserMessageViewRepository.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Domain\Analytics\Repository; use DateTimeInterface; +use Doctrine\DBAL\Exception; use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; @@ -51,4 +52,56 @@ public function countBetween(DateTimeInterface $start, DateTimeInterface $end): ->getQuery() ->getSingleScalarResult(); } + + /** + * @return array counts keyed by 'Y-m-d' + * @throws Exception + */ + public function countGroupedByDay(DateTimeInterface $start, DateTimeInterface $end): array + { + $connection = $this->getEntityManager()->getConnection(); + $table = $this->getClassMetadata()->getTableName(); + + $sql = sprintf( + 'SELECT DATE(viewed) AS day, COUNT(*) AS cnt FROM %s WHERE viewed >= :start AND viewed <= :end' + . ' GROUP BY DATE(viewed)', + $table + ); + + $rows = $connection->executeQuery($sql, [ + 'start' => $start->format('Y-m-d H:i:s'), + 'end' => $end->format('Y-m-d H:i:s'), + ])->fetchAllAssociative(); + + $result = []; + foreach ($rows as $row) { + $result[(string) $row['day']] = (int) $row['cnt']; + } + return $result; + } + + /** + * @param int[] $messageIds + * @return array view counts keyed by message id + */ + public function countByMessageIds(array $messageIds): array + { + if (empty($messageIds)) { + return []; + } + + $rows = $this->createQueryBuilder('umv') + ->select('umv.messageId AS messageId, COUNT(umv.id) AS cnt') + ->where('umv.messageId IN (:ids)') + ->setParameter('ids', $messageIds) + ->groupBy('umv.messageId') + ->getQuery() + ->getResult(); + + $result = []; + foreach ($rows as $row) { + $result[(int) $row['messageId']] = (int) $row['cnt']; + } + return $result; + } } diff --git a/src/Domain/Analytics/Service/AnalyticsService.php b/src/Domain/Analytics/Service/AnalyticsService.php index 853c2f9e3..f9f52721e 100644 --- a/src/Domain/Analytics/Service/AnalyticsService.php +++ b/src/Domain/Analytics/Service/AnalyticsService.php @@ -430,18 +430,21 @@ public function getTopLocalParts(int $limit = 25): array public function getCampaignPerformance(): array { - $performance = []; $endDate = new DateTimeImmutable('today 23:59:59'); $startDate = $endDate->sub(new DateInterval('P29D'))->modify('00:00:00'); + $opensByDay = $this->userMessageViewManager->countViewsGroupedByDay($startDate, $endDate); + $clicksByDay = $this->linkTrackManager->countClicksGroupedByDay($startDate, $endDate); + + $performance = []; for ($index = 0; $index < 30; $index++) { - $dayStart = $startDate->add(new DateInterval('P' . $index . 'D')); - $dayEnd = $dayStart->modify('23:59:59'); + $day = $startDate->add(new DateInterval('P' . $index . 'D')); + $dateKey = $day->format('Y-m-d'); $performance[] = [ - 'date' => $dayStart->format('Y-m-d'), - 'opens' => $this->userMessageViewManager->countViewsBetween($dayStart, $dayEnd), - 'clicks' => $this->linkTrackManager->countClicksBetween($dayStart, $dayEnd), + 'date' => $dateKey, + 'opens' => $opensByDay[$dateKey] ?? 0, + 'clicks' => $clicksByDay[$dateKey] ?? 0, ]; } @@ -459,16 +462,16 @@ public function getRecentCampaigns(int $limit = 5): array $messages = $this->messageRepository ->getFilteredAfterId((new MessageFilter())->setLastId(0)->setLimit($limit)) ->getItems(); + + $messageIds = array_map(static fn ($message) => $message->getId(), $messages); + $viewCounts = $this->userMessageViewManager->countViewsByMessageIds($messageIds); + $uniqueClickCounts = $this->linkTrackManager->countUniqueClickersByMessageIds($messageIds); + $recentCampaigns = []; foreach ($messages as $message) { - $views = $this->userMessageViewManager->countViewsByMessageId($message->getId()); - $linkTracks = $this->linkTrackManager->getLinkTracksByMessageId($message->getId()); - - $uniqueClickers = []; - foreach ($linkTracks as $linkTrack) { - $uniqueClickers[$linkTrack->getUserId()] = true; - } - $uniqueClicks = count($uniqueClickers); + $id = $message->getId(); + $views = $viewCounts[$id] ?? 0; + $uniqueClicks = $uniqueClickCounts[$id] ?? 0; $sentCount = $message->getMetadata()->getViews() + $message->getMetadata()->getBounceCount(); diff --git a/src/Domain/Analytics/Service/Manager/LinkTrackManager.php b/src/Domain/Analytics/Service/Manager/LinkTrackManager.php index 9f657ebc8..0775ec1dc 100644 --- a/src/Domain/Analytics/Service/Manager/LinkTrackManager.php +++ b/src/Domain/Analytics/Service/Manager/LinkTrackManager.php @@ -34,4 +34,14 @@ public function countClicksBetween(DateTimeInterface $start, DateTimeInterface $ { return $this->linkTrackRepository->countBetween($start, $end); } + + public function countClicksGroupedByDay(DateTimeInterface $start, DateTimeInterface $end): array + { + return $this->linkTrackRepository->countGroupedByDay($start, $end); + } + + public function countUniqueClickersByMessageIds(array $messageIds): array + { + return $this->linkTrackRepository->countUniqueClickersByMessageIds($messageIds); + } } diff --git a/src/Domain/Analytics/Service/Manager/UserMessageViewManager.php b/src/Domain/Analytics/Service/Manager/UserMessageViewManager.php index 6dce3cf72..52192651f 100644 --- a/src/Domain/Analytics/Service/Manager/UserMessageViewManager.php +++ b/src/Domain/Analytics/Service/Manager/UserMessageViewManager.php @@ -34,4 +34,14 @@ public function countViewsBetween(DateTimeInterface $start, DateTimeInterface $e { return $this->userMessageViewRepository->countBetween($start, $end); } + + public function countViewsGroupedByDay(DateTimeInterface $start, DateTimeInterface $end): array + { + return $this->userMessageViewRepository->countGroupedByDay($start, $end); + } + + public function countViewsByMessageIds(array $messageIds): array + { + return $this->userMessageViewRepository->countByMessageIds($messageIds); + } } diff --git a/src/Domain/Messaging/Model/Message.php b/src/Domain/Messaging/Model/Message.php index 072661b4e..94faee06c 100644 --- a/src/Domain/Messaging/Model/Message.php +++ b/src/Domain/Messaging/Model/Message.php @@ -24,6 +24,7 @@ #[ORM\Entity(repositoryClass: MessageRepository::class)] #[ORM\Table(name: 'message')] #[ORM\Index(name: 'phplist_message_uuididx', columns: ['uuid'])] +#[ORM\Index(name: 'phplist_message_sentidx', columns: ['sent'])] #[ORM\HasLifecycleCallbacks] class Message implements DomainModel, Identity, ModificationDate, OwnableInterface { diff --git a/src/Domain/Messaging/Model/UserMessageBounce.php b/src/Domain/Messaging/Model/UserMessageBounce.php index 48b97b5cb..2a7ef5197 100644 --- a/src/Domain/Messaging/Model/UserMessageBounce.php +++ b/src/Domain/Messaging/Model/UserMessageBounce.php @@ -16,6 +16,7 @@ #[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'])] +// todo: #[ORM\Index(name: 'phplist_user_message_bounce_timeidx', columns: ['time'])] class UserMessageBounce implements DomainModel, Identity { #[ORM\Id] diff --git a/tests/Unit/Domain/Analytics/Service/AnalyticsServiceTest.php b/tests/Unit/Domain/Analytics/Service/AnalyticsServiceTest.php index a56dd5d8c..2470f4709 100644 --- a/tests/Unit/Domain/Analytics/Service/AnalyticsServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/AnalyticsServiceTest.php @@ -4,7 +4,9 @@ namespace PhpList\Core\Tests\Unit\Domain\Analytics\Service; +use DateInterval; use DateTime; +use DateTimeImmutable; use PhpList\Core\Domain\Analytics\Model\LinkTrack; use PhpList\Core\Domain\Analytics\Repository\UserMessageViewRepository; use PhpList\Core\Domain\Analytics\Service\AnalyticsService; @@ -376,4 +378,83 @@ public function testGetSummaryStatistics(): void self::assertEquals(2.0, $result['bounce_rate']['value']); self::assertEquals(0.0, $result['bounce_rate']['change_vs_last_month']); } + + public function testGetCampaignPerformance(): void + { + $endDate = new DateTimeImmutable('today 23:59:59'); + $startDate = $endDate->sub(new DateInterval('P29D'))->modify('00:00:00'); + + $someDay = $startDate->add(new DateInterval('P5D'))->format('Y-m-d'); + + $this->userMessageViewManager->expects(self::once()) + ->method('countViewsGroupedByDay') + ->with($startDate, $endDate) + ->willReturn([$someDay => 7]); + + $this->linkTrackManager->expects(self::once()) + ->method('countClicksGroupedByDay') + ->with($startDate, $endDate) + ->willReturn([$someDay => 3]); + + $result = $this->subject->getCampaignPerformance(); + + self::assertCount(30, $result); + + $matching = array_values(array_filter($result, static fn ($row) => $row['date'] === $someDay)); + self::assertCount(1, $matching); + self::assertSame(7, $matching[0]['opens']); + self::assertSame(3, $matching[0]['clicks']); + + $other = array_values(array_filter($result, static fn ($row) => $row['date'] !== $someDay)); + self::assertSame(0, $other[0]['opens']); + self::assertSame(0, $other[0]['clicks']); + } + + public function testGetRecentCampaigns(): void + { + $limit = 5; + $messageId = 42; + + $messageMetadata = $this->createMock(MessageMetadata::class); + $messageMetadata->method('getViews')->willReturn(80); + $messageMetadata->method('getBounceCount')->willReturn(20); + $messageMetadata->method('getSent')->willReturn(new DateTime('2023-02-01 10:00:00')); + $messageMetadata->method('getStatus')->willReturn(null); + + $messageContent = $this->createMock(MessageContent::class); + $messageContent->method('getSubject')->willReturn('Recent Campaign'); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn($messageId); + $message->method('getMetadata')->willReturn($messageMetadata); + $message->method('getContent')->willReturn($messageContent); + + $messageResult = new PaginatedResult([$message], 1, 1, $messageId); + + $this->messageRepository->expects(self::once()) + ->method('getFilteredAfterId') + ->with($this->callback(function (MessageFilter $filter) use ($limit): bool { + return $filter->getLastId() === 0 && $filter->getLimit() === $limit; + })) + ->willReturn($messageResult); + + $this->userMessageViewManager->expects(self::once()) + ->method('countViewsByMessageIds') + ->with([$messageId]) + ->willReturn([$messageId => 40]); + + $this->linkTrackManager->expects(self::once()) + ->method('countUniqueClickersByMessageIds') + ->with([$messageId]) + ->willReturn([$messageId => 10]); + + $result = $this->subject->getRecentCampaigns($limit); + + self::assertCount(1, $result); + self::assertSame('Recent Campaign', $result[0]['name']); + self::assertNull($result[0]['status']); + self::assertSame('2023-02-01', $result[0]['date']); + self::assertSame('40%', $result[0]['open_rate']); + self::assertSame('10%', $result[0]['click_rate']); + } } From 94b3bf2797b0e02d37d04563789f0819c449a20b Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 17 Aug 2026 13:42:33 +0400 Subject: [PATCH 14/39] fix: migration --- config/doctrine_migrations.yml | 2 +- src/Migrations/Version20251028092902MySqlUpdate.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/config/doctrine_migrations.yml b/config/doctrine_migrations.yml index 97e3bd6f9..7c5eda4ae 100644 --- a/config/doctrine_migrations.yml +++ b/config/doctrine_migrations.yml @@ -2,7 +2,7 @@ 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 + all_or_nothing: false organize_migrations: false custom_template: '%kernel.project_dir%/src/Migrations/_template_migration.php.tpl' storage: diff --git a/src/Migrations/Version20251028092902MySqlUpdate.php b/src/Migrations/Version20251028092902MySqlUpdate.php index 2881be2f5..db1955ee3 100644 --- a/src/Migrations/Version20251028092902MySqlUpdate.php +++ b/src/Migrations/Version20251028092902MySqlUpdate.php @@ -23,6 +23,7 @@ public function up(Schema $schema): void get_class($platform) )); + $this->addSql('UPDATE phplist_admin SET created = COALESCE(created, modified, NOW()) WHERE created IS NULL'); $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)'); From a7d5b220431225ffe0a09a374653705f5fe7e7ff Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 19 Aug 2026 18:09:03 +0400 Subject: [PATCH 15/39] fix: MyISAM engine --- .../Version20251028092902MySqlUpdate.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/Migrations/Version20251028092902MySqlUpdate.php b/src/Migrations/Version20251028092902MySqlUpdate.php index db1955ee3..faed4c169 100644 --- a/src/Migrations/Version20251028092902MySqlUpdate.php +++ b/src/Migrations/Version20251028092902MySqlUpdate.php @@ -23,6 +23,27 @@ public function up(Schema $schema): void get_class($platform) )); + // legacy phpList installs created these tables as MyISAM, which cannot be referenced by + // the InnoDB foreign keys added below (MySQL error 1824: Failed to open the referenced table) + $this->addSql('ALTER TABLE phplist_admin ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_admin_attribute ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_adminattribute ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_admintoken ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_list ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_listmessage ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_listuser ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_message ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_subscribepage ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_template ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_templateimage ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_user_attribute ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_user_blacklist ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_user_blacklist_data ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_user_user ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_user_user_attribute ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_user_user_history ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_usermessage ENGINE=InnoDB'); + $this->addSql('UPDATE phplist_admin SET created = COALESCE(created, modified, NOW()) WHERE created IS NULL'); $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'); From 2f075fe63235a5a75424485ffb4ae2a1c47f4ec5 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 20 Aug 2026 09:57:19 +0400 Subject: [PATCH 16/39] MySqlEngineUpdate --- ...Version20251028092902MySqlEngineUpdate.php | 60 +++++++++++++++++++ .../Version20251028092902MySqlUpdate.php | 41 +++++++------ 2 files changed, 80 insertions(+), 21 deletions(-) create mode 100644 src/Migrations/Version20251028092902MySqlEngineUpdate.php diff --git a/src/Migrations/Version20251028092902MySqlEngineUpdate.php b/src/Migrations/Version20251028092902MySqlEngineUpdate.php new file mode 100644 index 000000000..4175c7415 --- /dev/null +++ b/src/Migrations/Version20251028092902MySqlEngineUpdate.php @@ -0,0 +1,60 @@ +connection->getDatabasePlatform(); + $this->skipIf(!$platform instanceof MySQLPlatform, sprintf( + 'Unsupported platform for this migration: %s', + get_class($platform) + )); + + $engine = $this->connection->fetchOne(" + SELECT ENGINE + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'phplist_user_user' + "); + + if ($engine !== 'InnoDB') { + // legacy phpList installs created these tables as MyISAM, which cannot be referenced by + // the InnoDB foreign keys added below (MySQL error 1824: Failed to open the referenced table) + $this->addSql('ALTER TABLE phplist_admin ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_admin_attribute ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_adminattribute ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_admintoken ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_list ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_listmessage ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_listuser ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_message ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_subscribepage ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_template ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_templateimage ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_user_attribute ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_user_blacklist ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_user_blacklist_data ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_user_user ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_user_user_attribute ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_user_user_history ENGINE=InnoDB'); + $this->addSql('ALTER TABLE phplist_usermessage ENGINE=InnoDB'); + } + } + + public function down(Schema $schema): void + { + + } +} diff --git a/src/Migrations/Version20251028092902MySqlUpdate.php b/src/Migrations/Version20251028092902MySqlUpdate.php index faed4c169..d7827228b 100644 --- a/src/Migrations/Version20251028092902MySqlUpdate.php +++ b/src/Migrations/Version20251028092902MySqlUpdate.php @@ -23,41 +23,25 @@ public function up(Schema $schema): void get_class($platform) )); - // legacy phpList installs created these tables as MyISAM, which cannot be referenced by - // the InnoDB foreign keys added below (MySQL error 1824: Failed to open the referenced table) - $this->addSql('ALTER TABLE phplist_admin ENGINE=InnoDB'); - $this->addSql('ALTER TABLE phplist_admin_attribute ENGINE=InnoDB'); - $this->addSql('ALTER TABLE phplist_adminattribute ENGINE=InnoDB'); - $this->addSql('ALTER TABLE phplist_admintoken ENGINE=InnoDB'); - $this->addSql('ALTER TABLE phplist_list ENGINE=InnoDB'); - $this->addSql('ALTER TABLE phplist_listmessage ENGINE=InnoDB'); - $this->addSql('ALTER TABLE phplist_listuser ENGINE=InnoDB'); - $this->addSql('ALTER TABLE phplist_message ENGINE=InnoDB'); - $this->addSql('ALTER TABLE phplist_subscribepage ENGINE=InnoDB'); - $this->addSql('ALTER TABLE phplist_template ENGINE=InnoDB'); - $this->addSql('ALTER TABLE phplist_templateimage ENGINE=InnoDB'); - $this->addSql('ALTER TABLE phplist_user_attribute ENGINE=InnoDB'); - $this->addSql('ALTER TABLE phplist_user_blacklist ENGINE=InnoDB'); - $this->addSql('ALTER TABLE phplist_user_blacklist_data ENGINE=InnoDB'); - $this->addSql('ALTER TABLE phplist_user_user ENGINE=InnoDB'); - $this->addSql('ALTER TABLE phplist_user_user_attribute ENGINE=InnoDB'); - $this->addSql('ALTER TABLE phplist_user_user_history ENGINE=InnoDB'); - $this->addSql('ALTER TABLE phplist_usermessage ENGINE=InnoDB'); - $this->addSql('UPDATE phplist_admin SET created = COALESCE(created, modified, NOW()) WHERE created IS NULL'); $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('DELETE t FROM phplist_admin_attribute t LEFT JOIN phplist_adminattribute p ON t.adminattributeid = p.id WHERE p.id IS NULL'); + $this->addSql('DELETE t FROM phplist_admin_attribute t LEFT JOIN phplist_admin p ON t.adminid = p.id WHERE p.id IS NULL'); $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('DELETE t FROM phplist_admin_login t LEFT JOIN phplist_admin p ON t.adminid = p.id WHERE p.id IS 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('UPDATE phplist_admin_password_request t LEFT JOIN phplist_admin p ON t.admin = p.id SET t.admin = NULL WHERE t.admin IS NOT NULL AND p.id IS 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('UPDATE phplist_admintoken t LEFT JOIN phplist_admin p ON t.adminid = p.id SET t.adminid = NULL WHERE t.adminid IS NOT NULL AND p.id IS 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'); @@ -93,11 +77,14 @@ public function up(Schema $schema): void $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('UPDATE phplist_list t LEFT JOIN phplist_admin p ON t.owner = p.id SET t.owner = NULL WHERE t.owner IS NOT NULL AND p.id IS 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('DELETE t FROM phplist_listmessage t LEFT JOIN phplist_message p ON t.messageid = p.id WHERE p.id IS NULL'); + $this->addSql('DELETE t FROM phplist_listmessage t LEFT JOIN phplist_list p ON t.listid = p.id WHERE p.id IS 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)'); @@ -106,6 +93,8 @@ public function up(Schema $schema): void $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('DELETE t FROM phplist_listuser t LEFT JOIN phplist_user_user p ON t.userid = p.id WHERE p.id IS NULL'); + $this->addSql('DELETE t FROM phplist_listuser t LEFT JOIN phplist_list p ON t.listid = p.id WHERE p.id IS 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)'); @@ -113,6 +102,8 @@ public function up(Schema $schema): void $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 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('UPDATE phplist_message t LEFT JOIN phplist_admin p ON t.owner = p.id SET t.owner = NULL WHERE t.owner IS NOT NULL AND p.id IS NULL'); + $this->addSql('UPDATE phplist_message t LEFT JOIN phplist_template p ON t.template = p.id SET t.template = NULL WHERE t.template IS NOT NULL AND p.id IS 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)'); @@ -123,11 +114,13 @@ public function up(Schema $schema): void $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('UPDATE phplist_subscribepage t LEFT JOIN phplist_admin p ON t.owner = p.id SET t.owner = NULL WHERE t.owner IS NOT NULL AND p.id IS 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('DELETE t FROM phplist_templateimage t LEFT JOIN phplist_template p ON t.template = p.id WHERE p.id IS 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'); @@ -138,6 +131,7 @@ public function up(Schema $schema): void $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('DELETE t FROM phplist_user_blacklist_data t LEFT JOIN phplist_user_blacklist p ON t.email = p.email WHERE p.email IS NULL'); $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'); @@ -161,15 +155,20 @@ public function up(Schema $schema): void $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('DELETE t FROM phplist_user_user_attribute t LEFT JOIN phplist_user_attribute p ON t.attributeid = p.id WHERE p.id IS NULL'); + $this->addSql('DELETE t FROM phplist_user_user_attribute t LEFT JOIN phplist_user_user p ON t.userid = p.id WHERE p.id IS 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('DELETE t FROM phplist_user_user_history t LEFT JOIN phplist_user_user p ON t.userid = p.id WHERE p.id IS 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('DELETE t FROM phplist_usermessage t LEFT JOIN phplist_user_user p ON t.userid = p.id WHERE p.id IS NULL'); + $this->addSql('DELETE t FROM phplist_usermessage t LEFT JOIN phplist_message p ON t.messageid = p.id WHERE p.id IS NULL'); $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'); From e96f2cfda2ede3c5f25e884d801ed669a6d48f81 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 20 Aug 2026 11:16:08 +0400 Subject: [PATCH 17/39] feat: implement dynamic index renaming and creation in migrations --- src/Migrations/AbstractPrefixedMigration.php | 34 +++++++++++++++++++ .../Version20251028092902MySqlUpdate.php | 24 ++++++++----- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/Migrations/AbstractPrefixedMigration.php b/src/Migrations/AbstractPrefixedMigration.php index f0f67b5c2..96959f5f8 100644 --- a/src/Migrations/AbstractPrefixedMigration.php +++ b/src/Migrations/AbstractPrefixedMigration.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Migrations; +use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; /** @@ -27,6 +28,39 @@ protected function addSql(string $sql, array $params = [], array $types = []): v ); } + /** + * Legacy phpList dumps don't all carry the same set of index names (older exports predate + * some indexes entirely), so a hardcoded RENAME INDEX can fail against a given dump. This + * renames whichever of the candidate legacy names is actually present, or creates the target + * index fresh if none of them are. + */ + protected function renameOrCreateIndex( + Schema $schema, + string $tableName, + array $possibleOldIndexNames, + string $newIndexName, + array $columns + ): void { + $table = $schema->getTable($this->getPrefixedTableName($tableName)); + + foreach ($possibleOldIndexNames as $oldIndexName) { + if ($table->hasIndex($oldIndexName)) { + $this->addSql(sprintf('ALTER TABLE %s RENAME INDEX %s TO %s', $tableName, $oldIndexName, $newIndexName)); + + return; + } + } + + if (!$table->hasIndex($newIndexName)) { + $this->addSql(sprintf('CREATE INDEX %s ON %s (%s)', $newIndexName, $tableName, implode(', ', $columns))); + } + } + + private function getPrefixedTableName(string $tableName): string + { + return str_replace(self::DEFAULT_PREFIX, $this->getTablePrefix(), $tableName); + } + private function getTablePrefix(): string { $prefix = $_ENV['DATABASE_PREFIX'] ?? getenv('DATABASE_PREFIX'); diff --git a/src/Migrations/Version20251028092902MySqlUpdate.php b/src/Migrations/Version20251028092902MySqlUpdate.php index d7827228b..869915aa8 100644 --- a/src/Migrations/Version20251028092902MySqlUpdate.php +++ b/src/Migrations/Version20251028092902MySqlUpdate.php @@ -99,9 +99,9 @@ public function up(Schema $schema): void $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 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->renameOrCreateIndex($schema, 'phplist_listuser', ['useridx'], 'phplist_listuser_useridx', ['userid']); + $this->renameOrCreateIndex($schema, 'phplist_listuser', ['listidx'], 'phplist_listuser_listidx', ['listid']); + $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 astext astext INT DEFAULT 0 NOT NULL, CHANGE ashtml ashtml INT DEFAULT 0 NOT NULL, CHANGE astextandhtml astextandhtml INT DEFAULT 0 NOT NULL, CHANGE aspdf aspdf INT DEFAULT 0 NOT NULL, CHANGE astextandpdf astextandpdf INT DEFAULT 0 NOT NULL, CHANGE viewed viewed INT DEFAULT 0 NOT NULL, CHANGE bouncecount bouncecount INT DEFAULT 0 NOT NULL'); $this->addSql('UPDATE phplist_message t LEFT JOIN phplist_admin p ON t.owner = p.id SET t.owner = NULL WHERE t.owner IS NOT NULL AND p.id IS NULL'); $this->addSql('UPDATE phplist_message t LEFT JOIN phplist_template p ON t.template = p.id SET t.template = NULL WHERE t.template IS NOT NULL AND p.id IS NULL'); $this->addSql('ALTER TABLE phplist_message ADD CONSTRAINT FK_C5D81FCDCF60E67C FOREIGN KEY (owner) REFERENCES phplist_admin (id)'); @@ -146,11 +146,17 @@ public function up(Schema $schema): void $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->renameOrCreateIndex( + $schema, + 'phplist_user_user', + ['idxuniqid', 'idx_phplist_user_user_uniqid'], + 'phplist_user_user_idxuniqid', + ['uniqid'] + ); $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->renameOrCreateIndex($schema, 'phplist_user_user', ['confidx'], 'phplist_user_user_confidx', ['confirmed']); + $this->renameOrCreateIndex($schema, 'phplist_user_user', ['blidx'], 'phplist_user_user_blidx', ['blacklisted']); + $this->renameOrCreateIndex($schema, 'phplist_user_user', ['optidx'], 'phplist_user_user_optidx', ['optedin']); $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'); @@ -173,9 +179,9 @@ public function up(Schema $schema): void $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->renameOrCreateIndex($schema, 'phplist_usermessage', ['statusidx'], 'phplist_usermessage_statusidx', ['status']); $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->renameOrCreateIndex($schema, 'phplist_usermessage', ['viewedidx'], 'phplist_usermessage_viewedidx', ['viewed']); $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'); From 6a25cf3969d39203039a748c011e7212d81cb82b Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 20 Aug 2026 12:43:23 +0400 Subject: [PATCH 18/39] fix: psql migrations to use INT --- .../Version20251031072945PostGreInit.php | 2 +- src/Migrations/Version20260204094237.php | 54 ------------------- 2 files changed, 1 insertion(+), 55 deletions(-) delete mode 100644 src/Migrations/Version20260204094237.php diff --git a/src/Migrations/Version20251031072945PostGreInit.php b/src/Migrations/Version20251031072945PostGreInit.php index 6b2446c95..08076e8d0 100644 --- a/src/Migrations/Version20251031072945PostGreInit.php +++ b/src/Migrations/Version20251031072945PostGreInit.php @@ -120,7 +120,7 @@ public function up(Schema $schema): void $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, 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 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 INT DEFAULT 0 NOT NULL, ashtml INT DEFAULT 0 NOT NULL, aspdf INT DEFAULT 0 NOT NULL, astextandhtml INT DEFAULT 0 NOT NULL, astextandpdf INT DEFAULT 0 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, 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)'); diff --git a/src/Migrations/Version20260204094237.php b/src/Migrations/Version20260204094237.php deleted file mode 100644 index 56ab5b1a6..000000000 --- a/src/Migrations/Version20260204094237.php +++ /dev/null @@ -1,54 +0,0 @@ -connection->getDatabasePlatform(); - $this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf( - 'Unsupported platform for this migration: %s', - get_class($platform) - )); - - $this->addSql('ALTER TABLE phplist_message ALTER astext TYPE INT USING astext::integer'); - $this->addSql('ALTER TABLE phplist_message ALTER ashtml TYPE INT USING ashtml::integer'); - $this->addSql('ALTER TABLE phplist_message ALTER aspdf TYPE INT USING aspdf::integer'); - $this->addSql('ALTER TABLE phplist_message ALTER astextandhtml TYPE INT USING astextandhtml::integer'); - $this->addSql('ALTER TABLE phplist_message ALTER astextandpdf TYPE INT USING astextandpdf::integer'); - } - - 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('ALTER TABLE phplist_message ALTER astext TYPE BOOLEAN USING (astext::integer <> 0)'); - $this->addSql('ALTER TABLE phplist_message ALTER ashtml TYPE BOOLEAN USING (ashtml::integer <> 0)'); - $this->addSql('ALTER TABLE phplist_message ALTER aspdf TYPE BOOLEAN USING (aspdf::integer <> 0)'); - $this->addSql('ALTER TABLE phplist_message ALTER astextandhtml TYPE BOOLEAN USING (astextandhtml::integer <> 0)'); - $this->addSql('ALTER TABLE phplist_message ALTER astextandpdf TYPE BOOLEAN USING (astextandpdf::integer <> 0)'); - } -} From 8b844f98f1b5b192195ff2076e8f40c547c31285 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 20 Aug 2026 14:57:06 +0400 Subject: [PATCH 19/39] feat: add status and sortOrder to MessageFilter, update MessageRepository for filtering and sorting --- .../Messaging/Model/Filter/MessageFilter.php | 29 +++++ .../Repository/MessageRepository.php | 34 +++++- ...260820120000MySqlAddMessageStatusIndex.php | 38 ++++++ ...0820120001PostGreAddMessageStatusIndex.php | 38 ++++++ .../Repository/MessageRepositoryTest.php | 115 ++++++++++++++++++ 5 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 src/Migrations/Version20260820120000MySqlAddMessageStatusIndex.php create mode 100644 src/Migrations/Version20260820120001PostGreAddMessageStatusIndex.php diff --git a/src/Domain/Messaging/Model/Filter/MessageFilter.php b/src/Domain/Messaging/Model/Filter/MessageFilter.php index ccb5b1ac9..470c18908 100644 --- a/src/Domain/Messaging/Model/Filter/MessageFilter.php +++ b/src/Domain/Messaging/Model/Filter/MessageFilter.php @@ -12,6 +12,8 @@ class MessageFilter extends PaginatedFilter implements FilterRequestInterface { private ?Administrator $owner = null; private ?string $subject = null; + private ?string $status = null; + private string $sortOrder = 'asc'; public function getOwner(): ?Administrator { @@ -37,4 +39,31 @@ public function setSubject(?string $subject): self $this->subject = $subject; return $this; } + + public function getStatus(): ?string + { + return $this->status; + } + + public function setStatus(?string $status): self + { + if ($status !== null) { + $status = trim($status); + } + $this->status = $status; + return $this; + } + + public function getSortOrder(): string + { + return $this->sortOrder; + } + + public function setSortOrder(string $sortOrder): self + { + if (in_array($sortOrder, ['asc', 'desc'], true)) { + $this->sortOrder = $sortOrder; + } + return $this; + } } diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php index cc22602c3..133947946 100644 --- a/src/Domain/Messaging/Repository/MessageRepository.php +++ b/src/Domain/Messaging/Repository/MessageRepository.php @@ -48,7 +48,12 @@ public function findById(int $id): ?Message ->getOneOrNullResult(); } - /** @return PaginatedResult */ + /** + * @return PaginatedResult + * @SuppressWarnings("CyclomaticComplexity") + * @SuppressWarnings("NPathComplexity") + * + */ public function getFilteredAfterId(FilterRequestInterface $filter): PaginatedResult { $lastId = $filter->getLastId(); @@ -56,7 +61,9 @@ public function getFilteredAfterId(FilterRequestInterface $filter): PaginatedRes $queryBuilder = $this->createQueryBuilder('m'); if ($filter instanceof MessageFilter && $filter->getOwner() !== null) { - $queryBuilder->andWhere('IDENTITY(m.owner) = :ownerId') + // Legacy/imported messages have no owner recorded - treat them as shared rather + // than invisible, instead of excluding them outright via a strict owner match. + $queryBuilder->andWhere('(m.owner IS NULL OR IDENTITY(m.owner) = :ownerId)') ->setParameter('ownerId', $filter->getOwner()->getId()); } @@ -65,17 +72,34 @@ public function getFilteredAfterId(FilterRequestInterface $filter): PaginatedRes ->setParameter('subject', '%' . $filter->getSubject() . '%'); } + if ($filter instanceof MessageFilter && $filter->getStatus() !== null) { + $statuses = array_values(array_filter(array_map('trim', explode(',', $filter->getStatus())))); + if (count($statuses) === 1) { + $queryBuilder->andWhere('m.metadata.status = :status') + ->setParameter('status', $statuses[0]); + } elseif (count($statuses) > 1) { + $queryBuilder->andWhere('m.metadata.status IN (:statuses)') + ->setParameter('statuses', $statuses); + } + } + $countQb = clone $queryBuilder; $total = (int) $countQb ->select('COUNT(DISTINCT m.id)') ->getQuery() ->getSingleScalarResult(); + $sortOrder = $filter instanceof MessageFilter ? $filter->getSortOrder() : 'asc'; + $comparison = $sortOrder === 'desc' ? '<' : '>'; + + if ($lastId > 0) { + $queryBuilder->andWhere(sprintf('m.id %s :lastId', $comparison)) + ->setParameter('lastId', $lastId); + } + /** @var list $items */ $items = $queryBuilder - ->andWhere('m.id > :lastId') - ->setParameter('lastId', $lastId) - ->orderBy('m.id', 'ASC') + ->orderBy('m.id', strtoupper($sortOrder)) ->setMaxResults($limit) ->getQuery() ->getResult(); diff --git a/src/Migrations/Version20260820120000MySqlAddMessageStatusIndex.php b/src/Migrations/Version20260820120000MySqlAddMessageStatusIndex.php new file mode 100644 index 000000000..7b2a85df1 --- /dev/null +++ b/src/Migrations/Version20260820120000MySqlAddMessageStatusIndex.php @@ -0,0 +1,38 @@ +connection->getDatabasePlatform(); + $this->skipIf(!$platform instanceof MySQLPlatform, sprintf( + 'Unsupported platform for this migration: %s', + get_class($platform) + )); + + $this->addSql('CREATE INDEX phplist_message_statusidx ON phplist_message (status, id)'); + } + + 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('DROP INDEX phplist_message_statusidx ON phplist_message'); + } +} diff --git a/src/Migrations/Version20260820120001PostGreAddMessageStatusIndex.php b/src/Migrations/Version20260820120001PostGreAddMessageStatusIndex.php new file mode 100644 index 000000000..2dfe290ad --- /dev/null +++ b/src/Migrations/Version20260820120001PostGreAddMessageStatusIndex.php @@ -0,0 +1,38 @@ +connection->getDatabasePlatform(); + $this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf( + 'Unsupported platform for this migration: %s', + get_class($platform) + )); + + $this->addSql('CREATE INDEX phplist_message_statusidx ON phplist_message (status, id)'); + } + + 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 INDEX phplist_message_statusidx'); + } +} diff --git a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php index 29793766c..7bd83207d 100644 --- a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php +++ b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php @@ -8,6 +8,7 @@ use Doctrine\ORM\Tools\SchemaTool; use PhpList\Core\Domain\Configuration\Model\OutputFormat; use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Messaging\Model\Filter\MessageFilter; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat; @@ -131,4 +132,118 @@ public function testMessageTimestampsAreSetOnPersist(): void self::assertSimilarDates($expectedDate, $message->getUpdatedAt()); } + + private function persistMessage( + Message\MessageStatus $status, + string $subject, + ?Administrator $owner = null + ): Message { + $message = new Message( + new MessageFormat(true, OutputFormat::Text->value), + new MessageSchedule(1, null, 3, null, null), + new MessageMetadata($status), + new MessageContent($subject), + new MessageOptions(), + $owner + ); + + $this->entityManager->persist($message); + + return $message; + } + + public function testGetFilteredAfterIdIncludesOwnerlessMessagesForAnyAdmin(): void + { + $admin = (new Administrator())->setLoginName('owner-test-admin'); + $otherAdmin = (new Administrator())->setLoginName('other-admin'); + $this->entityManager->persist($admin); + $this->entityManager->persist($otherAdmin); + + $this->persistMessage(Message\MessageStatus::Sent, 'Legacy unowned campaign'); + $this->persistMessage(Message\MessageStatus::Sent, 'My own campaign', $admin); + $this->persistMessage(Message\MessageStatus::Sent, "Someone else's campaign", $otherAdmin); + $this->entityManager->flush(); + $this->entityManager->clear(); + $admin = $this->entityManager->getRepository(Administrator::class)->find($admin->getId()); + + $filter = (new MessageFilter())->setOwner($admin); + $result = $this->messageRepository->getFilteredAfterId($filter); + + $subjects = array_map( + static fn (Message $message) => $message->getContent()->getSubject(), + $result->getItems() + ); + self::assertContains('Legacy unowned campaign', $subjects); + self::assertContains('My own campaign', $subjects); + self::assertNotContains("Someone else's campaign", $subjects); + } + + public function testGetFilteredAfterIdFiltersBySingleStatus(): void + { + $this->persistMessage(Message\MessageStatus::Draft, 'Draft one'); + $this->persistMessage(Message\MessageStatus::Sent, 'Sent one'); + $this->entityManager->flush(); + $this->entityManager->clear(); + + $filter = (new MessageFilter())->setStatus('draft'); + $result = $this->messageRepository->getFilteredAfterId($filter); + + self::assertCount(1, $result->getItems()); + self::assertSame('Draft one', $result->getItems()[0]->getContent()->getSubject()); + self::assertSame(1, $result->getTotal()); + } + + public function testGetFilteredAfterIdFiltersByMultipleCommaSeparatedStatuses(): void + { + $this->persistMessage(Message\MessageStatus::Draft, 'Draft one'); + $this->persistMessage(Message\MessageStatus::Submitted, 'Submitted one'); + $this->persistMessage(Message\MessageStatus::Sent, 'Sent one'); + $this->entityManager->flush(); + $this->entityManager->clear(); + + $filter = (new MessageFilter())->setStatus('draft,submitted'); + $result = $this->messageRepository->getFilteredAfterId($filter); + + self::assertCount(2, $result->getItems()); + self::assertSame(2, $result->getTotal()); + } + + public function testGetFilteredAfterIdDefaultsToAscendingOrder(): void + { + $first = $this->persistMessage(Message\MessageStatus::Sent, 'First'); + $second = $this->persistMessage(Message\MessageStatus::Sent, 'Second'); + $this->entityManager->flush(); + $this->entityManager->clear(); + + $result = $this->messageRepository->getFilteredAfterId(new MessageFilter()); + + self::assertSame($first->getId(), $result->getItems()[0]->getId()); + self::assertSame($second->getId(), $result->getItems()[1]->getId()); + } + + public function testGetFilteredAfterIdSortsDescendingAndCursorsBackward(): void + { + $first = $this->persistMessage(Message\MessageStatus::Sent, 'First'); + $second = $this->persistMessage(Message\MessageStatus::Sent, 'Second'); + $third = $this->persistMessage(Message\MessageStatus::Sent, 'Third'); + $this->entityManager->flush(); + $this->entityManager->clear(); + + $filter = (new MessageFilter())->setSortOrder('desc')->setLimit(2); + $firstPage = $this->messageRepository->getFilteredAfterId($filter); + + self::assertCount(2, $firstPage->getItems()); + self::assertSame($third->getId(), $firstPage->getItems()[0]->getId()); + self::assertSame($second->getId(), $firstPage->getItems()[1]->getId()); + self::assertSame(3, $firstPage->getTotal()); + + $secondPageFilter = (new MessageFilter()) + ->setSortOrder('desc') + ->setLimit(2) + ->setLastId($firstPage->getItems()[1]->getId()); + $secondPage = $this->messageRepository->getFilteredAfterId($secondPageFilter); + + self::assertCount(1, $secondPage->getItems()); + self::assertSame($first->getId(), $secondPage->getItems()[0]->getId()); + } } From 17bb14683353beefa51fa563d4608f651d6ffa4d Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 21 Aug 2026 10:20:12 +0400 Subject: [PATCH 20/39] feat: add CreateAdminCommand for creating new admin users and remove ImportDefaultsCommand --- config/parameters.yml | 1 - .../Identity/Command/CreateAdminCommand.php | 135 ++++++++++++++++++ .../Command/ImportDefaultsCommand.php | 100 ------------- 3 files changed, 135 insertions(+), 101 deletions(-) create mode 100644 src/Domain/Identity/Command/CreateAdminCommand.php delete mode 100644 src/Domain/Identity/Command/ImportDefaultsCommand.php diff --git a/config/parameters.yml b/config/parameters.yml index f2793be52..aecc30ecb 100644 --- a/config/parameters.yml +++ b/config/parameters.yml @@ -14,7 +14,6 @@ parameters: database_user: '%env(PHPLIST_DATABASE_USER)%' database_password: '%env(PHPLIST_DATABASE_PASSWORD)%' database_prefix: '%env(DATABASE_PREFIX)%' - app.default_admin_password: '%env(PHPLIST_DEFAULT_ADMIN_PASSWORD)%' list_table_prefix: '%env(LIST_TABLE_PREFIX)%' app.dev_version: '%env(APP_DEV_VERSION)%' app.dev_email: '%env(APP_DEV_EMAIL)%' diff --git a/src/Domain/Identity/Command/CreateAdminCommand.php b/src/Domain/Identity/Command/CreateAdminCommand.php new file mode 100644 index 000000000..c35aa0615 --- /dev/null +++ b/src/Domain/Identity/Command/CreateAdminCommand.php @@ -0,0 +1,135 @@ +addOption('login', null, InputOption::VALUE_REQUIRED, 'Login name for the admin') + ->addOption('password', null, InputOption::VALUE_REQUIRED, 'Password for the admin') + ->addOption('email', null, InputOption::VALUE_REQUIRED, 'Email for the admin'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + + $login = $this->resolveValue($input, $output, $helper, 'login', 'Enter login for admin: ', false); + if ($login === null) { + $output->writeln('Login must not be empty.'); + return Command::FAILURE; + } + + $existing = $this->administratorRepository->findOneBy(['loginName' => $login]); + if ($existing !== null) { + $output->writeln(sprintf( + 'Admin already exists: login="%s", email="%s"', + $existing->getLoginName(), + $existing->getEmail(), + )); + return Command::SUCCESS; + } + + $email = $this->resolveValue($input, $output, $helper, 'email', 'Enter email for admin: ', false); + if ($email === null) { + $output->writeln('Email must not be empty.'); + return Command::FAILURE; + } + + $password = $this->resolveValue( + $input, + $output, + $helper, + 'password', + sprintf('Enter password for admin (login "%s"): ', $login), + true + ); + if ($password === null) { + $output->writeln('Password must not be empty.'); + return Command::FAILURE; + } + + $dto = new CreateAdministratorDto( + loginName: $login, + password: $password, + email: $email, + isSuperUser: true, + privileges: $this->allPrivilegesGranted(), + ); + $admin = $this->administratorManager->createAdministrator($dto); + $this->entityManager->flush(); + + $output->writeln(sprintf( + 'Admin created: login="%s", email="%s", superuser=yes, privileges=all', + $admin->getLoginName(), + $admin->getEmail() + )); + + return Command::SUCCESS; + } + + private function resolveValue( + InputInterface $input, + OutputInterface $output, + QuestionHelper $helper, + string $optionName, + string $prompt, + bool $hidden + ): ?string { + $value = $input->getOption($optionName); + + if ($value === null) { + $question = new Question($prompt); + if ($hidden) { + $question->setHidden(true); + $question->setHiddenFallback(false); + } + $value = $helper->ask($input, $output, $question); + } + + $value = (string) $value; + return trim($value) === '' ? null : $value; + } + + /** + * @return array + */ + private function allPrivilegesGranted(): array + { + $all = []; + foreach (PrivilegeFlag::cases() as $flag) { + $all[$flag->value] = true; + } + return $all; + } +} diff --git a/src/Domain/Identity/Command/ImportDefaultsCommand.php b/src/Domain/Identity/Command/ImportDefaultsCommand.php deleted file mode 100644 index c91457c3c..000000000 --- a/src/Domain/Identity/Command/ImportDefaultsCommand.php +++ /dev/null @@ -1,100 +0,0 @@ -defaultAdminPassword !== '' ? $this->defaultAdminPassword : null; - - $allPrivileges = $this->allPrivilegesGranted(); - - $existing = $this->administratorRepository->findOneBy(['loginName' => $login]); - if ($existing === null) { - // If creating the default admin, require a password. Prefer env var, else prompt for input. - if ($password === null) { - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); - $question = new Question('Enter password for default admin (login "admin"): '); - $question->setHidden(true); - $question->setHiddenFallback(false); - $password = (string) $helper->ask($input, $output, $question); - if (trim($password) === '') { - $output->writeln('Password must not be empty.'); - return Command::FAILURE; - } - } - - $dto = new CreateAdministratorDto( - loginName: $login, - password: $password, - email: $email, - isSuperUser: true, - privileges: $allPrivileges, - ); - $admin = $this->administratorManager->createAdministrator($dto); - $this->entityManager->flush(); - - $output->writeln(sprintf( - 'Default admin created: login="%s", email="%s", superuser=yes, privileges=all', - $admin->getLoginName(), - $admin->getEmail() - )); - } else { - $output->writeln(sprintf( - 'Default admin already exists: login="%s", email="%s"', - $existing->getLoginName(), - $existing->getEmail(), - )); - } - - return Command::SUCCESS; - } - - /** - * @return array - */ - private function allPrivilegesGranted(): array - { - $all = []; - foreach (PrivilegeFlag::cases() as $flag) { - $all[$flag->value] = true; - } - return $all; - } -} From 39049e558be433a1ca1aad6d555f3c448b8a8e29 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 21 Aug 2026 11:52:35 +0400 Subject: [PATCH 21/39] feat: update TablePrefixListener to support additional prefixed namespaces --- config/doctrine_migrations.yml | 1 - src/Core/Doctrine/TablePrefixListener.php | 20 +++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/config/doctrine_migrations.yml b/config/doctrine_migrations.yml index 7c5eda4ae..1db93ec51 100644 --- a/config/doctrine_migrations.yml +++ b/config/doctrine_migrations.yml @@ -1,7 +1,6 @@ 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: false organize_migrations: false custom_template: '%kernel.project_dir%/src/Migrations/_template_migration.php.tpl' diff --git a/src/Core/Doctrine/TablePrefixListener.php b/src/Core/Doctrine/TablePrefixListener.php index eee9098fe..4aa49acc7 100644 --- a/src/Core/Doctrine/TablePrefixListener.php +++ b/src/Core/Doctrine/TablePrefixListener.php @@ -11,6 +11,16 @@ #[AsDoctrineListener(event: Events::loadClassMetadata)] class TablePrefixListener { + /** + * Namespace prefixes of entities that should be prefixed with the app's table prefix. Bundles that ship + * their own entities (e.g. TatevikGr\RssFeedBundle) don't know about this convention on their own, so + * their namespace has to be opted in here explicitly. + */ + private const PREFIXED_NAMESPACES = [ + 'PhpList\\Core\\Domain\\', + 'TatevikGr\\RssFeedBundle\\Entity\\', + ]; + public function __construct(private readonly string $tablePrefix) { } @@ -23,7 +33,15 @@ public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs): void return; } - if (!str_starts_with($metadata->getName(), 'PhpList\\Core\\Domain\\')) { + $isPrefixed = false; + foreach (self::PREFIXED_NAMESPACES as $namespace) { + if (str_starts_with($metadata->getName(), $namespace)) { + $isPrefixed = true; + break; + } + } + + if (!$isPrefixed) { return; } From 7a95a2fedfce77b4f9cf153c45cf0fae984477aa Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 24 Aug 2026 12:15:42 +0400 Subject: [PATCH 22/39] chore: remove outdated RssDispatchCommand and dependency on tatevikgr/rss-feed --- composer.json | 6 +++--- config/services/commands.yml | 3 --- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index 4bab2a2c6..b361c66bc 100644 --- a/composer.json +++ b/composer.json @@ -78,7 +78,6 @@ "symfony/lock": "^6.4", "webklex/php-imap": "^6.2", "ext-imap": "*", - "tatevikgr/rss-feed": "dev-main", "ext-pdo": "*", "ezyang/htmlpurifier": "^4.19", "ext-libxml": "*", @@ -108,8 +107,9 @@ "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" }, "suggest": { - "phplist/web-frontend": "5.0.x-dev", - "phplist/rest-api": "5.0.x-dev" + "phplist/web-frontend": "dev-main", + "phplist/rest-api": "dev-main", + "tatevikgr/rss-feed": "dev-main" }, "autoload": { "psr-4": { diff --git a/config/services/commands.yml b/config/services/commands.yml index 7e16ac873..65a0439bd 100644 --- a/config/services/commands.yml +++ b/config/services/commands.yml @@ -15,6 +15,3 @@ services: PhpList\Core\Bounce\Command\ProcessBouncesCommand: arguments: $protocolProcessors: !tagged_iterator 'phplist.bounce_protocol_processor' - - TatevikGr\RssFeedBundle\Command\RssDispatchCommand: - tags: ['console.command'] From 4ef6d0b80dbee9153836a8ea3296163345eb7610 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 25 Aug 2026 12:46:00 +0400 Subject: [PATCH 23/39] feat: optimize domain and local part statistics retrieval in AnalyticsService and SubscriberRepository --- .../Analytics/Service/AnalyticsService.php | 159 ++++-------------- .../Repository/SubscriberRepository.php | 77 +++++++++ .../Service/AnalyticsServiceTest.php | 105 +++--------- 3 files changed, 127 insertions(+), 214 deletions(-) diff --git a/src/Domain/Analytics/Service/AnalyticsService.php b/src/Domain/Analytics/Service/AnalyticsService.php index f9f52721e..e0ff69857 100644 --- a/src/Domain/Analytics/Service/AnalyticsService.php +++ b/src/Domain/Analytics/Service/AnalyticsService.php @@ -149,36 +149,12 @@ public function getViewOpensStatistics(int $limit = 50, int $lastId = 0): array */ public function getTopDomains(int $limit = 50, int $minSubscribers = 5): array { - $subscribers = $this->subscriberRepository->findAll(); + $rows = $this->subscriberRepository->getTopDomains($limit, $minSubscribers); - $domains = []; - foreach ($subscribers as $subscriber) { - $domain = $this->extractDomain($subscriber->getEmail()); - if ($domain !== '') { - $domains[$domain] = ($domains[$domain] ?? 0) + 1; - } - } - - $filteredDomains = array_filter($domains, function ($count) use ($minSubscribers) { - return $count >= $minSubscribers; - }); - - arsort($filteredDomains); - - $result = []; - $count = 0; - foreach ($filteredDomains as $domain => $subscriberCount) { - if ($count >= $limit) { - break; - } - - $result[] = [ - 'domain' => $domain, - 'subscribers' => $subscriberCount, - ]; - - $count++; - } + $result = array_map(static fn (array $row): array => [ + 'domain' => $row['domain'], + 'subscribers' => (int) $row['subscribers'], + ], $rows); return [ 'domains' => $result, @@ -281,69 +257,34 @@ private function calculateChange(float|int $current, float|int $previous): float */ public function getDomainConfirmationStatistics(int $limit = 50): array { - $domains = []; - $subscribers = $this->subscriberRepository->findAll(); - - foreach ($subscribers as $subscriber) { - $domain = $this->extractDomain($subscriber->getEmail()); - - if (!empty($domain)) { - if (!isset($domains[$domain])) { - $domains[$domain] = [ - 'confirmed' => 0, - 'unconfirmed' => 0, - 'blacklisted' => 0, - 'total' => 0, - ]; - } - - $domains[$domain]['total']++; - - if ($subscriber->isBlacklisted()) { - $domains[$domain]['blacklisted']++; - } elseif ($subscriber->isConfirmed()) { - $domains[$domain]['confirmed']++; - } else { - $domains[$domain]['unconfirmed']++; - } - } - } + $rows = $this->subscriberRepository->getDomainConfirmationStatistics($limit); - uasort($domains, function ($domain1, $domain2) { - return $domain2['unconfirmed'] <=> $domain1['unconfirmed']; - }); - - $result = []; - $count = 0; - foreach ($domains as $domain => $stats) { - if ($count >= $limit) { - break; - } + $result = array_map(function (array $row): array { + $total = (int) $row['total']; + $confirmed = (int) $row['confirmed']; + $unconfirmed = (int) $row['unconfirmed']; + $blacklisted = (int) $row['blacklisted']; - $domainTotal = $stats['total']; - - $result[] = [ - 'domain' => $domain, + return [ + 'domain' => $row['domain'], 'confirmed' => [ - 'count' => $stats['confirmed'], - 'percentage' => $this->formatStat($stats['confirmed'], $domainTotal) + 'count' => $confirmed, + 'percentage' => $this->formatStat($confirmed, $total) ], 'unconfirmed' => [ - 'count' => $stats['unconfirmed'], - 'percentage' => $this->formatStat($stats['unconfirmed'], $domainTotal) + 'count' => $unconfirmed, + 'percentage' => $this->formatStat($unconfirmed, $total) ], 'blacklisted' => [ - 'count' => $stats['blacklisted'], - 'percentage' => $this->formatStat($stats['blacklisted'], $domainTotal) + 'count' => $blacklisted, + 'percentage' => $this->formatStat($blacklisted, $total) ], 'total' => [ - 'count' => $stats['total'], - 'percentage' => $this->formatStat($stats['total'], $domainTotal) + 'count' => $total, + 'percentage' => $this->formatStat($total, $total) ], ]; - - $count++; - } + }, $rows); return [ 'domains' => $result, @@ -351,19 +292,6 @@ public function getDomainConfirmationStatistics(int $limit = 50): array ]; } - private function extractDomain(string $email): ?string - { - $atPoint = strrchr($email, '@'); - - if ($atPoint === false) { - return null; - } - - $domain = substr($atPoint, 1); - - return $domain !== '' ? $domain : null; - } - private function formatStat(int $count, int $total): int|float { $percentage = $total > 0 ? ($count / $total) * 100 : 0; @@ -384,43 +312,18 @@ private function formatStat(int $count, int $total): int|float */ public function getTopLocalParts(int $limit = 25): array { - $localParts = []; - - $subscribers = $this->subscriberRepository->findAll(); - - foreach ($subscribers as $subscriber) { - $email = $subscriber->getEmail(); - $atPosition = strpos($email, '@'); - - if ($atPosition !== false) { - $localPart = substr($email, 0, $atPosition); + $rows = $this->subscriberRepository->getTopLocalParts($limit); + $totalSubscribers = $this->subscriberRepository->countWithValidEmail(); - if (!isset($localParts[$localPart])) { - $localParts[$localPart] = 0; - } + $result = array_map(function (array $row) use ($totalSubscribers): array { + $count = (int) $row['count']; - $localParts[$localPart]++; - } - } - - arsort($localParts); - - $result = []; - $count = 0; - $totalSubscribers = array_sum($localParts); - foreach ($localParts as $localPart => $subscriberCount) { - if ($count >= $limit) { - break; - } - - $result[] = [ - 'localPart' => $localPart, - 'count' => $subscriberCount, - 'percentage' => $this->formatStat($subscriberCount, $totalSubscribers), + return [ + 'localPart' => $row['localPart'], + 'count' => $count, + 'percentage' => $this->formatStat($count, $totalSubscribers), ]; - - $count++; - } + }, $rows); return [ 'localParts' => $result, diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index 4fbfac0a1..9a8bdc5be 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -343,4 +343,81 @@ public function getByEmails(array $emails): array ->getQuery() ->getResult(); } + + /** + * Returns the top domains (by subscriber count) among subscribers with a valid email address. + * Aggregation happens in SQL so only $limit rows are ever loaded into memory. + * + * @return array + */ + public function getTopDomains(int $limit, int $minSubscribers): array + { + return $this->createQueryBuilder('s') + ->select("SUBSTRING(s.email, LOCATE('@', s.email) + 1, LENGTH(s.email)) AS domain") + ->addSelect('COUNT(s.id) AS subscribers') + ->where("LOCATE('@', s.email) > 0") + ->groupBy('domain') + ->having('COUNT(s.id) >= :minSubscribers') + ->setParameter('minSubscribers', $minSubscribers) + ->orderBy('subscribers', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getArrayResult(); + } + + /** + * Returns per-domain confirmed/unconfirmed/blacklisted subscriber counts, ordered by unconfirmed count. + * Aggregation happens in SQL so only $limit rows are ever loaded into memory. + * + * @return array + */ + public function getDomainConfirmationStatistics(int $limit): array + { + return $this->createQueryBuilder('s') + ->select("SUBSTRING(s.email, LOCATE('@', s.email) + 1, LENGTH(s.email)) AS domain") + ->addSelect('COUNT(s.id) AS total') + ->addSelect('SUM(CASE WHEN s.blacklisted = true THEN 1 ELSE 0 END) AS blacklisted') + ->addSelect('SUM(CASE WHEN s.blacklisted = false AND s.confirmed = true THEN 1 ELSE 0 END) AS confirmed') + ->addSelect( + 'SUM(CASE WHEN s.blacklisted = false AND s.confirmed = false THEN 1 ELSE 0 END) AS unconfirmed' + ) + ->where("LOCATE('@', s.email) > 0") + ->groupBy('domain') + ->orderBy('unconfirmed', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getArrayResult(); + } + + /** + * Returns the top local-parts (by subscriber count) among subscribers with a valid email address. + * Aggregation happens in SQL so only $limit rows are ever loaded into memory. + * + * @return array + */ + public function getTopLocalParts(int $limit): array + { + return $this->createQueryBuilder('s') + ->select("SUBSTRING(s.email, 1, LOCATE('@', s.email) - 1) AS localPart") + ->addSelect('COUNT(s.id) AS count') + ->where("LOCATE('@', s.email) > 0") + ->groupBy('localPart') + ->orderBy('count', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getArrayResult(); + } + + /** + * Counts subscribers whose email address contains an '@'. + */ + public function countWithValidEmail(): int + { + return (int) $this->createQueryBuilder('s') + ->select('COUNT(s.id)') + ->where("LOCATE('@', s.email) > 0") + ->getQuery() + ->getSingleScalarResult(); + } } diff --git a/tests/Unit/Domain/Analytics/Service/AnalyticsServiceTest.php b/tests/Unit/Domain/Analytics/Service/AnalyticsServiceTest.php index 2470f4709..a75587475 100644 --- a/tests/Unit/Domain/Analytics/Service/AnalyticsServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/AnalyticsServiceTest.php @@ -21,7 +21,6 @@ use PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageForwardRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; -use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -188,38 +187,13 @@ public function testGetViewOpensStatistics(): void public function testGetTopDomains(): void { - $subscriber1 = $this->createMock(Subscriber::class); - $subscriber1->method('getEmail')->willReturn('user1@example.com'); - - $subscriber2 = $this->createMock(Subscriber::class); - $subscriber2->method('getEmail')->willReturn('user2@example.com'); - - $subscriber3 = $this->createMock(Subscriber::class); - $subscriber3->method('getEmail')->willReturn('user3@example.com'); - - $subscriber4 = $this->createMock(Subscriber::class); - $subscriber4->method('getEmail')->willReturn('user4@example.com'); - - $subscriber5 = $this->createMock(Subscriber::class); - $subscriber5->method('getEmail')->willReturn('user5@example.com'); - - $subscriber6 = $this->createMock(Subscriber::class); - $subscriber6->method('getEmail')->willReturn('user6@example.com'); - - $subscriber7 = $this->createMock(Subscriber::class); - $subscriber7->method('getEmail')->willReturn('user1@test.com'); - - $subscriber8 = $this->createMock(Subscriber::class); - $subscriber8->method('getEmail')->willReturn('user2@test.com'); - - $subscriber9 = $this->createMock(Subscriber::class); - $subscriber9->method('getEmail')->willReturn('user3@another.com'); - $this->subscriberRepository->expects(self::once()) - ->method('findAll') + ->method('getTopDomains') + ->with(50, 1) ->willReturn([ - $subscriber1, $subscriber2, $subscriber3, $subscriber4, $subscriber5, - $subscriber6, $subscriber7, $subscriber8, $subscriber9 + ['domain' => 'example.com', 'subscribers' => 6], + ['domain' => 'test.com', 'subscribers' => 2], + ['domain' => 'another.com', 'subscribers' => 1], ]); $result = $this->subject->getTopDomains(50, 1); @@ -241,46 +215,12 @@ public function testGetTopDomains(): void public function testGetDomainConfirmationStatistics(): void { - $subscriber1 = $this->createMock(Subscriber::class); - $subscriber1->method('getEmail')->willReturn('user1@example.com'); - $subscriber1->method('isConfirmed')->willReturn(true); - $subscriber1->method('isBlacklisted')->willReturn(false); - - $subscriber2 = $this->createMock(Subscriber::class); - $subscriber2->method('getEmail')->willReturn('user2@example.com'); - $subscriber2->method('isConfirmed')->willReturn(true); - $subscriber2->method('isBlacklisted')->willReturn(false); - - $subscriber3 = $this->createMock(Subscriber::class); - $subscriber3->method('getEmail')->willReturn('user3@example.com'); - $subscriber3->method('isConfirmed')->willReturn(false); - $subscriber3->method('isBlacklisted')->willReturn(false); - - $subscriber4 = $this->createMock(Subscriber::class); - $subscriber4->method('getEmail')->willReturn('user4@example.com'); - $subscriber4->method('isConfirmed')->willReturn(false); - $subscriber4->method('isBlacklisted')->willReturn(false); - - $subscriber5 = $this->createMock(Subscriber::class); - $subscriber5->method('getEmail')->willReturn('user5@example.com'); - $subscriber5->method('isConfirmed')->willReturn(false); - $subscriber5->method('isBlacklisted')->willReturn(true); - - $subscriber6 = $this->createMock(Subscriber::class); - $subscriber6->method('getEmail')->willReturn('user1@test.com'); - $subscriber6->method('isConfirmed')->willReturn(true); - $subscriber6->method('isBlacklisted')->willReturn(false); - - $subscriber7 = $this->createMock(Subscriber::class); - $subscriber7->method('getEmail')->willReturn('user2@test.com'); - $subscriber7->method('isConfirmed')->willReturn(false); - $subscriber7->method('isBlacklisted')->willReturn(false); - $this->subscriberRepository->expects(self::once()) - ->method('findAll') + ->method('getDomainConfirmationStatistics') + ->with(50) ->willReturn([ - $subscriber1, $subscriber2, $subscriber3, $subscriber4, - $subscriber5, $subscriber6, $subscriber7 + ['domain' => 'example.com', 'total' => 5, 'confirmed' => 2, 'unconfirmed' => 2, 'blacklisted' => 1], + ['domain' => 'test.com', 'total' => 2, 'confirmed' => 1, 'unconfirmed' => 1, 'blacklisted' => 0], ]); $result = $this->subject->getDomainConfirmationStatistics(); @@ -313,27 +253,20 @@ public function testGetDomainConfirmationStatistics(): void public function testGetTopLocalParts(): void { - $subscriber1 = $this->createMock(Subscriber::class); - $subscriber1->method('getEmail')->willReturn('user1@example.com'); - - $subscriber2 = $this->createMock(Subscriber::class); - $subscriber2->method('getEmail')->willReturn('user2@example.com'); - - $subscriber3 = $this->createMock(Subscriber::class); - $subscriber3->method('getEmail')->willReturn('user1@test.com'); - - $subscriber4 = $this->createMock(Subscriber::class); - $subscriber4->method('getEmail')->willReturn('admin@example.com'); - - $subscriber5 = $this->createMock(Subscriber::class); - $subscriber5->method('getEmail')->willReturn('info@example.com'); - $this->subscriberRepository->expects(self::once()) - ->method('findAll') + ->method('getTopLocalParts') + ->with(25) ->willReturn([ - $subscriber1, $subscriber2, $subscriber3, $subscriber4, $subscriber5 + ['localPart' => 'user1', 'count' => 2], + ['localPart' => 'user2', 'count' => 1], + ['localPart' => 'admin', 'count' => 1], + ['localPart' => 'info', 'count' => 1], ]); + $this->subscriberRepository->expects(self::once()) + ->method('countWithValidEmail') + ->willReturn(5); + $result = $this->subject->getTopLocalParts(); self::assertArrayHasKey('localParts', $result); From 563831071588bcbd216c08994b3d5e4a63609e2a Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 25 Aug 2026 13:01:16 +0400 Subject: [PATCH 24/39] fix tests --- .../MessageHandler/DynamicTableMessageHandlerTest.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/Unit/Domain/Subscription/MessageHandler/DynamicTableMessageHandlerTest.php b/tests/Unit/Domain/Subscription/MessageHandler/DynamicTableMessageHandlerTest.php index 8139e492d..d923b7c3e 100644 --- a/tests/Unit/Domain/Subscription/MessageHandler/DynamicTableMessageHandlerTest.php +++ b/tests/Unit/Domain/Subscription/MessageHandler/DynamicTableMessageHandlerTest.php @@ -7,6 +7,8 @@ use Doctrine\DBAL\Exception\TableExistsException; use Doctrine\DBAL\Schema\AbstractSchemaManager; use Doctrine\DBAL\Schema\Table; +use Doctrine\DBAL\Types\IntegerType; +use Doctrine\DBAL\Types\StringType; use InvalidArgumentException; use PhpList\Core\Domain\Subscription\Message\DynamicTableMessage; use PhpList\Core\Domain\Subscription\MessageHandler\DynamicTableMessageHandler; @@ -49,19 +51,19 @@ public function testInvokeCreatesTableWhenNotExists(): void // id column $idCol = $table->getColumn('id'); - $this->assertSame('integer', $idCol->getType()->getName()); + $this->assertInstanceOf(IntegerType::class, $idCol->getType()); $this->assertTrue($idCol->getAutoincrement()); $this->assertTrue($idCol->getNotnull()); // name column $nameCol = $table->getColumn('name'); - $this->assertSame('string', $nameCol->getType()->getName()); + $this->assertInstanceOf(StringType::class, $nameCol->getType()); $this->assertSame(255, $nameCol->getLength()); $this->assertFalse($nameCol->getNotnull()); // listorder column $orderCol = $table->getColumn('listorder'); - $this->assertSame('integer', $orderCol->getType()->getName()); + $this->assertInstanceOf(IntegerType::class, $orderCol->getType()); $this->assertFalse($orderCol->getNotnull()); $this->assertSame(0, $orderCol->getDefault()); From 6752781255f68aa352ceec330a8567d3af9ad62b Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 25 Aug 2026 13:10:07 +0400 Subject: [PATCH 25/39] update phpstan --- composer.json | 5 +-- phpstan.neon | 4 +++ .../Service/SubscriberBlacklistService.php | 2 +- .../WebklexBounceProcessingService.php | 2 +- src/Domain/Analytics/Model/LinkTrack.php | 12 +++---- .../Analytics/Model/LinkTrackForward.php | 6 ++-- src/Domain/Analytics/Model/UserStats.php | 6 ++-- .../Analytics/Service/LinkTrackService.php | 2 +- src/Domain/Common/PdfGenerator.php | 4 +-- .../Common/Repository/AbstractRepository.php | 2 +- .../Common/Service/ExternalImageService.php | 2 +- .../Common/Validator/UploadValidator.php | 10 +++--- src/Domain/Configuration/Model/UrlCache.php | 7 +++- .../Provider/DefaultConfigProvider.php | 2 +- .../Model/AdminAttributeDefinition.php | 3 ++ .../Identity/Model/AdminPasswordRequest.php | 4 +-- src/Domain/Identity/Model/Administrator.php | 7 ++-- .../Identity/Model/AdministratorToken.php | 4 +-- .../CampaignProcessorMessageHandler.php | 4 +-- .../TestCampaignProcessorMessageHandler.php | 4 +-- .../Model/Dto/MessagePrecacheDto.php | 2 +- src/Domain/Messaging/Model/ListMessage.php | 4 +-- src/Domain/Messaging/Model/SendProcess.php | 9 +++-- src/Domain/Messaging/Model/Template.php | 3 ++ .../Service/Builder/MessageOptionsBuilder.php | 2 +- .../Service/ForwardContentService.php | 4 ++- .../Messaging/Service/MailSizeChecker.php | 2 +- .../Service/Manager/BounceRuleManager.php | 2 +- .../Service/Manager/SendProcessManager.php | 2 +- .../Service/Manager/TemplateManager.php | 4 +-- .../Service/MessageForwardService.php | 6 +++- src/Domain/Subscription/Model/Subscriber.php | 26 ++++++-------- .../Subscription/Model/SubscriberList.php | 34 +++++++++---------- .../Subscription/Model/Subscription.php | 17 +++++----- .../Repository/DynamicListAttrRepository.php | 6 ++-- .../Repository/SubscriberPageRepository.php | 9 ++--- .../Repository/SubscriberRepository.php | 2 +- .../Manager/DynamicListAttrManager.php | 4 --- .../Service/SubscriberCsvExporter.php | 2 +- src/Security/Authentication.php | 4 --- .../Traits/DatabaseTestTrait.php | 4 +-- src/TestingSupport/Traits/ModelTestTrait.php | 2 +- .../AdministratorRepositoryTest.php | 6 ++-- .../AdministratorTokenRepositoryTest.php | 2 +- .../Messaging/Fixtures/MessageFixture.php | 3 +- .../Repository/SubscriberRepositoryTest.php | 16 +++------ .../Service/SubscriberDeletionServiceTest.php | 1 - tests/Unit/Core/EnvironmentTest.php | 4 +-- tests/Unit/Domain/Common/PdfGeneratorTest.php | 1 - .../Service/Manager/EventLogManagerTest.php | 3 +- .../MessagePlaceholderProcessorTest.php | 2 +- .../Identity/Model/AdministratorTest.php | 5 --- .../Identity/Model/AdministratorTokenTest.php | 5 --- .../Service/AdminCopyEmailSenderTest.php | 4 +-- .../Service/AdministratorManagerTest.php | 2 -- .../Identity/Service/PasswordManagerTest.php | 1 - .../Messaging/Model/SubscriberListTest.php | 4 +-- .../Service/Builder/EmailBuilderTest.php | 2 +- .../Builder/ForwardEmailBuilderTest.php | 2 +- .../Builder/SystemEmailBuilderTest.php | 2 +- .../Service/ForwardContentServiceTest.php | 23 ++++++------- .../Service/ForwardDeliveryServiceTest.php | 16 ++++----- .../Service/ForwardingStatsServiceTest.php | 19 +++++------ .../Manager/TemplateImageManagerTest.php | 1 - .../Service/Manager/TemplateManagerTest.php | 1 - .../Manager/UserMessageForwardManagerTest.php | 3 +- .../Mapper/DefaultTemplateMapperTest.php | 2 -- .../Messaging/Service/SendRateLimiterTest.php | 4 +-- .../Validator/TemplateImageValidatorTest.php | 2 -- .../Validator/TemplateLinkValidatorTest.php | 9 ++--- .../DynamicTableMessageHandlerTest.php | 4 --- .../Subscription/Model/SubscriberTest.php | 5 --- .../Subscription/Model/SubscriptionTest.php | 15 -------- .../AttributeDefinitionManagerTest.php | 2 -- .../Manager/DynamicListAttrManagerTest.php | 2 -- .../DynamicListAttrTablesManagerTest.php | 1 - .../Manager/SubscribePageManagerTest.php | 2 +- .../SubscriberAttributeManagerTest.php | 2 -- .../Manager/SubscriberHistoryManagerTest.php | 1 - .../Manager/SubscriberListManagerTest.php | 1 - .../Manager/SubscriptionManagerTest.php | 1 - .../Provider/SubscriberProviderTest.php | 4 --- .../Service/SubscriberCsvExporterTest.php | 8 ++--- .../Validator/AttributeTypeValidatorTest.php | 5 +-- 84 files changed, 183 insertions(+), 257 deletions(-) diff --git a/composer.json b/composer.json index b361c66bc..a6fd81259 100644 --- a/composer.json +++ b/composer.json @@ -93,7 +93,7 @@ "require-dev": { "phpunit/phpunit": "^9.5", "squizlabs/php_codesniffer": "^3.2.0", - "phpstan/phpstan": "^1.10", + "phpstan/phpstan": "^2.2", "nette/caching": "^3.0.0", "nikic/php-parser": "^4.19.1", "phpmd/phpmd": "^2.6.0", @@ -104,7 +104,8 @@ "symfony/http-foundation": "^6.4", "symfony/routing": "^6.4", "symfony/console": "^6.4", - "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "phpstan/phpstan-doctrine": "^2.0" }, "suggest": { "phplist/web-frontend": "dev-main", diff --git a/phpstan.neon b/phpstan.neon index 3a51f9ec0..70705d26b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,3 +1,7 @@ +includes: + - vendor/phpstan/phpstan-doctrine/extension.neon + - vendor/phpstan/phpstan-doctrine/rules.neon + parameters: level: 5 paths: diff --git a/src/Bounce/Service/SubscriberBlacklistService.php b/src/Bounce/Service/SubscriberBlacklistService.php index 38d37e7d0..03155587c 100644 --- a/src/Bounce/Service/SubscriberBlacklistService.php +++ b/src/Bounce/Service/SubscriberBlacklistService.php @@ -34,7 +34,7 @@ public function __construct( } /** - * @SuppressWarnings(PHPMD.Superglobals) + * @SuppressWarnings("PHPMD.Superglobals") */ public function blacklist(Subscriber $subscriber, string $reason): void { diff --git a/src/Bounce/Service/WebklexBounceProcessingService.php b/src/Bounce/Service/WebklexBounceProcessingService.php index 4ca204619..c09f30fdd 100644 --- a/src/Bounce/Service/WebklexBounceProcessingService.php +++ b/src/Bounce/Service/WebklexBounceProcessingService.php @@ -15,7 +15,7 @@ use Webklex\PHPIMAP\Folder; /** - * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) + * @SuppressWarnings("PHPMD.ExcessiveClassComplexity") */ class WebklexBounceProcessingService implements BounceProcessingServiceInterface { diff --git a/src/Domain/Analytics/Model/LinkTrack.php b/src/Domain/Analytics/Model/LinkTrack.php index b0d8c7bf4..ef8d147b4 100644 --- a/src/Domain/Analytics/Model/LinkTrack.php +++ b/src/Domain/Analytics/Model/LinkTrack.php @@ -42,10 +42,10 @@ class LinkTrack implements DomainModel, Identity private ?DateTimeInterface $firstClick = null; #[ORM\Column(name: 'latestclick', type: 'datetime')] - private ?DateTimeInterface $latestClick = null; + private DateTimeInterface $latestClick; #[ORM\Column(type: 'integer', nullable: true, options: ['default' => 0])] - private int $clicked = 0; + private ?int $clicked = 0; public function __construct() { @@ -112,23 +112,23 @@ public function setFirstClick(?DateTimeInterface $firstClick): self return $this; } - public function getLatestClick(): ?DateTimeInterface + public function getLatestClick(): DateTimeInterface { return $this->latestClick; } - public function setLatestClick(?DateTimeInterface $latestClick): self + public function setLatestClick(DateTimeInterface $latestClick): self { $this->latestClick = $latestClick; return $this; } - public function getClicked(): int + public function getClicked(): ?int { return $this->clicked; } - public function setClicked(int $clicked): self + public function setClicked(?int $clicked): self { $this->clicked = $clicked; return $this; diff --git a/src/Domain/Analytics/Model/LinkTrackForward.php b/src/Domain/Analytics/Model/LinkTrackForward.php index 2bc059b0b..c2cf7a275 100644 --- a/src/Domain/Analytics/Model/LinkTrackForward.php +++ b/src/Domain/Analytics/Model/LinkTrackForward.php @@ -33,7 +33,7 @@ class LinkTrackForward implements DomainModel, Identity private ?string $uuid = ''; #[ORM\Column(type: 'boolean', nullable: true, options: ['default' => 0])] - private bool $personalise = false; + private ?bool $personalise = false; public function getId(): ?int { @@ -73,12 +73,12 @@ public function setUuid(?string $uuid): self return $this; } - public function isPersonalise(): bool + public function isPersonalise(): ?bool { return $this->personalise; } - public function setPersonalise(bool $personalise): self + public function setPersonalise(?bool $personalise): self { $this->personalise = $personalise; return $this; diff --git a/src/Domain/Analytics/Model/UserStats.php b/src/Domain/Analytics/Model/UserStats.php index 57e671f71..48789b3b8 100644 --- a/src/Domain/Analytics/Model/UserStats.php +++ b/src/Domain/Analytics/Model/UserStats.php @@ -30,7 +30,7 @@ class UserStats implements DomainModel, Identity private ?string $item = null; #[ORM\Column(name: 'listid', type: 'integer', nullable: true, options: ['default' => 0])] - private int $listId = 0; + private ?int $listId = 0; #[ORM\Column(name: 'value', type: 'integer', nullable: true, options: ['default' => 0])] private ?int $value = null; @@ -50,7 +50,7 @@ public function getItem(): ?string return $this->item; } - public function getListId(): int + public function getListId(): ?int { return $this->listId; } @@ -72,7 +72,7 @@ public function setItem(?string $item): self return $this; } - public function setListId(int $listId): self + public function setListId(?int $listId): self { $this->listId = $listId; return $this; diff --git a/src/Domain/Analytics/Service/LinkTrackService.php b/src/Domain/Analytics/Service/LinkTrackService.php index d6d60f957..dc478e84b 100644 --- a/src/Domain/Analytics/Service/LinkTrackService.php +++ b/src/Domain/Analytics/Service/LinkTrackService.php @@ -48,7 +48,7 @@ public function extractAndSaveLinks(MessagePrecacheDto $content, int $userId, ?i throw new MissingMessageIdException(); } - $links = $this->extractLinksFromHtml($content->content ?? ''); + $links = $this->extractLinksFromHtml($content->content); if ($content->htmlFooter) { $links = array_merge($links, $this->extractLinksFromHtml($content->htmlFooter)); diff --git a/src/Domain/Common/PdfGenerator.php b/src/Domain/Common/PdfGenerator.php index 1f25840fe..7b852aede 100644 --- a/src/Domain/Common/PdfGenerator.php +++ b/src/Domain/Common/PdfGenerator.php @@ -12,9 +12,7 @@ public function createPdfBytes(string $text): string { $pdf = new FPDF(); // Disable compression to ensure plain text and metadata are visible in output (helps testing) - if (method_exists($pdf, 'SetCompression')) { - $pdf->SetCompression(false); - } + $pdf->SetCompression(false); $pdf->SetCreator('phpList'); $pdf->AddPage(); $pdf->SetFont('Arial', '', 12); diff --git a/src/Domain/Common/Repository/AbstractRepository.php b/src/Domain/Common/Repository/AbstractRepository.php index bfefd054d..aa77520b9 100644 --- a/src/Domain/Common/Repository/AbstractRepository.php +++ b/src/Domain/Common/Repository/AbstractRepository.php @@ -11,7 +11,7 @@ * Base class for repositories. * * @author Oliver Klee - * @SuppressWarnings(PHPMD.NumberOfChildren) + * @SuppressWarnings("PHPMD.NumberOfChildren") */ abstract class AbstractRepository extends EntityRepository { diff --git a/src/Domain/Common/Service/ExternalImageService.php b/src/Domain/Common/Service/ExternalImageService.php index 08452a08d..63d9f35cf 100644 --- a/src/Domain/Common/Service/ExternalImageService.php +++ b/src/Domain/Common/Service/ExternalImageService.php @@ -121,7 +121,7 @@ private function downloadUsingCurl(string $filename): ?string if ($cURLHandle !== false) { curl_setopt($cURLHandle, CURLOPT_HTTPGET, true); - curl_setopt($cURLHandle, CURLOPT_HEADER, 0); + curl_setopt($cURLHandle, CURLOPT_HEADER, false); curl_setopt($cURLHandle, CURLOPT_RETURNTRANSFER, true); curl_setopt($cURLHandle, CURLOPT_TIMEOUT, $this->externalImageTimeout); curl_setopt($cURLHandle, CURLOPT_FOLLOWLOCATION, true); diff --git a/src/Domain/Common/Validator/UploadValidator.php b/src/Domain/Common/Validator/UploadValidator.php index d2602211e..d5d810d31 100644 --- a/src/Domain/Common/Validator/UploadValidator.php +++ b/src/Domain/Common/Validator/UploadValidator.php @@ -107,11 +107,11 @@ private function parseSizeLimit(string $value): int $size = (int) $matches[1]; $unit = strtolower((string) ($matches[2] ?? '')); - return match ($unit) { - '', 'b' => $size, - 'k', 'kb' => $size * 1024, - 'm', 'mb' => $size * 1024 * 1024, - 'g', 'gb' => $size * 1024 * 1024 * 1024, + return match (true) { + $unit === '' || $unit === 'b' => $size, + str_starts_with($unit, 'k') => $size * 1024, + str_starts_with($unit, 'm') => $size * 1024 * 1024, + str_starts_with($unit, 'g') => $size * 1024 * 1024 * 1024, default => throw new InvalidUploadException(sprintf('Invalid upload size limit "%s".', $value)), }; } diff --git a/src/Domain/Configuration/Model/UrlCache.php b/src/Domain/Configuration/Model/UrlCache.php index a83942122..adcea26a6 100644 --- a/src/Domain/Configuration/Model/UrlCache.php +++ b/src/Domain/Configuration/Model/UrlCache.php @@ -33,7 +33,7 @@ class UrlCache implements DomainModel, Identity private ?DateTime $added = null; #[ORM\Column(name: 'content', type: 'blob', nullable: true)] - private ?string $content = null; + private mixed $content = null; public function getId(): ?int { @@ -57,6 +57,11 @@ public function getAdded(): ?DateTime public function getContent(): ?string { + if (is_resource($this->content)) { + $value = stream_get_contents($this->content); + return $value === false ? null : $value; + } + return $this->content; } diff --git a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php index 51b95ea11..3222acc98 100644 --- a/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php +++ b/src/Domain/Configuration/Service/Provider/DefaultConfigProvider.php @@ -20,7 +20,7 @@ public function __construct(private TranslatorInterface $translator) { } - /** @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ + /** @SuppressWarnings("PHPMD.ExcessiveMethodLength") */ private function init(): void { if (!empty($this->defaults)) { diff --git a/src/Domain/Identity/Model/AdminAttributeDefinition.php b/src/Domain/Identity/Model/AdminAttributeDefinition.php index c2b20d0b1..d1b62e58f 100644 --- a/src/Domain/Identity/Model/AdminAttributeDefinition.php +++ b/src/Domain/Identity/Model/AdminAttributeDefinition.php @@ -39,6 +39,9 @@ class AdminAttributeDefinition implements DomainModel, Identity #[ORM\Column(name:'tablename', type: 'string', length: 255, nullable: true)] private ?string $tableName; + /** + * @var Collection + */ #[ORM\OneToMany( targetEntity: AdminAttributeValue::class, mappedBy: 'attributeDefinition', diff --git a/src/Domain/Identity/Model/AdminPasswordRequest.php b/src/Domain/Identity/Model/AdminPasswordRequest.php index 230e675ad..00b46606b 100644 --- a/src/Domain/Identity/Model/AdminPasswordRequest.php +++ b/src/Domain/Identity/Model/AdminPasswordRequest.php @@ -24,7 +24,7 @@ class AdminPasswordRequest implements DomainModel, Identity #[ORM\ManyToOne(targetEntity: Administrator::class)] #[ORM\JoinColumn(name: 'admin', referencedColumnName: 'id', nullable: true)] - private Administrator $administrator; + private ?Administrator $administrator; #[ORM\Column(name: 'key_value', type: 'string', length: 32)] private string $keyValue; @@ -46,7 +46,7 @@ public function getDate(): DateTime return $this->date; } - public function getAdmin(): Administrator + public function getAdmin(): ?Administrator { return $this->administrator; } diff --git a/src/Domain/Identity/Model/Administrator.php b/src/Domain/Identity/Model/Administrator.php index f6c9ba050..d640f2457 100644 --- a/src/Domain/Identity/Model/Administrator.php +++ b/src/Domain/Identity/Model/Administrator.php @@ -36,7 +36,7 @@ class Administrator implements DomainModel, Identity, CreationDate, Modification private ?int $id = null; #[ORM\Column(name: 'created', type: 'datetime', nullable: false)] - protected ?DateTime $createdAt = null; + protected DateTime $createdAt; #[ORM\Column(name: 'modified', type: 'datetime', nullable: false)] private DateTime $updatedAt; @@ -68,6 +68,9 @@ class Administrator implements DomainModel, Identity, CreationDate, Modification #[ORM\Column(name: 'privileges', type: 'text', nullable: true)] private ?string $privileges = null; + /** + * @var Collection + */ #[ORM\OneToMany(targetEntity: SubscriberList::class, mappedBy: 'owner')] private Collection $ownedLists; @@ -84,7 +87,7 @@ public function getId(): ?int return $this->id; } - public function getCreatedAt(): ?DateTime + public function getCreatedAt(): DateTime { return $this->createdAt; } diff --git a/src/Domain/Identity/Model/AdministratorToken.php b/src/Domain/Identity/Model/AdministratorToken.php index 3d9da22dd..eebef8276 100644 --- a/src/Domain/Identity/Model/AdministratorToken.php +++ b/src/Domain/Identity/Model/AdministratorToken.php @@ -35,14 +35,14 @@ class AdministratorToken implements DomainModel, Identity, CreationDate #[ORM\Column(name: 'expires', type: 'datetime')] #[SerializedName('expiry_date')] - private ?DateTime $expiry = null; + private DateTime $expiry; #[ORM\Column(name: 'value')] #[SerializedName('key')] private string $key = ''; #[ORM\ManyToOne(targetEntity: Administrator::class)] - #[ORM\JoinColumn(name: 'adminid', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\JoinColumn(name: 'adminid', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] private Administrator $administrator; public function __construct(Administrator $administrator) diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php index e1aa9219b..ad8d0f482 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php @@ -46,8 +46,8 @@ use Throwable; /** - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - * @SuppressWarnings(PHPMD.ExcessiveParameterList) + * @SuppressWarnings("PHPMD.CouplingBetweenObjects") + * @SuppressWarnings("PHPMD.ExcessiveParameterList") */ #[AsMessageHandler] class CampaignProcessorMessageHandler diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessor/TestCampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessor/TestCampaignProcessorMessageHandler.php index 8ae47ea71..95b0a1075 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessor/TestCampaignProcessorMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessor/TestCampaignProcessorMessageHandler.php @@ -32,8 +32,8 @@ use Throwable; /** - * @SuppressWarnings(PHPMD.CouplingBetweenObjects) - * @SuppressWarnings(PHPMD.ExcessiveParameterList) + * @SuppressWarnings("PHPMD.CouplingBetweenObjects") + * @SuppressWarnings("PHPMD.ExcessiveParameterList") */ #[AsMessageHandler] class TestCampaignProcessorMessageHandler diff --git a/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php b/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php index 3b0f0d243..57cbc32be 100644 --- a/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php +++ b/src/Domain/Messaging/Model/Dto/MessagePrecacheDto.php @@ -4,7 +4,7 @@ namespace PhpList\Core\Domain\Messaging\Model\Dto; -/** @SuppressWarnings(TooManyFields) */ +/** @SuppressWarnings("TooManyFields") */ class MessagePrecacheDto { public string $replyToEmail = ''; diff --git a/src/Domain/Messaging/Model/ListMessage.php b/src/Domain/Messaging/Model/ListMessage.php index d624b6997..e20b3d9dd 100644 --- a/src/Domain/Messaging/Model/ListMessage.php +++ b/src/Domain/Messaging/Model/ListMessage.php @@ -37,7 +37,7 @@ class ListMessage implements DomainModel, Identity, ModificationDate private ?DateTimeInterface $entered = null; #[ORM\Column(name: 'modified', type: 'datetime')] - private ?DateTime $updatedAt = null; + private DateTime $updatedAt; public function __construct(Message $message, SubscriberList $subscriberList) { @@ -67,7 +67,7 @@ public function getEntered(): ?DateTimeInterface return $this->entered; } - public function getUpdatedAt(): ?DateTime + public function getUpdatedAt(): DateTime { return $this->updatedAt; } diff --git a/src/Domain/Messaging/Model/SendProcess.php b/src/Domain/Messaging/Model/SendProcess.php index 14abe737a..2c68ff9fe 100644 --- a/src/Domain/Messaging/Model/SendProcess.php +++ b/src/Domain/Messaging/Model/SendProcess.php @@ -22,7 +22,7 @@ class SendProcess implements DomainModel, Identity, ModificationDate private ?int $id = null; #[ORM\Column(name: 'modified', type: 'datetime')] - private ?DateTime $updatedAt = null; + private DateTime $updatedAt; #[ORM\Column(name: 'started', type: 'datetime', nullable: true)] private ?DateTime $started = null; @@ -36,12 +36,17 @@ class SendProcess implements DomainModel, Identity, ModificationDate #[ORM\Column(name: 'page', type: 'string', length: 100, nullable: true)] private ?string $page = null; + public function __construct() + { + $this->updatedAt = new DateTime(); + } + public function getId(): ?int { return $this->id; } - public function getUpdatedAt(): ?DateTime + public function getUpdatedAt(): DateTime { return $this->updatedAt; } diff --git a/src/Domain/Messaging/Model/Template.php b/src/Domain/Messaging/Model/Template.php index 3bbd8c8c8..b54bc1257 100644 --- a/src/Domain/Messaging/Model/Template.php +++ b/src/Domain/Messaging/Model/Template.php @@ -33,6 +33,9 @@ class Template implements DomainModel, Identity #[ORM\Column(name: 'listorder', type: 'integer', nullable: true)] private ?int $listOrder = null; + /** + * @var Collection + */ #[ORM\OneToMany( targetEntity: TemplateImage::class, mappedBy: 'template', diff --git a/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php b/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php index 91689d1eb..9a6ea3669 100644 --- a/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php +++ b/src/Domain/Messaging/Service/Builder/MessageOptionsBuilder.php @@ -17,7 +17,7 @@ public function build(object $dto): MessageOptions } return new MessageOptions( - fromField: $dto->fromField ?? '', + fromField: $dto->fromField, toField: $dto->toField ?? '', replyTo: $dto->replyTo ?? '', userSelection: $dto->userSelection, diff --git a/src/Domain/Messaging/Service/ForwardContentService.php b/src/Domain/Messaging/Service/ForwardContentService.php index cb1e505be..a260bead3 100644 --- a/src/Domain/Messaging/Service/ForwardContentService.php +++ b/src/Domain/Messaging/Service/ForwardContentService.php @@ -5,6 +5,8 @@ namespace PhpList\Core\Domain\Messaging\Service; use PhpList\Core\Domain\Configuration\Model\OutputFormat; +use PhpList\Core\Domain\Messaging\Exception\EmailBlacklistedException; +use PhpList\Core\Domain\Messaging\Exception\InvalidRecipientOrSubjectException; use PhpList\Core\Domain\Messaging\Exception\MessageCacheMissingException; use PhpList\Core\Domain\Messaging\Model\Dto\MessageForwardDto; use PhpList\Core\Domain\Messaging\Model\Message; @@ -23,7 +25,7 @@ public function __construct( } /** @return array{Email, OutputFormat} - * @throws MessageCacheMissingException + * @throws MessageCacheMissingException | InvalidRecipientOrSubjectException | EmailBlacklistedException */ public function getContents( Message $campaign, diff --git a/src/Domain/Messaging/Service/MailSizeChecker.php b/src/Domain/Messaging/Service/MailSizeChecker.php index 9d8c9a92b..e5f0de6ec 100644 --- a/src/Domain/Messaging/Service/MailSizeChecker.php +++ b/src/Domain/Messaging/Service/MailSizeChecker.php @@ -14,7 +14,7 @@ class MailSizeChecker { - private ?int $maxMailSize; + private int $maxMailSize; public function __construct( private readonly EventLogManager $eventLogManager, diff --git a/src/Domain/Messaging/Service/Manager/BounceRuleManager.php b/src/Domain/Messaging/Service/Manager/BounceRuleManager.php index 67d97d181..6dcd10aa0 100644 --- a/src/Domain/Messaging/Service/Manager/BounceRuleManager.php +++ b/src/Domain/Messaging/Service/Manager/BounceRuleManager.php @@ -99,7 +99,7 @@ public function incrementCount(BounceRegex $rule): void $this->repository->save($rule); } - public function linkRuleToBounce(BounceRegex $rule, Bounce $bounce): BounceregexBounce + public function linkRuleToBounce(BounceRegex $rule, Bounce $bounce): BounceRegexBounce { $relation = new BounceRegexBounce($rule->getId(), $bounce->getId()); $this->bounceRelationRepository->save($relation); diff --git a/src/Domain/Messaging/Service/Manager/SendProcessManager.php b/src/Domain/Messaging/Service/Manager/SendProcessManager.php index 6cfacce46..082fe9a46 100644 --- a/src/Domain/Messaging/Service/Manager/SendProcessManager.php +++ b/src/Domain/Messaging/Service/Manager/SendProcessManager.php @@ -45,7 +45,7 @@ public function findNewestAliveWithAge(string $page): ?array } $modified = $row->getUpdatedAt(); - $age = $modified ? max(0, time() - (int)$modified->format('U')) : 0; + $age = max(0, time() - (int)$modified->format('U')); return [ 'id' => $row->getId(), diff --git a/src/Domain/Messaging/Service/Manager/TemplateManager.php b/src/Domain/Messaging/Service/Manager/TemplateManager.php index cca54eec3..06f21ef95 100644 --- a/src/Domain/Messaging/Service/Manager/TemplateManager.php +++ b/src/Domain/Messaging/Service/Manager/TemplateManager.php @@ -31,9 +31,7 @@ public function create(CreateTemplateDto $createTemplateDto): Template ->setListOrder($createTemplateDto->listOrder); $content = $createTemplateDto->fileContent ?? $createTemplateDto->content; - if ($content !== null) { - $template->setContent($content); - } + $template->setContent($content); $context = (new ValidationContext()) ->set('checkLinks', $createTemplateDto->shouldCheckLinks) diff --git a/src/Domain/Messaging/Service/MessageForwardService.php b/src/Domain/Messaging/Service/MessageForwardService.php index 7e086a0f8..b93f5d763 100644 --- a/src/Domain/Messaging/Service/MessageForwardService.php +++ b/src/Domain/Messaging/Service/MessageForwardService.php @@ -74,7 +74,11 @@ public function forward(MessageForwardDto $messageForwardDto, Message $campaign) friendEmail: $friendEmail, forwardDto: $messageForwardDto, ); - } catch (EmailBlacklistedException | MessageCacheMissingException | InvalidRecipientOrSubjectException $e) { + } catch (MessageCacheMissingException + | EmailBlacklistedException + | InvalidRecipientOrSubjectException $e + ) { + // todo: check if need to catch MessageCacheMissingException $forwardingRecipientResult = $this->handleFailure( campaign: $campaign, forwardingSubscriber: $forwardingSubscriber, diff --git a/src/Domain/Subscription/Model/Subscriber.php b/src/Domain/Subscription/Model/Subscriber.php index 8eda5ed6a..532995408 100644 --- a/src/Domain/Subscription/Model/Subscriber.php +++ b/src/Domain/Subscription/Model/Subscriber.php @@ -19,9 +19,9 @@ * campaigns for those subscriber lists. * @author Oliver Klee * @author Tatevik Grigoryan - * @SuppressWarnings(TooManyFields) - * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) - * @SuppressWarnings(PHPMD.ExcessivePublicCount) + * @SuppressWarnings("TooManyFields") + * @SuppressWarnings("PHPMD.ExcessiveClassComplexity") + * @SuppressWarnings("PHPMD.ExcessivePublicCount") */ #[ORM\Entity(repositoryClass: SubscriberRepository::class)] #[ORM\Table(name: 'user_user')] @@ -45,7 +45,7 @@ class Subscriber implements DomainModel, Identity, CreationDate, ModificationDat protected ?DateTime $createdAt = null; #[ORM\Column(name: 'modified', type: 'datetime', nullable: false)] - private ?DateTime $updatedAt = null; + private DateTime $updatedAt; #[ORM\Column(unique: true)] private string $email = ''; @@ -59,7 +59,7 @@ class Subscriber implements DomainModel, Identity, CreationDate, ModificationDat #[ORM\Column(name: 'bouncecount', type: 'integer')] private int $bounceCount = 0; - #[ORM\Column(name: 'uniqid', type: 'string', length: 255, nullable: true)] + #[ORM\Column(name: 'uniqid', type: 'string', length: 255)] private string $uniqueId = ''; #[ORM\Column(name: 'htmlemail', type: 'boolean')] @@ -71,6 +71,9 @@ class Subscriber implements DomainModel, Identity, CreationDate, ModificationDat #[ORM\Column(name: 'extradata', type: 'text', nullable: true)] private ?string $extraData = null; + /** + * @var Collection + */ #[ORM\OneToMany( targetEntity: Subscription::class, mappedBy: 'subscriber', @@ -134,7 +137,7 @@ public function getCreatedAt(): ?DateTime return $this->createdAt; } - public function getUpdatedAt(): ?DateTime + public function getUpdatedAt(): DateTime { return $this->updatedAt; } @@ -257,7 +260,7 @@ public function setExtraData(?string $extraData): self } /** - * @return Collection + * @return Collection */ public function getSubscriptions(): Collection { @@ -274,15 +277,6 @@ public function addSubscription(Subscription $subscription): self return $this; } - public function removeSubscription(Subscription $subscription): self - { - if ($this->subscriptions->removeElement($subscription)) { - $subscription->setSubscriber(null); - } - - return $this; - } - public function getSubscribedLists(): Collection { $result = new ArrayCollection(); diff --git a/src/Domain/Subscription/Model/SubscriberList.php b/src/Domain/Subscription/Model/SubscriberList.php index d1d2a0713..96b396c49 100644 --- a/src/Domain/Subscription/Model/SubscriberList.php +++ b/src/Domain/Subscription/Model/SubscriberList.php @@ -43,13 +43,13 @@ class SubscriberList implements DomainModel, Identity, CreationDate, Modificatio private ?string $rssFeed = null; #[ORM\Column] - private ?string $description = ''; + private string $description = ''; #[ORM\Column(name: 'entered', type: 'datetime', nullable: true)] protected ?DateTime $createdAt = null; #[ORM\Column(name: 'modified', type: 'datetime')] - private ?DateTime $updatedAt = null; + private DateTime $updatedAt; #[ORM\Column(name: 'listorder', type: 'integer', nullable: true)] private ?int $listPosition; @@ -61,12 +61,15 @@ class SubscriberList implements DomainModel, Identity, CreationDate, Modificatio private bool $public; #[ORM\Column] - private ?string $category = ''; + private string $category = ''; #[ORM\ManyToOne(targetEntity: Administrator::class, inversedBy: 'ownedLists')] #[ORM\JoinColumn(name: 'owner')] private ?Administrator $owner = null; + /** + * @var Collection + */ #[ORM\OneToMany( targetEntity: Subscription::class, mappedBy: 'subscriberList', @@ -76,6 +79,9 @@ class SubscriberList implements DomainModel, Identity, CreationDate, Modificatio #[MaxDepth(1)] private Collection $subscriptions; + /** + * @var Collection + */ #[ORM\OneToMany(targetEntity: ListMessage::class, mappedBy: 'subscriberList')] private Collection $listMessages; @@ -84,6 +90,7 @@ public function __construct() $this->subscriptions = new ArrayCollection(); $this->listMessages = new ArrayCollection(); $this->createdAt = new DateTime(); + $this->updatedAt = new DateTime(); $this->listPosition = 0; $this->subjectPrefix = ''; $this->category = ''; @@ -117,14 +124,14 @@ 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 { - $this->description = $description; + $this->description = $description ?? ''; return $this; } @@ -154,7 +161,7 @@ public function setSubjectPrefix(?string $subjectPrefix): self public function isPublic(): bool { - return $this->public ?? false; + return $this->public; } public function setPublic(bool $public): self @@ -163,14 +170,14 @@ public function setPublic(bool $public): self return $this; } - public function getCategory(): ?string + public function getCategory(): string { return $this->category; } public function setCategory(?string $category): self { - $this->category = $category; + $this->category = $category ?? ''; return $this; } @@ -200,15 +207,6 @@ public function addSubscription(Subscription $subscription): self return $this; } - public function removeSubscription(Subscription $subscription): self - { - if ($this->subscriptions->removeElement($subscription)) { - $subscription->setSubscriberList(null); - } - - return $this; - } - public function getSubscribers(): Collection { $result = new ArrayCollection(); @@ -224,7 +222,7 @@ public function getCreatedAt(): ?DateTime return $this->createdAt; } - public function getUpdatedAt(): ?DateTime + public function getUpdatedAt(): DateTime { return $this->updatedAt; } diff --git a/src/Domain/Subscription/Model/Subscription.php b/src/Domain/Subscription/Model/Subscription.php index 98df47030..3721d571d 100644 --- a/src/Domain/Subscription/Model/Subscription.php +++ b/src/Domain/Subscription/Model/Subscription.php @@ -35,7 +35,7 @@ class Subscription implements DomainModel, CreationDate, ModificationDate protected ?DateTime $createdAt = null; #[ORM\Column(name: 'modified', type: 'datetime')] - private ?DateTime $updatedAt = null; + private DateTime $updatedAt; #[ORM\Id] #[ORM\ManyToOne( @@ -44,7 +44,7 @@ class Subscription implements DomainModel, CreationDate, ModificationDate )] #[ORM\JoinColumn(name: 'userid')] #[SerializedName('subscriber')] - private ?Subscriber $subscriber = null; + private Subscriber $subscriber; #[ORM\Id] #[ORM\ManyToOne( @@ -54,30 +54,31 @@ class Subscription implements DomainModel, CreationDate, ModificationDate #[ORM\JoinColumn(name: 'listid', onDelete: 'CASCADE')] #[Ignore] #[Groups(['SubscriberListMembers'])] - private ?SubscriberList $subscriberList = null; + private SubscriberList $subscriberList; public function __construct() { $this->createdAt = new DateTime(); + $this->updatedAt = new DateTime(); } - public function getSubscriber(): Subscriber|Proxy|null + public function getSubscriber(): Subscriber|Proxy { return $this->subscriber; } - public function setSubscriber(?Subscriber $subscriber): self + public function setSubscriber(Subscriber $subscriber): self { $this->subscriber = $subscriber; return $this; } - public function getSubscriberList(): ?SubscriberList + public function getSubscriberList(): SubscriberList|Proxy { return $this->subscriberList; } - public function setSubscriberList(?SubscriberList $subscriberList): self + public function setSubscriberList(SubscriberList $subscriberList): self { $this->subscriberList = $subscriberList; return $this; @@ -88,7 +89,7 @@ public function getCreatedAt(): ?DateTime return $this->createdAt; } - public function getUpdatedAt(): ?DateTime + public function getUpdatedAt(): DateTime { return $this->updatedAt; } diff --git a/src/Domain/Subscription/Repository/DynamicListAttrRepository.php b/src/Domain/Subscription/Repository/DynamicListAttrRepository.php index 25dbcf34b..219345b40 100644 --- a/src/Domain/Subscription/Repository/DynamicListAttrRepository.php +++ b/src/Domain/Subscription/Repository/DynamicListAttrRepository.php @@ -6,8 +6,8 @@ use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\ParameterType; use InvalidArgumentException; -use PDO; use PhpList\Core\Domain\Subscription\Model\Dto\DynamicListAttrDto; use Symfony\Component\Serializer\Exception\ExceptionInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; @@ -161,12 +161,12 @@ public function isNameTakenByOtherRecord(string $listTable, DynamicListAttrDto $ $sql = 'SELECT 1 FROM ' . $table . ' WHERE LOWER(name) = LOWER(:name)'; $params = ['name' => $dto->name]; - $types = ['name' => PDO::PARAM_STR]; + $types = ['name' => ParameterType::STRING]; if ($dto->id !== null) { $sql .= ' AND id <> :excludeId'; $params['excludeId'] = $dto->id; - $types['excludeId'] = PDO::PARAM_INT; + $types['excludeId'] = ParameterType::INTEGER; } $sql .= ' LIMIT 1'; diff --git a/src/Domain/Subscription/Repository/SubscriberPageRepository.php b/src/Domain/Subscription/Repository/SubscriberPageRepository.php index c9f394490..7556b85e6 100644 --- a/src/Domain/Subscription/Repository/SubscriberPageRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberPageRepository.php @@ -65,12 +65,9 @@ public function getFilteredAfterId(FilterRequestInterface $filter): PaginatedRes $grouped = []; foreach ($rows as $row) { - /** @var SubscribePage $page */ - $page = $row['page'] ?? null; + $page = $row['page']; $data = $row['data'] ?? null; - if ($page !== null) { - $grouped[$page->getId()][] = $row; - } + $grouped[$page->getId()][] = $row; if ($data !== null) { $grouped[$data->getId()][] = ['data' => $data]; } @@ -82,7 +79,7 @@ public function getFilteredAfterId(FilterRequestInterface $filter): PaginatedRes } return new PaginatedResult( - items: array_values($pages), + items: $pages, total: $total, limit: $filter->getLimit(), lastId: $filter->getLastId(), diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index 9a8bdc5be..e9def63e5 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -19,7 +19,7 @@ * * @author Oliver Klee * @author Tatevik Grigoryan - * @SuppressWarnings(PHPMD.TooManyPublicMethods) + * @SuppressWarnings("PHPMD.TooManyPublicMethods") */ class SubscriberRepository extends AbstractRepository implements PaginatableRepositoryInterface { diff --git a/src/Domain/Subscription/Service/Manager/DynamicListAttrManager.php b/src/Domain/Subscription/Service/Manager/DynamicListAttrManager.php index a19d21b9b..6d4d952d3 100644 --- a/src/Domain/Subscription/Service/Manager/DynamicListAttrManager.php +++ b/src/Domain/Subscription/Service/Manager/DynamicListAttrManager.php @@ -54,10 +54,6 @@ public function insertOptions(string $listTable, array $rawOptions, array &$used $unique[] = $opt; } - if ($unique === []) { - return $result; - } - return $this->dynamicListAttrRepository->transactional(function () use ($listTable, $unique) { return $this->dynamicListAttrRepository->insertMany($listTable, $unique); }); diff --git a/src/Domain/Subscription/Service/SubscriberCsvExporter.php b/src/Domain/Subscription/Service/SubscriberCsvExporter.php index f2b1d43c4..48802fe07 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvExporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvExporter.php @@ -263,7 +263,7 @@ private function normalizerSubscriberData(Subscriber $subscriber): array 'blacklisted' => $subscriber->isBlacklisted() ? '1' : '0', 'bounceCount' => $subscriber->getBounceCount(), 'createdAt' => $subscriber->getCreatedAt()?->format('Y-m-d H:i:s') ?? '', - 'updatedAt' => $subscriber->getUpdatedAt()?->format('Y-m-d H:i:s') ?? '', + 'updatedAt' => $subscriber->getUpdatedAt()->format('Y-m-d H:i:s'), 'uniqueId' => $subscriber->getUniqueId(), 'htmlEmail' => $subscriber->hasHtmlEmail() ? '1' : '0', 'rssFrequency' => $subscriber->getRssFrequency(), diff --git a/src/Security/Authentication.php b/src/Security/Authentication.php index 5c6d69c4e..bb744f0e4 100644 --- a/src/Security/Authentication.php +++ b/src/Security/Authentication.php @@ -51,11 +51,7 @@ public function authenticateByApiKey(Request $request): ?Administrator return null; } - /** @var Administrator|null $administrator */ $administrator = $token->getAdministrator(); - if ($administrator === null) { - return null; - } try { // This checks for cases where a superuser created a session key and then got their super user diff --git a/src/TestingSupport/Traits/DatabaseTestTrait.php b/src/TestingSupport/Traits/DatabaseTestTrait.php index f6f5e551f..f6ca6b65d 100644 --- a/src/TestingSupport/Traits/DatabaseTestTrait.php +++ b/src/TestingSupport/Traits/DatabaseTestTrait.php @@ -4,7 +4,7 @@ namespace PhpList\Core\TestingSupport\Traits; -use Doctrine\DBAL\Platforms\SqlitePlatform; +use Doctrine\DBAL\Platforms\SQLitePlatform; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Tools\SchemaTool; use Doctrine\ORM\Tools\ToolsException; @@ -84,7 +84,7 @@ protected function loadSchema(): void $schemaTool = new SchemaTool($this->entityManager); $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); - if ($this->entityManager->getConnection()->getDatabasePlatform() instanceof SqlitePlatform) { + if ($this->entityManager->getConnection()->getDatabasePlatform() instanceof SQLitePlatform) { $this->runForSqlite($metadata, $schemaTool); } else { $this->runForMySql($metadata, $schemaTool); diff --git a/src/TestingSupport/Traits/ModelTestTrait.php b/src/TestingSupport/Traits/ModelTestTrait.php index 10ccbae8f..47f542c84 100644 --- a/src/TestingSupport/Traits/ModelTestTrait.php +++ b/src/TestingSupport/Traits/ModelTestTrait.php @@ -33,7 +33,7 @@ private function setSubjectId(DomainModel $model, int $id): void * @param string $propertyName * @param mixed $value * - * @return void* + * @return void */ private function setSubjectProperty(DomainModel $model, string $propertyName, mixed $value): void { diff --git a/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php b/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php index a69a751bf..4b36a190b 100644 --- a/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php +++ b/tests/Integration/Domain/Identity/Repository/AdministratorRepositoryTest.php @@ -42,7 +42,7 @@ protected function tearDown(): void public function testFindReadsModelFromDatabase(): void { - /** @var Administrator $actual */ + /** @var ?Administrator $actual */ $actual = $this->repository->findOneBy(['email' => 'john@example.com']); $this->assertNotNull($actual); @@ -66,7 +66,7 @@ public function testFindReadsModelFromDatabase(): void public function testCreationDateOfExistingModelStaysUnchangedOnUpdate(): void { $id = 1; - /** @var Administrator $model */ + /** @var ?Administrator $model */ $model = $this->repository->find($id); $this->assertNotNull($model); $originalCreationDate = $model->getCreatedAt(); @@ -80,7 +80,7 @@ public function testCreationDateOfExistingModelStaysUnchangedOnUpdate(): void public function testModificationDateOfExistingModelGetsUpdatedOnUpdate(): void { $id = 1; - /** @var Administrator $model */ + /** @var ?Administrator $model */ $model = $this->repository->find($id); $this->assertNotNull($model); diff --git a/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php b/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php index 015061d09..55d034604 100644 --- a/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php +++ b/tests/Integration/Domain/Identity/Repository/AdministratorTokenRepositoryTest.php @@ -26,7 +26,7 @@ class AdministratorTokenRepositoryTest extends WebTestCase use DatabaseTestTrait; use SimilarDatesAssertionTrait; - private ?AdministratorTokenRepository $repository; + private AdministratorTokenRepository $repository; protected function setUp(): void { diff --git a/tests/Integration/Domain/Messaging/Fixtures/MessageFixture.php b/tests/Integration/Domain/Messaging/Fixtures/MessageFixture.php index 081131002..faba2cef7 100644 --- a/tests/Integration/Domain/Messaging/Fixtures/MessageFixture.php +++ b/tests/Integration/Domain/Messaging/Fixtures/MessageFixture.php @@ -12,6 +12,7 @@ use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Model\Message\MessageFormat; use PhpList\Core\Domain\Messaging\Model\Message\MessageMetadata; +use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; use PhpList\Core\Domain\Messaging\Model\Message\MessageOptions; use PhpList\Core\Domain\Messaging\Model\Message\MessageSchedule; use PhpList\Core\Domain\Messaging\Model\Template; @@ -61,7 +62,7 @@ public function load(ObjectManager $manager): void embargo: new DateTime($row['embargo']), ); $metadata = new MessageMetadata( - status: $row['status'], + status: MessageStatus::from($row['status']), bounceCount: (int)$row['bouncecount'], entered: new DateTime($row['entered']), sent: new DateTime($row['sent']), diff --git a/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php b/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php index 2fdff18bc..2aa89b253 100644 --- a/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php +++ b/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php @@ -5,12 +5,10 @@ namespace PhpList\Core\Tests\Integration\Domain\Subscription\Repository; use DateTime; -use Doctrine\Common\Collections\ArrayCollection; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\Tools\SchemaTool; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\Subscription; -use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository; use PhpList\Core\TestingSupport\Traits\DatabaseTestTrait; @@ -32,7 +30,6 @@ class SubscriberRepositoryTest extends KernelTestCase use SimilarDatesAssertionTrait; private ?SubscriberRepository $subscriberRepository = null; - private ?SubscriberListRepository $subscriberListRepository = null; private ?SubscriptionRepository $subscriptionRepository = null; protected function setUp(): void @@ -41,7 +38,6 @@ protected function setUp(): void $this->loadSchema(); $this->subscriberRepository = self::getContainer()->get(SubscriberRepository::class); - $this->subscriberListRepository = self::getContainer()->get(SubscriberListRepository::class); $this->subscriptionRepository = self::getContainer()->get(SubscriptionRepository::class); } @@ -195,17 +191,15 @@ public function testFindsAssociatedSubscribedLists() $this->loadFixtures([SubscriberFixture::class, SubscriberListFixture::class, SubscriptionFixture::class]); $id = 1; - /** @var Subscriber $model */ + /** @var ?Subscriber $model */ $model = $this->subscriberRepository->findSubscriberWithSubscriptions($id); - $subscriberLists = new ArrayCollection(); + $subscriberListIds = []; foreach ($model->getSubscriptions() as $subscription) { - $subscriberLists->add($subscription->getSubscriberList()); + $subscriberListIds[] = $subscription->getSubscriberList()->getId(); } - $expectedList = $this->subscriberListRepository->find(2); - $unexpectedList = $this->subscriberListRepository->find(1); - self::assertTrue($subscriberLists->contains($expectedList)); - self::assertFalse($subscriberLists->contains($unexpectedList)); + self::assertContains(2, $subscriberListIds); + self::assertNotContains(1, $subscriberListIds); } public function testRemoveAlsoRemovesAssociatedSubscriptions() diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php index 3a7ae27b7..41e54a0f5 100644 --- a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php +++ b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php @@ -115,7 +115,6 @@ public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError(): try { $this->subscriberDeletionService->deleteLeavingBlacklist($subscriber); $this->entityManager->flush(); - $this->assertTrue(true, 'No exception was thrown'); } catch (Exception $e) { $this->fail('Exception was thrown: ' . $e->getMessage()); } diff --git a/tests/Unit/Core/EnvironmentTest.php b/tests/Unit/Core/EnvironmentTest.php index 7ac061691..2d96438ca 100644 --- a/tests/Unit/Core/EnvironmentTest.php +++ b/tests/Unit/Core/EnvironmentTest.php @@ -36,10 +36,8 @@ public function validEnvironmentDataProvider(): array */ public function testValidateEnvironmentForValidEnvironmentPasses(string $environment): void { + $this->expectNotToPerformAssertions(); Environment::validateEnvironment($environment); - - // Adding an assertion to confirm the method executes without throwing an exception. - self::assertTrue(true); } public function testValidateEnvironmentForInvalidEnvironmentThrowsException(): void diff --git a/tests/Unit/Domain/Common/PdfGeneratorTest.php b/tests/Unit/Domain/Common/PdfGeneratorTest.php index 78df55c83..549a2c935 100644 --- a/tests/Unit/Domain/Common/PdfGeneratorTest.php +++ b/tests/Unit/Domain/Common/PdfGeneratorTest.php @@ -16,7 +16,6 @@ public function testCreatePdfBytesProducesNonEmptyPdfWithHeaderAndEof(): void $pdfBytes = $generator->createPdfBytes($text); - $this->assertIsString($pdfBytes); $this->assertNotSame('', $pdfBytes); // Must start with a valid PDF header diff --git a/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php b/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php index f2d4d949b..12d778abd 100644 --- a/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php +++ b/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php @@ -59,8 +59,7 @@ public function testGetWithFiltersDelegatesToRepository(): void ->with( $this->callback(function (EventLogFilter $filter) { // Use getters to validate - return method_exists($filter, 'getPage') - && $filter->getPage() === 'settings' + return $filter->getPage() === 'settings' && $filter->getLastId() === 100 && $filter->getLimit() === 25 && $filter->getDateFrom() instanceof DateTimeImmutable diff --git a/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php b/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php index 77cfe0f90..93594a2a4 100644 --- a/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php +++ b/tests/Unit/Domain/Configuration/Service/MessagePlaceholderProcessorTest.php @@ -163,7 +163,7 @@ public function supports(string $key, PlaceholderContext $ctx): bool { return strtoupper($key) === 'SUPPORT'; } - public function resolve(string $key, PlaceholderContext $ctx): ?string + public function resolve(string $key, PlaceholderContext $ctx): string { return 'SVAL'; } diff --git a/tests/Unit/Domain/Identity/Model/AdministratorTest.php b/tests/Unit/Domain/Identity/Model/AdministratorTest.php index cf90e0c95..29190e921 100644 --- a/tests/Unit/Domain/Identity/Model/AdministratorTest.php +++ b/tests/Unit/Domain/Identity/Model/AdministratorTest.php @@ -69,11 +69,6 @@ public function testSetEmailAddressSetsEmailAddress(): void self::assertSame($value, $this->subject->getEmail()); } - public function testGetUpdatedAtInitiallyReturnsNotNull(): void - { - self::assertNotNull($this->subject->getUpdatedAt()); - } - public function testUpdateModificationDateSetsModificationDateToNow(): void { $this->subject->setEmail('update@email.com'); diff --git a/tests/Unit/Domain/Identity/Model/AdministratorTokenTest.php b/tests/Unit/Domain/Identity/Model/AdministratorTokenTest.php index 84a98df7c..e2d88726d 100644 --- a/tests/Unit/Domain/Identity/Model/AdministratorTokenTest.php +++ b/tests/Unit/Domain/Identity/Model/AdministratorTokenTest.php @@ -89,9 +89,4 @@ public function testGenerateKeyCreatesDifferentKeysForEachCall(): void self::assertNotSame($firstKey, $secondKey); } - - public function testGetAdministratorReturnsConstructorProvidedAdministrator(): void - { - self::assertNotNull($this->subject->getAdministrator()); - } } diff --git a/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php b/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php index 6f2e4cb40..fdc2e4832 100644 --- a/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php +++ b/tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php @@ -78,8 +78,7 @@ public function testSendsToListOwnersWhenFlagEnabled(): void $recipient = $envelope->getRecipients()[0] ?? null; $expectedRecipient = $emails[$invocationIndex++] ?? null; - return $sender !== null - && $sender->getAddress() === $bounce + return $sender->getAddress() === $bounce && $recipient !== null && $recipient->getAddress() === $expectedRecipient; }) @@ -201,7 +200,6 @@ function (Email $email, Envelope $envelope) use (&$sendCalls): void { $senderAddress = $envelope->getSender(); $recipient = $envelope->getRecipients()[0] ?? null; - $this->assertNotNull($senderAddress); $this->assertSame($bounce, $senderAddress->getAddress()); $this->assertNotNull($recipient); diff --git a/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php b/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php index 0a56460f4..94eecd08e 100644 --- a/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php @@ -85,7 +85,5 @@ public function testDeleteAdministrator(): void $manager = new AdministratorManager($entityManager, $hashGenerator); $manager->deleteAdministrator($admin); - - $this->assertTrue(true); } } diff --git a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php index c97547ffe..72f884af8 100644 --- a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php @@ -96,7 +96,6 @@ public function testGeneratePasswordResetTokenCleansUpExistingRequests(): void $token = $this->subject->generatePasswordResetToken($email); - $this->assertIsString($token); $this->assertNotEmpty($token); } diff --git a/tests/Unit/Domain/Messaging/Model/SubscriberListTest.php b/tests/Unit/Domain/Messaging/Model/SubscriberListTest.php index 87334cfed..2eb094707 100644 --- a/tests/Unit/Domain/Messaging/Model/SubscriberListTest.php +++ b/tests/Unit/Domain/Messaging/Model/SubscriberListTest.php @@ -52,9 +52,9 @@ public function testUpdateCreationDateSetsCreationDateToNow(): void self::assertSimilarDates(new DateTime(), $this->subscriberList->getCreatedAt()); } - public function testgetUpdatedAtInitiallyReturnsNull(): void + public function testGetUpdatedAtInitiallyReturnsCreationTime(): void { - self::assertNull($this->subscriberList->getUpdatedAt()); + self::assertSimilarDates(new DateTime(), $this->subscriberList->getUpdatedAt()); } public function testUpdateModificationDateSetsModificationDateToNow(): void diff --git a/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php index 90af07cb0..f0181e838 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/EmailBuilderTest.php @@ -176,7 +176,7 @@ public function testBuildsHtmlPreferredWithAttachments(): void $this->templateImageEmbedder ->expects($this->once()) ->method('__invoke') - ->with(html: '

HTML

', messageId: 777) + ->with('

HTML

', 777) ->willReturn('

HTML

'); $this->attachmentAdder ->expects($this->once()) diff --git a/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php index 8c1c7c495..2ca82e741 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/ForwardEmailBuilderTest.php @@ -130,7 +130,7 @@ public function testBuildsForwardEmailWithSubjectPrefixHeadersAndReplyTo(): void $this->templateImageEmbedder ->expects(self::once()) ->method('__invoke') - ->with(html: '

HTML

', messageId: 99) + ->with('

HTML

', 99) ->willReturn('

HTML

'); $this->attachmentAdder diff --git a/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php b/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php index b003ca326..ddfa64380 100644 --- a/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php +++ b/tests/Unit/Domain/Messaging/Service/Builder/SystemEmailBuilderTest.php @@ -145,7 +145,7 @@ public function testBuildsEmailWithExpectedHeadersAndBodiesInDevMode(): void $this->templateImageEmbedder->expects($this->once()) ->method('__invoke') - ->with(html: '

HTML

', messageId: 777) + ->with('

HTML

', 777) ->willReturn('

HTML

'); $builder = $this->makeBuilder( diff --git a/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php b/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php index 8a7bc67a2..1ead919a4 100644 --- a/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php @@ -92,9 +92,9 @@ public function testProcessesLinksAndDelegatesToBuilder(): void ->expects(self::once()) ->method('processMessageLinks') ->with( - campaignId: 42, - cachedMessageDto: $cached, - subscriber: $subscriber + 42, + $cached, + $subscriber ) ->willReturn($processed); @@ -103,14 +103,14 @@ public function testProcessesLinksAndDelegatesToBuilder(): void ->expects(self::once()) ->method('buildForwardEmail') ->with( - messageId: 42, - friendEmail: 'f@example.com', - forwardedBy: $subscriber, - data: $processed, - htmlPref: true, - fromName: 'From Name', - fromEmail: 'from@example.com', - forwardedPersonalNote: 'note' + 42, + 'f@example.com', + $subscriber, + $processed, + true, + 'From Name', + 'from@example.com', + 'note' ) ->willReturn([$expectedEmail, OutputFormat::Text]); @@ -127,7 +127,6 @@ public function testProcessesLinksAndDelegatesToBuilder(): void ) ); - self::assertIsArray($result); self::assertSame($expectedEmail, $result[0]); self::assertSame(OutputFormat::Text, $result[1]); } diff --git a/tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php b/tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php index ca02a0b6c..0bd7b2388 100644 --- a/tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php @@ -79,10 +79,10 @@ public function testMarkSentDelegatesToManager(): void $this->forwardManager->expects(self::once()) ->method('create') ->with( - subscriber: self::identicalTo($subscriber), - campaign: self::identicalTo($campaign), - friendEmail: $friendEmail, - status: 'sent' + self::identicalTo($subscriber), + self::identicalTo($campaign), + $friendEmail, + 'sent' ); $service->markSent($campaign, $subscriber, $friendEmail); @@ -103,10 +103,10 @@ public function testMarkFailedDelegatesToManager(): void $this->forwardManager->expects(self::once()) ->method('create') ->with( - subscriber: self::identicalTo($subscriber), - campaign: self::identicalTo($campaign), - friendEmail: $friendEmail, - status: 'failed' + self::identicalTo($subscriber), + self::identicalTo($campaign), + $friendEmail, + 'failed' ); $service->markFailed($campaign, $subscriber, $friendEmail); diff --git a/tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php b/tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php index cc8f34bc7..a84747fdf 100644 --- a/tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php @@ -40,8 +40,6 @@ public function testNoAttributeConfiguredDoesNothing(): void $service->incrementFriendsCount($subscriber); $service->updateFriendsCount($subscriber); - // reached without interactions - self::assertTrue(true); } public function testIncrementThenUpdatePersistsAndResets(): void @@ -63,16 +61,16 @@ public function testIncrementThenUpdatePersistsAndResets(): void $this->valueRepo->expects(self::once()) ->method('findOneBySubscriberAndAttributeName') - ->with(subscriber: self::identicalTo($subscriber), attributeName: 'FriendsForwarded') + ->with(self::identicalTo($subscriber), 'FriendsForwarded') ->willReturn($existing); // After two increments (3 -> 4 -> 5), update should persist '5' $this->attrManager->expects(self::once()) ->method('createOrUpdateByName') ->with( - subscriber: self::identicalTo($subscriber), - attributeName: 'FriendsForwarded', - value: '5' + self::identicalTo($subscriber), + 'FriendsForwarded', + '5' ); $service->incrementFriendsCount($subscriber); @@ -82,7 +80,6 @@ public function testIncrementThenUpdatePersistsAndResets(): void // Second update attempt should be a no-op due to cache reset $this->attrManager->expects(self::never())->method('createOrUpdateByName'); $service->updateFriendsCount($subscriber); - self::assertTrue(true); } public function testCacheIsolationBySubscriber(): void @@ -99,7 +96,7 @@ public function testCacheIsolationBySubscriber(): void // Initial load for A returns 0 $this->valueRepo->expects(self::once()) ->method('findOneBySubscriberAndAttributeName') - ->with(subscriber: self::identicalTo($subscriberA), attributeName: 'FriendsForwarded') + ->with(self::identicalTo($subscriberA), 'FriendsForwarded') ->willReturn(null); // cache for A becomes 1 $service->incrementFriendsCount($subscriberA); @@ -108,9 +105,9 @@ public function testCacheIsolationBySubscriber(): void $this->attrManager->expects(self::once()) ->method('createOrUpdateByName') ->with( - subscriber: self::identicalTo($subscriberA), - attributeName: 'FriendsForwarded', - value: '1' + self::identicalTo($subscriberA), + 'FriendsForwarded', + '1' ); // Calling update for B must be a no-op (cache belongs to A) $service->updateFriendsCount($subscriberB); diff --git a/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php index 63b3a4f9d..296e0c1db 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateImageManagerTest.php @@ -117,7 +117,6 @@ public function testExtractAllImages(): void $result = $this->manager->extractAllImages($html); - $this->assertIsArray($result); $this->assertContains('image1.jpg', $result); $this->assertContains('https://example.com/image2.png', $result); } diff --git a/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php index efcb8b008..13af41681 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/TemplateManagerTest.php @@ -153,7 +153,6 @@ public function testListDefaultsReturnsDefaultTemplateDefinitions(): void $defaults = $this->manager->listDefaults(); - $this->assertIsArray($defaults); $this->assertCount(2, $defaults); $this->assertSame('system', $defaults[0]['key']); $this->assertSame('System', $defaults[0]['name']); diff --git a/tests/Unit/Domain/Messaging/Service/Manager/UserMessageForwardManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/UserMessageForwardManagerTest.php index edf754c63..87573a405 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/UserMessageForwardManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/UserMessageForwardManagerTest.php @@ -44,8 +44,7 @@ public function testCreatePersistsAndReturnsForwardWithExpectedFields(): void return $fwd->getUserId() === 42 && $fwd->getMessageId() === 7 && $fwd->getForward() === $expectedFriendEmail - && $fwd->getStatus() === $this->expectedStatus - && $fwd->getCreatedAt() !== null; + && $fwd->getStatus() === $this->expectedStatus; }) ); diff --git a/tests/Unit/Domain/Messaging/Service/Mapper/DefaultTemplateMapperTest.php b/tests/Unit/Domain/Messaging/Service/Mapper/DefaultTemplateMapperTest.php index 03f186bda..db1789b22 100644 --- a/tests/Unit/Domain/Messaging/Service/Mapper/DefaultTemplateMapperTest.php +++ b/tests/Unit/Domain/Messaging/Service/Mapper/DefaultTemplateMapperTest.php @@ -21,7 +21,6 @@ public function testListReturnsConfiguredDefaults(): void { $defaults = $this->mapper->list(); - $this->assertIsArray($defaults); $this->assertNotEmpty($defaults); $this->assertSame('system', $defaults[0]['key']); $this->assertSame('System', $defaults[0]['name']); @@ -50,7 +49,6 @@ public function testLoadContentReadsTemplateFile(): void { $content = $this->mapper->loadContent('system.html'); - $this->assertIsString($content); $this->assertNotSame('', $content); $this->assertStringContainsString('[CONTENT]', $content); } diff --git a/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php b/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php index e29f69299..b54265b75 100644 --- a/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php +++ b/tests/Unit/Domain/Messaging/Service/SendRateLimiterTest.php @@ -64,12 +64,11 @@ public function testBatchLimitTriggersWaitMessageAndResetsCounters(): void // 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->expectNotToPerformAssertions(); $this->ispProvider->method('load')->willReturn(new IspRestrictions(null, null, null)); $limiter = new SendRateLimiter( ispRestrictionsProvider: $this->ispProvider, @@ -89,6 +88,5 @@ public function testThrottleSleepsPerMessagePathIsCallable(): void 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 40e1064a5..09f3f2ffe 100644 --- a/tests/Unit/Domain/Messaging/Validator/TemplateImageValidatorTest.php +++ b/tests/Unit/Domain/Messaging/Validator/TemplateImageValidatorTest.php @@ -53,8 +53,6 @@ public function testValidatesExistenceWithHttp200(): void ->willReturn(new Response(200)); $this->validator->validate(['https://example.com/image.jpg'], $context); - - $this->assertTrue(true); } public function testValidatesExistenceWithHttp404(): void diff --git a/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php b/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php index 5767f1936..b61341db2 100644 --- a/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php +++ b/tests/Unit/Domain/Messaging/Validator/TemplateLinkValidatorTest.php @@ -21,20 +21,18 @@ protected function setUp(): void public function testSkipsValidationIfNotString(): void { + $this->expectNotToPerformAssertions(); $context = (new ValidationContext())->set('checkLinks', true); $this->validator->validate(['not', 'a', 'string'], $context); - - $this->assertTrue(true); } public function testSkipsValidationIfCheckLinksIsFalse(): void { + $this->expectNotToPerformAssertions(); $context = (new ValidationContext())->set('checkLinks', false); $this->validator->validate('Broken link', $context); - - $this->assertTrue(true); } public function testValidatesInvalidLinks(): void @@ -51,6 +49,7 @@ public function testValidatesInvalidLinks(): void public function testAllowsValidLinksAndPlaceholders(): void { + $this->expectNotToPerformAssertions(); $context = (new ValidationContext())->set('checkLinks', true); $html = '' . @@ -61,7 +60,5 @@ public function testAllowsValidLinksAndPlaceholders(): void ''; $this->validator->validate($html, $context); - - $this->assertTrue(true); } } diff --git a/tests/Unit/Domain/Subscription/MessageHandler/DynamicTableMessageHandlerTest.php b/tests/Unit/Domain/Subscription/MessageHandler/DynamicTableMessageHandlerTest.php index d923b7c3e..32603a023 100644 --- a/tests/Unit/Domain/Subscription/MessageHandler/DynamicTableMessageHandlerTest.php +++ b/tests/Unit/Domain/Subscription/MessageHandler/DynamicTableMessageHandlerTest.php @@ -106,8 +106,6 @@ public function testInvokeDoesNothingWhenTableAlreadyExists(): void $handler = new DynamicTableMessageHandler($this->schemaManager); $handler($message); - // reached without creating a table - $this->assertTrue(true); } public function testInvokeThrowsForInvalidTableName(): void @@ -127,7 +125,6 @@ public function testInvokeThrowsForInvalidTableName(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid list table name: ' . $invalidName); $handler($message); - $this->assertTrue(true); } public function testInvokeSwallowsTableExistsRace(): void @@ -153,6 +150,5 @@ public function testInvokeSwallowsTableExistsRace(): void // Should not throw despite the TableExistsException $handler($message); - $this->assertTrue(true); } } diff --git a/tests/Unit/Domain/Subscription/Model/SubscriberTest.php b/tests/Unit/Domain/Subscription/Model/SubscriberTest.php index d1aa848c5..6a524308a 100644 --- a/tests/Unit/Domain/Subscription/Model/SubscriberTest.php +++ b/tests/Unit/Domain/Subscription/Model/SubscriberTest.php @@ -56,11 +56,6 @@ public function testUpdateCreationDateSetsCreationDateToNow(): void self::assertSimilarDates(new \DateTime(), $this->subscriber->getCreatedAt()); } - public function testGetUpdatedAtInitiallyReturnsNotNull(): void - { - self::assertNotNull($this->subscriber->getUpdatedAt()); - } - public function testUpdateModificationDateSetsModificationDateToNow(): void { $this->subscriber->updateUpdatedAt(); diff --git a/tests/Unit/Domain/Subscription/Model/SubscriptionTest.php b/tests/Unit/Domain/Subscription/Model/SubscriptionTest.php index e0148a839..797210bb5 100644 --- a/tests/Unit/Domain/Subscription/Model/SubscriptionTest.php +++ b/tests/Unit/Domain/Subscription/Model/SubscriptionTest.php @@ -35,11 +35,6 @@ public function testIsDomainModel(): void self::assertInstanceOf(DomainModel::class, $this->subject); } - public function testGetSubscriberInitiallyReturnsNull(): void - { - self::assertNull($this->subject->getSubscriber()); - } - public function testSetSubscriberSetsSubscriber(): void { $model = new Subscriber('test@example.com'); @@ -48,11 +43,6 @@ public function testSetSubscriberSetsSubscriber(): void self::assertSame($model, $this->subject->getSubscriber()); } - public function testGetSubscriberListInitiallyReturnsNull(): void - { - self::assertNull($this->subject->getSubscriberList()); - } - public function testSetSubscriberListSetsSubscriberList(): void { $model = new SubscriberList(); @@ -66,11 +56,6 @@ public function testGetCreatedAtInitiallyReturnsCurrentTime(): void self::assertSimilarDates(new DateTime(), $this->subject->getCreatedAt()); } - public function testGetUpdatedAtInitiallyReturnsNull(): void - { - self::assertNull($this->subject->getUpdatedAt()); - } - public function testUpdateModificationDateSetsModificationDateToNow(): void { $this->subject->updateUpdatedAt(); diff --git a/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php index f109f1c4f..3761ccdde 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php @@ -189,7 +189,5 @@ public function testDeleteAttributeDefinition(): void $repository->expects($this->once())->method('remove')->with($attribute); $manager->delete($attribute); - - $this->assertTrue(true); } } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/DynamicListAttrManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/DynamicListAttrManagerTest.php index c51cb81d4..d39bc28ab 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/DynamicListAttrManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/DynamicListAttrManagerTest.php @@ -49,8 +49,6 @@ public function testInsertOptionsSkipsEmpty(): void // Empty array should be a no-op (no DB calls) $this->listAttrRepo->expects($this->never())->method('transactional'); $manager->insertOptions('colors', []); - // if we got here, expectations were met - $this->assertTrue(true); } public function testInsertOptionsSkipsDuplicatesAndAssignsOrder(): void diff --git a/tests/Unit/Domain/Subscription/Service/Manager/DynamicListAttrTablesManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/DynamicListAttrTablesManagerTest.php index a9166799d..eb3c6fdaf 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/DynamicListAttrTablesManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/DynamicListAttrTablesManagerTest.php @@ -78,6 +78,5 @@ public function testCreateOptionsTableIfNotExistsDispatchesMessage(): void $manager = $this->makeManager(); $manager->createOptionsTableIfNotExists('sizes'); $manager->createOptionsTableIfNotExists('sizes'); - $this->assertTrue(true); } } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php index 5bfc65bb9..7c05dca25 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscribePageManagerTest.php @@ -355,7 +355,7 @@ public function testSyncPageDataCallsCopyToConfigWhenFeatureIsEnabled(): void $this->configMigrationService ->expects($this->once()) ->method('copyToConfig') - ->with(page: $this->page, data: $data); + ->with($this->page, $data); $this->manager->syncPageData($data, $this->page); } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php index e0632df90..3cab283e3 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberAttributeManagerTest.php @@ -146,7 +146,5 @@ public function testDeleteSubscriberAttribute(): void translator: new Translator('en'), ); $manager->delete($attribute); - - self::assertTrue(true); } } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php index aaac68474..f28dc08a1 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php @@ -71,6 +71,5 @@ public function testGetHistoryReturnsEmptyArrayWhenRepositoryReturnsEmptyArray() $result = $this->subscriptionHistoryService->getHistory($lastId, $limit, $filter); $this->assertSame($expectedResult, $result); - $this->assertEmpty($result); } } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberListManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberListManagerTest.php index 124ace088..442473674 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberListManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberListManagerTest.php @@ -59,7 +59,6 @@ public function testGetPaginated(): void $result = $this->manager->getPaginated(0, 1); - $this->assertIsArray($result); $this->assertCount(1, $result); $this->assertSame($list, $result[0]); } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php index a78404804..9b575d38c 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php @@ -113,7 +113,6 @@ public function testGetSubscriberListMembersReturnsList(): void $result = $this->manager->getSubscriberListMembers($subscriberList); - $this->assertIsArray($result); $this->assertCount(1, $result); $this->assertInstanceOf(Subscriber::class, $result[0]); } diff --git a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php index ee263b730..a68576a1e 100644 --- a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php +++ b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php @@ -46,7 +46,6 @@ public function testGetSubscribersForMessageWithNoListsReturnsEmptyArray(): void $message, ); - $this->assertIsArray($result); $this->assertEmpty($result); } @@ -69,7 +68,6 @@ public function testGetSubscribersForMessageWithOneListButNoSubscribersReturnsEm $this->createMock(CampaignProcessorMessageInterface::class), $message, ); - $this->assertIsArray($result); $this->assertEmpty($result); } @@ -97,7 +95,6 @@ public function testGetSubscribersForMessageWithOneListAndSubscribersReturnsSubs new CampaignProcessorMessage(1), $message, ); - $this->assertIsArray($result); $this->assertCount(2, $result); $this->assertSame($subscriber1, $result[0]); $this->assertSame($subscriber2, $result[1]); @@ -131,7 +128,6 @@ public function testGetSubscribersForMessageWithMultipleListsReturnsUniqueSubscr $this->createMock(CampaignProcessorMessageInterface::class), $message, ); - $this->assertIsArray($result); $this->assertCount(3, $result); $this->assertContains($subscriber1, $result); diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberCsvExporterTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberCsvExporterTest.php index 91605f73c..3c7ae4b3f 100644 --- a/tests/Unit/Domain/Subscription/Service/SubscriberCsvExporterTest.php +++ b/tests/Unit/Domain/Subscription/Service/SubscriberCsvExporterTest.php @@ -98,8 +98,8 @@ public function testExportToCsvWithFilterReturnsStreamedResponse(): void $this->assertInstanceOf(Response::class, $response); $this->assertSame('text/csv; charset=utf-8', $response->headers->get('Content-Type')); $this->assertStringContainsString( - needle: 'attachment; filename=subscribers_export_', - haystack: $response->headers->get('Content-Disposition') + 'attachment; filename=subscribers_export_', + $response->headers->get('Content-Disposition') ); } @@ -145,8 +145,8 @@ public function testExportToCsvWithoutFilterCreatesDefaultFilter(): void $this->assertInstanceOf(Response::class, $response); $this->assertSame('text/csv; charset=utf-8', $response->headers->get('Content-Type')); $this->assertStringContainsString( - needle: 'attachment; filename=subscribers_export_', - haystack: $response->headers->get('Content-Disposition') + 'attachment; filename=subscribers_export_', + $response->headers->get('Content-Disposition') ); } } diff --git a/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php b/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php index 7f31f772c..054334bae 100644 --- a/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php +++ b/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php @@ -20,11 +20,10 @@ protected function setUp(): void public function testValidatesValidType(): void { + $this->expectNotToPerformAssertions(); $this->validator->validate('textline'); $this->validator->validate('checkbox'); $this->validator->validate('date'); - - $this->assertTrue(true); } public function testThrowsExceptionForInvalidType(): void @@ -32,7 +31,6 @@ public function testThrowsExceptionForInvalidType(): void $this->expectException(ValidatorException::class); $this->validator->validate('invalid_type'); - $this->assertTrue(true); } public function testThrowsExceptionForNonStringValue(): void @@ -40,6 +38,5 @@ public function testThrowsExceptionForNonStringValue(): void $this->expectException(ValidatorException::class); $this->validator->validate(123); - $this->assertTrue(true); } } From b6ad628d991c4c74d8bcf98ed2649d548e6dfda5 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 26 Aug 2026 10:10:15 +0400 Subject: [PATCH 26/39] refactor: consolidate AttributeDefinitionCreationException into Common\Exception and update related usages --- .../AttributeDefinitionCreationException.php | 4 +- .../AbstractAttributeTypeValidator.php | 72 +++++++++++++++++++ .../AdminAttributeDefinitionManager.php | 2 +- .../Validator/AttributeTypeValidator.php | 63 ++-------------- .../AttributeDefinitionCreationException.php | 23 ------ .../Manager/AttributeDefinitionManager.php | 2 +- .../Validator/AttributeTypeValidator.php | 62 ++-------------- .../AdminAttributeDefinitionManagerTest.php | 2 +- .../AttributeDefinitionManagerTest.php | 2 +- 9 files changed, 88 insertions(+), 144 deletions(-) rename src/Domain/{Identity => Common}/Exception/AttributeDefinitionCreationException.php (88%) create mode 100644 src/Domain/Common/Validator/AbstractAttributeTypeValidator.php delete mode 100644 src/Domain/Subscription/Exception/AttributeDefinitionCreationException.php diff --git a/src/Domain/Identity/Exception/AttributeDefinitionCreationException.php b/src/Domain/Common/Exception/AttributeDefinitionCreationException.php similarity index 88% rename from src/Domain/Identity/Exception/AttributeDefinitionCreationException.php rename to src/Domain/Common/Exception/AttributeDefinitionCreationException.php index 5d1058937..07c19eb06 100644 --- a/src/Domain/Identity/Exception/AttributeDefinitionCreationException.php +++ b/src/Domain/Common/Exception/AttributeDefinitionCreationException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Identity\Exception; +namespace PhpList\Core\Domain\Common\Exception; use RuntimeException; @@ -20,4 +20,4 @@ public function getStatusCode(): int { return $this->statusCode; } -} +} \ No newline at end of file diff --git a/src/Domain/Common/Validator/AbstractAttributeTypeValidator.php b/src/Domain/Common/Validator/AbstractAttributeTypeValidator.php new file mode 100644 index 000000000..322c5f8c2 --- /dev/null +++ b/src/Domain/Common/Validator/AbstractAttributeTypeValidator.php @@ -0,0 +1,72 @@ +normalizeToEnum($value); + + if (!in_array($enum, $this->getValidTypes(), true)) { + $validList = implode(', ', array_map( + static fn (AttributeTypeEnum $enum) => $enum->value, + $this->getValidTypes() + )); + + $message = $this->translator->trans( + 'Invalid attribute type: "%type%". Valid types are: %valid_types%', + [ + '%type%' => $enum->value, + '%valid_types%' => $validList, + ] + ); + + throw new ValidatorException($message); + } + } + + /** + * @throws ValidatorException if value cannot be converted to AttributeTypeEnum + */ + private function normalizeToEnum(mixed $value): AttributeTypeEnum + { + if ($value instanceof AttributeTypeEnum) { + return $value; + } + + if (is_string($value)) { + try { + return AttributeTypeEnum::from($value); + } catch (Throwable) { + $lower = strtolower($value); + foreach (AttributeTypeEnum::cases() as $case) { + if ($case->value === $lower) { + return $case; + } + } + } + } + + throw new ValidatorException( + $this->translator->trans('Value must be an AttributeTypeEnum or string.') + ); + } +} \ No newline at end of file diff --git a/src/Domain/Identity/Service/Manager/AdminAttributeDefinitionManager.php b/src/Domain/Identity/Service/Manager/AdminAttributeDefinitionManager.php index e66f9fe4c..62106eaf1 100644 --- a/src/Domain/Identity/Service/Manager/AdminAttributeDefinitionManager.php +++ b/src/Domain/Identity/Service/Manager/AdminAttributeDefinitionManager.php @@ -5,7 +5,7 @@ namespace PhpList\Core\Domain\Identity\Service\Manager; use PhpList\Core\Domain\Common\Model\PaginatedResult; -use PhpList\Core\Domain\Identity\Exception\AttributeDefinitionCreationException; +use PhpList\Core\Domain\Common\Exception\AttributeDefinitionCreationException; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; use PhpList\Core\Domain\Identity\Model\Dto\AdminAttributeDefinitionDto; use PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository; diff --git a/src/Domain/Identity/Validator/AttributeTypeValidator.php b/src/Domain/Identity/Validator/AttributeTypeValidator.php index c7bb3853a..1eb3b8a68 100644 --- a/src/Domain/Identity/Validator/AttributeTypeValidator.php +++ b/src/Domain/Identity/Validator/AttributeTypeValidator.php @@ -4,71 +4,18 @@ namespace PhpList\Core\Domain\Identity\Validator; -use InvalidArgumentException; use PhpList\Core\Domain\Common\Model\AttributeTypeEnum; -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; +use PhpList\Core\Domain\Common\Validator\AbstractAttributeTypeValidator; -class AttributeTypeValidator implements ValidatorInterface +class AttributeTypeValidator extends AbstractAttributeTypeValidator { - public function __construct(private readonly TranslatorInterface $translator) - { - } - private const VALID_TYPES = [ AttributeTypeEnum::TextLine, AttributeTypeEnum::Hidden, ]; - public function validate(mixed $value, ValidationContext $context = null): void + protected function getValidTypes(): array { - $enum = $this->normalizeToEnum($value); - - if (!in_array($enum, self::VALID_TYPES, true)) { - $validList = implode(', ', array_map( - static fn(AttributeTypeEnum $enum) => $enum->value, - self::VALID_TYPES - )); - - $message = $this->translator->trans( - 'Invalid attribute type: "%type%". Valid types are: %valid_types%', - [ - '%type%' => $enum->value, - '%valid_types%' => $validList, - ] - ); - - throw new ValidatorException($message); - } - } - - /** - * @throws InvalidArgumentException if value cannot be converted to AttributeTypeEnum - */ - private function normalizeToEnum(mixed $value): AttributeTypeEnum - { - if ($value instanceof AttributeTypeEnum) { - return $value; - } - - if (is_string($value)) { - try { - return AttributeTypeEnum::from($value); - } catch (Throwable) { - $lower = strtolower($value); - foreach (AttributeTypeEnum::cases() as $case) { - if ($case->value === $lower) { - return $case; - } - } - } - } - - throw new InvalidArgumentException( - $this->translator->trans('Value must be an AttributeTypeEnum or string.') - ); + return self::VALID_TYPES; } -} +} \ No newline at end of file diff --git a/src/Domain/Subscription/Exception/AttributeDefinitionCreationException.php b/src/Domain/Subscription/Exception/AttributeDefinitionCreationException.php deleted file mode 100644 index 2ea5ef45f..000000000 --- a/src/Domain/Subscription/Exception/AttributeDefinitionCreationException.php +++ /dev/null @@ -1,23 +0,0 @@ -statusCode = $statusCode; - } - - public function getStatusCode(): int - { - return $this->statusCode; - } -} diff --git a/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php b/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php index 201597661..bfc809f13 100644 --- a/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php +++ b/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php @@ -4,7 +4,7 @@ namespace PhpList\Core\Domain\Subscription\Service\Manager; -use PhpList\Core\Domain\Subscription\Exception\AttributeDefinitionCreationException; +use PhpList\Core\Domain\Common\Exception\AttributeDefinitionCreationException; use PhpList\Core\Domain\Subscription\Model\Dto\AttributeDefinitionDto; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; diff --git a/src/Domain/Subscription/Validator/AttributeTypeValidator.php b/src/Domain/Subscription/Validator/AttributeTypeValidator.php index d4ededff4..fcf686299 100644 --- a/src/Domain/Subscription/Validator/AttributeTypeValidator.php +++ b/src/Domain/Subscription/Validator/AttributeTypeValidator.php @@ -5,18 +5,10 @@ namespace PhpList\Core\Domain\Subscription\Validator; use PhpList\Core\Domain\Common\Model\AttributeTypeEnum; -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; +use PhpList\Core\Domain\Common\Validator\AbstractAttributeTypeValidator; -class AttributeTypeValidator implements ValidatorInterface +class AttributeTypeValidator extends AbstractAttributeTypeValidator { - public function __construct(private readonly TranslatorInterface $translator) - { - } - private const VALID_TYPES = [ AttributeTypeEnum::TextLine, AttributeTypeEnum::Hidden, @@ -29,52 +21,8 @@ public function __construct(private readonly TranslatorInterface $translator) AttributeTypeEnum::CheckboxGroup, ]; - public function validate(mixed $value, ValidationContext $context = null): void + protected function getValidTypes(): array { - $enum = $this->normalizeToEnum($value); - - if (!in_array($enum, self::VALID_TYPES, true)) { - $validList = implode(', ', array_map( - static fn(AttributeTypeEnum $enum) => $enum->value, - self::VALID_TYPES - )); - - $message = $this->translator->trans( - 'Invalid attribute type: "%type%". Valid types are: %valid_types%', - [ - '%type%' => $enum->value, - '%valid_types%' => $validList, - ] - ); - - throw new ValidatorException($message); - } - } - - /** - * @throws ValidatorException if value cannot be converted to AttributeTypeEnum - */ - private function normalizeToEnum(mixed $value): AttributeTypeEnum - { - if ($value instanceof AttributeTypeEnum) { - return $value; - } - - if (is_string($value)) { - try { - return AttributeTypeEnum::from($value); - } catch (Throwable) { - $lower = strtolower($value); - foreach (AttributeTypeEnum::cases() as $case) { - if ($case->value === $lower) { - return $case; - } - } - } - } - - throw new ValidatorException( - $this->translator->trans('Value must be an AttributeTypeEnum or string.') - ); + return self::VALID_TYPES; } -} +} \ No newline at end of file diff --git a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php index 863e69f35..2165272ca 100644 --- a/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/AdminAttributeDefinitionManagerTest.php @@ -5,7 +5,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Service; use PhpList\Core\Domain\Common\Model\PaginatedResult; -use PhpList\Core\Domain\Identity\Exception\AttributeDefinitionCreationException; +use PhpList\Core\Domain\Common\Exception\AttributeDefinitionCreationException; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; use PhpList\Core\Domain\Identity\Model\Dto\AdminAttributeDefinitionDto; use PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository; diff --git a/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php index 3761ccdde..efad3937c 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php @@ -5,7 +5,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service\Manager; use PhpList\Core\Domain\Common\Model\AttributeTypeEnum; -use PhpList\Core\Domain\Subscription\Exception\AttributeDefinitionCreationException; +use PhpList\Core\Domain\Common\Exception\AttributeDefinitionCreationException; use PhpList\Core\Domain\Subscription\Model\Dto\AttributeDefinitionDto; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; From 7358aaae78a95b870eb0778ebc0b5b672b677af3 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 26 Aug 2026 10:42:21 +0400 Subject: [PATCH 27/39] refactor: move Bounce service classes to Domain\Messaging namespace --- config/services/commands.yml | 2 +- config/services/managers.yml | 2 -- config/services/processor.yml | 10 +++++----- config/services/resolvers.yml | 2 +- config/services/services.yml | 16 ++++++++-------- src/Core/BounceProcessorPass.php | 6 +++--- .../AttributeDefinitionCreationException.php | 2 +- .../Validator/AbstractAttributeTypeValidator.php | 2 +- .../Validator/AttributeTypeValidator.php | 2 +- .../Messaging}/Command/ProcessBouncesCommand.php | 12 ++++++------ .../Exception/ImapConnectionException.php | 2 +- .../Exception/OpenMboxFileException.php | 2 +- .../Messaging}/Service/BounceActionResolver.php | 4 ++-- .../Service/BounceProcessingServiceInterface.php | 2 +- .../Service/ConsecutiveBounceHandler.php | 4 ++-- .../BlacklistEmailAndDeleteBounceHandler.php | 6 +++--- .../Service/Handler/BlacklistEmailHandler.php | 4 ++-- .../BlacklistUserAndDeleteBounceHandler.php | 6 +++--- .../Service/Handler/BlacklistUserHandler.php | 4 ++-- .../Handler/BounceActionHandlerInterface.php | 2 +- ...aseCountConfirmUserAndDeleteBounceHandler.php | 4 ++-- .../Service/Handler/DeleteBounceHandler.php | 4 ++-- .../Handler/DeleteUserAndBounceHandler.php | 4 ++-- .../Service/Handler/DeleteUserHandler.php | 2 +- .../UnconfirmUserAndDeleteBounceHandler.php | 4 ++-- .../Service/Handler/UnconfirmUserHandler.php | 2 +- .../Messaging}/Service/LockService.php | 2 +- .../Messaging}/Service/Manager/BounceManager.php | 2 +- .../Messaging}/Service/MessageParser.php | 2 +- .../Service/NativeBounceProcessingService.php | 8 ++++---- .../Processor/AdvancedBounceRulesProcessor.php | 6 +++--- .../Service/Processor/BounceDataProcessor.php | 4 ++-- .../Processor/BounceProtocolProcessor.php | 2 +- .../Service/Processor/MboxBounceProcessor.php | 4 ++-- .../Service/Processor/PopBounceProcessor.php | 4 ++-- .../Processor/UnidentifiedBounceReprocessor.php | 6 +++--- .../Service/SubscriberBlacklistService.php | 2 +- .../Service/WebklexBounceProcessingService.php | 8 ++++---- .../Service/WebklexImapClientFactory.php | 2 +- .../Validator/AttributeTypeValidator.php | 2 +- .../Command/ProcessBouncesCommandTest.php | 14 +++++++------- .../Service/BounceActionResolverTest.php | 6 +++--- .../Service/ConsecutiveBounceHandlerTest.php | 8 ++++---- .../BlacklistEmailAndDeleteBounceHandlerTest.php | 8 ++++---- .../Handler/BlacklistEmailHandlerTest.php | 6 +++--- .../BlacklistUserAndDeleteBounceHandlerTest.php | 8 ++++---- .../Service/Handler/BlacklistUserHandlerTest.php | 6 +++--- ...ountConfirmUserAndDeleteBounceHandlerTest.php | 6 +++--- .../Service/Handler/DeleteBounceHandlerTest.php | 6 +++--- .../Handler/DeleteUserAndBounceHandlerTest.php | 6 +++--- .../Service/Handler/DeleteUserHandlerTest.php | 4 ++-- .../UnconfirmUserAndDeleteBounceHandlerTest.php | 6 +++--- .../Service/Handler/UnconfirmUserHandlerTest.php | 4 ++-- .../Messaging}/Service/LockServiceTest.php | 4 ++-- .../Service/Manager/BounceManagerTest.php | 2 +- .../Messaging}/Service/MessageParserTest.php | 4 ++-- .../AdvancedBounceRulesProcessorTest.php | 8 ++++---- .../Processor/BounceDataProcessorTest.php | 6 +++--- .../Processor/MboxBounceProcessorTest.php | 6 +++--- .../Processor/PopBounceProcessorTest.php | 6 +++--- .../UnidentifiedBounceReprocessorTest.php | 10 +++++----- .../Service/WebklexImapClientFactoryTest.php | 4 ++-- 62 files changed, 151 insertions(+), 153 deletions(-) rename src/{Bounce => Domain/Messaging}/Command/ProcessBouncesCommand.php (91%) rename src/{Bounce => Domain/Messaging}/Exception/ImapConnectionException.php (84%) rename src/{Bounce => Domain/Messaging}/Exception/OpenMboxFileException.php (84%) rename src/{Bounce => Domain/Messaging}/Service/BounceActionResolver.php (92%) rename src/{Bounce => Domain/Messaging}/Service/BounceProcessingServiceInterface.php (77%) rename src/{Bounce => Domain/Messaging}/Service/ConsecutiveBounceHandler.php (97%) rename src/{Bounce => Domain/Messaging}/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php (91%) rename src/{Bounce => Domain/Messaging}/Service/Handler/BlacklistEmailHandler.php (93%) rename src/{Bounce => Domain/Messaging}/Service/Handler/BlacklistUserAndDeleteBounceHandler.php (91%) rename src/{Bounce => Domain/Messaging}/Service/Handler/BlacklistUserHandler.php (93%) rename src/{Bounce => Domain/Messaging}/Service/Handler/BounceActionHandlerInterface.php (76%) rename src/{Bounce => Domain/Messaging}/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php (94%) rename src/{Bounce => Domain/Messaging}/Service/Handler/DeleteBounceHandler.php (82%) rename src/{Bounce => Domain/Messaging}/Service/Handler/DeleteUserAndBounceHandler.php (88%) rename src/{Bounce => Domain/Messaging}/Service/Handler/DeleteUserHandler.php (94%) rename src/{Bounce => Domain/Messaging}/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php (93%) rename src/{Bounce => Domain/Messaging}/Service/Handler/UnconfirmUserHandler.php (96%) rename src/{Bounce => Domain/Messaging}/Service/LockService.php (99%) rename src/{Bounce => Domain/Messaging}/Service/Manager/BounceManager.php (98%) rename src/{Bounce => Domain/Messaging}/Service/MessageParser.php (98%) rename src/{Bounce => Domain/Messaging}/Service/NativeBounceProcessingService.php (94%) rename src/{Bounce => Domain/Messaging}/Service/Processor/AdvancedBounceRulesProcessor.php (95%) rename src/{Bounce => Domain/Messaging}/Service/Processor/BounceDataProcessor.php (98%) rename src/{Bounce => Domain/Messaging}/Service/Processor/BounceProtocolProcessor.php (91%) rename src/{Bounce => Domain/Messaging}/Service/Processor/MboxBounceProcessor.php (91%) rename src/{Bounce => Domain/Messaging}/Service/Processor/PopBounceProcessor.php (93%) rename src/{Bounce => Domain/Messaging}/Service/Processor/UnidentifiedBounceReprocessor.php (93%) rename src/{Bounce => Domain/Messaging}/Service/SubscriberBlacklistService.php (98%) rename src/{Bounce => Domain/Messaging}/Service/WebklexBounceProcessingService.php (97%) rename src/{Bounce => Domain/Messaging}/Service/WebklexImapClientFactory.php (97%) rename tests/Unit/{Bounce => Domain/Messaging}/Command/ProcessBouncesCommandTest.php (95%) rename tests/Unit/{Bounce => Domain/Messaging}/Service/BounceActionResolverTest.php (91%) rename tests/Unit/{Bounce => Domain/Messaging}/Service/ConsecutiveBounceHandlerTest.php (96%) rename tests/Unit/{Bounce => Domain/Messaging}/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php (90%) rename tests/Unit/{Bounce => Domain/Messaging}/Service/Handler/BlacklistEmailHandlerTest.php (91%) rename tests/Unit/{Bounce => Domain/Messaging}/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php (92%) rename tests/Unit/{Bounce => Domain/Messaging}/Service/Handler/BlacklistUserHandlerTest.php (92%) rename tests/Unit/{Bounce => Domain/Messaging}/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php (94%) rename tests/Unit/{Bounce => Domain/Messaging}/Service/Handler/DeleteBounceHandlerTest.php (83%) rename tests/Unit/{Bounce => Domain/Messaging}/Service/Handler/DeleteUserAndBounceHandlerTest.php (91%) rename tests/Unit/{Bounce => Domain/Messaging}/Service/Handler/DeleteUserHandlerTest.php (94%) rename tests/Unit/{Bounce => Domain/Messaging}/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php (93%) rename tests/Unit/{Bounce => Domain/Messaging}/Service/Handler/UnconfirmUserHandlerTest.php (94%) rename tests/Unit/{Bounce => Domain/Messaging}/Service/LockServiceTest.php (96%) rename tests/Unit/{Bounce => Domain/Messaging}/Service/MessageParserTest.php (95%) rename tests/Unit/{ => Domain/Messaging/Service}/Processor/AdvancedBounceRulesProcessorTest.php (96%) rename tests/Unit/{ => Domain/Messaging/Service}/Processor/BounceDataProcessorTest.php (97%) rename tests/Unit/{ => Domain/Messaging/Service}/Processor/MboxBounceProcessorTest.php (92%) rename tests/Unit/{ => Domain/Messaging/Service}/Processor/PopBounceProcessorTest.php (91%) rename tests/Unit/{ => Domain/Messaging/Service}/Processor/UnidentifiedBounceReprocessorTest.php (89%) rename tests/Unit/{Bounce => Domain/Messaging}/Service/WebklexImapClientFactoryTest.php (94%) diff --git a/config/services/commands.yml b/config/services/commands.yml index 65a0439bd..d9305748c 100644 --- a/config/services/commands.yml +++ b/config/services/commands.yml @@ -12,6 +12,6 @@ services: resource: '../../src/Domain/Identity/Command' tags: ['console.command'] - PhpList\Core\Bounce\Command\ProcessBouncesCommand: + PhpList\Core\Domain\Messaging\Command\ProcessBouncesCommand: arguments: $protocolProcessors: !tagged_iterator 'phplist.bounce_protocol_processor' diff --git a/config/services/managers.yml b/config/services/managers.yml index 7a306db5c..936bf38f5 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -8,8 +8,6 @@ services: resource: '../../src/Domain/**/Service/Manager/*' exclude: '../../src/Domain/**/Service/Manager/Builder/*' - PhpList\Core\Bounce\Service\Manager\BounceManager: ~ - Doctrine\DBAL\Schema\AbstractSchemaManager: factory: ['@doctrine.dbal.default_connection', 'createSchemaManager'] diff --git a/config/services/processor.yml b/config/services/processor.yml index 1e591bacc..8ff38bf48 100644 --- a/config/services/processor.yml +++ b/config/services/processor.yml @@ -4,20 +4,20 @@ services: autoconfigure: true public: false - PhpList\Core\Bounce\Service\Processor\PopBounceProcessor: + PhpList\Core\Domain\Messaging\Service\Processor\PopBounceProcessor: arguments: $host: '%imap_bounce.host%' $port: '%imap_bounce.port%' $mailboxNames: '%imap_bounce.mailbox_name%' tags: ['phplist.bounce_protocol_processor'] - PhpList\Core\Bounce\Service\Processor\MboxBounceProcessor: + PhpList\Core\Domain\Messaging\Service\Processor\MboxBounceProcessor: tags: ['phplist.bounce_protocol_processor'] - PhpList\Core\Bounce\Service\Processor\AdvancedBounceRulesProcessor: ~ + PhpList\Core\Domain\Messaging\Service\Processor\AdvancedBounceRulesProcessor: ~ - PhpList\Core\Bounce\Service\Processor\UnidentifiedBounceReprocessor: ~ + PhpList\Core\Domain\Messaging\Service\Processor\UnidentifiedBounceReprocessor: ~ - PhpList\Core\Bounce\Service\Processor\BounceDataProcessor: ~ + PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor: ~ PhpList\Core\Domain\Subscription\Service\SubscribePagePlaceholderProcessor: ~ diff --git a/config/services/resolvers.yml b/config/services/resolvers.yml index baa9d3b98..64c97389c 100644 --- a/config/services/resolvers.yml +++ b/config/services/resolvers.yml @@ -22,7 +22,7 @@ services: autowire: true autoconfigure: true - PhpList\Core\Bounce\Service\BounceActionResolver: + PhpList\Core\Domain\Messaging\Service\BounceActionResolver: arguments: - !tagged_iterator { tag: 'phplist.bounce_action_handler' } diff --git a/config/services/services.yml b/config/services/services.yml index 31fc1a32f..9e527c13c 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -138,7 +138,7 @@ services: autowire: true autoconfigure: true - PhpList\Core\Bounce\Service\ConsecutiveBounceHandler: + PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler: autowire: true autoconfigure: true arguments: @@ -147,7 +147,7 @@ services: Webklex\PHPIMAP\ClientManager: ~ - PhpList\Core\Bounce\Service\WebklexImapClientFactory: + PhpList\Core\Domain\Messaging\Service\WebklexImapClientFactory: autowire: true autoconfigure: true arguments: @@ -164,34 +164,34 @@ services: $username: '%imap_bounce.email%' $password: '%imap_bounce.password%' - PhpList\Core\Bounce\Service\NativeBounceProcessingService: + PhpList\Core\Domain\Messaging\Service\NativeBounceProcessingService: autowire: true autoconfigure: true arguments: $purgeProcessed: '%imap_bounce.purge%' $purgeUnprocessed: '%imap_bounce.purge_unprocessed%' - PhpList\Core\Bounce\Service\WebklexBounceProcessingService: + PhpList\Core\Domain\Messaging\Service\WebklexBounceProcessingService: autowire: true autoconfigure: true arguments: $purgeProcessed: '%imap_bounce.purge%' $purgeUnprocessed: '%imap_bounce.purge_unprocessed%' - PhpList\Core\Bounce\Service\LockService: + PhpList\Core\Domain\Messaging\Service\LockService: autowire: true autoconfigure: true - PhpList\Core\Bounce\Service\SubscriberBlacklistService: + PhpList\Core\Domain\Messaging\Service\SubscriberBlacklistService: autowire: true autoconfigure: true - PhpList\Core\Bounce\Service\MessageParser: + PhpList\Core\Domain\Messaging\Service\MessageParser: autowire: true autoconfigure: true _instanceof: - PhpList\Core\Bounce\Service\Handler\BounceActionHandlerInterface: + PhpList\Core\Domain\Messaging\Service\Handler\BounceActionHandlerInterface: tags: - { name: 'phplist.bounce_action_handler' } diff --git a/src/Core/BounceProcessorPass.php b/src/Core/BounceProcessorPass.php index 6ec27daec..2ab5c9c54 100644 --- a/src/Core/BounceProcessorPass.php +++ b/src/Core/BounceProcessorPass.php @@ -4,9 +4,9 @@ namespace PhpList\Core\Core; -use PhpList\Core\Bounce\Service\BounceProcessingServiceInterface; -use PhpList\Core\Bounce\Service\NativeBounceProcessingService; -use PhpList\Core\Bounce\Service\WebklexBounceProcessingService; +use PhpList\Core\Domain\Messaging\Service\BounceProcessingServiceInterface; +use PhpList\Core\Domain\Messaging\Service\NativeBounceProcessingService; +use PhpList\Core\Domain\Messaging\Service\WebklexBounceProcessingService; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; diff --git a/src/Domain/Common/Exception/AttributeDefinitionCreationException.php b/src/Domain/Common/Exception/AttributeDefinitionCreationException.php index 07c19eb06..af507ccaf 100644 --- a/src/Domain/Common/Exception/AttributeDefinitionCreationException.php +++ b/src/Domain/Common/Exception/AttributeDefinitionCreationException.php @@ -20,4 +20,4 @@ public function getStatusCode(): int { return $this->statusCode; } -} \ No newline at end of file +} diff --git a/src/Domain/Common/Validator/AbstractAttributeTypeValidator.php b/src/Domain/Common/Validator/AbstractAttributeTypeValidator.php index 322c5f8c2..b69827053 100644 --- a/src/Domain/Common/Validator/AbstractAttributeTypeValidator.php +++ b/src/Domain/Common/Validator/AbstractAttributeTypeValidator.php @@ -69,4 +69,4 @@ private function normalizeToEnum(mixed $value): AttributeTypeEnum $this->translator->trans('Value must be an AttributeTypeEnum or string.') ); } -} \ No newline at end of file +} diff --git a/src/Domain/Identity/Validator/AttributeTypeValidator.php b/src/Domain/Identity/Validator/AttributeTypeValidator.php index 1eb3b8a68..177657fa5 100644 --- a/src/Domain/Identity/Validator/AttributeTypeValidator.php +++ b/src/Domain/Identity/Validator/AttributeTypeValidator.php @@ -18,4 +18,4 @@ protected function getValidTypes(): array { return self::VALID_TYPES; } -} \ No newline at end of file +} diff --git a/src/Bounce/Command/ProcessBouncesCommand.php b/src/Domain/Messaging/Command/ProcessBouncesCommand.php similarity index 91% rename from src/Bounce/Command/ProcessBouncesCommand.php rename to src/Domain/Messaging/Command/ProcessBouncesCommand.php index fcb37ba28..52e224696 100644 --- a/src/Bounce/Command/ProcessBouncesCommand.php +++ b/src/Domain/Messaging/Command/ProcessBouncesCommand.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Command; +namespace PhpList\Core\Domain\Messaging\Command; use Doctrine\ORM\EntityManagerInterface; use Exception; -use PhpList\Core\Bounce\Service\ConsecutiveBounceHandler; -use PhpList\Core\Bounce\Service\LockService; -use PhpList\Core\Bounce\Service\Processor\AdvancedBounceRulesProcessor; -use PhpList\Core\Bounce\Service\Processor\BounceProtocolProcessor; -use PhpList\Core\Bounce\Service\Processor\UnidentifiedBounceReprocessor; +use PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler; +use PhpList\Core\Domain\Messaging\Service\LockService; +use PhpList\Core\Domain\Messaging\Service\Processor\AdvancedBounceRulesProcessor; +use PhpList\Core\Domain\Messaging\Service\Processor\BounceProtocolProcessor; +use PhpList\Core\Domain\Messaging\Service\Processor\UnidentifiedBounceReprocessor; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; diff --git a/src/Bounce/Exception/ImapConnectionException.php b/src/Domain/Messaging/Exception/ImapConnectionException.php similarity index 84% rename from src/Bounce/Exception/ImapConnectionException.php rename to src/Domain/Messaging/Exception/ImapConnectionException.php index 58d3495d9..8e5295e25 100644 --- a/src/Bounce/Exception/ImapConnectionException.php +++ b/src/Domain/Messaging/Exception/ImapConnectionException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Exception; +namespace PhpList\Core\Domain\Messaging\Exception; use RuntimeException; use Throwable; diff --git a/src/Bounce/Exception/OpenMboxFileException.php b/src/Domain/Messaging/Exception/OpenMboxFileException.php similarity index 84% rename from src/Bounce/Exception/OpenMboxFileException.php rename to src/Domain/Messaging/Exception/OpenMboxFileException.php index c5dc775f6..2fc7c4585 100644 --- a/src/Bounce/Exception/OpenMboxFileException.php +++ b/src/Domain/Messaging/Exception/OpenMboxFileException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Exception; +namespace PhpList\Core\Domain\Messaging\Exception; use RuntimeException; use Throwable; diff --git a/src/Bounce/Service/BounceActionResolver.php b/src/Domain/Messaging/Service/BounceActionResolver.php similarity index 92% rename from src/Bounce/Service/BounceActionResolver.php rename to src/Domain/Messaging/Service/BounceActionResolver.php index 0359866c0..93d432dd5 100644 --- a/src/Bounce/Service/BounceActionResolver.php +++ b/src/Domain/Messaging/Service/BounceActionResolver.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service; +namespace PhpList\Core\Domain\Messaging\Service; -use PhpList\Core\Bounce\Service\Handler\BounceActionHandlerInterface; +use PhpList\Core\Domain\Messaging\Service\Handler\BounceActionHandlerInterface; use RuntimeException; class BounceActionResolver diff --git a/src/Bounce/Service/BounceProcessingServiceInterface.php b/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php similarity index 77% rename from src/Bounce/Service/BounceProcessingServiceInterface.php rename to src/Domain/Messaging/Service/BounceProcessingServiceInterface.php index 8050a4000..9d16702f4 100644 --- a/src/Bounce/Service/BounceProcessingServiceInterface.php +++ b/src/Domain/Messaging/Service/BounceProcessingServiceInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service; +namespace PhpList\Core\Domain\Messaging\Service; interface BounceProcessingServiceInterface { diff --git a/src/Bounce/Service/ConsecutiveBounceHandler.php b/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php similarity index 97% rename from src/Bounce/Service/ConsecutiveBounceHandler.php rename to src/Domain/Messaging/Service/ConsecutiveBounceHandler.php index 6a2687f22..3f1e34d1f 100644 --- a/src/Bounce/Service/ConsecutiveBounceHandler.php +++ b/src/Domain/Messaging/Service/ConsecutiveBounceHandler.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service; +namespace PhpList\Core\Domain\Messaging\Service; -use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Model\UserMessage; use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; diff --git a/src/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php similarity index 91% rename from src/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php rename to src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php index c4171031c..ddd56a473 100644 --- a/src/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandler.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Handler; +namespace PhpList\Core\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\Manager\BounceManager; -use PhpList\Core\Bounce\Service\SubscriberBlacklistService; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Messaging\Model\BounceAction; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/src/Bounce/Service/Handler/BlacklistEmailHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php similarity index 93% rename from src/Bounce/Service/Handler/BlacklistEmailHandler.php rename to src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php index 95c4f9e19..7a73add71 100644 --- a/src/Bounce/Service/Handler/BlacklistEmailHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistEmailHandler.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Handler; +namespace PhpList\Core\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\SubscriberBlacklistService; +use PhpList\Core\Domain\Messaging\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Messaging\Model\BounceAction; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/src/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php similarity index 91% rename from src/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandler.php rename to src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php index 35d2202ad..e90f8ddfb 100644 --- a/src/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandler.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Handler; +namespace PhpList\Core\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\Manager\BounceManager; -use PhpList\Core\Bounce\Service\SubscriberBlacklistService; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Messaging\Model\BounceAction; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/src/Bounce/Service/Handler/BlacklistUserHandler.php b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php similarity index 93% rename from src/Bounce/Service/Handler/BlacklistUserHandler.php rename to src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php index a3dd9ef4e..b69b467bd 100644 --- a/src/Bounce/Service/Handler/BlacklistUserHandler.php +++ b/src/Domain/Messaging/Service/Handler/BlacklistUserHandler.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Handler; +namespace PhpList\Core\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\SubscriberBlacklistService; +use PhpList\Core\Domain\Messaging\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Messaging\Model\BounceAction; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/src/Bounce/Service/Handler/BounceActionHandlerInterface.php b/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php similarity index 76% rename from src/Bounce/Service/Handler/BounceActionHandlerInterface.php rename to src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php index ce43f7c75..6b90cb493 100644 --- a/src/Bounce/Service/Handler/BounceActionHandlerInterface.php +++ b/src/Domain/Messaging/Service/Handler/BounceActionHandlerInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Handler; +namespace PhpList\Core\Domain\Messaging\Service\Handler; interface BounceActionHandlerInterface { diff --git a/src/Bounce/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php similarity index 94% rename from src/Bounce/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php rename to src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php index 32d129d2d..2eabd11ca 100644 --- a/src/Bounce/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandler.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Handler; +namespace PhpList\Core\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\BounceAction; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; diff --git a/src/Bounce/Service/Handler/DeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php similarity index 82% rename from src/Bounce/Service/Handler/DeleteBounceHandler.php rename to src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php index b3455614a..a7643de72 100644 --- a/src/Bounce/Service/Handler/DeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/DeleteBounceHandler.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Handler; +namespace PhpList\Core\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\BounceAction; class DeleteBounceHandler implements BounceActionHandlerInterface diff --git a/src/Bounce/Service/Handler/DeleteUserAndBounceHandler.php b/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php similarity index 88% rename from src/Bounce/Service/Handler/DeleteUserAndBounceHandler.php rename to src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php index f51dcdca5..8fdc8a264 100644 --- a/src/Bounce/Service/Handler/DeleteUserAndBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandler.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Handler; +namespace PhpList\Core\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PhpList\Core\Domain\Messaging\Model\BounceAction; diff --git a/src/Bounce/Service/Handler/DeleteUserHandler.php b/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php similarity index 94% rename from src/Bounce/Service/Handler/DeleteUserHandler.php rename to src/Domain/Messaging/Service/Handler/DeleteUserHandler.php index ce74894b2..b340596f7 100644 --- a/src/Bounce/Service/Handler/DeleteUserHandler.php +++ b/src/Domain/Messaging/Service/Handler/DeleteUserHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Handler; +namespace PhpList\Core\Domain\Messaging\Service\Handler; use PhpList\Core\Domain\Messaging\Model\BounceAction; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; diff --git a/src/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php similarity index 93% rename from src/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php rename to src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php index 6d191a8f6..59908d23e 100644 --- a/src/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php +++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandler.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Handler; +namespace PhpList\Core\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\BounceAction; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; diff --git a/src/Bounce/Service/Handler/UnconfirmUserHandler.php b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php similarity index 96% rename from src/Bounce/Service/Handler/UnconfirmUserHandler.php rename to src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php index 979bb20cb..b0b6e2a8c 100644 --- a/src/Bounce/Service/Handler/UnconfirmUserHandler.php +++ b/src/Domain/Messaging/Service/Handler/UnconfirmUserHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Handler; +namespace PhpList\Core\Domain\Messaging\Service\Handler; use PhpList\Core\Domain\Messaging\Model\BounceAction; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; diff --git a/src/Bounce/Service/LockService.php b/src/Domain/Messaging/Service/LockService.php similarity index 99% rename from src/Bounce/Service/LockService.php rename to src/Domain/Messaging/Service/LockService.php index a875959c5..f4a47f34e 100644 --- a/src/Bounce/Service/LockService.php +++ b/src/Domain/Messaging/Service/LockService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service; +namespace PhpList\Core\Domain\Messaging\Service; use PhpList\Core\Domain\Messaging\Repository\SendProcessRepository; use PhpList\Core\Domain\Messaging\Service\Manager\SendProcessManager; diff --git a/src/Bounce/Service/Manager/BounceManager.php b/src/Domain/Messaging/Service/Manager/BounceManager.php similarity index 98% rename from src/Bounce/Service/Manager/BounceManager.php rename to src/Domain/Messaging/Service/Manager/BounceManager.php index 868aae10f..bae5e0942 100644 --- a/src/Bounce/Service/Manager/BounceManager.php +++ b/src/Domain/Messaging/Service/Manager/BounceManager.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Manager; +namespace PhpList\Core\Domain\Messaging\Service\Manager; use DateTime; use DateTimeImmutable; diff --git a/src/Bounce/Service/MessageParser.php b/src/Domain/Messaging/Service/MessageParser.php similarity index 98% rename from src/Bounce/Service/MessageParser.php rename to src/Domain/Messaging/Service/MessageParser.php index 336cbe024..14b4f952f 100644 --- a/src/Bounce/Service/MessageParser.php +++ b/src/Domain/Messaging/Service/MessageParser.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service; +namespace PhpList\Core\Domain\Messaging\Service; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; diff --git a/src/Bounce/Service/NativeBounceProcessingService.php b/src/Domain/Messaging/Service/NativeBounceProcessingService.php similarity index 94% rename from src/Bounce/Service/NativeBounceProcessingService.php rename to src/Domain/Messaging/Service/NativeBounceProcessingService.php index 887aa94de..b58f771ad 100644 --- a/src/Bounce/Service/NativeBounceProcessingService.php +++ b/src/Domain/Messaging/Service/NativeBounceProcessingService.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service; +namespace PhpList\Core\Domain\Messaging\Service; use Doctrine\ORM\EntityManagerInterface; use IMAP\Connection; -use PhpList\Core\Bounce\Exception\OpenMboxFileException; -use PhpList\Core\Bounce\Service\Manager\BounceManager; -use PhpList\Core\Bounce\Service\Processor\BounceDataProcessor; +use PhpList\Core\Domain\Messaging\Exception\OpenMboxFileException; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor; use PhpList\Core\Domain\Common\Mail\NativeImapMailReader; use Psr\Log\LoggerInterface; use Throwable; diff --git a/src/Bounce/Service/Processor/AdvancedBounceRulesProcessor.php b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php similarity index 95% rename from src/Bounce/Service/Processor/AdvancedBounceRulesProcessor.php rename to src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php index 3d6fb116a..1b7038322 100644 --- a/src/Bounce/Service/Processor/AdvancedBounceRulesProcessor.php +++ b/src/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessor.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Processor; +namespace PhpList\Core\Domain\Messaging\Service\Processor; -use PhpList\Core\Bounce\Service\BounceActionResolver; -use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\BounceActionResolver; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager; use PhpList\Core\Domain\Subscription\Model\Subscriber; diff --git a/src/Bounce/Service/Processor/BounceDataProcessor.php b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php similarity index 98% rename from src/Bounce/Service/Processor/BounceDataProcessor.php rename to src/Domain/Messaging/Service/Processor/BounceDataProcessor.php index d40707b3b..3ddff5a5e 100644 --- a/src/Bounce/Service/Processor/BounceDataProcessor.php +++ b/src/Domain/Messaging/Service/Processor/BounceDataProcessor.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Processor; +namespace PhpList\Core\Domain\Messaging\Service\Processor; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; -use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Model\BounceStatus; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; diff --git a/src/Bounce/Service/Processor/BounceProtocolProcessor.php b/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php similarity index 91% rename from src/Bounce/Service/Processor/BounceProtocolProcessor.php rename to src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php index 6bb77a49d..a0e7d904e 100644 --- a/src/Bounce/Service/Processor/BounceProtocolProcessor.php +++ b/src/Domain/Messaging/Service/Processor/BounceProtocolProcessor.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Processor; +namespace PhpList\Core\Domain\Messaging\Service\Processor; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; diff --git a/src/Bounce/Service/Processor/MboxBounceProcessor.php b/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php similarity index 91% rename from src/Bounce/Service/Processor/MboxBounceProcessor.php rename to src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php index b3f8c79bd..d61742d58 100644 --- a/src/Bounce/Service/Processor/MboxBounceProcessor.php +++ b/src/Domain/Messaging/Service/Processor/MboxBounceProcessor.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Processor; +namespace PhpList\Core\Domain\Messaging\Service\Processor; -use PhpList\Core\Bounce\Service\BounceProcessingServiceInterface; +use PhpList\Core\Domain\Messaging\Service\BounceProcessingServiceInterface; use RuntimeException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; diff --git a/src/Bounce/Service/Processor/PopBounceProcessor.php b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php similarity index 93% rename from src/Bounce/Service/Processor/PopBounceProcessor.php rename to src/Domain/Messaging/Service/Processor/PopBounceProcessor.php index 9ebb26c48..b00797749 100644 --- a/src/Bounce/Service/Processor/PopBounceProcessor.php +++ b/src/Domain/Messaging/Service/Processor/PopBounceProcessor.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Processor; +namespace PhpList\Core\Domain\Messaging\Service\Processor; -use PhpList\Core\Bounce\Service\BounceProcessingServiceInterface; +use PhpList\Core\Domain\Messaging\Service\BounceProcessingServiceInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/src/Bounce/Service/Processor/UnidentifiedBounceReprocessor.php b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php similarity index 93% rename from src/Bounce/Service/Processor/UnidentifiedBounceReprocessor.php rename to src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php index 416684b2d..b37054871 100644 --- a/src/Bounce/Service/Processor/UnidentifiedBounceReprocessor.php +++ b/src/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessor.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service\Processor; +namespace PhpList\Core\Domain\Messaging\Service\Processor; use DateTimeImmutable; -use PhpList\Core\Bounce\Service\Manager\BounceManager; -use PhpList\Core\Bounce\Service\MessageParser; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\MessageParser; use PhpList\Core\Domain\Messaging\Model\BounceStatus; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/src/Bounce/Service/SubscriberBlacklistService.php b/src/Domain/Messaging/Service/SubscriberBlacklistService.php similarity index 98% rename from src/Bounce/Service/SubscriberBlacklistService.php rename to src/Domain/Messaging/Service/SubscriberBlacklistService.php index 03155587c..af8c75521 100644 --- a/src/Bounce/Service/SubscriberBlacklistService.php +++ b/src/Domain/Messaging/Service/SubscriberBlacklistService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service; +namespace PhpList\Core\Domain\Messaging\Service; use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Subscription\Model\Subscriber; diff --git a/src/Bounce/Service/WebklexBounceProcessingService.php b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php similarity index 97% rename from src/Bounce/Service/WebklexBounceProcessingService.php rename to src/Domain/Messaging/Service/WebklexBounceProcessingService.php index c09f30fdd..c489585cd 100644 --- a/src/Bounce/Service/WebklexBounceProcessingService.php +++ b/src/Domain/Messaging/Service/WebklexBounceProcessingService.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service; +namespace PhpList\Core\Domain\Messaging\Service; use DateTimeImmutable; use DateTimeInterface; -use PhpList\Core\Bounce\Exception\ImapConnectionException; -use PhpList\Core\Bounce\Service\Manager\BounceManager; -use PhpList\Core\Bounce\Service\Processor\BounceDataProcessor; +use PhpList\Core\Domain\Messaging\Exception\ImapConnectionException; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor; use Psr\Log\LoggerInterface; use Throwable; use Webklex\PHPIMAP\Client; diff --git a/src/Bounce/Service/WebklexImapClientFactory.php b/src/Domain/Messaging/Service/WebklexImapClientFactory.php similarity index 97% rename from src/Bounce/Service/WebklexImapClientFactory.php rename to src/Domain/Messaging/Service/WebklexImapClientFactory.php index 48fc26bc9..10271e4c1 100644 --- a/src/Bounce/Service/WebklexImapClientFactory.php +++ b/src/Domain/Messaging/Service/WebklexImapClientFactory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Bounce\Service; +namespace PhpList\Core\Domain\Messaging\Service; use Webklex\PHPIMAP\Client; use Webklex\PHPIMAP\ClientManager; diff --git a/src/Domain/Subscription/Validator/AttributeTypeValidator.php b/src/Domain/Subscription/Validator/AttributeTypeValidator.php index fcf686299..632db9211 100644 --- a/src/Domain/Subscription/Validator/AttributeTypeValidator.php +++ b/src/Domain/Subscription/Validator/AttributeTypeValidator.php @@ -25,4 +25,4 @@ protected function getValidTypes(): array { return self::VALID_TYPES; } -} \ No newline at end of file +} diff --git a/tests/Unit/Bounce/Command/ProcessBouncesCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php similarity index 95% rename from tests/Unit/Bounce/Command/ProcessBouncesCommandTest.php rename to tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php index 4ab6d5569..130a258fa 100644 --- a/tests/Unit/Bounce/Command/ProcessBouncesCommandTest.php +++ b/tests/Unit/Domain/Messaging/Command/ProcessBouncesCommandTest.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Bounce\Command; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Command; use Doctrine\ORM\EntityManagerInterface; use Exception; -use PhpList\Core\Bounce\Command\ProcessBouncesCommand; -use PhpList\Core\Bounce\Service\ConsecutiveBounceHandler; -use PhpList\Core\Bounce\Service\LockService; -use PhpList\Core\Bounce\Service\Processor\AdvancedBounceRulesProcessor; -use PhpList\Core\Bounce\Service\Processor\BounceProtocolProcessor; -use PhpList\Core\Bounce\Service\Processor\UnidentifiedBounceReprocessor; +use PhpList\Core\Domain\Messaging\Command\ProcessBouncesCommand; +use PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler; +use PhpList\Core\Domain\Messaging\Service\LockService; +use PhpList\Core\Domain\Messaging\Service\Processor\AdvancedBounceRulesProcessor; +use PhpList\Core\Domain\Messaging\Service\Processor\BounceProtocolProcessor; +use PhpList\Core\Domain\Messaging\Service\Processor\UnidentifiedBounceReprocessor; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; diff --git a/tests/Unit/Bounce/Service/BounceActionResolverTest.php b/tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php similarity index 91% rename from tests/Unit/Bounce/Service/BounceActionResolverTest.php rename to tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php index 92b1054d7..49d4aadbd 100644 --- a/tests/Unit/Bounce/Service/BounceActionResolverTest.php +++ b/tests/Unit/Domain/Messaging/Service/BounceActionResolverTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Bounce\Service; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; -use PhpList\Core\Bounce\Service\BounceActionResolver; -use PhpList\Core\Bounce\Service\Handler\BounceActionHandlerInterface; +use PhpList\Core\Domain\Messaging\Service\BounceActionResolver; +use PhpList\Core\Domain\Messaging\Service\Handler\BounceActionHandlerInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use RuntimeException; diff --git a/tests/Unit/Bounce/Service/ConsecutiveBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php similarity index 96% rename from tests/Unit/Bounce/Service/ConsecutiveBounceHandlerTest.php rename to tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php index fbfdfa8a4..55825ea9e 100644 --- a/tests/Unit/Bounce/Service/ConsecutiveBounceHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/ConsecutiveBounceHandlerTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Bounce\Service; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; -use PhpList\Core\Bounce\Service\ConsecutiveBounceHandler; -use PhpList\Core\Bounce\Service\Manager\BounceManager; -use PhpList\Core\Bounce\Service\SubscriberBlacklistService; +use PhpList\Core\Domain\Messaging\Service\ConsecutiveBounceHandler; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; diff --git a/tests/Unit/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php similarity index 90% rename from tests/Unit/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php rename to tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php index c7c2260d8..03ec37799 100644 --- a/tests/Unit/Bounce/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailAndDeleteBounceHandlerTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\Handler\BlacklistEmailAndDeleteBounceHandler; -use PhpList\Core\Bounce\Service\Manager\BounceManager; -use PhpList\Core\Bounce\Service\SubscriberBlacklistService; +use PhpList\Core\Domain\Messaging\Service\Handler\BlacklistEmailAndDeleteBounceHandler; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; diff --git a/tests/Unit/Bounce/Service/Handler/BlacklistEmailHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php similarity index 91% rename from tests/Unit/Bounce/Service/Handler/BlacklistEmailHandlerTest.php rename to tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php index b5b06e598..c465b10ea 100644 --- a/tests/Unit/Bounce/Service/Handler/BlacklistEmailHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistEmailHandlerTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\Handler\BlacklistEmailHandler; -use PhpList\Core\Bounce\Service\SubscriberBlacklistService; +use PhpList\Core\Domain\Messaging\Service\Handler\BlacklistEmailHandler; +use PhpList\Core\Domain\Messaging\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PHPUnit\Framework\MockObject\MockObject; diff --git a/tests/Unit/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php similarity index 92% rename from tests/Unit/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php rename to tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php index e2975d377..9de898616 100644 --- a/tests/Unit/Bounce/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserAndDeleteBounceHandlerTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\Handler\BlacklistUserAndDeleteBounceHandler; -use PhpList\Core\Bounce\Service\Manager\BounceManager; -use PhpList\Core\Bounce\Service\SubscriberBlacklistService; +use PhpList\Core\Domain\Messaging\Service\Handler\BlacklistUserAndDeleteBounceHandler; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; diff --git a/tests/Unit/Bounce/Service/Handler/BlacklistUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php similarity index 92% rename from tests/Unit/Bounce/Service/Handler/BlacklistUserHandlerTest.php rename to tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php index 153faa1c2..511448687 100644 --- a/tests/Unit/Bounce/Service/Handler/BlacklistUserHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/BlacklistUserHandlerTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\Handler\BlacklistUserHandler; -use PhpList\Core\Bounce\Service\SubscriberBlacklistService; +use PhpList\Core\Domain\Messaging\Service\Handler\BlacklistUserHandler; +use PhpList\Core\Domain\Messaging\Service\SubscriberBlacklistService; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PHPUnit\Framework\MockObject\MockObject; diff --git a/tests/Unit/Bounce/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php similarity index 94% rename from tests/Unit/Bounce/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php rename to tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php index 9625d348e..ce7fdc16b 100644 --- a/tests/Unit/Bounce/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/DecreaseCountConfirmUserAndDeleteBounceHandlerTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\Handler\DecreaseCountConfirmUserAndDeleteBounceHandler; -use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\Handler\DecreaseCountConfirmUserAndDeleteBounceHandler; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; diff --git a/tests/Unit/Bounce/Service/Handler/DeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php similarity index 83% rename from tests/Unit/Bounce/Service/Handler/DeleteBounceHandlerTest.php rename to tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php index 1455ab83a..a87ba785c 100644 --- a/tests/Unit/Bounce/Service/Handler/DeleteBounceHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/DeleteBounceHandlerTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\Handler\DeleteBounceHandler; -use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\Handler\DeleteBounceHandler; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\Bounce; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Bounce/Service/Handler/DeleteUserAndBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php similarity index 91% rename from tests/Unit/Bounce/Service/Handler/DeleteUserAndBounceHandlerTest.php rename to tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php index 768efd0c4..f5974fb6e 100644 --- a/tests/Unit/Bounce/Service/Handler/DeleteUserAndBounceHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserAndBounceHandlerTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\Handler\DeleteUserAndBounceHandler; -use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\Handler\DeleteUserAndBounceHandler; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; diff --git a/tests/Unit/Bounce/Service/Handler/DeleteUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php similarity index 94% rename from tests/Unit/Bounce/Service/Handler/DeleteUserHandlerTest.php rename to tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php index af61b8d5d..427f81468 100644 --- a/tests/Unit/Bounce/Service/Handler/DeleteUserHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/DeleteUserHandlerTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\Handler\DeleteUserHandler; +use PhpList\Core\Domain\Messaging\Service\Handler\DeleteUserHandler; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PHPUnit\Framework\MockObject\MockObject; diff --git a/tests/Unit/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php similarity index 93% rename from tests/Unit/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php rename to tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php index 92b146fbc..f6acbee13 100644 --- a/tests/Unit/Bounce/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserAndDeleteBounceHandlerTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\Handler\UnconfirmUserAndDeleteBounceHandler; -use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\Handler\UnconfirmUserAndDeleteBounceHandler; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; diff --git a/tests/Unit/Bounce/Service/Handler/UnconfirmUserHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php similarity index 94% rename from tests/Unit/Bounce/Service/Handler/UnconfirmUserHandlerTest.php rename to tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php index dcc0c0d86..fbbc265a2 100644 --- a/tests/Unit/Bounce/Service/Handler/UnconfirmUserHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/UnconfirmUserHandlerTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Bounce\Service\Handler; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Handler; -use PhpList\Core\Bounce\Service\Handler\UnconfirmUserHandler; +use PhpList\Core\Domain\Messaging\Service\Handler\UnconfirmUserHandler; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; diff --git a/tests/Unit/Bounce/Service/LockServiceTest.php b/tests/Unit/Domain/Messaging/Service/LockServiceTest.php similarity index 96% rename from tests/Unit/Bounce/Service/LockServiceTest.php rename to tests/Unit/Domain/Messaging/Service/LockServiceTest.php index 8577ef5f4..b9cb9c299 100644 --- a/tests/Unit/Bounce/Service/LockServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/LockServiceTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Bounce\Service; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; -use PhpList\Core\Bounce\Service\LockService; +use PhpList\Core\Domain\Messaging\Service\LockService; use PhpList\Core\Domain\Messaging\Model\SendProcess; use PhpList\Core\Domain\Messaging\Repository\SendProcessRepository; use PhpList\Core\Domain\Messaging\Service\Manager\SendProcessManager; diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php index 445dd240c..3a07b0a02 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php @@ -6,7 +6,7 @@ use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; -use PhpList\Core\Bounce\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; use PhpList\Core\Domain\Messaging\Repository\BounceRepository; diff --git a/tests/Unit/Bounce/Service/MessageParserTest.php b/tests/Unit/Domain/Messaging/Service/MessageParserTest.php similarity index 95% rename from tests/Unit/Bounce/Service/MessageParserTest.php rename to tests/Unit/Domain/Messaging/Service/MessageParserTest.php index 35e607067..49b38615e 100644 --- a/tests/Unit/Bounce/Service/MessageParserTest.php +++ b/tests/Unit/Domain/Messaging/Service/MessageParserTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Bounce\Service; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; -use PhpList\Core\Bounce\Service\MessageParser; +use PhpList\Core\Domain\Messaging\Service\MessageParser; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PHPUnit\Framework\MockObject\MockObject; diff --git a/tests/Unit/Processor/AdvancedBounceRulesProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php similarity index 96% rename from tests/Unit/Processor/AdvancedBounceRulesProcessorTest.php rename to tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php index 7a57980d4..91737353e 100644 --- a/tests/Unit/Processor/AdvancedBounceRulesProcessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/AdvancedBounceRulesProcessorTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Processor; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Processor; -use PhpList\Core\Bounce\Service\BounceActionResolver; -use PhpList\Core\Bounce\Service\Manager\BounceManager; -use PhpList\Core\Bounce\Service\Processor\AdvancedBounceRulesProcessor; +use PhpList\Core\Domain\Messaging\Service\BounceActionResolver; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\Processor\AdvancedBounceRulesProcessor; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Model\BounceRegex; use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; diff --git a/tests/Unit/Processor/BounceDataProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php similarity index 97% rename from tests/Unit/Processor/BounceDataProcessorTest.php rename to tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php index d5d901c04..74d17e17e 100644 --- a/tests/Unit/Processor/BounceDataProcessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/BounceDataProcessorTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Processor; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Processor; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; -use PhpList\Core\Bounce\Service\Manager\BounceManager; -use PhpList\Core\Bounce\Service\Processor\BounceDataProcessor; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor; use PhpList\Core\Domain\Messaging\Model\Bounce; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Subscription\Model\Subscriber; diff --git a/tests/Unit/Processor/MboxBounceProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php similarity index 92% rename from tests/Unit/Processor/MboxBounceProcessorTest.php rename to tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php index a67235dd6..9bf1c92f3 100644 --- a/tests/Unit/Processor/MboxBounceProcessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/MboxBounceProcessorTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Processor; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Processor; -use PhpList\Core\Bounce\Service\BounceProcessingServiceInterface; -use PhpList\Core\Bounce\Service\Processor\MboxBounceProcessor; +use PhpList\Core\Domain\Messaging\Service\BounceProcessingServiceInterface; +use PhpList\Core\Domain\Messaging\Service\Processor\MboxBounceProcessor; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use RuntimeException; diff --git a/tests/Unit/Processor/PopBounceProcessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php similarity index 91% rename from tests/Unit/Processor/PopBounceProcessorTest.php rename to tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php index d218edd0c..d0141386a 100644 --- a/tests/Unit/Processor/PopBounceProcessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/PopBounceProcessorTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Processor; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Processor; -use PhpList\Core\Bounce\Service\BounceProcessingServiceInterface; -use PhpList\Core\Bounce\Service\Processor\PopBounceProcessor; +use PhpList\Core\Domain\Messaging\Service\BounceProcessingServiceInterface; +use PhpList\Core\Domain\Messaging\Service\Processor\PopBounceProcessor; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Input\InputInterface; diff --git a/tests/Unit/Processor/UnidentifiedBounceReprocessorTest.php b/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php similarity index 89% rename from tests/Unit/Processor/UnidentifiedBounceReprocessorTest.php rename to tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php index 0e2d2254a..3c740be92 100644 --- a/tests/Unit/Processor/UnidentifiedBounceReprocessorTest.php +++ b/tests/Unit/Domain/Messaging/Service/Processor/UnidentifiedBounceReprocessorTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Processor; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Processor; use DateTimeImmutable; -use PhpList\Core\Bounce\Service\Manager\BounceManager; -use PhpList\Core\Bounce\Service\MessageParser; -use PhpList\Core\Bounce\Service\Processor\BounceDataProcessor; -use PhpList\Core\Bounce\Service\Processor\UnidentifiedBounceReprocessor; +use PhpList\Core\Domain\Messaging\Service\Manager\BounceManager; +use PhpList\Core\Domain\Messaging\Service\MessageParser; +use PhpList\Core\Domain\Messaging\Service\Processor\BounceDataProcessor; +use PhpList\Core\Domain\Messaging\Service\Processor\UnidentifiedBounceReprocessor; use PhpList\Core\Domain\Messaging\Model\Bounce; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Bounce/Service/WebklexImapClientFactoryTest.php b/tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php similarity index 94% rename from tests/Unit/Bounce/Service/WebklexImapClientFactoryTest.php rename to tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php index c2b536cd0..ca792e8fd 100644 --- a/tests/Unit/Bounce/Service/WebklexImapClientFactoryTest.php +++ b/tests/Unit/Domain/Messaging/Service/WebklexImapClientFactoryTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Bounce\Service; +namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; -use PhpList\Core\Bounce\Service\WebklexImapClientFactory; +use PhpList\Core\Domain\Messaging\Service\WebklexImapClientFactory; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Webklex\PHPIMAP\Client; From b09dc86896f6cccb93a2dbeb15aa2e6652111d8c Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 26 Aug 2026 10:59:25 +0400 Subject: [PATCH 28/39] refactor: move HashGenerator and Authentication classes to Domain\Identity\Service namespace --- config/services.yml | 4 ++-- config/services/repositories.yml | 2 +- src/Domain/Identity/Repository/AdministratorRepository.php | 2 +- src/{Security => Domain/Identity/Service}/Authentication.php | 2 +- src/{Security => Domain/Identity/Service}/HashGenerator.php | 2 +- src/Domain/Identity/Service/Manager/AdministratorManager.php | 2 +- src/Domain/Identity/Service/Manager/PasswordManager.php | 2 +- .../Identity/Service}/AuthenticationTest.php | 4 ++-- .../Identity/Service}/HashGeneratorTest.php | 4 ++-- .../Unit/Domain/Identity/Service/AdministratorManagerTest.php | 2 +- .../Identity/Service}/AuthenticationTest.php | 4 ++-- .../Identity/Service}/HashGeneratorTest.php | 4 ++-- tests/Unit/Domain/Identity/Service/PasswordManagerTest.php | 2 +- 13 files changed, 18 insertions(+), 18 deletions(-) rename src/{Security => Domain/Identity/Service}/Authentication.php (97%) rename src/{Security => Domain/Identity/Service}/HashGenerator.php (96%) rename tests/Integration/{Security => Domain/Identity/Service}/AuthenticationTest.php (96%) rename tests/Integration/{Security => Domain/Identity/Service}/HashGeneratorTest.php (89%) rename tests/Unit/{Security => Domain/Identity/Service}/AuthenticationTest.php (96%) rename tests/Unit/{Security => Domain/Identity/Service}/HashGeneratorTest.php (95%) diff --git a/config/services.yml b/config/services.yml index 1fcc3b351..31a2b6f1f 100644 --- a/config/services.yml +++ b/config/services.yml @@ -10,10 +10,10 @@ services: PhpList\Core\Core\ApplicationStructure: public: true - PhpList\Core\Security\Authentication: + PhpList\Core\Domain\Identity\Service\Authentication: public: true - PhpList\Core\Security\HashGenerator: + PhpList\Core\Domain\Identity\Service\HashGenerator: public: true PhpList\Core\Routing\ExtraLoader: diff --git a/config/services/repositories.yml b/config/services/repositories.yml index 37b31c18d..a0650b353 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -33,7 +33,7 @@ services: arguments: - PhpList\Core\Domain\Identity\Model\Administrator - Doctrine\ORM\Mapping\ClassMetadata\ClassMetadata - - PhpList\Core\Security\HashGenerator + - PhpList\Core\Domain\Identity\Service\HashGenerator PhpList\Core\Domain\Identity\Repository\AdminAttributeValueRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: diff --git a/src/Domain/Identity/Repository/AdministratorRepository.php b/src/Domain/Identity/Repository/AdministratorRepository.php index 0bdae5b64..5973eb253 100644 --- a/src/Domain/Identity/Repository/AdministratorRepository.php +++ b/src/Domain/Identity/Repository/AdministratorRepository.php @@ -10,7 +10,7 @@ use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; use PhpList\Core\Domain\Identity\Model\Administrator; -use PhpList\Core\Security\HashGenerator; +use PhpList\Core\Domain\Identity\Service\HashGenerator; /** * Repository for Administrator models. diff --git a/src/Security/Authentication.php b/src/Domain/Identity/Service/Authentication.php similarity index 97% rename from src/Security/Authentication.php rename to src/Domain/Identity/Service/Authentication.php index bb744f0e4..152fb1c0b 100644 --- a/src/Security/Authentication.php +++ b/src/Domain/Identity/Service/Authentication.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Security; +namespace PhpList\Core\Domain\Identity\Service; use Doctrine\ORM\EntityNotFoundException; use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; diff --git a/src/Security/HashGenerator.php b/src/Domain/Identity/Service/HashGenerator.php similarity index 96% rename from src/Security/HashGenerator.php rename to src/Domain/Identity/Service/HashGenerator.php index 67ab3054e..f104da6a8 100644 --- a/src/Security/HashGenerator.php +++ b/src/Domain/Identity/Service/HashGenerator.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Security; +namespace PhpList\Core\Domain\Identity\Service; /** * This class provides functions for working with secure hashes. diff --git a/src/Domain/Identity/Service/Manager/AdministratorManager.php b/src/Domain/Identity/Service/Manager/AdministratorManager.php index 940eaa425..a318556dc 100644 --- a/src/Domain/Identity/Service/Manager/AdministratorManager.php +++ b/src/Domain/Identity/Service/Manager/AdministratorManager.php @@ -8,7 +8,7 @@ use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Model\Dto\CreateAdministratorDto; use PhpList\Core\Domain\Identity\Model\Dto\UpdateAdministratorDto; -use PhpList\Core\Security\HashGenerator; +use PhpList\Core\Domain\Identity\Service\HashGenerator; class AdministratorManager { diff --git a/src/Domain/Identity/Service/Manager/PasswordManager.php b/src/Domain/Identity/Service/Manager/PasswordManager.php index 01f9bb7d3..28b5bbbe2 100644 --- a/src/Domain/Identity/Service/Manager/PasswordManager.php +++ b/src/Domain/Identity/Service/Manager/PasswordManager.php @@ -10,7 +10,7 @@ use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; use PhpList\Core\Domain\Messaging\Message\PasswordResetMessage; -use PhpList\Core\Security\HashGenerator; +use PhpList\Core\Domain\Identity\Service\HashGenerator; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/tests/Integration/Security/AuthenticationTest.php b/tests/Integration/Domain/Identity/Service/AuthenticationTest.php similarity index 96% rename from tests/Integration/Security/AuthenticationTest.php rename to tests/Integration/Domain/Identity/Service/AuthenticationTest.php index 8b1d2d0e6..55733e1dd 100644 --- a/tests/Integration/Security/AuthenticationTest.php +++ b/tests/Integration/Domain/Identity/Service/AuthenticationTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Integration\Security; +namespace PhpList\Core\Tests\Integration\Domain\Identity\Service; use Doctrine\ORM\Tools\SchemaTool; use PhpList\Core\Domain\Identity\Model\Administrator; -use PhpList\Core\Security\Authentication; +use PhpList\Core\Domain\Identity\Service\Authentication; use PhpList\Core\TestingSupport\Traits\DatabaseTestTrait; use PhpList\Core\Tests\Integration\Domain\Identity\Fixtures\AdministratorFixture; use PhpList\Core\Tests\Integration\Domain\Identity\Fixtures\AdministratorTokenWithAdministratorFixture; diff --git a/tests/Integration/Security/HashGeneratorTest.php b/tests/Integration/Domain/Identity/Service/HashGeneratorTest.php similarity index 89% rename from tests/Integration/Security/HashGeneratorTest.php rename to tests/Integration/Domain/Identity/Service/HashGeneratorTest.php index cc0d810e8..9ceb9b951 100644 --- a/tests/Integration/Security/HashGeneratorTest.php +++ b/tests/Integration/Domain/Identity/Service/HashGeneratorTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Integration\Security; +namespace PhpList\Core\Tests\Integration\Domain\Identity\Service; use Doctrine\ORM\Tools\SchemaTool; -use PhpList\Core\Security\HashGenerator; +use PhpList\Core\Domain\Identity\Service\HashGenerator; use PhpList\Core\TestingSupport\Traits\DatabaseTestTrait; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; diff --git a/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php b/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php index 94eecd08e..22534b495 100644 --- a/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/AdministratorManagerTest.php @@ -9,7 +9,7 @@ use PhpList\Core\Domain\Identity\Model\Dto\CreateAdministratorDto; use PhpList\Core\Domain\Identity\Model\Dto\UpdateAdministratorDto; use PhpList\Core\Domain\Identity\Service\Manager\AdministratorManager; -use PhpList\Core\Security\HashGenerator; +use PhpList\Core\Domain\Identity\Service\HashGenerator; use PHPUnit\Framework\TestCase; class AdministratorManagerTest extends TestCase diff --git a/tests/Unit/Security/AuthenticationTest.php b/tests/Unit/Domain/Identity/Service/AuthenticationTest.php similarity index 96% rename from tests/Unit/Security/AuthenticationTest.php rename to tests/Unit/Domain/Identity/Service/AuthenticationTest.php index 58f75f616..79dd3a83c 100644 --- a/tests/Unit/Security/AuthenticationTest.php +++ b/tests/Unit/Domain/Identity/Service/AuthenticationTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Security; +namespace PhpList\Core\Tests\Unit\Domain\Identity\Service; use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Model\AdministratorToken; -use PhpList\Core\Security\Authentication; +use PhpList\Core\Domain\Identity\Service\Authentication; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; diff --git a/tests/Unit/Security/HashGeneratorTest.php b/tests/Unit/Domain/Identity/Service/HashGeneratorTest.php similarity index 95% rename from tests/Unit/Security/HashGeneratorTest.php rename to tests/Unit/Domain/Identity/Service/HashGeneratorTest.php index 86aac803a..480afca37 100644 --- a/tests/Unit/Security/HashGeneratorTest.php +++ b/tests/Unit/Domain/Identity/Service/HashGeneratorTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpList\Core\Tests\Unit\Security; +namespace PhpList\Core\Tests\Unit\Domain\Identity\Service; -use PhpList\Core\Security\HashGenerator; +use PhpList\Core\Domain\Identity\Service\HashGenerator; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php index 72f884af8..d212ca1ec 100644 --- a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php @@ -11,7 +11,7 @@ use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; use PhpList\Core\Domain\Identity\Service\Manager\PasswordManager; use PhpList\Core\Domain\Messaging\Message\PasswordResetMessage; -use PhpList\Core\Security\HashGenerator; +use PhpList\Core\Domain\Identity\Service\HashGenerator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; From 7d4a88e334e1315493f0f9de702b0aa0cac51ce4 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 26 Aug 2026 11:30:50 +0400 Subject: [PATCH 29/39] refactor: update SubscriberList properties to use nullable types --- .../Subscription/Model/SubscriberList.php | 17 ++++++++--------- .../Messaging/Model/SubscriberListTest.php | 8 ++++---- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/Domain/Subscription/Model/SubscriberList.php b/src/Domain/Subscription/Model/SubscriberList.php index 96b396c49..d62bd8720 100644 --- a/src/Domain/Subscription/Model/SubscriberList.php +++ b/src/Domain/Subscription/Model/SubscriberList.php @@ -42,8 +42,8 @@ class SubscriberList implements DomainModel, Identity, CreationDate, Modificatio #[ORM\Column(name: 'rssfeed', type: 'string', length: 255, nullable: true)] private ?string $rssFeed = null; - #[ORM\Column] - private string $description = ''; + #[ORM\Column(nullable: true)] + private ?string $description = null; #[ORM\Column(name: 'entered', type: 'datetime', nullable: true)] protected ?DateTime $createdAt = null; @@ -60,8 +60,8 @@ class SubscriberList implements DomainModel, Identity, CreationDate, Modificatio #[ORM\Column(name: 'active', type: 'boolean')] private bool $public; - #[ORM\Column] - private string $category = ''; + #[ORM\Column(nullable: true)] + private ?string $category = null; #[ORM\ManyToOne(targetEntity: Administrator::class, inversedBy: 'ownedLists')] #[ORM\JoinColumn(name: 'owner')] @@ -93,7 +93,6 @@ public function __construct() $this->updatedAt = new DateTime(); $this->listPosition = 0; $this->subjectPrefix = ''; - $this->category = ''; $this->public = false; } @@ -124,14 +123,14 @@ 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 { - $this->description = $description ?? ''; + $this->description = $description; return $this; } @@ -170,14 +169,14 @@ public function setPublic(bool $public): self return $this; } - public function getCategory(): string + public function getCategory(): ?string { return $this->category; } public function setCategory(?string $category): self { - $this->category = $category ?? ''; + $this->category = $category; return $this; } diff --git a/tests/Unit/Domain/Messaging/Model/SubscriberListTest.php b/tests/Unit/Domain/Messaging/Model/SubscriberListTest.php index 2eb094707..b3eeecaac 100644 --- a/tests/Unit/Domain/Messaging/Model/SubscriberListTest.php +++ b/tests/Unit/Domain/Messaging/Model/SubscriberListTest.php @@ -77,9 +77,9 @@ public function testSetNameSetsName(): void self::assertSame($value, $this->subscriberList->getName()); } - public function testGetDescriptionInitiallyReturnsEmptyString(): void + public function testGetDescriptionInitiallyReturnsNull(): void { - self::assertSame('', $this->subscriberList->getDescription()); + self::assertSame(null, $this->subscriberList->getDescription()); } public function testSetDescriptionSetsDescription(): void @@ -128,9 +128,9 @@ public function testSetPublicSetsPublic(): void self::assertTrue($this->subscriberList->isPublic()); } - public function testGetCategoryInitiallyReturnsEmptyString(): void + public function testGetCategoryInitiallyReturnsNull(): void { - self::assertSame('', $this->subscriberList->getCategory()); + self::assertSame(null, $this->subscriberList->getCategory()); } public function testSetCategorySetsCategory(): void From 8588d8f776c7cdb7fedaa4b57259813d43bc7225 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 27 Aug 2026 11:07:22 +0400 Subject: [PATCH 30/39] refactor: create migration Version20260827065637 for database schema updates --- src/Migrations/Version20260827065637.php | 109 +++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/Migrations/Version20260827065637.php diff --git a/src/Migrations/Version20260827065637.php b/src/Migrations/Version20260827065637.php new file mode 100644 index 000000000..aa2daa7e9 --- /dev/null +++ b/src/Migrations/Version20260827065637.php @@ -0,0 +1,109 @@ +connection->getDatabasePlatform(); + $this->skipIf(!$platform instanceof MySQLPlatform, sprintf( + 'Unsupported platform for this migration: %s', + get_class($platform) + )); + + $this->addSql('DROP INDEX loginname ON phplist_admin'); + $this->addSql('DROP INDEX phplist_admin_loginnameidx ON phplist_admin'); + $this->addSql('DELETE FROM phplist_admin WHERE loginname IS NULL'); + $this->addSql('UPDATE phplist_admin SET email = COALESCE(email, \'\')'); + $this->addSql('ALTER TABLE phplist_admin CHANGE loginname loginname VARCHAR(66) NOT NULL, CHANGE email email VARCHAR(255) NOT NULL, CHANGE modifiedby modifiedby VARCHAR(66) DEFAULT NULL'); + $this->addSql('CREATE UNIQUE INDEX phplist_admin_loginnameidx ON phplist_admin (loginname)'); + $this->addSql('DELETE FROM phplist_adminattribute WHERE name IS NULL'); + $this->addSql('ALTER TABLE phplist_adminattribute CHANGE name name VARCHAR(255) NOT NULL'); + $this->addSql('DELETE FROM phplist_admintoken WHERE adminid IS NULL'); + $this->addSql('ALTER TABLE phplist_admintoken CHANGE adminid adminid INT NOT NULL'); + $this->addSql('ALTER TABLE phplist_config CHANGE item item VARCHAR(35) NOT NULL'); + $this->addSql('DROP INDEX messageid ON phplist_linktrack'); + $this->addSql('DROP INDEX phplist_linktrack_miduidurlindex ON phplist_linktrack'); + $this->addSql('ALTER TABLE phplist_linktrack CHANGE forward forward VARCHAR(255) DEFAULT NULL'); + $this->addSql('CREATE INDEX phplist_linktrack_latestclickindex ON phplist_linktrack (latestclick)'); + $this->addSql('CREATE UNIQUE INDEX phplist_linktrack_miduidurlindex ON phplist_linktrack (messageid, userid, url)'); +// $this->addSql('UPDATE phplist_list SET name = COALESCE(name, \'\')'); + $this->addSql('ALTER TABLE phplist_list CHANGE name name VARCHAR(255) NOT NULL, CHANGE description description VARCHAR(255) DEFAULT NULL, CHANGE category category VARCHAR(255) DEFAULT NULL'); + $this->addSql('DROP INDEX phplist_message_statusidx ON phplist_message'); + $this->addSql('UPDATE phplist_message SET subject = COALESCE(subject, \'(no subject)\'), fromfield = COALESCE(fromfield, \'\'), tofield = COALESCE(tofield, \'\'), replyto = COALESCE(replyto, \'\'), processed = COALESCE(processed, 0), astext = COALESCE(astext, 0), ashtml = COALESCE(ashtml, 0), astextandhtml = COALESCE(astextandhtml, 0), aspdf = COALESCE(aspdf, 0), astextandpdf = COALESCE(astextandpdf, 0)'); + $this->addSql('ALTER TABLE phplist_message CHANGE subject subject VARCHAR(255) DEFAULT \'(no subject)\' NOT NULL, CHANGE fromfield fromfield VARCHAR(255) DEFAULT \'\' NOT NULL, CHANGE tofield tofield VARCHAR(255) DEFAULT \'\' NOT NULL, CHANGE replyto replyto VARCHAR(255) DEFAULT \'\' NOT NULL, CHANGE message message LONGTEXT DEFAULT NULL, CHANGE textmessage textmessage LONGTEXT DEFAULT NULL, CHANGE processed processed INT UNSIGNED DEFAULT 0 NOT NULL, CHANGE astext astext INT NOT NULL, CHANGE ashtml ashtml INT NOT NULL, CHANGE astextandhtml astextandhtml INT NOT NULL, CHANGE aspdf aspdf INT NOT NULL, CHANGE astextandpdf astextandpdf INT NOT NULL'); + $this->addSql('CREATE INDEX phplist_message_sentidx ON phplist_message (sent)'); + $this->addSql('ALTER TABLE phplist_messagedata CHANGE name name VARCHAR(100) NOT NULL'); + $this->addSql('UPDATE phplist_subscribepage SET title = COALESCE(title, \'\')'); + $this->addSql('ALTER TABLE phplist_subscribepage CHANGE title title VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE phplist_subscribepage_data CHANGE name name VARCHAR(100) NOT NULL'); + $this->addSql('UPDATE phplist_template SET title = COALESCE(title, \'\')'); + $this->addSql('ALTER TABLE phplist_template CHANGE title title VARCHAR(255) NOT NULL'); +// $this->addSql('UPDATE phplist_user_attribute SET name = COALESCE(name, \'\')'); + $this->addSql('ALTER TABLE phplist_user_attribute CHANGE name name VARCHAR(255) NOT NULL'); + $this->addSql('DROP INDEX email_2 ON phplist_user_blacklist_data'); +// $this->addSql('UPDATE phplist_user_blacklist_data SET name = LEFT(COALESCE(name, \'\'), 25)'); + $this->addSql('ALTER TABLE phplist_user_blacklist_data CHANGE name name VARCHAR(25) NOT NULL'); + $this->addSql('DROP INDEX message_lookup ON phplist_user_message_bounce'); + $this->addSql('DROP INDEX emailidx ON phplist_user_user'); +// $this->addSql('UPDATE phplist_user_user SET email = COALESCE(email, \'\'), uniqid = COALESCE(uniqid, \'\')'); + $this->addSql('ALTER TABLE phplist_user_user CHANGE email email VARCHAR(255) NOT NULL, CHANGE uniqid uniqid VARCHAR(255) NOT NULL'); + $this->addSql('DROP INDEX userattid ON phplist_user_user_attribute'); + } + + 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('DROP INDEX phplist_admin_loginnameidx ON phplist_admin'); + $this->addSql('ALTER TABLE phplist_admin CHANGE loginname loginname VARCHAR(66) DEFAULT \'\', CHANGE email email VARCHAR(255) DEFAULT NULL, CHANGE modifiedby modifiedby VARCHAR(66) DEFAULT \'\''); + $this->addSql('CREATE UNIQUE INDEX loginname ON phplist_admin (loginname)'); + $this->addSql('CREATE INDEX phplist_admin_loginnameidx ON phplist_admin (loginname)'); + $this->addSql('ALTER TABLE phplist_adminattribute CHANGE name name VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_admintoken CHANGE adminid adminid INT DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_config CHANGE item item VARCHAR(35) DEFAULT \'\' NOT NULL'); + $this->addSql('DROP INDEX phplist_linktrack_latestclickindex ON phplist_linktrack'); + $this->addSql('DROP INDEX phplist_linktrack_miduidurlindex ON phplist_linktrack'); + $this->addSql('ALTER TABLE phplist_linktrack CHANGE forward forward TEXT DEFAULT NULL'); + $this->addSql('CREATE UNIQUE INDEX messageid ON phplist_linktrack (messageid, userid, url)'); + $this->addSql('CREATE INDEX phplist_linktrack_miduidurlindex ON phplist_linktrack (messageid, userid, url)'); + $this->addSql('ALTER TABLE phplist_list CHANGE name name VARCHAR(255) DEFAULT NULL, CHANGE description description VARCHAR(255) NOT NULL, CHANGE category category VARCHAR(255) NOT NULL'); + $this->addSql('DROP INDEX phplist_message_sentidx ON phplist_message'); + $this->addSql('ALTER TABLE phplist_message CHANGE astext astext INT DEFAULT 0 NOT NULL, CHANGE ashtml ashtml INT DEFAULT 0 NOT NULL, CHANGE aspdf aspdf INT DEFAULT 0 NOT NULL, CHANGE astextandhtml astextandhtml INT DEFAULT 0 NOT NULL, CHANGE astextandpdf astextandpdf INT DEFAULT 0 NOT NULL, CHANGE processed processed INT DEFAULT 0, CHANGE subject subject VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT \'(no subject)\' NOT NULL COLLATE `utf8mb4_general_ci`, CHANGE message message LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_general_ci`, CHANGE textmessage textmessage LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_general_ci`, CHANGE fromfield fromfield VARCHAR(255) DEFAULT NULL, CHANGE tofield tofield VARCHAR(255) DEFAULT NULL, CHANGE replyto replyto VARCHAR(255) DEFAULT NULL'); + $this->addSql('CREATE INDEX phplist_message_statusidx ON phplist_message (status, id)'); + $this->addSql('ALTER TABLE phplist_messagedata CHANGE name name VARCHAR(100) DEFAULT \'\' NOT NULL'); + $this->addSql('ALTER TABLE phplist_subscribepage CHANGE title title VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_subscribepage_data CHANGE name name VARCHAR(100) DEFAULT \'\' NOT NULL'); + $this->addSql('ALTER TABLE phplist_template CHANGE title title VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_user_attribute CHANGE name name VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE phplist_user_blacklist_data CHANGE name name VARCHAR(100) DEFAULT NULL'); + $this->addSql('CREATE UNIQUE INDEX email_2 ON phplist_user_blacklist_data (email)'); + $this->addSql('CREATE INDEX message_lookup ON phplist_user_message_bounce (message)'); + $this->addSql('ALTER TABLE phplist_user_user CHANGE email email VARCHAR(255) DEFAULT NULL, CHANGE uniqid uniqid VARCHAR(255) DEFAULT NULL'); + $this->addSql('CREATE INDEX emailidx ON phplist_user_user (email)'); + $this->addSql('CREATE INDEX userattid ON phplist_user_user_attribute (attributeid, userid)'); + } +} From ed92e8c6a55af4340d0e5c239d5691c7cf5dd774 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 27 Aug 2026 12:03:12 +0400 Subject: [PATCH 31/39] refactor: remove deprecated I18n model and repository classes --- src/Domain/Configuration/Model/I18n.php | 67 ------------------- .../Repository/I18nRepository.php | 12 ---- src/Migrations/Version20260827065637.php | 1 - 3 files changed, 80 deletions(-) delete mode 100644 src/Domain/Configuration/Model/I18n.php delete mode 100644 src/Domain/Configuration/Repository/I18nRepository.php diff --git a/src/Domain/Configuration/Model/I18n.php b/src/Domain/Configuration/Model/I18n.php deleted file mode 100644 index 0f709259e..000000000 --- a/src/Domain/Configuration/Model/I18n.php +++ /dev/null @@ -1,67 +0,0 @@ -lan; - } - - public function setLan(string $lan): self - { - $this->lan = $lan; - return $this; - } - - public function getOriginal(): string - { - return $this->original; - } - - public function setOriginal(string $original): self - { - $this->original = $original; - return $this; - } - - public function getTranslation(): string - { - return $this->translation; - } - - public function setTranslation(string $translation): self - { - $this->translation = $translation; - return $this; - } -} diff --git a/src/Domain/Configuration/Repository/I18nRepository.php b/src/Domain/Configuration/Repository/I18nRepository.php deleted file mode 100644 index 33fa599ae..000000000 --- a/src/Domain/Configuration/Repository/I18nRepository.php +++ /dev/null @@ -1,12 +0,0 @@ -addSql('DROP INDEX loginname ON phplist_admin'); $this->addSql('DROP INDEX phplist_admin_loginnameidx ON phplist_admin'); $this->addSql('DELETE FROM phplist_admin WHERE loginname IS NULL'); - $this->addSql('UPDATE phplist_admin SET email = COALESCE(email, \'\')'); $this->addSql('ALTER TABLE phplist_admin CHANGE loginname loginname VARCHAR(66) NOT NULL, CHANGE email email VARCHAR(255) NOT NULL, CHANGE modifiedby modifiedby VARCHAR(66) DEFAULT NULL'); $this->addSql('CREATE UNIQUE INDEX phplist_admin_loginnameidx ON phplist_admin (loginname)'); $this->addSql('DELETE FROM phplist_adminattribute WHERE name IS NULL'); From 26976eac966ebc0889a4168ced9b0dd8fd0df363 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 31 Aug 2026 11:40:02 +0400 Subject: [PATCH 32/39] add migration to clean up orphaned user attribute table names --- ...CleanupOrphanedUserAttributeTableNames.php | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/Migrations/Version20260831120000CleanupOrphanedUserAttributeTableNames.php diff --git a/src/Migrations/Version20260831120000CleanupOrphanedUserAttributeTableNames.php b/src/Migrations/Version20260831120000CleanupOrphanedUserAttributeTableNames.php new file mode 100644 index 000000000..5b5fdb196 --- /dev/null +++ b/src/Migrations/Version20260831120000CleanupOrphanedUserAttributeTableNames.php @@ -0,0 +1,82 @@ +connection->getDatabasePlatform(); + $this->skipIf(!$platform instanceof MySQLPlatform, sprintf( + 'Unsupported platform for this migration: %s', + get_class($platform) + )); + + $databasePrefix = $this->getEnv('DATABASE_PREFIX', 'phplist_'); + $listTablePrefix = $this->getEnv('LIST_TABLE_PREFIX', 'listattr_'); + $userAttributeTable = $databasePrefix . 'user_attribute'; + + $rows = $this->connection->fetchAllAssociative( + sprintf('SELECT id, tablename FROM %s WHERE tablename IS NOT NULL', $userAttributeTable) + ); + + foreach ($rows as $row) { + $dynamicTableName = $databasePrefix . $listTablePrefix . $row['tablename']; + + if (!$this->tableExists($dynamicTableName)) { + $this->connection->update( + $userAttributeTable, + ['tablename' => null], + ['id' => $row['id']] + ); + } + } + } + + /** + * Doesn't use the DBAL schema manager here: the 'default' connection has + * OnlyOrmTablesFilter registered as a doctrine.dbal.schema_filter, which hides any + * table not backed by ORM entity metadata. Dynamic list-attribute tables like + * phplist_listattr_countries are created ad-hoc and aren't mapped entities, so + * tablesExist() would always report them as missing. Querying information_schema + * directly bypasses that filter. + */ + private function tableExists(string $tableName): bool + { + $count = $this->connection->fetchOne( + 'SELECT COUNT(*) FROM information_schema.tables WHERE table_name = ? AND table_schema = DATABASE()', + [$tableName] + ); + + return (int) $count > 0; + } + + public function down(Schema $schema): void + { + throw new IrreversibleMigration('The original tablename values cannot be recovered once cleared.'); + } + + private function getEnv(string $name, string $default): string + { + $value = $_ENV[$name] ?? getenv($name); + + return is_string($value) && $value !== '' ? $value : $default; + } +} From 0f4d0845865c49beb639f7679146f12f3453f84f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 31 Aug 2026 12:10:52 +0400 Subject: [PATCH 33/39] add "tatevikgr/rss-feed" back --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index a6fd81259..d0371f585 100644 --- a/composer.json +++ b/composer.json @@ -77,6 +77,7 @@ "symfony/messenger": "^6.4", "symfony/lock": "^6.4", "webklex/php-imap": "^6.2", + "tatevikgr/rss-feed": "dev-main", "ext-imap": "*", "ext-pdo": "*", "ezyang/htmlpurifier": "^4.19", From 92bb9aced6102317737feab3ad57c80ce1778e6e Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 31 Aug 2026 12:41:57 +0400 Subject: [PATCH 34/39] refactor: update AdminLogin to use DateTimeImmutable for moment property --- src/Domain/Identity/Model/AdminLogin.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Domain/Identity/Model/AdminLogin.php b/src/Domain/Identity/Model/AdminLogin.php index 74d9abee5..21561e3ec 100644 --- a/src/Domain/Identity/Model/AdminLogin.php +++ b/src/Domain/Identity/Model/AdminLogin.php @@ -25,8 +25,8 @@ class AdminLogin implements DomainModel, Identity #[ORM\JoinColumn(name: 'adminid', referencedColumnName: 'id', nullable: false)] private Administrator $administrator; - #[ORM\Column(name: 'moment', type: 'bigint')] - private int $moment; + #[ORM\Column(type: 'datetime_immutable', nullable: false)] + private DateTimeImmutable $moment; #[ORM\Column(name: 'remote_ip4', type: 'string', length: 32)] private string $remoteIp4; @@ -42,13 +42,12 @@ class AdminLogin implements DomainModel, Identity public function __construct( Administrator $administrator, - DateTimeInterface $createdAt, string $remoteIp4, string $remoteIp6, string $sessionId, ) { $this->administrator = $administrator; - $this->moment = $createdAt->getTimestamp(); + $this->moment = new DateTimeImmutable(); $this->remoteIp4 = $remoteIp4; $this->remoteIp6 = $remoteIp6; $this->sessionId = $sessionId; @@ -77,7 +76,7 @@ public function getAdministrator(): Administrator public function getCreatedAt(): DateTimeInterface { - return (new DateTimeImmutable())->setTimestamp($this->moment); + return $this->moment; } public function getRemoteIp4(): string From 16d17eb2d450a77af5aff01db8dcebf30a822f61 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 31 Aug 2026 12:59:13 +0400 Subject: [PATCH 35/39] refactor: add project namespace filtering to OnlyOrmTablesFilter --- src/Core/Doctrine/OnlyOrmTablesFilter.php | 25 +++++++++++++++++++ .../Traits/DatabaseTestTrait.php | 4 +-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/Core/Doctrine/OnlyOrmTablesFilter.php b/src/Core/Doctrine/OnlyOrmTablesFilter.php index 69097fcd7..4443237ab 100644 --- a/src/Core/Doctrine/OnlyOrmTablesFilter.php +++ b/src/Core/Doctrine/OnlyOrmTablesFilter.php @@ -11,6 +11,16 @@ class OnlyOrmTablesFilter { + /** + * Namespace prefixes of entities considered "owned" by this project. Entities mapped from + * elsewhere (e.g. TatevikGr\RssFeedBundle, which registers its own ORM mapping) are excluded from + * the allowlist so that doctrine:migrations:diff doesn't propose schema changes for them - those + * bundles ship and run their own migrations. + */ + private const PROJECT_NAMESPACES = [ + 'PhpList\\Core\\', + ]; + /** @var string[]|null */ private ?array $allow = null; /** @var string[]|null */ @@ -59,6 +69,10 @@ private function buildAllowOnce(): array $tables = []; foreach ($this->entityManager->getMetadataFactory()->getAllMetadata() as $metadatum) { + if (!$this->isProjectOwned($metadatum->getName())) { + continue; + } + $tableName = $metadatum->getTableName(); if ($tableName) { $tables[] = strtolower($tableName); @@ -81,4 +95,15 @@ private function buildAllowOnce(): array return [$this->allow, $this->allowPrefixes]; } + + private function isProjectOwned(string $className): bool + { + foreach (self::PROJECT_NAMESPACES as $namespace) { + if (str_starts_with($className, $namespace)) { + return true; + } + } + + return false; + } } diff --git a/src/TestingSupport/Traits/DatabaseTestTrait.php b/src/TestingSupport/Traits/DatabaseTestTrait.php index f6ca6b65d..f6f5e551f 100644 --- a/src/TestingSupport/Traits/DatabaseTestTrait.php +++ b/src/TestingSupport/Traits/DatabaseTestTrait.php @@ -4,7 +4,7 @@ namespace PhpList\Core\TestingSupport\Traits; -use Doctrine\DBAL\Platforms\SQLitePlatform; +use Doctrine\DBAL\Platforms\SqlitePlatform; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Tools\SchemaTool; use Doctrine\ORM\Tools\ToolsException; @@ -84,7 +84,7 @@ protected function loadSchema(): void $schemaTool = new SchemaTool($this->entityManager); $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); - if ($this->entityManager->getConnection()->getDatabasePlatform() instanceof SQLitePlatform) { + if ($this->entityManager->getConnection()->getDatabasePlatform() instanceof SqlitePlatform) { $this->runForSqlite($metadata, $schemaTool); } else { $this->runForMySql($metadata, $schemaTool); From 7c2e92f135fcd193b1331a789f8b0a0c58e9c8a2 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 31 Aug 2026 13:11:32 +0400 Subject: [PATCH 36/39] update PHPUnit configuration to use custom bootstrap file --- phpunit.xml.dist | 2 +- tests/bootstrap.php | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 tests/bootstrap.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 3237ea39d..4a1306525 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -5,7 +5,7 @@ xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/9.5/phpunit.xsd" backupGlobals="false" colors="true" - bootstrap="vendor/autoload.php" + bootstrap="tests/bootstrap.php" > diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 000000000..2691701db --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,10 @@ +loadEnv(dirname(__DIR__) . '/.env', 'APP_ENV', Environment::TESTING); From 9a04a9badc6c8a85b2c07e081aa0cb6ccab86e34 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 31 Aug 2026 13:47:58 +0400 Subject: [PATCH 37/39] fix: tests, improve table creation logic in DatabaseTestTrait --- .../Traits/DatabaseTestTrait.php | 69 ++++++++++++++----- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/src/TestingSupport/Traits/DatabaseTestTrait.php b/src/TestingSupport/Traits/DatabaseTestTrait.php index f6f5e551f..d645894c3 100644 --- a/src/TestingSupport/Traits/DatabaseTestTrait.php +++ b/src/TestingSupport/Traits/DatabaseTestTrait.php @@ -96,34 +96,65 @@ private function runForMySql($metadata, $schemaTool): void try { $schemaTool->createSchema($metadata); } catch (ToolsException $e) { - $connection = $this->entityManager->getConnection(); - $schemaManager = $connection->createSchemaManager(); + $missing = $this->filterMissingTables($metadata); - foreach ($metadata as $classMetadata) { - $tableName = $classMetadata->getTableName(); - - if (!$schemaManager->tablesExist([$tableName])) { - $schemaTool->createSchema([$classMetadata]); - } + if ($missing !== []) { + $schemaTool->createSchema($missing); } } } private function runForSqlite($metadata, $schemaTool): void { - $connection = $this->entityManager->getConnection(); - $schemaManager = $connection->createSchemaManager(); - - foreach ($metadata as $classMetadata) { - $tableName = $classMetadata->getTableName(); + $missing = $this->filterMissingTables($metadata); - if (!$schemaManager->tablesExist([$tableName])) { - try { - $schemaTool->createSchema([$classMetadata]); - } catch (ToolsException $e) { - echo $e->getMessage(); - } + if ($missing !== []) { + try { + $schemaTool->createSchema($missing); + } catch (ToolsException $e) { + echo $e->getMessage(); } } } + + /** + * Creating tables one class at a time (rather than in a single createSchema() call for all + * of them) would break foreign key ordering: a single-class createSchema() call emits that + * class's own ADD CONSTRAINT statements immediately, which fails if a table it references + * hasn't been (re)created yet. Passing the whole batch of missing classes to createSchema() + * lets Doctrine order all CREATE TABLE statements before any ADD CONSTRAINT statements. + */ + private function filterMissingTables(array $metadata): array + { + return array_values(array_filter( + $metadata, + fn ($classMetadata) => !$this->tableExistsIgnoringSchemaFilter($classMetadata->getTableName()) + )); + } + + /** + * Doesn't use the DBAL schema manager's tablesExist() here: the 'default' connection has + * OnlyOrmTablesFilter registered as a doctrine.dbal.schema_filter, which hides tables mapped + * from bundles outside the project namespace (e.g. TatevikGr\RssFeedBundle's phplist_item_data), + * so tablesExist() would always report them as missing. Querying the platform's own table + * catalog directly bypasses that filter. + */ + private function tableExistsIgnoringSchemaFilter(string $tableName): bool + { + $connection = $this->entityManager->getConnection(); + + if ($connection->getDatabasePlatform() instanceof SqlitePlatform) { + $count = $connection->fetchOne( + 'SELECT COUNT(*) FROM sqlite_master WHERE type = ? AND name = ?', + ['table', $tableName] + ); + } else { + $count = $connection->fetchOne( + 'SELECT COUNT(*) FROM information_schema.tables WHERE table_name = ? AND table_schema = DATABASE()', + [$tableName] + ); + } + + return (int) $count > 0; + } } From d300611ed8a3a17520ee64403bfef80a2d442445 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 7 Sep 2026 12:17:15 +0400 Subject: [PATCH 38/39] Elasticsearch (#387) * ElasticsearchClient * IndexDocumentMessageHandler * ElasticsearchIndexer * ReindexSearchCommand * SubscriberHistoryElasticsearch * fix errors * Add lastId handling in SubscriberHistoryElasticsearchReader and tests for pagination * Clarify dispatch behavior in Elasticsearch indexing documentation * Implement versioning for index and delete operations in Elasticsearch client * Validate batch-size and last-id options in ReindexSearchCommand * Refactor SubscriberHistory index name usage to use constant * Remove ELASTICSEARCH_INDEX_PREFIX from configuration files and replace with DATABASE_PREFIX * Persist UserMessageBounce entity in linkUserMessageBounce method * Add Elasticsearch support for UserMessageBounce with reader and filter * bugfix * Refactor AnalyticsService to use UserMessageBounceReaderInterface for bounce data retrieval * UserMessageBounceElasticsearchHybridReader * Add UserMessageBounceReportReaderInterface and implement in UserMessageBounceElasticsearchHybridReader * Make Elasticsearch optional by introducing configurable readers for SubscriberHistory and UserMessageBounce * add MAX_RESULTS_BY_USER * Update lastId calculation in UserMessageBounceRepository for improved accuracy * Implement purge functionality for SubscriberHistory with configurable retention period * Ensure SubscriberHistory rows are cascaded on Subscriber removal to prevent orphaned Elasticsearch documents * After review 0 --------- Co-authored-by: Tatevik --- .coderabbit.yaml | 48 +++ .env.dist | 14 + .env.test | 3 + .env.test.local.dist | 9 - .github/workflows/ci.yml | 16 + README.md | 4 + composer.json | 3 +- config/packages/messenger.yaml | 20 +- config/parameters.yml | 11 + config/services.yml | 4 + config/services/commands.yml | 4 + config/services/elasticsearch.yml | 69 ++++ config/services/messenger.yml | 6 + config/services/repositories.yml | 29 ++ docs/ElasticsearchSearch.md | 158 ++++++++ .../Doctrine/SearchIndexDoctrineListener.php | 161 ++++++++ .../Analytics/Service/AnalyticsService.php | 10 +- src/Domain/Common/Model/PaginatedResult.php | 2 +- .../Model/Filter/UserMessageBounceFilter.php | 53 +++ .../UserMessageBounceRecordInterface.php | 26 ++ .../ReadModel/UserMessageBounceReadModel.php | 46 +++ .../Messaging/Model/UserMessageBounce.php | 35 +- .../UserMessageBounceReaderInterface.php | 30 ++ ...UserMessageBounceReportReaderInterface.php | 31 ++ .../UserMessageBounceConfigurableReader.php | 59 +++ ...MessageBounceElasticsearchHybridReader.php | 351 ++++++++++++++++++ .../UserMessageBounceElasticsearchReader.php | 166 +++++++++ ...rMessageBounceReportConfigurableReader.php | 39 ++ .../UserMessageBounceRepository.php | 82 +++- .../Service/Manager/BounceManager.php | 2 + .../UserMessageBounceIndexDefinition.php | 37 ++ .../UserMessageBounceReindexProvider.php | 40 ++ .../Client/ElasticsearchClientAdapter.php | 114 ++++++ .../Client/ElasticsearchClientFactory.php | 32 ++ .../Client/ElasticsearchClientInterface.php | 54 +++ .../Command/InitSearchIndicesCommand.php | 63 ++++ .../Command/PurgeSearchIndexedRowsCommand.php | 210 +++++++++++ .../Search/Command/ReindexSearchCommand.php | 122 ++++++ .../SearchBackendUnavailableException.php | 16 + .../Search/Message/IndexDocumentMessage.php | 51 +++ .../IndexDocumentMessageHandler.php | 35 ++ .../SearchIndexDefinitionInterface.php | 21 ++ .../Interfaces/SearchIndexableInterface.php | 20 + .../SearchPurgeProviderInterface.php | 32 ++ .../SearchReindexProviderInterface.php | 19 + src/Domain/Search/Model/SearchOperation.php | 11 + .../SearchIndexDefinitionRegistry.php | 36 ++ .../Registry/SearchPurgeProviderRegistry.php | 37 ++ .../SearchReindexProviderRegistry.php | 36 ++ .../Search/Service/ElasticsearchIndexer.php | 45 +++ .../Service/ElasticsearchIndexerInterface.php | 32 ++ .../SubscriberHistoryRecordInterface.php | 33 ++ .../ReadModel/SubscriberHistoryReadModel.php | 58 +++ src/Domain/Subscription/Model/Subscriber.php | 20 +- .../Subscription/Model/SubscriberHistory.php | 43 ++- .../SubscriberHistoryReaderInterface.php | 24 ++ .../SubscriberHistoryConfigurableReader.php | 44 +++ .../SubscriberHistoryElasticsearchReader.php | 116 ++++++ .../SubscriberHistoryRepository.php | 46 ++- .../Manager/SubscriberHistoryManager.php | 13 +- .../Service/Manager/SubscriberManager.php | 6 +- .../SubscriberHistoryIndexDefinition.php | 44 +++ .../Search/SubscriberHistoryPurgeProvider.php | 51 +++ .../SubscriberHistoryReindexProvider.php | 40 ++ ...ageBounceElasticsearchHybridReaderTest.php | 318 ++++++++++++++++ .../UserMessageBounceRepositoryTest.php | 39 ++ .../SubscriberHistoryRepositoryTest.php | 138 +++++++ .../SearchIndexDoctrineListenerTest.php | 173 +++++++++ .../Service/AnalyticsServiceTest.php | 12 +- ...serMessageBounceConfigurableReaderTest.php | 76 ++++ ...erMessageBounceElasticsearchReaderTest.php | 231 ++++++++++++ ...sageBounceReportConfigurableReaderTest.php | 60 +++ .../Service/Manager/BounceManagerTest.php | 5 + .../UserMessageBounceIndexDefinitionTest.php | 37 ++ .../UserMessageBounceReindexProviderTest.php | 19 + .../PurgeSearchIndexedRowsCommandTest.php | 123 ++++++ .../InMemoryVersionedElasticsearchClient.php | 68 ++++ ...mentMessageHandlerRevisionOrderingTest.php | 113 ++++++ .../IndexDocumentMessageHandlerTest.php | 51 +++ .../Service/ElasticsearchIndexerTest.php | 87 +++++ ...ubscriberHistoryConfigurableReaderTest.php | 64 ++++ ...bscriberHistoryElasticsearchReaderTest.php | 157 ++++++++ .../Manager/SubscriberHistoryManagerTest.php | 8 +- .../Service/Manager/SubscriberManagerTest.php | 4 +- .../SubscriberHistoryIndexDefinitionTest.php | 37 ++ .../SubscriberHistoryPurgeProviderTest.php | 57 +++ .../SubscriberHistoryReindexProviderTest.php | 19 + 87 files changed, 4941 insertions(+), 47 deletions(-) create mode 100644 .env.test delete mode 100644 .env.test.local.dist create mode 100644 config/services/elasticsearch.yml create mode 100644 docs/ElasticsearchSearch.md create mode 100644 src/Core/Doctrine/SearchIndexDoctrineListener.php create mode 100644 src/Domain/Messaging/Model/Filter/UserMessageBounceFilter.php create mode 100644 src/Domain/Messaging/Model/Interfaces/UserMessageBounceRecordInterface.php create mode 100644 src/Domain/Messaging/Model/ReadModel/UserMessageBounceReadModel.php create mode 100644 src/Domain/Messaging/Repository/Interfaces/UserMessageBounceReaderInterface.php create mode 100644 src/Domain/Messaging/Repository/Interfaces/UserMessageBounceReportReaderInterface.php create mode 100644 src/Domain/Messaging/Repository/UserMessageBounceConfigurableReader.php create mode 100644 src/Domain/Messaging/Repository/UserMessageBounceElasticsearchHybridReader.php create mode 100644 src/Domain/Messaging/Repository/UserMessageBounceElasticsearchReader.php create mode 100644 src/Domain/Messaging/Repository/UserMessageBounceReportConfigurableReader.php create mode 100644 src/Domain/Messaging/Service/Search/UserMessageBounceIndexDefinition.php create mode 100644 src/Domain/Messaging/Service/Search/UserMessageBounceReindexProvider.php create mode 100644 src/Domain/Search/Client/ElasticsearchClientAdapter.php create mode 100644 src/Domain/Search/Client/ElasticsearchClientFactory.php create mode 100644 src/Domain/Search/Client/ElasticsearchClientInterface.php create mode 100644 src/Domain/Search/Command/InitSearchIndicesCommand.php create mode 100644 src/Domain/Search/Command/PurgeSearchIndexedRowsCommand.php create mode 100644 src/Domain/Search/Command/ReindexSearchCommand.php create mode 100644 src/Domain/Search/Exception/SearchBackendUnavailableException.php create mode 100644 src/Domain/Search/Message/IndexDocumentMessage.php create mode 100644 src/Domain/Search/MessageHandler/IndexDocumentMessageHandler.php create mode 100644 src/Domain/Search/Model/Interfaces/SearchIndexDefinitionInterface.php create mode 100644 src/Domain/Search/Model/Interfaces/SearchIndexableInterface.php create mode 100644 src/Domain/Search/Model/Interfaces/SearchPurgeProviderInterface.php create mode 100644 src/Domain/Search/Model/Interfaces/SearchReindexProviderInterface.php create mode 100644 src/Domain/Search/Model/SearchOperation.php create mode 100644 src/Domain/Search/Registry/SearchIndexDefinitionRegistry.php create mode 100644 src/Domain/Search/Registry/SearchPurgeProviderRegistry.php create mode 100644 src/Domain/Search/Registry/SearchReindexProviderRegistry.php create mode 100644 src/Domain/Search/Service/ElasticsearchIndexer.php create mode 100644 src/Domain/Search/Service/ElasticsearchIndexerInterface.php create mode 100644 src/Domain/Subscription/Model/Interfaces/SubscriberHistoryRecordInterface.php create mode 100644 src/Domain/Subscription/Model/ReadModel/SubscriberHistoryReadModel.php create mode 100644 src/Domain/Subscription/Repository/Interfaces/SubscriberHistoryReaderInterface.php create mode 100644 src/Domain/Subscription/Repository/SubscriberHistoryConfigurableReader.php create mode 100644 src/Domain/Subscription/Repository/SubscriberHistoryElasticsearchReader.php create mode 100644 src/Domain/Subscription/Service/Search/SubscriberHistoryIndexDefinition.php create mode 100644 src/Domain/Subscription/Service/Search/SubscriberHistoryPurgeProvider.php create mode 100644 src/Domain/Subscription/Service/Search/SubscriberHistoryReindexProvider.php create mode 100644 tests/Integration/Domain/Messaging/Repository/UserMessageBounceElasticsearchHybridReaderTest.php create mode 100644 tests/Integration/Domain/Subscription/Repository/SubscriberHistoryRepositoryTest.php create mode 100644 tests/Unit/Core/Doctrine/SearchIndexDoctrineListenerTest.php create mode 100644 tests/Unit/Domain/Messaging/Repository/UserMessageBounceConfigurableReaderTest.php create mode 100644 tests/Unit/Domain/Messaging/Repository/UserMessageBounceElasticsearchReaderTest.php create mode 100644 tests/Unit/Domain/Messaging/Repository/UserMessageBounceReportConfigurableReaderTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Search/UserMessageBounceIndexDefinitionTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/Search/UserMessageBounceReindexProviderTest.php create mode 100644 tests/Unit/Domain/Search/Command/PurgeSearchIndexedRowsCommandTest.php create mode 100644 tests/Unit/Domain/Search/Fake/InMemoryVersionedElasticsearchClient.php create mode 100644 tests/Unit/Domain/Search/MessageHandler/IndexDocumentMessageHandlerRevisionOrderingTest.php create mode 100644 tests/Unit/Domain/Search/MessageHandler/IndexDocumentMessageHandlerTest.php create mode 100644 tests/Unit/Domain/Search/Service/ElasticsearchIndexerTest.php create mode 100644 tests/Unit/Domain/Subscription/Repository/SubscriberHistoryConfigurableReaderTest.php create mode 100644 tests/Unit/Domain/Subscription/Repository/SubscriberHistoryElasticsearchReaderTest.php create mode 100644 tests/Unit/Domain/Subscription/Service/Search/SubscriberHistoryIndexDefinitionTest.php create mode 100644 tests/Unit/Domain/Subscription/Service/Search/SubscriberHistoryPurgeProviderTest.php create mode 100644 tests/Unit/Domain/Subscription/Service/Search/SubscriberHistoryReindexProviderTest.php diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 6305f1b4c..c343b8fc0 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -64,6 +64,54 @@ reviews: - Ensure domain-layer code invoked by the job (outside the DynamicListAttr exception) remains free of persistence calls. - Batch flush operations where practical. + - path: "src/Domain/Search/**" + instructions: &elasticsearch_consistency | + You are reviewing code for the MySQL/Elasticsearch dual-write system (write-through DB + + Elasticsearch via `SearchIndexDoctrineListener`, with `*ConfigurableReader`/`*HybridReader` + classes able to read from either backend, and a retention/purge command that deletes DB rows + once confirmed in Elasticsearch). Flag anything that can let the two stores drift apart: + + - ❌ Bulk/raw deletes or updates on a `SearchIndexableInterface` entity's table that bypass + Doctrine's entity lifecycle - DQL `->delete()`/`->update()` queries, or DBAL + `executeStatement()`/raw SQL - never trigger `SearchIndexDoctrineListener::preRemove/postRemove` + (those only fire for `EntityManager::remove()`/`persist()` + `flush()`). Any such bulk + operation silently skips the Elasticsearch side. This is legitimate ONLY when it is an + explicit, reviewed "delete from MySQL but deliberately keep the Elasticsearch document" + retention/purge path (and even then, it must verify the row exists in Elasticsearch via a + real ES query *before* deleting - never delete on the assumption that dual-write "should + have" succeeded). + - ❌ A `SearchIndexableInterface` entity with a foreign key that has a DB-level + `onDelete: 'CASCADE'` (`#[ORM\JoinColumn(..., onDelete: 'CASCADE')]`) but no matching + Doctrine-level `cascade: ['remove']` on the owning/parent side's association. Without the + Doctrine-level cascade, deleting the parent lets the database silently drop child rows that + Doctrine never loads/removes individually, so the listener never fires for them and their + Elasticsearch documents are orphaned. + - ❌ Calls to `ElasticsearchClientInterface::index()`/`delete()`, or construction of + `IndexDocumentMessage`, using a hardcoded, reused, or non-monotonic revision instead of the + established wall-clock-microseconds pattern (see `SearchIndexDoctrineListener::nextRevision()`). + External versioning is what stops a delayed/retried write or delete from clobbering newer + state - a wrong revision can let a stale message win. + - ❌ New/changed `*ConfigurableReader` implementations that don't correctly gate on + `elasticsearch.enabled` and fall back to the Doctrine repository, or that silently mix data + from both backends for the same logical query/response without that being the explicit intent. + - ⚠️ Any consumer that needs a *complete*/unbounded history or aggregate (not a bounded recent + window) reading directly from the Doctrine repository for an entity that has a retention/purge + policy configured elsewhere - it will silently see only the retained window once older rows + are purged. Point out that it should read through the ES-backed reader/interface instead. + - ⚠️ Missing or incomplete tests around the above: a repository method that deletes/updates rows + for a `SearchIndexableInterface` entity should have a test proving it does (or intentionally + does not) go through the dual-write path, and any new cascade relationship should have a test + that actually removes the parent and asserts the child is gone too. + + - path: "src/**/*Elasticsearch*.php" + instructions: *elasticsearch_consistency + + - path: "src/**/Service/Search/**" + instructions: *elasticsearch_consistency + + - path: "src/Core/Doctrine/SearchIndexDoctrineListener.php" + instructions: *elasticsearch_consistency + auto_review: enabled: true base_branches: diff --git a/.env.dist b/.env.dist index 03df0e910..e66a76e46 100644 --- a/.env.dist +++ b/.env.dist @@ -52,6 +52,20 @@ BOUNCE_IMAP_PURGE_UNPROCESSED=0 # Messenger configuration for asynchronous processing MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=true +SEARCH_TRANSPORT_DSN=doctrine://default?queue_name=search_index + +# Elasticsearch configuration +# Set to false to fall back to reading/writing these tables straight from MySQL - no Elasticsearch +# cluster required. See docs/ElasticsearchSearch.md. +ELASTICSEARCH_ENABLED=false +ELASTICSEARCH_HOSTS=http://127.0.0.1:9200 +ELASTICSEARCH_USERNAME= +ELASTICSEARCH_PASSWORD= +ELASTICSEARCH_CONNECT_TIMEOUT=2 +ELASTICSEARCH_REQUEST_TIMEOUT=5 +# ISO-8601 duration (e.g. P1M) for how long SubscriberHistory rows are kept in MySQL after being +# confirmed in Elasticsearch. Empty disables purging. See docs/ElasticsearchSearch.md. +ELASTICSEARCH_PURGE_SUBSCRIBER_HISTORY_RETENTION= # A secret key that's used to generate certain security-related tokens PHPLIST_SECRET=%s diff --git a/.env.test b/.env.test new file mode 100644 index 000000000..88b6c3386 --- /dev/null +++ b/.env.test @@ -0,0 +1,3 @@ +PHPLIST_DATABASE_DRIVER=pdo_sqlite +PHPLIST_DATABASE_PATH=:memory: +SEARCH_TRANSPORT_DSN=sync:// diff --git a/.env.test.local.dist b/.env.test.local.dist deleted file mode 100644 index c9992c349..000000000 --- a/.env.test.local.dist +++ /dev/null @@ -1,9 +0,0 @@ -# Optional: copy this file to ".env.test.local" to run tests against an in-memory SQLite -# database instead of MySQL, so no database server is needed for `vendor/bin/phpunit`. -# -# Note: this file is not loaded automatically by PHPUnit CLI runs (this project's ApplicationKernel -# does not read .env files on its own); either export these as real environment variables before -# running phpunit, or wire them up via your own bootstrap/CI step. - -PHPLIST_DATABASE_DRIVER=pdo_sqlite -PHPLIST_DATABASE_PATH=:memory: \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 263488c74..865965232 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,17 @@ jobs: ports: - 3306/tcp options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.15.0 + env: + discovery.type: single-node + xpack.security.enabled: false + ES_JAVA_OPTS: -Xms256m -Xmx256m + ports: + - 9200/tcp + options: >- + --health-cmd="curl -sf http://localhost:9200/_cluster/health || exit 1" + --health-interval=10s --health-timeout=5s --health-retries=5 strategy: fail-fast: false matrix: @@ -68,6 +79,10 @@ jobs: php bin/console doctrine:schema:validate --skip-sync - name: Run units tests with phpunit run: vendor/bin/phpunit tests/Unit/ --testdox + - name: Initialize Elasticsearch indices + run: | + export ELASTICSEARCH_HOSTS=http://127.0.0.1:${{ job.services.elasticsearch.ports['9200'] }} + php bin/console phplist:search:init-indices - name: Run integration tests with phpunit run: | export PHPLIST_DATABASE_NAME=${{ env.DB_DATABASE }} @@ -75,6 +90,7 @@ jobs: export PHPLIST_DATABASE_PASSWORD=${{ env.DB_PASSWORD }} export PHPLIST_DATABASE_PORT=${{ job.services.mysql.ports['3306'] }} export PHPLIST_DATABASE_HOST=127.0.0.1 + export ELASTICSEARCH_HOSTS=http://127.0.0.1:${{ job.services.elasticsearch.ports['9200'] }} vendor/bin/phpunit tests/Integration/ - name: Running the system tests run: vendor/bin/phpunit tests/System/ --testdox; diff --git a/README.md b/README.md index 4b929df20..1f6a71cd3 100755 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ this code. * [Mailer transports](docs/MailerTransports.md) - configuring Gmail, Amazon SES, Mailchimp, and SendGrid * [Asynchronous email sending](docs/AsyncEmailSending.md) - queuing email delivery with Symfony Messenger * [Graylog integration](docs/Graylog.md) - centralized log management +* [Elasticsearch-backed search for big tables](docs/ElasticsearchSearch.md) - dual-write to the database and Elasticsearch, reading from Elasticsearch only * [Generating class API docs](PHPDOC.md) - regenerating the phpDocumentor output ## Running the web server @@ -201,6 +202,9 @@ To extract translation strings from the source into an XLIFF catalog: ```bash php bin/console translation:extract --force en --format=xlf +php bin/console messenger:setup-transports +php bin/console messenger:consume async --limit=1 +php bin/console phplist:search:init-indices ``` ## Copyright diff --git a/composer.json b/composer.json index d0371f585..5c6f9e059 100644 --- a/composer.json +++ b/composer.json @@ -89,7 +89,8 @@ "phpdocumentor/reflection-docblock": "^5.2", "guzzlehttp/guzzle": "^7.4.5", "symfony/dotenv": "^6.4", - "symfony/doctrine-messenger": "^6.4" + "symfony/doctrine-messenger": "^6.4", + "elasticsearch/elasticsearch": "^8.9" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 930226189..4896be58d 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -14,13 +14,30 @@ framework: check_delayed_interval: 60000 retry_strategy: max_retries: 3 - # milliseconds delay + # millisecond delay delay: 1000 multiplier: 2 max_delay: 0 failed: 'doctrine://default?queue_name=failed' + # Dedicated queue (same Doctrine connection/table, distinct queue_name) for Elasticsearch + # dual-write. auto_setup is deliberately disabled: the messenger_messages table is already + # created by the async_email transport, and no DDL must ever run while a Doctrine ORM + # transaction is open (see SearchIndexDoctrineListener, which dispatches from postFlush). + # On a fresh install that enables search before ever sending email, run + # `bin/console messenger:setup-transports` once. + # Configurable so tests can swap in 'sync://' (see .env.test) and index synchronously. + async_search: + dsn: '%env(SEARCH_TRANSPORT_DSN)%' + options: + auto_setup: false + retry_strategy: + max_retries: 5 + delay: 1000 + multiplier: 2 + max_delay: 30000 + routing: # Route your messages to the transports 'PhpList\Core\Domain\Messaging\Message\AsyncEmailMessage': async_email @@ -30,4 +47,5 @@ framework: 'PhpList\Core\Domain\Messaging\Message\CampaignProcessor\CampaignProcessorMessage': async_email 'PhpList\Core\Domain\Messaging\Message\CampaignProcessor\SyncCampaignProcessorMessage': sync 'PhpList\Core\Domain\Subscription\Message\DynamicTableMessage': sync + 'PhpList\Core\Domain\Search\Message\IndexDocumentMessage': async_search diff --git a/config/parameters.yml b/config/parameters.yml index aecc30ecb..1edd106dd 100644 --- a/config/parameters.yml +++ b/config/parameters.yml @@ -50,6 +50,17 @@ parameters: # Messenger configuration for asynchronous processing app.messenger_transport_dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + app.search_transport_dsn: '%env(SEARCH_TRANSPORT_DSN)%' + + # Elasticsearch configuration + elasticsearch.enabled: '%env(bool:ELASTICSEARCH_ENABLED)%' + elasticsearch.hosts: '%env(csv:ELASTICSEARCH_HOSTS)%' + elasticsearch.username: '%env(ELASTICSEARCH_USERNAME)%' + elasticsearch.password: '%env(ELASTICSEARCH_PASSWORD)%' + elasticsearch.index_prefix: '%env(DATABASE_PREFIX)%' + elasticsearch.connect_timeout: '%env(int:ELASTICSEARCH_CONNECT_TIMEOUT)%' + elasticsearch.request_timeout: '%env(int:ELASTICSEARCH_REQUEST_TIMEOUT)%' + elasticsearch.purge.subscriber_history_retention: '%env(ELASTICSEARCH_PURGE_SUBSCRIBER_HISTORY_RETENTION)%' # A secret key that's used to generate certain security-related tokens secret: '%env(PHPLIST_SECRET)%' diff --git a/config/services.yml b/config/services.yml index 31a2b6f1f..768a4ca41 100644 --- a/config/services.yml +++ b/config/services.yml @@ -55,6 +55,10 @@ services: arguments: $tablePrefix: '%database_prefix%' + PhpList\Core\Core\Doctrine\SearchIndexDoctrineListener: + arguments: + $enabled: '%elasticsearch.enabled%' + HTMLPurifier_Config: class: HTMLPurifier_Config factory: [ 'HTMLPurifier_Config', 'createDefault' ] diff --git a/config/services/commands.yml b/config/services/commands.yml index d9305748c..32b431f69 100644 --- a/config/services/commands.yml +++ b/config/services/commands.yml @@ -12,6 +12,10 @@ services: resource: '../../src/Domain/Identity/Command' tags: ['console.command'] + PhpList\Core\Domain\Search\Command\: + resource: '../../src/Domain/Search/Command' + tags: ['console.command'] + PhpList\Core\Domain\Messaging\Command\ProcessBouncesCommand: arguments: $protocolProcessors: !tagged_iterator 'phplist.bounce_protocol_processor' diff --git a/config/services/elasticsearch.yml b/config/services/elasticsearch.yml new file mode 100644 index 000000000..e6da2c57b --- /dev/null +++ b/config/services/elasticsearch.yml @@ -0,0 +1,69 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + _instanceof: + PhpList\Core\Domain\Search\Model\Interfaces\SearchIndexDefinitionInterface: + tags: ['phplist.search_index_definition'] + PhpList\Core\Domain\Search\Model\Interfaces\SearchReindexProviderInterface: + tags: ['phplist.search_reindex_provider'] + PhpList\Core\Domain\Search\Model\Interfaces\SearchPurgeProviderInterface: + tags: ['phplist.search_purge_provider'] + + Elastic\Elasticsearch\Client: + factory: ['PhpList\Core\Domain\Search\Client\ElasticsearchClientFactory', 'create'] + arguments: + $hosts: '%elasticsearch.hosts%' + $username: '%elasticsearch.username%' + $password: '%elasticsearch.password%' + $connectTimeout: '%elasticsearch.connect_timeout%' + $requestTimeout: '%elasticsearch.request_timeout%' + + PhpList\Core\Domain\Search\Client\ElasticsearchClientInterface: + alias: PhpList\Core\Domain\Search\Client\ElasticsearchClientAdapter + + PhpList\Core\Domain\Search\Client\ElasticsearchClientAdapter: ~ + + PhpList\Core\Domain\Search\Service\ElasticsearchIndexerInterface: + alias: PhpList\Core\Domain\Search\Service\ElasticsearchIndexer + + PhpList\Core\Domain\Search\Service\ElasticsearchIndexer: + arguments: + $indexPrefix: '%elasticsearch.index_prefix%' + + PhpList\Core\Domain\Search\Registry\SearchIndexDefinitionRegistry: + arguments: + $definitions: !tagged_iterator 'phplist.search_index_definition' + + PhpList\Core\Domain\Search\Registry\SearchReindexProviderRegistry: + arguments: + $providers: !tagged_iterator 'phplist.search_reindex_provider' + + PhpList\Core\Domain\Search\Registry\SearchPurgeProviderRegistry: + arguments: + $providers: !tagged_iterator 'phplist.search_purge_provider' + + PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryElasticsearchReader: + arguments: + $indexPrefix: '%elasticsearch.index_prefix%' + + PhpList\Core\Domain\Subscription\Service\Search\: + resource: '../../src/Domain/Subscription/Service/Search' + + PhpList\Core\Domain\Subscription\Service\Search\SubscriberHistoryPurgeProvider: + arguments: + $indexPrefix: '%elasticsearch.index_prefix%' + $retentionPeriod: '%elasticsearch.purge.subscriber_history_retention%' + + PhpList\Core\Domain\Messaging\Repository\UserMessageBounceElasticsearchReader: + arguments: + $indexPrefix: '%elasticsearch.index_prefix%' + + PhpList\Core\Domain\Messaging\Repository\UserMessageBounceElasticsearchHybridReader: + arguments: + $indexPrefix: '%elasticsearch.index_prefix%' + + PhpList\Core\Domain\Messaging\Service\Search\: + resource: '../../src/Domain/Messaging/Service/Search' diff --git a/config/services/messenger.yml b/config/services/messenger.yml index 38130f6e0..16e02ff42 100644 --- a/config/services/messenger.yml +++ b/config/services/messenger.yml @@ -11,6 +11,12 @@ services: resource: '../../src/Domain/Subscription/MessageHandler' tags: [ 'messenger.message_handler' ] + # Register Search message handlers (e.g., IndexDocumentMessageHandler) + PhpList\Core\Domain\Search\MessageHandler\: + autowire: true + resource: '../../src/Domain/Search/MessageHandler' + tags: [ 'messenger.message_handler' ] + PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor\CampaignProcessorMessageHandler: autowire: true autoconfigure: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml index a0650b353..5ee7eb407 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -83,6 +83,16 @@ services: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Subscription\Model\SubscriberHistory + + # Reads for SubscriberHistoryManager/SubscriberManager go through this configurable reader, which + # picks Elasticsearch or the database based on elasticsearch.enabled (ELASTICSEARCH_ENABLED) - see + # SubscriberHistoryConfigurableReader and docs/ElasticsearchSearch.md. + PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryConfigurableReader: + autowire: true + arguments: + $elasticsearchEnabled: '%elasticsearch.enabled%' + PhpList\Core\Domain\Subscription\Repository\Interfaces\SubscriberHistoryReaderInterface: + alias: PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryConfigurableReader PhpList\Core\Domain\Subscription\Repository\UserBlacklistRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: @@ -117,6 +127,25 @@ services: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Messaging\Model\UserMessageBounce + + # Reads for UserMessageBounceReaderInterface consumers go through this configurable reader, which + # picks Elasticsearch or the database based on elasticsearch.enabled (ELASTICSEARCH_ENABLED) - see + # UserMessageBounceConfigurableReader and docs/ElasticsearchSearch.md. + PhpList\Core\Domain\Messaging\Repository\UserMessageBounceConfigurableReader: + autowire: true + arguments: + $elasticsearchEnabled: '%elasticsearch.enabled%' + PhpList\Core\Domain\Messaging\Repository\Interfaces\UserMessageBounceReaderInterface: + alias: PhpList\Core\Domain\Messaging\Repository\UserMessageBounceConfigurableReader + + # Same configurable swap for the reporting queries (getListBounceTotals/getCampaignBounceTotals) + # that join bounce data against Subscriber/Message. + PhpList\Core\Domain\Messaging\Repository\UserMessageBounceReportConfigurableReader: + autowire: true + arguments: + $elasticsearchEnabled: '%elasticsearch.enabled%' + PhpList\Core\Domain\Messaging\Repository\Interfaces\UserMessageBounceReportReaderInterface: + alias: PhpList\Core\Domain\Messaging\Repository\UserMessageBounceReportConfigurableReader PhpList\Core\Domain\Messaging\Repository\UserMessageForwardRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: diff --git a/docs/ElasticsearchSearch.md b/docs/ElasticsearchSearch.md new file mode 100644 index 000000000..97f9af813 --- /dev/null +++ b/docs/ElasticsearchSearch.md @@ -0,0 +1,158 @@ +# Elasticsearch-backed Search for Big Tables + +This document explains the generic Elasticsearch dual-write/read infrastructure and its consumers: +`SubscriberHistory` (table `phplist_user_user_history`) and `UserMessageBounce` +(table `phplist_user_message_bounce`). + +## Overview + +Some tables grow too large for comfortable ad-hoc filtering/pagination straight off MySQL. For those +tables, phpList Core writes to the database **and** to Elasticsearch, but reads **only** from +Elasticsearch: + +- **Writes** stay exactly as they are today (Doctrine `persist()`/`remove()`). A generic Doctrine + event listener (`PhpList\Core\Core\Doctrine\SearchIndexDoctrineListener`) detects any entity that + implements `SearchIndexableInterface` and asynchronously dispatches an indexing/deletion message via + Symfony Messenger from Doctrine's `postFlush` event, i.e. after the ORM flush - not necessarily after + a real commit. Callers that wrap `flush()` in their own explicit transaction (e.g. + `SubscriberCsvImporter`) cause `postFlush` to fire before that outer transaction actually commits; see + "Consistency model" below for why this is still safe. +- **Reads** for those entities go through a dedicated reader interface (e.g. + `SubscriberHistoryReaderInterface`) that is aliased in DI to an Elasticsearch-backed implementation + instead of the Doctrine repository. + +This is deliberately generic: adding the next big table only requires implementing three small +interfaces (see "Adding a new searchable entity" below) - no changes to the dual-write plumbing. + +## Consistency model + +- Dual-write is **asynchronous**. Between a row being committed to MySQL and the `async_search` worker + processing its queued message, a read from Elasticsearch will not yet reflect that row. +- Reads **hard-fail** if Elasticsearch is unreachable - there is no fallback to the database. Any + Elasticsearch error is raised as `PhpList\Core\Domain\Search\Exception\SearchBackendUnavailableException`. +- Dispatch happens from `postFlush`, not `postPersist`/`postUpdate`/`postRemove` (those fire while the + flush is still in progress), so a row that ends up rolled back is never indexed. For a plain + `flush()` call with no surrounding transaction, `postFlush`'s own commit is the real commit, so + dispatch does happen after the row is durably committed. When a caller instead wraps `flush()` in + its own explicit transaction (e.g. `SubscriberCsvImporter`), `postFlush` fires before that outer + transaction commits - but the `async_search` transport is a Doctrine queue on the same connection, so + the queued message insert shares that same outer transaction: the row and its message still commit + or roll back together. The remaining crash window is narrower than "commit vs. dispatch" - it's + strictly between the outer transaction's real commit and the `async_search` worker consuming the + message; if a process dies in that window, that one row is missed until the next + `phplist:search:reindex` run. + +Consumers of `phplist/core` that build UI on top of these read paths should plan for both of the above +(e.g. a brief "just added" staleness window, and handling a 5xx-equivalent from a search-unavailable +condition) rather than assuming synchronous consistency with the database. + +## Configuration + +Set in `.env` (see `.env.dist`): + +```dotenv +ELASTICSEARCH_HOSTS=http://127.0.0.1:9200 +ELASTICSEARCH_USERNAME= +ELASTICSEARCH_PASSWORD= +ELASTICSEARCH_CONNECT_TIMEOUT=2 +ELASTICSEARCH_REQUEST_TIMEOUT=5 +``` + +`ELASTICSEARCH_HOSTS` accepts a comma-separated list for multi-node clusters. The index prefix is +applied to every logical index alias (e.g. alias `subscriber_history` becomes index +`phplist_subscriber_history` with the default prefix), the same convention as `DATABASE_PREFIX` for +MySQL tables. + +### Making Elasticsearch optional + +Set `ELASTICSEARCH_ENABLED=false` to run without an Elasticsearch cluster at all - no code changes, +no cluster required, and it's safe even if `ELASTICSEARCH_HOSTS` is unreachable or unset: + +- **Writes**: `SearchIndexDoctrineListener` becomes a no-op - nothing is ever queued to the + `async_search` transport, so no `IndexDocumentMessage` accumulates unconsumed. +- **Reads**: every reader interface (`SubscriberHistoryReaderInterface`, + `UserMessageBounceReaderInterface`, `UserMessageBounceReportReaderInterface`) is aliased to a small + `*ConfigurableReader` that picks the Doctrine repository instead of the Elasticsearch reader - see + `SubscriberHistoryConfigurableReader`/`UserMessageBounceConfigurableReader`/ + `UserMessageBounceReportConfigurableReader`. + +The `elasticsearch/elasticsearch` PHP client package is still a hard Composer dependency of +`phplist/core` either way - disabling the feature at runtime doesn't remove the need to have that +package installed, only the need to have a reachable cluster. + +## Queueing + +Indexing/deletion messages are routed to a dedicated `async_search` Messenger transport +(`config/packages/messenger.yaml`) - the same Doctrine-backed queue table used by `async_email`, but a +distinct `queue_name` so the two workloads don't compete or block each other. Run a worker for it in +addition to the email worker: + +```bash +bin/console messenger:consume async_search +``` + +As with `async_email`, run this as a background service (e.g. via Supervisor) in production. +`auto_setup` is disabled for this transport: the `messenger_messages` table must already exist (it's +created lazily by `async_email` on first use). On a fresh install that enables search before ever +sending an email, run `bin/console messenger:setup-transports` once. + +## Console commands + +```bash +# Create or update Elasticsearch indices (mappings) for every registered searchable entity. +# Safe to re-run - never drops or recreates an existing index. +bin/console phplist:search:init-indices [--index=] + +# Backfill Elasticsearch from the database. Safe to re-run (indexing is an upsert by id). +bin/console phplist:search:reindex [] [--batch-size=500] [--last-id=0] + +# Delete DB rows older than a configured retention period, once confirmed to exist in +# Elasticsearch. Skips (and warns about, without deleting) any row not yet found in ES. +bin/console phplist:search:purge [] [--batch-size=500] [--dry-run] +``` + +Run `phplist:search:init-indices` once per environment before the first `phplist:search:reindex`, and +again after adding a new searchable entity or changing a mapping. + +### Purging old rows (`phplist:search:purge`) + +Only entities with an opted-in purge provider and a non-empty retention period are eligible. Today +that's `SubscriberHistory`, controlled by `ELASTICSEARCH_PURGE_SUBSCRIBER_HISTORY_RETENTION` (an +ISO-8601 duration, e.g. `P1M`; empty/unset disables purging for it). Adding another entity means +implementing `SearchPurgeProviderInterface` (mirroring `SubscriberHistoryPurgeProvider`) and adding its +own retention parameter - it's auto-tagged and picked up the same way reindex providers are. + +**Before scheduling this command in production**, make sure Elasticsearch snapshots/backups are +configured. Once a row is purged from MySQL, `phplist:search:reindex` can no longer recover it if the +ES index is ever lost - the verify-before-delete step only protects against rows that *haven't* made it +into ES yet, not against losing the ES index itself afterwards. This command is not currently wired +into any cron/Supervisor schedule; that should happen only after the snapshot policy is in place. + +## Adding a new searchable entity + +1. Implement `PhpList\Core\Domain\Search\Model\Interfaces\SearchIndexableInterface` on the entity + (`getSearchIndexName()`, `getSearchDocumentId()`, `toSearchDocument()`). This is what makes + `SearchIndexDoctrineListener` dual-write it automatically - no other write-path changes needed. +2. Add an index definition (`SearchIndexDefinitionInterface`: alias + mapping + settings) under the + entity's own `Service/Search` folder, following + `PhpList\Core\Domain\Subscription\Service\Search\SubscriberHistoryIndexDefinition` - it's + auto-tagged and picked up by `phplist:search:init-indices` via DI `_instanceof` autoconfiguration + in `config/services/elasticsearch.yml`. +3. Add a reindex provider (`SearchReindexProviderInterface`: alias + `countAll()` + `fetchBatch()`) + following `SubscriberHistoryReindexProvider` - likewise auto-tagged, picked up by + `phplist:search:reindex`. +4. If reads should also move to Elasticsearch, introduce a reader interface for that entity (mirroring + `SubscriberHistoryReaderInterface`) and an Elasticsearch-backed implementation (mirroring + `SubscriberHistoryElasticsearchReader`). To keep Elasticsearch optional for the new entity too, also + add a `*ConfigurableReader` (mirroring `SubscriberHistoryConfigurableReader`) that picks between the + Doctrine repository and the Elasticsearch reader based on `elasticsearch.enabled`, and alias the + interface to that instead of aliasing directly to either backend. + +## Troubleshooting + +- **Reads throwing `SearchBackendUnavailableException`**: check Elasticsearch is reachable at + `ELASTICSEARCH_HOSTS` and that `phplist:search:init-indices` has been run. +- **New/updated rows not appearing in search results**: make sure a `messenger:consume async_search` + worker is running; check `bin/console messenger:failed:show` for stuck messages. +- **Data drifted between MySQL and Elasticsearch**: re-run `bin/console phplist:search:reindex ` + - it's a safe, idempotent full backfill. diff --git a/src/Core/Doctrine/SearchIndexDoctrineListener.php b/src/Core/Doctrine/SearchIndexDoctrineListener.php new file mode 100644 index 000000000..8dc848298 --- /dev/null +++ b/src/Core/Doctrine/SearchIndexDoctrineListener.php @@ -0,0 +1,161 @@ + */ + private array $pending = []; + + /** @var array keyed by spl_object_id() */ + private array $removalKeys = []; + + public function __construct( + private readonly MessageBusInterface $messageBus, + private readonly bool $enabled = true, + ) { + } + + public function postPersist(PostPersistEventArgs $args): void + { + if (!$this->enabled) { + return; + } + + $entity = $args->getObject(); + if (!$entity instanceof SearchIndexableInterface) { + return; + } + + $this->queue($entity, SearchOperation::Index, $entity->getSearchIndexName(), $entity->getSearchDocumentId()); + } + + public function postUpdate(PostUpdateEventArgs $args): void + { + if (!$this->enabled) { + return; + } + + $entity = $args->getObject(); + if (!$entity instanceof SearchIndexableInterface) { + return; + } + + $this->queue($entity, SearchOperation::Index, $entity->getSearchIndexName(), $entity->getSearchDocumentId()); + } + + public function preRemove(PreRemoveEventArgs $args): void + { + if (!$this->enabled) { + return; + } + + $entity = $args->getObject(); + if (!$entity instanceof SearchIndexableInterface) { + return; + } + + $this->removalKeys[spl_object_id($entity)] = [$entity->getSearchIndexName(), $entity->getSearchDocumentId()]; + } + + public function postRemove(PostRemoveEventArgs $args): void + { + if (!$this->enabled) { + return; + } + + $entity = $args->getObject(); + if (!$entity instanceof SearchIndexableInterface) { + return; + } + + $objectId = spl_object_id($entity); + [$indexName, $documentId] = $this->removalKeys[$objectId] + ?? [$entity->getSearchIndexName(), $entity->getSearchDocumentId()]; + unset($this->removalKeys[$objectId]); + + $this->queue($entity, SearchOperation::Delete, $indexName, $documentId); + } + + public function postFlush(PostFlushEventArgs $args): void + { + if ($this->pending === []) { + return; + } + + $messages = $this->pending; + $this->pending = []; + + foreach ($messages as $message) { + $this->messageBus->dispatch($message); + } + } + + private function queue( + SearchIndexableInterface $entity, + SearchOperation $operation, + string $indexName, + string $documentId, + ): void { + $key = $indexName . '|' . $documentId; + $document = $operation === SearchOperation::Index ? $entity->toSearchDocument() : []; + + $this->pending[$key] = new IndexDocumentMessage( + $indexName, + $documentId, + $document, + $operation, + $this->nextRevision(), + ); + } + + /** + * Wall-clock microseconds, not a per-process counter: a delayed Messenger retry carries the + * revision assigned when it was originally queued, and must stay comparable against revisions + * assigned by other PHP processes/workers for the same document so the indexer (via Elasticsearch + * external versioning) can tell a stale retry apart from a newer write. + */ + private function nextRevision(): int + { + return (int) (microtime(true) * 1_000_000); + } +} diff --git a/src/Domain/Analytics/Service/AnalyticsService.php b/src/Domain/Analytics/Service/AnalyticsService.php index e0ff69857..170a24be3 100644 --- a/src/Domain/Analytics/Service/AnalyticsService.php +++ b/src/Domain/Analytics/Service/AnalyticsService.php @@ -10,8 +10,8 @@ use PhpList\Core\Domain\Analytics\Service\Manager\LinkTrackManager; use PhpList\Core\Domain\Analytics\Service\Manager\UserMessageViewManager; use PhpList\Core\Domain\Messaging\Model\Filter\MessageFilter; +use PhpList\Core\Domain\Messaging\Repository\Interfaces\UserMessageBounceReaderInterface; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; -use PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageForwardRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; @@ -22,7 +22,7 @@ public function __construct( private readonly LinkTrackManager $linkTrackManager, private readonly UserMessageViewManager $userMessageViewManager, private readonly MessageRepository $messageRepository, - private readonly UserMessageBounceRepository $messageBounceRepository, + private readonly UserMessageBounceReaderInterface $messageBounceReader, private readonly UserMessageForwardRepository $messageForwardRepository, private readonly SubscriberRepository $subscriberRepository, private readonly UserMessageRepository $userMessageRepository, @@ -68,7 +68,7 @@ public function getCampaignStatistics(int $limit = 50, int $lastId = 0): array } $uniqueClicks = count($uniqueClickers); - $bounces = $this->messageBounceRepository->getCountByMessageId($message->getId()); + $bounces = $this->messageBounceReader->getCountByMessageId($message->getId()); $forwards = $this->messageForwardRepository->getCountByMessageId($message->getId()); $sentDate = $message->getMetadata()->getSent(); $sentCount = $message->getMetadata()->getBounceCount() + $views; @@ -178,11 +178,11 @@ public function getSummaryStatistics(): array $sentTotal = $this->userMessageRepository->countSentBetween($thisMonthStart, $now); $openTotal = $this->userMessageViewRepository->countBetween($thisMonthStart, $now); - $bounceTotal = $this->messageBounceRepository->countBetween($thisMonthStart, $now); + $bounceTotal = $this->messageBounceReader->countBetween($thisMonthStart, $now); $sentTotalLastMonth = $this->userMessageRepository->countSentBetween($lastMonthStart, $lastMonthEnd); $openTotalLastMonth = $this->userMessageViewRepository->countBetween($lastMonthStart, $lastMonthEnd); - $bounceTotalLastMonth = $this->messageBounceRepository->countBetween($lastMonthStart, $lastMonthEnd); + $bounceTotalLastMonth = $this->messageBounceReader->countBetween($lastMonthStart, $lastMonthEnd); $openRate = $this->calculateRate($openTotal, $sentTotal); $openRateLastMonth = $this->calculateRate($openTotalLastMonth, $sentTotalLastMonth); diff --git a/src/Domain/Common/Model/PaginatedResult.php b/src/Domain/Common/Model/PaginatedResult.php index 83eec8f2d..aa9bb1b73 100644 --- a/src/Domain/Common/Model/PaginatedResult.php +++ b/src/Domain/Common/Model/PaginatedResult.php @@ -6,7 +6,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; -/** @template T of DomainModel */ +/** @template-covariant T of DomainModel */ class PaginatedResult { /** @var list */ diff --git a/src/Domain/Messaging/Model/Filter/UserMessageBounceFilter.php b/src/Domain/Messaging/Model/Filter/UserMessageBounceFilter.php new file mode 100644 index 000000000..ada2752e0 --- /dev/null +++ b/src/Domain/Messaging/Model/Filter/UserMessageBounceFilter.php @@ -0,0 +1,53 @@ +userId = $userId; + $this->messageId = $messageId; + $this->bounceId = $bounceId; + $this->dateFrom = $dateFrom; + $this->setLastId($lastId); + $this->setLimit($limit); + } + + public function getUserId(): ?int + { + return $this->userId; + } + + public function getMessageId(): ?int + { + return $this->messageId; + } + + public function getBounceId(): ?int + { + return $this->bounceId; + } + + public function getDateFrom(): ?DateTimeImmutable + { + return $this->dateFrom; + } +} diff --git a/src/Domain/Messaging/Model/Interfaces/UserMessageBounceRecordInterface.php b/src/Domain/Messaging/Model/Interfaces/UserMessageBounceRecordInterface.php new file mode 100644 index 000000000..37e77171d --- /dev/null +++ b/src/Domain/Messaging/Model/Interfaces/UserMessageBounceRecordInterface.php @@ -0,0 +1,26 @@ +id; + } + + public function getUserId(): int + { + return $this->userId; + } + + public function getMessageId(): int + { + return $this->messageId; + } + + public function getBounceId(): int + { + return $this->bounceId; + } + + public function getCreatedAt(): DateTime + { + return $this->createdAt; + } +} diff --git a/src/Domain/Messaging/Model/UserMessageBounce.php b/src/Domain/Messaging/Model/UserMessageBounce.php index 2a7ef5197..9a350f880 100644 --- a/src/Domain/Messaging/Model/UserMessageBounce.php +++ b/src/Domain/Messaging/Model/UserMessageBounce.php @@ -5,10 +5,13 @@ namespace PhpList\Core\Domain\Messaging\Model; use DateTime; +use DateTimeInterface; 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\Messaging\Model\Interfaces\UserMessageBounceRecordInterface; use PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository; +use PhpList\Core\Domain\Search\Model\Interfaces\SearchIndexableInterface; #[ORM\Entity(repositoryClass: UserMessageBounceRepository::class)] #[ORM\Table(name: 'user_message_bounce')] @@ -17,8 +20,16 @@ #[ORM\Index(name: 'phplist_user_message_bounce_umbindex', columns: ['user', 'message', 'bounce'])] #[ORM\Index(name: 'phplist_user_message_bounce_useridx', columns: ['user'])] // todo: #[ORM\Index(name: 'phplist_user_message_bounce_timeidx', columns: ['time'])] -class UserMessageBounce implements DomainModel, Identity +class UserMessageBounce implements + DomainModel, + Identity, + SearchIndexableInterface, + UserMessageBounceRecordInterface { + public const SEARCH_INDEX_NAME = 'user_message_bounce'; + // 1000 is enough, I think, but if we ever need more, we can implement pagination. + public const MAX_RESULTS_BY_USER = 1000; + #[ORM\Id] #[ORM\Column(type: 'integer')] #[ORM\GeneratedValue] @@ -84,4 +95,26 @@ public function setBounceId(int $bounceId): self $this->bounceId = $bounceId; return $this; } + + public function getSearchIndexName(): string + { + return self::SEARCH_INDEX_NAME; + } + + public function getSearchDocumentId(): string + { + return (string) $this->id; + } + + public function toSearchDocument(): array + { + return [ + 'id' => $this->id, + 'idSort' => $this->id, + 'userId' => $this->userId, + 'messageId' => $this->messageId, + 'bounceId' => $this->bounceId, + 'time' => $this->createdAt->format(DateTimeInterface::ATOM), + ]; + } } diff --git a/src/Domain/Messaging/Repository/Interfaces/UserMessageBounceReaderInterface.php b/src/Domain/Messaging/Repository/Interfaces/UserMessageBounceReaderInterface.php new file mode 100644 index 000000000..083be412f --- /dev/null +++ b/src/Domain/Messaging/Repository/Interfaces/UserMessageBounceReaderInterface.php @@ -0,0 +1,30 @@ + */ + public function getFilteredAfterId(FilterRequestInterface $filter): PaginatedResult; + + /** @return UserMessageBounceRecordInterface[] */ + public function getByUserId(int $userId): array; + + public function getCountByMessageId(int $messageId): int; + + public function countBetween(DateTimeInterface $start, DateTimeInterface $end): int; + + public function existsByMessageIdAndUserId(int $messageId, int $subscriberId): bool; +} diff --git a/src/Domain/Messaging/Repository/Interfaces/UserMessageBounceReportReaderInterface.php b/src/Domain/Messaging/Repository/Interfaces/UserMessageBounceReportReaderInterface.php new file mode 100644 index 000000000..4e5746289 --- /dev/null +++ b/src/Domain/Messaging/Repository/Interfaces/UserMessageBounceReportReaderInterface.php @@ -0,0 +1,31 @@ + + */ + public function getListBounceTotals(int $listId): array; + + /** @return array */ + public function getCampaignBounceTotals(?int $ownerId = null): array; +} diff --git a/src/Domain/Messaging/Repository/UserMessageBounceConfigurableReader.php b/src/Domain/Messaging/Repository/UserMessageBounceConfigurableReader.php new file mode 100644 index 000000000..0f927c4c1 --- /dev/null +++ b/src/Domain/Messaging/Repository/UserMessageBounceConfigurableReader.php @@ -0,0 +1,59 @@ +activeReader()->getFilteredAfterId($filter); + } + + /** @return UserMessageBounceRecordInterface[] */ + public function getByUserId(int $userId): array + { + return $this->activeReader()->getByUserId($userId); + } + + public function getCountByMessageId(int $messageId): int + { + return $this->activeReader()->getCountByMessageId($messageId); + } + + public function countBetween(DateTimeInterface $start, DateTimeInterface $end): int + { + return $this->activeReader()->countBetween($start, $end); + } + + public function existsByMessageIdAndUserId(int $messageId, int $subscriberId): bool + { + return $this->activeReader()->existsByMessageIdAndUserId($messageId, $subscriberId); + } + + private function activeReader(): UserMessageBounceReaderInterface + { + return $this->elasticsearchEnabled ? $this->elasticsearchReader : $this->databaseReader; + } +} diff --git a/src/Domain/Messaging/Repository/UserMessageBounceElasticsearchHybridReader.php b/src/Domain/Messaging/Repository/UserMessageBounceElasticsearchHybridReader.php new file mode 100644 index 000000000..9bc94e1fa --- /dev/null +++ b/src/Domain/Messaging/Repository/UserMessageBounceElasticsearchHybridReader.php @@ -0,0 +1,351 @@ + + */ + public function getListBounceTotals(int $listId): array + { + $rows = $this->entityManager->createQueryBuilder() + ->select( + 'subscriber.id AS subscriberId', + 'subscriber.email AS email', + 'subscriber.confirmed AS confirmed', + 'subscriber.blacklisted AS blacklisted' + ) + ->from(Subscriber::class, 'subscriber') + ->innerJoin(Subscription::class, 'subscription', 'ON', 'subscription.subscriber = subscriber') + ->where('IDENTITY(subscription.subscriberList) = :listId') + ->setParameter('listId', $listId) + ->groupBy('subscriber.id, subscriber.email, subscriber.confirmed, subscriber.blacklisted') + ->orderBy('subscriber.id', 'ASC') + ->getQuery() + ->getArrayResult(); + + if ($rows === []) { + return []; + } + + $subscriberIds = array_map(static fn (array $row): int => (int) $row['subscriberId'], $rows); + $totalsByUserId = $this->countsByTermsField('userId', $subscriberIds); + + $result = []; + foreach ($rows as $row) { + $subscriberId = (int) $row['subscriberId']; + $totalBounces = $totalsByUserId[$subscriberId] ?? 0; + + if ($totalBounces === 0) { + continue; + } + + $result[] = [ + 'subscriber_id' => $subscriberId, + 'email' => (string) $row['email'], + 'confirmed' => (bool) $row['confirmed'], + 'blacklisted' => (bool) $row['blacklisted'], + 'total_bounces' => $totalBounces, + ]; + } + + return $result; + } + + /** @return array */ + public function getCampaignBounceTotals(?int $ownerId = null): array + { + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('m.id AS messageId', 'm.content.subject AS subject') + ->from(Message::class, 'm') + ->orderBy('m.id', 'ASC'); + + if ($ownerId !== null) { + $queryBuilder + ->andWhere('IDENTITY(m.owner) = :ownerId') + ->setParameter('ownerId', $ownerId); + } + + /** @var array $rows */ + $rows = $queryBuilder->getQuery()->getArrayResult(); + + if ($rows === []) { + return []; + } + + $messageIds = array_map(static fn (array $row): int => (int) $row['messageId'], $rows); + $totalsByMessageId = $this->countsByTermsField('messageId', $messageIds); + + $result = []; + foreach ($rows as $row) { + $messageId = (int) $row['messageId']; + $totalBounces = $totalsByMessageId[$messageId] ?? 0; + + if ($totalBounces === 0) { + continue; + } + + $result[] = [ + 'message_id' => $messageId, + 'subject' => $row['subject'], + 'total_bounces' => $totalBounces, + ]; + } + + return $result; + } + + /** @return array */ + public function getPaginatedWithJoinNoRelation(int $fromId, int $limit): array + { + $response = $this->client->search( + $this->resolvePhysicalIndexName(), + [ + 'query' => ['range' => ['idSort' => ['gt' => $fromId]]], + 'sort' => [['idSort' => 'asc']], + 'size' => $limit, + ], + ); + + $hits = $response['hits']['hits'] ?? []; + if ($hits === []) { + return []; + } + + $records = array_map($this->hydrate(...), $hits); + $bounceIds = array_values(array_unique(array_map( + static fn (UserMessageBounceRecordInterface $record): int => $record->getBounceId(), + $records + ))); + + $bouncesById = $this->findBouncesByIdIndexedById($bounceIds); + + $result = []; + foreach ($records as $record) { + $bounce = $bouncesById[$record->getBounceId()] ?? null; + + if ($bounce === null) { + continue; + } + + $result[] = ['umb' => $record, 'bounce' => $bounce]; + } + + return $result; + } + + /** + * @return array + */ + public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array + { + /** @var UserMessage[] $userMessages */ + $userMessages = $this->entityManager->createQueryBuilder() + ->select('um') + ->from(UserMessage::class, 'um') + ->where('um.user = :userId') + ->andWhere('um.status = :status') + ->setParameter('userId', $subscriber->getId()) + ->setParameter('status', 'sent') + ->orderBy('um.createdAt', 'DESC') + ->getQuery() + ->getResult(); + + if ($userMessages === []) { + return []; + } + + $docsByMessageId = $this->groupByMessageId($this->fetchDocsByUserId((int) $subscriber->getId())); + $bouncesById = $this->findBouncesByIdIndexedById($this->bounceIdsUsedIn($docsByMessageId)); + + $result = []; + foreach ($userMessages as $userMessage) { + $docs = $docsByMessageId[$userMessage->getMessage()->getId()] ?? []; + + if ($docs === []) { + $result[] = ['um' => $userMessage, 'umb' => null, 'b' => null]; + continue; + } + + foreach ($docs as $doc) { + $result[] = [ + 'um' => $userMessage, + 'umb' => $doc, + 'b' => $bouncesById[$doc->getBounceId()] ?? null, + ]; + } + } + + return $result; + } + + /** @return UserMessageBounceRecordInterface[] */ + private function fetchDocsByUserId(int $userId): array + { + $response = $this->client->search( + $this->resolvePhysicalIndexName(), + [ + 'query' => ['term' => ['userId' => $userId]], + 'sort' => [['idSort' => 'desc']], + 'size' => 10000, + ], + ); + + $hits = $response['hits']['hits'] ?? []; + + return array_map($this->hydrate(...), $hits); + } + + /** + * @param UserMessageBounceRecordInterface[] $docs + * @return array + */ + private function groupByMessageId(array $docs): array + { + $docsByMessageId = []; + foreach ($docs as $doc) { + $docsByMessageId[$doc->getMessageId()][] = $doc; + } + + return $docsByMessageId; + } + + /** + * @param array $docsByMessageId + * @return int[] + */ + private function bounceIdsUsedIn(array $docsByMessageId): array + { + $bounceIds = []; + foreach ($docsByMessageId as $docs) { + foreach ($docs as $doc) { + $bounceIds[] = $doc->getBounceId(); + } + } + + return array_values(array_unique($bounceIds)); + } + + /** + * @param int[] $bounceIds + * @return array + */ + private function findBouncesByIdIndexedById(array $bounceIds): array + { + if ($bounceIds === []) { + return []; + } + + $bouncesById = []; + /** @var Bounce $bounce */ + foreach ($this->entityManager->getRepository(Bounce::class)->findBy(['id' => $bounceIds]) as $bounce) { + $bouncesById[$bounce->getId()] = $bounce; + } + + return $bouncesById; + } + + /** @param array{_source: array} $hit */ + private function hydrate(array $hit): UserMessageBounceReadModel + { + $source = $hit['_source']; + + return new UserMessageBounceReadModel( + id: isset($source['id']) ? (int) $source['id'] : null, + userId: (int) $source['userId'], + messageId: (int) $source['messageId'], + bounceId: (int) $source['bounceId'], + createdAt: isset($source['time']) + ? (DateTime::createFromFormat(DATE_ATOM, $source['time']) ?: new DateTime()) + : new DateTime(), + ); + } + + /** + * Aggregates document counts by an exact-match field, restricted to a given set of ids - used to + * correlate bounce counts (Elasticsearch) with rows from a small, non-"big table" DB query + * (subscribers in a list, messages owned by an admin) without joining across data stores. + * + * @param int[] $ids + * @return array counts keyed by id + */ + private function countsByTermsField(string $field, array $ids): array + { + if ($ids === []) { + return []; + } + + $response = $this->client->search( + $this->resolvePhysicalIndexName(), + [ + 'size' => 0, + 'query' => ['bool' => ['filter' => [['terms' => [$field => $ids]]]]], + 'aggs' => [ + 'by_field' => [ + 'terms' => ['field' => $field, 'size' => count($ids)], + ], + ], + ], + ); + + $counts = []; + foreach ($response['aggregations']['by_field']['buckets'] ?? [] as $bucket) { + $counts[(int) $bucket['key']] = (int) $bucket['doc_count']; + } + + return $counts; + } + + private function resolvePhysicalIndexName(): string + { + return $this->indexPrefix . UserMessageBounce::SEARCH_INDEX_NAME; + } +} diff --git a/src/Domain/Messaging/Repository/UserMessageBounceElasticsearchReader.php b/src/Domain/Messaging/Repository/UserMessageBounceElasticsearchReader.php new file mode 100644 index 000000000..af8c7e1b4 --- /dev/null +++ b/src/Domain/Messaging/Repository/UserMessageBounceElasticsearchReader.php @@ -0,0 +1,166 @@ +getUserId() !== null) { + $mustFilters[] = ['term' => ['userId' => $filter->getUserId()]]; + } + + if ($filter->getMessageId() !== null) { + $mustFilters[] = ['term' => ['messageId' => $filter->getMessageId()]]; + } + + if ($filter->getBounceId() !== null) { + $mustFilters[] = ['term' => ['bounceId' => $filter->getBounceId()]]; + } + + if ($filter->getDateFrom() !== null) { + $mustFilters[] = ['range' => ['time' => ['gte' => $filter->getDateFrom()->format(DATE_ATOM)]]]; + } + + $mustFilters[] = ['range' => ['idSort' => ['gt' => $filter->getLastId()]]]; + + $response = $this->client->search( + $this->resolvePhysicalIndexName(), + [ + 'query' => ['bool' => ['filter' => $mustFilters]], + 'sort' => [['idSort' => 'asc']], + 'size' => $filter->getLimit(), + 'track_total_hits' => true, + ], + ); + + $hits = $response['hits']['hits'] ?? []; + $lastHit = $hits !== [] ? $hits[array_key_last($hits)] : null; + + return new PaginatedResult( + items: array_map($this->hydrate(...), $hits), + total: (int) ($response['hits']['total']['value'] ?? 0), + limit: $filter->getLimit(), + lastId: $lastHit !== null ? (int) $lastHit['_source']['idSort'] : $filter->getLastId(), + ); + } + + /** @return UserMessageBounceRecordInterface[] */ + public function getByUserId(int $userId): array + { + $response = $this->client->search( + $this->resolvePhysicalIndexName(), + [ + 'query' => ['term' => ['userId' => $userId]], + 'sort' => [['idSort' => 'desc']], + 'size' => UserMessageBounce::MAX_RESULTS_BY_USER, + ], + ); + + $hits = $response['hits']['hits'] ?? []; + + return array_map($this->hydrate(...), $hits); + } + + public function getCountByMessageId(int $messageId): int + { + $response = $this->client->search( + $this->resolvePhysicalIndexName(), + [ + 'query' => ['term' => ['messageId' => $messageId]], + 'size' => 0, + 'track_total_hits' => true, + ], + ); + + return (int) ($response['hits']['total']['value'] ?? 0); + } + + public function countBetween(DateTimeInterface $start, DateTimeInterface $end): int + { + $response = $this->client->search( + $this->resolvePhysicalIndexName(), + [ + 'query' => ['range' => ['time' => [ + 'gte' => $start->format(DateTimeInterface::ATOM), + 'lte' => $end->format(DateTimeInterface::ATOM), + ]]], + 'size' => 0, + 'track_total_hits' => true, + ], + ); + + return (int) ($response['hits']['total']['value'] ?? 0); + } + + public function existsByMessageIdAndUserId(int $messageId, int $subscriberId): bool + { + $response = $this->client->search( + $this->resolvePhysicalIndexName(), + [ + 'query' => ['bool' => ['filter' => [ + ['term' => ['messageId' => $messageId]], + ['term' => ['userId' => $subscriberId]], + ]]], + 'size' => 0, + 'track_total_hits' => true, + ], + ); + + return ((int) ($response['hits']['total']['value'] ?? 0)) > 0; + } + + /** @param array{_source: array} $hit */ + private function hydrate(array $hit): UserMessageBounceReadModel + { + $source = $hit['_source']; + + return new UserMessageBounceReadModel( + id: isset($source['id']) ? (int) $source['id'] : null, + userId: (int) $source['userId'], + messageId: (int) $source['messageId'], + bounceId: (int) $source['bounceId'], + createdAt: isset($source['time']) + ? (DateTime::createFromFormat(DATE_ATOM, $source['time']) ?: new DateTime()) + : new DateTime(), + ); + } + + private function resolvePhysicalIndexName(): string + { + return $this->indexPrefix . UserMessageBounce::SEARCH_INDEX_NAME; + } +} diff --git a/src/Domain/Messaging/Repository/UserMessageBounceReportConfigurableReader.php b/src/Domain/Messaging/Repository/UserMessageBounceReportConfigurableReader.php new file mode 100644 index 000000000..7f154402b --- /dev/null +++ b/src/Domain/Messaging/Repository/UserMessageBounceReportConfigurableReader.php @@ -0,0 +1,39 @@ +activeReader()->getListBounceTotals($listId); + } + + public function getCampaignBounceTotals(?int $ownerId = null): array + { + return $this->activeReader()->getCampaignBounceTotals($ownerId); + } + + private function activeReader(): UserMessageBounceReportReaderInterface + { + return $this->elasticsearchEnabled ? $this->elasticsearchReader : $this->databaseReader; + } +} diff --git a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php index c677e5c16..4d99217d5 100644 --- a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php +++ b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php @@ -5,20 +5,98 @@ namespace PhpList\Core\Domain\Messaging\Repository; use DateTimeInterface; +use InvalidArgumentException; +use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface; +use PhpList\Core\Domain\Common\Model\PaginatedResult; 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\Filter\UserMessageBounceFilter; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\UserMessage; use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; +use PhpList\Core\Domain\Messaging\Repository\Interfaces\UserMessageBounceReaderInterface; +use PhpList\Core\Domain\Messaging\Repository\Interfaces\UserMessageBounceReportReaderInterface; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\Subscription; -class UserMessageBounceRepository extends AbstractRepository implements PaginatableRepositoryInterface +class UserMessageBounceRepository extends AbstractRepository implements + PaginatableRepositoryInterface, + UserMessageBounceReaderInterface, + UserMessageBounceReportReaderInterface { use CursorPaginationTrait; + /** + * @return PaginatedResult + * @throws InvalidArgumentException + */ + public function getFilteredAfterId(FilterRequestInterface $filter): PaginatedResult + { + if (!$filter instanceof UserMessageBounceFilter) { + throw new InvalidArgumentException('Expected UserMessageBounceFilter.'); + } + + $lastId = $filter->getLastId(); + $limit = $filter->getLimit(); + $queryBuilder = $this->createQueryBuilder('umb'); + + if ($filter->getUserId() !== null) { + $queryBuilder->andWhere('umb.userId = :userId') + ->setParameter('userId', $filter->getUserId()); + } + + if ($filter->getMessageId() !== null) { + $queryBuilder->andWhere('umb.messageId = :messageId') + ->setParameter('messageId', $filter->getMessageId()); + } + + if ($filter->getBounceId() !== null) { + $queryBuilder->andWhere('umb.bounceId = :bounceId') + ->setParameter('bounceId', $filter->getBounceId()); + } + + if ($filter->getDateFrom() !== null) { + $queryBuilder->andWhere('umb.createdAt >= :dateFrom') + ->setParameter('dateFrom', $filter->getDateFrom()); + } + + $countQb = clone $queryBuilder; + $total = (int) $countQb + ->select('COUNT(DISTINCT umb.id)') + ->getQuery() + ->getSingleScalarResult(); + + /** @var list $items */ + $items = $queryBuilder + ->andWhere('umb.id > :lastId') + ->setParameter('lastId', $lastId) + ->orderBy('umb.id', 'ASC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + + return new PaginatedResult( + items: $items, + total: $total, + limit: $limit, + lastId: $items !== [] ? $items[array_key_last($items)]->getId() : $lastId, + ); + } + + /** @return UserMessageBounce[] */ + public function getByUserId(int $userId): array + { + return $this->createQueryBuilder('umb') + ->andWhere('umb.userId = :userId') + ->setParameter('userId', $userId) + ->orderBy('umb.id', 'DESC') + ->setMaxResults(UserMessageBounce::MAX_RESULTS_BY_USER) + ->getQuery() + ->getResult(); + } + public function getCountByMessageId(int $messageId): int { return (int) $this->createQueryBuilder('umb') @@ -188,7 +266,7 @@ public function getUserMessageHistoryWithBounces(Subscriber $subscriber): array ->andWhere('um.status = :status') ->setParameter('userId', $subscriber->getId()) ->setParameter('status', 'sent') - ->orderBy('um.entered', 'DESC') + ->orderBy('um.createdAt', 'DESC') ->getQuery() ->getResult(); } diff --git a/src/Domain/Messaging/Service/Manager/BounceManager.php b/src/Domain/Messaging/Service/Manager/BounceManager.php index bae5e0942..e8e7c497a 100644 --- a/src/Domain/Messaging/Service/Manager/BounceManager.php +++ b/src/Domain/Messaging/Service/Manager/BounceManager.php @@ -89,6 +89,8 @@ public function linkUserMessageBounce( $userMessageBounce->setUserId($subscriberId); $userMessageBounce->setMessageId($messageId); + $this->userMessageBounceRepo->persist($userMessageBounce); + return $userMessageBounce; } diff --git a/src/Domain/Messaging/Service/Search/UserMessageBounceIndexDefinition.php b/src/Domain/Messaging/Service/Search/UserMessageBounceIndexDefinition.php new file mode 100644 index 000000000..292921692 --- /dev/null +++ b/src/Domain/Messaging/Service/Search/UserMessageBounceIndexDefinition.php @@ -0,0 +1,37 @@ + [ + 'id' => ['type' => 'keyword'], + // Numeric mirror of `id`, used for range/sort in cursor pagination - `id` stays a + // keyword for exact-match filtering. + 'idSort' => ['type' => 'long'], + 'userId' => ['type' => 'keyword'], + 'messageId' => ['type' => 'keyword'], + 'bounceId' => ['type' => 'keyword'], + 'time' => ['type' => 'date'], + ], + ]; + } + + public function getSettings(): array + { + return []; + } +} diff --git a/src/Domain/Messaging/Service/Search/UserMessageBounceReindexProvider.php b/src/Domain/Messaging/Service/Search/UserMessageBounceReindexProvider.php new file mode 100644 index 000000000..34c9be224 --- /dev/null +++ b/src/Domain/Messaging/Service/Search/UserMessageBounceReindexProvider.php @@ -0,0 +1,40 @@ +repository->createQueryBuilder('umb') + ->select('COUNT(umb.id)') + ->getQuery() + ->getSingleScalarResult(); + } + + public function fetchBatch(int $lastId, int $batchSize): iterable + { + return $this->repository->createQueryBuilder('umb') + ->andWhere('umb.id > :lastId') + ->setParameter('lastId', $lastId) + ->orderBy('umb.id', 'ASC') + ->setMaxResults($batchSize) + ->getQuery() + ->toIterable(); + } +} diff --git a/src/Domain/Search/Client/ElasticsearchClientAdapter.php b/src/Domain/Search/Client/ElasticsearchClientAdapter.php new file mode 100644 index 000000000..b1bbefb01 --- /dev/null +++ b/src/Domain/Search/Client/ElasticsearchClientAdapter.php @@ -0,0 +1,114 @@ +call(function () use ($indexName, $documentId, $document, $revision): void { + try { + $this->client->index([ + 'index' => $indexName, + 'id' => $documentId, + 'body' => $document, + 'version' => $revision, + 'version_type' => 'external_gte', + ]); + } catch (ClientResponseException $exception) { + if ($exception->getCode() !== self::HTTP_CONFLICT) { + throw $exception; + } + } + }); + } + + public function delete(string $indexName, string $documentId, int $revision): void + { + $this->call(function () use ($indexName, $documentId, $revision): void { + try { + $this->client->delete([ + 'index' => $indexName, + 'id' => $documentId, + 'version' => $revision, + 'version_type' => 'external_gte', + ]); + } catch (ClientResponseException $exception) { + if (!in_array($exception->getCode(), [self::HTTP_NOT_FOUND, self::HTTP_CONFLICT], true)) { + throw $exception; + } + } + }); + } + + public function indexExists(string $indexName): bool + { + return $this->call(fn (): bool => $this->client->indices()->exists(['index' => $indexName])->asBool()); + } + + public function createIndex(string $indexName, array $mapping, array $settings): void + { + $this->call(function () use ($indexName, $mapping, $settings): void { + $body = ['mappings' => $mapping]; + if ($settings !== []) { + $body['settings'] = $settings; + } + + $this->client->indices()->create([ + 'index' => $indexName, + 'body' => $body, + ]); + }); + } + + public function updateMapping(string $indexName, array $mapping): void + { + $this->call(function () use ($indexName, $mapping): void { + $this->client->indices()->putMapping([ + 'index' => $indexName, + 'body' => $mapping, + ]); + }); + } + + public function search(string $indexName, array $query): array + { + return $this->call(fn (): array => $this->client->search([ + 'index' => $indexName, + 'body' => $query, + ])->asArray()); + } + + /** + * @template T + * @param callable(): T $operation + * @return T + * @throws SearchBackendUnavailableException + */ + private function call(callable $operation): mixed + { + try { + return $operation(); + } catch (Throwable $exception) { + throw new SearchBackendUnavailableException( + 'Elasticsearch operation failed: ' . $exception->getMessage(), + 0, + $exception, + ); + } + } +} diff --git a/src/Domain/Search/Client/ElasticsearchClientFactory.php b/src/Domain/Search/Client/ElasticsearchClientFactory.php new file mode 100644 index 000000000..1eff7b869 --- /dev/null +++ b/src/Domain/Search/Client/ElasticsearchClientFactory.php @@ -0,0 +1,32 @@ +setHosts($hosts); + + if (!empty($username)) { + $builder->setBasicAuthentication($username, $password ?? ''); + } + + $builder->setHttpClientOptions([ + 'max_connect_duration' => $connectTimeout, + 'timeout' => $requestTimeout, + ]); + + return $builder->build(); + } +} diff --git a/src/Domain/Search/Client/ElasticsearchClientInterface.php b/src/Domain/Search/Client/ElasticsearchClientInterface.php new file mode 100644 index 000000000..b7240c1f1 --- /dev/null +++ b/src/Domain/Search/Client/ElasticsearchClientInterface.php @@ -0,0 +1,54 @@ + $document + * @throws SearchBackendUnavailableException + */ + public function index(string $indexName, string $documentId, array $document, int $revision): void; + + /** + * Returns quietly (idempotent) if the document does not exist, or if $revision is older than the + * revision currently stored for this document. + * @throws SearchBackendUnavailableException + */ + public function delete(string $indexName, string $documentId, int $revision): void; + + /** @throws SearchBackendUnavailableException */ + public function indexExists(string $indexName): bool; + + /** + * @param array $mapping + * @param array $settings + * @throws SearchBackendUnavailableException + */ + public function createIndex(string $indexName, array $mapping, array $settings): void; + + /** + * @param array $mapping + * @throws SearchBackendUnavailableException + */ + public function updateMapping(string $indexName, array $mapping): void; + + /** + * @param array $query + * @return array Raw decoded ES response body. + * @throws SearchBackendUnavailableException + */ + public function search(string $indexName, array $query): array; +} diff --git a/src/Domain/Search/Command/InitSearchIndicesCommand.php b/src/Domain/Search/Command/InitSearchIndicesCommand.php new file mode 100644 index 000000000..a3e5e6dcd --- /dev/null +++ b/src/Domain/Search/Command/InitSearchIndicesCommand.php @@ -0,0 +1,63 @@ +addOption( + 'index', + null, + InputOption::VALUE_REQUIRED, + 'Only create/update the index for this alias (e.g. subscriber_history)', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $alias = $input->getOption('index'); + + $definitions = $alias !== null + ? array_filter([$this->registry->find($alias)]) + : $this->registry->getAll(); + + if ($definitions === []) { + $io->warning($alias !== null + ? sprintf('No index definition registered for alias "%s".', $alias) + : 'No index definitions registered.'); + + return Command::SUCCESS; + } + + foreach ($definitions as $definition) { + $this->indexer->createOrUpdateIndex($definition); + $io->writeln(sprintf('OK %s', $definition->getIndexAlias())); + } + + return Command::SUCCESS; + } +} diff --git a/src/Domain/Search/Command/PurgeSearchIndexedRowsCommand.php b/src/Domain/Search/Command/PurgeSearchIndexedRowsCommand.php new file mode 100644 index 000000000..02baf2939 --- /dev/null +++ b/src/Domain/Search/Command/PurgeSearchIndexedRowsCommand.php @@ -0,0 +1,210 @@ +addArgument('alias', InputArgument::OPTIONAL, 'Purge only this alias (default: all configured)') + ->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Rows per batch', self::DEFAULT_BATCH_SIZE) + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report counts without deleting anything'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $alias = $input->getArgument('alias'); + $batchSize = (int) $input->getOption('batch-size'); + $dryRun = (bool) $input->getOption('dry-run'); + + if ($batchSize < 1) { + $io->error('The --batch-size option must be greater than zero.'); + return Command::FAILURE; + } + + if ($alias !== null) { + $provider = $this->registry->find($alias); + + if ($provider === null) { + $io->error(sprintf('No purge provider registered for alias "%s".', $alias)); + return Command::FAILURE; + } + + if ($provider->getRetentionPeriod() === null) { + $io->error(sprintf('No retention period configured for alias "%s".', $alias)); + return Command::FAILURE; + } + + $providers = [$provider]; + } else { + $providers = array_filter( + $this->registry->getAll(), + static fn (SearchPurgeProviderInterface $provider): bool => $provider->getRetentionPeriod() !== null, + ); + } + + if ($providers === []) { + $io->warning('No purge providers with a configured retention period.'); + return Command::SUCCESS; + } + + foreach ($providers as $provider) { + $this->purgeProvider($provider, $batchSize, $dryRun, $io); + } + + return Command::SUCCESS; + } + + private function purgeProvider( + SearchPurgeProviderInterface $provider, + int $batchSize, + bool $dryRun, + SymfonyStyle $io, + ): void { + $cutoff = (new DateTimeImmutable())->sub($provider->getRetentionPeriod()); + $io->writeln(sprintf( + '%s: purging rows older than %s', + $provider->getAlias(), + $cutoff->format(DateTimeImmutable::ATOM), + )); + + $lastId = 0; + $scanned = 0; + $deleted = 0; + $skipped = []; + + do { + $batch = [...$provider->fetchBatchOlderThan($cutoff, $lastId, $batchSize)]; + $countInBatch = count($batch); + + if ($countInBatch === 0) { + break; + } + + $result = $this->purgeBatch($provider, $batch, $dryRun); + $deleted += $result['deleted']; + array_push($skipped, ...$result['skipped']); + $scanned += $countInBatch; + $lastId = $result['lastId']; + } while ($countInBatch >= $batchSize); + + $this->reportResults($provider, $scanned, $deleted, $skipped, $dryRun, $io); + } + + /** + * @param SearchIndexableInterface[] $batch + * @return array{deleted: int, skipped: int[], lastId: int} + */ + private function purgeBatch(SearchPurgeProviderInterface $provider, array $batch, bool $dryRun): array + { + $docIds = array_map( + static fn (SearchIndexableInterface $entity): string => $entity->getSearchDocumentId(), + $batch, + ); + $confirmedIds = $this->confirmedInElasticsearch($provider->getSearchIndexName(), $docIds); + + $confirmedEntityIds = []; + $skipped = []; + foreach ($docIds as $docId) { + if (in_array($docId, $confirmedIds, true)) { + $confirmedEntityIds[] = (int) $docId; + } else { + $skipped[] = (int) $docId; + } + } + + $deleted = count($confirmedEntityIds); + if (!$dryRun && $confirmedEntityIds !== []) { + $deleted = $provider->deleteByIds($confirmedEntityIds); + } + + return [ + 'deleted' => $deleted, + 'skipped' => $skipped, + 'lastId' => (int) $docIds[array_key_last($docIds)], + ]; + } + + /** @param int[] $skipped */ + private function reportResults( + SearchPurgeProviderInterface $provider, + int $scanned, + int $deleted, + array $skipped, + bool $dryRun, + SymfonyStyle $io, + ): void { + if ($skipped !== []) { + $io->warning(sprintf( + '%s: %d row(s) older than cutoff were not found in Elasticsearch and were left in place: %s', + $provider->getAlias(), + count($skipped), + implode(', ', array_slice($skipped, 0, 10)) . (count($skipped) > 10 ? ', ...' : ''), + )); + } + + $io->success(sprintf( + '%s: scanned %d row(s), %s %d row(s)%s.', + $provider->getAlias(), + $scanned, + $dryRun ? 'would delete' : 'deleted', + $deleted, + $skipped !== [] ? sprintf(', skipped %d unconfirmed', count($skipped)) : '', + )); + } + + /** @param string[] $docIds @return string[] */ + private function confirmedInElasticsearch(string $indexName, array $docIds): array + { + if ($docIds === []) { + return []; + } + + $response = $this->client->search($indexName, [ + 'size' => count($docIds), + '_source' => false, + 'query' => [ + 'bool' => [ + 'filter' => [ + ['terms' => ['id' => array_map('intval', $docIds)]], + ], + ], + ], + ]); + + return array_map( + static fn (array $hit): string => (string) $hit['_id'], + $response['hits']['hits'] ?? [], + ); + } +} diff --git a/src/Domain/Search/Command/ReindexSearchCommand.php b/src/Domain/Search/Command/ReindexSearchCommand.php new file mode 100644 index 000000000..c87543cc1 --- /dev/null +++ b/src/Domain/Search/Command/ReindexSearchCommand.php @@ -0,0 +1,122 @@ +addArgument('alias', InputArgument::OPTIONAL, 'Reindex only this alias (default: all registered)') + ->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Rows per batch', self::DEFAULT_BATCH_SIZE) + ->addOption( + 'last-id', + null, + InputOption::VALUE_REQUIRED, + 'Resume from this id (only meaningful with a single alias)', + 0, + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $alias = $input->getArgument('alias'); + $batchSize = (int) $input->getOption('batch-size'); + $lastId = (int) $input->getOption('last-id'); + + if ($batchSize < 1) { + $io->error('The --batch-size option must be greater than zero.'); + return Command::FAILURE; + } + + if ($alias === null && $lastId !== 0) { + $io->error('The --last-id option requires an index alias.'); + return Command::FAILURE; + } + + $providers = $alias !== null + ? array_filter([$this->registry->find($alias)]) + : $this->registry->getAll(); + + if ($providers === []) { + $io->warning($alias !== null + ? sprintf('No reindex provider registered for alias "%s".', $alias) + : 'No reindex providers registered.'); + + return Command::SUCCESS; + } + + foreach ($providers as $provider) { + $this->reindexProvider($provider, $lastId, $batchSize, $io); + } + + return Command::SUCCESS; + } + + private function reindexProvider( + SearchReindexProviderInterface $provider, + int $lastId, + int $batchSize, + SymfonyStyle $io, + ): void { + $total = $provider->countAll(); + $io->writeln(sprintf('%s: %d rows total', $provider->getAlias(), $total)); + $progressBar = $io->createProgressBar($total); + + $indexed = 0; + do { + $batch = $provider->fetchBatch($lastId, $batchSize); + $countInBatch = 0; + // Captured once per batch, before any of this batch's ES writes, rather than per document + // at send time: a batch can take a while to send (progress bar, ES round trips), and a + // per-document revision taken at send time could end up newer than a concurrent delete's + // revision for a row this batch already read earlier, resurrecting it in Elasticsearch. + $revision = (int) (microtime(true) * 1_000_000); + + foreach ($batch as $entity) { + $this->indexer->index( + $entity->getSearchIndexName(), + $entity->getSearchDocumentId(), + $entity->toSearchDocument(), + $revision, + ); + $lastId = (int) $entity->getSearchDocumentId(); + $countInBatch++; + $indexed++; + } + + $progressBar->setProgress($indexed); + } while ($countInBatch >= $batchSize); + + $progressBar->finish(); + $io->newLine(2); + $io->success(sprintf('%s: indexed %d rows.', $provider->getAlias(), $indexed)); + } +} diff --git a/src/Domain/Search/Exception/SearchBackendUnavailableException.php b/src/Domain/Search/Exception/SearchBackendUnavailableException.php new file mode 100644 index 000000000..da3dd01f5 --- /dev/null +++ b/src/Domain/Search/Exception/SearchBackendUnavailableException.php @@ -0,0 +1,16 @@ + $document */ + public function __construct( + private readonly string $indexName, + private readonly string $documentId, + private readonly array $document, + private readonly SearchOperation $operation, + private readonly int $revision, + ) { + } + + public function getIndexName(): string + { + return $this->indexName; + } + + public function getDocumentId(): string + { + return $this->documentId; + } + + /** @return array */ + public function getDocument(): array + { + return $this->document; + } + + public function getOperation(): SearchOperation + { + return $this->operation; + } + + public function getRevision(): int + { + return $this->revision; + } +} diff --git a/src/Domain/Search/MessageHandler/IndexDocumentMessageHandler.php b/src/Domain/Search/MessageHandler/IndexDocumentMessageHandler.php new file mode 100644 index 000000000..7896d4cc0 --- /dev/null +++ b/src/Domain/Search/MessageHandler/IndexDocumentMessageHandler.php @@ -0,0 +1,35 @@ +getOperation()) { + SearchOperation::Index => $this->indexer->index( + $message->getIndexName(), + $message->getDocumentId(), + $message->getDocument(), + $message->getRevision(), + ), + SearchOperation::Delete => $this->indexer->delete( + $message->getIndexName(), + $message->getDocumentId(), + $message->getRevision(), + ), + }; + } +} diff --git a/src/Domain/Search/Model/Interfaces/SearchIndexDefinitionInterface.php b/src/Domain/Search/Model/Interfaces/SearchIndexDefinitionInterface.php new file mode 100644 index 000000000..4d7df6790 --- /dev/null +++ b/src/Domain/Search/Model/Interfaces/SearchIndexDefinitionInterface.php @@ -0,0 +1,21 @@ + */ + public function getMapping(): array; + + /** @return array */ + public function getSettings(): array; +} diff --git a/src/Domain/Search/Model/Interfaces/SearchIndexableInterface.php b/src/Domain/Search/Model/Interfaces/SearchIndexableInterface.php new file mode 100644 index 000000000..275ca8f02 --- /dev/null +++ b/src/Domain/Search/Model/Interfaces/SearchIndexableInterface.php @@ -0,0 +1,20 @@ + */ + public function toSearchDocument(): array; +} diff --git a/src/Domain/Search/Model/Interfaces/SearchPurgeProviderInterface.php b/src/Domain/Search/Model/Interfaces/SearchPurgeProviderInterface.php new file mode 100644 index 000000000..35e957638 --- /dev/null +++ b/src/Domain/Search/Model/Interfaces/SearchPurgeProviderInterface.php @@ -0,0 +1,32 @@ + */ + public function fetchBatchOlderThan(DateTimeInterface $cutoff, int $lastId, int $batchSize): iterable; + + /** @param int[] $ids @return int number of rows deleted */ + public function deleteByIds(array $ids): int; +} diff --git a/src/Domain/Search/Model/Interfaces/SearchReindexProviderInterface.php b/src/Domain/Search/Model/Interfaces/SearchReindexProviderInterface.php new file mode 100644 index 000000000..a8bcf9556 --- /dev/null +++ b/src/Domain/Search/Model/Interfaces/SearchReindexProviderInterface.php @@ -0,0 +1,19 @@ + */ + public function fetchBatch(int $lastId, int $batchSize): iterable; +} diff --git a/src/Domain/Search/Model/SearchOperation.php b/src/Domain/Search/Model/SearchOperation.php new file mode 100644 index 000000000..3a905d26f --- /dev/null +++ b/src/Domain/Search/Model/SearchOperation.php @@ -0,0 +1,11 @@ + $definitions */ + public function __construct(iterable $definitions) + { + $this->definitions = $definitions instanceof \Traversable ? iterator_to_array($definitions) : $definitions; + } + + /** @return SearchIndexDefinitionInterface[] */ + public function getAll(): array + { + return $this->definitions; + } + + public function find(string $alias): ?SearchIndexDefinitionInterface + { + foreach ($this->definitions as $definition) { + if ($definition->getIndexAlias() === $alias) { + return $definition; + } + } + + return null; + } +} diff --git a/src/Domain/Search/Registry/SearchPurgeProviderRegistry.php b/src/Domain/Search/Registry/SearchPurgeProviderRegistry.php new file mode 100644 index 000000000..3cd3236e4 --- /dev/null +++ b/src/Domain/Search/Registry/SearchPurgeProviderRegistry.php @@ -0,0 +1,37 @@ + $providers */ + public function __construct(iterable $providers) + { + $this->providers = $providers instanceof Traversable ? iterator_to_array($providers) : $providers; + } + + /** @return SearchPurgeProviderInterface[] */ + public function getAll(): array + { + return $this->providers; + } + + public function find(string $alias): ?SearchPurgeProviderInterface + { + foreach ($this->providers as $provider) { + if ($provider->getAlias() === $alias) { + return $provider; + } + } + + return null; + } +} diff --git a/src/Domain/Search/Registry/SearchReindexProviderRegistry.php b/src/Domain/Search/Registry/SearchReindexProviderRegistry.php new file mode 100644 index 000000000..6f8a1c3c6 --- /dev/null +++ b/src/Domain/Search/Registry/SearchReindexProviderRegistry.php @@ -0,0 +1,36 @@ + $providers */ + public function __construct(iterable $providers) + { + $this->providers = $providers instanceof \Traversable ? iterator_to_array($providers) : $providers; + } + + /** @return SearchReindexProviderInterface[] */ + public function getAll(): array + { + return $this->providers; + } + + public function find(string $alias): ?SearchReindexProviderInterface + { + foreach ($this->providers as $provider) { + if ($provider->getAlias() === $alias) { + return $provider; + } + } + + return null; + } +} diff --git a/src/Domain/Search/Service/ElasticsearchIndexer.php b/src/Domain/Search/Service/ElasticsearchIndexer.php new file mode 100644 index 000000000..317d40c08 --- /dev/null +++ b/src/Domain/Search/Service/ElasticsearchIndexer.php @@ -0,0 +1,45 @@ +client->index($this->resolvePhysicalIndexName($indexAlias), $documentId, $document, $revision); + } + + public function delete(string $indexAlias, string $documentId, int $revision): void + { + $this->client->delete($this->resolvePhysicalIndexName($indexAlias), $documentId, $revision); + } + + public function createOrUpdateIndex(SearchIndexDefinitionInterface $definition): void + { + $indexName = $this->resolvePhysicalIndexName($definition->getIndexAlias()); + + if ($this->client->indexExists($indexName)) { + $this->client->updateMapping($indexName, $definition->getMapping()); + + return; + } + + $this->client->createIndex($indexName, $definition->getMapping(), $definition->getSettings()); + } + + private function resolvePhysicalIndexName(string $indexAlias): string + { + return $this->indexPrefix . $indexAlias; + } +} diff --git a/src/Domain/Search/Service/ElasticsearchIndexerInterface.php b/src/Domain/Search/Service/ElasticsearchIndexerInterface.php new file mode 100644 index 000000000..8e01097dc --- /dev/null +++ b/src/Domain/Search/Service/ElasticsearchIndexerInterface.php @@ -0,0 +1,32 @@ + $document + * @param int $revision Monotonic per-document revision; writes older than the last applied + * revision for this document are dropped instead of applied (see ElasticsearchClientAdapter). + * @throws SearchBackendUnavailableException + */ + public function index(string $indexAlias, string $documentId, array $document, int $revision): void; + + /** + * @param int $revision Monotonic per-document revision; see index(). + * @throws SearchBackendUnavailableException + */ + public function delete(string $indexAlias, string $documentId, int $revision): void; + + /** + * Creates the index with its mapping/settings if absent, otherwise applies the mapping + * non-destructively (never drops/recreates an existing index). + * @throws SearchBackendUnavailableException + */ + public function createOrUpdateIndex(SearchIndexDefinitionInterface $definition): void; +} diff --git a/src/Domain/Subscription/Model/Interfaces/SubscriberHistoryRecordInterface.php b/src/Domain/Subscription/Model/Interfaces/SubscriberHistoryRecordInterface.php new file mode 100644 index 000000000..48292f558 --- /dev/null +++ b/src/Domain/Subscription/Model/Interfaces/SubscriberHistoryRecordInterface.php @@ -0,0 +1,33 @@ +id; + } + + public function getSubscriberId(): ?int + { + return $this->subscriberId; + } + + public function getIp(): ?string + { + return $this->ip; + } + + public function getCreatedAt(): ?DateTime + { + return $this->createdAt; + } + + public function getSummary(): ?string + { + return $this->summary; + } + + public function getDetail(): ?string + { + return $this->detail; + } + + public function getSystemInfo(): ?string + { + return $this->systemInfo; + } +} diff --git a/src/Domain/Subscription/Model/Subscriber.php b/src/Domain/Subscription/Model/Subscriber.php index 532995408..e29a9b7d6 100644 --- a/src/Domain/Subscription/Model/Subscriber.php +++ b/src/Domain/Subscription/Model/Subscriber.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\Subscription\Model\Interfaces\SubscriberHistoryRecordInterface; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; /** @@ -93,6 +94,18 @@ class Subscriber implements DomainModel, Identity, CreationDate, ModificationDat )] private Collection $attributes; + /** + * Doctrine-only bookkeeping, not part of the public API (see getHistory()/setHistory() for that). + * SubscriberHistory's FK has only a DB-level ON DELETE CASCADE - without this mapped association, + * removing a Subscriber straight through the EntityManager would let the database silently drop its + * SubscriberHistory rows without Doctrine ever loading/removing them individually, so + * SearchIndexDoctrineListener would never fire for those rows and their Elasticsearch documents + * would be orphaned. This cascade makes Doctrine remove them itself instead. + * @var Collection + */ + #[ORM\OneToMany(targetEntity: SubscriberHistory::class, mappedBy: 'subscriber', cascade: ['remove'])] + private Collection $historyRecords; + #[ORM\Column(name: 'optedin', type: 'boolean')] private bool $optedIn = false; @@ -114,7 +127,7 @@ class Subscriber implements DomainModel, Identity, CreationDate, ModificationDat #[ORM\Column(name: 'foreignkey', type: 'string', length: 100, nullable: true)] private ?string $foreignKey = null; - /** @var SubscriberHistory[] */ + /** @var SubscriberHistoryRecordInterface[] */ private array $history = []; public function __construct(string $email) @@ -122,6 +135,7 @@ public function __construct(string $email) $this->email = $email; $this->subscriptions = new ArrayCollection(); $this->attributes = new ArrayCollection(); + $this->historyRecords = new ArrayCollection(); $this->extraData = ''; $this->createdAt = new DateTime(); $this->updatedAt = new DateTime(); @@ -378,7 +392,7 @@ public function setForeignKey(?string $foreignKey): void } /** - * @return SubscriberHistory[] + * @return SubscriberHistoryRecordInterface[] */ public function getHistory(): array { @@ -386,7 +400,7 @@ public function getHistory(): array } /** - * @param SubscriberHistory[] $history + * @param SubscriberHistoryRecordInterface[] $history */ public function setHistory(array $history): void { diff --git a/src/Domain/Subscription/Model/SubscriberHistory.php b/src/Domain/Subscription/Model/SubscriberHistory.php index 08f4f974b..91991c446 100644 --- a/src/Domain/Subscription/Model/SubscriberHistory.php +++ b/src/Domain/Subscription/Model/SubscriberHistory.php @@ -5,23 +5,33 @@ namespace PhpList\Core\Domain\Subscription\Model; use DateTime; +use DateTimeInterface; 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\Search\Model\Interfaces\SearchIndexableInterface; +use PhpList\Core\Domain\Subscription\Model\Interfaces\SubscriberHistoryRecordInterface; use PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository; #[ORM\Entity(repositoryClass: SubscriberHistoryRepository::class)] #[ORM\Table(name: 'user_user_history')] #[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 +class SubscriberHistory implements + DomainModel, + Identity, + SearchIndexableInterface, + SubscriberHistoryRecordInterface { + public const SEARCH_INDEX_NAME = 'subscriber_history'; + public const MAX_RESULTS_BY_USER = 1000; + #[ORM\Id] #[ORM\Column(type: 'integer')] #[ORM\GeneratedValue] private ?int $id = null; - #[ORM\ManyToOne(targetEntity: Subscriber::class)] + #[ORM\ManyToOne(targetEntity: Subscriber::class, inversedBy: 'historyRecords')] #[ORM\JoinColumn(name: 'userid', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] private Subscriber $subscriber; @@ -56,6 +66,11 @@ public function getSubscriber(): Subscriber return $this->subscriber; } + public function getSubscriberId(): ?int + { + return $this->subscriber->getId(); + } + public function getIp(): ?string { return $this->ip; @@ -110,4 +125,28 @@ public function setSystemInfo(?string $systemInfo): self $this->systemInfo = $systemInfo; return $this; } + + public function getSearchIndexName(): string + { + return self::SEARCH_INDEX_NAME; + } + + public function getSearchDocumentId(): string + { + return (string) $this->id; + } + + public function toSearchDocument(): array + { + return [ + 'id' => $this->id, + 'idSort' => $this->id, + 'subscriberId' => $this->getSubscriberId(), + 'ip' => $this->ip, + 'date' => $this->createdAt?->format(DateTimeInterface::ATOM), + 'summary' => $this->summary, + 'detail' => $this->detail, + 'systemInfo' => $this->systemInfo, + ]; + } } diff --git a/src/Domain/Subscription/Repository/Interfaces/SubscriberHistoryReaderInterface.php b/src/Domain/Subscription/Repository/Interfaces/SubscriberHistoryReaderInterface.php new file mode 100644 index 000000000..3b86e4000 --- /dev/null +++ b/src/Domain/Subscription/Repository/Interfaces/SubscriberHistoryReaderInterface.php @@ -0,0 +1,24 @@ + */ + public function getFilteredAfterId(FilterRequestInterface $filter): PaginatedResult; + + /** @return SubscriberHistoryRecordInterface[] */ + public function getBySubscriber(Subscriber $subscriber): array; +} diff --git a/src/Domain/Subscription/Repository/SubscriberHistoryConfigurableReader.php b/src/Domain/Subscription/Repository/SubscriberHistoryConfigurableReader.php new file mode 100644 index 000000000..c96af0779 --- /dev/null +++ b/src/Domain/Subscription/Repository/SubscriberHistoryConfigurableReader.php @@ -0,0 +1,44 @@ +activeReader()->getFilteredAfterId($filter); + } + + /** @return SubscriberHistoryRecordInterface[] */ + public function getBySubscriber(Subscriber $subscriber): array + { + return $this->activeReader()->getBySubscriber($subscriber); + } + + private function activeReader(): SubscriberHistoryReaderInterface + { + return $this->elasticsearchEnabled ? $this->elasticsearchReader : $this->databaseReader; + } +} diff --git a/src/Domain/Subscription/Repository/SubscriberHistoryElasticsearchReader.php b/src/Domain/Subscription/Repository/SubscriberHistoryElasticsearchReader.php new file mode 100644 index 000000000..deb109fb6 --- /dev/null +++ b/src/Domain/Subscription/Repository/SubscriberHistoryElasticsearchReader.php @@ -0,0 +1,116 @@ +getSubscriber() !== null) { + $mustFilters[] = ['term' => ['subscriberId' => $filter->getSubscriber()->getId()]]; + } + + if ($filter->getDateFrom() !== null) { + $mustFilters[] = ['range' => ['date' => ['gte' => $filter->getDateFrom()->format(DATE_ATOM)]]]; + } + + if ($filter->getIp() !== null) { + $mustFilters[] = ['term' => ['ip' => $filter->getIp()]]; + } + + if ($filter->getSummery() !== null) { + $mustFilters[] = ['term' => ['summary.keyword' => $filter->getSummery()]]; + } + + $mustFilters[] = ['range' => ['idSort' => ['gt' => $filter->getLastId()]]]; + + $response = $this->client->search( + $this->resolvePhysicalIndexName(), + [ + 'query' => ['bool' => ['filter' => $mustFilters]], + 'sort' => [['idSort' => 'asc']], + 'size' => $filter->getLimit(), + 'track_total_hits' => true, + ], + ); + + $hits = $response['hits']['hits'] ?? []; + $lastHit = $hits !== [] ? $hits[array_key_last($hits)] : null; + + return new PaginatedResult( + items: array_map($this->hydrate(...), $hits), + total: (int) ($response['hits']['total']['value'] ?? 0), + limit: $filter->getLimit(), + lastId: $lastHit !== null ? (int) $lastHit['_source']['idSort'] : $filter->getLastId(), + ); + } + + /** @return SubscriberHistoryRecordInterface[] */ + public function getBySubscriber(Subscriber $subscriber): array + { + $response = $this->client->search( + $this->resolvePhysicalIndexName(), + [ + 'query' => ['term' => ['subscriberId' => $subscriber->getId()]], + 'sort' => [['idSort' => 'desc']], + 'size' => SubscriberHistory::MAX_RESULTS_BY_USER, + ], + ); + + $hits = $response['hits']['hits'] ?? []; + + return array_map($this->hydrate(...), $hits); + } + + /** @param array{_source: array} $hit */ + private function hydrate(array $hit): SubscriberHistoryReadModel + { + $source = $hit['_source']; + + return new SubscriberHistoryReadModel( + id: isset($source['id']) ? (int) $source['id'] : null, + subscriberId: isset($source['subscriberId']) ? (int) $source['subscriberId'] : null, + ip: $source['ip'] ?? null, + createdAt: isset($source['date']) ? DateTime::createFromFormat(DATE_ATOM, $source['date']) ?: null : null, + summary: $source['summary'] ?? null, + detail: $source['detail'] ?? null, + systemInfo: $source['systemInfo'] ?? null, + ); + } + + private function resolvePhysicalIndexName(): string + { + return $this->indexPrefix . SubscriberHistory::SEARCH_INDEX_NAME; + } +} diff --git a/src/Domain/Subscription/Repository/SubscriberHistoryRepository.php b/src/Domain/Subscription/Repository/SubscriberHistoryRepository.php index 137faa84a..107e5d843 100644 --- a/src/Domain/Subscription/Repository/SubscriberHistoryRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberHistoryRepository.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Domain\Subscription\Repository; +use DateTimeInterface; use InvalidArgumentException; use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface; use PhpList\Core\Domain\Common\Model\PaginatedResult; @@ -13,8 +14,11 @@ 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\Interfaces\SubscriberHistoryReaderInterface; -class SubscriberHistoryRepository extends AbstractRepository implements PaginatableRepositoryInterface +class SubscriberHistoryRepository extends AbstractRepository implements + PaginatableRepositoryInterface, + SubscriberHistoryReaderInterface { use CursorPaginationTrait; @@ -82,7 +86,47 @@ public function getBySubscriber(Subscriber $subscriber): array ->andWhere('sh.subscriber = :subscriberId') ->setParameter('subscriberId', $subscriber->getId()) ->orderBy('sh.id', 'DESC') + ->setMaxResults(SubscriberHistory::MAX_RESULTS_BY_USER) ->getQuery() ->getResult(); } + + public function countOlderThan(DateTimeInterface $cutoff): int + { + return (int) $this->createQueryBuilder('sh') + ->select('COUNT(sh.id)') + ->andWhere('sh.createdAt < :cutoff') + ->setParameter('cutoff', $cutoff) + ->getQuery() + ->getSingleScalarResult(); + } + + /** @return iterable */ + public function fetchBatchOlderThan(DateTimeInterface $cutoff, int $lastId, int $batchSize): iterable + { + return $this->createQueryBuilder('sh') + ->andWhere('sh.id > :lastId') + ->andWhere('sh.createdAt < :cutoff') + ->setParameter('lastId', $lastId) + ->setParameter('cutoff', $cutoff) + ->orderBy('sh.id', 'ASC') + ->setMaxResults($batchSize) + ->getQuery() + ->toIterable(); + } + + /** @param int[] $ids */ + public function deleteByIds(array $ids): int + { + if ($ids === []) { + return 0; + } + + return $this->createQueryBuilder('sh') + ->delete() + ->andWhere('sh.id IN (:ids)') + ->setParameter('ids', $ids) + ->getQuery() + ->execute(); + } } diff --git a/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php b/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php index bd36422d0..5338580b5 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberHistoryManager.php @@ -10,39 +10,40 @@ 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\Interfaces\SubscriberHistoryRecordInterface; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberHistory; -use PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository; +use PhpList\Core\Domain\Subscription\Repository\Interfaces\SubscriberHistoryReaderInterface; use Symfony\Contracts\Translation\TranslatorInterface; class SubscriberHistoryManager { - private SubscriberHistoryRepository $repository; + private SubscriberHistoryReaderInterface $reader; private ClientIpResolver $clientIpResolver; private SystemInfoCollector $systemInfoCollector; private TranslatorInterface $translator; private EntityManagerInterface $entityManager; public function __construct( - SubscriberHistoryRepository $repository, + SubscriberHistoryReaderInterface $reader, ClientIpResolver $clientIpResolver, SystemInfoCollector $systemInfoCollector, TranslatorInterface $translator, EntityManagerInterface $entityManager, ) { - $this->repository = $repository; + $this->reader = $reader; $this->clientIpResolver = $clientIpResolver; $this->systemInfoCollector = $systemInfoCollector; $this->translator = $translator; $this->entityManager = $entityManager; } - /** @return SubscriberHistory[] */ + /** @return SubscriberHistoryRecordInterface[] */ public function getHistory(int $lastId, int $limit, SubscriberHistoryFilter $filter): array { $filter->setLastId($lastId)->setLimit($limit); - return $this->repository->getFilteredAfterId($filter)->getItems(); + return $this->reader->getFilteredAfterId($filter)->getItems(); } public function addHistory(Subscriber $subscriber, string $message, ?string $details = null): SubscriberHistory diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php index e13980ae6..189a28817 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php @@ -11,7 +11,7 @@ use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Dto\UpdateSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Subscriber; -use PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository; +use PhpList\Core\Domain\Subscription\Repository\Interfaces\SubscriberHistoryReaderInterface; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -25,7 +25,7 @@ public function __construct( private readonly SubscriberDeletionService $subscriberDeletionService, private readonly TranslatorInterface $translator, private readonly SubscriberHistoryManager $subscriberHistoryManager, - private readonly SubscriberHistoryRepository $subscriberHistoryRepository, + private readonly SubscriberHistoryReaderInterface $subscriberHistoryReader, ) { } @@ -55,7 +55,7 @@ public function getSubscriberDetails(int $subscriberId): ?Subscriber return null; } - $history = $this->subscriberHistoryRepository->getBySubscriber($subscriber); + $history = $this->subscriberHistoryReader->getBySubscriber($subscriber); $subscriber->setHistory($history); return $subscriber; diff --git a/src/Domain/Subscription/Service/Search/SubscriberHistoryIndexDefinition.php b/src/Domain/Subscription/Service/Search/SubscriberHistoryIndexDefinition.php new file mode 100644 index 000000000..521714299 --- /dev/null +++ b/src/Domain/Subscription/Service/Search/SubscriberHistoryIndexDefinition.php @@ -0,0 +1,44 @@ + [ + 'id' => ['type' => 'keyword'], + // Numeric mirror of `id`, used for range/sort in cursor pagination - `id` stays a + // keyword for exact-match filtering. + 'idSort' => ['type' => 'long'], + 'subscriberId' => ['type' => 'keyword'], + 'ip' => ['type' => 'keyword'], + 'date' => ['type' => 'date'], + 'summary' => [ + 'type' => 'text', + 'fields' => [ + 'keyword' => ['type' => 'keyword', 'ignore_above' => 256], + ], + ], + 'detail' => ['type' => 'text'], + 'systemInfo' => ['type' => 'text'], + ], + ]; + } + + public function getSettings(): array + { + return []; + } +} diff --git a/src/Domain/Subscription/Service/Search/SubscriberHistoryPurgeProvider.php b/src/Domain/Subscription/Service/Search/SubscriberHistoryPurgeProvider.php new file mode 100644 index 000000000..20efdd837 --- /dev/null +++ b/src/Domain/Subscription/Service/Search/SubscriberHistoryPurgeProvider.php @@ -0,0 +1,51 @@ +retentionPeriod !== '' ? new DateInterval($this->retentionPeriod) : null; + } + + public function getSearchIndexName(): string + { + return $this->indexPrefix . SubscriberHistory::SEARCH_INDEX_NAME; + } + + public function countOlderThan(DateTimeInterface $cutoff): int + { + return $this->repository->countOlderThan($cutoff); + } + + public function fetchBatchOlderThan(DateTimeInterface $cutoff, int $lastId, int $batchSize): iterable + { + return $this->repository->fetchBatchOlderThan($cutoff, $lastId, $batchSize); + } + + public function deleteByIds(array $ids): int + { + return $this->repository->deleteByIds($ids); + } +} diff --git a/src/Domain/Subscription/Service/Search/SubscriberHistoryReindexProvider.php b/src/Domain/Subscription/Service/Search/SubscriberHistoryReindexProvider.php new file mode 100644 index 000000000..e32ced1d8 --- /dev/null +++ b/src/Domain/Subscription/Service/Search/SubscriberHistoryReindexProvider.php @@ -0,0 +1,40 @@ +repository->createQueryBuilder('sh') + ->select('COUNT(sh.id)') + ->getQuery() + ->getSingleScalarResult(); + } + + public function fetchBatch(int $lastId, int $batchSize): iterable + { + return $this->repository->createQueryBuilder('sh') + ->andWhere('sh.id > :lastId') + ->setParameter('lastId', $lastId) + ->orderBy('sh.id', 'ASC') + ->setMaxResults($batchSize) + ->getQuery() + ->toIterable(); + } +} diff --git a/tests/Integration/Domain/Messaging/Repository/UserMessageBounceElasticsearchHybridReaderTest.php b/tests/Integration/Domain/Messaging/Repository/UserMessageBounceElasticsearchHybridReaderTest.php new file mode 100644 index 000000000..4459a4dad --- /dev/null +++ b/tests/Integration/Domain/Messaging/Repository/UserMessageBounceElasticsearchHybridReaderTest.php @@ -0,0 +1,318 @@ +loadSchema(); + + $this->client = $this->createMock(ElasticsearchClientInterface::class); + $this->reader = new UserMessageBounceElasticsearchHybridReader($this->client, 'phplist_', $this->entityManager); + } + + protected function tearDown(): void + { + $schemaTool = new SchemaTool($this->entityManager); + $schemaTool->dropDatabase(); + parent::tearDown(); + } + + public function testGetListBounceTotalsMergesElasticsearchCountsWithSubscriberData(): void + { + $admin = (new Administrator()) + ->setLoginName('admin') + ->setEmail('admin@example.com'); + $this->entityManager->persist($admin); + + $targetList = (new SubscriberList())->setName('Target list')->setOwner($admin); + $otherList = (new SubscriberList())->setName('Other list')->setOwner($admin); + $this->entityManager->persist($targetList); + $this->entityManager->persist($otherList); + + $subscriber1 = (new Subscriber('one@example.com'))->setConfirmed(true)->setBlacklisted(false); + $subscriber2 = (new Subscriber('two@example.com'))->setConfirmed(false)->setBlacklisted(true); + $subscriber3 = (new Subscriber('three@example.com'))->setConfirmed(true)->setBlacklisted(false); + $this->entityManager->persist($subscriber1); + $this->entityManager->persist($subscriber2); + $this->entityManager->persist($subscriber3); + $this->entityManager->flush(); + + $subscription1 = (new Subscription())->setSubscriber($subscriber1)->setSubscriberList($targetList); + $subscription2 = (new Subscription())->setSubscriber($subscriber2)->setSubscriberList($targetList); + $subscription3 = (new Subscription())->setSubscriber($subscriber3)->setSubscriberList($otherList); + $this->entityManager->persist($subscription1); + $this->entityManager->persist($subscription2); + $this->entityManager->persist($subscription3); + $this->entityManager->flush(); + + $this->client + ->expects($this->once()) + ->method('search') + ->with( + 'phplist_user_message_bounce', + $this->callback(function (array $query) use ($subscriber1, $subscriber2): bool { + return $query['query']['bool']['filter'][0]['terms']['userId'] === [ + $subscriber1->getId(), + $subscriber2->getId(), + ]; + }), + ) + ->willReturn([ + 'aggregations' => [ + 'by_field' => [ + 'buckets' => [ + ['key' => $subscriber1->getId(), 'doc_count' => 2], + ['key' => $subscriber2->getId(), 'doc_count' => 1], + ], + ], + ], + ]); + + $rows = $this->reader->getListBounceTotals($targetList->getId()); + + self::assertSame( + [ + [ + 'subscriber_id' => $subscriber1->getId(), + 'email' => 'one@example.com', + 'confirmed' => true, + 'blacklisted' => false, + 'total_bounces' => 2, + ], + [ + 'subscriber_id' => $subscriber2->getId(), + 'email' => 'two@example.com', + 'confirmed' => false, + 'blacklisted' => true, + 'total_bounces' => 1, + ], + ], + $rows + ); + } + + public function testGetCampaignBounceTotalsMergesElasticsearchCountsWithMessageData(): void + { + $admin = (new Administrator()) + ->setLoginName('admin') + ->setEmail('admin@example.com'); + $this->entityManager->persist($admin); + $this->entityManager->flush(); + + $message1 = $this->createMessage('Campaign one', $admin); + $message2 = $this->createMessage('Campaign two', $admin); + $this->entityManager->persist($message1); + $this->entityManager->persist($message2); + $this->entityManager->flush(); + + $this->client + ->expects($this->once()) + ->method('search') + ->with( + 'phplist_user_message_bounce', + $this->callback(function (array $query) use ($message1, $message2): bool { + return $query['query']['bool']['filter'][0]['terms']['messageId'] === [ + $message1->getId(), + $message2->getId(), + ]; + }), + ) + ->willReturn([ + 'aggregations' => [ + 'by_field' => [ + 'buckets' => [ + ['key' => $message1->getId(), 'doc_count' => 4], + ], + ], + ], + ]); + + $rows = $this->reader->getCampaignBounceTotals(); + + self::assertSame( + [ + [ + 'message_id' => $message1->getId(), + 'subject' => 'Campaign one', + 'total_bounces' => 4, + ], + ], + $rows + ); + } + + public function testGetPaginatedWithJoinNoRelationHydratesMatchingBounceEntitiesAndSkipsMissingOnes(): void + { + $bounce1 = new Bounce(status: 'new'); + $bounce2 = new Bounce(status: 'processed'); + $this->entityManager->persist($bounce1); + $this->entityManager->persist($bounce2); + $this->entityManager->flush(); + + $this->client + ->expects($this->once()) + ->method('search') + ->with( + 'phplist_user_message_bounce', + $this->callback(function (array $query): bool { + return $query['query'] === ['range' => ['idSort' => ['gt' => 0]]] + && $query['size'] === 10; + }), + ) + ->willReturn([ + 'hits' => [ + 'hits' => [ + ['_source' => [ + 'id' => 1, + 'idSort' => 1, + 'userId' => 5, + 'messageId' => 10, + 'bounceId' => $bounce1->getId(), + 'time' => '2026-01-01T00:00:00+00:00', + ]], + ['_source' => [ + 'id' => 2, + 'idSort' => 2, + 'userId' => 6, + 'messageId' => 11, + // No matching Bounce row - must be skipped, mirroring the SQL inner join. + 'bounceId' => 999999, + 'time' => '2026-01-02T00:00:00+00:00', + ]], + ['_source' => [ + 'id' => 3, + 'idSort' => 3, + 'userId' => 7, + 'messageId' => 12, + 'bounceId' => $bounce2->getId(), + 'time' => '2026-01-03T00:00:00+00:00', + ]], + ], + ], + ]); + + $rows = $this->reader->getPaginatedWithJoinNoRelation(0, 10); + + self::assertCount(2, $rows); + self::assertSame(1, $rows[0]['umb']->getId()); + self::assertSame($bounce1, $rows[0]['bounce']); + self::assertSame(3, $rows[1]['umb']->getId()); + self::assertSame($bounce2, $rows[1]['bounce']); + } + + public function testGetUserMessageHistoryWithBouncesMergesSentMessagesWithBounceDocs(): void + { + $admin = (new Administrator()) + ->setLoginName('admin') + ->setEmail('admin@example.com'); + $this->entityManager->persist($admin); + + $subscriber = new Subscriber('history@example.com'); + $this->entityManager->persist($subscriber); + $this->entityManager->flush(); + + $message1 = $this->createMessage('First', $admin); + $message2 = $this->createMessage('Second', $admin); + $this->entityManager->persist($message1); + $this->entityManager->persist($message2); + $this->entityManager->flush(); + + $bounce = new Bounce(status: 'new'); + $this->entityManager->persist($bounce); + $this->entityManager->flush(); + + $userMessage1 = new UserMessage($subscriber, $message1); + $userMessage1->setStatus(UserMessageStatus::Sent); + $userMessage2 = new UserMessage($subscriber, $message2); + $userMessage2->setStatus(UserMessageStatus::Sent); + $this->entityManager->persist($userMessage1); + $this->entityManager->persist($userMessage2); + $this->entityManager->flush(); + + $this->client + ->expects($this->once()) + ->method('search') + ->with( + 'phplist_user_message_bounce', + $this->callback(function (array $query) use ($subscriber): bool { + return $query['query'] === ['term' => ['userId' => $subscriber->getId()]]; + }), + ) + ->willReturn([ + 'hits' => [ + 'hits' => [ + ['_source' => [ + 'id' => 1, + 'idSort' => 1, + 'userId' => $subscriber->getId(), + 'messageId' => $message1->getId(), + 'bounceId' => $bounce->getId(), + 'time' => '2026-01-01T00:00:00+00:00', + ]], + ], + ], + ]); + + $rows = $this->reader->getUserMessageHistoryWithBounces($subscriber); + + self::assertCount(2, $rows); + + $rowsByMessageId = []; + foreach ($rows as $row) { + $rowsByMessageId[$row['um']->getMessage()->getId()] = $row; + } + + self::assertSame($bounce, $rowsByMessageId[$message1->getId()]['b']); + self::assertSame($bounce->getId(), $rowsByMessageId[$message1->getId()]['umb']->getBounceId()); + self::assertNull($rowsByMessageId[$message2->getId()]['b']); + self::assertNull($rowsByMessageId[$message2->getId()]['umb']); + } + + private function createMessage(string $subject, Administrator $owner): Message + { + return new Message( + new MessageFormat(true, 'text'), + new MessageSchedule(null, null, null, null, null), + new MessageMetadata(), + new MessageContent($subject), + new MessageOptions(), + $owner + ); + } +} diff --git a/tests/Integration/Domain/Messaging/Repository/UserMessageBounceRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/UserMessageBounceRepositoryTest.php index ce3a0e9fa..d7c823267 100644 --- a/tests/Integration/Domain/Messaging/Repository/UserMessageBounceRepositoryTest.php +++ b/tests/Integration/Domain/Messaging/Repository/UserMessageBounceRepositoryTest.php @@ -8,6 +8,7 @@ use Doctrine\ORM\Tools\SchemaTool; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Bounce; +use PhpList\Core\Domain\Messaging\Model\Filter\UserMessageBounceFilter; use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; use PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository; use PhpList\Core\Domain\Subscription\Model\Subscriber; @@ -136,4 +137,42 @@ public function testGetListBounceTotalsReturnsAggregatedBouncesPerSubscriberForL $rows ); } + + public function testGetFilteredAfterIdAdvancesCursorAcrossConsecutivePages(): void + { + $bounce = new Bounce(status: 'new'); + $this->entityManager->persist($bounce); + $this->entityManager->flush(); + + $umb1 = (new UserMessageBounce($bounce->getId(), new DateTime()))->setUserId(1)->setMessageId(10); + $umb2 = (new UserMessageBounce($bounce->getId(), new DateTime()))->setUserId(2)->setMessageId(11); + $this->entityManager->persist($umb1); + $this->entityManager->persist($umb2); + $this->entityManager->flush(); + + $firstPage = $this->repository->getFilteredAfterId(new UserMessageBounceFilter(lastId: 0, limit: 1)); + + self::assertCount(1, $firstPage->getItems()); + self::assertSame($umb1->getId(), $firstPage->getItems()[0]->getId()); + self::assertSame($umb1->getId(), $firstPage->getLastId()); + + $secondPage = $this->repository->getFilteredAfterId( + new UserMessageBounceFilter(lastId: $firstPage->getLastId(), limit: 1) + ); + + self::assertCount(1, $secondPage->getItems()); + self::assertSame($umb2->getId(), $secondPage->getItems()[0]->getId()); + self::assertNotSame( + $firstPage->getItems()[0]->getId(), + $secondPage->getItems()[0]->getId(), + ); + } + + public function testGetFilteredAfterIdKeepsInputLastIdWhenPageIsEmpty(): void + { + $result = $this->repository->getFilteredAfterId(new UserMessageBounceFilter(lastId: 999, limit: 10)); + + self::assertSame([], $result->getItems()); + self::assertSame(999, $result->getLastId()); + } } diff --git a/tests/Integration/Domain/Subscription/Repository/SubscriberHistoryRepositoryTest.php b/tests/Integration/Domain/Subscription/Repository/SubscriberHistoryRepositoryTest.php new file mode 100644 index 000000000..c576618ff --- /dev/null +++ b/tests/Integration/Domain/Subscription/Repository/SubscriberHistoryRepositoryTest.php @@ -0,0 +1,138 @@ +loadSchema(); + + $this->repository = self::getContainer()->get(SubscriberHistoryRepository::class); + } + + protected function tearDown(): void + { + $schemaTool = new SchemaTool($this->entityManager); + $schemaTool->dropDatabase(); + parent::tearDown(); + } + + private function persistHistoryRow(DateTime $date): SubscriberHistory + { + $subscriber = new Subscriber('subscriber-' . uniqid('', true) . '@example.com'); + $this->entityManager->persist($subscriber); + $this->entityManager->flush(); + + $history = new SubscriberHistory($subscriber); + $reflection = new ReflectionProperty(SubscriberHistory::class, 'createdAt'); + $reflection->setAccessible(true); + $reflection->setValue($history, $date); + + $this->entityManager->persist($history); + $this->entityManager->flush(); + + return $history; + } + + public function testCountOlderThanOnlyCountsRowsBeforeCutoff(): void + { + $old = $this->persistHistoryRow(new DateTime('2020-01-01')); + $this->persistHistoryRow(new DateTime('2030-01-01')); + + $count = $this->repository->countOlderThan(new DateTime('2025-01-01')); + + self::assertSame(1, $count); + self::assertNotNull($old->getId()); + } + + public function testFetchBatchOlderThanReturnsOnlyMatchingRowsInIdOrder(): void + { + $old1 = $this->persistHistoryRow(new DateTime('2020-01-01')); + $old2 = $this->persistHistoryRow(new DateTime('2020-06-01')); + $this->persistHistoryRow(new DateTime('2030-01-01')); + + $batch = [...$this->repository->fetchBatchOlderThan(new DateTime('2025-01-01'), 0, 10)]; + + self::assertCount(2, $batch); + self::assertSame($old1->getId(), $batch[0]->getId()); + self::assertSame($old2->getId(), $batch[1]->getId()); + } + + public function testFetchBatchOlderThanRespectsLastIdCursor(): void + { + $old1 = $this->persistHistoryRow(new DateTime('2020-01-01')); + $old2 = $this->persistHistoryRow(new DateTime('2020-06-01')); + + $batch = [...$this->repository->fetchBatchOlderThan(new DateTime('2025-01-01'), $old1->getId(), 10)]; + + self::assertCount(1, $batch); + self::assertSame($old2->getId(), $batch[0]->getId()); + } + + public function testDeleteByIdsRemovesOnlyGivenRows(): void + { + $toDelete = $this->persistHistoryRow(new DateTime('2020-01-01')); + $toKeep = $this->persistHistoryRow(new DateTime('2020-06-01')); + + $deleted = $this->repository->deleteByIds([$toDelete->getId()]); + // bulk DQL delete bypasses the identity map + $this->entityManager->clear(); + + self::assertSame(1, $deleted); + self::assertSame(1, $this->repository->countOlderThan(new DateTime('2025-01-01'))); + self::assertNotNull($this->repository->find($toKeep->getId())); + self::assertNull($this->repository->find($toDelete->getId())); + } + + public function testDeleteByIdsWithEmptyArrayDeletesNothing(): void + { + $this->persistHistoryRow(new DateTime('2020-01-01')); + + $deleted = $this->repository->deleteByIds([]); + + self::assertSame(0, $deleted); + } + + public function testRemovingSubscriberCascadeDeletesItsHistoryRecords(): void + { + $subscriber = new Subscriber('cascade-' . uniqid('', true) . '@example.com'); + $this->entityManager->persist($subscriber); + $this->entityManager->flush(); + + $history = new SubscriberHistory($subscriber); + $this->entityManager->persist($history); + $this->entityManager->flush(); + $historyId = $history->getId(); + $subscriberId = $subscriber->getId(); + + // Removing the Subscriber directly (not via SubscriberDeletionService) must still cascade to + // SubscriberHistory through Doctrine, not rely solely on the DB-level ON DELETE CASCADE, so + // SearchIndexDoctrineListener::preRemove/postRemove fires for the history row too. Cascade + // remove only walks a *loaded* collection, so re-fetch the Subscriber fresh from the DB first, + // as any real caller doing this outside of SubscriberDeletionService's manual loop would. + $this->entityManager->clear(); + $fetchedSubscriber = $this->entityManager->find(Subscriber::class, $subscriberId); + $this->entityManager->remove($fetchedSubscriber); + $this->entityManager->flush(); + $this->entityManager->clear(); + + self::assertNull($this->repository->find($historyId)); + } +} diff --git a/tests/Unit/Core/Doctrine/SearchIndexDoctrineListenerTest.php b/tests/Unit/Core/Doctrine/SearchIndexDoctrineListenerTest.php new file mode 100644 index 000000000..5be2f4d48 --- /dev/null +++ b/tests/Unit/Core/Doctrine/SearchIndexDoctrineListenerTest.php @@ -0,0 +1,173 @@ +messageBus = $this->createMock(MessageBusInterface::class); + $this->objectManager = $this->createMock(EntityManagerInterface::class); + $this->listener = new SearchIndexDoctrineListener($this->messageBus); + } + + private function createIndexable(string $indexName, string $documentId, array $document): SearchIndexableInterface + { + $entity = $this->createMock(SearchIndexableInterface::class); + $entity->method('getSearchIndexName')->willReturn($indexName); + $entity->method('getSearchDocumentId')->willReturn($documentId); + $entity->method('toSearchDocument')->willReturn($document); + + return $entity; + } + + public function testPostPersistDoesNotDispatchBeforePostFlush(): void + { + $entity = $this->createIndexable('subscriber_history', '1', ['id' => 1]); + + $this->messageBus->expects($this->never())->method('dispatch'); + + $this->listener->postPersist(new PostPersistEventArgs($entity, $this->objectManager)); + } + + public function testPostFlushDispatchesBufferedIndexMessage(): void + { + $entity = $this->createIndexable('subscriber_history', '1', ['id' => 1]); + + $this->messageBus + ->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (IndexDocumentMessage $message): bool { + return $message->getIndexName() === 'subscriber_history' + && $message->getDocumentId() === '1' + && $message->getDocument() === ['id' => 1] + && $message->getOperation() === SearchOperation::Index + && $message->getRevision() > 0; + })) + ->willReturn(new Envelope(new stdClass())); + + $this->listener->postPersist(new PostPersistEventArgs($entity, $this->objectManager)); + $this->listener->postFlush(new PostFlushEventArgs($this->objectManager)); + } + + public function testRevisionsAreMonotonicallyIncreasingAcrossFlushes(): void + { + $entity = $this->createIndexable('subscriber_history', '1', ['id' => 1]); + $revisions = []; + + $this->messageBus + ->expects($this->exactly(2)) + ->method('dispatch') + ->with($this->callback(function (IndexDocumentMessage $message) use (&$revisions): bool { + $revisions[] = $message->getRevision(); + + return true; + })) + ->willReturn(new Envelope(new stdClass())); + + $this->listener->postPersist(new PostPersistEventArgs($entity, $this->objectManager)); + $this->listener->postFlush(new PostFlushEventArgs($this->objectManager)); + + $this->listener->postUpdate(new PostUpdateEventArgs($entity, $this->objectManager)); + $this->listener->postFlush(new PostFlushEventArgs($this->objectManager)); + + $this->assertCount(2, $revisions); + $this->assertGreaterThanOrEqual($revisions[0], $revisions[1]); + } + + public function testPostRemoveBuffersDeleteOperationWithEmptyDocument(): void + { + $entity = $this->createIndexable('subscriber_history', '1', ['id' => 1]); + + $this->messageBus + ->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (IndexDocumentMessage $message): bool { + return $message->getOperation() === SearchOperation::Delete + && $message->getDocument() === []; + })) + ->willReturn(new Envelope(new stdClass())); + + $this->listener->postRemove(new PostRemoveEventArgs($entity, $this->objectManager)); + $this->listener->postFlush(new PostFlushEventArgs($this->objectManager)); + } + + public function testMultipleTouchesInOneFlushDedupeToOneDispatch(): void + { + $entity = $this->createIndexable('subscriber_history', '1', ['id' => 1]); + + $this->messageBus->expects($this->once())->method('dispatch') + ->willReturn(new Envelope(new stdClass())); + + $this->listener->postPersist(new PostPersistEventArgs($entity, $this->objectManager)); + $this->listener->postUpdate(new PostUpdateEventArgs($entity, $this->objectManager)); + $this->listener->postFlush(new PostFlushEventArgs($this->objectManager)); + } + + public function testPostFlushWithNothingBufferedDoesNotDispatch(): void + { + $this->messageBus->expects($this->never())->method('dispatch'); + + $this->listener->postFlush(new PostFlushEventArgs($this->objectManager)); + } + + public function testNonSearchIndexableEntityIsIgnored(): void + { + $entity = new stdClass(); + + $this->messageBus->expects($this->never())->method('dispatch'); + + $this->listener->postPersist(new PostPersistEventArgs($entity, $this->objectManager)); + $this->listener->postFlush(new PostFlushEventArgs($this->objectManager)); + } + + public function testPendingBufferIsClearedAfterDispatch(): void + { + $entity = $this->createIndexable('subscriber_history', '1', ['id' => 1]); + + $this->messageBus->expects($this->once())->method('dispatch') + ->willReturn(new Envelope(new stdClass())); + + $this->listener->postPersist(new PostPersistEventArgs($entity, $this->objectManager)); + $this->listener->postFlush(new PostFlushEventArgs($this->objectManager)); + + // A second postFlush with nothing new queued must not re-dispatch the same message. + $this->listener->postFlush(new PostFlushEventArgs($this->objectManager)); + } + + public function testDisabledListenerNeverQueuesOrDispatchesAnything(): void + { + $listener = new SearchIndexDoctrineListener($this->messageBus, enabled: false); + $entity = $this->createIndexable('subscriber_history', '1', ['id' => 1]); + + $this->messageBus->expects($this->never())->method('dispatch'); + + $listener->postPersist(new PostPersistEventArgs($entity, $this->objectManager)); + $listener->postUpdate(new PostUpdateEventArgs($entity, $this->objectManager)); + $listener->preRemove(new PreRemoveEventArgs($entity, $this->objectManager)); + $listener->postRemove(new PostRemoveEventArgs($entity, $this->objectManager)); + $listener->postFlush(new PostFlushEventArgs($this->objectManager)); + } +} diff --git a/tests/Unit/Domain/Analytics/Service/AnalyticsServiceTest.php b/tests/Unit/Domain/Analytics/Service/AnalyticsServiceTest.php index a75587475..3b4cb7f9c 100644 --- a/tests/Unit/Domain/Analytics/Service/AnalyticsServiceTest.php +++ b/tests/Unit/Domain/Analytics/Service/AnalyticsServiceTest.php @@ -17,8 +17,8 @@ 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\Repository\Interfaces\UserMessageBounceReaderInterface; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; -use PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageForwardRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; @@ -31,7 +31,7 @@ class AnalyticsServiceTest extends TestCase private LinkTrackManager|MockObject $linkTrackManager; private UserMessageViewManager|MockObject $userMessageViewManager; private MessageRepository|MockObject $messageRepository; - private UserMessageBounceRepository|MockObject $userMessageBounceRepository; + private UserMessageBounceReaderInterface|MockObject $userMessageBounceReader; private UserMessageForwardRepository|MockObject $userMessageForwardRepository; private SubscriberRepository|MockObject $subscriberRepository; private UserMessageRepository|MockObject $userMessageRepository; @@ -42,7 +42,7 @@ protected function setUp(): void $this->linkTrackManager = $this->createMock(LinkTrackManager::class); $this->userMessageViewManager = $this->createMock(UserMessageViewManager::class); $this->messageRepository = $this->createMock(MessageRepository::class); - $this->userMessageBounceRepository = $this->createMock(UserMessageBounceRepository::class); + $this->userMessageBounceReader = $this->createMock(UserMessageBounceReaderInterface::class); $this->userMessageForwardRepository = $this->createMock(UserMessageForwardRepository::class); $this->subscriberRepository = $this->createMock(SubscriberRepository::class); $this->userMessageRepository = $this->createMock(UserMessageRepository::class); @@ -52,7 +52,7 @@ protected function setUp(): void $this->linkTrackManager, $this->userMessageViewManager, $this->messageRepository, - $this->userMessageBounceRepository, + $this->userMessageBounceReader, $this->userMessageForwardRepository, $this->subscriberRepository, $this->userMessageRepository, @@ -109,7 +109,7 @@ public function testGetCampaignStatistics(): void ->with($messageId) ->willReturn([$linkTrack1, $linkTrack2]); - $this->userMessageBounceRepository->expects(self::once()) + $this->userMessageBounceReader->expects(self::once()) ->method('getCountByMessageId') ->with($messageId) ->willReturn(3); @@ -291,7 +291,7 @@ public function testGetSummaryStatistics(): void $this->userMessageRepository->method('countSentBetween')->willReturnOnConsecutiveCalls(500, 400); $this->userMessageViewRepository->method('countBetween')->willReturnOnConsecutiveCalls(250, 160); - $this->userMessageBounceRepository->method('countBetween')->willReturnOnConsecutiveCalls(10, 8); + $this->userMessageBounceReader->method('countBetween')->willReturnOnConsecutiveCalls(10, 8); $result = $this->subject->getSummaryStatistics(); diff --git a/tests/Unit/Domain/Messaging/Repository/UserMessageBounceConfigurableReaderTest.php b/tests/Unit/Domain/Messaging/Repository/UserMessageBounceConfigurableReaderTest.php new file mode 100644 index 000000000..9a2298f0d --- /dev/null +++ b/tests/Unit/Domain/Messaging/Repository/UserMessageBounceConfigurableReaderTest.php @@ -0,0 +1,76 @@ +databaseReader = $this->createMock(UserMessageBounceRepository::class); + $this->elasticsearchReader = $this->createMock(UserMessageBounceElasticsearchReader::class); + } + + public function testDelegatesToElasticsearchWhenEnabled(): void + { + $reader = new UserMessageBounceConfigurableReader($this->databaseReader, $this->elasticsearchReader, true); + + $this->elasticsearchReader->expects($this->once()) + ->method('getCountByMessageId') + ->with(42) + ->willReturn(7); + $this->databaseReader->expects($this->never())->method('getCountByMessageId'); + + $this->assertSame(7, $reader->getCountByMessageId(42)); + } + + public function testDelegatesToDatabaseWhenDisabled(): void + { + $reader = new UserMessageBounceConfigurableReader($this->databaseReader, $this->elasticsearchReader, false); + $start = new DateTime('2026-01-01'); + $end = new DateTime('2026-01-31'); + + $this->databaseReader->expects($this->once()) + ->method('countBetween') + ->with($start, $end) + ->willReturn(3); + $this->elasticsearchReader->expects($this->never())->method('countBetween'); + + $this->assertSame(3, $reader->countBetween($start, $end)); + } + + public function testExistsByMessageIdAndUserIdDelegatesToActiveReader(): void + { + $reader = new UserMessageBounceConfigurableReader($this->databaseReader, $this->elasticsearchReader, true); + + $this->elasticsearchReader->expects($this->once()) + ->method('existsByMessageIdAndUserId') + ->with(5, 9) + ->willReturn(true); + + $this->assertTrue($reader->existsByMessageIdAndUserId(5, 9)); + } + + public function testGetByUserIdDelegatesToActiveReader(): void + { + $reader = new UserMessageBounceConfigurableReader($this->databaseReader, $this->elasticsearchReader, false); + + $this->databaseReader->expects($this->once()) + ->method('getByUserId') + ->with(3) + ->willReturn([]); + + $this->assertSame([], $reader->getByUserId(3)); + } +} diff --git a/tests/Unit/Domain/Messaging/Repository/UserMessageBounceElasticsearchReaderTest.php b/tests/Unit/Domain/Messaging/Repository/UserMessageBounceElasticsearchReaderTest.php new file mode 100644 index 000000000..f195ada3f --- /dev/null +++ b/tests/Unit/Domain/Messaging/Repository/UserMessageBounceElasticsearchReaderTest.php @@ -0,0 +1,231 @@ +client = $this->createMock(ElasticsearchClientInterface::class); + $this->reader = new UserMessageBounceElasticsearchReader($this->client, 'phplist_'); + } + + public function testGetFilteredAfterIdQueriesPrefixedIndexAndHydratesResults(): void + { + $filter = new UserMessageBounceFilter(lastId: 5, limit: 10); + + $this->client + ->expects($this->once()) + ->method('search') + ->with( + 'phplist_user_message_bounce', + $this->callback(function (array $query): bool { + return $query['size'] === 10 + && $query['query']['bool']['filter'][0] === ['range' => ['idSort' => ['gt' => 5]]]; + }), + ) + ->willReturn([ + 'hits' => [ + 'total' => ['value' => 1], + 'hits' => [ + ['_source' => [ + 'id' => 7, + 'idSort' => 7, + 'userId' => 3, + 'messageId' => 42, + 'bounceId' => 99, + 'time' => '2026-01-01T00:00:00+00:00', + ]], + ], + ], + ]); + + $result = $this->reader->getFilteredAfterId($filter); + + $this->assertSame(1, $result->getTotal()); + $this->assertCount(1, $result->getItems()); + $this->assertSame(7, $result->getItems()[0]->getId()); + $this->assertSame(3, $result->getItems()[0]->getUserId()); + $this->assertSame(42, $result->getItems()[0]->getMessageId()); + $this->assertSame(99, $result->getItems()[0]->getBounceId()); + } + + public function testGetFilteredAfterIdPaginatesAcrossTwoPagesWithoutRepeatingResults(): void + { + $firstFilter = new UserMessageBounceFilter(lastId: 0, limit: 1); + $expectedCursors = [0, 5]; + $call = 0; + + $this->client + ->expects($this->exactly(2)) + ->method('search') + ->with( + 'phplist_user_message_bounce', + $this->callback(function (array $query) use (&$call, $expectedCursors): bool { + $expectedCursor = $expectedCursors[$call]; + $call++; + + return $query['query']['bool']['filter'][0] === [ + 'range' => [ + 'idSort' => ['gt' => $expectedCursor] + ] + ]; + }), + ) + ->willReturnOnConsecutiveCalls( + [ + 'hits' => [ + 'total' => ['value' => 2], + 'hits' => [ + ['_source' => [ + 'id' => 5, + 'idSort' => 5, + 'userId' => 1, + 'messageId' => 10, + 'bounceId' => 20, + 'time' => '2026-01-01T00:00:00+00:00', + ]], + ], + ], + ], + [ + 'hits' => [ + 'total' => ['value' => 2], + 'hits' => [ + ['_source' => [ + 'id' => 8, + 'idSort' => 8, + 'userId' => 2, + 'messageId' => 11, + 'bounceId' => 21, + 'time' => '2026-01-02T00:00:00+00:00', + ]], + ], + ], + ], + ); + + $firstPage = $this->reader->getFilteredAfterId($firstFilter); + + $this->assertSame(5, $firstPage->getLastId()); + $this->assertSame(5, $firstPage->getItems()[0]->getId()); + + $secondFilter = new UserMessageBounceFilter(lastId: $firstPage->getLastId(), limit: 1); + $secondPage = $this->reader->getFilteredAfterId($secondFilter); + + $this->assertSame(8, $secondPage->getLastId()); + $this->assertSame(8, $secondPage->getItems()[0]->getId()); + $this->assertNotSame( + $firstPage->getItems()[0]->getId(), + $secondPage->getItems()[0]->getId(), + ); + } + + public function testGetFilteredAfterIdRejectsWrongFilterType(): void + { + $wrongFilter = $this->createMock(FilterRequestInterface::class); + + $this->expectException(InvalidArgumentException::class); + $this->reader->getFilteredAfterId($wrongFilter); + } + + public function testGetByUserIdSortsDescending(): void + { + $this->client + ->expects($this->once()) + ->method('search') + ->with( + 'phplist_user_message_bounce', + $this->callback(function (array $query): bool { + return $query['query'] === ['term' => ['userId' => 9]] + && $query['sort'] === [['idSort' => 'desc']]; + }), + ) + ->willReturn(['hits' => ['hits' => []]]); + + $result = $this->reader->getByUserId(9); + + $this->assertSame([], $result); + } + + public function testGetCountByMessageIdQueriesTotalHitsForMessage(): void + { + $this->client + ->expects($this->once()) + ->method('search') + ->with( + 'phplist_user_message_bounce', + $this->callback(function (array $query): bool { + return $query['query'] === ['term' => ['messageId' => 42]] + && $query['size'] === 0; + }), + ) + ->willReturn(['hits' => ['total' => ['value' => 7]]]); + + $this->assertSame(7, $this->reader->getCountByMessageId(42)); + } + + public function testCountBetweenQueriesTimeRange(): void + { + $start = new DateTime('2026-01-01 00:00:00'); + $end = new DateTime('2026-01-31 23:59:59'); + + $this->client + ->expects($this->once()) + ->method('search') + ->with( + 'phplist_user_message_bounce', + $this->callback(function (array $query) use ($start, $end): bool { + return $query['query'] === ['range' => ['time' => [ + 'gte' => $start->format(DATE_ATOM), + 'lte' => $end->format(DATE_ATOM), + ]]]; + }), + ) + ->willReturn(['hits' => ['total' => ['value' => 3]]]); + + $this->assertSame(3, $this->reader->countBetween($start, $end)); + } + + public function testExistsByMessageIdAndUserIdReturnsTrueWhenHitsExist(): void + { + $this->client + ->expects($this->once()) + ->method('search') + ->with( + 'phplist_user_message_bounce', + $this->callback(function (array $query): bool { + return $query['query']['bool']['filter'] === [ + ['term' => ['messageId' => 5]], + ['term' => ['userId' => 9]], + ]; + }), + ) + ->willReturn(['hits' => ['total' => ['value' => 1]]]); + + $this->assertTrue($this->reader->existsByMessageIdAndUserId(5, 9)); + } + + public function testExistsByMessageIdAndUserIdReturnsFalseWhenNoHits(): void + { + $this->client + ->method('search') + ->willReturn(['hits' => ['total' => ['value' => 0]]]); + + $this->assertFalse($this->reader->existsByMessageIdAndUserId(5, 9)); + } +} diff --git a/tests/Unit/Domain/Messaging/Repository/UserMessageBounceReportConfigurableReaderTest.php b/tests/Unit/Domain/Messaging/Repository/UserMessageBounceReportConfigurableReaderTest.php new file mode 100644 index 000000000..56a16a892 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Repository/UserMessageBounceReportConfigurableReaderTest.php @@ -0,0 +1,60 @@ +databaseReader = $this->createMock(UserMessageBounceRepository::class); + $this->elasticsearchReader = $this->createMock(UserMessageBounceElasticsearchHybridReader::class); + } + + public function testGetListBounceTotalsDelegatesToElasticsearchWhenEnabled(): void + { + $reader = new UserMessageBounceReportConfigurableReader( + $this->databaseReader, + $this->elasticsearchReader, + true, + ); + $expected = [['subscriber_id' => 1, 'email' => 'a@example.com', 'confirmed' => true, + 'blacklisted' => false, 'total_bounces' => 2]]; + + $this->elasticsearchReader->expects($this->once()) + ->method('getListBounceTotals') + ->with(10) + ->willReturn($expected); + $this->databaseReader->expects($this->never())->method('getListBounceTotals'); + + $this->assertSame($expected, $reader->getListBounceTotals(10)); + } + + public function testGetCampaignBounceTotalsDelegatesToDatabaseWhenDisabled(): void + { + $reader = new UserMessageBounceReportConfigurableReader( + $this->databaseReader, + $this->elasticsearchReader, + false, + ); + $expected = [['message_id' => 1, 'subject' => 'Hello', 'total_bounces' => 4]]; + + $this->databaseReader->expects($this->once()) + ->method('getCampaignBounceTotals') + ->with(7) + ->willReturn($expected); + $this->elasticsearchReader->expects($this->never())->method('getCampaignBounceTotals'); + + $this->assertSame($expected, $reader->getCampaignBounceTotals(7)); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php index 3a07b0a02..043aa5f49 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/BounceManagerTest.php @@ -130,6 +130,11 @@ public function testLinkUserMessageBounceFlushesAndSetsFields(): void $bounce->method('getId')->willReturn(77); $dt = new DateTimeImmutable('2024-05-01 12:34:56'); + + $this->userMessageBounceRepository->expects($this->once()) + ->method('persist') + ->with($this->isInstanceOf(UserMessageBounce::class)); + $umb = $this->manager->linkUserMessageBounce($bounce, $dt, 123, 456); $this->assertSame(77, $umb->getBounceId()); diff --git a/tests/Unit/Domain/Messaging/Service/Search/UserMessageBounceIndexDefinitionTest.php b/tests/Unit/Domain/Messaging/Service/Search/UserMessageBounceIndexDefinitionTest.php new file mode 100644 index 000000000..29214e513 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Search/UserMessageBounceIndexDefinitionTest.php @@ -0,0 +1,37 @@ +assertSame('user_message_bounce', $definition->getIndexAlias()); + } + + public function testMappingDeclaresExpectedFields(): void + { + $definition = new UserMessageBounceIndexDefinition(); + $properties = $definition->getMapping()['properties']; + + foreach (['id', 'idSort', 'userId', 'messageId', 'bounceId', 'time'] as $field) { + $this->assertArrayHasKey($field, $properties); + } + $this->assertSame('long', $properties['idSort']['type']); + $this->assertSame('keyword', $properties['id']['type']); + } + + public function testSettingsAreEmptyByDefault(): void + { + $definition = new UserMessageBounceIndexDefinition(); + + $this->assertSame([], $definition->getSettings()); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Search/UserMessageBounceReindexProviderTest.php b/tests/Unit/Domain/Messaging/Service/Search/UserMessageBounceReindexProviderTest.php new file mode 100644 index 000000000..8009a0d90 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/Search/UserMessageBounceReindexProviderTest.php @@ -0,0 +1,19 @@ +createMock(UserMessageBounceRepository::class)); + + $this->assertSame('user_message_bounce', $provider->getAlias()); + } +} diff --git a/tests/Unit/Domain/Search/Command/PurgeSearchIndexedRowsCommandTest.php b/tests/Unit/Domain/Search/Command/PurgeSearchIndexedRowsCommandTest.php new file mode 100644 index 000000000..a3046cd03 --- /dev/null +++ b/tests/Unit/Domain/Search/Command/PurgeSearchIndexedRowsCommandTest.php @@ -0,0 +1,123 @@ +createMock(SearchIndexableInterface::class); + $row->method('getSearchDocumentId')->willReturn((string) $id); + + return $row; + } + + private function commandTesterWithProviders(SearchPurgeProviderInterface ...$providers): CommandTester + { + $this->client = $this->createMock(ElasticsearchClientInterface::class); + $registry = new SearchPurgeProviderRegistry($providers); + $command = new PurgeSearchIndexedRowsCommand($registry, $this->client); + + $application = new Application(); + $application->add($command); + + return new CommandTester($command); + } + + public function testDeletesOnlyRowsConfirmedInElasticsearch(): void + { + $confirmedRow = $this->makeFakeRow(1); + $unconfirmedRow = $this->makeFakeRow(2); + + $provider = $this->createMock(SearchPurgeProviderInterface::class); + $provider->method('getAlias')->willReturn('some_alias'); + $provider->method('getRetentionPeriod')->willReturn(new DateInterval('P1M')); + $provider->method('getSearchIndexName')->willReturn('phplist_some_alias'); + $provider->method('fetchBatchOlderThan')->willReturnOnConsecutiveCalls( + [$confirmedRow, $unconfirmedRow], + [], + ); + + $tester = $this->commandTesterWithProviders($provider); + $this->client->method('search')->willReturn([ + 'hits' => ['hits' => [['_id' => '1']]], + ]); + + $provider->expects($this->once())->method('deleteByIds')->with([1])->willReturn(1); + + $tester->execute([]); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('not found in', $output); + $this->assertStringContainsString('deleted 1 row(s), skipped 1 unconfirmed', $output); + $this->assertSame(0, $tester->getStatusCode()); + } + + public function testDryRunDoesNotDeleteAnything(): void + { + $row = $this->makeFakeRow(1); + + $provider = $this->createMock(SearchPurgeProviderInterface::class); + $provider->method('getAlias')->willReturn('some_alias'); + $provider->method('getRetentionPeriod')->willReturn(new DateInterval('P1M')); + $provider->method('getSearchIndexName')->willReturn('phplist_some_alias'); + $provider->method('fetchBatchOlderThan')->willReturnOnConsecutiveCalls([$row], []); + + $tester = $this->commandTesterWithProviders($provider); + $this->client->method('search')->willReturn(['hits' => ['hits' => [['_id' => '1']]]]); + + $provider->expects($this->never())->method('deleteByIds'); + + $tester->execute(['--dry-run' => true]); + + $this->assertStringContainsString('would delete', $tester->getDisplay()); + $this->assertSame(0, $tester->getStatusCode()); + } + + public function testSkipsProvidersWithoutARetentionPeriod(): void + { + $provider = $this->createMock(SearchPurgeProviderInterface::class); + $provider->method('getAlias')->willReturn('some_alias'); + $provider->method('getRetentionPeriod')->willReturn(null); + + $tester = $this->commandTesterWithProviders($provider); + $provider->expects($this->never())->method('fetchBatchOlderThan'); + + $tester->execute([]); + + $this->assertStringContainsString( + 'No purge providers with a configured retention period', + $tester->getDisplay(), + ); + $this->assertSame(0, $tester->getStatusCode()); + } + + public function testFailsWhenAliasHasNoRetentionPeriodConfigured(): void + { + $provider = $this->createMock(SearchPurgeProviderInterface::class); + $provider->method('getAlias')->willReturn('some_alias'); + $provider->method('getRetentionPeriod')->willReturn(null); + + $tester = $this->commandTesterWithProviders($provider); + + $tester->execute(['alias' => 'some_alias']); + + $this->assertStringContainsString('No retention period configured', $tester->getDisplay()); + $this->assertSame(1, $tester->getStatusCode()); + } +} diff --git a/tests/Unit/Domain/Search/Fake/InMemoryVersionedElasticsearchClient.php b/tests/Unit/Domain/Search/Fake/InMemoryVersionedElasticsearchClient.php new file mode 100644 index 000000000..7b10e8a42 --- /dev/null +++ b/tests/Unit/Domain/Search/Fake/InMemoryVersionedElasticsearchClient.php @@ -0,0 +1,68 @@ + */ + private array $revisions = []; + + /** @var array|null> */ + private array $documents = []; + + public function index(string $indexName, string $documentId, array $document, int $revision): void + { + $key = $indexName . '|' . $documentId; + if (isset($this->revisions[$key]) && $revision < $this->revisions[$key]) { + return; + } + + $this->revisions[$key] = $revision; + $this->documents[$key] = $document; + } + + public function delete(string $indexName, string $documentId, int $revision): void + { + $key = $indexName . '|' . $documentId; + if (isset($this->revisions[$key]) && $revision < $this->revisions[$key]) { + return; + } + + $this->revisions[$key] = $revision; + $this->documents[$key] = null; + } + + /** @return array|null */ + public function getDocument(string $indexName, string $documentId): ?array + { + return $this->documents[$indexName . '|' . $documentId] ?? null; + } + + public function indexExists(string $indexName): bool + { + return true; + } + + public function createIndex(string $indexName, array $mapping, array $settings): void + { + } + + public function updateMapping(string $indexName, array $mapping): void + { + } + + public function search(string $indexName, array $query): array + { + return []; + } +} diff --git a/tests/Unit/Domain/Search/MessageHandler/IndexDocumentMessageHandlerRevisionOrderingTest.php b/tests/Unit/Domain/Search/MessageHandler/IndexDocumentMessageHandlerRevisionOrderingTest.php new file mode 100644 index 000000000..c830e565b --- /dev/null +++ b/tests/Unit/Domain/Search/MessageHandler/IndexDocumentMessageHandlerRevisionOrderingTest.php @@ -0,0 +1,113 @@ + handler -> indexer -> client) rather than mocking away the + * exact ordering guarantee under test. + */ +class IndexDocumentMessageHandlerRevisionOrderingTest extends TestCase +{ + private InMemoryVersionedElasticsearchClient $client; + private IndexDocumentMessageHandler $handler; + + protected function setUp(): void + { + $this->client = new InMemoryVersionedElasticsearchClient(); + $this->handler = new IndexDocumentMessageHandler(new ElasticsearchIndexer($this->client, 'phplist_')); + } + + public function testDelayedRetryOfOlderUpdateDoesNotOverwriteNewerUpdate(): void + { + // The update at revision 100 is dispatched first but, say, the Messenger transport fails + // to deliver it until a later retry - meanwhile a newer update (revision 200) for the same + // document is dispatched and processed first. + ($this->handler)(new IndexDocumentMessage( + 'subscriber_history', + '1', + ['id' => 1, 'summary' => 'Updated'], + SearchOperation::Index, + 200, + )); + + // The retry of the older message finally lands. + ($this->handler)(new IndexDocumentMessage( + 'subscriber_history', + '1', + ['id' => 1, 'summary' => 'Original'], + SearchOperation::Index, + 100, + )); + + $this->assertSame( + ['id' => 1, 'summary' => 'Updated'], + $this->client->getDocument('phplist_subscriber_history', '1'), + ); + } + + public function testDelayedRetryOfOlderUpdateDoesNotResurrectDeletedDocument(): void + { + ($this->handler)(new IndexDocumentMessage( + 'subscriber_history', + '1', + ['id' => 1, 'summary' => 'Original'], + SearchOperation::Index, + 100, + )); + + // A delete for the same document, at a newer revision, is processed first. + ($this->handler)(new IndexDocumentMessage( + 'subscriber_history', + '1', + [], + SearchOperation::Delete, + 300, + )); + + // The delayed retry of the stale update finally lands and must not resurrect the document. + ($this->handler)(new IndexDocumentMessage( + 'subscriber_history', + '1', + ['id' => 1, 'summary' => 'Original'], + SearchOperation::Index, + 150, + )); + + $this->assertNull($this->client->getDocument('phplist_subscriber_history', '1')); + } + + public function testNewerUpdateAfterADeleteIsStillApplied(): void + { + ($this->handler)(new IndexDocumentMessage( + 'subscriber_history', + '1', + [], + SearchOperation::Delete, + 300, + )); + + ($this->handler)(new IndexDocumentMessage( + 'subscriber_history', + '1', + ['id' => 1, 'summary' => 'Recreated'], + SearchOperation::Index, + 400, + )); + + $this->assertSame( + ['id' => 1, 'summary' => 'Recreated'], + $this->client->getDocument('phplist_subscriber_history', '1'), + ); + } +} diff --git a/tests/Unit/Domain/Search/MessageHandler/IndexDocumentMessageHandlerTest.php b/tests/Unit/Domain/Search/MessageHandler/IndexDocumentMessageHandlerTest.php new file mode 100644 index 000000000..661ed121b --- /dev/null +++ b/tests/Unit/Domain/Search/MessageHandler/IndexDocumentMessageHandlerTest.php @@ -0,0 +1,51 @@ +indexer = $this->createMock(ElasticsearchIndexerInterface::class); + $this->handler = new IndexDocumentMessageHandler($this->indexer); + } + + public function testInvokeIndexesOnIndexOperation(): void + { + $document = ['id' => 1, 'summary' => 'hello']; + $message = new IndexDocumentMessage('subscriber_history', '1', $document, SearchOperation::Index, 100); + + $this->indexer + ->expects($this->once()) + ->method('index') + ->with('subscriber_history', '1', $document, 100); + $this->indexer->expects($this->never())->method('delete'); + + ($this->handler)($message); + } + + public function testInvokeDeletesOnDeleteOperation(): void + { + $message = new IndexDocumentMessage('subscriber_history', '1', [], SearchOperation::Delete, 100); + + $this->indexer + ->expects($this->once()) + ->method('delete') + ->with('subscriber_history', '1', 100); + $this->indexer->expects($this->never())->method('index'); + + ($this->handler)($message); + } +} diff --git a/tests/Unit/Domain/Search/Service/ElasticsearchIndexerTest.php b/tests/Unit/Domain/Search/Service/ElasticsearchIndexerTest.php new file mode 100644 index 000000000..6d8f300f2 --- /dev/null +++ b/tests/Unit/Domain/Search/Service/ElasticsearchIndexerTest.php @@ -0,0 +1,87 @@ +client = $this->createMock(ElasticsearchClientInterface::class); + $this->indexer = new ElasticsearchIndexer($this->client, 'phplist_'); + } + + public function testIndexAppliesIndexPrefix(): void + { + $this->client + ->expects($this->once()) + ->method('index') + ->with('phplist_subscriber_history', '42', ['id' => 42], 100); + + $this->indexer->index('subscriber_history', '42', ['id' => 42], 100); + } + + public function testDeleteAppliesIndexPrefix(): void + { + $this->client + ->expects($this->once()) + ->method('delete') + ->with('phplist_subscriber_history', '42', 100); + + $this->indexer->delete('subscriber_history', '42', 100); + } + + public function testCreateOrUpdateIndexCreatesWhenAbsent(): void + { + $definition = $this->createMock(SearchIndexDefinitionInterface::class); + $definition->method('getIndexAlias')->willReturn('subscriber_history'); + $definition->method('getMapping')->willReturn(['properties' => ['id' => ['type' => 'keyword']]]); + $definition->method('getSettings')->willReturn([]); + + $this->client + ->expects($this->once()) + ->method('indexExists') + ->with('phplist_subscriber_history') + ->willReturn(false); + + $this->client + ->expects($this->once()) + ->method('createIndex') + ->with('phplist_subscriber_history', $definition->getMapping(), []); + $this->client->expects($this->never())->method('updateMapping'); + + $this->indexer->createOrUpdateIndex($definition); + } + + public function testCreateOrUpdateIndexUpdatesMappingWhenPresent(): void + { + $definition = $this->createMock(SearchIndexDefinitionInterface::class); + $definition->method('getIndexAlias')->willReturn('subscriber_history'); + $definition->method('getMapping')->willReturn(['properties' => ['id' => ['type' => 'keyword']]]); + $definition->method('getSettings')->willReturn([]); + + $this->client + ->expects($this->once()) + ->method('indexExists') + ->with('phplist_subscriber_history') + ->willReturn(true); + + $this->client + ->expects($this->once()) + ->method('updateMapping') + ->with('phplist_subscriber_history', $definition->getMapping()); + $this->client->expects($this->never())->method('createIndex'); + + $this->indexer->createOrUpdateIndex($definition); + } +} diff --git a/tests/Unit/Domain/Subscription/Repository/SubscriberHistoryConfigurableReaderTest.php b/tests/Unit/Domain/Subscription/Repository/SubscriberHistoryConfigurableReaderTest.php new file mode 100644 index 000000000..4af5782c4 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Repository/SubscriberHistoryConfigurableReaderTest.php @@ -0,0 +1,64 @@ +databaseReader = $this->createMock(SubscriberHistoryRepository::class); + $this->elasticsearchReader = $this->createMock(SubscriberHistoryElasticsearchReader::class); + } + + public function testDelegatesToElasticsearchWhenEnabled(): void + { + $reader = new SubscriberHistoryConfigurableReader( + $this->databaseReader, + $this->elasticsearchReader, + true, + ); + $filter = $this->createMock(FilterRequestInterface::class); + $expected = new PaginatedResult([], 0, 50, 0); + + $this->elasticsearchReader->expects($this->once()) + ->method('getFilteredAfterId') + ->with($filter) + ->willReturn($expected); + $this->databaseReader->expects($this->never())->method('getFilteredAfterId'); + + $this->assertSame($expected, $reader->getFilteredAfterId($filter)); + } + + public function testDelegatesToDatabaseWhenDisabled(): void + { + $reader = new SubscriberHistoryConfigurableReader( + $this->databaseReader, + $this->elasticsearchReader, + false, + ); + $subscriber = $this->createMock(Subscriber::class); + $expected = []; + + $this->databaseReader->expects($this->once()) + ->method('getBySubscriber') + ->with($subscriber) + ->willReturn($expected); + $this->elasticsearchReader->expects($this->never())->method('getBySubscriber'); + + $this->assertSame($expected, $reader->getBySubscriber($subscriber)); + } +} diff --git a/tests/Unit/Domain/Subscription/Repository/SubscriberHistoryElasticsearchReaderTest.php b/tests/Unit/Domain/Subscription/Repository/SubscriberHistoryElasticsearchReaderTest.php new file mode 100644 index 000000000..a1c12bf5e --- /dev/null +++ b/tests/Unit/Domain/Subscription/Repository/SubscriberHistoryElasticsearchReaderTest.php @@ -0,0 +1,157 @@ +client = $this->createMock(ElasticsearchClientInterface::class); + $this->reader = new SubscriberHistoryElasticsearchReader($this->client, 'phplist_'); + } + + public function testGetFilteredAfterIdQueriesPrefixedIndexAndHydratesResults(): void + { + $filter = new SubscriberHistoryFilter(lastId: 5, limit: 10); + + $this->client + ->expects($this->once()) + ->method('search') + ->with( + 'phplist_subscriber_history', + $this->callback(function (array $query): bool { + return $query['size'] === 10 + && $query['query']['bool']['filter'][0] === ['range' => ['idSort' => ['gt' => 5]]]; + }), + ) + ->willReturn([ + 'hits' => [ + 'total' => ['value' => 1], + 'hits' => [ + ['_source' => [ + 'id' => 7, + 'idSort' => 7, + 'subscriberId' => 3, + 'ip' => '127.0.0.1', + 'date' => '2026-01-01T00:00:00+00:00', + 'summary' => 'Updated', + 'detail' => 'Detail', + 'systemInfo' => 'Info', + ]], + ], + ], + ]); + + $result = $this->reader->getFilteredAfterId($filter); + + $this->assertSame(1, $result->getTotal()); + $this->assertCount(1, $result->getItems()); + $this->assertSame(7, $result->getItems()[0]->getId()); + $this->assertSame(3, $result->getItems()[0]->getSubscriberId()); + $this->assertSame('Updated', $result->getItems()[0]->getSummary()); + } + + public function testGetFilteredAfterIdPaginatesAcrossTwoPagesWithoutRepeatingResults(): void + { + $firstFilter = new SubscriberHistoryFilter(lastId: 0, limit: 1); + + $this->client + ->expects($this->exactly(2)) + ->method('search') + ->willReturnOnConsecutiveCalls( + [ + 'hits' => [ + 'total' => ['value' => 2], + 'hits' => [ + ['_source' => [ + 'id' => 5, + 'idSort' => 5, + 'subscriberId' => 1, + 'ip' => '127.0.0.1', + 'date' => '2026-01-01T00:00:00+00:00', + 'summary' => 'First', + 'detail' => 'Detail', + 'systemInfo' => 'Info', + ]], + ], + ], + ], + [ + 'hits' => [ + 'total' => ['value' => 2], + 'hits' => [ + ['_source' => [ + 'id' => 8, + 'idSort' => 8, + 'subscriberId' => 2, + 'ip' => '127.0.0.1', + 'date' => '2026-01-02T00:00:00+00:00', + 'summary' => 'Second', + 'detail' => 'Detail', + 'systemInfo' => 'Info', + ]], + ], + ], + ], + ); + + $firstPage = $this->reader->getFilteredAfterId($firstFilter); + + $this->assertSame(5, $firstPage->getLastId()); + $this->assertSame(5, $firstPage->getItems()[0]->getId()); + + $secondFilter = new SubscriberHistoryFilter(lastId: $firstPage->getLastId(), limit: 1); + $secondPage = $this->reader->getFilteredAfterId($secondFilter); + + $this->assertSame(8, $secondPage->getLastId()); + $this->assertSame(8, $secondPage->getItems()[0]->getId()); + $this->assertNotSame( + $firstPage->getItems()[0]->getId(), + $secondPage->getItems()[0]->getId(), + ); + } + + public function testGetFilteredAfterIdRejectsWrongFilterType(): void + { + $wrongFilter = $this->createMock(FilterRequestInterface::class); + + $this->expectException(InvalidArgumentException::class); + $this->reader->getFilteredAfterId($wrongFilter); + } + + public function testGetBySubscriberSortsDescending(): void + { + $subscriber = $this->createMock(Subscriber::class); + $subscriber->method('getId')->willReturn(9); + + $this->client + ->expects($this->once()) + ->method('search') + ->with( + 'phplist_subscriber_history', + $this->callback(function (array $query): bool { + return $query['query'] === ['term' => ['subscriberId' => 9]] + && $query['sort'] === [['idSort' => 'desc']]; + }), + ) + ->willReturn(['hits' => ['hits' => []]]); + + $result = $this->reader->getBySubscriber($subscriber); + + $this->assertSame([], $result); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php index f28dc08a1..ce8aa7d1b 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberHistoryManagerTest.php @@ -10,7 +10,7 @@ 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\Repository\Interfaces\SubscriberHistoryReaderInterface; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -18,14 +18,14 @@ class SubscriberHistoryManagerTest extends TestCase { - private SubscriberHistoryRepository|MockObject $subscriberHistoryRepository; + private SubscriberHistoryReaderInterface|MockObject $subscriberHistoryRepository; private SubscriberHistoryManager $subscriptionHistoryService; protected function setUp(): void { - $this->subscriberHistoryRepository = $this->createMock(SubscriberHistoryRepository::class); + $this->subscriberHistoryRepository = $this->createMock(SubscriberHistoryReaderInterface::class); $this->subscriptionHistoryService = new SubscriberHistoryManager( - repository: $this->subscriberHistoryRepository, + reader: $this->subscriberHistoryRepository, clientIpResolver: $this->createMock(ClientIpResolver::class), systemInfoCollector: $this->createMock(SystemInfoCollector::class), translator: $this->createMock(TranslatorInterface::class), diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php index d02ca66fe..628f15537 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriberManagerTest.php @@ -7,7 +7,7 @@ use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Subscription\Model\Dto\CreateSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Subscriber; -use PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository; +use PhpList\Core\Domain\Subscription\Repository\Interfaces\SubscriberHistoryReaderInterface; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberHistoryManager; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; @@ -33,7 +33,7 @@ protected function setUp(): void subscriberDeletionService: $subscriberDeletionService, translator: new Translator('en'), subscriberHistoryManager: $this->createMock(SubscriberHistoryManager::class), - subscriberHistoryRepository: $this->createMock(SubscriberHistoryRepository::class), + subscriberHistoryReader: $this->createMock(SubscriberHistoryReaderInterface::class), ); } diff --git a/tests/Unit/Domain/Subscription/Service/Search/SubscriberHistoryIndexDefinitionTest.php b/tests/Unit/Domain/Subscription/Service/Search/SubscriberHistoryIndexDefinitionTest.php new file mode 100644 index 000000000..065ec6270 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/Search/SubscriberHistoryIndexDefinitionTest.php @@ -0,0 +1,37 @@ +assertSame('subscriber_history', $definition->getIndexAlias()); + } + + public function testMappingDeclaresExpectedFields(): void + { + $definition = new SubscriberHistoryIndexDefinition(); + $properties = $definition->getMapping()['properties']; + + foreach (['id', 'idSort', 'subscriberId', 'ip', 'date', 'summary', 'detail', 'systemInfo'] as $field) { + $this->assertArrayHasKey($field, $properties); + } + $this->assertSame('long', $properties['idSort']['type']); + $this->assertSame('keyword', $properties['id']['type']); + } + + public function testSettingsAreEmptyByDefault(): void + { + $definition = new SubscriberHistoryIndexDefinition(); + + $this->assertSame([], $definition->getSettings()); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Search/SubscriberHistoryPurgeProviderTest.php b/tests/Unit/Domain/Subscription/Service/Search/SubscriberHistoryPurgeProviderTest.php new file mode 100644 index 000000000..fb49fca8c --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/Search/SubscriberHistoryPurgeProviderTest.php @@ -0,0 +1,57 @@ +createMock(SubscriberHistoryRepository::class), + 'phplist_', + 'P1M', + ); + + $this->assertSame('subscriber_history', $provider->getAlias()); + } + + public function testGetRetentionPeriodReturnsNullWhenNotConfigured(): void + { + $provider = new SubscriberHistoryPurgeProvider( + $this->createMock(SubscriberHistoryRepository::class), + 'phplist_', + '', + ); + + $this->assertNull($provider->getRetentionPeriod()); + } + + public function testGetRetentionPeriodParsesConfiguredIsoDuration(): void + { + $provider = new SubscriberHistoryPurgeProvider( + $this->createMock(SubscriberHistoryRepository::class), + 'phplist_', + 'P1M', + ); + + $this->assertEquals(new DateInterval('P1M'), $provider->getRetentionPeriod()); + } + + public function testGetSearchIndexNameIncludesPrefix(): void + { + $provider = new SubscriberHistoryPurgeProvider( + $this->createMock(SubscriberHistoryRepository::class), + 'phplist_', + 'P1M', + ); + + $this->assertSame('phplist_subscriber_history', $provider->getSearchIndexName()); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Search/SubscriberHistoryReindexProviderTest.php b/tests/Unit/Domain/Subscription/Service/Search/SubscriberHistoryReindexProviderTest.php new file mode 100644 index 000000000..28926eb52 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/Search/SubscriberHistoryReindexProviderTest.php @@ -0,0 +1,19 @@ +createMock(SubscriberHistoryRepository::class)); + + $this->assertSame('subscriber_history', $provider->getAlias()); + } +} From 132665eb3e8c9be6a77845f08a75aca3f7e2c208 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 9 Sep 2026 19:20:05 +0400 Subject: [PATCH 39/39] ref: email sending (#388) * ref: replace findByIdAndStatus with tryClaimForProcessing in CampaignProcessorMessageHandler * feat: add exclude list functionality to CampaignProcessorMessageHandler * feat: implement domain rate limiting functionality * feat: implement domain rate limiting * feat: getStuckCampaigns * feat: enhance exclusion logic for campaign subscribers * fix: getStatus method to handle null status * feat: update guidelines for atomic conditional-UPDATE reservation patterns * feat: add stuck campaign threshold to tryClaimForProcessing method * feat: implement atomic reset for blocked count in DomainThrottleStateRepository --------- Co-authored-by: Tatevik --- .coderabbit.yaml | 17 +- .env.dist | 6 + .env.test | 2 + config/parameters.yml | 6 + config/services/repositories.yml | 4 + config/services/services.yml | 9 + .../CampaignProcessorMessageHandler.php | 122 ++++-- .../Messaging/Model/DomainThrottleState.php | 62 +++ .../Model/Dto/DomainThrottleReservation.php | 17 + .../Model/Dto/DomainThrottleResult.php | 20 + .../Model/Message/MessageMetadata.php | 1 - src/Domain/Messaging/Model/UserMessage.php | 4 +- .../DomainThrottleStateRepository.php | 144 +++++++ .../Repository/MessageRepository.php | 99 +++++ .../Messaging/Service/DomainRateLimiter.php | 119 ++++++ .../Service/Handler/RequeueHandler.php | 16 +- .../Service/Manager/MessageManager.php | 7 + .../Repository/SubscriberRepository.php | 46 +++ .../Service/Provider/SubscriberProvider.php | 68 +++- ...08130000MySqlCreateDomainThrottleTable.php | 46 +++ ...130001PostGreCreateDomainThrottleTable.php | 46 +++ .../DomainThrottleStateRepositoryTest.php | 102 +++++ .../Repository/MessageRepositoryTest.php | 84 ++++ .../Repository/SubscriberRepositoryTest.php | 62 +++ .../CampaignProcessorMessageHandlerTest.php | 369 +++++++++++++++++- .../Service/DomainRateLimiterTest.php | 158 ++++++++ .../Service/Handler/RequeueHandlerTest.php | 32 +- .../Service/Manager/MessageManagerTest.php | 18 + .../Provider/SubscriberProviderTest.php | 43 +- 29 files changed, 1670 insertions(+), 59 deletions(-) create mode 100644 src/Domain/Messaging/Model/DomainThrottleState.php create mode 100644 src/Domain/Messaging/Model/Dto/DomainThrottleReservation.php create mode 100644 src/Domain/Messaging/Model/Dto/DomainThrottleResult.php create mode 100644 src/Domain/Messaging/Repository/DomainThrottleStateRepository.php create mode 100644 src/Domain/Messaging/Service/DomainRateLimiter.php create mode 100644 src/Migrations/Version20260908130000MySqlCreateDomainThrottleTable.php create mode 100644 src/Migrations/Version20260908130001PostGreCreateDomainThrottleTable.php create mode 100644 tests/Integration/Domain/Messaging/Repository/DomainThrottleStateRepositoryTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/DomainRateLimiterTest.php diff --git a/.coderabbit.yaml b/.coderabbit.yaml index c343b8fc0..ab0a966f0 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -35,7 +35,22 @@ reviews: - Still prefer keeping this logic well-encapsulated (e.g. in dedicated services/repos), not scattered across unrelated domain objects. - - ⚠️ For non-DynamicListAttr code: + - ✅ **Relaxed rule for atomic conditional-UPDATE reservation/claim patterns**: + - Some Domain repositories intentionally use `$connection->executeStatement(...)` to run a + conditional `UPDATE ... WHERE ` (checking the affected-row count) or a guarded + `INSERT` (catching `UniqueConstraintViolationException`) as a portable, lock-free way to + atomically claim/reserve a row across concurrent workers - e.g. + `MessageRepository::tryClaimForProcessing` and + `DomainThrottleStateRepository::tryReserveSlot`/`resetBlockedCount`. + - This is not a domain-purity violation: it involves no `flush()`, transaction, or DDL, and + avoids vendor-specific upsert/locking syntax so it stays portable across MySQL/PostgreSQL/SQLite. + - Do *not* flag `executeStatement()` DML calls that follow this guarded-UPDATE/affected-rows + or catch-`UniqueConstraintViolationException`-on-INSERT pattern against the repository's own + entity table. + - Still flag any DBAL write that is unconditional, unrelated to claim/reservation semantics, or + that mutates a different entity's table than the repository owns. + + - ⚠️ For other non-DynamicListAttr code: - If code is invoking actual table-creation, DDL execution, or schema synchronization, then request moving that to the Infrastructure or Application layer (e.g. MessageHandler). - Repositories in Domain should be abstractions without side effects; they should express *intent*, diff --git a/.env.dist b/.env.dist index e66a76e46..a9738072b 100644 --- a/.env.dist +++ b/.env.dist @@ -78,9 +78,15 @@ MAILQUEUE_BATCH_SIZE=5 MAILQUEUE_BATCH_PERIOD=5 MAILQUEUE_THROTTLE=5 MESSAGING_MAX_PROCESS_TIME=600 +MESSAGING_STUCK_CAMPAIGN_THRESHOLD=1800 MAX_MAILSIZE=209715200 DEFAULT_MESSAGEAGE=691200 USE_MANUAL_TEXT_PART=0 +USE_LIST_EXCLUDE=0 +USE_DOMAIN_THROTTLE=0 +DOMAIN_BATCH_SIZE=1 +DOMAIN_BATCH_PERIOD=120 +DOMAIN_AUTO_THROTTLE=0 MESSAGING_BLACKLIST_GRACE_TIME=600 GOOGLE_SENDERID= USE_AMAZONSES=0 diff --git a/.env.test b/.env.test index 88b6c3386..a23613db7 100644 --- a/.env.test +++ b/.env.test @@ -1,3 +1,5 @@ PHPLIST_DATABASE_DRIVER=pdo_sqlite PHPLIST_DATABASE_PATH=:memory: SEARCH_TRANSPORT_DSN=sync:// +ELASTICSEARCH_ENABLED=false + diff --git a/config/parameters.yml b/config/parameters.yml index 1edd106dd..772774d14 100644 --- a/config/parameters.yml +++ b/config/parameters.yml @@ -76,9 +76,15 @@ parameters: messaging.mail_queue_period: '%env(MAILQUEUE_BATCH_PERIOD)%' messaging.mail_queue_throttle: '%env(MAILQUEUE_THROTTLE)%' messaging.max_process_time: '%env(MESSAGING_MAX_PROCESS_TIME)%' + messaging.stuck_campaign_threshold: '%env(int:MESSAGING_STUCK_CAMPAIGN_THRESHOLD)%' messaging.max_mail_size: '%env(MAX_MAILSIZE)%' messaging.default_message_age: '%env(DEFAULT_MESSAGEAGE)%' messaging.use_manual_text_part: '%env(USE_MANUAL_TEXT_PART)%' + messaging.use_list_exclude: '%env(bool:USE_LIST_EXCLUDE)%' + messaging.use_domain_throttle: '%env(bool:USE_DOMAIN_THROTTLE)%' + messaging.domain_batch_size: '%env(int:DOMAIN_BATCH_SIZE)%' + messaging.domain_batch_period: '%env(int:DOMAIN_BATCH_PERIOD)%' + messaging.domain_auto_throttle: '%env(bool:DOMAIN_AUTO_THROTTLE)%' messaging.blacklist_grace_time: '%env(MESSAGING_BLACKLIST_GRACE_TIME)%' messaging.google_sender_id: '%env(GOOGLE_SENDERID)%' messaging.use_amazon_ses: '%env(USE_AMAZONSES)%' diff --git a/config/services/repositories.yml b/config/services/repositories.yml index 5ee7eb407..4cb9d01b4 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -185,6 +185,10 @@ services: arguments: - PhpList\Core\Domain\Messaging\Model\Attachment + PhpList\Core\Domain\Messaging\Repository\DomainThrottleStateRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\DomainThrottleState PhpList\Core\Domain\Messaging\Repository\MessageAttachmentRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: diff --git a/config/services/services.yml b/config/services/services.yml index 9e527c13c..4d0b45147 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -73,6 +73,15 @@ services: $mailqueueBatchPeriod: '%messaging.mail_queue_period%' $mailqueueThrottle: '%messaging.mail_queue_throttle%' + PhpList\Core\Domain\Messaging\Service\DomainRateLimiter: + autowire: true + autoconfigure: true + arguments: + $enabled: '%messaging.use_domain_throttle%' + $domainBatchSize: '%messaging.domain_batch_size%' + $domainBatchPeriod: '%messaging.domain_batch_period%' + $autoThrottle: '%messaging.domain_auto_throttle%' + PhpList\Core\Domain\Common\SystemInfoCollector: autowire: true autoconfigure: true diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php index ad8d0f482..1e44e45dc 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php @@ -25,6 +25,7 @@ use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; use PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder; use PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder; +use PhpList\Core\Domain\Messaging\Service\DomainRateLimiter; use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; use PhpList\Core\Domain\Messaging\Service\MailSizeChecker; use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; @@ -72,13 +73,20 @@ public function __construct( private readonly EmailBuilder $campaignEmailBuilder, private readonly MailSizeChecker $mailSizeChecker, private readonly ConfigProvider $configProvider, + private readonly DomainRateLimiter $domainRateLimiter, #[Autowire('%imap_bounce.email%')] private readonly string $bounceEmail, + #[Autowire('%messaging.use_list_exclude%')] private readonly bool $useListExclude = false, + #[Autowire('%messaging.stuck_campaign_threshold%')] private readonly int $stuckCampaignThresholdSeconds = 0, ) { } public function __invoke(CampaignProcessorMessage|SyncCampaignProcessorMessage $data): void { - $campaign = $this->messageRepository->findByIdAndStatus($data->getMessageId(), MessageStatus::Submitted); + // todo: recheck this stuckCampaignThresholdSeconds logic + $campaign = $this->messageRepository->tryClaimForProcessing( + $data->getMessageId(), + $this->stuckCampaignThresholdSeconds + ); if (!$campaign) { $this->logger->warning( $this->translator->trans('Campaign not found or not in submitted status'), @@ -121,32 +129,17 @@ public function __invoke(CampaignProcessorMessage|SyncCampaignProcessorMessage $ $this->handleAdminNotifications($campaign, $loadedMessageData, $data->getMessageId()); - $this->updateMessageStatus($campaign, MessageStatus::Prepared); - $subscribers = $this->subscriberProvider->getSubscribersForMessageOrLists($data, $campaign); + // Campaign was already atomically claimed into Prepared status above. + $excludeListIds = $this->getExcludeListIds($loadedMessageData); + $this->markExcludedSubscribers($campaign, $data, $excludeListIds); + $subscribers = $this->subscriberProvider->getSubscribersForMessageOrLists( + $data, + $campaign, + $excludeListIds + ); $this->updateMessageStatus($campaign, MessageStatus::InProcess); -// if (USE_LIST_EXCLUDE) { -// if (VERBOSE) { -// processQueueOutput(s('looking for users who can be excluded from this mailing')); -// } -// if (count($msgdata['excludelist'])) { -// $query -// = ' select userid' -// .' from '.$GLOBALS['tables']['listuser'] -// .' where listid in ('.implode(',', $msgdata['excludelist']).')'; -// if (VERBOSE) { -// processQueueOutput('Exclude query '.$query); -// } -// $req = Sql_Query($query); -// while ($row = Sql_Fetch_Row($req)) { -// $um = Sql_Query(sprintf('replace into %s (entered,userid,messageid,status) -// values(now(),%d,%d,"excluded")', -// $tables['usermessage'], $row[0], $messageid)); -// } -// } -// } - $stoppedEarly = $this->processSubscribersForCampaign($campaign, $subscribers, $cacheKey); if ($stoppedEarly && $this->requeueHandler->handle($campaign)) { @@ -157,6 +150,72 @@ public function __invoke(CampaignProcessorMessage|SyncCampaignProcessorMessage $ $this->updateMessageStatus($campaign, MessageStatus::Sent); } + /** + * Exclude-list IDs are stored via MessageData as an array keyed by list ID e.g. [3 => 1, 7 => 1]. + * + * @return int[] + */ + private function getExcludeListIds(array $loadedMessageData): array + { + if (!$this->useListExclude) { + return []; + } + + $excludeList = $loadedMessageData['excludelist'] ?? []; + if (!is_array($excludeList) || $excludeList === []) { + return []; + } + + return array_values(array_filter(array_map( + static fn (mixed $key): ?int => is_numeric($key) ? (int) $key : null, + array_keys($excludeList) + ), static fn (?int $id): bool => $id !== null)); + } + + /** + * pre-marking of exclude-list members as "excluded" in usermessage before the main send loop runs, + * so there's a persisted audit trail for why a subscriber wasn't sent to. Skips + * subscribers who already have a nontodo UserMessage for this campaign, so a later run + * can't clobber an already-recorded Sent/NotSent/etc. status from an earlier partial run. + * Only campaign recipients (i.e. subscribers who'd otherwise be sent this campaign) are + * marked, since a subscriber on an exclude list who isn't a campaign recipient anyway + * shouldn't get an exclusion record. + */ + private function markExcludedSubscribers( + Message $campaign, + CampaignProcessorMessage|SyncCampaignProcessorMessage $data, + array $excludeListIds, + ): void { + if ($excludeListIds === []) { + return; + } + + $excludedSubscribers = $this->subscriberProvider->getExcludedSubscribers($excludeListIds); + if ($excludedSubscribers === []) { + return; + } + + $sendableSubscribers = $this->subscriberProvider->getSendableSubscribersForMessageOrLists( + $data, + $campaign + ); + + foreach ($excludedSubscribers as $subscriber) { + if (!isset($sendableSubscribers[$subscriber->getEmail()])) { + continue; + } + + $existing = $this->userMessageRepository->findByUserAndMessage($subscriber, $campaign); + if ($existing && $existing->getStatus() !== UserMessageStatus::Todo) { + continue; + } + + $userMessage = $existing ?? new UserMessage($subscriber, $campaign); + $userMessage->setStatus(UserMessageStatus::Excluded); + $this->userMessageRepository->save($userMessage); + } + } + private function unconfirmSubscriber(Subscriber $subscriber): void { if ($subscriber->isConfirmed()) { @@ -170,6 +229,9 @@ private function updateMessageStatus(Message $message, MessageStatus $status): v if ($status === MessageStatus::InProcess && $message->getMetadata()->getSendStart() === null) { $message->getMetadata()->setSendStart(new DateTime()); } + if ($status === MessageStatus::Sent) { + $message->getMetadata()->setSent(new DateTime()); + } $message->getMetadata()->setStatus($status); $this->entityManager->flush(); } @@ -220,6 +282,9 @@ private function handleEmailSending( htmlPref: $subscriber->hasHtmlEmail(), ); if ($result === null) { + $status = $subscriber->isBlacklisted() ? UserMessageStatus::Excluded : UserMessageStatus::NotSent; + $this->updateUserMessageStatus($userMessage, $status); + return; } [$email, $sentAs] = $result; @@ -228,7 +293,7 @@ private function handleEmailSending( $this->rateLimitedCampaignMailer->send($email); ($this->mailSizeChecker)($campaign, $email, $subscriber->hasHtmlEmail()); $this->updateUserMessageStatus($userMessage, UserMessageStatus::Sent); - $campaign->incrementSentCount($sentAs); + $this->messageRepository->incrementSentCounts($campaign->getId(), $sentAs); } catch (MessageSizeLimitExceededException $e) { // stop after the first message if size is exceeded $this->updateMessageStatus($campaign, MessageStatus::Suspended); @@ -330,6 +395,13 @@ private function processSubscribersForCampaign(Message $campaign, array $subscri continue; } + if (!$this->domainRateLimiter->attemptSend($subscriber->getEmail())->allowed) { + // Leave no UserMessage record so this subscriber is picked up again on a + // later run, once their domain's throttle window has passed. + $stoppedEarly = true; + continue; + } + $userMessage = $existing ?? new UserMessage($subscriber, $campaign); $userMessage->setStatus(UserMessageStatus::Active); $this->userMessageRepository->save($userMessage); diff --git a/src/Domain/Messaging/Model/DomainThrottleState.php b/src/Domain/Messaging/Model/DomainThrottleState.php new file mode 100644 index 000000000..13035049d --- /dev/null +++ b/src/Domain/Messaging/Model/DomainThrottleState.php @@ -0,0 +1,62 @@ +domain = $domain; + $this->windowStart = $windowStart; + $this->sentCount = $sentCount; + $this->blockedCount = $blockedCount; + } + + public function getDomain(): string + { + return $this->domain; + } + + public function getWindowStart(): int + { + return $this->windowStart; + } + + public function getSentCount(): int + { + return $this->sentCount; + } + + public function getBlockedCount(): int + { + return $this->blockedCount; + } +} diff --git a/src/Domain/Messaging/Model/Dto/DomainThrottleReservation.php b/src/Domain/Messaging/Model/Dto/DomainThrottleReservation.php new file mode 100644 index 000000000..652286fbe --- /dev/null +++ b/src/Domain/Messaging/Model/Dto/DomainThrottleReservation.php @@ -0,0 +1,17 @@ +sent = $sent; diff --git a/src/Domain/Messaging/Model/UserMessage.php b/src/Domain/Messaging/Model/UserMessage.php index 93b457f34..6c03ca1d1 100644 --- a/src/Domain/Messaging/Model/UserMessage.php +++ b/src/Domain/Messaging/Model/UserMessage.php @@ -66,9 +66,9 @@ public function getViewed(): ?DateTime return $this->viewed; } - public function getStatus(): ?UserMessageStatus + public function getStatus(): UserMessageStatus { - return UserMessageStatus::from($this->status); + return $this->status !== null ? UserMessageStatus::from($this->status) : UserMessageStatus::Todo; } public function setViewed(?DateTime $viewed): self diff --git a/src/Domain/Messaging/Repository/DomainThrottleStateRepository.php b/src/Domain/Messaging/Repository/DomainThrottleStateRepository.php new file mode 100644 index 000000000..4baa39074 --- /dev/null +++ b/src/Domain/Messaging/Repository/DomainThrottleStateRepository.php @@ -0,0 +1,144 @@ +getEntityManager()->getConnection(); + $table = $connection->quoteIdentifier($this->getClassMetadata()->getTableName()); + + if ($this->incrementSentIfAllowed($connection, $table, $domain, $windowStart, $batchSize)) { + return new DomainThrottleReservation(allowed: true); + } + + if ($this->rolloverWindow($connection, $table, $domain, $windowStart)) { + return new DomainThrottleReservation(allowed: true); + } + + if ($this->insertFirstRow($connection, $table, $domain, $windowStart)) { + return new DomainThrottleReservation(allowed: true); + } + + // Lost the insert race to another worker; its row may already have room in this + // window, so give the increment one more try before concluding we're blocked. + if ($this->incrementSentIfAllowed($connection, $table, $domain, $windowStart, $batchSize)) { + return new DomainThrottleReservation(allowed: true); + } + + return new DomainThrottleReservation( + allowed: false, + blockedAttempts: $this->incrementBlocked($connection, $table, $domain, $windowStart), + ); + } + + /** + * Atomically claims the auto-throttle trigger for the specified domain/window. + * + * The row's blocked_count is reset to 0 only if it is still greater than $threshold at the moment + * the UPDATE executes. The worker whose UPDATE successfully performs that reset receives true and is + * considered to have claimed the trigger. Concurrent workers racing to claim the same trigger + * will see no rows updated once the count has already been reset and will receive false. + */ + public function resetBlockedCount(string $domain, int $windowStart, int $threshold): bool + { + $connection = $this->getEntityManager()->getConnection(); + $table = $connection->quoteIdentifier($this->getClassMetadata()->getTableName()); + + $affected = $connection->executeStatement( + sprintf( + 'UPDATE %s SET blocked_count = 0 + WHERE domain = :domain AND window_start = :window AND blocked_count > :threshold', + $table + ), + ['domain' => $domain, 'window' => $windowStart, 'threshold' => $threshold] + ); + + return $affected > 0; + } + + /** @phpstan-impure */ + private function incrementSentIfAllowed( + Connection $connection, + string $table, + string $domain, + int $windowStart, + int $batchSize + ): bool { + $affected = $connection->executeStatement( + sprintf( + 'UPDATE %s SET sent_count = sent_count + 1 + WHERE domain = :domain AND window_start = :window AND sent_count < :batchSize', + $table + ), + ['domain' => $domain, 'window' => $windowStart, 'batchSize' => $batchSize] + ); + + return $affected > 0; + } + + /** @phpstan-impure */ + private function rolloverWindow(Connection $connection, string $table, string $domain, int $windowStart): bool + { + $affected = $connection->executeStatement( + sprintf( + 'UPDATE %s SET window_start = :window, sent_count = 1, blocked_count = 0 + WHERE domain = :domain AND window_start < :window', + $table + ), + ['domain' => $domain, 'window' => $windowStart] + ); + + return $affected > 0; + } + + /** @phpstan-impure */ + private function insertFirstRow(Connection $connection, string $table, string $domain, int $windowStart): bool + { + try { + $connection->executeStatement( + sprintf( + 'INSERT INTO %s (domain, window_start, sent_count, blocked_count) VALUES (:domain, :window, 1, 0)', + $table + ), + ['domain' => $domain, 'window' => $windowStart] + ); + + return true; + } catch (UniqueConstraintViolationException) { + return false; + } + } + + /** @phpstan-impure */ + private function incrementBlocked(Connection $connection, string $table, string $domain, int $windowStart): int + { + $connection->executeStatement( + sprintf( + 'UPDATE %s SET blocked_count = blocked_count + 1 WHERE domain = :domain AND window_start = :window', + $table + ), + ['domain' => $domain, 'window' => $windowStart] + ); + + return (int) $connection->fetchOne( + sprintf('SELECT blocked_count FROM %s WHERE domain = :domain AND window_start = :window', $table), + ['domain' => $domain, 'window' => $windowStart] + ); + } +} diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php index 133947946..c5698775d 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 DateTime; use DateTimeImmutable; use DateTimeInterface; use Doctrine\ORM\AbstractQuery; @@ -11,6 +12,7 @@ use PhpList\Core\Domain\Common\Model\PaginatedResult; use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; +use PhpList\Core\Domain\Configuration\Model\OutputFormat; use PhpList\Core\Domain\Messaging\Model\Filter\MessageFilter; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Subscription\Model\SubscriberList; @@ -158,6 +160,103 @@ public function findByIdAndStatus(int $id, Message\MessageStatus $status): ?Mess ->getOneOrNullResult(); } + /** + * Atomically claims a campaign for processing by flipping its status from Submitted to + * Prepared in a single UPDATE ... WHERE statement, so two concurrent workers can't both + * pass a check-then-act race and process the same campaign. + * + * When $staleAfterSeconds > 0, the same atomic UPDATE also reclaims a row stuck in + * Prepared/InProcess whose `modified` is older than that threshold (crashed/killed worker, + * or a handler that threw before requeuing). Staleness is re-checked against the row's + * current `modified` at the moment of this UPDATE, not pre-computed by the caller, so a + * worker that's merely slow (and keeps bumping `modified` via incrementSentCounts()) can't + * be claimed out from under itself by a second, concurrent dispatch. + */ + public function tryClaimForProcessing(int $id, int $staleAfterSeconds = 0): ?Message + { + $connection = $this->getEntityManager()->getConnection(); + $table = $connection->quoteIdentifier($this->getClassMetadata()->getTableName()); + $now = new DateTime(); + + $params = [ + 'to' => Message\MessageStatus::Prepared->value, + 'now' => $now->format('Y-m-d H:i:s'), + 'id' => $id, + 'from' => Message\MessageStatus::Submitted->value, + ]; + + $claimCondition = 'status = :from'; + if ($staleAfterSeconds > 0) { + $claimCondition = '(status = :from OR (status IN (:prepared, :inProcess) AND modified < :staleBefore))'; + $params['prepared'] = Message\MessageStatus::Prepared->value; + $params['inProcess'] = Message\MessageStatus::InProcess->value; + $params['staleBefore'] = (clone $now) + ->modify(sprintf('-%d seconds', $staleAfterSeconds)) + ->format('Y-m-d H:i:s'); + } + + $sql = sprintf('UPDATE %s SET status = :to, modified = :now WHERE id = :id AND %s', $table, $claimCondition); + + $affected = $connection->executeStatement($sql, $params); + + if ($affected === 0) { + return null; + } + + return $this->find($id); + } + + /** + * Returns campaigns stuck in Prepared/InProcess whose row hasn't been touched since + * $staleBefore, i.e. candidates for tryClaimForProcessing's stale-reclaim path. Callers + * are expected to re-dispatch a CampaignProcessorMessage for each, since nothing else + * automatically resumes a campaign that isn't in Submitted status. + * + * @return Message[] + */ + public function getStuckInProcessing(DateTimeImmutable $staleBefore): array + { + return $this->createQueryBuilder('m') + ->where('m.metadata.status IN (:statuses)') + ->andWhere('m.updatedAt < :staleBefore') + ->setParameter('statuses', [ + Message\MessageStatus::Prepared->value, + Message\MessageStatus::InProcess->value, + ]) + ->setParameter('staleBefore', $staleBefore) + ->getQuery() + ->getResult(); + } + + /** + * Atomically increments a campaign's processed/format-sent counters directly in the + * database (bypassing the entity's in-memory incrementSentCount()), so concurrent + * updates to the same campaign can't lose an update the way a read-modify-write via + * the entity manager could. Also bumps `modified`, since this is the liveness signal + * tryClaimForProcessing's stale-reclaim relies on. + */ + public function incrementSentCounts(int $messageId, OutputFormat $sentAs): void + { + $formatField = match ($sentAs) { + OutputFormat::Html => 'm.format.asHtml', + OutputFormat::Text => 'm.format.asText', + OutputFormat::Pdf => 'm.format.asPdf', + OutputFormat::TextAndHtml => 'm.format.asTextAndHtml', + OutputFormat::TextAndPdf => 'm.format.asTextAndPdf', + }; + + $this->createQueryBuilder('m') + ->update() + ->set('m.metadata.processed', 'm.metadata.processed + 1') + ->set($formatField, $formatField . ' + 1') + ->set('m.updatedAt', ':now') + ->where('m.id = :id') + ->setParameter('now', new DateTime()) + ->setParameter('id', $messageId) + ->getQuery() + ->execute(); + } + public function getNonEmptyFields(int $id): array { $message = $this->createQueryBuilder('m') diff --git a/src/Domain/Messaging/Service/DomainRateLimiter.php b/src/Domain/Messaging/Service/DomainRateLimiter.php new file mode 100644 index 000000000..8d4634b73 --- /dev/null +++ b/src/Domain/Messaging/Service/DomainRateLimiter.php @@ -0,0 +1,119 @@ +enabled || $this->domainBatchSize <= 0 || $this->domainBatchPeriod <= 0) { + return new DomainThrottleResult(allowed: true, domain: null); + } + + $domain = $this->extractDomain($email); + if ($domain === null) { + return new DomainThrottleResult(allowed: true, domain: null); + } + + $windowStart = intdiv(time(), $this->domainBatchPeriod) * $this->domainBatchPeriod; + $reservation = $this->repository->tryReserveSlot($domain, $windowStart, $this->domainBatchSize); + + if ($reservation->allowed) { + return new DomainThrottleResult(allowed: true, domain: $domain); + } + + $this->logger->info('Send blocked by domain throttle', [ + 'domain' => $domain, + 'blocked_attempts' => $reservation->blockedAttempts, + 'domain_batch_size' => $this->domainBatchSize, + 'domain_batch_period' => $this->domainBatchPeriod, + ]); + + return $this->applyAutoThrottleIfDue($domain, $windowStart, $reservation->blockedAttempts); + } + + private function applyAutoThrottleIfDue( + string $domain, + int $windowStart, + int $blockedAttempts + ): DomainThrottleResult { + if (!$this->autoThrottle || $blockedAttempts <= self::AUTO_THROTTLE_ATTEMPT_THRESHOLD) { + return new DomainThrottleResult(allowed: false, domain: $domain, blockedAttempts: $blockedAttempts); + } + + // Concurrent workers can all observe blockedAttempts over the threshold at once; only + // the one that atomically claims the reset applies the backoff delay, so the rest + // continue instead of all sleeping for the same trigger. + $claimed = $this->repository->resetBlockedCount( + $domain, + $windowStart, + self::AUTO_THROTTLE_ATTEMPT_THRESHOLD + ); + if (!$claimed) { + return new DomainThrottleResult(allowed: false, domain: $domain, blockedAttempts: $blockedAttempts); + } + + $delaySeconds = max(1, intdiv($this->domainBatchPeriod, max(1, $this->domainBatchSize * 4))); + + $this->logger->info('Introducing extra delay to reduce domain throttle failures', [ + 'domain' => $domain, + 'delay_seconds' => $delaySeconds, + ]); + sleep($delaySeconds); + + return new DomainThrottleResult( + allowed: false, + domain: $domain, + blockedAttempts: $blockedAttempts, + backoffApplied: true, + backoffSeconds: $delaySeconds, + ); + } + + private function extractDomain(string $email): ?string + { + $atPosition = strrpos($email, '@'); + if ($atPosition === false) { + return null; + } + + return strtolower(substr($email, $atPosition + 1)); + } +} diff --git a/src/Domain/Messaging/Service/Handler/RequeueHandler.php b/src/Domain/Messaging/Service/Handler/RequeueHandler.php index 3fbca634e..0d028023c 100644 --- a/src/Domain/Messaging/Service/Handler/RequeueHandler.php +++ b/src/Domain/Messaging/Service/Handler/RequeueHandler.php @@ -14,6 +14,16 @@ class RequeueHandler { + /** + * Fallback delay (minutes) used when a campaign stops early (time limit, domain throttle, + * etc.) but has no explicit requeueInterval configured. requeueInterval/requeueUntil control + * *how long* to wait before resuming, not *whether* to resume: a campaign that stopped early + * must always be retried, mirroring phplist3's unconditional "don't mark sent while anything + * failed/was throttled" guard - it must never be silently marked Sent with recipients still + * unprocessed. requeueUntil remains a legitimate opt-out (a real deadline). + */ + private const DEFAULT_REQUEUE_INTERVAL_MINUTES = 1; + public function __construct( private readonly LoggerInterface $logger, private readonly TranslatorInterface $translator, @@ -24,11 +34,11 @@ public function handle(Message $campaign, ?OutputInterface $output = null): bool { $schedule = $campaign->getSchedule(); $interval = $schedule->getRequeueInterval() ?? 0; - $until = $schedule->getRequeueUntil(); - if ($interval <= 0) { - return false; + $interval = self::DEFAULT_REQUEUE_INTERVAL_MINUTES; } + $until = $schedule->getRequeueUntil(); + $now = new DateTime(); if ($until instanceof DateTime && $now > $until) { return false; diff --git a/src/Domain/Messaging/Service/Manager/MessageManager.php b/src/Domain/Messaging/Service/Manager/MessageManager.php index 7ed345946..bd7b93155 100644 --- a/src/Domain/Messaging/Service/Manager/MessageManager.php +++ b/src/Domain/Messaging/Service/Manager/MessageManager.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Domain\Messaging\Service\Manager; +use DateTimeImmutable; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\Dto\MessageContext; use PhpList\Core\Domain\Messaging\Model\Dto\MessageDtoInterface; @@ -101,6 +102,12 @@ public function getMessagesByOwner(Administrator $owner): array return $this->messageRepository->getByOwnerId($owner->getId()); } + /** @return Message[] */ + public function getStuckCampaigns(DateTimeImmutable $staleBefore): array + { + return $this->messageRepository->getStuckInProcessing($staleBefore); + } + private function canBeSubmitted(Message $message): bool { return $message->getListMessages()->count() > 0 diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index e9def63e5..2f20b3349 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -73,6 +73,52 @@ public function getSubscribersBySubscribedListId(int $listId): array ->getResult(); } + /** + * Same as getSubscribersBySubscribedListId(), but restricted to subscribers who are + * confirmed and not disabled - i.e. eligible to receive a campaign. Blacklisting is + * intentionally not filtered here since it's checked live against UserBlacklistRepository + * at send time instead of the (potentially stale) Subscriber::$blacklisted flag. + * + * @return Subscriber[] + */ + public function getSendableSubscribersBySubscribedListId(int $listId): array + { + return $this->createQueryBuilder('s') + ->innerJoin('s.subscriptions', 'subscription') + ->innerJoin('subscription.subscriberList', 'list') + ->where('list.id = :listId') + ->andWhere('s.confirmed = :confirmed') + ->andWhere('s.disabled = :disabled') + ->setParameter('listId', $listId) + ->setParameter('confirmed', true) + ->setParameter('disabled', false) + ->getQuery() + ->getResult(); + } + + /** + * Returns all subscribers on any of the given lists, regardless of confirmed/disabled + * status - used to resolve campaign exclude-lists, where membership alone is enough + * to suppress a send. + * + * @param int[] $listIds + * @return Subscriber[] + */ + public function getSubscribersBySubscribedListIds(array $listIds): array + { + if ($listIds === []) { + return []; + } + + return $this->createQueryBuilder('s') + ->innerJoin('s.subscriptions', 'subscription') + ->innerJoin('subscription.subscriberList', 'list') + ->where('list.id IN (:listIds)') + ->setParameter('listIds', $listIds) + ->getQuery() + ->getResult(); + } + /** * @return PaginatedResult * @throws InvalidArgumentException diff --git a/src/Domain/Subscription/Service/Provider/SubscriberProvider.php b/src/Domain/Subscription/Service/Provider/SubscriberProvider.php index 758db32ec..3d5b50fa4 100644 --- a/src/Domain/Subscription/Service/Provider/SubscriberProvider.php +++ b/src/Domain/Subscription/Service/Provider/SubscriberProvider.php @@ -29,14 +29,58 @@ public function __construct( * * @param CampaignProcessorMessageInterface $data * @param Message $campaign + * @param int[] $excludeListIds List IDs whose members should be suppressed from the send, + * regardless of their confirmed/disabled status. * @return Subscriber[] Array of subscribers */ - public function getSubscribersForMessageOrLists(CampaignProcessorMessageInterface $data, Message $campaign): array - { + public function getSubscribersForMessageOrLists( + CampaignProcessorMessageInterface $data, + Message $campaign, + array $excludeListIds = [], + ): array { if ($data instanceof TestCampaignProcessorMessage) { return $this->subscriberRepository->getByEmails($data->getSubscriberEmails()); } + $subscribers = $this->getSendableSubscribersByListMembership($data, $campaign); + + foreach ($this->getExcludedSubscribers($excludeListIds) as $excluded) { + unset($subscribers[$excluded->getEmail()]); + } + + return array_values($subscribers); + } + + /** + * Resolves the campaign's sendable recipients by list membership (confirmed, not disabled), + * before any list-based exclusion is applied. Used to determine which excluded subscribers + * are actually campaign recipients, so exclusion records aren't created for non-recipients. + * + * @return array Subscribers keyed by email + */ + public function getSendableSubscribersForMessageOrLists( + CampaignProcessorMessageInterface $data, + Message $campaign, + ): array { + if ($data instanceof TestCampaignProcessorMessage) { + $subscribers = []; + foreach ($this->subscriberRepository->getByEmails($data->getSubscriberEmails()) as $subscriber) { + $subscribers[$subscriber->getEmail()] = $subscriber; + } + + return $subscribers; + } + + return $this->getSendableSubscribersByListMembership($data, $campaign); + } + + /** + * @return array Subscribers keyed by email + */ + private function getSendableSubscribersByListMembership( + CampaignProcessorMessageInterface $data, + Message $campaign, + ): array { if (count($data->getListIds()) > 0) { $listIds = $data->getListIds(); } else { @@ -45,12 +89,28 @@ public function getSubscribersForMessageOrLists(CampaignProcessorMessageInterfac $subscribers = []; foreach ($listIds as $listId) { - $listSubscribers = $this->subscriberRepository->getSubscribersBySubscribedListId($listId); + $listSubscribers = $this->subscriberRepository->getSendableSubscribersBySubscribedListId($listId); foreach ($listSubscribers as $subscriber) { $subscribers[$subscriber->getEmail()] = $subscriber; } } - return array_values($subscribers); + return $subscribers; + } + + /** + * Resolves the subscribers on the given exclude-lists, regardless of confirmed/disabled + * status - membership alone is enough to suppress a send. + * + * @param int[] $excludeListIds + * @return Subscriber[] + */ + public function getExcludedSubscribers(array $excludeListIds): array + { + if ($excludeListIds === []) { + return []; + } + + return $this->subscriberRepository->getSubscribersBySubscribedListIds($excludeListIds); } } diff --git a/src/Migrations/Version20260908130000MySqlCreateDomainThrottleTable.php b/src/Migrations/Version20260908130000MySqlCreateDomainThrottleTable.php new file mode 100644 index 000000000..f1cddf57c --- /dev/null +++ b/src/Migrations/Version20260908130000MySqlCreateDomainThrottleTable.php @@ -0,0 +1,46 @@ +connection->getDatabasePlatform(); + $this->skipIf(!$platform instanceof MySQLPlatform, sprintf( + 'Unsupported platform for this migration: %s', + get_class($platform) + )); + + $this->addSql( + 'CREATE TABLE phplist_domain_throttle ( + domain VARCHAR(255) NOT NULL, + window_start INT NOT NULL, + sent_count INT NOT NULL DEFAULT 0, + blocked_count INT NOT NULL DEFAULT 0, + PRIMARY KEY (domain) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3' + ); + } + + 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('DROP TABLE phplist_domain_throttle'); + } +} \ No newline at end of file diff --git a/src/Migrations/Version20260908130001PostGreCreateDomainThrottleTable.php b/src/Migrations/Version20260908130001PostGreCreateDomainThrottleTable.php new file mode 100644 index 000000000..f78354654 --- /dev/null +++ b/src/Migrations/Version20260908130001PostGreCreateDomainThrottleTable.php @@ -0,0 +1,46 @@ +connection->getDatabasePlatform(); + $this->skipIf(!$platform instanceof PostgreSQLPlatform, sprintf( + 'Unsupported platform for this migration: %s', + get_class($platform) + )); + + $this->addSql( + 'CREATE TABLE phplist_domain_throttle ( + domain VARCHAR(255) NOT NULL, + window_start INT NOT NULL, + sent_count INT NOT NULL DEFAULT 0, + blocked_count INT NOT NULL DEFAULT 0, + PRIMARY KEY (domain) + )' + ); + } + + 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 TABLE phplist_domain_throttle'); + } +} \ No newline at end of file diff --git a/tests/Integration/Domain/Messaging/Repository/DomainThrottleStateRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/DomainThrottleStateRepositoryTest.php new file mode 100644 index 000000000..08a6c6a0a --- /dev/null +++ b/tests/Integration/Domain/Messaging/Repository/DomainThrottleStateRepositoryTest.php @@ -0,0 +1,102 @@ +loadSchema(); + + $this->repository = self::getContainer()->get(DomainThrottleStateRepository::class); + } + + protected function tearDown(): void + { + $schemaTool = new SchemaTool($this->entityManager); + $schemaTool->dropDatabase(); + parent::tearDown(); + } + + public function testFirstReservationForNewDomainIsAllowed(): void + { + $reservation = $this->repository->tryReserveSlot('example.com', 1000, 1); + + $this->assertTrue($reservation->allowed); + $this->assertSame(0, $reservation->blockedAttempts); + } + + public function testReservationBlockedOnceQuotaReachedInSameWindow(): void + { + $this->assertTrue($this->repository->tryReserveSlot('example.com', 1000, 1)->allowed); + + $second = $this->repository->tryReserveSlot('example.com', 1000, 1); + + $this->assertFalse($second->allowed); + $this->assertSame(1, $second->blockedAttempts); + + $third = $this->repository->tryReserveSlot('example.com', 1000, 1); + $this->assertFalse($third->allowed); + $this->assertSame(2, $third->blockedAttempts); + } + + public function testDomainsAreTrackedIndependently(): void + { + $this->assertTrue($this->repository->tryReserveSlot('a.com', 1000, 1)->allowed); + + $this->assertTrue($this->repository->tryReserveSlot('b.com', 1000, 1)->allowed); + $this->assertFalse($this->repository->tryReserveSlot('a.com', 1000, 1)->allowed); + } + + public function testReservationAllowedAgainAfterWindowRollsOver(): void + { + $this->assertTrue($this->repository->tryReserveSlot('example.com', 1000, 1)->allowed); + $this->assertFalse($this->repository->tryReserveSlot('example.com', 1000, 1)->allowed); + + $nextWindow = $this->repository->tryReserveSlot('example.com', 1120, 1); + + $this->assertTrue($nextWindow->allowed); + } + + public function testResetBlockedCountClearsCounterForCurrentWindow(): void + { + $this->repository->tryReserveSlot('example.com', 1000, 1); + $this->repository->tryReserveSlot('example.com', 1000, 1); + $blocked = $this->repository->tryReserveSlot('example.com', 1000, 1); + $this->assertSame(2, $blocked->blockedAttempts); + + $claimed = $this->repository->resetBlockedCount('example.com', 1000, threshold: 1); + $this->assertTrue($claimed); + + $afterReset = $this->repository->tryReserveSlot('example.com', 1000, 1); + $this->assertFalse($afterReset->allowed); + $this->assertSame(1, $afterReset->blockedAttempts); + } + + public function testResetBlockedCountDoesNotClaimWhenCountAtOrBelowThreshold(): void + { + $this->repository->tryReserveSlot('example.com', 1000, 1); + $this->repository->tryReserveSlot('example.com', 1000, 1); + $blocked = $this->repository->tryReserveSlot('example.com', 1000, 1); + $this->assertSame(2, $blocked->blockedAttempts); + + $claimed = $this->repository->resetBlockedCount('example.com', 1000, threshold: 2); + $this->assertFalse($claimed); + + $afterAttempt = $this->repository->tryReserveSlot('example.com', 1000, 1); + $this->assertFalse($afterAttempt->allowed); + $this->assertSame(3, $afterAttempt->blockedAttempts); + } +} diff --git a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php index 7bd83207d..a384457e5 100644 --- a/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php +++ b/tests/Integration/Domain/Messaging/Repository/MessageRepositoryTest.php @@ -221,6 +221,90 @@ public function testGetFilteredAfterIdDefaultsToAscendingOrder(): void self::assertSame($second->getId(), $result->getItems()[1]->getId()); } + public function testTryClaimForProcessingClaimsSubmittedCampaign(): void + { + $message = $this->persistMessage(Message\MessageStatus::Submitted, 'Ready to send'); + $this->entityManager->flush(); + $id = $message->getId(); + $this->entityManager->clear(); + + $claimed = $this->messageRepository->tryClaimForProcessing($id); + + self::assertNotNull($claimed); + self::assertSame($id, $claimed->getId()); + self::assertSame(Message\MessageStatus::Prepared, $claimed->getMetadata()->getStatus()); + } + + public function testTryClaimForProcessingReturnsNullWhenNotSubmitted(): void + { + $message = $this->persistMessage(Message\MessageStatus::Draft, 'Not ready yet'); + $this->entityManager->flush(); + $id = $message->getId(); + $this->entityManager->clear(); + + self::assertNull($this->messageRepository->tryClaimForProcessing($id)); + } + + public function testTryClaimForProcessingCannotClaimTwice(): void + { + $message = $this->persistMessage(Message\MessageStatus::Submitted, 'Only one winner'); + $this->entityManager->flush(); + $id = $message->getId(); + $this->entityManager->clear(); + + $firstClaim = $this->messageRepository->tryClaimForProcessing($id); + $secondClaim = $this->messageRepository->tryClaimForProcessing($id); + + self::assertNotNull($firstClaim); + self::assertNull($secondClaim); + } + + public function testTryClaimForProcessingReclaimsStalePreparedCampaignWhenThresholdGiven(): void + { + $message = $this->persistMessage(Message\MessageStatus::Prepared, 'Stuck in prepared'); + $this->entityManager->flush(); + $id = $message->getId(); + $this->backdateModified($id, 3600); + $this->entityManager->clear(); + + $claimed = $this->messageRepository->tryClaimForProcessing($id, staleAfterSeconds: 1800); + + self::assertNotNull($claimed); + self::assertSame(Message\MessageStatus::Prepared, $claimed->getMetadata()->getStatus()); + } + + public function testTryClaimForProcessingDoesNotReclaimRecentlyTouchedPreparedCampaign(): void + { + $message = $this->persistMessage(Message\MessageStatus::Prepared, 'Still alive'); + $this->entityManager->flush(); + $id = $message->getId(); + $this->entityManager->clear(); + + self::assertNull($this->messageRepository->tryClaimForProcessing($id, staleAfterSeconds: 1800)); + } + + public function testTryClaimForProcessingIgnoresStalePreparedCampaignWithoutThreshold(): void + { + $message = $this->persistMessage(Message\MessageStatus::Prepared, 'Stuck but no threshold given'); + $this->entityManager->flush(); + $id = $message->getId(); + $this->backdateModified($id, 3600); + $this->entityManager->clear(); + + self::assertNull($this->messageRepository->tryClaimForProcessing($id)); + } + + private function backdateModified(int $id, int $secondsAgo): void + { + $table = $this->entityManager->getClassMetadata(Message::class)->getTableName(); + $modified = (new DateTime())->modify(sprintf('-%d seconds', $secondsAgo)); + + $this->entityManager->getConnection()->executeStatement( + sprintf('UPDATE %s SET modified = :modified WHERE id = :id', $table), + ['modified' => $modified->format('Y-m-d H:i:s'), 'id' => $id] + ); + } + public function testGetFilteredAfterIdSortsDescendingAndCursorsBackward(): void { $first = $this->persistMessage(Message\MessageStatus::Sent, 'First'); diff --git a/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php b/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php index 2aa89b253..1a091bae8 100644 --- a/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php +++ b/tests/Integration/Domain/Subscription/Repository/SubscriberRepositoryTest.php @@ -8,6 +8,7 @@ use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\Tools\SchemaTool; use PhpList\Core\Domain\Subscription\Model\Subscriber; +use PhpList\Core\Domain\Subscription\Model\SubscriberList; use PhpList\Core\Domain\Subscription\Model\Subscription; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository; @@ -239,4 +240,65 @@ public function testRemoveRemovesModel() $numberOfModelsAfterRemove = count($this->subscriberRepository->findAll()); self::assertSame(1, $numberOfModelsBeforeRemove - $numberOfModelsAfterRemove); } + + private function subscribe(Subscriber $subscriber, SubscriberList $list): void + { + $subscription = (new Subscription()) + ->setSubscriber($subscriber) + ->setSubscriberList($list); + $this->entityManager->persist($subscription); + } + + public function testGetSendableSubscribersBySubscribedListIdExcludesUnconfirmedAndDisabled(): void + { + $list = (new SubscriberList())->setName('list'); + $this->entityManager->persist($list); + + $confirmed = (new Subscriber('confirmed@example.com'))->setConfirmed(true); + $unconfirmed = (new Subscriber('unconfirmed@example.com'))->setConfirmed(false); + $disabled = (new Subscriber('disabled@example.com'))->setConfirmed(true)->setDisabled(true); + foreach ([$confirmed, $unconfirmed, $disabled] as $subscriber) { + $this->entityManager->persist($subscriber); + $this->subscribe($subscriber, $list); + } + $this->entityManager->flush(); + + $result = $this->subscriberRepository->getSendableSubscribersBySubscribedListId($list->getId()); + + self::assertTrue(in_array($confirmed, $result, true)); + self::assertFalse(in_array($unconfirmed, $result, true)); + self::assertFalse(in_array($disabled, $result, true)); + } + + public function testGetSubscribersBySubscribedListIdsReturnsMembersOfAnyGivenList(): void + { + $listA = (new SubscriberList())->setName('a'); + $listB = (new SubscriberList())->setName('b'); + $listC = (new SubscriberList())->setName('c'); + $this->entityManager->persist($listA); + $this->entityManager->persist($listB); + $this->entityManager->persist($listC); + + $inA = new Subscriber('in-a@example.com'); + $inB = new Subscriber('in-b@example.com'); + $inC = new Subscriber('in-c@example.com'); + $this->entityManager->persist($inA); + $this->entityManager->persist($inB); + $this->entityManager->persist($inC); + $this->subscribe($inA, $listA); + $this->subscribe($inB, $listB); + $this->subscribe($inC, $listC); + $this->entityManager->flush(); + + $result = $this->subscriberRepository->getSubscribersBySubscribedListIds([$listA->getId(), $listB->getId()]); + + self::assertTrue(in_array($inA, $result, true)); + self::assertTrue(in_array($inB, $result, true)); + self::assertFalse(in_array($inC, $result, true)); + } + + public function testGetSubscribersBySubscribedListIdsReturnsEmptyArrayForEmptyInput(): void + { + self::assertSame([], $this->subscriberRepository->getSubscribersBySubscribedListIds([])); + } } diff --git a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php index 683c215a8..fc7d58027 100644 --- a/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php +++ b/tests/Unit/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandlerTest.php @@ -10,15 +10,18 @@ use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Messaging\Message\CampaignProcessor\CampaignProcessorMessage; use PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor\CampaignProcessorMessageHandler; +use PhpList\Core\Domain\Messaging\Model\Dto\DomainThrottleResult; use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; 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\Model\Message\MessageStatus; +use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus; +use PhpList\Core\Domain\Messaging\Model\UserMessage; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Repository\UserMessageRepository; use PhpList\Core\Domain\Messaging\Service\Builder\EmailBuilder; use PhpList\Core\Domain\Messaging\Service\Builder\SystemEmailBuilder; +use PhpList\Core\Domain\Messaging\Service\DomainRateLimiter; use PhpList\Core\Domain\Messaging\Service\Handler\RequeueHandler; use PhpList\Core\Domain\Messaging\Service\MailSizeChecker; use PhpList\Core\Domain\Messaging\Service\MaxProcessTimeLimiter; @@ -52,6 +55,10 @@ class CampaignProcessorMessageHandlerTest extends TestCase private MessagePrecacheService|MockObject $precacheService; private CacheInterface|MockObject $cache; private MailerInterface|MockObject $symfonyMailer; + private UserMessageRepository|MockObject $userMessageRepository; + private MaxProcessTimeLimiter|MockObject $timeLimiter; + private RequeueHandler|MockObject $requeueHandler; + private DomainRateLimiter|MockObject $domainRateLimiter; protected function setUp(): void { @@ -72,7 +79,21 @@ protected function setUp(): void $timeLimiter->method('start'); $timeLimiter->method('shouldStop')->willReturn(false); - $this->handler = new CampaignProcessorMessageHandler( + $this->userMessageRepository = $userMessageRepository; + $this->timeLimiter = $timeLimiter; + $this->requeueHandler = $requeueHandler; + $this->domainRateLimiter = $this->createMock(DomainRateLimiter::class); + $this->domainRateLimiter->method('attemptSend') + ->willReturn(new DomainThrottleResult(allowed: true, domain: null)); + + $this->handler = $this->createHandler(); + } + + private function createHandler( + bool $useListExclude = false, + int $stuckCampaignThresholdSeconds = 0, + ): CampaignProcessorMessageHandler { + return new CampaignProcessorMessageHandler( mailer: $this->symfonyMailer, rateLimitedCampaignMailer: $this->mailer, entityManager: $this->entityManager, @@ -80,9 +101,9 @@ protected function setUp(): void messagePreparator: $this->messagePreparator, logger: $this->logger, cache: $this->cache, - userMessageRepository: $userMessageRepository, - timeLimiter: $timeLimiter, - requeueHandler: $requeueHandler, + userMessageRepository: $this->userMessageRepository, + timeLimiter: $this->timeLimiter, + requeueHandler: $this->requeueHandler, translator: $this->translator, subscriberHistoryManager: $this->createMock(SubscriberHistoryManager::class), messageRepository: $this->messageRepository, @@ -92,7 +113,10 @@ protected function setUp(): void campaignEmailBuilder: $this->createMock(EmailBuilder::class), mailSizeChecker: $this->createMock(MailSizeChecker::class), configProvider: $this->createMock(ConfigProvider::class), + domainRateLimiter: $this->domainRateLimiter, bounceEmail: 'bounce@email.com', + useListExclude: $useListExclude, + stuckCampaignThresholdSeconds: $stuckCampaignThresholdSeconds, ); } @@ -101,8 +125,8 @@ public function testInvokeWhenCampaignNotFound(): void $message = new CampaignProcessorMessage(999); $this->messageRepository->expects($this->once()) - ->method('findByIdAndStatus') - ->with(999, MessageStatus::Submitted) + ->method('tryClaimForProcessing') + ->with(999, 0) ->willReturn(null); $this->translator->method('trans')->willReturnCallback(fn(string $msg) => $msg); @@ -114,6 +138,22 @@ public function testInvokeWhenCampaignNotFound(): void ($this->handler)($message); } + public function testInvokePassesStuckCampaignThresholdToTryClaimForProcessing(): void + { + $handler = $this->createHandler(stuckCampaignThresholdSeconds: 1800); + + $message = new CampaignProcessorMessage(999); + + $this->messageRepository->expects($this->once()) + ->method('tryClaimForProcessing') + ->with(999, 1800) + ->willReturn(null); + + $this->translator->method('trans')->willReturnCallback(fn(string $msg) => $msg); + + $handler($message); + } + public function testInvokeWithNoSubscribers(): void { $campaign = $this->createCampaignMock(); @@ -122,8 +162,8 @@ public function testInvokeWithNoSubscribers(): void $campaign->method('getId')->willReturn(1); $data = new CampaignProcessorMessage(1); - $this->messageRepository->method('findByIdAndStatus') - ->with(1, MessageStatus::Submitted) + $this->messageRepository->method('tryClaimForProcessing') + ->with(1, 0) ->willReturn($campaign); $this->precacheService->expects($this->once()) @@ -148,6 +188,258 @@ public function testInvokeWithNoSubscribers(): void ($this->handler)($data); } + public function testInvokePassesExcludeListIdsFromMessageDataToSubscriberProviderWhenEnabled(): void + { + $handler = $this->createHandler(useListExclude: true); + + $campaign = $this->createCampaignMock(); + $metadata = $this->createMock(MessageMetadata::class); + $campaign->method('getMetadata')->willReturn($metadata); + $campaign->method('getId')->willReturn(1); + $data = new CampaignProcessorMessage(1); + + $this->messageRepository->method('tryClaimForProcessing') + ->with(1, 0) + ->willReturn($campaign); + + $messageDataLoaderProperty = (new ReflectionClass($handler))->getProperty('messageDataLoader'); + /** @var MessageDataLoader|MockObject $messageDataLoaderMock */ + $messageDataLoaderMock = $messageDataLoaderProperty->getValue($handler); + $messageDataLoaderMock->method('__invoke')->willReturn([ + 'excludelist' => [55 => 1, 66 => 1], + ]); + + $this->precacheService->expects($this->once()) + ->method('precacheMessage') + ->with($campaign, $this->anything()) + ->willReturn(true); + + $this->subscriberProvider->expects($this->once()) + ->method('getSubscribersForMessageOrLists') + ->with($data, $campaign, [55, 66]) + ->willReturn([]); + + $metadata->expects($this->atLeastOnce()) + ->method('setStatus'); + + $handler($data); + } + + public function testInvokeIgnoresExcludeListWhenUseListExcludeDisabled(): void + { + $handler = $this->createHandler(useListExclude: false); + + $campaign = $this->createCampaignMock(); + $metadata = $this->createMock(MessageMetadata::class); + $campaign->method('getMetadata')->willReturn($metadata); + $campaign->method('getId')->willReturn(1); + $data = new CampaignProcessorMessage(1); + + $this->messageRepository->method('tryClaimForProcessing') + ->with(1, 0) + ->willReturn($campaign); + + $messageDataLoaderProperty = (new ReflectionClass($handler))->getProperty('messageDataLoader'); + /** @var MessageDataLoader|MockObject $messageDataLoaderMock */ + $messageDataLoaderMock = $messageDataLoaderProperty->getValue($handler); + $messageDataLoaderMock->method('__invoke')->willReturn([ + 'excludelist' => [55 => 1, 66 => 1], + ]); + + $this->precacheService->expects($this->once()) + ->method('precacheMessage') + ->with($campaign, $this->anything()) + ->willReturn(true); + + $this->subscriberProvider->expects($this->once()) + ->method('getSubscribersForMessageOrLists') + ->with($data, $campaign, []) + ->willReturn([]); + + $metadata->expects($this->atLeastOnce()) + ->method('setStatus'); + + $handler($data); + } + + public function testInvokeMarksExcludedSubscribersAsExcludedInUserMessage(): void + { + $handler = $this->createHandler(useListExclude: true); + + $campaign = $this->createCampaignMock(); + $metadata = $this->createMock(MessageMetadata::class); + $campaign->method('getMetadata')->willReturn($metadata); + $campaign->method('getId')->willReturn(1); + $data = new CampaignProcessorMessage(1); + + $this->messageRepository->method('tryClaimForProcessing') + ->with(1, 0) + ->willReturn($campaign); + + $messageDataLoaderProperty = (new ReflectionClass($handler))->getProperty('messageDataLoader'); + /** @var MessageDataLoader|MockObject $messageDataLoaderMock */ + $messageDataLoaderMock = $messageDataLoaderProperty->getValue($handler); + $messageDataLoaderMock->method('__invoke')->willReturn([ + 'excludelist' => [55 => 1], + ]); + + $this->precacheService->expects($this->once()) + ->method('precacheMessage') + ->with($campaign, $this->anything()) + ->willReturn(true); + + $excludedSubscriber = $this->createMock(Subscriber::class); + $excludedSubscriber->method('getEmail')->willReturn('excluded@example.com'); + + $this->subscriberProvider->expects($this->once()) + ->method('getExcludedSubscribers') + ->with([55]) + ->willReturn([$excludedSubscriber]); + + $this->subscriberProvider->expects($this->once()) + ->method('getSendableSubscribersForMessageOrLists') + ->with($data, $campaign) + ->willReturn(['excluded@example.com' => $excludedSubscriber]); + + $this->subscriberProvider->expects($this->once()) + ->method('getSubscribersForMessageOrLists') + ->with($data, $campaign, [55]) + ->willReturn([]); + + $this->userMessageRepository->expects($this->once()) + ->method('findByUserAndMessage') + ->with($excludedSubscriber, $campaign) + ->willReturn(null); + + $this->userMessageRepository->expects($this->once()) + ->method('save') + ->with($this->callback( + fn (UserMessage $userMessage): bool => $userMessage->getUser() === $excludedSubscriber + && $userMessage->getStatus() === UserMessageStatus::Excluded + )); + + $metadata->expects($this->atLeastOnce()) + ->method('setStatus'); + + $handler($data); + } + + public function testInvokeDoesNotOverwriteExistingNonTodoUserMessageWhenMarkingExcluded(): void + { + $handler = $this->createHandler(useListExclude: true); + + $campaign = $this->createCampaignMock(); + $metadata = $this->createMock(MessageMetadata::class); + $campaign->method('getMetadata')->willReturn($metadata); + $campaign->method('getId')->willReturn(1); + $data = new CampaignProcessorMessage(1); + + $this->messageRepository->method('tryClaimForProcessing') + ->with(1, 0) + ->willReturn($campaign); + + $messageDataLoaderProperty = (new ReflectionClass($handler))->getProperty('messageDataLoader'); + /** @var MessageDataLoader|MockObject $messageDataLoaderMock */ + $messageDataLoaderMock = $messageDataLoaderProperty->getValue($handler); + $messageDataLoaderMock->method('__invoke')->willReturn([ + 'excludelist' => [55 => 1], + ]); + + $this->precacheService->expects($this->once()) + ->method('precacheMessage') + ->with($campaign, $this->anything()) + ->willReturn(true); + + $excludedSubscriber = $this->createMock(Subscriber::class); + $excludedSubscriber->method('getEmail')->willReturn('already-sent@example.com'); + + $this->subscriberProvider->expects($this->once()) + ->method('getExcludedSubscribers') + ->with([55]) + ->willReturn([$excludedSubscriber]); + + $this->subscriberProvider->expects($this->once()) + ->method('getSendableSubscribersForMessageOrLists') + ->with($data, $campaign) + ->willReturn(['already-sent@example.com' => $excludedSubscriber]); + + $this->subscriberProvider->expects($this->once()) + ->method('getSubscribersForMessageOrLists') + ->willReturn([]); + + $existingUserMessage = $this->createMock(UserMessage::class); + $existingUserMessage->method('getStatus')->willReturn(UserMessageStatus::Sent); + + $this->userMessageRepository->expects($this->once()) + ->method('findByUserAndMessage') + ->with($excludedSubscriber, $campaign) + ->willReturn($existingUserMessage); + + $this->userMessageRepository->expects($this->never()) + ->method('save'); + + $metadata->expects($this->atLeastOnce()) + ->method('setStatus'); + + $handler($data); + } + + public function testInvokeDoesNotMarkExcludedSubscriberWhoIsNotACampaignRecipient(): void + { + $handler = $this->createHandler(useListExclude: true); + + $campaign = $this->createCampaignMock(); + $metadata = $this->createMock(MessageMetadata::class); + $campaign->method('getMetadata')->willReturn($metadata); + $campaign->method('getId')->willReturn(1); + $data = new CampaignProcessorMessage(1); + + $this->messageRepository->method('tryClaimForProcessing') + ->with(1, 0) + ->willReturn($campaign); + + $messageDataLoaderProperty = (new ReflectionClass($handler))->getProperty('messageDataLoader'); + /** @var MessageDataLoader|MockObject $messageDataLoaderMock */ + $messageDataLoaderMock = $messageDataLoaderProperty->getValue($handler); + $messageDataLoaderMock->method('__invoke')->willReturn([ + 'excludelist' => [55 => 1], + ]); + + $this->precacheService->expects($this->once()) + ->method('precacheMessage') + ->with($campaign, $this->anything()) + ->willReturn(true); + + $nonRecipientExcludedSubscriber = $this->createMock(Subscriber::class); + $nonRecipientExcludedSubscriber->method('getEmail')->willReturn('not-a-recipient@example.com'); + + $this->subscriberProvider->expects($this->once()) + ->method('getExcludedSubscribers') + ->with([55]) + ->willReturn([$nonRecipientExcludedSubscriber]); + + $this->subscriberProvider->expects($this->once()) + ->method('getSendableSubscribersForMessageOrLists') + ->with($data, $campaign) + ->willReturn([]); + + $this->subscriberProvider->expects($this->once()) + ->method('getSubscribersForMessageOrLists') + ->with($data, $campaign, [55]) + ->willReturn([]); + + $this->userMessageRepository->expects($this->never()) + ->method('findByUserAndMessage'); + + $this->userMessageRepository->expects($this->never()) + ->method('save'); + + $metadata->expects($this->atLeastOnce()) + ->method('setStatus'); + + $handler($data); + } + public function testInvokeWithInvalidSubscriberEmail(): void { $campaign = $this->createCampaignMock(); @@ -156,8 +448,8 @@ public function testInvokeWithInvalidSubscriberEmail(): void $campaign->method('getId')->willReturn(1); $data = new CampaignProcessorMessage(1); - $this->messageRepository->method('findByIdAndStatus') - ->with(1, MessageStatus::Submitted) + $this->messageRepository->method('tryClaimForProcessing') + ->with(1, 0) ->willReturn($campaign); $this->precacheService->expects($this->once()) @@ -203,8 +495,8 @@ public function testInvokeWithValidSubscriberEmail(): void $campaign->method('getId')->willReturn(1); $data = new CampaignProcessorMessage(1); - $this->messageRepository->method('findByIdAndStatus') - ->with(1, MessageStatus::Submitted) + $this->messageRepository->method('tryClaimForProcessing') + ->with(1, 0) ->willReturn($campaign); $this->precacheService->expects($this->once()) @@ -271,8 +563,8 @@ public function testInvokeWithMailerException(): void $campaign->method('getId')->willReturn(123); $data = new CampaignProcessorMessage(123); - $this->messageRepository->method('findByIdAndStatus') - ->with(123, MessageStatus::Submitted) + $this->messageRepository->method('tryClaimForProcessing') + ->with(123, 0) ->willReturn($campaign); $this->precacheService->expects($this->once()) @@ -348,8 +640,8 @@ public function testInvokeWithMultipleSubscribers(): void $data = new CampaignProcessorMessage(1); $this->messageRepository - ->method('findByIdAndStatus') - ->with(1, MessageStatus::Submitted) + ->method('tryClaimForProcessing') + ->with(1, 0) ->willReturn($campaign); $this->precacheService @@ -464,6 +756,49 @@ function () use (&$buildCampaignEmailCalls): array { $this->assertCount(2, $buildCampaignEmailCalls); } + public function testInvokeSkipsDomainThrottledSubscriberWithoutCreatingUserMessage(): void + { + $campaign = $this->createCampaignMock(); + $metadata = $this->createMock(MessageMetadata::class); + $campaign->method('getMetadata')->willReturn($metadata); + $campaign->method('getId')->willReturn(1); + $data = new CampaignProcessorMessage(1); + + $this->messageRepository->method('tryClaimForProcessing') + ->with(1, 0) + ->willReturn($campaign); + + $this->precacheService->expects($this->once()) + ->method('precacheMessage') + ->with($campaign, $this->anything()) + ->willReturn(true); + + $throttledSubscriber = $this->createMock(Subscriber::class); + $throttledSubscriber->method('getEmail')->willReturn('throttled@example.com'); + + $this->subscriberProvider->expects($this->once()) + ->method('getSubscribersForMessageOrLists') + ->willReturn([$throttledSubscriber]); + + $this->domainRateLimiter = $this->createMock(DomainRateLimiter::class); + $this->domainRateLimiter->method('attemptSend') + ->willReturn(new DomainThrottleResult(allowed: false, domain: 'example.com', blockedAttempts: 1)); + $handler = $this->createHandler(); + + $this->userMessageRepository->expects($this->never()) + ->method('save'); + + $this->requeueHandler->expects($this->once()) + ->method('handle') + ->with($campaign) + ->willReturn(true); + + $metadata->expects($this->atLeastOnce()) + ->method('setStatus'); + + $handler($data); + } + /** * Creates a mock for the Message class with content */ diff --git a/tests/Unit/Domain/Messaging/Service/DomainRateLimiterTest.php b/tests/Unit/Domain/Messaging/Service/DomainRateLimiterTest.php new file mode 100644 index 000000000..e4b3d5879 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/DomainRateLimiterTest.php @@ -0,0 +1,158 @@ +repository = $this->createMock(DomainThrottleStateRepository::class); + $this->logger = $this->createMock(LoggerInterface::class); + } + + private function createLimiter( + bool $enabled = true, + int $domainBatchSize = 1, + int $domainBatchPeriod = 120, + bool $autoThrottle = false, + ): DomainRateLimiter { + return new DomainRateLimiter( + repository: $this->repository, + logger: $this->logger, + enabled: $enabled, + domainBatchSize: $domainBatchSize, + domainBatchPeriod: $domainBatchPeriod, + autoThrottle: $autoThrottle, + ); + } + + public function testAllowsSendsWhenDisabled(): void + { + $this->repository->expects($this->never())->method('tryReserveSlot'); + + $limiter = $this->createLimiter(enabled: false); + + $this->assertTrue($limiter->attemptSend('a@example.com')->allowed); + } + + public function testAllowsSendsWhenBatchSizeOrPeriodIsNotPositive(): void + { + $this->repository->expects($this->never())->method('tryReserveSlot'); + + $limiter = $this->createLimiter(domainBatchSize: 0); + $this->assertTrue($limiter->attemptSend('a@example.com')->allowed); + + $limiter = $this->createLimiter(domainBatchPeriod: 0); + $this->assertTrue($limiter->attemptSend('a@example.com')->allowed); + } + + public function testAllowsSendsWhenAddressHasNoAtSign(): void + { + $this->repository->expects($this->never())->method('tryReserveSlot'); + + $limiter = $this->createLimiter(); + + $this->assertTrue($limiter->attemptSend('not-an-email')->allowed); + } + + public function testDelegatesReservationToRepositoryUsingLowercasedDomain(): void + { + $this->repository->expects($this->once()) + ->method('tryReserveSlot') + ->with('example.com', $this->isType('int'), 1) + ->willReturn(new DomainThrottleReservation(allowed: true)); + + $limiter = $this->createLimiter(); + $result = $limiter->attemptSend('first@Example.COM'); + + $this->assertTrue($result->allowed); + $this->assertSame('example.com', $result->domain); + } + + public function testReturnsBlockedResultWithAttemptsWhenQuotaReached(): void + { + $this->repository->method('tryReserveSlot') + ->willReturn(new DomainThrottleReservation(allowed: false, blockedAttempts: 3)); + + $this->logger->expects($this->once()) + ->method('info') + ->with('Send blocked by domain throttle', $this->anything()); + + $limiter = $this->createLimiter(); + $result = $limiter->attemptSend('third@example.com'); + + $this->assertFalse($result->allowed); + $this->assertSame(3, $result->blockedAttempts); + $this->assertFalse($result->backoffApplied); + } + + public function testDoesNotBackoffWhenAutoThrottleDisabled(): void + { + $this->repository->method('tryReserveSlot') + ->willReturn(new DomainThrottleReservation(allowed: false, blockedAttempts: 999)); + $this->repository->expects($this->never())->method('resetBlockedCount'); + + $limiter = $this->createLimiter(autoThrottle: false); + $result = $limiter->attemptSend('third@example.com'); + + $this->assertFalse($result->backoffApplied); + } + + public function testDoesNotBackoffBelowAttemptThreshold(): void + { + $this->repository->method('tryReserveSlot') + ->willReturn(new DomainThrottleReservation(allowed: false, blockedAttempts: 5)); + $this->repository->expects($this->never())->method('resetBlockedCount'); + + $limiter = $this->createLimiter(autoThrottle: true); + $result = $limiter->attemptSend('third@example.com'); + + $this->assertFalse($result->backoffApplied); + } + + public function testAppliesBackoffAndResetsBlockedCountOnceThresholdExceeded(): void + { + $this->repository->method('tryReserveSlot') + ->willReturn(new DomainThrottleReservation(allowed: false, blockedAttempts: 26)); + $this->repository->expects($this->once()) + ->method('resetBlockedCount') + ->with('example.com', $this->isType('int'), 25) + ->willReturn(true); + + // Small batch period/size keeps the resulting sleep() short (~1s) so the test stays fast. + $limiter = $this->createLimiter(domainBatchSize: 1, domainBatchPeriod: 4, autoThrottle: true); + $result = $limiter->attemptSend('third@example.com'); + + $this->assertFalse($result->allowed); + $this->assertTrue($result->backoffApplied); + $this->assertGreaterThanOrEqual(1, $result->backoffSeconds); + } + + public function testDoesNotBackoffWhenLosingTheResetRaceToAnotherWorker(): void + { + $this->repository->method('tryReserveSlot') + ->willReturn(new DomainThrottleReservation(allowed: false, blockedAttempts: 26)); + $this->repository->expects($this->once()) + ->method('resetBlockedCount') + ->willReturn(false); + + $limiter = $this->createLimiter(domainBatchSize: 1, domainBatchPeriod: 4, autoThrottle: true); + $result = $limiter->attemptSend('third@example.com'); + + $this->assertFalse($result->allowed); + $this->assertFalse($result->backoffApplied); + $this->assertSame(0, $result->backoffSeconds); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php b/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php index 495f496ed..ddef51903 100644 --- a/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Handler/RequeueHandlerTest.php @@ -51,12 +51,40 @@ private function createMessage( return new Message($format, $schedule, $metadata, $content, $options, owner: null, template: null); } - public function testReturnsFalseWhenIntervalIsZeroOrNegative(): void + public function testFallsBackToOneMinuteIntervalWhenNoneConfigured(): void { + // requeueInterval controls how long to wait before resuming, not whether to resume: + // a campaign that stopped early must always be retried (mirrors phplist3's + // unconditional "don't mark sent while anything failed/was throttled" guard), so a + // missing/zero interval must not disable requeuing entirely. $handler = new RequeueHandler($this->logger, new Translator('en')); $message = $this->createMessage(0, null, null); - $this->output->expects($this->never())->method('writeln'); + $this->output->expects($this->once())->method('writeln'); + $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('PT1M')); + $maxExpected = (clone $after)->add(new DateInterval('PT1M')); + $this->assertGreaterThanOrEqual($minExpected->getTimestamp(), $embargo->getTimestamp()); + $this->assertLessThanOrEqual($maxExpected->getTimestamp(), $embargo->getTimestamp()); + } + + public function testStillReturnsFalseWhenNoIntervalConfiguredButRequeueUntilAlreadyPassed(): void + { + $handler = new RequeueHandler($this->logger, new Translator('en')); + $past = (new DateTime())->sub(new DateInterval('PT5M')); + $message = $this->createMessage(0, $past, null); + $this->logger->expects($this->never())->method('info'); $result = $handler->handle($message, $this->output); diff --git a/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php index 0021ae87b..6e7bcfe30 100644 --- a/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php +++ b/tests/Unit/Domain/Messaging/Service/Manager/MessageManagerTest.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Manager; use DateTime; +use DateTimeImmutable; use InvalidArgumentException; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Messaging\Model\ListMessage; @@ -241,4 +242,21 @@ public function testUpdateStatusSetsSubmittedWhenRequiredFieldsAndListArePresent $this->assertSame($message, $updated); $this->assertSame(Message\MessageStatus::Submitted, $message->getMetadata()->getStatus()); } + + public function testGetStuckCampaignsDelegatesToRepository(): void + { + $messageRepository = $this->createMock(MessageRepository::class); + $messageBuilder = $this->createMock(MessageBuilder::class); + $manager = new MessageManager($messageRepository, $messageBuilder); + + $staleBefore = new DateTimeImmutable('-30 minutes'); + $stuckMessage = $this->createMock(Message::class); + + $messageRepository->expects($this->once()) + ->method('getStuckInProcessing') + ->with($staleBefore) + ->willReturn([$stuckMessage]); + + $this->assertSame([$stuckMessage], $manager->getStuckCampaigns($staleBefore)); + } } diff --git a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php index a68576a1e..5ba8c29d1 100644 --- a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php +++ b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php @@ -39,7 +39,7 @@ public function testGetSubscribersForMessageWithNoListsReturnsEmptyArray(): void $this->subscriberRepository ->expects($this->never()) - ->method('getSubscribersBySubscribedListId'); + ->method('getSendableSubscribersBySubscribedListId'); $result = $this->subscriberProvider->getSubscribersForMessageOrLists( $this->createMock(CampaignProcessorMessageInterface::class), @@ -60,7 +60,7 @@ public function testGetSubscribersForMessageWithOneListButNoSubscribersReturnsEm $this->subscriberRepository ->expects($this->once()) - ->method('getSubscribersBySubscribedListId') + ->method('getSendableSubscribersBySubscribedListId') ->with(456) ->willReturn([]); @@ -87,7 +87,7 @@ public function testGetSubscribersForMessageWithOneListAndSubscribersReturnsSubs $this->subscriberRepository ->expects($this->once()) - ->method('getSubscribersBySubscribedListId') + ->method('getSendableSubscribersBySubscribedListId') ->with(456) ->willReturn([$subscriber1, $subscriber2]); @@ -118,7 +118,7 @@ public function testGetSubscribersForMessageWithMultipleListsReturnsUniqueSubscr $this->subscriberRepository ->expects($this->exactly(2)) - ->method('getSubscribersBySubscribedListId') + ->method('getSendableSubscribersBySubscribedListId') ->willReturnMap([ [456, [$subscriber1, $subscriber2]], [789, [$subscriber2, $subscriber3]], @@ -134,4 +134,39 @@ public function testGetSubscribersForMessageWithMultipleListsReturnsUniqueSubscr $this->assertContains($subscriber2, $result); $this->assertContains($subscriber3, $result); } + + public function testGetSubscribersForMessageExcludesSubscribersOnExcludeLists(): void + { + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn(123); + + $this->subscriberListRepository + ->method('getListIdsByMessage') + ->willReturn([456]); + + $subscriber1 = $this->createMock(Subscriber::class); + $subscriber1->method('getEmail')->willReturn('keep@example.am'); + $subscriber2 = $this->createMock(Subscriber::class); + $subscriber2->method('getEmail')->willReturn('exclude@example.am'); + + $this->subscriberRepository + ->method('getSendableSubscribersBySubscribedListId') + ->with(456) + ->willReturn([$subscriber1, $subscriber2]); + + $this->subscriberRepository + ->expects($this->once()) + ->method('getSubscribersBySubscribedListIds') + ->with([789]) + ->willReturn([$subscriber2]); + + $result = $this->subscriberProvider->getSubscribersForMessageOrLists( + $this->createMock(CampaignProcessorMessageInterface::class), + $message, + [789], + ); + + $this->assertCount(1, $result); + $this->assertSame($subscriber1, $result[0]); + } }