From 77f68e77786772539c1b62cf86e43394464f0166 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 28 May 2025 17:13:54 +0400 Subject: [PATCH 01/26] ISSUE-345: mailer packages --- composer.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 89a2fcde..c447bc9d 100644 --- a/composer.json +++ b/composer.json @@ -59,7 +59,12 @@ "masterminds/html5": "^2.9", "ext-dom": "*", "league/csv": "^9.23.0", - "doctrine/doctrine-migrations-bundle": "^3.4" + "doctrine/doctrine-migrations-bundle": "^3.4", + "symfony/mailer": "^6.4", + "symfony/google-mailer": "^6.4", + "symfony/amazon-mailer": "^6.4", + "symfony/mailchimp-mailer": "^6.4", + "symfony/sendgrid-mailer": "^6.4" }, "require-dev": { "phpunit/phpunit": "^9.5", From 6aa337b1093a44ff5fbb2e45429d6ba66695f98e Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 28 May 2025 17:19:58 +0400 Subject: [PATCH 02/26] ISSUE-345: add env --- .env.example | 27 ++++++++++++++++++++++++++ .gitignore | 2 ++ README.md | 35 +++++++++++++++++++++++++++++++++- bin/console | 17 +++++++++++++++-- composer.json | 3 ++- src/Core/Bootstrap.php | 43 +++++++++++++++++++++++++++++++++++++++++- 6 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..648459f6 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# This is an example .env file for phpList +# Copy this file to .env and customize it for your environment + +# Application environment (dev, test, prod) +APP_ENV=dev + +# Debug mode (1 for enabled, 0 for disabled) +APP_DEBUG=1 + +# Database configuration +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' + +# Mailer configuration +MAILER_DSN=smtp://user:pass@smtp.example.com:25 +#MAILER_DSN=gmail://username:password@default +#MAILER_DSN=ses://ACCESS_KEY:SECRET_KEY@default?region=eu-west-1 +#MAILER_DSN=sendgrid://KEY@default +#MAILER_DSN=mandrill://KEY@default + +# Secret key for security +PHPLIST_SECRET=bd21d72faef90cdb9c4e99e82f5edd089bfb0c5a diff --git a/.gitignore b/.gitignore index 25db886b..73f08282 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ .vagrant .phpdoc/ .phpunit.result.cache +/.env +/.env.local diff --git a/README.md b/README.md index 502e84c6..c5fa8798 100755 --- a/README.md +++ b/README.md @@ -63,7 +63,40 @@ 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 `config/parameters.yml` or in the `.env` file (see below). + +## Environment Variables + +phpList now supports loading environment variables from `.env` files. This allows you to configure your application without modifying any code or configuration files. + +### Usage + +1. Copy the `.env.example` file to `.env` in the project root: + ```bash + cp .env.example .env + ``` + +2. Edit the `.env` file and customize the variables according to your environment: + ``` + # Application environment (dev, test, prod) + APP_ENV=dev + + # Debug mode (1 for enabled, 0 for disabled) + APP_DEBUG=1 + + # Database configuration + DATABASE_URL=mysql://db_user:db_password@localhost:3306/db_name + + # Mailer configuration + MAILER_DSN=smtp://localhost:25 + + # Secret key for security + APP_SECRET=change_this_to_a_secret_value + ``` + +3. For local overrides, you can create a `.env.local` file which will be loaded after `.env`. + +Note: The `.env` and `.env.local` files are excluded from version control to prevent sensitive information from being committed. ### Development diff --git a/bin/console b/bin/console index ba36a420..7f06c77f 100755 --- a/bin/console +++ b/bin/console @@ -7,15 +7,28 @@ use PhpList\Core\Core\Bootstrap; use PhpList\Core\Core\Environment; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Input\ArgvInput; +use Symfony\Component\Dotenv\Dotenv; use Symfony\Component\ErrorHandler\ErrorHandler; set_time_limit(0); require __DIR__ . '/../vendor/autoload.php'; +// Load environment variables from .env file +$dotenv = new Dotenv(); +$rootDir = dirname(__DIR__); +if (file_exists($rootDir . '/.env')) { + $dotenv->load($rootDir . '/.env'); + // Load .env.local if it exists (for local overrides) + if (file_exists($rootDir . '/.env.local')) { + $dotenv->load($rootDir . '/.env.local'); + } +} + $input = new ArgvInput(); -$environment = $input->getParameterOption(['--env', '-e'], getenv('SYMFONY_ENV') ?: Environment::DEVELOPMENT); -$debug = getenv('SYMFONY_DEBUG') !== '0' && !$input->hasParameterOption(['--no-debug', '']) +$environment = $input->getParameterOption(['--env', '-e'], $_ENV['APP_ENV'] ?? getenv('SYMFONY_ENV') ?: Environment::DEVELOPMENT); +$debug = (isset($_ENV['APP_DEBUG']) ? $_ENV['APP_DEBUG'] !== '0' : getenv('SYMFONY_DEBUG') !== '0') + && !$input->hasParameterOption(['--no-debug', '']) && $environment !== Environment::PRODUCTION; if ($debug) { diff --git a/composer.json b/composer.json index c447bc9d..4efede16 100644 --- a/composer.json +++ b/composer.json @@ -64,7 +64,8 @@ "symfony/google-mailer": "^6.4", "symfony/amazon-mailer": "^6.4", "symfony/mailchimp-mailer": "^6.4", - "symfony/sendgrid-mailer": "^6.4" + "symfony/sendgrid-mailer": "^6.4", + "symfony/dotenv": "^6.4" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/src/Core/Bootstrap.php b/src/Core/Bootstrap.php index fe79a805..8aee505e 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; @@ -35,6 +36,7 @@ class Bootstrap private ?ApplicationKernel $applicationKernel = null; private ApplicationStructure $applicationStructure; private ErrorHandler $errorHandler; + private bool $envLoaded = false; /** * Protected constructor to avoid direct instantiation of this class. @@ -45,6 +47,33 @@ protected function __construct() { $this->applicationStructure = new ApplicationStructure(); $this->errorHandler = new ErrorHandler(); + $this->loadEnvironmentVariables(); + } + + /** + * Loads environment variables from .env files. + * + * @return void fluent interface + */ + private function loadEnvironmentVariables(): void + { + if ($this->envLoaded) { + return; + } + + $dotenv = new Dotenv(); + $rootDir = $this->getApplicationRoot(); + + if (file_exists($rootDir . '/.env')) { + $dotenv->load($rootDir . '/.env'); + + if (file_exists($rootDir . '/.env.local')) { + $dotenv->load($rootDir . '/.env.local'); + } + + $this->envLoaded = true; + } + } /** @@ -89,6 +118,10 @@ public static function purgeInstance(): void */ public function setEnvironment(string $environment): Bootstrap { + if ($environment === Environment::DEFAULT_ENVIRONMENT && isset($_ENV['APP_ENV'])) { + $environment = $_ENV['APP_ENV']; + } + Environment::validateEnvironment($environment); $this->environment = $environment; @@ -102,11 +135,19 @@ public function getEnvironment(): string private function isSymfonyDebugModeEnabled(): bool { + if (isset($_ENV['APP_DEBUG'])) { + return $_ENV['APP_DEBUG'] === '1' || $_ENV['APP_DEBUG'] === 'true'; + } + return $this->environment !== Environment::PRODUCTION; } private function isDebugEnabled(): bool { + if (isset($_ENV['APP_DEBUG'])) { + return $_ENV['APP_DEBUG'] === '1' || $_ENV['APP_DEBUG'] === 'true'; + } + return $this->environment !== Environment::PRODUCTION; } @@ -138,7 +179,7 @@ public function ensureDevelopmentOrTestingEnvironment(): self } /** - * Main entry point called at every request usually from global scope. Checks if everything is correct + * The main entry point called at every request usually from global scope. Checks if everything is correct * and loads the configuration. * * @return Bootstrap fluent interface From 8b81b6a38b7838aad03c3fec44fc89071838fa4e Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 29 May 2025 20:51:29 +0400 Subject: [PATCH 03/26] ISSUE-345: refactor --- .env.example | 27 ----- composer.json | 4 +- config/PHPMD/rules.xml | 2 +- config/config.yml | 2 + config/parameters.yml.dist | 5 + config/services/commands.yml | 10 ++ config/services/managers.yml | 6 + src/Core/Bootstrap.php | 41 ------- .../Command/SendTestEmailCommand.php | 72 ++++++++++++ src/Domain/Messaging/Service/EmailService.php | 103 ++++++++++++++++++ 10 files changed, 202 insertions(+), 70 deletions(-) delete mode 100644 .env.example create mode 100644 config/services/commands.yml create mode 100644 src/Domain/Messaging/Command/SendTestEmailCommand.php create mode 100644 src/Domain/Messaging/Service/EmailService.php diff --git a/.env.example b/.env.example deleted file mode 100644 index 648459f6..00000000 --- a/.env.example +++ /dev/null @@ -1,27 +0,0 @@ -# This is an example .env file for phpList -# Copy this file to .env and customize it for your environment - -# Application environment (dev, test, prod) -APP_ENV=dev - -# Debug mode (1 for enabled, 0 for disabled) -APP_DEBUG=1 - -# Database configuration -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' - -# Mailer configuration -MAILER_DSN=smtp://user:pass@smtp.example.com:25 -#MAILER_DSN=gmail://username:password@default -#MAILER_DSN=ses://ACCESS_KEY:SECRET_KEY@default?region=eu-west-1 -#MAILER_DSN=sendgrid://KEY@default -#MAILER_DSN=mandrill://KEY@default - -# Secret key for security -PHPLIST_SECRET=bd21d72faef90cdb9c4e99e82f5edd089bfb0c5a diff --git a/composer.json b/composer.json index 4efede16..936a1a69 100644 --- a/composer.json +++ b/composer.json @@ -65,7 +65,9 @@ "symfony/amazon-mailer": "^6.4", "symfony/mailchimp-mailer": "^6.4", "symfony/sendgrid-mailer": "^6.4", - "symfony/dotenv": "^6.4" + "symfony/dotenv": "^6.4", + "symfony/twig-bundle": "^6.4", + "symfony/messenger": "^6.4" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/config/PHPMD/rules.xml b/config/PHPMD/rules.xml index 8c88111a..2d88410b 100644 --- a/config/PHPMD/rules.xml +++ b/config/PHPMD/rules.xml @@ -51,7 +51,7 @@ - + diff --git a/config/config.yml b/config/config.yml index fd4705bb..93df3f5c 100644 --- a/config/config.yml +++ b/config/config.yml @@ -40,3 +40,5 @@ framework: serializer: enabled: true enable_attributes: true + mailer: + dsn: '%app.mailer_dsn%' diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index 00ce9a48..e2c5b62d 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -25,3 +25,8 @@ parameters: # A secret key that's used to generate certain security-related tokens secret: '%%env(PHPLIST_SECRET)%%' env(PHPLIST_SECRET): %1$s + + app.mailer_from: '%env(MAILER_FROM)%' + env(MAILER_FROM): 'noreply@phplist.com' + app.mailer_dsn: '%env(MAILER_DSN)%' + env(MAILER_DSN): 'smtp://username:password@smtp.mailtrap.io:2525' diff --git a/config/services/commands.yml b/config/services/commands.yml new file mode 100644 index 00000000..61dad7a5 --- /dev/null +++ b/config/services/commands.yml @@ -0,0 +1,10 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + PhpList\Core\Domain\Messaging\Command\: + resource: '../../src/Domain/Messaging/Command' + tags: ['console.command'] + diff --git a/config/services/managers.yml b/config/services/managers.yml index cdb1eb63..eaa37758 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -61,3 +61,9 @@ services: autowire: true autoconfigure: true public: true + + PhpList\Core\Domain\Messaging\Service\EmailService: + autowire: true + autoconfigure: true + arguments: + $defaultFromEmail: '%app.mailer_from%' diff --git a/src/Core/Bootstrap.php b/src/Core/Bootstrap.php index 8aee505e..c56b6e4a 100644 --- a/src/Core/Bootstrap.php +++ b/src/Core/Bootstrap.php @@ -7,7 +7,6 @@ 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; @@ -36,7 +35,6 @@ class Bootstrap private ?ApplicationKernel $applicationKernel = null; private ApplicationStructure $applicationStructure; private ErrorHandler $errorHandler; - private bool $envLoaded = false; /** * Protected constructor to avoid direct instantiation of this class. @@ -47,33 +45,6 @@ protected function __construct() { $this->applicationStructure = new ApplicationStructure(); $this->errorHandler = new ErrorHandler(); - $this->loadEnvironmentVariables(); - } - - /** - * Loads environment variables from .env files. - * - * @return void fluent interface - */ - private function loadEnvironmentVariables(): void - { - if ($this->envLoaded) { - return; - } - - $dotenv = new Dotenv(); - $rootDir = $this->getApplicationRoot(); - - if (file_exists($rootDir . '/.env')) { - $dotenv->load($rootDir . '/.env'); - - if (file_exists($rootDir . '/.env.local')) { - $dotenv->load($rootDir . '/.env.local'); - } - - $this->envLoaded = true; - } - } /** @@ -118,10 +89,6 @@ public static function purgeInstance(): void */ public function setEnvironment(string $environment): Bootstrap { - if ($environment === Environment::DEFAULT_ENVIRONMENT && isset($_ENV['APP_ENV'])) { - $environment = $_ENV['APP_ENV']; - } - Environment::validateEnvironment($environment); $this->environment = $environment; @@ -135,19 +102,11 @@ public function getEnvironment(): string private function isSymfonyDebugModeEnabled(): bool { - if (isset($_ENV['APP_DEBUG'])) { - return $_ENV['APP_DEBUG'] === '1' || $_ENV['APP_DEBUG'] === 'true'; - } - return $this->environment !== Environment::PRODUCTION; } private function isDebugEnabled(): bool { - if (isset($_ENV['APP_DEBUG'])) { - return $_ENV['APP_DEBUG'] === '1' || $_ENV['APP_DEBUG'] === 'true'; - } - return $this->environment !== Environment::PRODUCTION; } diff --git a/src/Domain/Messaging/Command/SendTestEmailCommand.php b/src/Domain/Messaging/Command/SendTestEmailCommand.php new file mode 100644 index 00000000..4e78ba12 --- /dev/null +++ b/src/Domain/Messaging/Command/SendTestEmailCommand.php @@ -0,0 +1,72 @@ +emailService = $emailService; + } + + protected function configure(): void + { + $this + ->setDescription(self::$defaultDescription) + ->addArgument('recipient', InputArgument::OPTIONAL, 'Recipient email address'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $recipient = $input->getArgument('recipient'); + if (!$recipient) { + $output->writeln('Recipient email address not provided'); + + return Command::FAILURE; + } + + if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) { + $output->writeln('Invalid email address: ' . $recipient); + + return Command::FAILURE; + } + + try { + $output->writeln('Sending test email to ' . $recipient); + + $email = (new Email()) + ->from(new Address('admin@example.com', 'Admin Team')) + ->to($recipient) + ->subject('Test Email from phpList') + ->text('This is a test email sent from phpList Email Service.') + ->html('

Test

This is a test email sent from phpList Email Service

'); + + $this->emailService->sendEmail($email); + $output->writeln('Test email sent successfully!'); + + return Command::SUCCESS; + } catch (Exception $e) { + $output->writeln('Failed to send test email: ' . $e->getMessage()); + + return Command::FAILURE; + } + } +} diff --git a/src/Domain/Messaging/Service/EmailService.php b/src/Domain/Messaging/Service/EmailService.php new file mode 100644 index 00000000..8a97eeb0 --- /dev/null +++ b/src/Domain/Messaging/Service/EmailService.php @@ -0,0 +1,103 @@ +mailer = $mailer; + $this->defaultFromEmail = $defaultFromEmail; + } + + /** + * Send a simple email + * + * @param Email $email + * @param array $cc + * @param array $bcc + * @param array $replyTo + * @param array $attachments + * @return void + * @throws TransportExceptionInterface + */ + public function sendEmail( + Email $email, + array $cc = [], + array $bcc = [], + array $replyTo = [], + array $attachments = [] + ): void { + if (count($email->getFrom()) === 0) { + $email->from($this->defaultFromEmail); + } + + foreach ($cc as $ccAddress) { + $email->addCc($ccAddress); + } + + foreach ($bcc as $bccAddress) { + $email->addBcc($bccAddress); + } + + foreach ($replyTo as $replyToAddress) { + $email->addReplyTo($replyToAddress); + } + + foreach ($attachments as $attachment) { + $email->attachFromPath($attachment); + } + + $this->mailer->send($email); + } + + /** + * Email multiple recipients + * + * @param array $toAddresses Array of recipient email addresses + * @param string $subject Email subject + * @param string $text Plain text content + * @param string $html HTML content (optional) + * @param string|null $from Sender email address (optional, uses default if not provided) + * @param string|null $fromName Sender name (optional) + * @param array $attachments Array of file paths to attach (optional) + * + * @return void + * @throws TransportExceptionInterface + */ + public function sendBulkEmail( + array $toAddresses, + string $subject, + string $text, + string $html = '', + ?string $from = null, + ?string $fromName = null, + array $attachments = [] + ): void { + $baseEmail = (new Email()) + ->subject($subject) + ->text($text) + ->html($html); + + if ($from) { + $baseEmail->from($fromName ? new Address($from, $fromName) : $from); + } + + foreach ($toAddresses as $recipient) { + $email = clone $baseEmail; + $email->to($recipient); + + $this->sendEmail($email, [], [], [], $attachments); + } + } +} From b85de93be20af39bcd36a73c00a475869e356365 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 29 May 2025 21:01:50 +0400 Subject: [PATCH 04/26] ISSUE-345: tests --- .gitignore | 2 - README.md | 87 +++------- bin/console | 17 +- composer.json | 1 - .../Command/SendTestEmailCommand.php | 1 - .../Command/SendTestEmailCommandTest.php | 106 ++++++++++++ .../Messaging/Service/EmailServiceTest.php | 157 ++++++++++++++++++ 7 files changed, 292 insertions(+), 79 deletions(-) create mode 100644 tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/EmailServiceTest.php diff --git a/.gitignore b/.gitignore index 73f08282..25db886b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,3 @@ .vagrant .phpdoc/ .phpunit.result.cache -/.env -/.env.local diff --git a/README.md b/README.md index c5fa8798..f21c5cff 100755 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ phpList is an open source newsletter manager. This project is a rewrite of the ## About this package -This is the core module of the successor to phpList 3. It will have the +This is the core module of the successor to phpList 3. It will have the following responsibilities: * provide access to the DB via Doctrine models and repositories (and raw SQL @@ -41,7 +41,7 @@ Since this package is only a service required to run a full installation of **ph ## Contributing to this package -Contributions to phpList repositories are highly welcomed! To get started please take a look at the [contribution guide](.github/CONTRIBUTING.md). It contains everything you would need to make your first contribution including how to run local style checks and run tests. +Contributions to phpList repositories are highly welcomed! To get started please take a look at the [contribution guide](.github/CONTRIBUTING.md). It contains everything you would need to make your first contribution including how to run local style checks and run tests. ### Code of Conduct @@ -63,40 +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` or in the `.env` file (see below). - -## Environment Variables - -phpList now supports loading environment variables from `.env` files. This allows you to configure your application without modifying any code or configuration files. - -### Usage - -1. Copy the `.env.example` file to `.env` in the project root: - ```bash - cp .env.example .env - ``` - -2. Edit the `.env` file and customize the variables according to your environment: - ``` - # Application environment (dev, test, prod) - APP_ENV=dev - - # Debug mode (1 for enabled, 0 for disabled) - APP_DEBUG=1 - - # Database configuration - DATABASE_URL=mysql://db_user:db_password@localhost:3306/db_name - - # Mailer configuration - MAILER_DSN=smtp://localhost:25 - - # Secret key for security - APP_SECRET=change_this_to_a_secret_value - ``` - -3. For local overrides, you can create a `.env.local` file which will be loaded after `.env`. - -Note: The `.env` and `.env.local` files are excluded from version control to prevent sensitive information from being committed. +Please first set the database credentials in `config/parameters.yml`. ### Development @@ -112,9 +79,9 @@ already in use, on the next free port after 8000). You can stop the server with CTRL + C. -#### Development and Documentation +#### 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). +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 generatings docs in [PHPDOC.md](PHPDOC.md) @@ -157,12 +124,12 @@ listed in the `extra` section of the module's `composer.json` like this: ```json "extra": { - "phplist/core": { - "bundles": [ - "Symfony\\Bundle\\FrameworkBundle\\FrameworkBundle", - "PhpList\\Core\\EmptyStartPageBundle\\PhpListEmptyStartPageBundle" - ] - } + "phplist/core": { + "bundles": [ + "Symfony\\Bundle\\FrameworkBundle\\FrameworkBundle", + "PhpList\\Core\\EmptyStartPageBundle\\PhpListEmptyStartPageBundle" + ] + } } ``` @@ -177,14 +144,14 @@ the `extra` section of the module's `composer.json` like this: ```json "extra": { - "phplist/core": { - "routes": { - "homepage": { - "resource": "@PhpListEmptyStartPageBundle/Controller/", - "type": "annotation" - } - } + "phplist/core": { + "routes": { + "homepage": { + "resource": "@PhpListEmptyStartPageBundle/Controller/", + "type": "annotation" + } } + } } ``` @@ -192,17 +159,17 @@ You can also provide system configuration for your module: ```json "extra": { - "phplist/core": { - "configuration": { - "framework": { - "templating": { - "engines": [ - "twig" - ] - } - } + "phplist/core": { + "configuration": { + "framework": { + "templating": { + "engines": [ + "twig" + ] } + } } + } } ``` diff --git a/bin/console b/bin/console index 7f06c77f..ba36a420 100755 --- a/bin/console +++ b/bin/console @@ -7,28 +7,15 @@ use PhpList\Core\Core\Bootstrap; use PhpList\Core\Core\Environment; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Input\ArgvInput; -use Symfony\Component\Dotenv\Dotenv; use Symfony\Component\ErrorHandler\ErrorHandler; set_time_limit(0); require __DIR__ . '/../vendor/autoload.php'; -// Load environment variables from .env file -$dotenv = new Dotenv(); -$rootDir = dirname(__DIR__); -if (file_exists($rootDir . '/.env')) { - $dotenv->load($rootDir . '/.env'); - // Load .env.local if it exists (for local overrides) - if (file_exists($rootDir . '/.env.local')) { - $dotenv->load($rootDir . '/.env.local'); - } -} - $input = new ArgvInput(); -$environment = $input->getParameterOption(['--env', '-e'], $_ENV['APP_ENV'] ?? getenv('SYMFONY_ENV') ?: Environment::DEVELOPMENT); -$debug = (isset($_ENV['APP_DEBUG']) ? $_ENV['APP_DEBUG'] !== '0' : getenv('SYMFONY_DEBUG') !== '0') - && !$input->hasParameterOption(['--no-debug', '']) +$environment = $input->getParameterOption(['--env', '-e'], getenv('SYMFONY_ENV') ?: Environment::DEVELOPMENT); +$debug = getenv('SYMFONY_DEBUG') !== '0' && !$input->hasParameterOption(['--no-debug', '']) && $environment !== Environment::PRODUCTION; if ($debug) { diff --git a/composer.json b/composer.json index 936a1a69..0fd65a52 100644 --- a/composer.json +++ b/composer.json @@ -65,7 +65,6 @@ "symfony/amazon-mailer": "^6.4", "symfony/mailchimp-mailer": "^6.4", "symfony/sendgrid-mailer": "^6.4", - "symfony/dotenv": "^6.4", "symfony/twig-bundle": "^6.4", "symfony/messenger": "^6.4" }, diff --git a/src/Domain/Messaging/Command/SendTestEmailCommand.php b/src/Domain/Messaging/Command/SendTestEmailCommand.php index 4e78ba12..9e9594da 100644 --- a/src/Domain/Messaging/Command/SendTestEmailCommand.php +++ b/src/Domain/Messaging/Command/SendTestEmailCommand.php @@ -5,7 +5,6 @@ namespace PhpList\Core\Domain\Messaging\Command; use Exception; -use PhpList\Core\Domain\Messaging\Model\Dto\EmailMessage; use PhpList\Core\Domain\Messaging\Service\EmailService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; diff --git a/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php b/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php new file mode 100644 index 00000000..a8863a31 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php @@ -0,0 +1,106 @@ +emailService = $this->createMock(EmailService::class); + $command = new SendTestEmailCommand($this->emailService); + + $application = new Application(); + $application->add($command); + + $this->commandTester = new CommandTester($command); + } + + public function testExecuteWithValidEmail(): void + { + $this->emailService->expects($this->once()) + ->method('sendEmail') + ->with($this->callback(function (Email $email) { + $this->assertEquals('Test Email from phpList', $email->getSubject()); + $this->assertStringContainsString('This is a test email', $email->getTextBody()); + $this->assertStringContainsString('

Test

', $email->getHtmlBody()); + + $toAddresses = $email->getTo(); + $this->assertCount(1, $toAddresses); + $this->assertEquals('test@example.com', $toAddresses[0]->getAddress()); + + $fromAddresses = $email->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals('admin@example.com', $fromAddresses[0]->getAddress()); + $this->assertEquals('Admin Team', $fromAddresses[0]->getName()); + + return true; + })); + + $this->commandTester->execute([ + 'recipient' => 'test@example.com', + ]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Test email sent successfully', $output); + + $this->assertEquals(0, $this->commandTester->getStatusCode()); + } + + public function testExecuteWithoutRecipient(): void + { + $this->emailService->expects($this->never()) + ->method('sendEmail'); + + $this->commandTester->execute([]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Recipient email address not provided', $output); + + $this->assertEquals(1, $this->commandTester->getStatusCode()); + } + + public function testExecuteWithInvalidEmail(): void + { + $this->emailService->expects($this->never()) + ->method('sendEmail'); + + $this->commandTester->execute([ + 'recipient' => 'invalid-email', + ]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Invalid email address', $output); + + $this->assertEquals(1, $this->commandTester->getStatusCode()); + } + + public function testExecuteWithEmailServiceException(): void + { + $this->emailService->expects($this->once()) + ->method('sendEmail') + ->willThrowException(new \Exception('Test exception')); + + $this->commandTester->execute([ + 'recipient' => 'test@example.com', + ]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Failed to send test email', $output); + $this->assertStringContainsString('Test exception', $output); + + $this->assertEquals(1, $this->commandTester->getStatusCode()); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php b/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php new file mode 100644 index 00000000..c7f5fcd8 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php @@ -0,0 +1,157 @@ +mailer = $this->createMock(MailerInterface::class); + $this->emailService = new EmailService($this->mailer, $this->defaultFromEmail); + } + + public function testSendEmailWithDefaultFrom(): void + { + $email = (new Email()) + ->to('recipient@example.com') + ->subject('Test Subject') + ->text('Test Content'); + + $this->mailer->expects($this->once()) + ->method('send') + ->with($this->callback(function (Email $sentEmail) { + $fromAddresses = $sentEmail->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals($this->defaultFromEmail, $fromAddresses[0]->getAddress()); + return true; + })); + + $this->emailService->sendEmail($email); + } + + public function testSendEmailWithCustomFrom(): void + { + $customFrom = 'custom@example.com'; + $email = (new Email()) + ->from($customFrom) + ->to('recipient@example.com') + ->subject('Test Subject') + ->text('Test Content'); + + $this->mailer->expects($this->once()) + ->method('send') + ->with($this->callback(function (Email $sentEmail) use ($customFrom) { + $fromAddresses = $sentEmail->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals($customFrom, $fromAddresses[0]->getAddress()); + return true; + })); + + $this->emailService->sendEmail($email); + } + + public function testSendEmailWithCcBccAndReplyTo(): void + { + $email = (new Email()) + ->to('recipient@example.com') + ->subject('Test Subject') + ->text('Test Content'); + + $cc = ['cc@example.com']; + $bcc = ['bcc@example.com']; + $replyTo = ['reply@example.com']; + + $this->mailer->expects($this->once()) + ->method('send') + ->with($this->callback(function (Email $sentEmail) use ($cc, $bcc, $replyTo) { + $ccAddresses = $sentEmail->getCc(); + $bccAddresses = $sentEmail->getBcc(); + $replyToAddresses = $sentEmail->getReplyTo(); + + $this->assertCount(1, $ccAddresses); + $this->assertEquals($cc[0], $ccAddresses[0]->getAddress()); + + $this->assertCount(1, $bccAddresses); + $this->assertEquals($bcc[0], $bccAddresses[0]->getAddress()); + + $this->assertCount(1, $replyToAddresses); + $this->assertEquals($replyTo[0], $replyToAddresses[0]->getAddress()); + + return true; + })); + + $this->emailService->sendEmail($email, $cc, $bcc, $replyTo); + } + + public function testSendEmailWithAttachments(): void + { + $email = (new Email()) + ->to('recipient@example.com') + ->subject('Test Subject') + ->text('Test Content'); + + $attachments = ['/path/to/attachment.pdf']; + + $this->mailer->expects($this->once()) + ->method('send'); + + $this->emailService->sendEmail($email, [], [], [], $attachments); + } + + public function testSendBulkEmail(): void + { + $recipients = ['user1@example.com', 'user2@example.com', 'user3@example.com']; + $subject = 'Bulk Test Subject'; + $text = 'Bulk Test Content'; + $html = '

Bulk Test HTML Content

'; + $from = 'sender@example.com'; + $fromName = 'Sender Name'; + + $this->mailer->expects($this->exactly(count($recipients))) + ->method('send') + ->with($this->callback(function (Email $sentEmail) use ($subject, $text, $html, $from, $fromName) { + $this->assertEquals($subject, $sentEmail->getSubject()); + $this->assertEquals($text, $sentEmail->getTextBody()); + $this->assertEquals($html, $sentEmail->getHtmlBody()); + + $fromAddresses = $sentEmail->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals($from, $fromAddresses[0]->getAddress()); + $this->assertEquals($fromName, $fromAddresses[0]->getName()); + + return true; + })); + + $this->emailService->sendBulkEmail($recipients, $subject, $text, $html, $from, $fromName); + } + + public function testSendBulkEmailWithDefaultFrom(): void + { + $recipients = ['user1@example.com', 'user2@example.com']; + $subject = 'Bulk Test Subject'; + $text = 'Bulk Test Content'; + + $this->mailer->expects($this->exactly(count($recipients))) + ->method('send') + ->with($this->callback(function (Email $sentEmail) { + $fromAddresses = $sentEmail->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals($this->defaultFromEmail, $fromAddresses[0]->getAddress()); + return true; + })); + + $this->emailService->sendBulkEmail($recipients, $subject, $text); + } +} From c7bceefcda3231ccab2a0d9734bc5dbf9db08bc5 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 29 May 2025 21:27:36 +0400 Subject: [PATCH 05/26] ISSUE-345: fix params --- config/parameters.yml.dist | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index e2c5b62d..988628dc 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -22,11 +22,12 @@ parameters: database_password: '%%env(PHPLIST_DATABASE_PASSWORD)%%' env(PHPLIST_DATABASE_PASSWORD): 'phplist' + # mailer configs + app.mailer_from: '%%env(MAILER_FROM)%%' + env(MAILER_FROM): 'noreply@phplist.com' + app.mailer_dsn: '%%env(MAILER_DSN)%%' + env(MAILER_DSN): 'smtp://username:password@smtp.mailtrap.io:2525' + # A secret key that's used to generate certain security-related tokens secret: '%%env(PHPLIST_SECRET)%%' env(PHPLIST_SECRET): %1$s - - app.mailer_from: '%env(MAILER_FROM)%' - env(MAILER_FROM): 'noreply@phplist.com' - app.mailer_dsn: '%env(MAILER_DSN)%' - env(MAILER_DSN): 'smtp://username:password@smtp.mailtrap.io:2525' From 808f55b123526b33de35bab923cd61c313267068 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 29 May 2025 21:43:18 +0400 Subject: [PATCH 06/26] ISSUE-345: doc + params --- README.md | 13 +++ .../app.yml => services/parameters.yml} | 4 +- docs/ClassStructure.md | 2 +- docs/MailerTransports.md | 105 ++++++++++++++++++ 4 files changed, 121 insertions(+), 3 deletions(-) rename config/{packages/app.yml => services/parameters.yml} (92%) create mode 100644 docs/MailerTransports.md diff --git a/README.md b/README.md index f21c5cff..0b0e41b4 100755 --- a/README.md +++ b/README.md @@ -53,9 +53,11 @@ this code. ## Structure * [Class Docs][docs/phpdoc/] +* [Mailer Transports](docs/mailer-transports.md) - How to use different email providers (Gmail, Amazon SES, Mailchimp, SendGrid) * [Class structure overview](docs/ClassStructure.md) * [Graphic domain model](docs/DomainModel/DomainModel.svg) and a [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) ## Running the web server @@ -204,6 +206,17 @@ 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-2021 [phpList Ltd](https://www.phplist.com/). diff --git a/config/packages/app.yml b/config/services/parameters.yml similarity index 92% rename from config/packages/app.yml rename to config/services/parameters.yml index 61cf7460..6c944ede 100644 --- a/config/packages/app.yml +++ b/config/services/parameters.yml @@ -1,5 +1,5 @@ -app: - config: +parameters: + app.config: message_from_address: 'news@example.com' admin_address: 'admin@example.com' default_message_age: 15768000 diff --git a/docs/ClassStructure.md b/docs/ClassStructure.md index ff4da328..e7cf6801 100644 --- a/docs/ClassStructure.md +++ b/docs/ClassStructure.md @@ -21,7 +21,7 @@ database access code in these classes. ### Repository/ -These classes are reponsible for reading domain models from the database, +These classes are responsible for reading domain models from the database, for writing them there, and for other database queries. diff --git a/docs/MailerTransports.md b/docs/MailerTransports.md new file mode 100644 index 00000000..cde763da --- /dev/null +++ b/docs/MailerTransports.md @@ -0,0 +1,105 @@ +# Using Different Mailer Transports in phpList + +This document explains how to use the various mailer transports that are included in the phpList core dependencies: + +- Google Mailer (Gmail) +- Amazon SES +- Mailchimp Transactional (Mandrill) +- SendGrid + +## Configuration + +The phpList core uses Symfony Mailer for sending emails. The mailer transport is configured using the `MAILER_DSN` environment variable, which is defined in `config/parameters.yml`. + +## Available Transports + +### 1. Google Mailer (Gmail) + +To use Gmail as your email transport: + +``` +# Using Gmail with OAuth (recommended) +MAILER_DSN=gmail://USERNAME:APP_PASSWORD@default + +# Using Gmail with SMTP +MAILER_DSN=smtp://USERNAME:PASSWORD@smtp.gmail.com:587 +``` + +Notes: +- Replace `USERNAME` with your Gmail address +- For OAuth setup, follow [Symfony Gmail documentation](https://symfony.com/doc/current/mailer.html#using-gmail-to-send-emails) +- For the SMTP method, you may need to enable "Less secure app access" or use an App Password + +### 2. Amazon SES + +To use Amazon SES: + +``` +# Using API credentials +MAILER_DSN=ses://ACCESS_KEY:SECRET_KEY@default?region=REGION + +# Using SMTP interface +MAILER_DSN=smtp://USERNAME:PASSWORD@email-smtp.REGION.amazonaws.com:587 +``` + +Notes: +- Replace `ACCESS_KEY` and `SECRET_KEY` with your AWS credentials +- Replace `REGION` with your AWS region (e.g., us-east-1) +- For SMTP, use the credentials generated in the Amazon SES console + +### 3. Mailchimp Transactional (Mandrill) + +To use Mailchimp Transactional (formerly Mandrill): + +``` +MAILER_DSN=mandrill://API_KEY@default +``` + +Notes: +- Replace `API_KEY` with your Mailchimp Transactional API key +- You can find your API key in the Mailchimp Transactional (Mandrill) dashboard + +### 4. SendGrid + +To use SendGrid: + +``` +# Using API +MAILER_DSN=sendgrid://API_KEY@default + +# Using SMTP +MAILER_DSN=smtp://apikey:API_KEY@smtp.sendgrid.net:587 +``` + +Notes: +- Replace `API_KEY` with your SendGrid API key +- For SMTP, the username is literally "apikey" and the password is your actual API key + +## Testing Your Configuration + +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 +``` + +## Switching Between Transports + +You can easily switch between different mailer transports by changing the `MAILER_DSN` environment variable. This can be done in several ways: + +1. Edit the `config/parameters.yml` file directly +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 + ``` + +## Additional Configuration + +Some transports may require additional configuration options. Refer to the Symfony documentation for more details: + +- [Symfony Mailer Documentation](https://symfony.com/doc/current/mailer.html) +- [Gmail Transport](https://symfony.com/doc/current/mailer.html#using-gmail-to-send-emails) +- [Amazon SES Transport](https://symfony.com/doc/current/mailer.html#using-amazon-ses) +- [Mailchimp Transport](https://symfony.com/doc/current/mailer.html#using-mailchimp) +- [SendGrid Transport](https://symfony.com/doc/current/mailer.html#using-sendgrid) From 77e2e77de02cad060b1b50df359ef7d4b14ed082 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 29 May 2025 22:11:50 +0400 Subject: [PATCH 07/26] ISSUE-345: async emails --- config/config.yml | 1 + config/packages/messenger.yaml | 26 +++ config/services.yml | 5 + docs/AsyncEmailSending.md | 102 +++++++++++ .../Command/SendTestEmailCommand.php | 24 ++- .../Messaging/Message/AsyncEmailMessage.php | 58 ++++++ .../AsyncEmailMessageHandler.php | 37 ++++ src/Domain/Messaging/Service/EmailService.php | 85 ++++++++- .../Command/SendTestEmailCommandTest.php | 110 +++++++++++- .../Messaging/Service/EmailServiceTest.php | 166 +++++++++++++++++- 10 files changed, 583 insertions(+), 31 deletions(-) create mode 100644 config/packages/messenger.yaml create mode 100644 docs/AsyncEmailSending.md create mode 100644 src/Domain/Messaging/Message/AsyncEmailMessage.php create mode 100644 src/Domain/Messaging/MessageHandler/AsyncEmailMessageHandler.php diff --git a/config/config.yml b/config/config.yml index 93df3f5c..6875f2c9 100644 --- a/config/config.yml +++ b/config/config.yml @@ -2,6 +2,7 @@ imports: - { resource: services.yml } - { resource: doctrine.yml } - { resource: doctrine_migrations.yml } + - { resource: packages/*.yml } # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml new file mode 100644 index 00000000..3ae2402d --- /dev/null +++ b/config/packages/messenger.yaml @@ -0,0 +1,26 @@ +# 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 + + transports: + # https://symfony.com/doc/current/messenger.html#transport-configuration + async_email: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + options: + auto_setup: true + use_notify: true + check_delayed_interval: 60000 + retry_strategy: + max_retries: 3 + # milliseconds delay + delay: 1000 + multiplier: 2 + max_delay: 0 + + # failed: 'doctrine://default?queue_name=failed' + + routing: + # Route your messages to the transports + 'PhpList\Core\Domain\Messaging\Message\AsyncEmailMessage': async_email diff --git a/config/services.yml b/config/services.yml index b83adce3..e3329d6c 100644 --- a/config/services.yml +++ b/config/services.yml @@ -37,6 +37,11 @@ services: public: true tags: [controller.service_arguments] + # Register message handlers for Symfony Messenger + PhpList\Core\Domain\Messaging\MessageHandler\: + resource: '../src/Domain/Messaging/MessageHandler' + tags: ['messenger.message_handler'] + doctrine.orm.metadata.annotation_reader: alias: doctrine.annotation_reader diff --git a/docs/AsyncEmailSending.md b/docs/AsyncEmailSending.md new file mode 100644 index 00000000..da4f247c --- /dev/null +++ b/docs/AsyncEmailSending.md @@ -0,0 +1,102 @@ +# Asynchronous Email Sending in phpList + +This document explains how to use the asynchronous email sending functionality in phpList. + +## Overview + +phpList now supports sending emails asynchronously using Symfony Messenger. This means that when you send an email, it is queued for delivery rather than being sent immediately. This has several benefits: + +1. **Improved Performance**: Your application doesn't have to wait for the email to be sent before continuing execution +2. **Better Reliability**: If the email server is temporarily unavailable, the message remains in the queue and will be retried automatically +3. **Scalability**: You can process the email queue separately from your main application, allowing for better resource management + +## Configuration + +The asynchronous email functionality is configured in `config/packages/messenger.yaml` and uses the `MESSENGER_TRANSPORT_DSN` environment variable defined in `config/parameters.yml`. + +By default, the system uses Doctrine (database) as the transport for queued messages: + +```yaml +env(MESSENGER_TRANSPORT_DSN): 'doctrine://default?auto_setup=true' +``` + +You can change this to use other transports supported by Symfony Messenger, such as: + +- **AMQP (RabbitMQ)**: `amqp://guest:guest@localhost:5672/%2f/messages` +- **Redis**: `redis://localhost:6379/messages` +- **In-Memory (for testing)**: `in-memory://` + +## Using Asynchronous Email Sending + +### Basic Usage + +The `EmailService` class now sends emails asynchronously by default: + +```php +// This will queue the email for sending +$emailService->sendEmail($email); +``` + +### Synchronous Sending + +If you need to send an email immediately (synchronously), you can use the `sendEmailSync` method: + +```php +// This will send the email immediately +$emailService->sendEmailSync($email); +``` + +### Bulk Emails + +For sending to multiple recipients: + +```php +// Asynchronous (queued) +$emailService->sendBulkEmail($recipients, $subject, $text, $html); + +// Synchronous (immediate) +$emailService->sendBulkEmailSync($recipients, $subject, $text, $html); +``` + +## Testing Email Sending + +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 + +# Send an email synchronously (immediately) +bin/console app:send-test-email recipient@example.com --sync +``` + +## Processing the Email Queue + +To process queued emails, you need to run the Symfony Messenger worker: + +```bash +bin/console messenger:consume async_email +``` + +For production environments, it's recommended to run this command as a background service or using a process manager like Supervisor. + +## Monitoring + +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 + +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` +3. Verify your mailer configuration in `config/parameters.yml` +4. Try sending an email synchronously to test the mailer configuration diff --git a/src/Domain/Messaging/Command/SendTestEmailCommand.php b/src/Domain/Messaging/Command/SendTestEmailCommand.php index 9e9594da..ce0cbc3c 100644 --- a/src/Domain/Messaging/Command/SendTestEmailCommand.php +++ b/src/Domain/Messaging/Command/SendTestEmailCommand.php @@ -15,7 +15,7 @@ class SendTestEmailCommand extends Command { - protected static $defaultName = 'app:send-test-email'; + protected static $defaultName = 'phplist:test-email'; protected static $defaultDescription = 'Send a test email to verify email configuration'; private EmailService $emailService; @@ -30,7 +30,8 @@ protected function configure(): void { $this ->setDescription(self::$defaultDescription) - ->addArgument('recipient', InputArgument::OPTIONAL, 'Recipient email address'); + ->addArgument('recipient', InputArgument::OPTIONAL, 'Recipient email address') + ->addOption('sync', null, InputArgument::OPTIONAL, 'Send email synchronously instead of queuing it', false); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -49,7 +50,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int } try { - $output->writeln('Sending test email to ' . $recipient); + $syncMode = $input->getOption('sync'); + + if ($syncMode) { + $output->writeln('Sending test email synchronously to ' . $recipient); + } else { + $output->writeln('Queuing test email for ' . $recipient); + } $email = (new Email()) ->from(new Address('admin@example.com', 'Admin Team')) @@ -57,9 +64,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int ->subject('Test Email from phpList') ->text('This is a test email sent from phpList Email Service.') ->html('

Test

This is a test email sent from phpList Email Service

'); - - $this->emailService->sendEmail($email); - $output->writeln('Test email sent successfully!'); + + if ($syncMode) { + $this->emailService->sendEmailSync($email); + $output->writeln('Test email sent successfully!'); + } else { + $this->emailService->sendEmail($email); + $output->writeln('Test email queued successfully! It will be sent asynchronously.'); + } return Command::SUCCESS; } catch (Exception $e) { diff --git a/src/Domain/Messaging/Message/AsyncEmailMessage.php b/src/Domain/Messaging/Message/AsyncEmailMessage.php new file mode 100644 index 00000000..0ea834ba --- /dev/null +++ b/src/Domain/Messaging/Message/AsyncEmailMessage.php @@ -0,0 +1,58 @@ +email = $email; + $this->cc = $cc; + $this->bcc = $bcc; + $this->replyTo = $replyTo; + $this->attachments = $attachments; + } + + public function getEmail(): Email + { + return $this->email; + } + + public function getCc(): array + { + return $this->cc; + } + + public function getBcc(): array + { + return $this->bcc; + } + + public function getReplyTo(): array + { + return $this->replyTo; + } + + public function getAttachments(): array + { + return $this->attachments; + } +} diff --git a/src/Domain/Messaging/MessageHandler/AsyncEmailMessageHandler.php b/src/Domain/Messaging/MessageHandler/AsyncEmailMessageHandler.php new file mode 100644 index 00000000..42d91417 --- /dev/null +++ b/src/Domain/Messaging/MessageHandler/AsyncEmailMessageHandler.php @@ -0,0 +1,37 @@ +emailService = $emailService; + } + + /** + * Process an asynchronous email message by sending the email + */ + public function __invoke(AsyncEmailMessage $message): void + { + $this->emailService->sendEmailSync( + $message->getEmail(), + $message->getCc(), + $message->getBcc(), + $message->getReplyTo(), + $message->getAttachments() + ); + } +} diff --git a/src/Domain/Messaging/Service/EmailService.php b/src/Domain/Messaging/Service/EmailService.php index 8a97eeb0..c3c3fc30 100644 --- a/src/Domain/Messaging/Service/EmailService.php +++ b/src/Domain/Messaging/Service/EmailService.php @@ -4,8 +4,10 @@ namespace PhpList\Core\Domain\Messaging\Service; +use PhpList\Core\Domain\Messaging\Message\AsyncEmailMessage; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Address; @@ -13,15 +15,20 @@ class EmailService { private MailerInterface $mailer; private string $defaultFromEmail; + private MessageBusInterface $messageBus; - public function __construct(MailerInterface $mailer, string $defaultFromEmail) - { + public function __construct( + MailerInterface $mailer, + string $defaultFromEmail, + MessageBusInterface $messageBus + ) { $this->mailer = $mailer; $this->defaultFromEmail = $defaultFromEmail; + $this->messageBus = $messageBus; } /** - * Send a simple email + * Send a simple email asynchronously * * @param Email $email * @param array $cc @@ -29,7 +36,6 @@ public function __construct(MailerInterface $mailer, string $defaultFromEmail) * @param array $replyTo * @param array $attachments * @return void - * @throws TransportExceptionInterface */ public function sendEmail( Email $email, @@ -41,7 +47,33 @@ public function sendEmail( if (count($email->getFrom()) === 0) { $email->from($this->defaultFromEmail); } - + + $message = new AsyncEmailMessage($email, $cc, $bcc, $replyTo, $attachments); + $this->messageBus->dispatch($message); + } + + /** + * Send a simple email synchronously + * + * @param Email $email + * @param array $cc + * @param array $bcc + * @param array $replyTo + * @param array $attachments + * @return void + * @throws TransportExceptionInterface + */ + public function sendEmailSync( + Email $email, + array $cc = [], + array $bcc = [], + array $replyTo = [], + array $attachments = [] + ): void { + if (count($email->getFrom()) === 0) { + $email->from($this->defaultFromEmail); + } + foreach ($cc as $ccAddress) { $email->addCc($ccAddress); } @@ -62,7 +94,7 @@ public function sendEmail( } /** - * Email multiple recipients + * Email multiple recipients asynchronously * * @param array $toAddresses Array of recipient email addresses * @param string $subject Email subject @@ -73,7 +105,6 @@ public function sendEmail( * @param array $attachments Array of file paths to attach (optional) * * @return void - * @throws TransportExceptionInterface */ public function sendBulkEmail( array $toAddresses, @@ -100,4 +131,44 @@ public function sendBulkEmail( $this->sendEmail($email, [], [], [], $attachments); } } + + /** + * Email multiple recipients synchronously + * + * @param array $toAddresses Array of recipient email addresses + * @param string $subject Email subject + * @param string $text Plain text content + * @param string $html HTML content (optional) + * @param string|null $from Sender email address (optional, uses default if not provided) + * @param string|null $fromName Sender name (optional) + * @param array $attachments Array of file paths to attach (optional) + * + * @return void + * @throws TransportExceptionInterface + */ + public function sendBulkEmailSync( + array $toAddresses, + string $subject, + string $text, + string $html = '', + ?string $from = null, + ?string $fromName = null, + array $attachments = [] + ): void { + $baseEmail = (new Email()) + ->subject($subject) + ->text($text) + ->html($html); + + if ($from) { + $baseEmail->from($fromName ? new Address($from, $fromName) : $from); + } + + foreach ($toAddresses as $recipient) { + $email = clone $baseEmail; + $email->to($recipient); + + $this->sendEmailSync($email, [], [], [], $attachments); + } + } } diff --git a/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php b/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php index a8863a31..c1b4a92c 100644 --- a/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php +++ b/tests/Unit/Domain/Messaging/Command/SendTestEmailCommandTest.php @@ -21,10 +21,10 @@ protected function setUp(): void { $this->emailService = $this->createMock(EmailService::class); $command = new SendTestEmailCommand($this->emailService); - + $application = new Application(); $application->add($command); - + $this->commandTester = new CommandTester($command); } @@ -36,16 +36,16 @@ public function testExecuteWithValidEmail(): void $this->assertEquals('Test Email from phpList', $email->getSubject()); $this->assertStringContainsString('This is a test email', $email->getTextBody()); $this->assertStringContainsString('

Test

', $email->getHtmlBody()); - + $toAddresses = $email->getTo(); $this->assertCount(1, $toAddresses); $this->assertEquals('test@example.com', $toAddresses[0]->getAddress()); - + $fromAddresses = $email->getFrom(); $this->assertCount(1, $fromAddresses); $this->assertEquals('admin@example.com', $fromAddresses[0]->getAddress()); $this->assertEquals('Admin Team', $fromAddresses[0]->getName()); - + return true; })); @@ -54,8 +54,43 @@ public function testExecuteWithValidEmail(): void ]); $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Queuing test email for', $output); + $this->assertStringContainsString('Test email queued successfully', $output); + $this->assertStringContainsString('It will be sent asynchronously', $output); + + $this->assertEquals(0, $this->commandTester->getStatusCode()); + } + + public function testExecuteWithValidEmailSync(): void + { + $this->emailService->expects($this->once()) + ->method('sendEmailSync') + ->with($this->callback(function (Email $email) { + $this->assertEquals('Test Email from phpList', $email->getSubject()); + $this->assertStringContainsString('This is a test email', $email->getTextBody()); + $this->assertStringContainsString('

Test

', $email->getHtmlBody()); + + $toAddresses = $email->getTo(); + $this->assertCount(1, $toAddresses); + $this->assertEquals('test@example.com', $toAddresses[0]->getAddress()); + + $fromAddresses = $email->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals('admin@example.com', $fromAddresses[0]->getAddress()); + $this->assertEquals('Admin Team', $fromAddresses[0]->getName()); + + return true; + })); + + $this->commandTester->execute([ + 'recipient' => 'test@example.com', + '--sync' => true, + ]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Sending test email synchronously to', $output); $this->assertStringContainsString('Test email sent successfully', $output); - + $this->assertEquals(0, $this->commandTester->getStatusCode()); } @@ -63,12 +98,31 @@ public function testExecuteWithoutRecipient(): void { $this->emailService->expects($this->never()) ->method('sendEmail'); + $this->emailService->expects($this->never()) + ->method('sendEmailSync'); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('Recipient email address not provided', $output); - + + $this->assertEquals(1, $this->commandTester->getStatusCode()); + } + + public function testExecuteWithoutRecipientSync(): void + { + $this->emailService->expects($this->never()) + ->method('sendEmail'); + $this->emailService->expects($this->never()) + ->method('sendEmailSync'); + + $this->commandTester->execute([ + '--sync' => true, + ]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Recipient email address not provided', $output); + $this->assertEquals(1, $this->commandTester->getStatusCode()); } @@ -76,14 +130,34 @@ public function testExecuteWithInvalidEmail(): void { $this->emailService->expects($this->never()) ->method('sendEmail'); + $this->emailService->expects($this->never()) + ->method('sendEmailSync'); + + $this->commandTester->execute([ + 'recipient' => 'invalid-email', + ]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Invalid email address', $output); + + $this->assertEquals(1, $this->commandTester->getStatusCode()); + } + + public function testExecuteWithInvalidEmailSync(): void + { + $this->emailService->expects($this->never()) + ->method('sendEmail'); + $this->emailService->expects($this->never()) + ->method('sendEmailSync'); $this->commandTester->execute([ 'recipient' => 'invalid-email', + '--sync' => true, ]); $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('Invalid email address', $output); - + $this->assertEquals(1, $this->commandTester->getStatusCode()); } @@ -100,7 +174,25 @@ public function testExecuteWithEmailServiceException(): void $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('Failed to send test email', $output); $this->assertStringContainsString('Test exception', $output); - + + $this->assertEquals(1, $this->commandTester->getStatusCode()); + } + + public function testExecuteWithEmailServiceExceptionSync(): void + { + $this->emailService->expects($this->once()) + ->method('sendEmailSync') + ->willThrowException(new \Exception('Test sync exception')); + + $this->commandTester->execute([ + 'recipient' => 'test@example.com', + '--sync' => true, + ]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Failed to send test email', $output); + $this->assertStringContainsString('Test sync exception', $output); + $this->assertEquals(1, $this->commandTester->getStatusCode()); } } diff --git a/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php b/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php index c7f5fcd8..9409320b 100644 --- a/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php +++ b/tests/Unit/Domain/Messaging/Service/EmailServiceTest.php @@ -4,25 +4,51 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; +use PhpList\Core\Domain\Messaging\Message\AsyncEmailMessage; use PhpList\Core\Domain\Messaging\Service\EmailService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Mime\Email; class EmailServiceTest extends TestCase { private EmailService $emailService; private MailerInterface&MockObject $mailer; + private MessageBusInterface&MockObject $messageBus; private string $defaultFromEmail = 'default@example.com'; protected function setUp(): void { $this->mailer = $this->createMock(MailerInterface::class); - $this->emailService = new EmailService($this->mailer, $this->defaultFromEmail); + $this->messageBus = $this->createMock(MessageBusInterface::class); + $this->emailService = new EmailService($this->mailer, $this->defaultFromEmail, $this->messageBus); } public function testSendEmailWithDefaultFrom(): void + { + $email = (new Email()) + ->to('recipient@example.com') + ->subject('Test Subject') + ->text('Test Content'); + + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (AsyncEmailMessage $message) { + $sentEmail = $message->getEmail(); + $fromAddresses = $sentEmail->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals($this->defaultFromEmail, $fromAddresses[0]->getAddress()); + return true; + })) + ->willReturn(new Envelope(new AsyncEmailMessage($email))); + + $this->emailService->sendEmail($email); + } + + public function testSendEmailSyncWithDefaultFrom(): void { $email = (new Email()) ->to('recipient@example.com') @@ -38,10 +64,33 @@ public function testSendEmailWithDefaultFrom(): void return true; })); - $this->emailService->sendEmail($email); + $this->emailService->sendEmailSync($email); } public function testSendEmailWithCustomFrom(): void + { + $customFrom = 'custom@example.com'; + $email = (new Email()) + ->from($customFrom) + ->to('recipient@example.com') + ->subject('Test Subject') + ->text('Test Content'); + + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (AsyncEmailMessage $message) use ($customFrom) { + $sentEmail = $message->getEmail(); + $fromAddresses = $sentEmail->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals($customFrom, $fromAddresses[0]->getAddress()); + return true; + })) + ->willReturn(new Envelope(new AsyncEmailMessage($email))); + + $this->emailService->sendEmail($email); + } + + public function testSendEmailSyncWithCustomFrom(): void { $customFrom = 'custom@example.com'; $email = (new Email()) @@ -59,7 +108,7 @@ public function testSendEmailWithCustomFrom(): void return true; })); - $this->emailService->sendEmail($email); + $this->emailService->sendEmailSync($email); } public function testSendEmailWithCcBccAndReplyTo(): void @@ -73,6 +122,30 @@ public function testSendEmailWithCcBccAndReplyTo(): void $bcc = ['bcc@example.com']; $replyTo = ['reply@example.com']; + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (AsyncEmailMessage $message) use ($cc, $bcc, $replyTo) { + $this->assertEquals($cc, $message->getCc()); + $this->assertEquals($bcc, $message->getBcc()); + $this->assertEquals($replyTo, $message->getReplyTo()); + return true; + })) + ->willReturn(new Envelope(new AsyncEmailMessage($email))); + + $this->emailService->sendEmail($email, $cc, $bcc, $replyTo); + } + + public function testSendEmailSyncWithCcBccAndReplyTo(): void + { + $email = (new Email()) + ->to('recipient@example.com') + ->subject('Test Subject') + ->text('Test Content'); + + $cc = ['cc@example.com']; + $bcc = ['bcc@example.com']; + $replyTo = ['reply@example.com']; + $this->mailer->expects($this->once()) ->method('send') ->with($this->callback(function (Email $sentEmail) use ($cc, $bcc, $replyTo) { @@ -92,7 +165,7 @@ public function testSendEmailWithCcBccAndReplyTo(): void return true; })); - $this->emailService->sendEmail($email, $cc, $bcc, $replyTo); + $this->emailService->sendEmailSync($email, $cc, $bcc, $replyTo); } public function testSendEmailWithAttachments(): void @@ -104,10 +177,30 @@ public function testSendEmailWithAttachments(): void $attachments = ['/path/to/attachment.pdf']; + $this->messageBus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (AsyncEmailMessage $message) use ($attachments) { + $this->assertEquals($attachments, $message->getAttachments()); + return true; + })) + ->willReturn(new Envelope(new AsyncEmailMessage($email))); + + $this->emailService->sendEmail($email, [], [], [], $attachments); + } + + public function testSendEmailSyncWithAttachments(): void + { + $email = (new Email()) + ->to('recipient@example.com') + ->subject('Test Subject') + ->text('Test Content'); + + $attachments = ['/path/to/attachment.pdf']; + $this->mailer->expects($this->once()) ->method('send'); - $this->emailService->sendEmail($email, [], [], [], $attachments); + $this->emailService->sendEmailSync($email, [], [], [], $attachments); } public function testSendBulkEmail(): void @@ -119,22 +212,57 @@ public function testSendBulkEmail(): void $from = 'sender@example.com'; $fromName = 'Sender Name'; + $this->messageBus->expects($this->exactly(count($recipients))) + ->method('dispatch') + ->with($this->callback(function (AsyncEmailMessage $message) use ( + $subject, + $text, + $html, + $from, + $fromName + ) { + $sentEmail = $message->getEmail(); + $this->assertEquals($subject, $sentEmail->getSubject()); + $this->assertEquals($text, $sentEmail->getTextBody()); + $this->assertEquals($html, $sentEmail->getHtmlBody()); + + $fromAddresses = $sentEmail->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals($from, $fromAddresses[0]->getAddress()); + $this->assertEquals($fromName, $fromAddresses[0]->getName()); + + return true; + })) + ->willReturn(new Envelope($this->createMock(AsyncEmailMessage::class))); + + $this->emailService->sendBulkEmail($recipients, $subject, $text, $html, $from, $fromName); + } + + public function testSendBulkEmailSync(): void + { + $recipients = ['user1@example.com', 'user2@example.com', 'user3@example.com']; + $subject = 'Bulk Test Subject'; + $text = 'Bulk Test Content'; + $html = '

Bulk Test HTML Content

'; + $from = 'sender@example.com'; + $fromName = 'Sender Name'; + $this->mailer->expects($this->exactly(count($recipients))) ->method('send') ->with($this->callback(function (Email $sentEmail) use ($subject, $text, $html, $from, $fromName) { $this->assertEquals($subject, $sentEmail->getSubject()); $this->assertEquals($text, $sentEmail->getTextBody()); $this->assertEquals($html, $sentEmail->getHtmlBody()); - + $fromAddresses = $sentEmail->getFrom(); $this->assertCount(1, $fromAddresses); $this->assertEquals($from, $fromAddresses[0]->getAddress()); $this->assertEquals($fromName, $fromAddresses[0]->getName()); - + return true; })); - $this->emailService->sendBulkEmail($recipients, $subject, $text, $html, $from, $fromName); + $this->emailService->sendBulkEmailSync($recipients, $subject, $text, $html, $from, $fromName); } public function testSendBulkEmailWithDefaultFrom(): void @@ -143,6 +271,26 @@ public function testSendBulkEmailWithDefaultFrom(): void $subject = 'Bulk Test Subject'; $text = 'Bulk Test Content'; + $this->messageBus->expects($this->exactly(count($recipients))) + ->method('dispatch') + ->with($this->callback(function (AsyncEmailMessage $message) { + $sentEmail = $message->getEmail(); + $fromAddresses = $sentEmail->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals($this->defaultFromEmail, $fromAddresses[0]->getAddress()); + return true; + })) + ->willReturn(new Envelope($this->createMock(AsyncEmailMessage::class))); + + $this->emailService->sendBulkEmail($recipients, $subject, $text); + } + + public function testSendBulkEmailSyncWithDefaultFrom(): void + { + $recipients = ['user1@example.com', 'user2@example.com']; + $subject = 'Bulk Test Subject'; + $text = 'Bulk Test Content'; + $this->mailer->expects($this->exactly(count($recipients))) ->method('send') ->with($this->callback(function (Email $sentEmail) { @@ -152,6 +300,6 @@ public function testSendBulkEmailWithDefaultFrom(): void return true; })); - $this->emailService->sendBulkEmail($recipients, $subject, $text); + $this->emailService->sendBulkEmailSync($recipients, $subject, $text); } } From 12fb0a0bada14ccb028c132fcd7181863072631b Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 8 Jun 2025 13:26:47 +0400 Subject: [PATCH 08/26] fix admin/token relation --- src/Domain/Identity/Model/AdministratorToken.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Domain/Identity/Model/AdministratorToken.php b/src/Domain/Identity/Model/AdministratorToken.php index 41ff75aa..1a6c511f 100644 --- a/src/Domain/Identity/Model/AdministratorToken.php +++ b/src/Domain/Identity/Model/AdministratorToken.php @@ -43,7 +43,7 @@ class AdministratorToken implements DomainModel, Identity, CreationDate private string $key = ''; #[ORM\ManyToOne(targetEntity: Administrator::class)] - #[ORM\JoinColumn(name: 'adminid')] + #[ORM\JoinColumn(name: 'adminid', referencedColumnName: 'id', onDelete: 'CASCADE')] private ?Administrator $administrator = null; public function __construct() From ecfe93fefa9c5e295a2a31ac548a224416e7c6be Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 8 Jun 2025 16:31:36 +0400 Subject: [PATCH 09/26] remove twig from dependencies --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index 0fd65a52..6049237a 100644 --- a/composer.json +++ b/composer.json @@ -65,7 +65,6 @@ "symfony/amazon-mailer": "^6.4", "symfony/mailchimp-mailer": "^6.4", "symfony/sendgrid-mailer": "^6.4", - "symfony/twig-bundle": "^6.4", "symfony/messenger": "^6.4" }, "require-dev": { From afa50f4dd2b6825e894a346ca6873198e0cccf85 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 8 Jun 2025 19:24:57 +0400 Subject: [PATCH 10/26] configure twig --- composer.json | 2 ++ config/config.yml | 1 - config/packages/twig.yaml | 4 ++++ src/Core/ApplicationKernel.php | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 config/packages/twig.yaml diff --git a/composer.json b/composer.json index 6049237a..7f56c174 100644 --- a/composer.json +++ b/composer.json @@ -65,6 +65,7 @@ "symfony/amazon-mailer": "^6.4", "symfony/mailchimp-mailer": "^6.4", "symfony/sendgrid-mailer": "^6.4", + "symfony/twig-bundle": "^6.4", "symfony/messenger": "^6.4" }, "require-dev": { @@ -133,6 +134,7 @@ "bundles": [ "Symfony\\Bundle\\FrameworkBundle\\FrameworkBundle", "Symfony\\Bundle\\MonologBundle\\MonologBundle", + "Symfony\\Bundle\\TwigBundle\\TwigBundle", "Doctrine\\Bundle\\DoctrineBundle\\DoctrineBundle", "Doctrine\\Bundle\\MigrationsBundle\\DoctrineMigrationsBundle", "PhpList\\Core\\EmptyStartPageBundle\\EmptyStartPageBundle" diff --git a/config/config.yml b/config/config.yml index 6875f2c9..93df3f5c 100644 --- a/config/config.yml +++ b/config/config.yml @@ -2,7 +2,6 @@ imports: - { resource: services.yml } - { resource: doctrine.yml } - { resource: doctrine_migrations.yml } - - { resource: packages/*.yml } # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml new file mode 100644 index 00000000..d5320edd --- /dev/null +++ b/config/packages/twig.yaml @@ -0,0 +1,4 @@ +twig: + debug: '%kernel.debug%' + strict_variables: '%kernel.debug%' + default_path: 'test/templates' diff --git a/src/Core/ApplicationKernel.php b/src/Core/ApplicationKernel.php index 1d3c64ab..042b48d0 100644 --- a/src/Core/ApplicationKernel.php +++ b/src/Core/ApplicationKernel.php @@ -122,6 +122,7 @@ public function registerContainerConfiguration(LoaderInterface $loader): void $loader->load($this->getApplicationDir() . '/config/parameters.yml'); $loader->load($this->getRootDir() . '/config/config_' . $this->getEnvironment() . '.yml'); $loader->load($this->getApplicationDir() . '/config/config_modules.yml'); + $loader->load($this->getApplicationDir() . '/config/packages/twig.yaml'); } /** From a862f61e021a9b3c8e339619581f4a44793fc476 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 8 Jun 2025 20:12:32 +0400 Subject: [PATCH 11/26] remove twig --- config/packages/twig.yaml | 4 ---- src/Core/ApplicationKernel.php | 6 +++++- 2 files changed, 5 insertions(+), 5 deletions(-) delete mode 100644 config/packages/twig.yaml diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml deleted file mode 100644 index d5320edd..00000000 --- a/config/packages/twig.yaml +++ /dev/null @@ -1,4 +0,0 @@ -twig: - debug: '%kernel.debug%' - strict_variables: '%kernel.debug%' - default_path: 'test/templates' diff --git a/src/Core/ApplicationKernel.php b/src/Core/ApplicationKernel.php index 042b48d0..97249b45 100644 --- a/src/Core/ApplicationKernel.php +++ b/src/Core/ApplicationKernel.php @@ -122,7 +122,11 @@ public function registerContainerConfiguration(LoaderInterface $loader): void $loader->load($this->getApplicationDir() . '/config/parameters.yml'); $loader->load($this->getRootDir() . '/config/config_' . $this->getEnvironment() . '.yml'); $loader->load($this->getApplicationDir() . '/config/config_modules.yml'); - $loader->load($this->getApplicationDir() . '/config/packages/twig.yaml'); + + $twigConfigFile = $this->getApplicationDir() . '/config/packages/twig.yaml'; + if (file_exists($twigConfigFile)) { + $loader->load($twigConfigFile); + } } /** From 848efae4639cbdd8eace58097cc7058606507fce Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 9 Jun 2025 11:42:25 +0400 Subject: [PATCH 12/26] ISSUE-345: config manager --- config/services/managers.yml | 4 + config/services/parameters.yml | 1 + config/services/repositories.yml | 5 + .../Exception/ConfigNotEditableException.php | 15 ++ src/Domain/Configuration/Model/Config.php | 12 +- .../Service/Manager/ConfigManager.php | 67 ++++++++ .../Service/Manager/ConfigManagerTest.php | 146 ++++++++++++++++++ 7 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 src/Domain/Configuration/Exception/ConfigNotEditableException.php create mode 100644 src/Domain/Configuration/Service/Manager/ConfigManager.php create mode 100644 tests/Unit/Domain/Configuration/Service/Manager/ConfigManagerTest.php diff --git a/config/services/managers.yml b/config/services/managers.yml index eaa37758..41284f2c 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -67,3 +67,7 @@ services: autoconfigure: true arguments: $defaultFromEmail: '%app.mailer_from%' + + PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: + autowire: true + autoconfigure: true diff --git a/config/services/parameters.yml b/config/services/parameters.yml index 6c944ede..ebf1d99b 100644 --- a/config/services/parameters.yml +++ b/config/services/parameters.yml @@ -8,3 +8,4 @@ parameters: notify_start_default: 'start@example.com' notify_end_default: 'end@example.com' always_add_google_tracking: true + click_track: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml index 21ce7114..44911f64 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -60,3 +60,8 @@ services: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Messaging\Model\TemplateImage + + PhpList\Core\Domain\Configuration\Repository\ConfigRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Configuration\Model\Config diff --git a/src/Domain/Configuration/Exception/ConfigNotEditableException.php b/src/Domain/Configuration/Exception/ConfigNotEditableException.php new file mode 100644 index 00000000..eb0ed456 --- /dev/null +++ b/src/Domain/Configuration/Exception/ConfigNotEditableException.php @@ -0,0 +1,15 @@ +item; + return $this->key; } - public function setItem(string $item): self + public function setKey(string $key): self { - $this->item = $item; + $this->key = $key; return $this; } diff --git a/src/Domain/Configuration/Service/Manager/ConfigManager.php b/src/Domain/Configuration/Service/Manager/ConfigManager.php new file mode 100644 index 00000000..cae380be --- /dev/null +++ b/src/Domain/Configuration/Service/Manager/ConfigManager.php @@ -0,0 +1,67 @@ +configRepository = $configRepository; + } + + /** + * Get a configuration item by its key + */ + public function getByItem(string $item): ?Config + { + return $this->configRepository->findOneBy(['item' => $item]); + } + + /** + * Get all configuration items + * + * @return Config[] + */ + public function getAll(): array + { + return $this->configRepository->findAll(); + } + + /** + * Update a configuration item + * @throws ConfigNotEditableException + */ + public function update(Config $config, string $value): void + { + if (!$config->isEditable()) { + throw new ConfigNotEditableException($config->getKey()); + } + $config->setValue($value); + + $this->configRepository->save($config); + } + + public function create(string $key, string $value, bool $editable, ?string $type = null): void + { + $config = (new Config()) + ->setKey($key) + ->setValue($value) + ->setEditable($editable) + ->setType($type); + + $this->configRepository->save($config); + } + + public function delete(Config $config): void + { + $this->configRepository->remove($config); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Manager/ConfigManagerTest.php b/tests/Unit/Domain/Configuration/Service/Manager/ConfigManagerTest.php new file mode 100644 index 00000000..3b14122b --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Manager/ConfigManagerTest.php @@ -0,0 +1,146 @@ +createMock(ConfigRepository::class); + $manager = new ConfigManager($configRepository); + + $config = new Config(); + $config->setKey('test_item'); + $config->setValue('test_value'); + + $configRepository->expects($this->once()) + ->method('findOneBy') + ->with(['item' => 'test_item']) + ->willReturn($config); + + $result = $manager->getByItem('test_item'); + + $this->assertSame($config, $result); + $this->assertSame('test_item', $result->getKey()); + $this->assertSame('test_value', $result->getValue()); + } + + public function testGetAllReturnsAllConfigsFromRepository(): void + { + $configRepository = $this->createMock(ConfigRepository::class); + $manager = new ConfigManager($configRepository); + + $config1 = new Config(); + $config1->setKey('item1'); + $config1->setValue('value1'); + + $config2 = new Config(); + $config2->setKey('item2'); + $config2->setValue('value2'); + + $configs = [$config1, $config2]; + + $configRepository->expects($this->once()) + ->method('findAll') + ->willReturn($configs); + + $result = $manager->getAll(); + + $this->assertSame($configs, $result); + $this->assertCount(2, $result); + $this->assertSame('item1', $result[0]->getKey()); + $this->assertSame('value1', $result[0]->getValue()); + $this->assertSame('item2', $result[1]->getKey()); + $this->assertSame('value2', $result[1]->getValue()); + } + + public function testUpdateSavesConfigToRepository(): void + { + $configRepository = $this->createMock(ConfigRepository::class); + $manager = new ConfigManager($configRepository); + + $config = new Config(); + $config->setKey('test_item'); + $config->setValue('test_value'); + $config->setEditable(true); + + $configRepository->expects($this->once()) + ->method('save') + ->with($config); + + $manager->update($config, 'new_value'); + } + + public function testCreateSavesNewConfigToRepository(): void + { + $configRepository = $this->createMock(ConfigRepository::class); + $manager = new ConfigManager($configRepository); + + $configRepository->expects($this->once()) + ->method('save') + ->with($this->callback(function (Config $config) { + return $config->getKey() === 'test_key' && + $config->getValue() === 'test_value' && + $config->isEditable() === true && + $config->getType() === 'test_type'; + })); + + $manager->create('test_key', 'test_value', true, 'test_type'); + } + public function testGetByItemReturnsNullWhenItemDoesNotExist(): void + { + $configRepository = $this->createMock(ConfigRepository::class); + $manager = new ConfigManager($configRepository); + + $configRepository->expects($this->once()) + ->method('findOneBy') + ->with(['item' => 'non_existent_item']) + ->willReturn(null); + + $result = $manager->getByItem('non_existent_item'); + + $this->assertNull($result); + } + + public function testUpdateThrowsExceptionWhenConfigIsNotEditable(): void + { + $configRepository = $this->createMock(ConfigRepository::class); + $manager = new ConfigManager($configRepository); + + $config = new Config(); + $config->setKey('test_item'); + $config->setValue('test_value'); + $config->setEditable(false); + + $configRepository->expects($this->never()) + ->method('save'); + + $this->expectException(\PhpList\Core\Domain\Configuration\Exception\ConfigNotEditableException::class); + $this->expectExceptionMessage('Configuration item "test_item" is not editable.'); + + $manager->update($config, 'new_value'); + } + + public function testDeleteRemovesConfigFromRepository(): void + { + $configRepository = $this->createMock(ConfigRepository::class); + $manager = new ConfigManager($configRepository); + + $config = new Config(); + $config->setKey('test_item'); + $config->setValue('test_value'); + + $configRepository->expects($this->once()) + ->method('remove') + ->with($config); + + $manager->delete($config); + } +} From 601fbc12a1a8c8187c1e7a7b1ed0598df705c640 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 13 Jun 2025 17:55:57 +0400 Subject: [PATCH 13/26] ISSUE-345: admin privileges --- src/Domain/Identity/Model/Administrator.php | 11 ++-- src/Domain/Identity/Model/PrivilegeFlag.php | 13 ++++ src/Domain/Identity/Model/Privileges.php | 68 +++++++++++++++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 src/Domain/Identity/Model/PrivilegeFlag.php create mode 100644 src/Domain/Identity/Model/Privileges.php diff --git a/src/Domain/Identity/Model/Administrator.php b/src/Domain/Identity/Model/Administrator.php index d45e6c0e..5c7ca424 100644 --- a/src/Domain/Identity/Model/Administrator.php +++ b/src/Domain/Identity/Model/Administrator.php @@ -152,16 +152,19 @@ public function getNameLc(): string return $this->namelc; } - public function setPrivileges(string $privileges): self + public function setPrivileges(Privileges $privileges): self { - $this->privileges = $privileges; + $this->privileges = $privileges->toSerialized(); return $this; } - public function getPrivileges(): string + /** + * @SuppressWarnings(PHPMD.StaticAccess) + */ + public function getPrivileges(): Privileges { - return $this->privileges; + return Privileges::fromSerialized($this->privileges); } public function getCreatedAt(): ?DateTime diff --git a/src/Domain/Identity/Model/PrivilegeFlag.php b/src/Domain/Identity/Model/PrivilegeFlag.php new file mode 100644 index 00000000..e8e9151e --- /dev/null +++ b/src/Domain/Identity/Model/PrivilegeFlag.php @@ -0,0 +1,13 @@ + + */ + private array $flags = []; + + /** + * @SuppressWarnings(PHPMD.StaticAccess) + */ + private function __construct(array $flags = []) + { + foreach (PrivilegeFlag::cases() as $flag) { + $key = $flag->value; + $this->flags[$key] = $flags[$key] ?? false; + } + } + + public static function fromSerialized(?string $serialized): self + { + if (!$serialized) { + return new self(); + } + + $data = @unserialize($serialized); + if (!is_array($data)) { + return new self(); + } + + return new self($data); + } + + public function toSerialized(): string + { + return serialize($this->flags); + } + + public function has(PrivilegeFlag $flag): bool + { + return $this->flags[$flag->value] ?? false; + } + + public function grant(PrivilegeFlag $flag): self + { + $clone = clone $this; + $clone->flags[$flag->value] = true; + return $clone; + } + + public function revoke(PrivilegeFlag $flag): self + { + $clone = clone $this; + $clone->flags[$flag->value] = false; + return $clone; + } + + public function all(): array + { + return $this->flags; + } +} + From dadaded33817c2e71cd6f2e93419357c33cc6081 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Fri, 13 Jun 2025 18:35:53 +0400 Subject: [PATCH 14/26] ISSUE-345: admin privileges --- src/Domain/Identity/Model/Administrator.php | 20 ++++ .../Model/Dto/CreateAdministratorDto.php | 1 + .../Model/Dto/UpdateAdministratorDto.php | 1 + src/Domain/Identity/Model/Privileges.php | 19 +++- .../Identity/Service/AdministratorManager.php | 5 + .../Identity/Model/AdministratorTest.php | 45 +++++++- .../Identity/Model/PrivilegeFlagTest.php | 45 ++++++++ .../Domain/Identity/Model/PrivilegesTest.php | 102 ++++++++++++++++++ 8 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/Domain/Identity/Model/PrivilegeFlagTest.php create mode 100644 tests/Unit/Domain/Identity/Model/PrivilegesTest.php diff --git a/src/Domain/Identity/Model/Administrator.php b/src/Domain/Identity/Model/Administrator.php index 5c7ca424..34cfc9e8 100644 --- a/src/Domain/Identity/Model/Administrator.php +++ b/src/Domain/Identity/Model/Administrator.php @@ -6,6 +6,7 @@ use DateTime; use Doctrine\ORM\Mapping as ORM; +use InvalidArgumentException; use PhpList\Core\Domain\Common\Model\Interfaces\CreationDate; use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Common\Model\Interfaces\Identity; @@ -72,6 +73,7 @@ public function __construct() $this->createdAt = new DateTime(); $this->updatedAt = null; $this->email = ''; + $this->privileges = null; } public function getLoginName(): string @@ -159,6 +161,24 @@ public function setPrivileges(Privileges $privileges): self return $this; } + /** + * @SuppressWarnings(PHPMD.StaticAccess) + * @throws InvalidArgumentException + */ + public function setPrivilegesFromArray(array $privilegesData): void + { + $privileges = new Privileges(); + foreach ($privilegesData as $key => $value) { + $flag = PrivilegeFlag::tryFrom($key); + if (!$flag) { + throw new InvalidArgumentException('Unknown privilege key: '. $key); + } + + $privileges = $value ? $privileges->grant($flag) : $privileges->revoke($flag); + } + $this->setPrivileges($privileges); + } + /** * @SuppressWarnings(PHPMD.StaticAccess) */ diff --git a/src/Domain/Identity/Model/Dto/CreateAdministratorDto.php b/src/Domain/Identity/Model/Dto/CreateAdministratorDto.php index adfe9ec6..6d058d3d 100644 --- a/src/Domain/Identity/Model/Dto/CreateAdministratorDto.php +++ b/src/Domain/Identity/Model/Dto/CreateAdministratorDto.php @@ -14,6 +14,7 @@ public function __construct( public readonly string $password, public readonly string $email, public readonly bool $isSuperUser = false, + public readonly array $privileges = [], ) { } } diff --git a/src/Domain/Identity/Model/Dto/UpdateAdministratorDto.php b/src/Domain/Identity/Model/Dto/UpdateAdministratorDto.php index a8ad06f3..399a1d48 100644 --- a/src/Domain/Identity/Model/Dto/UpdateAdministratorDto.php +++ b/src/Domain/Identity/Model/Dto/UpdateAdministratorDto.php @@ -12,6 +12,7 @@ public function __construct( public readonly ?string $password = null, public readonly ?string $email = null, public readonly ?bool $superAdmin = null, + public readonly array $privileges = [], ) { } } diff --git a/src/Domain/Identity/Model/Privileges.php b/src/Domain/Identity/Model/Privileges.php index 05c78f7c..0b53de69 100644 --- a/src/Domain/Identity/Model/Privileges.php +++ b/src/Domain/Identity/Model/Privileges.php @@ -4,6 +4,9 @@ namespace PhpList\Core\Domain\Identity\Model; +use InvalidArgumentException; +use UnexpectedValueException; + class Privileges { /** @@ -14,7 +17,7 @@ class Privileges /** * @SuppressWarnings(PHPMD.StaticAccess) */ - private function __construct(array $flags = []) + public function __construct(?array $flags = []) { foreach (PrivilegeFlag::cases() as $flag) { $key = $flag->value; @@ -28,9 +31,18 @@ public static function fromSerialized(?string $serialized): self return new self(); } - $data = @unserialize($serialized); + set_error_handler(function () { + throw new InvalidArgumentException('Invalid serialized privileges string.'); + }); + + try { + $data = unserialize($serialized); + } finally { + restore_error_handler(); + } + if (!is_array($data)) { - return new self(); + throw new UnexpectedValueException('Privileges must deserialize to an array.'); } return new self($data); @@ -65,4 +77,3 @@ public function all(): array return $this->flags; } } - diff --git a/src/Domain/Identity/Service/AdministratorManager.php b/src/Domain/Identity/Service/AdministratorManager.php index 41731a14..50df185f 100644 --- a/src/Domain/Identity/Service/AdministratorManager.php +++ b/src/Domain/Identity/Service/AdministratorManager.php @@ -5,9 +5,12 @@ namespace PhpList\Core\Domain\Identity\Service; use Doctrine\ORM\EntityManagerInterface; +use InvalidArgumentException; 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\Domain\Identity\Model\PrivilegeFlag; +use PhpList\Core\Domain\Identity\Model\Privileges; use PhpList\Core\Security\HashGenerator; class AdministratorManager @@ -29,6 +32,7 @@ public function createAdministrator(CreateAdministratorDto $dto): Administrator $administrator->setSuperUser($dto->isSuperUser); $hashedPassword = $this->hashGenerator->createPasswordHash($dto->password); $administrator->setPasswordHash($hashedPassword); + $administrator->setPrivilegesFromArray($dto->privileges); $this->entityManager->persist($administrator); $this->entityManager->flush(); @@ -51,6 +55,7 @@ public function updateAdministrator(Administrator $administrator, UpdateAdminist $hashedPassword = $this->hashGenerator->createPasswordHash($dto->password); $administrator->setPasswordHash($hashedPassword); } + $administrator->setPrivilegesFromArray($dto->privileges); $this->entityManager->flush(); } diff --git a/tests/Unit/Domain/Identity/Model/AdministratorTest.php b/tests/Unit/Domain/Identity/Model/AdministratorTest.php index 10508b26..f721f58b 100644 --- a/tests/Unit/Domain/Identity/Model/AdministratorTest.php +++ b/tests/Unit/Domain/Identity/Model/AdministratorTest.php @@ -4,8 +4,11 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Model; +use DateTime; use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Identity\Model\Privileges; +use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; use PhpList\Core\TestingSupport\Traits\ModelTestTrait; use PhpList\Core\TestingSupport\Traits\SimilarDatesAssertionTrait; use PHPUnit\Framework\TestCase; @@ -75,7 +78,7 @@ public function testUpdateModificationDateSetsModificationDateToNow(): void { $this->subject->updateUpdatedAt(); - self::assertSimilarDates(new \DateTime(), $this->subject->getUpdatedAt()); + self::assertSimilarDates(new DateTime(), $this->subject->getUpdatedAt()); } public function testGetPasswordHashInitiallyReturnsEmptyString(): void @@ -98,7 +101,7 @@ public function testGetPasswordChangeDateInitiallyReturnsNull(): void public function testSetPasswordHashSetsPasswordChangeDateToNow(): void { - $date = new \DateTime(); + $date = new DateTime(); $this->subject->setPasswordHash('Zaphod Beeblebrox'); self::assertSimilarDates($date, $this->subject->getPasswordChangeDate()); @@ -127,4 +130,42 @@ public function testSetSuperUserSetsSuperUser(): void self::assertTrue($this->subject->isSuperUser()); } + + public function testGetPrivilegesInitiallyReturnsEmptyPrivileges(): void + { + $privileges = $this->subject->getPrivileges(); + + self::assertInstanceOf(Privileges::class, $privileges); + + foreach (PrivilegeFlag::cases() as $flag) { + self::assertFalse($privileges->has($flag)); + } + } + + public function testSetPrivilegesSetsPrivileges(): void + { + $privileges = Privileges::fromSerialized(''); + $privileges = $privileges->grant(PrivilegeFlag::Subscribers); + + $this->subject->setPrivileges($privileges); + + $retrievedPrivileges = $this->subject->getPrivileges(); + self::assertTrue($retrievedPrivileges->has(PrivilegeFlag::Subscribers)); + self::assertFalse($retrievedPrivileges->has(PrivilegeFlag::Campaigns)); + } + + public function testSetPrivilegesWithMultiplePrivileges(): void + { + $privileges = Privileges::fromSerialized(''); + $privileges = $privileges + ->grant(PrivilegeFlag::Subscribers) + ->grant(PrivilegeFlag::Campaigns); + + $this->subject->setPrivileges($privileges); + + $retrievedPrivileges = $this->subject->getPrivileges(); + self::assertTrue($retrievedPrivileges->has(PrivilegeFlag::Subscribers)); + self::assertTrue($retrievedPrivileges->has(PrivilegeFlag::Campaigns)); + self::assertFalse($retrievedPrivileges->has(PrivilegeFlag::Statistics)); + } } diff --git a/tests/Unit/Domain/Identity/Model/PrivilegeFlagTest.php b/tests/Unit/Domain/Identity/Model/PrivilegeFlagTest.php new file mode 100644 index 00000000..d7a6ad26 --- /dev/null +++ b/tests/Unit/Domain/Identity/Model/PrivilegeFlagTest.php @@ -0,0 +1,45 @@ +value); + } + + public function testEnumHasCampaignsCase(): void + { + self::assertSame('campaigns', PrivilegeFlag::Campaigns->value); + } + + public function testEnumHasStatisticsCase(): void + { + self::assertSame('statistics', PrivilegeFlag::Statistics->value); + } + + public function testEnumHasSettingsCase(): void + { + self::assertSame('settings', PrivilegeFlag::Settings->value); + } + + public function testEnumHasFourCases(): void + { + $cases = PrivilegeFlag::cases(); + + self::assertCount(4, $cases); + self::assertContains(PrivilegeFlag::Subscribers, $cases); + self::assertContains(PrivilegeFlag::Campaigns, $cases); + self::assertContains(PrivilegeFlag::Statistics, $cases); + self::assertContains(PrivilegeFlag::Settings, $cases); + } +} diff --git a/tests/Unit/Domain/Identity/Model/PrivilegesTest.php b/tests/Unit/Domain/Identity/Model/PrivilegesTest.php new file mode 100644 index 00000000..e81b4afc --- /dev/null +++ b/tests/Unit/Domain/Identity/Model/PrivilegesTest.php @@ -0,0 +1,102 @@ +subject = Privileges::fromSerialized(''); + } + + public function testFromSerializedWithInvalidDataThrowsError(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid serialized privileges string.'); + + Privileges::fromSerialized('invalid data'); + } + + public function testFromSerializedWithValidDataReturnsPopulatedPrivileges(): void + { + $data = [PrivilegeFlag::Subscribers->value => true]; + $serialized = serialize($data); + + $privileges = Privileges::fromSerialized($serialized); + + self::assertTrue($privileges->has(PrivilegeFlag::Subscribers)); + } + + public function testToSerializedReturnsSerializedData(): void + { + $privileges = Privileges::fromSerialized(''); + $privileges = $privileges->grant(PrivilegeFlag::Subscribers); + + $serialized = $privileges->toSerialized(); + $data = unserialize($serialized); + + self::assertTrue($data[PrivilegeFlag::Subscribers->value]); + } + + public function testHasReturnsFalseForUnsetPrivilege(): void + { + self::assertFalse($this->subject->has(PrivilegeFlag::Subscribers)); + } + + public function testHasReturnsTrueForSetPrivilege(): void + { + $this->subject = $this->subject->grant(PrivilegeFlag::Subscribers); + + self::assertTrue($this->subject->has(PrivilegeFlag::Subscribers)); + } + + public function testGrantSetsPrivilege(): void + { + $result = $this->subject->grant(PrivilegeFlag::Subscribers); + + self::assertTrue($result->has(PrivilegeFlag::Subscribers)); + } + + public function testGrantReturnsNewInstance(): void + { + $result = $this->subject->grant(PrivilegeFlag::Subscribers); + + self::assertNotSame($this->subject, $result); + } + + public function testRevokeClearsPrivilege(): void + { + $this->subject = $this->subject->grant(PrivilegeFlag::Subscribers); + $result = $this->subject->revoke(PrivilegeFlag::Subscribers); + + self::assertFalse($result->has(PrivilegeFlag::Subscribers)); + } + + public function testRevokeReturnsNewInstance(): void + { + $result = $this->subject->revoke(PrivilegeFlag::Subscribers); + + self::assertNotSame($this->subject, $result); + } + + public function testAllReturnsAllPrivileges(): void + { + $this->subject = $this->subject->grant(PrivilegeFlag::Subscribers); + $all = $this->subject->all(); + + self::assertTrue($all[PrivilegeFlag::Subscribers->value]); + self::assertFalse($all[PrivilegeFlag::Campaigns->value]); + } +} From 10fb04837feaa70119bb4afc8246e17706dc3366 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 14 Jun 2025 12:54:09 +0400 Subject: [PATCH 15/26] ISSUE-345: deprecation fix --- .../Command/SendTestEmailCommand.php | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Domain/Messaging/Command/SendTestEmailCommand.php b/src/Domain/Messaging/Command/SendTestEmailCommand.php index ce0cbc3c..bb6ba06b 100644 --- a/src/Domain/Messaging/Command/SendTestEmailCommand.php +++ b/src/Domain/Messaging/Command/SendTestEmailCommand.php @@ -6,6 +6,7 @@ use Exception; use PhpList\Core\Domain\Messaging\Service\EmailService; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -13,11 +14,12 @@ use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; +#[AsCommand( + name: 'phplist:test-email', + description: 'Send a test email to verify email configuration' +)] class SendTestEmailCommand extends Command { - protected static $defaultName = 'phplist:test-email'; - protected static $defaultDescription = 'Send a test email to verify email configuration'; - private EmailService $emailService; public function __construct(EmailService $emailService) @@ -29,9 +31,17 @@ public function __construct(EmailService $emailService) protected function configure(): void { $this - ->setDescription(self::$defaultDescription) - ->addArgument('recipient', InputArgument::OPTIONAL, 'Recipient email address') - ->addOption('sync', null, InputArgument::OPTIONAL, 'Send email synchronously instead of queuing it', false); + ->addArgument( + name: 'recipient', + mode: InputArgument::OPTIONAL, + description: 'Recipient email address' + ) + ->addOption( + name: 'sync', + mode: InputArgument::OPTIONAL, + description: 'Send email synchronously instead of queuing it', + default: false + ); } protected function execute(InputInterface $input, OutputInterface $output): int From 1f867c4cb43868c092ddb817a687d467d8bbaefe Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 15 Jun 2025 21:10:44 +0400 Subject: [PATCH 16/26] ISSUE-345: attribute type validator --- config/services/validators.yml | 4 ++ .../AdminAttributeDefinitionManager.php | 11 ++++- .../Manager/AttributeDefinitionManager.php | 11 ++++- .../Validator/AttributeTypeValidator.php | 44 ++++++++++++++++++ .../AdminAttributeDefinitionManagerTest.php | 4 +- .../AttributeDefinitionManagerTest.php | 16 ++++--- .../Validator/AttributeTypeValidatorTest.php | 45 +++++++++++++++++++ 7 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 src/Domain/Subscription/Validator/AttributeTypeValidator.php create mode 100644 tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php diff --git a/config/services/validators.yml b/config/services/validators.yml index 3d15e4a5..e7a90910 100644 --- a/config/services/validators.yml +++ b/config/services/validators.yml @@ -6,3 +6,7 @@ services: PhpList\Core\Domain\Messaging\Validator\TemplateImageValidator: autowire: true autoconfigure: true + + PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator: + autowire: true + autoconfigure: true diff --git a/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php b/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php index c1397e09..f0a18e07 100644 --- a/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php +++ b/src/Domain/Identity/Service/AdminAttributeDefinitionManager.php @@ -8,14 +8,19 @@ use PhpList\Core\Domain\Identity\Model\Dto\AdminAttributeDefinitionDto; use PhpList\Core\Domain\Identity\Repository\AdminAttributeDefinitionRepository; use PhpList\Core\Domain\Identity\Exception\AttributeDefinitionCreationException; +use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator; class AdminAttributeDefinitionManager { private AdminAttributeDefinitionRepository $definitionRepository; + private AttributeTypeValidator $attributeTypeValidator; - public function __construct(AdminAttributeDefinitionRepository $definitionRepository) - { + public function __construct( + AdminAttributeDefinitionRepository $definitionRepository, + AttributeTypeValidator $attributeTypeValidator + ) { $this->definitionRepository = $definitionRepository; + $this->attributeTypeValidator = $attributeTypeValidator; } public function create(AdminAttributeDefinitionDto $attributeDefinitionDto): AdminAttributeDefinition @@ -24,6 +29,7 @@ public function create(AdminAttributeDefinitionDto $attributeDefinitionDto): Adm if ($existingAttribute) { throw new AttributeDefinitionCreationException('Attribute definition already exists', 409); } + $this->attributeTypeValidator->validate($attributeDefinitionDto->type); $attributeDefinition = (new AdminAttributeDefinition($attributeDefinitionDto->name)) ->setType($attributeDefinitionDto->type) @@ -45,6 +51,7 @@ public function update( if ($existingAttribute && $existingAttribute->getId() !== $attributeDefinition->getId()) { throw new AttributeDefinitionCreationException('Another attribute with this name already exists.', 409); } + $this->attributeTypeValidator->validate($attributeDefinitionDto->type); $attributeDefinition ->setName($attributeDefinitionDto->name) diff --git a/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php b/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php index 8e2a0307..d8983e65 100644 --- a/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php +++ b/src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php @@ -8,14 +8,19 @@ use PhpList\Core\Domain\Subscription\Model\Dto\AttributeDefinitionDto; use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; +use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator; class AttributeDefinitionManager { private SubscriberAttributeDefinitionRepository $definitionRepository; + private AttributeTypeValidator $attributeTypeValidator; - public function __construct(SubscriberAttributeDefinitionRepository $definitionRepository) - { + public function __construct( + SubscriberAttributeDefinitionRepository $definitionRepository, + AttributeTypeValidator $attributeTypeValidator + ) { $this->definitionRepository = $definitionRepository; + $this->attributeTypeValidator = $attributeTypeValidator; } public function create(AttributeDefinitionDto $attributeDefinitionDto): SubscriberAttributeDefinition @@ -24,6 +29,7 @@ public function create(AttributeDefinitionDto $attributeDefinitionDto): Subscrib if ($existingAttribute) { throw new AttributeDefinitionCreationException('Attribute definition already exists', 409); } + $this->attributeTypeValidator->validate($attributeDefinitionDto->type); $attributeDefinition = (new SubscriberAttributeDefinition()) ->setName($attributeDefinitionDto->name) @@ -46,6 +52,7 @@ public function update( if ($existingAttribute && $existingAttribute->getId() !== $attributeDefinition->getId()) { throw new AttributeDefinitionCreationException('Another attribute with this name already exists.', 409); } + $this->attributeTypeValidator->validate($attributeDefinitionDto->type); $attributeDefinition ->setName($attributeDefinitionDto->name) diff --git a/src/Domain/Subscription/Validator/AttributeTypeValidator.php b/src/Domain/Subscription/Validator/AttributeTypeValidator.php new file mode 100644 index 00000000..3923cdfc --- /dev/null +++ b/src/Domain/Subscription/Validator/AttributeTypeValidator.php @@ -0,0 +1,44 @@ +repository = $this->createMock(AdminAttributeDefinitionRepository::class); - $this->subject = new AdminAttributeDefinitionManager($this->repository); + $attributeTypeValidator = $this->createMock(AttributeTypeValidator::class); + $this->subject = new AdminAttributeDefinitionManager($this->repository, $attributeTypeValidator); } public function testCreateCreatesNewAttributeDefinition(): void diff --git a/tests/Unit/Domain/Subscription/Service/AttributeDefinitionManagerTest.php b/tests/Unit/Domain/Subscription/Service/AttributeDefinitionManagerTest.php index bc5a6f18..85b3bb93 100644 --- a/tests/Unit/Domain/Subscription/Service/AttributeDefinitionManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/AttributeDefinitionManagerTest.php @@ -9,6 +9,7 @@ use PhpList\Core\Domain\Subscription\Model\SubscriberAttributeDefinition; use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; use PhpList\Core\Domain\Subscription\Service\Manager\AttributeDefinitionManager; +use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator; use PHPUnit\Framework\TestCase; class AttributeDefinitionManagerTest extends TestCase @@ -16,7 +17,8 @@ class AttributeDefinitionManagerTest extends TestCase public function testCreateAttributeDefinition(): void { $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); - $manager = new AttributeDefinitionManager($repository); + $validator = $this->createMock(AttributeTypeValidator::class); + $manager = new AttributeDefinitionManager($repository, $validator); $dto = new AttributeDefinitionDto( name: 'Country', @@ -48,7 +50,8 @@ public function testCreateAttributeDefinition(): void public function testCreateThrowsWhenAttributeAlreadyExists(): void { $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); - $manager = new AttributeDefinitionManager($repository); + $validator = $this->createMock(AttributeTypeValidator::class); + $manager = new AttributeDefinitionManager($repository, $validator); $dto = new AttributeDefinitionDto( name: 'Country', @@ -74,7 +77,8 @@ public function testCreateThrowsWhenAttributeAlreadyExists(): void public function testUpdateAttributeDefinition(): void { $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); - $manager = new AttributeDefinitionManager($repository); + $validator = $this->createMock(AttributeTypeValidator::class); + $manager = new AttributeDefinitionManager($repository, $validator); $attribute = new SubscriberAttributeDefinition(); $attribute->setName('Old'); @@ -108,7 +112,8 @@ public function testUpdateAttributeDefinition(): void public function testUpdateThrowsWhenAnotherAttributeWithSameNameExists(): void { $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); - $manager = new AttributeDefinitionManager($repository); + $validator = $this->createMock(AttributeTypeValidator::class); + $manager = new AttributeDefinitionManager($repository, $validator); $dto = new AttributeDefinitionDto( name: 'Existing', @@ -138,7 +143,8 @@ public function testUpdateThrowsWhenAnotherAttributeWithSameNameExists(): void public function testDeleteAttributeDefinition(): void { $repository = $this->createMock(SubscriberAttributeDefinitionRepository::class); - $manager = new AttributeDefinitionManager($repository); + $validator = $this->createMock(AttributeTypeValidator::class); + $manager = new AttributeDefinitionManager($repository, $validator); $attribute = new SubscriberAttributeDefinition(); diff --git a/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php b/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php new file mode 100644 index 00000000..c0ab3a5a --- /dev/null +++ b/tests/Unit/Domain/Subscription/Validator/AttributeTypeValidatorTest.php @@ -0,0 +1,45 @@ +validator = new AttributeTypeValidator(); + } + + public function testValidatesValidType(): void + { + $this->validator->validate('textline'); + $this->validator->validate('checkbox'); + $this->validator->validate('date'); + + $this->assertTrue(true); + } + + public function testThrowsExceptionForInvalidType(): void + { + $this->expectException(ValidatorException::class); + $this->expectExceptionMessage('Invalid attribute type: "invalid_type"'); + + $this->validator->validate('invalid_type'); + } + + public function testThrowsExceptionForNonStringValue(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Value must be a string.'); + + $this->validator->validate(123); + } +} From 3418bd4dc36d37057482cd3ce9c1dd2a5947c8e1 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 16 Jun 2025 10:57:29 +0400 Subject: [PATCH 17/26] ISSUE-345: remove not used imports --- src/Domain/Identity/Service/AdministratorManager.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Domain/Identity/Service/AdministratorManager.php b/src/Domain/Identity/Service/AdministratorManager.php index 50df185f..82d3d36f 100644 --- a/src/Domain/Identity/Service/AdministratorManager.php +++ b/src/Domain/Identity/Service/AdministratorManager.php @@ -5,12 +5,9 @@ namespace PhpList\Core\Domain\Identity\Service; use Doctrine\ORM\EntityManagerInterface; -use InvalidArgumentException; 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\Domain\Identity\Model\PrivilegeFlag; -use PhpList\Core\Domain\Identity\Model\Privileges; use PhpList\Core\Security\HashGenerator; class AdministratorManager From 785bc082b1177cb779fcb63c0debaa455ea08786 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 16 Jun 2025 10:57:50 +0400 Subject: [PATCH 18/26] ISSUE-345: register repos --- config/services/repositories.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/config/services/repositories.yml b/config/services/repositories.yml index 44911f64..5e4f4473 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -65,3 +65,23 @@ services: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Configuration\Model\Config + + PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\UserMessageBounce + + PhpList\Core\Domain\Messaging\Repository\UserMessageForwardRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\UserMessageForward + + PhpList\Core\Domain\Analytics\Repository\LinkTrackRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Analytics\Model\LinkTrack + + PhpList\Core\Domain\Analytics\Repository\UserMessageViewRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Analytics\Model\UserMessageView From c75de08d8c26d2738fc93f1bc484f00e3ce34dc7 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 16 Jun 2025 12:37:29 +0400 Subject: [PATCH 19/26] ISSUE-345: fix dql --- .../Analytics/Repository/UserMessageViewRepository.php | 2 +- src/Domain/Messaging/Model/UserMessageBounce.php | 10 +++++----- src/Domain/Messaging/Model/UserMessageForward.php | 10 +++++----- .../Repository/UserMessageBounceRepository.php | 2 +- .../Repository/UserMessageForwardRepository.php | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Domain/Analytics/Repository/UserMessageViewRepository.php b/src/Domain/Analytics/Repository/UserMessageViewRepository.php index b5c8495b..5458ea01 100644 --- a/src/Domain/Analytics/Repository/UserMessageViewRepository.php +++ b/src/Domain/Analytics/Repository/UserMessageViewRepository.php @@ -16,7 +16,7 @@ public function countByMessageId(int $messageId): int { return (int) $this->createQueryBuilder('umv') ->select('COUNT(umv.id)') - ->where('umv.message_id = :messageId') + ->where('umv.messageId = :messageId') ->setParameter('messageId', $messageId) ->getQuery() ->getSingleScalarResult(); diff --git a/src/Domain/Messaging/Model/UserMessageBounce.php b/src/Domain/Messaging/Model/UserMessageBounce.php index 3469fe1b..c842867f 100644 --- a/src/Domain/Messaging/Model/UserMessageBounce.php +++ b/src/Domain/Messaging/Model/UserMessageBounce.php @@ -28,7 +28,7 @@ class UserMessageBounce implements DomainModel, Identity private int $user; #[ORM\Column(name: 'message', type: 'integer')] - private int $message; + private int $messageId; #[ORM\Column(name: 'bounce', type: 'integer')] private int $bounce; @@ -51,9 +51,9 @@ public function getUser(): int return $this->user; } - public function getMessage(): int + public function getMessageId(): int { - return $this->message; + return $this->messageId; } public function getBounce(): int @@ -72,9 +72,9 @@ public function setUser(int $user): self return $this; } - public function setMessage(int $message): self + public function setMessageId(int $messageId): self { - $this->message = $message; + $this->messageId = $messageId; return $this; } diff --git a/src/Domain/Messaging/Model/UserMessageForward.php b/src/Domain/Messaging/Model/UserMessageForward.php index 6dbbcc15..a9432e45 100644 --- a/src/Domain/Messaging/Model/UserMessageForward.php +++ b/src/Domain/Messaging/Model/UserMessageForward.php @@ -26,7 +26,7 @@ class UserMessageForward implements DomainModel, Identity private int $user; #[ORM\Column(name: 'message', type: 'integer')] - private int $message; + private int $messageId; #[ORM\Column(name: 'forward', type: 'string', length: 255, nullable: true)] private ?string $forward = null; @@ -52,9 +52,9 @@ public function getUser(): int return $this->user; } - public function getMessage(): int + public function getMessageId(): int { - return $this->message; + return $this->messageId; } public function getForward(): ?string @@ -78,9 +78,9 @@ public function setUser(int $user): self return $this; } - public function setMessage(int $message): self + public function setMessageId(int $messageId): self { - $this->message = $message; + $this->messageId = $messageId; return $this; } diff --git a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php index 5beb9b89..16f07f79 100644 --- a/src/Domain/Messaging/Repository/UserMessageBounceRepository.php +++ b/src/Domain/Messaging/Repository/UserMessageBounceRepository.php @@ -16,7 +16,7 @@ public function getCountByMessageId(int $messageId): int { return (int) $this->createQueryBuilder('umb') ->select('COUNT(umb.id)') - ->where('IDENTITY(umb.message) = :messageId') + ->where('umb.messageId = :messageId') ->setParameter('messageId', $messageId) ->getQuery() ->getSingleScalarResult(); diff --git a/src/Domain/Messaging/Repository/UserMessageForwardRepository.php b/src/Domain/Messaging/Repository/UserMessageForwardRepository.php index b0fa5e58..e2a48b46 100644 --- a/src/Domain/Messaging/Repository/UserMessageForwardRepository.php +++ b/src/Domain/Messaging/Repository/UserMessageForwardRepository.php @@ -16,7 +16,7 @@ public function getCountByMessageId(int $messageId): int { return (int) $this->createQueryBuilder('umf') ->select('COUNT(umf.id)') - ->where('IDENTITY(umf.message) = :messageId') + ->where('umf.messageId = :messageId') ->setParameter('messageId', $messageId) ->getQuery() ->getSingleScalarResult(); From a5d85bf4bb98c0d87977e80a1c327f5e02ae96e1 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 18 Jun 2025 23:09:34 +0400 Subject: [PATCH 20/26] ISSUE-345: add fos --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 7f56c174..2f898e3b 100644 --- a/composer.json +++ b/composer.json @@ -137,7 +137,8 @@ "Symfony\\Bundle\\TwigBundle\\TwigBundle", "Doctrine\\Bundle\\DoctrineBundle\\DoctrineBundle", "Doctrine\\Bundle\\MigrationsBundle\\DoctrineMigrationsBundle", - "PhpList\\Core\\EmptyStartPageBundle\\EmptyStartPageBundle" + "PhpList\\Core\\EmptyStartPageBundle\\EmptyStartPageBundle", + "FOS\\RestBundle\\FOSRestBundle" ], "routes": { "homepage": { From 28ff35977e8036a426e324dc82274c74ea0e10ee Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Fri, 20 Jun 2025 23:24:56 +0400 Subject: [PATCH 21/26] ISSUE-12: subscription confirmation email (#342) * ISSUE-12: subscription confirmation * ISSUE-12: subscription confirmation async * ISSUE-12: configs * ISSUE-12: configs + style --------- Co-authored-by: Tatevik --- config/packages/messenger.yaml | 1 + config/parameters.yml.dist | 8 +- config/services.yml | 5 -- config/services/managers.yml | 16 ---- config/services/messenger.yml | 12 +++ config/services/services.yml | 16 ++++ .../Message/SubscriberConfirmationMessage.php | 43 ++++++++++ .../SubscriberConfirmationMessageHandler.php | 67 +++++++++++++++ src/Domain/Messaging/Service/EmailService.php | 49 ----------- .../Service/Manager/SubscriberManager.php | 26 +++++- .../SubscriberConfirmationMessageTest.php | 34 ++++++++ ...bscriberConfirmationMessageHandlerTest.php | 86 +++++++++++++++++++ .../Service/SubscriberManagerTest.php | 80 +++++++++++++++-- 13 files changed, 364 insertions(+), 79 deletions(-) create mode 100644 config/services/messenger.yml create mode 100644 config/services/services.yml create mode 100644 src/Domain/Messaging/Message/SubscriberConfirmationMessage.php create mode 100644 src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php create mode 100644 tests/Unit/Domain/Messaging/Message/SubscriberConfirmationMessageTest.php create mode 100644 tests/Unit/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandlerTest.php diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 3ae2402d..6841eed5 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -24,3 +24,4 @@ framework: routing: # Route your messages to the transports 'PhpList\Core\Domain\Messaging\Message\AsyncEmailMessage': async_email + 'PhpList\Core\Domain\Messaging\Message\SubscriberConfirmationMessage': async_email diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index 988628dc..31e82ead 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -22,11 +22,17 @@ parameters: database_password: '%%env(PHPLIST_DATABASE_PASSWORD)%%' env(PHPLIST_DATABASE_PASSWORD): 'phplist' - # mailer configs + # Email configuration app.mailer_from: '%%env(MAILER_FROM)%%' env(MAILER_FROM): 'noreply@phplist.com' app.mailer_dsn: '%%env(MAILER_DSN)%%' env(MAILER_DSN): 'smtp://username:password@smtp.mailtrap.io:2525' + app.confirmation_url: '%%env(CONFIRMATION_URL)%%' + env(CONFIRMATION_URL): 'https://example.com/confirm/' + + # 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)%%' diff --git a/config/services.yml b/config/services.yml index e3329d6c..b83adce3 100644 --- a/config/services.yml +++ b/config/services.yml @@ -37,11 +37,6 @@ services: public: true tags: [controller.service_arguments] - # Register message handlers for Symfony Messenger - PhpList\Core\Domain\Messaging\MessageHandler\: - resource: '../src/Domain/Messaging/MessageHandler' - tags: ['messenger.message_handler'] - doctrine.orm.metadata.annotation_reader: alias: doctrine.annotation_reader diff --git a/config/services/managers.yml b/config/services/managers.yml index 41284f2c..e0f18d4e 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -52,22 +52,6 @@ services: autowire: true autoconfigure: true - PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter: - autowire: true - autoconfigure: true - public: true - - PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter: - autowire: true - autoconfigure: true - public: true - - PhpList\Core\Domain\Messaging\Service\EmailService: - autowire: true - autoconfigure: true - arguments: - $defaultFromEmail: '%app.mailer_from%' - PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: autowire: true autoconfigure: true diff --git a/config/services/messenger.yml b/config/services/messenger.yml new file mode 100644 index 00000000..7fcfae55 --- /dev/null +++ b/config/services/messenger.yml @@ -0,0 +1,12 @@ +services: + # Register message handlers for Symfony Messenger + PhpList\Core\Domain\Messaging\MessageHandler\: + resource: '../../src/Domain/Messaging/MessageHandler' + tags: [ 'messenger.message_handler' ] + + PhpList\Core\Domain\Messaging\MessageHandler\SubscriberConfirmationMessageHandler: + autowire: true + autoconfigure: true + tags: [ 'messenger.message_handler' ] + arguments: + $confirmationUrl: '%app.confirmation_url%' diff --git a/config/services/services.yml b/config/services/services.yml new file mode 100644 index 00000000..c9a1e43f --- /dev/null +++ b/config/services/services.yml @@ -0,0 +1,16 @@ +services: + PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter: + autowire: true + autoconfigure: true + public: true + + PhpList\Core\Domain\Subscription\Service\SubscriberCsvImporter: + autowire: true + autoconfigure: true + public: true + + PhpList\Core\Domain\Messaging\Service\EmailService: + autowire: true + autoconfigure: true + arguments: + $defaultFromEmail: '%app.mailer_from%' diff --git a/src/Domain/Messaging/Message/SubscriberConfirmationMessage.php b/src/Domain/Messaging/Message/SubscriberConfirmationMessage.php new file mode 100644 index 00000000..676d2e4b --- /dev/null +++ b/src/Domain/Messaging/Message/SubscriberConfirmationMessage.php @@ -0,0 +1,43 @@ +email = $email; + $this->uniqueId = $uniqueId; + $this->htmlEmail = $htmlEmail; + } + + public function getEmail(): string + { + return $this->email; + } + + public function getUniqueId(): string + { + return $this->uniqueId; + } + + public function hasHtmlEmail(): bool + { + return $this->htmlEmail; + } +} diff --git a/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php b/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php new file mode 100644 index 00000000..f81e8365 --- /dev/null +++ b/src/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandler.php @@ -0,0 +1,67 @@ +emailService = $emailService; + $this->confirmationUrl = $confirmationUrl; + } + + /** + * Process a subscriber confirmation message by sending the confirmation email + */ + public function __invoke(SubscriberConfirmationMessage $message): void + { + $confirmationLink = $this->generateConfirmationLink($message->getUniqueId()); + + $subject = 'Please confirm your subscription'; + $textContent = "Thank you for subscribing!\n\n" + . "Please confirm your subscription by clicking the link below:\n" + . $confirmationLink . "\n\n" + . 'If you did not request this subscription, please ignore this email.'; + + $htmlContent = ''; + if ($message->hasHtmlEmail()) { + $htmlContent = '

Thank you for subscribing!

' + . '

Please confirm your subscription by clicking the link below:

' + . '

Confirm Subscription

' + . '

If you did not request this subscription, please ignore this email.

'; + } + + $email = (new Email()) + ->to($message->getEmail()) + ->subject($subject) + ->text($textContent); + + if (!empty($htmlContent)) { + $email->html($htmlContent); + } + + $this->emailService->sendEmail($email); + } + + /** + * Generate a confirmation link for the subscriber + */ + private function generateConfirmationLink(string $uniqueId): string + { + return $this->confirmationUrl . $uniqueId; + } +} diff --git a/src/Domain/Messaging/Service/EmailService.php b/src/Domain/Messaging/Service/EmailService.php index c3c3fc30..86b17ec5 100644 --- a/src/Domain/Messaging/Service/EmailService.php +++ b/src/Domain/Messaging/Service/EmailService.php @@ -5,7 +5,6 @@ namespace PhpList\Core\Domain\Messaging\Service; use PhpList\Core\Domain\Messaging\Message\AsyncEmailMessage; -use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Mime\Email; @@ -27,16 +26,6 @@ public function __construct( $this->messageBus = $messageBus; } - /** - * Send a simple email asynchronously - * - * @param Email $email - * @param array $cc - * @param array $bcc - * @param array $replyTo - * @param array $attachments - * @return void - */ public function sendEmail( Email $email, array $cc = [], @@ -52,17 +41,6 @@ public function sendEmail( $this->messageBus->dispatch($message); } - /** - * Send a simple email synchronously - * - * @param Email $email - * @param array $cc - * @param array $bcc - * @param array $replyTo - * @param array $attachments - * @return void - * @throws TransportExceptionInterface - */ public function sendEmailSync( Email $email, array $cc = [], @@ -93,19 +71,6 @@ public function sendEmailSync( $this->mailer->send($email); } - /** - * Email multiple recipients asynchronously - * - * @param array $toAddresses Array of recipient email addresses - * @param string $subject Email subject - * @param string $text Plain text content - * @param string $html HTML content (optional) - * @param string|null $from Sender email address (optional, uses default if not provided) - * @param string|null $fromName Sender name (optional) - * @param array $attachments Array of file paths to attach (optional) - * - * @return void - */ public function sendBulkEmail( array $toAddresses, string $subject, @@ -132,20 +97,6 @@ public function sendBulkEmail( } } - /** - * Email multiple recipients synchronously - * - * @param array $toAddresses Array of recipient email addresses - * @param string $subject Email subject - * @param string $text Plain text content - * @param string $html HTML content (optional) - * @param string|null $from Sender email address (optional, uses default if not provided) - * @param string|null $fromName Sender name (optional) - * @param array $attachments Array of file paths to attach (optional) - * - * @return void - * @throws TransportExceptionInterface - */ public function sendBulkEmailSync( array $toAddresses, string $subject, diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php index 92273278..4c142b15 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php @@ -5,22 +5,29 @@ namespace PhpList\Core\Domain\Subscription\Service\Manager; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Messaging\Message\SubscriberConfirmationMessage; use PhpList\Core\Domain\Subscription\Model\Dto\CreateSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Dto\ImportSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Dto\UpdateSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Messenger\MessageBusInterface; class SubscriberManager { private SubscriberRepository $subscriberRepository; private EntityManagerInterface $entityManager; + private MessageBusInterface $messageBus; - public function __construct(SubscriberRepository $subscriberRepository, EntityManagerInterface $entityManager) - { + public function __construct( + SubscriberRepository $subscriberRepository, + EntityManagerInterface $entityManager, + MessageBusInterface $messageBus + ) { $this->subscriberRepository = $subscriberRepository; $this->entityManager = $entityManager; + $this->messageBus = $messageBus; } public function createSubscriber(CreateSubscriberDto $subscriberDto): Subscriber @@ -35,9 +42,24 @@ public function createSubscriber(CreateSubscriberDto $subscriberDto): Subscriber $this->subscriberRepository->save($subscriber); + if ($subscriberDto->requestConfirmation) { + $this->sendConfirmationEmail($subscriber); + } + return $subscriber; } + private function sendConfirmationEmail(Subscriber $subscriber): void + { + $message = new SubscriberConfirmationMessage( + email: $subscriber->getEmail(), + uniqueId:$subscriber->getUniqueId(), + htmlEmail: $subscriber->hasHtmlEmail() + ); + + $this->messageBus->dispatch($message); + } + public function getSubscriber(int $subscriberId): Subscriber { $subscriber = $this->subscriberRepository->findSubscriberWithSubscriptions($subscriberId); diff --git a/tests/Unit/Domain/Messaging/Message/SubscriberConfirmationMessageTest.php b/tests/Unit/Domain/Messaging/Message/SubscriberConfirmationMessageTest.php new file mode 100644 index 00000000..1af63bef --- /dev/null +++ b/tests/Unit/Domain/Messaging/Message/SubscriberConfirmationMessageTest.php @@ -0,0 +1,34 @@ +assertSame($email, $message->getEmail()); + $this->assertSame($uniqueId, $message->getUniqueId()); + $this->assertTrue($message->hasHtmlEmail()); + } + + public function testDefaultHtmlEmailIsFalse(): void + { + $email = 'test@example.com'; + $uniqueId = 'abc123'; + + $message = new SubscriberConfirmationMessage($email, $uniqueId); + + $this->assertFalse($message->hasHtmlEmail()); + } +} diff --git a/tests/Unit/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandlerTest.php b/tests/Unit/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandlerTest.php new file mode 100644 index 00000000..2700b12f --- /dev/null +++ b/tests/Unit/Domain/Messaging/MessageHandler/SubscriberConfirmationMessageHandlerTest.php @@ -0,0 +1,86 @@ +emailService = $this->createMock(EmailService::class); + $this->handler = new SubscriberConfirmationMessageHandler($this->emailService, $this->confirmationUrl); + } + + public function testInvokeWithTextEmail(): void + { + $subscriberEmail = 'subscriber@example.com'; + $uniqueId = 'abc123'; + $message = new SubscriberConfirmationMessage($subscriberEmail, $uniqueId, false); + + $this->emailService->expects($this->once()) + ->method('sendEmail') + ->with($this->callback(function (Email $email) use ($subscriberEmail, $uniqueId) { + $this->assertEquals([$subscriberEmail], $this->getEmailAddresses($email->getTo())); + $this->assertEquals('Please confirm your subscription', $email->getSubject()); + + $textContent = $email->getTextBody(); + $this->assertStringContainsString('Thank you for subscribing', $textContent); + $this->assertStringContainsString($this->confirmationUrl . $uniqueId, $textContent); + + $this->assertEmpty($email->getHtmlBody()); + + return true; + })); + + $this->handler->__invoke($message); + } + + public function testInvokeWithHtmlEmail(): void + { + $subscriberEmail = 'subscriber@example.com'; + $uniqueId = 'abc123'; + $message = new SubscriberConfirmationMessage($subscriberEmail, $uniqueId, true); + + $this->emailService->expects($this->once()) + ->method('sendEmail') + ->with($this->callback(function (Email $email) use ($subscriberEmail, $uniqueId) { + $this->assertEquals([$subscriberEmail], $this->getEmailAddresses($email->getTo())); + $this->assertEquals('Please confirm your subscription', $email->getSubject()); + + $textContent = $email->getTextBody(); + $this->assertStringContainsString('Thank you for subscribing', $textContent); + $this->assertStringContainsString($this->confirmationUrl . $uniqueId, $textContent); + + $htmlContent = $email->getHtmlBody(); + $this->assertStringContainsString('

Thank you for subscribing!

', $htmlContent); + $linkStart = ''; + $this->assertStringContainsString($linkStart, $htmlContent); + + return true; + })); + + $this->handler->__invoke($message); + } + + /** + * Helper method to extract email addresses from Address objects + */ + private function getEmailAddresses(array $addresses): array + { + return array_map(function ($address) { + return $address->getAddress(); + }, $addresses); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberManagerTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberManagerTest.php index ef233294..d5564434 100644 --- a/tests/Unit/Domain/Subscription/Service/SubscriberManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/SubscriberManagerTest.php @@ -5,22 +5,42 @@ namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Messaging\Message\SubscriberConfirmationMessage; use PhpList\Core\Domain\Subscription\Model\Dto\CreateSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\MessageBusInterface; class SubscriberManagerTest extends TestCase { + private SubscriberRepository&MockObject $subscriberRepository; + private MessageBusInterface&MockObject $messageBus; + private SubscriberManager $subscriberManager; + + protected function setUp(): void + { + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $this->messageBus = $this->createMock(MessageBusInterface::class); + + $this->subscriberManager = new SubscriberManager( + $this->subscriberRepository, + $entityManager, + $this->messageBus + ); + } + public function testCreateSubscriberPersistsAndReturnsProperlyInitializedEntity(): void { - $repoMock = $this->createMock(SubscriberRepository::class); - $emMock = $this->createMock(EntityManagerInterface::class); - $repoMock + $this->subscriberRepository ->expects($this->once()) ->method('save') ->with($this->callback(function (Subscriber $sub): bool { + $sub->setUniqueId('test-unique-id-456'); return $sub->getEmail() === 'foo@bar.com' && $sub->isConfirmed() === false && $sub->isBlacklisted() === false @@ -28,17 +48,65 @@ public function testCreateSubscriberPersistsAndReturnsProperlyInitializedEntity( && $sub->isDisabled() === false; })); - $manager = new SubscriberManager($repoMock, $emMock); + $this->messageBus + ->expects($this->once()) + ->method('dispatch') + ->willReturn(new Envelope(new SubscriberConfirmationMessage('foo@bar.com', 'test-unique-id-456'))); $dto = new CreateSubscriberDto(email: 'foo@bar.com', requestConfirmation: true, htmlEmail: true); - $result = $manager->createSubscriber($dto); + $result = $this->subscriberManager->createSubscriber($dto); - $this->assertInstanceOf(Subscriber::class, $result); $this->assertSame('foo@bar.com', $result->getEmail()); $this->assertFalse($result->isConfirmed()); $this->assertFalse($result->isBlacklisted()); $this->assertTrue($result->hasHtmlEmail()); $this->assertFalse($result->isDisabled()); } + + public function testCreateSubscriberWithConfirmationSendsConfirmationEmail(): void + { + $capturedSubscriber = null; + $this->subscriberRepository + ->expects($this->once()) + ->method('save') + ->with($this->callback(function (Subscriber $subscriber) use (&$capturedSubscriber) { + $capturedSubscriber = $subscriber; + $subscriber->setUniqueId('test-unique-id-123'); + return true; + })); + + $this->messageBus + ->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function (SubscriberConfirmationMessage $message) { + $this->assertEquals('test@example.com', $message->getEmail()); + $this->assertEquals('test-unique-id-123', $message->getUniqueId()); + $this->assertTrue($message->hasHtmlEmail()); + return true; + })) + ->willReturn(new Envelope(new SubscriberConfirmationMessage('foo@bar.com', 'test-unique-id-456'))); + + $dto = new CreateSubscriberDto(email: 'test@example.com', requestConfirmation: true, htmlEmail: true); + $this->subscriberManager->createSubscriber($dto); + + $this->assertNotNull($capturedSubscriber); + $this->assertEquals('test@example.com', $capturedSubscriber->getEmail()); + $this->assertTrue($capturedSubscriber->hasHtmlEmail()); + $this->assertFalse($capturedSubscriber->isConfirmed()); + } + + public function testCreateSubscriberWithoutConfirmationDoesNotSendConfirmationEmail(): void + { + $this->subscriberRepository + ->expects($this->once()) + ->method('save'); + + $this->messageBus + ->expects($this->never()) + ->method('dispatch'); + + $dto = new CreateSubscriberDto(email: 'test@example.com', requestConfirmation: false, htmlEmail: true); + $this->subscriberManager->createSubscriber($dto); + } } From 9a7c7e431cb6237492e19019ee1704b134ee6414 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Fri, 20 Jun 2025 23:43:10 +0400 Subject: [PATCH 22/26] ISSUE-122: delete leaving blacklisted (#343) * ISSUE-122: deleteLeavingBlacklist --------- Co-authored-by: Tatevik --- config/services/managers.yml | 5 + config/services/repositories.yml | 15 ++ .../Messaging/Model/UserMessageBounce.php | 13 +- .../Messaging/Model/UserMessageForward.php | 10 +- .../Service/Manager/SubscriberManager.php | 8 +- .../Service/SubscriberDeletionService.php | 77 +++++++ .../Service/SubscriberDeletionServiceTest.php | 149 +++++++++++++ .../Service/SubscriberDeletionServiceTest.php | 200 ++++++++++++++++++ .../Service/SubscriberManagerTest.php | 48 ++++- 9 files changed, 505 insertions(+), 20 deletions(-) create mode 100644 src/Domain/Subscription/Service/SubscriberDeletionService.php create mode 100644 tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php create mode 100644 tests/Unit/Domain/Subscription/Service/SubscriberDeletionServiceTest.php diff --git a/config/services/managers.yml b/config/services/managers.yml index e0f18d4e..edd3a30f 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -55,3 +55,8 @@ services: PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: autowire: true autoconfigure: true + + PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService: + autowire: true + autoconfigure: true + public: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml index 5e4f4473..af452145 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -85,3 +85,18 @@ services: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Analytics\Model\UserMessageView + + PhpList\Core\Domain\Analytics\Repository\LinkTrackUmlClickRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Analytics\Model\LinkTrackUmlClick + + PhpList\Core\Domain\Messaging\Repository\UserMessageRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Messaging\Model\UserMessage + + PhpList\Core\Domain\Subscription\Repository\SubscriberHistoryRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\SubscriberHistory diff --git a/src/Domain/Messaging/Model/UserMessageBounce.php b/src/Domain/Messaging/Model/UserMessageBounce.php index c842867f..ccb05597 100644 --- a/src/Domain/Messaging/Model/UserMessageBounce.php +++ b/src/Domain/Messaging/Model/UserMessageBounce.php @@ -25,7 +25,7 @@ class UserMessageBounce implements DomainModel, Identity private ?int $id = null; #[ORM\Column(name: 'user', type: 'integer')] - private int $user; + private int $userId; #[ORM\Column(name: 'message', type: 'integer')] private int $messageId; @@ -36,8 +36,9 @@ class UserMessageBounce implements DomainModel, Identity #[ORM\Column(name: 'time', type: 'datetime', options: ['default' => 'CURRENT_TIMESTAMP'])] private DateTime $createdAt; - public function __construct() + public function __construct(int $bounce) { + $this->bounce = $bounce; $this->createdAt = new DateTime(); } @@ -46,9 +47,9 @@ public function getId(): ?int return $this->id; } - public function getUser(): int + public function getUserId(): int { - return $this->user; + return $this->userId; } public function getMessageId(): int @@ -66,9 +67,9 @@ public function getCreatedAt(): DateTime return $this->createdAt; } - public function setUser(int $user): self + public function setUserId(int $userId): self { - $this->user = $user; + $this->userId = $userId; return $this; } diff --git a/src/Domain/Messaging/Model/UserMessageForward.php b/src/Domain/Messaging/Model/UserMessageForward.php index a9432e45..9b2a8ef4 100644 --- a/src/Domain/Messaging/Model/UserMessageForward.php +++ b/src/Domain/Messaging/Model/UserMessageForward.php @@ -23,7 +23,7 @@ class UserMessageForward implements DomainModel, Identity private ?int $id = null; #[ORM\Column(name: 'user', type: 'integer')] - private int $user; + private int $userId; #[ORM\Column(name: 'message', type: 'integer')] private int $messageId; @@ -47,9 +47,9 @@ public function getId(): ?int return $this->id; } - public function getUser(): int + public function getUserId(): int { - return $this->user; + return $this->userId; } public function getMessageId(): int @@ -72,9 +72,9 @@ public function getCreatedAt(): DateTime return $this->createdAt; } - public function setUser(int $user): self + public function setUserId(int $userId): self { - $this->user = $user; + $this->userId = $userId; return $this; } diff --git a/src/Domain/Subscription/Service/Manager/SubscriberManager.php b/src/Domain/Subscription/Service/Manager/SubscriberManager.php index 4c142b15..93420795 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriberManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriberManager.php @@ -11,6 +11,7 @@ use PhpList\Core\Domain\Subscription\Model\Dto\UpdateSubscriberDto; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; +use PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Messenger\MessageBusInterface; @@ -19,15 +20,18 @@ class SubscriberManager private SubscriberRepository $subscriberRepository; private EntityManagerInterface $entityManager; private MessageBusInterface $messageBus; + private SubscriberDeletionService $subscriberDeletionService; public function __construct( SubscriberRepository $subscriberRepository, EntityManagerInterface $entityManager, - MessageBusInterface $messageBus + MessageBusInterface $messageBus, + SubscriberDeletionService $subscriberDeletionService ) { $this->subscriberRepository = $subscriberRepository; $this->entityManager = $entityManager; $this->messageBus = $messageBus; + $this->subscriberDeletionService = $subscriberDeletionService; } public function createSubscriber(CreateSubscriberDto $subscriberDto): Subscriber @@ -90,7 +94,7 @@ public function updateSubscriber(UpdateSubscriberDto $subscriberDto): Subscriber public function deleteSubscriber(Subscriber $subscriber): void { - $this->subscriberRepository->remove($subscriber); + $this->subscriberDeletionService->deleteLeavingBlacklist($subscriber); } public function createFromImport(ImportSubscriberDto $subscriberDto): Subscriber diff --git a/src/Domain/Subscription/Service/SubscriberDeletionService.php b/src/Domain/Subscription/Service/SubscriberDeletionService.php new file mode 100644 index 00000000..9681a49d --- /dev/null +++ b/src/Domain/Subscription/Service/SubscriberDeletionService.php @@ -0,0 +1,77 @@ +linkTrackUmlClickRepo = $linkTrackUmlClickRepo; + $this->entityManager = $entityManager; + $this->userMessageRepo = $userMessageRepo; + $this->subscriberAttrValueRepo = $subscriberAttrValueRepo; + $this->subscriberHistoryRepo = $subscriberHistoryRepo; + $this->userMessageBounceRepo = $userMessageBounceRepo; + $this->userMessageForwardRepo = $userMessageForwardRepo; + $this->userMessageViewRepo = $userMessageViewRepo; + $this->subscriptionRepo = $subscriptionRepo; + } + + public function deleteLeavingBlacklist(Subscriber $subscriber): void + { + $this->removeEntities($this->linkTrackUmlClickRepo->findBy(['userId' => $subscriber->getId()])); + $this->removeEntities($this->subscriptionRepo->findBy(['subscriber' => $subscriber])); + $this->removeEntities($this->userMessageRepo->findBy(['user' => $subscriber])); + $this->removeEntities($this->subscriberAttrValueRepo->findBy(['subscriber' => $subscriber])); + $this->removeEntities($this->subscriberHistoryRepo->findBy(['subscriber' => $subscriber])); + $this->removeEntities($this->userMessageBounceRepo->findBy(['userId' => $subscriber->getId()])); + $this->removeEntities($this->userMessageForwardRepo->findBy(['userId' => $subscriber->getId()])); + $this->removeEntities($this->userMessageViewRepo->findBy(['userId' => $subscriber->getId()])); + + $this->entityManager->remove($subscriber); + } + + /** + * Remove a collection of entities + * + * @param array $entities + */ + private function removeEntities(array $entities): void + { + foreach ($entities as $entity) { + $this->entityManager->remove($entity); + } + } +} diff --git a/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php new file mode 100644 index 00000000..e6d42236 --- /dev/null +++ b/tests/Integration/Domain/Subscription/Service/SubscriberDeletionServiceTest.php @@ -0,0 +1,149 @@ +loadSchema(); + + $this->subscriberDeletionService = self::getContainer()->get(SubscriberDeletionService::class); + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); + } + + protected function tearDown(): void + { + $schemaTool = new SchemaTool($this->entityManager); + $schemaTool->dropDatabase(); + parent::tearDown(); + } + + public function testDeleteSubscriberWithRelatedDataDoesNotThrowDoctrineError(): void + { + $admin = new Administrator(); + $this->entityManager->persist($admin); + + $msg = new Message( + format: new MessageFormat(true, MessageFormat::FORMAT_TEXT), + schedule: new MessageSchedule(1, null, 3, null, null), + metadata: new MessageMetadata('done'), + content: new MessageContent('Owned by Admin 1!'), + options: new MessageOptions(), + owner: $admin + ); + $this->entityManager->persist($msg); + + $subscriber = new Subscriber(); + $subscriber->setEmail('test-delete@example.com'); + $subscriber->setConfirmed(true); + $subscriber->setHtmlEmail(true); + $subscriber->setBlacklisted(false); + $subscriber->setDisabled(false); + $this->entityManager->persist($subscriber); + $this->entityManager->flush(); + + $subscriberId = $subscriber->getId(); + $this->assertNotNull($subscriberId, 'Subscriber ID should not be null'); + + $subscriberList = new SubscriberList(); + $subscriberList->setDescription('Test List Description'); + $this->entityManager->persist($subscriberList); + + $subscription = new Subscription(); + $subscription->setSubscriber($subscriber); + $subscription->setSubscriberList($subscriberList); + $this->entityManager->persist($subscription); + + $linkTrackUmlClick = new LinkTrackUmlClick(); + $linkTrackUmlClick->setMessageId(1); + $linkTrackUmlClick->setUserId($subscriberId); + $this->entityManager->persist($linkTrackUmlClick); + + $userMessage = new UserMessage($subscriber, $msg); + $userMessage->setStatus('sent'); + $this->entityManager->persist($userMessage); + + $userMessageBounce = new UserMessageBounce(1); + $userMessageBounce->setUserId($subscriberId); + $userMessageBounce->setMessageId(1); + $this->entityManager->persist($userMessageBounce); + + $userMessageForward = new UserMessageForward(); + $userMessageForward->setUserId($subscriberId); + $userMessageForward->setMessageId(1); + $this->entityManager->persist($userMessageForward); + + $userMessageView = new UserMessageView(); + $userMessageView->setMessageId(1); + $userMessageView->setUserid($subscriberId); + $this->entityManager->persist($userMessageView); + + $this->entityManager->flush(); + + 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()); + } + + $deletedSubscriber = $this->entityManager->getRepository(Subscriber::class)->find($subscriberId); + $this->assertNull($deletedSubscriber, 'Subscriber should be deleted'); + + $subscriptionRepo = $this->entityManager->getRepository(Subscription::class); + $subscriptions = $subscriptionRepo->findBy(['subscriber' => $subscriber]); + $this->assertEmpty($subscriptions, 'Subscriptions should be deleted'); + + $linkTrackRepo = $this->entityManager->getRepository(LinkTrackUmlClick::class); + $linkTrackUmlClicks = $linkTrackRepo->findBy(['userId' => $subscriberId]); + $this->assertEmpty($linkTrackUmlClicks, 'LinkTrackUmlClicks should be deleted'); + + $userMessageRepo = $this->entityManager->getRepository(UserMessage::class); + $userMessages = $userMessageRepo->findBy(['user' => $subscriber]); + $this->assertEmpty($userMessages, 'UserMessages should be deleted'); + + $bounceRepo = $this->entityManager->getRepository(UserMessageBounce::class); + $userMessageBounces = $bounceRepo->findBy(['userId' => $subscriberId]); + $this->assertEmpty($userMessageBounces, 'UserMessageBounces should be deleted'); + + $forwardRepo = $this->entityManager->getRepository(UserMessageForward::class); + $userMessageForwards = $forwardRepo->findBy(['userId' => $subscriberId]); + $this->assertEmpty($userMessageForwards, 'UserMessageForwards should be deleted'); + + $viewRepo = $this->entityManager->getRepository(UserMessageView::class); + $userMessageViews = $viewRepo->findBy(['userId' => $subscriberId]); + $this->assertEmpty($userMessageViews, 'UserMessageViews should be deleted'); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberDeletionServiceTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberDeletionServiceTest.php new file mode 100644 index 00000000..1ba1d7d0 --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/SubscriberDeletionServiceTest.php @@ -0,0 +1,200 @@ +linkTrackUmlClickRepository = $this->createMock(LinkTrackUmlClickRepository::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->userMessageRepository = $this->createMock(UserMessageRepository::class); + $this->subscriberAttributeValueRepository = $this->createMock(SubscriberAttributeValueRepository::class); + $this->subscriberHistoryRepository = $this->createMock(SubscriberHistoryRepository::class); + $this->userMessageBounceRepository = $this->createMock(UserMessageBounceRepository::class); + $this->userMessageForwardRepository = $this->createMock(UserMessageForwardRepository::class); + $this->userMessageViewRepository = $this->createMock(UserMessageViewRepository::class); + $this->subscriptionRepository = $this->createMock(SubscriptionRepository::class); + + $this->service = new SubscriberDeletionService( + $this->linkTrackUmlClickRepository, + $this->entityManager, + $this->userMessageRepository, + $this->subscriberAttributeValueRepository, + $this->subscriberHistoryRepository, + $this->userMessageBounceRepository, + $this->userMessageForwardRepository, + $this->userMessageViewRepository, + $this->subscriptionRepository + ); + } + + public function testDeleteLeavingBlacklistRemovesAllRelatedData(): void + { + $subscriber = $this->createMock(Subscriber::class); + $subscriberId = 123; + $subscriber->method('getId')->willReturn($subscriberId); + + $subscription = $this->createMock(Subscription::class); + $this->subscriptionRepository + ->method('findBy') + ->with(['subscriber' => $subscriber]) + ->willReturn([$subscription]); + + $linkTrackUmlClick = $this->createMock(LinkTrackUmlClick::class); + $this->linkTrackUmlClickRepository + ->method('findBy') + ->with(['userId' => $subscriberId]) + ->willReturn([$linkTrackUmlClick]); + + $this->entityManager + ->expects($this->atLeast(1)) + ->method('remove'); + + $userMessage = $this->createMock(UserMessage::class); + $this->userMessageRepository + ->method('findBy') + ->with(['user' => $subscriber]) + ->willReturn([$userMessage]); + + $subscriberAttribute = $this->createMock(SubscriberAttributeValue::class); + $this->subscriberAttributeValueRepository + ->method('findBy') + ->with(['subscriber' => $subscriber]) + ->willReturn([$subscriberAttribute]); + + $subscriberHistory = $this->createMock(SubscriberHistory::class); + $this->subscriberHistoryRepository + ->method('findBy') + ->with(['subscriber' => $subscriber]) + ->willReturn([$subscriberHistory]); + + $userMessageBounce = $this->createMock(UserMessageBounce::class); + $this->userMessageBounceRepository + ->method('findBy') + ->with(['userId' => $subscriberId]) + ->willReturn([$userMessageBounce]); + + $userMessageForward = $this->createMock(UserMessageForward::class); + $this->userMessageForwardRepository + ->method('findBy') + ->with(['userId' => $subscriberId]) + ->willReturn([$userMessageForward]); + + $userMessageView = $this->createMock(UserMessageView::class); + $this->userMessageViewRepository + ->method('findBy') + ->with(['userId' => $subscriberId]) + ->willReturn([$userMessageView]); + + $this->service->deleteLeavingBlacklist($subscriber); + } + + public function testDeleteLeavingBlacklistHandlesEmptyRelatedData(): void + { + $subscriber = $this->createMock(Subscriber::class); + $subscriberId = 123; + $subscriber->method('getId')->willReturn($subscriberId); + + $this->subscriptionRepository + ->method('findBy') + ->with(['subscriber' => $subscriber]) + ->willReturn([]); + + $this->linkTrackUmlClickRepository + ->method('findBy') + ->with(['userId' => $subscriberId]) + ->willReturn([]); + + $this->userMessageRepository + ->method('findBy') + ->with(['user' => $subscriber]) + ->willReturn([]); + $this->userMessageRepository + ->expects($this->never()) + ->method('remove'); + + $this->subscriberAttributeValueRepository + ->method('findBy') + ->with(['subscriber' => $subscriber]) + ->willReturn([]); + $this->subscriberAttributeValueRepository + ->expects($this->never()) + ->method('remove'); + + $this->subscriberHistoryRepository + ->method('findBy') + ->with(['subscriber' => $subscriber]) + ->willReturn([]); + $this->subscriberHistoryRepository + ->expects($this->never()) + ->method('remove'); + + $this->userMessageBounceRepository + ->method('findBy') + ->with(['userId' => $subscriberId]) + ->willReturn([]); + $this->userMessageBounceRepository + ->expects($this->never()) + ->method('remove'); + + $this->userMessageForwardRepository + ->method('findBy') + ->with(['userId' => $subscriberId]) + ->willReturn([]); + $this->userMessageForwardRepository + ->expects($this->never()) + ->method('remove'); + + $this->userMessageViewRepository + ->method('findBy') + ->with(['userId' => $subscriberId]) + ->willReturn([]); + $this->userMessageViewRepository + ->expects($this->never()) + ->method('remove'); + + $this->entityManager + ->expects($this->once()) + ->method('remove') + ->with($subscriber); + + $this->service->deleteLeavingBlacklist($subscriber); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberManagerTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberManagerTest.php index d5564434..7d246b7d 100644 --- a/tests/Unit/Domain/Subscription/Service/SubscriberManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/SubscriberManagerTest.php @@ -11,30 +11,60 @@ use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager; use PHPUnit\Framework\MockObject\MockObject; +use PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService; use PHPUnit\Framework\TestCase; use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\MessageBusInterface; class SubscriberManagerTest extends TestCase { - private SubscriberRepository&MockObject $subscriberRepository; - private MessageBusInterface&MockObject $messageBus; + private SubscriberRepository|MockObject $subscriberRepository; + private EntityManagerInterface|MockObject $entityManager; + private MessageBusInterface|MockObject $messageBus; + private SubscriberDeletionService|MockObject $subscriberDeletionService; private SubscriberManager $subscriberManager; protected function setUp(): void { $this->subscriberRepository = $this->createMock(SubscriberRepository::class); - $entityManager = $this->createMock(EntityManagerInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); $this->messageBus = $this->createMock(MessageBusInterface::class); + $this->subscriberDeletionService = $this->createMock(SubscriberDeletionService::class); $this->subscriberManager = new SubscriberManager( $this->subscriberRepository, - $entityManager, - $this->messageBus + $this->entityManager, + $this->messageBus, + $this->subscriberDeletionService ); } public function testCreateSubscriberPersistsAndReturnsProperlyInitializedEntity(): void + { + $this->subscriberRepository + ->expects($this->once()) + ->method('save') + ->with($this->callback(function (Subscriber $sub): bool { + return $sub->getEmail() === 'foo@bar.com' + && $sub->isConfirmed() === true + && $sub->isBlacklisted() === false + && $sub->hasHtmlEmail() === true + && $sub->isDisabled() === false; + })); + + $dto = new CreateSubscriberDto(email: 'foo@bar.com', requestConfirmation: false, htmlEmail: true); + + $result = $this->subscriberManager->createSubscriber($dto); + + $this->assertInstanceOf(Subscriber::class, $result); + $this->assertSame('foo@bar.com', $result->getEmail()); + $this->assertTrue($result->isConfirmed()); + $this->assertFalse($result->isBlacklisted()); + $this->assertTrue($result->hasHtmlEmail()); + $this->assertFalse($result->isDisabled()); + } + + public function testCreateSubscriberPersistsAndSendsEmail(): void { $this->subscriberRepository ->expects($this->once()) @@ -51,7 +81,9 @@ public function testCreateSubscriberPersistsAndReturnsProperlyInitializedEntity( $this->messageBus ->expects($this->once()) ->method('dispatch') - ->willReturn(new Envelope(new SubscriberConfirmationMessage('foo@bar.com', 'test-unique-id-456'))); + ->willReturnCallback(function ($message) { + return new Envelope($message); + }); $dto = new CreateSubscriberDto(email: 'foo@bar.com', requestConfirmation: true, htmlEmail: true); @@ -85,7 +117,9 @@ public function testCreateSubscriberWithConfirmationSendsConfirmationEmail(): vo $this->assertTrue($message->hasHtmlEmail()); return true; })) - ->willReturn(new Envelope(new SubscriberConfirmationMessage('foo@bar.com', 'test-unique-id-456'))); + ->willReturnCallback(function ($message) { + return new Envelope($message); + }); $dto = new CreateSubscriberDto(email: 'test@example.com', requestConfirmation: true, htmlEmail: true); $this->subscriberManager->createSubscriber($dto); From d21e99d5ec8a73bf4187fedfe73e62ac73dd34a7 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sat, 21 Jun 2025 00:42:32 +0400 Subject: [PATCH 23/26] ISSUE-72: block auth for disabled --- src/Domain/Identity/Service/SessionManager.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Domain/Identity/Service/SessionManager.php b/src/Domain/Identity/Service/SessionManager.php index 966eecff..52daafa3 100644 --- a/src/Domain/Identity/Service/SessionManager.php +++ b/src/Domain/Identity/Service/SessionManager.php @@ -29,6 +29,10 @@ public function createSession(string $loginName, string $password): Administrato throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567098); } + if ($administrator->isDisabled()) { + throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567099); + } + $token = new AdministratorToken(); $token->setAdministrator($administrator); $token->generateExpiry(); From 7ca4f82fa383462719f72636ff070d0d853048f2 Mon Sep 17 00:00:00 2001 From: TatevikGr Date: Mon, 23 Jun 2025 22:29:56 +0400 Subject: [PATCH 24/26] Process/send queued campaigns (#344) Co-authored-by: Tatevik --- composer.json | 3 +- config/services/managers.yml | 5 - config/services/providers.yml | 4 + config/services/repositories.yml | 5 + config/services/services.yml | 10 + .../Messaging/Command/ProcessQueueCommand.php | 106 ++++++ src/Domain/Messaging/Model/Message.php | 6 + .../Repository/ListMessageRepository.php | 11 + .../Repository/MessageRepository.php | 12 + .../Service/MessageProcessingPreparator.php | 55 ++++ .../Repository/SubscriberRepository.php | 12 + .../Service/Provider/SubscriberProvider.php | 45 +++ .../Command/ProcessQueueCommandTest.php | 305 ++++++++++++++++++ .../MessageProcessingPreparatorTest.php | 126 ++++++++ .../Provider/SubscriberProviderTest.php | 141 ++++++++ 15 files changed, 840 insertions(+), 6 deletions(-) create mode 100644 config/services/providers.yml create mode 100644 src/Domain/Messaging/Command/ProcessQueueCommand.php create mode 100644 src/Domain/Messaging/Service/MessageProcessingPreparator.php create mode 100644 src/Domain/Subscription/Service/Provider/SubscriberProvider.php create mode 100644 tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php create mode 100644 tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php create mode 100644 tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php diff --git a/composer.json b/composer.json index 2f898e3b..35b16fe8 100644 --- a/composer.json +++ b/composer.json @@ -66,7 +66,8 @@ "symfony/mailchimp-mailer": "^6.4", "symfony/sendgrid-mailer": "^6.4", "symfony/twig-bundle": "^6.4", - "symfony/messenger": "^6.4" + "symfony/messenger": "^6.4", + "symfony/lock": "^6.4" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/config/services/managers.yml b/config/services/managers.yml index edd3a30f..e0f18d4e 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -55,8 +55,3 @@ services: PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: autowire: true autoconfigure: true - - PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService: - autowire: true - autoconfigure: true - public: true diff --git a/config/services/providers.yml b/config/services/providers.yml new file mode 100644 index 00000000..226c4e81 --- /dev/null +++ b/config/services/providers.yml @@ -0,0 +1,4 @@ +services: + PhpList\Core\Domain\Subscription\Service\Provider\SubscriberProvider: + autowire: true + autoconfigure: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml index af452145..05662fed 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -100,3 +100,8 @@ services: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: - PhpList\Core\Domain\Subscription\Model\SubscriberHistory + + PhpList\Core\Domain\Messaging\Repository\ListMessageRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Subscription\Model\ListMessage diff --git a/config/services/services.yml b/config/services/services.yml index c9a1e43f..7120b035 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -14,3 +14,13 @@ services: autoconfigure: true arguments: $defaultFromEmail: '%app.mailer_from%' + + PhpList\Core\Domain\Subscription\Service\SubscriberDeletionService: + autowire: true + autoconfigure: true + public: true + + PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator: + autowire: true + autoconfigure: true + public: true diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php new file mode 100644 index 00000000..9f5a1aff --- /dev/null +++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php @@ -0,0 +1,106 @@ +messageRepository = $messageRepository; + $this->mailer = $mailer; + $this->lockFactory = $lockFactory; + $this->entityManager = $entityManager; + $this->subscriberProvider = $subscriberProvider; + $this->messageProcessingPreparator = $messageProcessingPreparator; + } + + /** + * @SuppressWarnings("PHPMD.UnusedFormalParameter") + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $lock = $this->lockFactory->createLock('queue_processor'); + if (!$lock->acquire()) { + $output->writeln('Queue is already being processed by another instance.'); + + return Command::FAILURE; + } + + try { + $this->messageProcessingPreparator->ensureSubscribersHaveUuid($output); + $this->messageProcessingPreparator->ensureCampaignsHaveUuid($output); + + $campaigns = $this->messageRepository->findBy(['status' => 'submitted']); + + foreach ($campaigns as $campaign) { + $this->processCampaign($campaign, $output); + } + } finally { + $lock->release(); + } + + return Command::SUCCESS; + } + + private function processCampaign(Message $campaign, OutputInterface $output): void + { + $subscribers = $this->subscriberProvider->getSubscribersForMessage($campaign); + // todo: check $ISPrestrictions logic + foreach ($subscribers as $subscriber) { + if (!filter_var($subscriber->getEmail(), FILTER_VALIDATE_EMAIL)) { + continue; + } + + $email = (new Email()) + ->from('news@example.com') + ->to($subscriber->getEmail()) + ->subject($campaign->getContent()->getSubject()) + ->text($campaign->getContent()->getTextMessage()) + ->html($campaign->getContent()->getText()); + + try { + $this->mailer->send($email); + + // todo: log somewhere that this subscriber got email + } catch (Throwable $e) { + $output->writeln('Failed to send to: ' . $subscriber->getEmail()); + } + + usleep(100000); + } + + $campaign->getMetadata()->setStatus('sent'); + $this->entityManager->flush(); + } +} diff --git a/src/Domain/Messaging/Model/Message.php b/src/Domain/Messaging/Model/Message.php index 4031a1ad..46acffeb 100644 --- a/src/Domain/Messaging/Model/Message.php +++ b/src/Domain/Messaging/Model/Message.php @@ -125,6 +125,12 @@ public function getUuid(): ?string return $this->uuid; } + public function setUuid(string $uuid): self + { + $this->uuid = $uuid; + return $this; + } + public function getOwner(): ?Administrator { return $this->owner; diff --git a/src/Domain/Messaging/Repository/ListMessageRepository.php b/src/Domain/Messaging/Repository/ListMessageRepository.php index 555f5fe6..bdfd4f83 100644 --- a/src/Domain/Messaging/Repository/ListMessageRepository.php +++ b/src/Domain/Messaging/Repository/ListMessageRepository.php @@ -11,4 +11,15 @@ class ListMessageRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + /** @return int[] */ + public function getListIdsByMessageId(int $messageId): array + { + return $this->createQueryBuilder('lm') + ->select('IDENTITY(lm.list)') + ->where('lm.messageId = :messageId') + ->setParameter('messageId', $messageId) + ->getQuery() + ->getSingleColumnResult(); + } } diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php index 674db8a0..1cc65df6 100644 --- a/src/Domain/Messaging/Repository/MessageRepository.php +++ b/src/Domain/Messaging/Repository/MessageRepository.php @@ -12,6 +12,18 @@ class MessageRepository extends AbstractRepository implements PaginatableRepositoryInterface { + /** + * @return Message[] + */ + public function findCampaignsWithoutUuid(): array + { + return $this->createQueryBuilder('m') + ->where('m.uuid IS NULL OR m.uuid = :emptyString') + ->setParameter('emptyString', '') + ->getQuery() + ->getResult(); + } + public function getByOwnerId(int $ownerId): array { return $this->createQueryBuilder('m') diff --git a/src/Domain/Messaging/Service/MessageProcessingPreparator.php b/src/Domain/Messaging/Service/MessageProcessingPreparator.php new file mode 100644 index 00000000..f687fae0 --- /dev/null +++ b/src/Domain/Messaging/Service/MessageProcessingPreparator.php @@ -0,0 +1,55 @@ +entityManager = $entityManager; + $this->subscriberRepository = $subscriberRepository; + $this->messageRepository = $messageRepository; + } + + public function ensureSubscribersHaveUuid(OutputInterface $output): void + { + $subscribersWithoutUuid = $this->subscriberRepository->findSubscribersWithoutUuid(); + + $numSubscribers = count($subscribersWithoutUuid); + if ($numSubscribers > 0) { + $output->writeln(sprintf('Giving a UUID to %d subscribers, this may take a while', $numSubscribers)); + foreach ($subscribersWithoutUuid as $subscriber) { + $subscriber->setUniqueId(bin2hex(random_bytes(16))); + } + $this->entityManager->flush(); + } + } + + public function ensureCampaignsHaveUuid(OutputInterface $output): void + { + $campaignsWithoutUuid = $this->messageRepository->findCampaignsWithoutUuid(); + + $numCampaigns = count($campaignsWithoutUuid); + if ($numCampaigns > 0) { + $output->writeln(sprintf('Giving a UUID to %d campaigns', $numCampaigns)); + foreach ($campaignsWithoutUuid as $campaign) { + $campaign->setUuid(bin2hex(random_bytes(18))); + } + $this->entityManager->flush(); + } + } +} diff --git a/src/Domain/Subscription/Repository/SubscriberRepository.php b/src/Domain/Subscription/Repository/SubscriberRepository.php index 8da29cf8..ade837f6 100644 --- a/src/Domain/Subscription/Repository/SubscriberRepository.php +++ b/src/Domain/Subscription/Repository/SubscriberRepository.php @@ -19,6 +19,18 @@ */ class SubscriberRepository extends AbstractRepository implements PaginatableRepositoryInterface { + /** + * @return Subscriber[] + */ + public function findSubscribersWithoutUuid(): array + { + return $this->createQueryBuilder('s') + ->where('s.uniqueId IS NULL OR s.uniqueId = :emptyString') + ->setParameter('emptyString', '') + ->getQuery() + ->getResult(); + } + public function findOneByEmail(string $email): ?Subscriber { return $this->findOneBy(['email' => $email]); diff --git a/src/Domain/Subscription/Service/Provider/SubscriberProvider.php b/src/Domain/Subscription/Service/Provider/SubscriberProvider.php new file mode 100644 index 00000000..0fdd2f1c --- /dev/null +++ b/src/Domain/Subscription/Service/Provider/SubscriberProvider.php @@ -0,0 +1,45 @@ +listMessageRepository = $listMessageRepository; + $this->subscriberRepository = $subscriberRepository; + } + + /** + * Get subscribers for a message + * + * @param Message $message The message to get subscribers for + * @return Subscriber[] Array of subscribers + */ + public function getSubscribersForMessage(Message $message): array + { + $listIds = $this->listMessageRepository->getListIdsByMessageId($message->getId()); + + $subscribers = []; + foreach ($listIds as $listId) { + $listSubscribers = $this->subscriberRepository->getSubscribersBySubscribedListId($listId); + foreach ($listSubscribers as $subscriber) { + $subscribers[$subscriber->getId()] = $subscriber; + } + } + + return array_values($subscribers); + } +} diff --git a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php new file mode 100644 index 00000000..9cca10db --- /dev/null +++ b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php @@ -0,0 +1,305 @@ +messageRepository = $this->createMock(MessageRepository::class); + $this->mailer = $this->createMock(MailerInterface::class); + $lockFactory = $this->createMock(LockFactory::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->subscriberProvider = $this->createMock(SubscriberProvider::class); + $this->messageProcessingPreparator = $this->createMock(MessageProcessingPreparator::class); + $this->lock = $this->createMock(LockInterface::class); + + $lockFactory->method('createLock') + ->with('queue_processor') + ->willReturn($this->lock); + + $command = new ProcessQueueCommand( + $this->messageRepository, + $this->mailer, + $lockFactory, + $this->entityManager, + $this->subscriberProvider, + $this->messageProcessingPreparator + ); + + $application = new Application(); + $application->add($command); + + $this->commandTester = new CommandTester($command); + } + + public function testExecuteWithLockAlreadyAcquired(): void + { + $this->lock->expects($this->once()) + ->method('acquire') + ->willReturn(false); + + $this->messageProcessingPreparator->expects($this->never()) + ->method('ensureSubscribersHaveUuid'); + + $this->commandTester->execute([]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Queue is already being processed by another instance', $output); + $this->assertEquals(1, $this->commandTester->getStatusCode()); + } + + public function testExecuteWithNoCampaigns(): void + { + $this->lock->expects($this->once()) + ->method('acquire') + ->willReturn(true); + + $this->lock->expects($this->once()) + ->method('release'); + + $this->messageProcessingPreparator->expects($this->once()) + ->method('ensureSubscribersHaveUuid'); + + $this->messageProcessingPreparator->expects($this->once()) + ->method('ensureCampaignsHaveUuid'); + + $this->messageRepository->expects($this->once()) + ->method('findBy') + ->with(['status' => 'submitted']) + ->willReturn([]); + + $this->commandTester->execute([]); + + $this->assertEquals(0, $this->commandTester->getStatusCode()); + } + + public function testExecuteWithCampaigns(): void + { + $this->lock->expects($this->once()) + ->method('acquire') + ->willReturn(true); + + $this->lock->expects($this->once()) + ->method('release'); + + $this->messageProcessingPreparator->expects($this->once()) + ->method('ensureSubscribersHaveUuid'); + + $this->messageProcessingPreparator->expects($this->once()) + ->method('ensureCampaignsHaveUuid'); + + $campaign = $this->createMock(Message::class); + $metadata = $this->createMock(MessageMetadata::class); + $content = $this->createMock(MessageContent::class); + + $campaign->expects($this->any()) + ->method('getMetadata') + ->willReturn($metadata); + + $campaign->expects($this->any()) + ->method('getContent') + ->willReturn($content); + + $content->expects($this->any()) + ->method('getSubject') + ->willReturn('Test Subject'); + + $content->expects($this->any()) + ->method('getTextMessage') + ->willReturn('Test Text Message'); + + $content->expects($this->any()) + ->method('getText') + ->willReturn('

Test HTML Message

'); + + $metadata->expects($this->once()) + ->method('setStatus') + ->with('sent'); + + $subscriber = $this->createMock(Subscriber::class); + $subscriber->expects($this->any()) + ->method('getEmail') + ->willReturn('test@example.com'); + + $this->messageRepository->expects($this->once()) + ->method('findBy') + ->with(['status' => 'submitted']) + ->willReturn([$campaign]); + + $this->subscriberProvider->expects($this->once()) + ->method('getSubscribersForMessage') + ->with($campaign) + ->willReturn([$subscriber]); + + $this->mailer->expects($this->once()) + ->method('send') + ->with($this->callback(function (Email $email) { + $this->assertEquals('Test Subject', $email->getSubject()); + $this->assertEquals('Test Text Message', $email->getTextBody()); + $this->assertEquals('

Test HTML Message

', $email->getHtmlBody()); + + $toAddresses = $email->getTo(); + $this->assertCount(1, $toAddresses); + $this->assertEquals('test@example.com', $toAddresses[0]->getAddress()); + + $fromAddresses = $email->getFrom(); + $this->assertCount(1, $fromAddresses); + $this->assertEquals('news@example.com', $fromAddresses[0]->getAddress()); + + return true; + })); + + $this->entityManager->expects($this->once()) + ->method('flush'); + + $this->commandTester->execute([]); + + $this->assertEquals(0, $this->commandTester->getStatusCode()); + } + + public function testExecuteWithInvalidSubscriberEmail(): void + { + $this->lock->expects($this->once()) + ->method('acquire') + ->willReturn(true); + + $this->lock->expects($this->once()) + ->method('release'); + + $campaign = $this->createMock(Message::class); + $metadata = $this->createMock(MessageMetadata::class); + $content = $this->createMock(MessageContent::class); + + $campaign->expects($this->any()) + ->method('getMetadata') + ->willReturn($metadata); + + $campaign->expects($this->any()) + ->method('getContent') + ->willReturn($content); + + $metadata->expects($this->once()) + ->method('setStatus') + ->with('sent'); + + $invalidSubscriber = $this->createMock(Subscriber::class); + $invalidSubscriber->expects($this->any()) + ->method('getEmail') + ->willReturn('invalid-email'); + + $this->messageRepository->expects($this->once()) + ->method('findBy') + ->with(['status' => 'submitted']) + ->willReturn([$campaign]); + + $this->subscriberProvider->expects($this->once()) + ->method('getSubscribersForMessage') + ->with($campaign) + ->willReturn([$invalidSubscriber]); + + $this->mailer->expects($this->never()) + ->method('send'); + + $this->entityManager->expects($this->once()) + ->method('flush'); + + $this->commandTester->execute([]); + + $this->assertEquals(0, $this->commandTester->getStatusCode()); + } + + public function testExecuteWithMailerException(): void + { + $this->lock->expects($this->once()) + ->method('acquire') + ->willReturn(true); + + $this->lock->expects($this->once()) + ->method('release'); + + $campaign = $this->createMock(Message::class); + $metadata = $this->createMock(MessageMetadata::class); + $content = $this->createMock(MessageContent::class); + + $campaign->expects($this->any()) + ->method('getMetadata') + ->willReturn($metadata); + + $campaign->expects($this->any()) + ->method('getContent') + ->willReturn($content); + + $content->expects($this->any()) + ->method('getSubject') + ->willReturn('Test Subject'); + + $content->expects($this->any()) + ->method('getTextMessage') + ->willReturn('Test Text Message'); + + $content->expects($this->any()) + ->method('getText') + ->willReturn('

Test HTML Message

'); + + $metadata->expects($this->once()) + ->method('setStatus') + ->with('sent'); + + $subscriber = $this->createMock(Subscriber::class); + $subscriber->expects($this->any()) + ->method('getEmail') + ->willReturn('test@example.com'); + + $this->messageRepository->expects($this->once()) + ->method('findBy') + ->with(['status' => 'submitted']) + ->willReturn([$campaign]); + + $this->subscriberProvider->expects($this->once()) + ->method('getSubscribersForMessage') + ->with($campaign) + ->willReturn([$subscriber]); + + $this->mailer->expects($this->once()) + ->method('send') + ->willThrowException(new \Exception('Failed to send email')); + + $this->entityManager->expects($this->once()) + ->method('flush'); + + $this->commandTester->execute([]); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Failed to send to: test@example.com', $output); + $this->assertEquals(0, $this->commandTester->getStatusCode()); + } +} diff --git a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php new file mode 100644 index 00000000..d76c653e --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php @@ -0,0 +1,126 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + $this->messageRepository = $this->createMock(MessageRepository::class); + $this->output = $this->createMock(OutputInterface::class); + + $this->preparator = new MessageProcessingPreparator( + $this->entityManager, + $this->subscriberRepository, + $this->messageRepository + ); + } + + public function testEnsureSubscribersHaveUuidWithNoSubscribers(): void + { + $this->subscriberRepository->expects($this->once()) + ->method('findSubscribersWithoutUuid') + ->willReturn([]); + + $this->output->expects($this->never()) + ->method('writeln'); + + $this->entityManager->expects($this->never()) + ->method('flush'); + + $this->preparator->ensureSubscribersHaveUuid($this->output); + } + + public function testEnsureSubscribersHaveUuidWithSubscribers(): void + { + $subscriber1 = $this->createMock(Subscriber::class); + $subscriber2 = $this->createMock(Subscriber::class); + + $subscribers = [$subscriber1, $subscriber2]; + + $this->subscriberRepository->expects($this->once()) + ->method('findSubscribersWithoutUuid') + ->willReturn($subscribers); + + $this->output->expects($this->once()) + ->method('writeln') + ->with($this->stringContains('Giving a UUID to 2 subscribers')); + + $subscriber1->expects($this->once()) + ->method('setUniqueId') + ->with($this->isType('string')); + + $subscriber2->expects($this->once()) + ->method('setUniqueId') + ->with($this->isType('string')); + + $this->entityManager->expects($this->once()) + ->method('flush'); + + $this->preparator->ensureSubscribersHaveUuid($this->output); + } + + public function testEnsureCampaignsHaveUuidWithNoCampaigns(): void + { + $this->messageRepository->expects($this->once()) + ->method('findCampaignsWithoutUuid') + ->willReturn([]); + + $this->output->expects($this->never()) + ->method('writeln'); + + $this->entityManager->expects($this->never()) + ->method('flush'); + + $this->preparator->ensureCampaignsHaveUuid($this->output); + } + + public function testEnsureCampaignsHaveUuidWithCampaigns(): void + { + $campaign1 = $this->createMock(Message::class); + $campaign2 = $this->createMock(Message::class); + + $campaigns = [$campaign1, $campaign2]; + + $this->messageRepository->expects($this->once()) + ->method('findCampaignsWithoutUuid') + ->willReturn($campaigns); + + $this->output->expects($this->once()) + ->method('writeln') + ->with($this->stringContains('Giving a UUID to 2 campaigns')); + + $campaign1->expects($this->once()) + ->method('setUuid') + ->with($this->isType('string')); + + $campaign2->expects($this->once()) + ->method('setUuid') + ->with($this->isType('string')); + + $this->entityManager->expects($this->once()) + ->method('flush'); + + $this->preparator->ensureCampaignsHaveUuid($this->output); + } +} diff --git a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php new file mode 100644 index 00000000..fafc80be --- /dev/null +++ b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php @@ -0,0 +1,141 @@ +listMessageRepository = $this->createMock(ListMessageRepository::class); + $this->subscriberRepository = $this->createMock(SubscriberRepository::class); + + $this->subscriberProvider = new SubscriberProvider( + $this->listMessageRepository, + $this->subscriberRepository + ); + } + + public function testGetSubscribersForMessageWithNoListsReturnsEmptyArray(): void + { + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn(123); + + $this->listMessageRepository + ->expects($this->once()) + ->method('getListIdsByMessageId') + ->with(123) + ->willReturn([]); + + $this->subscriberRepository + ->expects($this->never()) + ->method('getSubscribersBySubscribedListId'); + + $result = $this->subscriberProvider->getSubscribersForMessage($message); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testGetSubscribersForMessageWithOneListButNoSubscribersReturnsEmptyArray(): void + { + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn(123); + + $this->listMessageRepository + ->expects($this->once()) + ->method('getListIdsByMessageId') + ->with(123) + ->willReturn([456]); + + $this->subscriberRepository + ->expects($this->once()) + ->method('getSubscribersBySubscribedListId') + ->with(456) + ->willReturn([]); + + $result = $this->subscriberProvider->getSubscribersForMessage($message); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testGetSubscribersForMessageWithOneListAndSubscribersReturnsSubscribers(): void + { + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn(123); + + $subscriber1 = $this->createMock(Subscriber::class); + $subscriber1->method('getId')->willReturn(1); + $subscriber2 = $this->createMock(Subscriber::class); + $subscriber2->method('getId')->willReturn(2); + + $this->listMessageRepository + ->expects($this->once()) + ->method('getListIdsByMessageId') + ->with(123) + ->willReturn([456]); + + $this->subscriberRepository + ->expects($this->once()) + ->method('getSubscribersBySubscribedListId') + ->with(456) + ->willReturn([$subscriber1, $subscriber2]); + + $result = $this->subscriberProvider->getSubscribersForMessage($message); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertSame($subscriber1, $result[0]); + $this->assertSame($subscriber2, $result[1]); + } + + public function testGetSubscribersForMessageWithMultipleListsAndOverlappingSubscribersReturnsUniqueSubscribers(): void + { + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn(123); + + $subscriber1 = $this->createMock(Subscriber::class); + $subscriber1->method('getId')->willReturn(1); + $subscriber2 = $this->createMock(Subscriber::class); + $subscriber2->method('getId')->willReturn(2); + $subscriber3 = $this->createMock(Subscriber::class); + $subscriber3->method('getId')->willReturn(3); + + $this->listMessageRepository + ->expects($this->once()) + ->method('getListIdsByMessageId') + ->with(123) + ->willReturn([456, 789]); + + $this->subscriberRepository + ->expects($this->exactly(2)) + ->method('getSubscribersBySubscribedListId') + ->willReturnMap([ + [456, [$subscriber1, $subscriber2]], + [789, [$subscriber2, $subscriber3]], + ]); + + $result = $this->subscriberProvider->getSubscribersForMessage($message); + + $this->assertIsArray($result); + $this->assertCount(3, $result); + + $this->assertContains($subscriber1, $result); + $this->assertContains($subscriber2, $result); + $this->assertContains($subscriber3, $result); + } +} From c08d1950911d1b34b02897ed8e558e2f1f174758 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 25 Jun 2025 19:01:51 +0400 Subject: [PATCH 25/26] Add logs (+ graylog option) --- CHANGELOG.md | 1 + composer.json | 1 + config/config_dev.yml | 8 ++ config/config_prod.yml | 8 ++ config/services/parameters.yml | 2 + docs/Graylog.md | 81 +++++++++++++++++++ .../Messaging/Command/ProcessQueueCommand.php | 6 +- .../Service/SubscriberCsvExporter.php | 57 ++++++++++++- .../Provider/SubscriberProviderTest.php | 4 +- .../SubscriberCsvExportManagerTest.php | 4 +- .../Service/SubscriberDeletionServiceTest.php | 2 +- 11 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 docs/Graylog.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a5b10ab..0254484d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## x.y.z (next release) ### Added +- Graylog integration for centralized logging (#TBD) ### Changed diff --git a/composer.json b/composer.json index 35b16fe8..be974681 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "symfony/error-handler": "^6.4", "symfony/serializer": "^6.4", "symfony/monolog-bundle": "^3.10", + "graylog2/gelf-php": "^2.0", "symfony/serializer-pack": "^1.3", "symfony/orm-pack": "^2.4", "doctrine/orm": "^3.3", diff --git a/config/config_dev.yml b/config/config_dev.yml index 2b30e97b..c6cbdfd8 100644 --- a/config/config_dev.yml +++ b/config/config_dev.yml @@ -14,6 +14,14 @@ monolog: path: '%kernel.logs_dir%/%kernel.environment%.log' level: debug channels: ['!event'] + # Graylog handler disabled for development environment - using local logging only + # graylog: + # type: gelf + # publisher: + # hostname: '%app.config.graylog_host%' + # port: '%app.config.graylog_port%' + # level: debug + # channels: ['!event'] console: type: console process_psr_3_messages: false diff --git a/config/config_prod.yml b/config/config_prod.yml index cae2c76c..03e9ffc1 100644 --- a/config/config_prod.yml +++ b/config/config_prod.yml @@ -3,6 +3,14 @@ imports: monolog: handlers: + # Primary handler for production is Graylog + graylog: + type: gelf + publisher: + hostname: '%app.config.graylog_host%' + port: '%app.config.graylog_port%' + level: error + # Local file logging as backup main: type: fingers_crossed action_level: error diff --git a/config/services/parameters.yml b/config/services/parameters.yml index ebf1d99b..ddb3f961 100644 --- a/config/services/parameters.yml +++ b/config/services/parameters.yml @@ -9,3 +9,5 @@ parameters: notify_end_default: 'end@example.com' always_add_google_tracking: true click_track: true + graylog_host: 'graylog.example.com' + graylog_port: 12201 diff --git a/docs/Graylog.md b/docs/Graylog.md new file mode 100644 index 00000000..0abbcb57 --- /dev/null +++ b/docs/Graylog.md @@ -0,0 +1,81 @@ +# Graylog Integration + +This document explains how to use the Graylog integration in the phpList core application. + +## Overview + +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`: + +```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'] +``` + +Replace `graylog.example.com` with the hostname or IP address of your Graylog server, and update the port if necessary. + +## Graylog Server Setup + +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: + - 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 + +To test if logs are being sent to Graylog: + +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 + +## Troubleshooting + +If logs are not 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 diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php index 9f5a1aff..9b4bc0af 100644 --- a/src/Domain/Messaging/Command/ProcessQueueCommand.php +++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php @@ -76,7 +76,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int private function processCampaign(Message $campaign, OutputInterface $output): void { $subscribers = $this->subscriberProvider->getSubscribersForMessage($campaign); - // todo: check $ISPrestrictions logic + // phpcs:ignore Generic.Commenting.Todo + // @todo check $ISPrestrictions logic foreach ($subscribers as $subscriber) { if (!filter_var($subscriber->getEmail(), FILTER_VALIDATE_EMAIL)) { continue; @@ -92,7 +93,8 @@ private function processCampaign(Message $campaign, OutputInterface $output): vo try { $this->mailer->send($email); - // todo: log somewhere that this subscriber got email + // phpcs:ignore Generic.Commenting.Todo + // @todo log somewhere that this subscriber got email } catch (Throwable $e) { $output->writeln('Failed to send to: ' . $subscriber->getEmail()); } diff --git a/src/Domain/Subscription/Service/SubscriberCsvExporter.php b/src/Domain/Subscription/Service/SubscriberCsvExporter.php index 33aebb38..687e6f22 100644 --- a/src/Domain/Subscription/Service/SubscriberCsvExporter.php +++ b/src/Domain/Subscription/Service/SubscriberCsvExporter.php @@ -10,6 +10,7 @@ use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberAttributeManager; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; @@ -22,15 +23,18 @@ class SubscriberCsvExporter private SubscriberAttributeManager $attributeManager; private SubscriberRepository $subscriberRepository; private SubscriberAttributeDefinitionRepository $definitionRepository; + private LoggerInterface $logger; public function __construct( SubscriberAttributeManager $attributeManager, SubscriberRepository $subscriberRepository, - SubscriberAttributeDefinitionRepository $definitionRepository + SubscriberAttributeDefinitionRepository $definitionRepository, + LoggerInterface $logger ) { $this->attributeManager = $attributeManager; $this->subscriberRepository = $subscriberRepository; $this->definitionRepository = $definitionRepository; + $this->logger = $logger; } /** @@ -42,16 +46,30 @@ public function __construct( */ public function exportToCsv(?SubscriberFilter $filter = null, int $batchSize = 1000): Response { + $this->logger->info('Starting subscriber CSV export', [ + 'batch_size' => $batchSize, + 'filter' => $filter ? get_class($filter) : 'null' + ]); + if ($filter === null) { $filter = new SubscriberFilter(); + $this->logger->debug('No filter provided, using default filter'); } $tempFilePath = tempnam(sys_get_temp_dir(), 'subscribers_export_'); + $this->logger->debug('Created temporary file for export', ['path' => $tempFilePath]); + $this->generateCsvContent($filter, $batchSize, $tempFilePath, $filter->getColumns()); $response = new BinaryFileResponse($tempFilePath); + $response = $this->configureResponse($response); + + $this->logger->info('Subscriber CSV export completed', [ + 'file_size' => filesize($tempFilePath), + 'temp_file' => $tempFilePath + ]); - return $this->configureResponse($response); + return $response; } /** @@ -121,22 +139,55 @@ private function exportSubscribers( array $headers ): void { $lastId = 0; + $totalExported = 0; + $batchNumber = 0; + + $this->logger->debug('Starting batch export of subscribers', [ + 'batch_size' => $batchSize, + 'attribute_definitions_count' => count($attributeDefinitions), + 'headers_count' => count($headers) + ]); do { + $batchNumber++; + $this->logger->debug('Processing subscriber batch', [ + 'batch_number' => $batchNumber, + 'last_id' => $lastId, + 'batch_size' => $batchSize + ]); + $subscribers = $this->subscriberRepository->getFilteredAfterId( lastId: $lastId, limit: $batchSize, filter: $filter ); + $subscriberCount = count($subscribers); + $this->logger->debug('Retrieved subscribers for batch', [ + 'batch_number' => $batchNumber, + 'count' => $subscriberCount + ]); + foreach ($subscribers as $subscriber) { $row = $this->getSubscriberRow($subscriber, $attributeDefinitions, $headers); fputcsv($handle, $row); $lastId = $subscriber->getId(); } - $subscriberCount = count($subscribers); + $totalExported += $subscriberCount; + + $this->logger->debug('Completed batch processing', [ + 'batch_number' => $batchNumber, + 'processed_in_batch' => $subscriberCount, + 'total_exported' => $totalExported, + 'last_id' => $lastId + ]); } while ($subscriberCount === $batchSize); + + $this->logger->info('Completed exporting all subscribers', [ + 'total_batches' => $batchNumber, + 'total_subscribers' => $totalExported + ]); } /** diff --git a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php index fafc80be..73430c97 100644 --- a/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php +++ b/tests/Unit/Domain/Subscription/Service/Provider/SubscriberProviderTest.php @@ -103,7 +103,7 @@ public function testGetSubscribersForMessageWithOneListAndSubscribersReturnsSubs $this->assertSame($subscriber2, $result[1]); } - public function testGetSubscribersForMessageWithMultipleListsAndOverlappingSubscribersReturnsUniqueSubscribers(): void + public function testGetSubscribersForMessageWithMultipleListsReturnsUniqueSubscribers(): void { $message = $this->createMock(Message::class); $message->method('getId')->willReturn(123); @@ -133,7 +133,7 @@ public function testGetSubscribersForMessageWithMultipleListsAndOverlappingSubsc $this->assertIsArray($result); $this->assertCount(3, $result); - + $this->assertContains($subscriber1, $result); $this->assertContains($subscriber2, $result); $this->assertContains($subscriber3, $result); diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php index c3aeef68..046ae625 100644 --- a/tests/Unit/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/SubscriberCsvExportManagerTest.php @@ -14,6 +14,7 @@ use PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Response; class SubscriberCsvExportManagerTest extends TestCase @@ -32,7 +33,8 @@ protected function setUp(): void $this->subject = new SubscriberCsvExporter( $this->attributeManagerMock, $this->subscriberRepositoryMock, - $this->attributeDefinitionRepositoryMock + $this->attributeDefinitionRepositoryMock, + $this->createMock(LoggerInterface::class) ); } diff --git a/tests/Unit/Domain/Subscription/Service/SubscriberDeletionServiceTest.php b/tests/Unit/Domain/Subscription/Service/SubscriberDeletionServiceTest.php index 1ba1d7d0..27bd85db 100644 --- a/tests/Unit/Domain/Subscription/Service/SubscriberDeletionServiceTest.php +++ b/tests/Unit/Domain/Subscription/Service/SubscriberDeletionServiceTest.php @@ -61,7 +61,7 @@ protected function setUp(): void $this->userMessageBounceRepository, $this->userMessageForwardRepository, $this->userMessageViewRepository, - $this->subscriptionRepository + $this->subscriptionRepository, ); } From e48b5bd703cd3a0af6ac0c9b9d71149740d618fe Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 26 Jun 2025 22:40:03 +0400 Subject: [PATCH 26/26] Link track service --- config/parameters.yml.dist | 3 + config/services/parameters.yml | 2 - config/services/services.yml | 5 + .../Repository/LinkTrackRepository.php | 9 + .../Analytics/Service/LinkTrackService.php | 98 ++++++++ .../Messaging/Command/ProcessQueueCommand.php | 16 +- .../Service/MessageProcessingPreparator.php | 57 ++++- .../Service/LinkTrackServiceTest.php | 229 ++++++++++++++++++ .../Command/ProcessQueueCommandTest.php | 7 + .../MessageProcessingPreparatorTest.php | 90 ++++++- 10 files changed, 502 insertions(+), 14 deletions(-) create mode 100644 src/Domain/Analytics/Service/LinkTrackService.php create mode 100644 tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php diff --git a/config/parameters.yml.dist b/config/parameters.yml.dist index 31e82ead..4f9379ec 100644 --- a/config/parameters.yml.dist +++ b/config/parameters.yml.dist @@ -37,3 +37,6 @@ parameters: # A secret key that's used to generate certain security-related tokens secret: '%%env(PHPLIST_SECRET)%%' env(PHPLIST_SECRET): %1$s + + graylog_host: 'graylog.example.com' + graylog_port: 12201 diff --git a/config/services/parameters.yml b/config/services/parameters.yml index ddb3f961..ebf1d99b 100644 --- a/config/services/parameters.yml +++ b/config/services/parameters.yml @@ -9,5 +9,3 @@ parameters: notify_end_default: 'end@example.com' always_add_google_tracking: true click_track: true - graylog_host: 'graylog.example.com' - graylog_port: 12201 diff --git a/config/services/services.yml b/config/services/services.yml index 7120b035..47d12f98 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -24,3 +24,8 @@ services: autowire: true autoconfigure: true public: true + + PhpList\Core\Domain\Analytics\Service\LinkTrackService: + autowire: true + autoconfigure: true + public: true diff --git a/src/Domain/Analytics/Repository/LinkTrackRepository.php b/src/Domain/Analytics/Repository/LinkTrackRepository.php index 5f9bde56..c1766b70 100644 --- a/src/Domain/Analytics/Repository/LinkTrackRepository.php +++ b/src/Domain/Analytics/Repository/LinkTrackRepository.php @@ -31,4 +31,13 @@ public function getByMessageId(int $messageId, int $lastId, ?int $limit = null): return $query->getQuery()->getResult(); } + + public function findByUrlUserIdAndMessageId(string $url, int $userId, int $messageId): ?LinkTrack + { + return $this->findOneBy([ + 'url' => $url, + 'userId' => $userId, + 'messageId' => $messageId, + ]); + } } diff --git a/src/Domain/Analytics/Service/LinkTrackService.php b/src/Domain/Analytics/Service/LinkTrackService.php new file mode 100644 index 00000000..b84a9bc6 --- /dev/null +++ b/src/Domain/Analytics/Service/LinkTrackService.php @@ -0,0 +1,98 @@ +linkTrackRepository = $linkTrackRepository; + $this->configProvider = $configProvider; + } + + public function getUrlById(int $id): ?string + { + $linkTrack = $this->linkTrackRepository->find($id); + return $linkTrack?->getUrl(); + } + + public function isExtractAndSaveLinksApplicable(): bool + { + return (bool)$this->configProvider->get('click_track', false); + } + + /** + * Extract links from message content and save them to the LinkTrackRepository + * + * @return LinkTrack[] The saved LinkTrack entities + * @throws InvalidArgumentException if the message doesn't have an ID + */ + public function extractAndSaveLinks(Message $message, int $userId): array + { + if (!$this->isExtractAndSaveLinksApplicable()) { + return []; + } + + $content = $message->getContent(); + $messageId = $message->getId(); + + if ($messageId === null) { + throw new InvalidArgumentException('Message must have an ID'); + } + + $links = $this->extractLinksFromHtml($content->getText() ?? ''); + + if ($content->getFooter() !== null) { + $links = array_merge($links, $this->extractLinksFromHtml($content->getFooter())); + } + + $links = array_unique($links); + + $savedLinks = []; + + foreach ($links as $url) { + $existingLinkTrack = $this->linkTrackRepository->findByUrlUserIdAndMessageId($url, $userId, $messageId); + if ($existingLinkTrack !== null) { + $savedLinks[] = $existingLinkTrack; + continue; + } + $linkTrack = new LinkTrack(); + $linkTrack->setMessageId($messageId); + $linkTrack->setUserId($userId); + $linkTrack->setUrl($url); + + $this->linkTrackRepository->save($linkTrack); + $savedLinks[] = $linkTrack; + } + + return $savedLinks; + } + + /** + * Extract links from HTML content + * + * @return string[] The extracted links + */ + private function extractLinksFromHtml(string $html): array + { + $links = []; + + $pattern = '/]*href=(["\'])([^"\']+)\1[^>]*>/i'; + if (preg_match_all($pattern, $html, $matches)) { + $links = $matches[2]; + } + + return $links; + } +} diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php index 9b4bc0af..a237fd9f 100644 --- a/src/Domain/Messaging/Command/ProcessQueueCommand.php +++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php @@ -26,15 +26,15 @@ class ProcessQueueCommand extends Command private LockFactory $lockFactory; private EntityManagerInterface $entityManager; private SubscriberProvider $subscriberProvider; - private MessageProcessingPreparator $messageProcessingPreparator; + private MessageProcessingPreparator $messagePreparator; public function __construct( MessageRepository $messageRepository, - MailerInterface $mailer, - LockFactory $lockFactory, + MailerInterface $mailer, + LockFactory $lockFactory, EntityManagerInterface $entityManager, SubscriberProvider $subscriberProvider, - MessageProcessingPreparator $messageProcessingPreparator + MessageProcessingPreparator $messagePreparator ) { parent::__construct(); $this->messageRepository = $messageRepository; @@ -42,7 +42,7 @@ public function __construct( $this->lockFactory = $lockFactory; $this->entityManager = $entityManager; $this->subscriberProvider = $subscriberProvider; - $this->messageProcessingPreparator = $messageProcessingPreparator; + $this->messagePreparator = $messagePreparator; } /** @@ -58,8 +58,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int } try { - $this->messageProcessingPreparator->ensureSubscribersHaveUuid($output); - $this->messageProcessingPreparator->ensureCampaignsHaveUuid($output); + $this->messagePreparator->ensureSubscribersHaveUuid($output); + $this->messagePreparator->ensureCampaignsHaveUuid($output); $campaigns = $this->messageRepository->findBy(['status' => 'submitted']); @@ -82,7 +82,7 @@ private function processCampaign(Message $campaign, OutputInterface $output): vo if (!filter_var($subscriber->getEmail(), FILTER_VALIDATE_EMAIL)) { continue; } - + $this->messagePreparator->processMessageLinks($campaign, $subscriber->getId()); $email = (new Email()) ->from('news@example.com') ->to($subscriber->getEmail()) diff --git a/src/Domain/Messaging/Service/MessageProcessingPreparator.php b/src/Domain/Messaging/Service/MessageProcessingPreparator.php index f687fae0..c602f7d4 100644 --- a/src/Domain/Messaging/Service/MessageProcessingPreparator.php +++ b/src/Domain/Messaging/Service/MessageProcessingPreparator.php @@ -5,30 +5,38 @@ namespace PhpList\Core\Domain\Messaging\Service; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Analytics\Service\LinkTrackService; +use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use Symfony\Component\Console\Output\OutputInterface; class MessageProcessingPreparator { + // phpcs:ignore Generic.Commenting.Todo + // @todo: create functionality to track + public const LINT_TRACK_ENDPOINT = '/api/v2/link-track'; private EntityManagerInterface $entityManager; private SubscriberRepository $subscriberRepository; private MessageRepository $messageRepository; + private LinkTrackService $linkTrackService; public function __construct( EntityManagerInterface $entityManager, SubscriberRepository $subscriberRepository, - MessageRepository $messageRepository + MessageRepository $messageRepository, + LinkTrackService $linkTrackService ) { $this->entityManager = $entityManager; $this->subscriberRepository = $subscriberRepository; $this->messageRepository = $messageRepository; + $this->linkTrackService = $linkTrackService; } public function ensureSubscribersHaveUuid(OutputInterface $output): void { $subscribersWithoutUuid = $this->subscriberRepository->findSubscribersWithoutUuid(); - + $numSubscribers = count($subscribersWithoutUuid); if ($numSubscribers > 0) { $output->writeln(sprintf('Giving a UUID to %d subscribers, this may take a while', $numSubscribers)); @@ -42,7 +50,7 @@ public function ensureSubscribersHaveUuid(OutputInterface $output): void public function ensureCampaignsHaveUuid(OutputInterface $output): void { $campaignsWithoutUuid = $this->messageRepository->findCampaignsWithoutUuid(); - + $numCampaigns = count($campaignsWithoutUuid); if ($numCampaigns > 0) { $output->writeln(sprintf('Giving a UUID to %d campaigns', $numCampaigns)); @@ -52,4 +60,47 @@ public function ensureCampaignsHaveUuid(OutputInterface $output): void $this->entityManager->flush(); } } + + /** + * Process message content to extract URLs and replace them with link track URLs + */ + public function processMessageLinks(Message $message, int $userId): Message + { + if (!$this->linkTrackService->isExtractAndSaveLinksApplicable()) { + return $message; + } + + $savedLinks = $this->linkTrackService->extractAndSaveLinks($message, $userId); + + if (empty($savedLinks)) { + return $message; + } + + $content = $message->getContent(); + $htmlText = $content->getText(); + $footer = $content->getFooter(); + + if ($htmlText !== null) { + $htmlText = $this->replaceLinks($savedLinks, $htmlText); + $content->setText($htmlText); + } + + if ($footer !== null) { + $footer = $this->replaceLinks($savedLinks, $footer); + $content->setFooter($footer); + } + + return $message; + } + + private function replaceLinks(array $savedLinks, string $htmlText): string + { + foreach ($savedLinks as $linkTrack) { + $originalUrl = $linkTrack->getUrl(); + $trackUrl = '/' . self::LINT_TRACK_ENDPOINT . '?id=' . $linkTrack->getId(); + $htmlText = str_replace('href="' . $originalUrl . '"', 'href="' . $trackUrl . '"', $htmlText); + } + + return $htmlText; + } } diff --git a/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php new file mode 100644 index 00000000..613e2c1f --- /dev/null +++ b/tests/Unit/Domain/Analytics/Service/LinkTrackServiceTest.php @@ -0,0 +1,229 @@ +linkTrackRepository = $this->createMock(LinkTrackRepository::class); + $configProvider = $this->createMock(ConfigProvider::class); + + $configProvider->method('get') + ->with('click_track', false) + ->willReturn(true); + + $this->subject = new LinkTrackService($this->linkTrackRepository, $configProvider); + } + + public function testExtractAndSaveLinksWithNoLinks(): void + { + $messageId = 123; + $userId = 456; + + $messageContent = new MessageContent('Test Subject', 'No links here'); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn($messageId); + $message->method('getContent')->willReturn($messageContent); + + $this->linkTrackRepository->expects(self::never())->method('save'); + + $result = $this->subject->extractAndSaveLinks($message, $userId); + + self::assertEmpty($result); + } + + public function testExtractAndSaveLinksWithLinks(): void + { + $messageId = 123; + $userId = 456; + $htmlContent = '

Check out this link and ' + . 'this one.

'; + + $messageContent = new MessageContent('Test Subject', $htmlContent); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn($messageId); + $message->method('getContent')->willReturn($messageContent); + + $this->linkTrackRepository->expects(self::exactly(2)) + ->method('save') + ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) { + self::assertSame($messageId, $linkTrack->getMessageId()); + self::assertSame($userId, $linkTrack->getUserId()); + self::assertContains($linkTrack->getUrl(), ['https://example.com', 'https://test.com']); + return null; + }); + + $result = $this->subject->extractAndSaveLinks($message, $userId); + + self::assertCount(2, $result); + self::assertSame('https://example.com', $result[0]->getUrl()); + self::assertSame('https://test.com', $result[1]->getUrl()); + } + + public function testExtractAndSaveLinksWithFooter(): void + { + $messageId = 123; + $userId = 456; + $htmlContent = '

Main content with a link.

'; + $footerContent = '

Footer with another link.

'; + + $messageContent = new MessageContent('Test Subject', $htmlContent, null, $footerContent); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn($messageId); + $message->method('getContent')->willReturn($messageContent); + + $this->linkTrackRepository->expects(self::exactly(2)) + ->method('save') + ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) { + self::assertSame($messageId, $linkTrack->getMessageId()); + self::assertSame($userId, $linkTrack->getUserId()); + self::assertContains($linkTrack->getUrl(), ['https://example.com', 'https://footer.com']); + return null; + }); + + $result = $this->subject->extractAndSaveLinks($message, $userId); + + self::assertCount(2, $result); + self::assertSame('https://example.com', $result[0]->getUrl()); + self::assertSame('https://footer.com', $result[1]->getUrl()); + } + + public function testExtractAndSaveLinksWithDuplicateLinks(): void + { + $messageId = 123; + $userId = 456; + $htmlContent = '

Link 1 and Link 2.

'; + + $messageContent = new MessageContent('Test Subject', $htmlContent); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn($messageId); + $message->method('getContent')->willReturn($messageContent); + + $this->linkTrackRepository->expects(self::once()) + ->method('save') + ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) { + self::assertSame($messageId, $linkTrack->getMessageId()); + self::assertSame($userId, $linkTrack->getUserId()); + self::assertSame('https://example.com', $linkTrack->getUrl()); + return null; + }); + + $result = $this->subject->extractAndSaveLinks($message, $userId); + + self::assertCount(1, $result); + self::assertSame('https://example.com', $result[0]->getUrl()); + } + + public function testExtractAndSaveLinksWithNullText(): void + { + $messageId = 123; + $userId = 456; + $footerContent = '

Footer with a link.

'; + + $messageContent = new MessageContent('Test Subject', null, null, $footerContent); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn($messageId); + $message->method('getContent')->willReturn($messageContent); + + $this->linkTrackRepository->expects(self::once()) + ->method('save') + ->willReturnCallback(function (LinkTrack $linkTrack) use ($messageId, $userId) { + self::assertSame($messageId, $linkTrack->getMessageId()); + self::assertSame($userId, $linkTrack->getUserId()); + self::assertSame('https://footer.com', $linkTrack->getUrl()); + return null; + }); + + $result = $this->subject->extractAndSaveLinks($message, $userId); + + self::assertCount(1, $result); + self::assertSame('https://footer.com', $result[0]->getUrl()); + } + + public function testExtractAndSaveLinksWithMessageWithoutId(): void + { + $userId = 456; + $htmlContent = '

Link

'; + + $messageContent = new MessageContent('Test Subject', $htmlContent); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn(null); + $message->method('getContent')->willReturn($messageContent); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Message must have an ID'); + + $this->subject->extractAndSaveLinks($message, $userId); + } + + public function testIsExtractAndSaveLinksApplicableWhenClickTrackIsTrue(): void + { + self::assertTrue($this->subject->isExtractAndSaveLinksApplicable()); + } + + public function testIsExtractAndSaveLinksApplicableWhenClickTrackIsFalse(): void + { + $configProvider = $this->createMock(ConfigProvider::class); + $configProvider->method('get') + ->with('click_track', false) + ->willReturn(false); + + $subject = new LinkTrackService($this->linkTrackRepository, $configProvider); + + self::assertFalse($subject->isExtractAndSaveLinksApplicable()); + } + + public function testExtractAndSaveLinksWithExistingLink(): void + { + $messageId = 123; + $userId = 456; + $url = 'https://example.com'; + $htmlContent = '

Check out this link.

'; + + $messageContent = new MessageContent('Test Subject', $htmlContent); + + $message = $this->createMock(Message::class); + $message->method('getId')->willReturn($messageId); + $message->method('getContent')->willReturn($messageContent); + + $existingLinkTrack = new LinkTrack(); + $existingLinkTrack->setMessageId($messageId); + $existingLinkTrack->setUserId($userId); + $existingLinkTrack->setUrl($url); + + $this->linkTrackRepository->expects(self::once()) + ->method('findByUrlUserIdAndMessageId') + ->with($url, $userId, $messageId) + ->willReturn($existingLinkTrack); + + $this->linkTrackRepository->expects(self::never()) + ->method('save'); + + $result = $this->subject->extractAndSaveLinks($message, $userId); + + self::assertCount(1, $result); + self::assertSame($existingLinkTrack, $result[0]); + } +} diff --git a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php index 9cca10db..c310bd40 100644 --- a/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php +++ b/tests/Unit/Domain/Messaging/Command/ProcessQueueCommandTest.php @@ -149,6 +149,10 @@ public function testExecuteWithCampaigns(): void $subscriber->expects($this->any()) ->method('getEmail') ->willReturn('test@example.com'); + $subscriber->expects($this->any()) + ->method('getId') + ->willReturn(1); + $this->messageRepository->expects($this->once()) ->method('findBy') @@ -278,6 +282,9 @@ public function testExecuteWithMailerException(): void $subscriber->expects($this->any()) ->method('getEmail') ->willReturn('test@example.com'); + $subscriber->expects($this->any()) + ->method('getId') + ->willReturn(1); $this->messageRepository->expects($this->once()) ->method('findBy') diff --git a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php index d76c653e..c2c0d0a5 100644 --- a/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php +++ b/tests/Unit/Domain/Messaging/Service/MessageProcessingPreparatorTest.php @@ -5,6 +5,9 @@ namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Analytics\Model\LinkTrack; +use PhpList\Core\Domain\Analytics\Service\LinkTrackService; +use PhpList\Core\Domain\Messaging\Model\Message\MessageContent; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; use PhpList\Core\Domain\Subscription\Model\Subscriber; @@ -19,6 +22,7 @@ class MessageProcessingPreparatorTest extends TestCase private EntityManagerInterface&MockObject $entityManager; private SubscriberRepository&MockObject $subscriberRepository; private MessageRepository&MockObject $messageRepository; + private LinkTrackService&MockObject $linkTrackService; private OutputInterface&MockObject $output; private MessageProcessingPreparator $preparator; @@ -27,12 +31,14 @@ protected function setUp(): void $this->entityManager = $this->createMock(EntityManagerInterface::class); $this->subscriberRepository = $this->createMock(SubscriberRepository::class); $this->messageRepository = $this->createMock(MessageRepository::class); + $this->linkTrackService = $this->createMock(LinkTrackService::class); $this->output = $this->createMock(OutputInterface::class); $this->preparator = new MessageProcessingPreparator( $this->entityManager, $this->subscriberRepository, - $this->messageRepository + $this->messageRepository, + $this->linkTrackService ); } @@ -123,4 +129,86 @@ public function testEnsureCampaignsHaveUuidWithCampaigns(): void $this->preparator->ensureCampaignsHaveUuid($this->output); } + + public function testProcessMessageLinksWhenLinkTrackingNotApplicable(): void + { + $message = $this->createMock(Message::class); + $userId = 123; + + $this->linkTrackService->expects($this->once()) + ->method('isExtractAndSaveLinksApplicable') + ->willReturn(false); + + $this->linkTrackService->expects($this->never()) + ->method('extractAndSaveLinks'); + + $message->expects($this->never()) + ->method('getContent'); + + $result = $this->preparator->processMessageLinks($message, $userId); + + $this->assertSame($message, $result); + } + + public function testProcessMessageLinksWhenNoLinksExtracted(): void + { + $message = $this->createMock(Message::class); + $userId = 123; + + $this->linkTrackService->expects($this->once()) + ->method('isExtractAndSaveLinksApplicable') + ->willReturn(true); + + $this->linkTrackService->expects($this->once()) + ->method('extractAndSaveLinks') + ->with($message, $userId) + ->willReturn([]); + + $message->expects($this->never()) + ->method('getContent'); + + $result = $this->preparator->processMessageLinks($message, $userId); + + $this->assertSame($message, $result); + } + + public function testProcessMessageLinksWithLinksExtracted(): void + { + $message = $this->createMock(Message::class); + $content = $this->createMock(MessageContent::class); + $userId = 123; + + $linkTrack1 = $this->createMock(LinkTrack::class); + $linkTrack1->method('getId')->willReturn(1); + $linkTrack1->method('getUrl')->willReturn('https://example.com'); + + $linkTrack2 = $this->createMock(LinkTrack::class); + $linkTrack2->method('getId')->willReturn(2); + $linkTrack2->method('getUrl')->willReturn('https://example.org'); + + $savedLinks = [$linkTrack1, $linkTrack2]; + + $this->linkTrackService->method('isExtractAndSaveLinksApplicable')->willReturn(true); + $this->linkTrackService->method('extractAndSaveLinks')->with($message, $userId)->willReturn($savedLinks); + + $message->method('getContent')->willReturn($content); + + $htmlContent = 'Link 1 Link 2'; + $content->method('getText')->willReturn($htmlContent); + + $footer = 'Footer Link'; + $content->method('getFooter')->willReturn($footer); + + $content->expects($this->once()) + ->method('setText') + ->with($this->stringContains(MessageProcessingPreparator::LINT_TRACK_ENDPOINT . '?id=1')); + + $content->expects($this->once()) + ->method('setFooter') + ->with($this->stringContains(MessageProcessingPreparator::LINT_TRACK_ENDPOINT . '?id=1')); + + $result = $this->preparator->processMessageLinks($message, $userId); + + $this->assertSame($message, $result); + } }