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:
' + . '' + . '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); + } }