From 254372afaefa4bb9e8cdf24c5e578e5ea6d68efd Mon Sep 17 00:00:00 2001 From: Felix Zandanel Date: Wed, 3 Dec 2025 14:12:51 +0100 Subject: [PATCH 1/2] chore: neos/symfonymailer compatibility, extend README --- Classes/Aspect/QueuingAspect.php | 89 +++++++-------- Classes/Job/Context.php | 52 --------- Classes/Job/MailJob.php | 137 +++++++++++------------ Classes/QueueableEmail.php | 23 ++++ Classes/Service/MailQueue.php | 51 +++++++++ Classes/Traits/QueueNameTrait.php | 36 ------ Classes/Transport/QueuingTransport.php | 43 +++++++ Configuration/Caches.yaml | 1 - Configuration/Objects.yaml | 3 +- Configuration/Settings.yaml | 5 +- LICENSE | 2 +- README.md | 54 +++++++-- composer.json | 44 ++++---- neos-swiftmailer.message-decorator.patch | 73 ------------ 14 files changed, 299 insertions(+), 314 deletions(-) delete mode 100644 Classes/Job/Context.php create mode 100644 Classes/QueueableEmail.php create mode 100644 Classes/Service/MailQueue.php delete mode 100644 Classes/Traits/QueueNameTrait.php create mode 100644 Classes/Transport/QueuingTransport.php delete mode 100644 neos-swiftmailer.message-decorator.patch diff --git a/Classes/Aspect/QueuingAspect.php b/Classes/Aspect/QueuingAspect.php index 5bebbde..a2e6650 100644 --- a/Classes/Aspect/QueuingAspect.php +++ b/Classes/Aspect/QueuingAspect.php @@ -1,65 +1,66 @@ getMailer())") + * @throws ReflectionException */ - protected $settings; + public function decorateTransport(JoinPointInterface $joinPoint): Mailer + { + /** @var Mailer $mailer */ + $mailer = $joinPoint->getAdviceChain()->proceed($joinPoint); + $transportProperty = (new ReflectionObject($mailer))->getProperty('transport'); + $transportProperty->setValue($mailer, new QueuingTransport($transportProperty->getValue($mailer))); + return $mailer; + } /** - * Intercept all emails or add bcc according to package configuration + * When FormatD.Mailer is installed, the above `decorateMailer()` advice is not always called depending + * on configuration. In that case, we intercept the special transport object's `send()` method instead. * - * @param \Neos\Flow\Aop\JoinPointInterface $joinPoint - * @Flow\Around("setting(FormatD.Mailer.QueueAdaptor.enableAsynchronousMails) && method(Neos\SwiftMailer\Message->send())") - * @return void + * @Flow\Around("method(FormatD\Mailer\Transport\FdMailerTransport->send()) || method(FormatD\Mailer\Transport\InterceptingTransport->send())") */ - public function queueEmails(\Neos\Flow\Aop\JoinPointInterface $joinPoint) { + public function transportSend(JoinPointInterface $joinPoint): ?SentMessage + { + /** @var RawMessage $message */ + $message = $joinPoint->getMethodArgument('message'); - if ($this->jobContext->isMailQueueingDisabled()) { - return $joinPoint->getAdviceChain()->proceed($joinPoint); - } + if ($message instanceof Email && !$this->mailQueue->isMailQueuingDisabled()) { + /** @var ?Envelope $envelope */ + $envelope = $joinPoint->getMethodArgument('envelope'); - /** @var Message $email */ - $email = $joinPoint->getProxy(); - $job = new MailJob($email); - $this->jobManager->queue($email->getQueueName() ? $email->getQueueName() : 'fdmailer-mail-queue', $job); + // Queue the mail before interception, i.e. before rewrite of the mail headers (To, Bcc, etc.) + // When the queued mail is released, the mail is intercepted again. + $this->mailQueue->enqueueMessage($message, $envelope); + return null; + } - // Neos\SwiftMailer\Message->send() should return the number of recipients who were accepted for delivery - // We dont know that until mail is execured by queue so we assume every recipient was accepted - // @todo: read recipient count and return that - return 1; + return $joinPoint->getAdviceChain()->proceed($joinPoint); } - } - -?> diff --git a/Classes/Job/Context.php b/Classes/Job/Context.php deleted file mode 100644 index b2cab94..0000000 --- a/Classes/Job/Context.php +++ /dev/null @@ -1,52 +0,0 @@ -mailQueueingDisabled; - } - - /** - * Lets you switch off mail queueing for the runtime of $callback - * - * Usage: - * $this->jobContext->withoutMailQueuing(function () use ($message) { - * $message->send(); - * }); - * - * @param \Closure $callback - * @return void - * @throws \Exception - */ - public function withoutMailQueuing(\Closure $callback) - { - $mailQueueingIsAlreadyDisabled= $this->mailQueueingDisabled; - $this->mailQueueingDisabled = true; - try { - /** @noinspection PhpUndefinedMethodInspection */ - $callback->__invoke(); - } catch (\Exception $exception) { - $this->mailQueueingDisabled = false; - throw $exception; - } - if ($mailQueueingIsAlreadyDisabled === false) { - $this->mailQueueingDisabled = false; - } - } - -} diff --git a/Classes/Job/MailJob.php b/Classes/Job/MailJob.php index d049c51..e463adb 100644 --- a/Classes/Job/MailJob.php +++ b/Classes/Job/MailJob.php @@ -2,107 +2,104 @@ namespace FormatD\Mailer\QueueAdaptor\Job; -use Neos\Cache\Frontend\StringFrontend; -use Neos\Flow\Annotations as Flow; +use Exception; use Flowpack\JobQueue\Common\Job\JobInterface; -use Flowpack\JobQueue\Common\Queue\QueueInterface; use Flowpack\JobQueue\Common\Queue\Message; - -class MailJob implements JobInterface { - - /** - * @Flow\InjectConfiguration(type="Settings", package="FormatD.Mailer.QueueAdaptor", path="serializationCache") - * @var array - */ - protected $serializationCacheSettings; - - /** - * @Flow\Inject - * @var Context - */ - protected $jobContext; +use Flowpack\JobQueue\Common\Queue\QueueInterface; +use FormatD\Mailer\QueueAdaptor\Service\MailQueue; +use Neos\Cache\Exception as NeosCacheException; +use Neos\Cache\Exception\InvalidDataException; +use Neos\Cache\Frontend\StringFrontend; +use Neos\Flow\Annotations as Flow; +use Neos\SymfonyMailer\Service\MailerService; +use Ramsey\Uuid\Uuid; +use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mime\Email; + +/** + * @Flow\Scope("prototype") + */ +class MailJob implements JobInterface +{ + #[Flow\InjectConfiguration(path: 'serializationCache', package: 'FormatD.Mailer.QueueAdaptor')] + protected array $serializationCacheSettings; + + #[Flow\Inject] + protected MailQueue $mailQueue; /** - * @Flow\Inject + * Factory-backed objects (like cache) are **always** proxified by the Flow Object Manager, regardless if they're lazy or not. + * Such proxy objects do not extend the original class, thus a direct property type declaration gives a TypeError. * @var StringFrontend */ - protected $mailSerializationCache; + #[Flow\Inject] + protected mixed $mailDataCache = null; - /** - * @var \Neos\SwiftMailer\Message - */ - protected $email = null; + #[Flow\Inject] + protected MailerService $mailerService; - /** - * @var string - */ - protected $emailSerializationCacheIdentifier = null; + protected ?string $emailCacheIdentifier = null; - /** - * MailJob constructor. - * @param \Neos\SwiftMailer\Message $email - */ - public function __construct(\Neos\SwiftMailer\Message $email) { - $this->email = $email; + public function __construct( + protected Email $email, + protected ?Envelope $envelope = null + ) + { } /** * Execute the job - * * A job should finish itself after successful execution using the queue methods. * - * @param QueueInterface $queue - * @param Message $message The original message - * @return bool TRUE if the job was executed successfully and the message should be finished + * @throws Exception */ - public function execute(QueueInterface $queue, Message $message): bool { - - $message = $this->getEmail(); + public function execute(QueueInterface $queue, Message $message): bool + { + $this->tryRestoreDataFromCache(); - $this->jobContext->withoutMailQueuing(function () use ($message) { - $message->send(); - }); + if ($this->email) { + $this->mailQueue->withoutQueuing(function () { + $this->mailerService->getMailer()->send($this->email, $this->envelope); + }); + return true; + } - return TRUE; + // In case the mail job is executed after the email data has already been evicted from cache, we obviously cannot proceed. + throw new Exception('Email data is no longer available in cache, so email cannot be sent.'); } - /** - * Get a readable label for the job - * - * @return string A label for the job - */ - public function getLabel(): string { - return $this->getEmail()->getSubject(); + public function getLabel(): string + { + return $this->email->getSubject(); } /** - * Serialize the email to a file because it can get really big with attachments + * Serialize the email to (file) cache so we don't need to store big email data incl. attachments in the queue * * @return string[] - * @throws \Neos\Cache\Exception - * @throws \Neos\Cache\Exception\InvalidDataException + * @throws NeosCacheException|InvalidDataException */ - public function __sleep() + public function __sleep(): array { - if ($this->serializationCacheSettings['enabled']) { - $this->emailSerializationCacheIdentifier = uniqid('email-'); - $this->mailSerializationCache->set($this->emailSerializationCacheIdentifier, serialize($this->email), [], 172800); // 48 Std. lifetime - return array('emailSerializationCacheIdentifier'); + if (($this->serializationCacheSettings['enabled'] ?? false) && $this->mailDataCache) { + $this->emailCacheIdentifier = sprintf('email-%s', Uuid::uuid4()); + $data = [ + 'email' => $this->email, + 'envelope' => $this->envelope, + ]; + $this->mailDataCache->set($this->emailCacheIdentifier, serialize($data), [], 172800); // 48h lifetime + return ['emailCacheIdentifier']; } - return array('email'); + return ['email', 'envelope']; } - /** - * Restores the serialized email if cached in file - * - * @return \Neos\SwiftMailer\Message - */ - protected function getEmail() { - if (!$this->email && $this->emailSerializationCacheIdentifier && $this->mailSerializationCache->has($this->emailSerializationCacheIdentifier)) { - $this->email = unserialize($this->mailSerializationCache->get($this->emailSerializationCacheIdentifier)); + protected function tryRestoreDataFromCache(): void + { + if ($this->emailCacheIdentifier && ($serializedData = $this->mailDataCache?->get($this->emailCacheIdentifier))) { + $data = unserialize($serializedData); + $this->email = $data['email']; + $this->envelope = $data['envelope']; } - return $this->email; } - } diff --git a/Classes/QueueableEmail.php b/Classes/QueueableEmail.php new file mode 100644 index 0000000..8debe8a --- /dev/null +++ b/Classes/QueueableEmail.php @@ -0,0 +1,23 @@ +queueName = $queueName; + } + + public function getQueueName(): ?string + { + return $this->queueName; + } +} diff --git a/Classes/Service/MailQueue.php b/Classes/Service/MailQueue.php new file mode 100644 index 0000000..5083efd --- /dev/null +++ b/Classes/Service/MailQueue.php @@ -0,0 +1,51 @@ +getQueueName() : null) ?? $this->settings['queueName']; + $this->jobManager->queue($queueName, $job); + } + + public function isMailQueuingDisabled(): bool + { + return $this->mailQueuingDisabled; + } + + public function withoutQueuing(Closure $callback): void + { + $previousMailQueuingDisabledState = $this->mailQueuingDisabled; + $this->mailQueuingDisabled = true; + try { + $callback->__invoke(); + } catch (Exception $exception) { + $this->mailQueuingDisabled = $previousMailQueuingDisabledState; + throw $exception; + } + $this->mailQueuingDisabled = $previousMailQueuingDisabledState; + } +} diff --git a/Classes/Traits/QueueNameTrait.php b/Classes/Traits/QueueNameTrait.php deleted file mode 100644 index faebf23..0000000 --- a/Classes/Traits/QueueNameTrait.php +++ /dev/null @@ -1,36 +0,0 @@ -queueName; - } - - /** - * @param string $queueName - */ - public function setQueueName(?string $queueName): void - { - $this->queueName = $queueName; - } - -} - -?> diff --git a/Classes/Transport/QueuingTransport.php b/Classes/Transport/QueuingTransport.php new file mode 100644 index 0000000..1c7a1f7 --- /dev/null +++ b/Classes/Transport/QueuingTransport.php @@ -0,0 +1,43 @@ +mailQueue->isMailQueuingDisabled()) { + return $this->actualTransport->send($message, $envelope); + } + + $this->mailQueue->enqueueMessage($message, $envelope); + return null; + } + + public function __toString(): string + { + return 'fd-mailer-queue'; + } +} diff --git a/Configuration/Caches.yaml b/Configuration/Caches.yaml index 25afac9..2eb15f4 100644 --- a/Configuration/Caches.yaml +++ b/Configuration/Caches.yaml @@ -1,4 +1,3 @@ - FormatD_Mailer_QueueAdaptor_MailSerializationCache: frontend: Neos\Cache\Frontend\StringFrontend backend: Neos\Cache\Backend\FileBackend diff --git a/Configuration/Objects.yaml b/Configuration/Objects.yaml index 2aed263..9471535 100644 --- a/Configuration/Objects.yaml +++ b/Configuration/Objects.yaml @@ -1,7 +1,6 @@ - 'FormatD\Mailer\QueueAdaptor\Job\MailJob': properties: - mailSerializationCache: + mailDataCache: object: factoryObjectName: 'Neos\Flow\Cache\CacheManager' factoryMethodName: getCache diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 58cbc58..bb957b4 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -9,14 +9,15 @@ Flowpack: defaultTimeout: 50 releaseOptions: priority: 512 - delay: 120 + delay: 2 FormatD: Mailer: QueueAdaptor: # Enable or disable asynchronous handling of mails enableAsynchronousMails: true + queueName: 'fdmailer-mail-queue' - # E-Mails with attachments may be to big to put into the queue payload as a whole (at least for Doctrine Backend). So we use file cache to store this data. + # Emails with attachments may be too big to put into the queue payload as a whole (at least for Doctrine Backend). So we use file cache to store this data. serializationCache: enabled: true diff --git a/LICENSE b/LICENSE index dff9bd6..f0c4efd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Format D GmbH +Copyright (c) 2025 Format D GmbH Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 58b61bf..96e4248 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,9 @@ FormatD.Mailer.QueueAdaptor ========== -This package changes the mail delivery in Neos (`neos/swiftmailer`) to asynchronously send mails via a queue. +This package changes the mail delivery in Neos (`neos/symfonymailer`) to asynchronously send mails via a queue. The idea is to make it work as a plug-and-play replacement for every mail generated in the system. -Disclaimer ----------- - -This Package is just a proof of concept and needs a patch for `neos/swiftmailer` to work -(contained in this package and applied automatically by `cweagans/composer-patches`). -The patch is neccessary because the Message object of `neos/swiftmailer` cannot be serialized. -The patch changes the implementation from inheritance to a decorator pattern. - Setup ---------- @@ -51,3 +43,47 @@ Now test if it is working: ./flow email:send --body "Hello World" from@example.com to@example.com "My Test Mail" +## Send mail via specific queue + +All email objects (that are or extend `\Symfony\Component\Mime\Email`) are placed into the default queue +`fdmailer-mail-queue`. The default queue can be configured, see [Settings.yaml](Configuration/Settings.yaml). +Sending mail via a specific queue is also possible: + +```php + $mail = new \FormatD\Mailer\QueueAdaptor\QueueableEmail(); // extends \Symfony\Component\Mime\Email + $mail->setQueueName('my-queue'); + //... + $mailerService->getMailer()->send($mail); // Will be intercepted and placed into the specified queue +``` + +## Send mail immediately (without queue) + +```php + $mailQueue = $this->objectManager->get('\FormatD\Mailer\QueueAdaptor\Service\MailQueue'); + $mailQueue->withoutQueuing(function () { + $mail = new \Symfony\Component\Mime\Email(); + //... + $mailerService->getMailer()->send($mail); + }); +``` + +## Interoperability + +* Needs a Neos installation using [`neos/symfonymailer`](https://packagist.org/packages/neos/symfonymailer) (default as of Neos 8.3.24) +* Works with _and without_ [`formatd/mailer`](https://github.com/Format-D/FormatD.Mailer) + +## Compatibility + +Versioning scheme: + + 1.0.0 + | | | + | | Bugfix Releases (non breaking) + | Neos Compatibility Releases (non breaking except framework dependencies) + Feature Releases (breaking) + +Releases und compatibility: + +| Package-Version | Neos Flow Version | neos/fusion-form | +|-----------------|-------------------|------------------| +| 1.0.0 | ^8.0 | ^3.0 | diff --git a/composer.json b/composer.json index 5496448..b714ecc 100644 --- a/composer.json +++ b/composer.json @@ -1,28 +1,24 @@ { - "description": "Mail Queue for Neos.SwiftMailer", - "type": "neos-package", - "license": "MIT", - "name": "formatd/mailer-queueadaptor", - "require": { - "neos/flow": "*", - "formatd/mailer": "~v1.1.4", - "flowpack/jobqueue-common": "^3.1 || ^3.2 || ^3.3", - "cweagans/composer-patches": "^1.7" - }, - "autoload": { - "psr-4": { - "FormatD\\Mailer\\QueueAdaptor\\": "Classes/" - } - }, - "extra": { - "patches": { - "neos/swiftmailer": { - "Change: Change message object to decorator pattern": "Packages/Application/FormatD.Mailer.QueueAdaptor/neos-swiftmailer.message-decorator.patch" - } + "description": "Mail Queue for Neos.SymfonyMailer using Flowpack.JobQueue", + "type": "neos-package", + "license": "MIT", + "name": "formatd/mailer-queueadaptor", + "require": { + "neos/flow": "~8.0", + "neos/fusion-form": "~3.0", + "flowpack/jobqueue-common": "^3.1 || ^3.2 || ^3.3 || ^3.4" + }, + "suggest": { + "formatd/mailer": "^2.0.0" + }, + "autoload": { + "psr-4": { + "FormatD\\Mailer\\QueueAdaptor\\": "Classes/" + } }, - "composer-exit-on-patch-failure": true, - "neos": { - "package-key": "FormatD.Mailer.QueueAdaptor" + "extra": { + "neos": { + "package-key": "FormatD.Mailer.QueueAdaptor" + } } - } } diff --git a/neos-swiftmailer.message-decorator.patch b/neos-swiftmailer.message-decorator.patch deleted file mode 100644 index bb1a26e..0000000 --- a/neos-swiftmailer.message-decorator.patch +++ /dev/null @@ -1,73 +0,0 @@ -Index: Classes/Message.php -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP -<+>UTF-8 -=================================================================== -diff --git a/Classes/Message.php b/Classes/Message.php -^--- a/Classes/Message.php (date 1620418084798) -+++ b/Classes/Message.php (date 1620418084798) -@@ -21,8 +21,54 @@ - * - * @Flow\Scope("prototype") - */ --class Message extends \Swift_Message -+class Message - { -+ -+ /** -+ * Create a new Message. -+ * -+ * Details may be optionally passed into the constructor. -+ * -+ * @param string $subject -+ * @param string $body -+ * @param string $contentType -+ * @param string $charset -+ */ -+ public function __construct($subject = null, $body = null, $contentType = null, $charset = null) { -+ $this->message = new \Swift_Message($subject = null, $body = null, $contentType = null, $charset = null); -+ } -+ -+ /** -+ * @return string[] -+ */ -+ public function __sleep() { -+ return ['failedRecipients', 'sent', 'message', 'intercepted']; -+ } -+ -+ /** -+ * Pass all calls to $this->message -+ * -+ * @param string $method -+ * @param array $args -+ * @return false|mixed -+ */ -+ public function __call($method, $args) { -+ -+ $returnValue = call_user_func_array(array($this->message, $method), $args); -+ -+ // Enable method chaining (new Message())->setTo(...)->setFrom(...) -+ if ($returnValue === $this->message) { -+ return $this; -+ } -+ -+ return $returnValue; -+ } -+ -+ /** -+ * @var \Swift_Message -+ */ -+ protected $message; -+ - /** - * @Flow\Inject - * @var \Neos\SwiftMailer\MailerInterface -@@ -52,7 +98,7 @@ - { - $this->sent = true; - $this->failedRecipients = []; -- return $this->mailer->send($this, $this->failedRecipients); -+ return $this->mailer->send($this->message, $this->failedRecipients); - } - - /** From 045a810c4b8c347c694cd794ca8eec4ce37d125f Mon Sep 17 00:00:00 2001 From: Felix Zandanel Date: Fri, 5 Dec 2025 11:09:43 +0100 Subject: [PATCH 2/2] chore: Make "enableAsynchronousMails" setting work again --- Classes/Service/MailQueue.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Classes/Service/MailQueue.php b/Classes/Service/MailQueue.php index 5083efd..f2b654e 100644 --- a/Classes/Service/MailQueue.php +++ b/Classes/Service/MailQueue.php @@ -33,7 +33,7 @@ public function enqueueMessage(Email $message, ?Envelope $envelope = null): void public function isMailQueuingDisabled(): bool { - return $this->mailQueuingDisabled; + return $this->mailQueuingDisabled || !($this->settings['enableAsynchronousMails'] ?? false); } public function withoutQueuing(Closure $callback): void