From 85ae910527fcb43a91cf02232d73569095f78cf6 Mon Sep 17 00:00:00 2001 From: Fritz Michael Gschwantner Date: Sat, 15 Mar 2025 18:23:04 +0000 Subject: [PATCH 1/4] First draft --- .../DependencyInjection/Configuration.php | 16 +++++++++++++- .../FrameworkExtension.php | 21 ++++++++++++++++++- .../Resources/config/mailer.php | 6 ++++++ .../Mailer/Messenger/MessageHandler.php | 10 ++++++++- src/Symfony/Component/Mailer/Transport.php | 19 +++++++++++++++-- .../Mailer/Transport/AbstractTransport.php | 15 +++++++++++++ 6 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 17b4a438b2aec..855d24a35123d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -2249,7 +2249,21 @@ private function addMailerSection(ArrayNodeDefinition $rootNode, callable $enabl ->scalarNode('dsn')->defaultNull()->end() ->arrayNode('transports') ->useAttributeAsKey('name') - ->prototype('scalar')->end() + ->arrayPrototype() + ->beforeNormalization() + ->ifString() + ->then(function (string $dsn) { + return ['dsn' => $dsn]; + }) + ->end() + ->children() + ->scalarNode('dsn')->end() + ->scalarNode('rate_limiter') + ->defaultNull() + ->info('Rate limiter name to use when sending messages.') + ->end() + ->end() + ->end() ->end() ->arrayNode('envelope') ->info('Mailer Envelope configuration') diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index e2d0888dbd7e6..b6722be5b589f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2781,7 +2781,26 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co $config['dsn'] = 'smtp://null'; } $transports = $config['dsn'] ? ['main' => $config['dsn']] : $config['transports']; - $container->getDefinition('mailer.transports')->setArgument(0, $transports); + $container->getDefinition('mailer.transports')->setArgument(0, array_combine(array_keys($config['transports']), array_column($config['transports'], 'dsn'))); + + $transportRateLimiterReferences = []; + + foreach ($transports as $name => $transport) { + if ($transport['rate_limiter']) { + if (!interface_exists(LimiterInterface::class)) { + throw new LogicException('Rate limiter cannot be used within Mailer as the RateLimiter component is not installed. Try running "composer require symfony/rate-limiter".'); + } + + $transportRateLimiterReferences[$name] = new Reference('limiter.'.$transport['rate_limiter']); + } + } + + if (!$transportRateLimiterReferences) { + $container->removeDefinition('mailer.rate_limiter_locator'); + } else { + $container->getDefinition('mailer.rate_limiter_locator') + ->replaceArgument(0, $transportRateLimiterReferences); + } $mailer = $container->getDefinition('mailer.mailer'); if (false === $messageBus = $config['message_bus']) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer.php index b604d09091a2b..006b1fe51b01f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer.php @@ -11,6 +11,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\Mailer\Command\MailerTestCommand; use Symfony\Component\Mailer\EventListener\DkimSignedMessageListener; use Symfony\Component\Mailer\EventListener\EnvelopeListener; @@ -49,6 +50,7 @@ ->set('mailer.transport_factory', Transport::class) ->args([ tagged_iterator('mailer.transport_factory'), + service('mailer.rate_limiter_locator')->nullOnInvalid(), ]) ->set('mailer.default_transport', TransportInterface::class) @@ -128,5 +130,9 @@ service('mailer.transports'), ]) ->tag('console.command') + + ->set('mailer.rate_limiter_locator', ServiceLocator::class) + ->args([[]]) + ->tag('container.service_locator') ; }; diff --git a/src/Symfony/Component/Mailer/Messenger/MessageHandler.php b/src/Symfony/Component/Mailer/Messenger/MessageHandler.php index 06fbdb0812275..3c05853487bae 100644 --- a/src/Symfony/Component/Mailer/Messenger/MessageHandler.php +++ b/src/Symfony/Component/Mailer/Messenger/MessageHandler.php @@ -13,6 +13,8 @@ use Symfony\Component\Mailer\SentMessage; use Symfony\Component\Mailer\Transport\TransportInterface; +use Symfony\Component\Messenger\Exception\RecoverableMessageHandlingException; +use Symfony\Component\RateLimiter\Exception\RateLimitExceededException; /** * @author Fabien Potencier @@ -26,6 +28,12 @@ public function __construct( public function __invoke(SendEmailMessage $message): ?SentMessage { - return $this->transport->send($message->getMessage(), $message->getEnvelope()); + try { + return $this->transport->send($message->getMessage(), $message->getEnvelope()); + } catch (RateLimitExceededException $e) { + $retryDelay = $e->getRetryAfter()->getTimestamp() - time(); + + throw new RecoverableMessageHandlingException('Rate limit for mailer transport exceeded.', previous: $e, retryDelay: $retryDelay); + } } } diff --git a/src/Symfony/Component/Mailer/Transport.php b/src/Symfony/Component/Mailer/Transport.php index 773603dd8de83..cb7b787ae7997 100644 --- a/src/Symfony/Component/Mailer/Transport.php +++ b/src/Symfony/Component/Mailer/Transport.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Mailer; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Mailer\Bridge\AhaSend\Transport\AhaSendTransportFactory; @@ -34,6 +36,7 @@ use Symfony\Component\Mailer\Bridge\Sweego\Transport\SweegoTransportFactory; use Symfony\Component\Mailer\Exception\InvalidArgumentException; use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; +use Symfony\Component\Mailer\Transport\AbstractTransport; use Symfony\Component\Mailer\Transport\Dsn; use Symfony\Component\Mailer\Transport\FailoverTransport; use Symfony\Component\Mailer\Transport\NativeTransportFactory; @@ -44,6 +47,7 @@ use Symfony\Component\Mailer\Transport\TransportFactoryInterface; use Symfony\Component\Mailer\Transport\TransportInterface; use Symfony\Component\Mailer\Transport\Transports; +use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; /** @@ -93,6 +97,7 @@ public static function fromDsns(#[\SensitiveParameter] array $dsns, ?EventDispat */ public function __construct( private iterable $factories, + private ?ContainerInterface $rateLimiterLocator = null, ) { } @@ -100,19 +105,29 @@ public function fromStrings(#[\SensitiveParameter] array $dsns): Transports { $transports = []; foreach ($dsns as $name => $dsn) { - $transports[$name] = $this->fromString($dsn); + try { + $rateLimiter = $this->rateLimiterLocator?->get($name); + } catch (NotFoundExceptionInterface) { + $rateLimiter = null; + } + + $transports[$name] = $this->fromString($dsn, $rateLimiter); } return new Transports($transports); } - public function fromString(#[\SensitiveParameter] string $dsn): TransportInterface + public function fromString(#[\SensitiveParameter] string $dsn, ?RateLimiterFactoryInterface $rateLimiter = null): TransportInterface { [$transport, $offset] = $this->parseDsn($dsn); if ($offset !== \strlen($dsn)) { throw new InvalidArgumentException('The mailer DSN has some garbage at the end.'); } + if ($rateLimiter && $transport instanceof AbstractTransport) { + $transport->setRateLimiterFactory($rateLimiter); + } + return $transport; } diff --git a/src/Symfony/Component/Mailer/Transport/AbstractTransport.php b/src/Symfony/Component/Mailer/Transport/AbstractTransport.php index 718a3bf9618b3..24c5d80e81d1f 100644 --- a/src/Symfony/Component/Mailer/Transport/AbstractTransport.php +++ b/src/Symfony/Component/Mailer/Transport/AbstractTransport.php @@ -24,6 +24,7 @@ use Symfony\Component\Mime\Address; use Symfony\Component\Mime\BodyRendererInterface; use Symfony\Component\Mime\RawMessage; +use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; /** * @author Fabien Potencier @@ -31,6 +32,7 @@ abstract class AbstractTransport implements TransportInterface { private LoggerInterface $logger; + private ?RateLimiterFactoryInterface $rateLimiterFactory = null; private float $rate = 0; private float $lastSent = 0; @@ -62,10 +64,14 @@ public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMess { $message = clone $message; $envelope = null !== $envelope ? clone $envelope : Envelope::create($message); + $rateLimiter = $this->rateLimiterFactory?->create(); try { if (!$this->dispatcher) { $sentMessage = new SentMessage($message, $envelope); + + $rateLimiter?->consume(1)->ensureAccepted(); + $this->doSend($sentMessage); return $sentMessage; @@ -87,6 +93,8 @@ public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMess $sentMessage = new SentMessage($message, $envelope); try { + $rateLimiter?->consume(1)->ensureAccepted(); + $this->doSend($sentMessage); } catch (\Throwable $error) { $this->dispatcher->dispatch(new FailedMessageEvent($message, $error)); @@ -103,6 +111,13 @@ public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMess } } + public function setRateLimiterFactory(RateLimiterFactoryInterface $rateLimiterFactory): static + { + $this->rateLimiterFactory = $rateLimiterFactory; + + return $this; + } + abstract protected function doSend(SentMessage $message): void; /** From 2db50eb872428db6ea54dcc17edef26b4794f874 Mon Sep 17 00:00:00 2001 From: Fritz Michael Gschwantner Date: Sun, 16 Mar 2025 12:06:29 +0000 Subject: [PATCH 2/4] Fix retryDelay --- src/Symfony/Component/Mailer/Messenger/MessageHandler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Mailer/Messenger/MessageHandler.php b/src/Symfony/Component/Mailer/Messenger/MessageHandler.php index 3c05853487bae..59a9cbd82a2b2 100644 --- a/src/Symfony/Component/Mailer/Messenger/MessageHandler.php +++ b/src/Symfony/Component/Mailer/Messenger/MessageHandler.php @@ -31,7 +31,7 @@ public function __invoke(SendEmailMessage $message): ?SentMessage try { return $this->transport->send($message->getMessage(), $message->getEnvelope()); } catch (RateLimitExceededException $e) { - $retryDelay = $e->getRetryAfter()->getTimestamp() - time(); + $retryDelay = ($e->getRetryAfter()->getTimestamp() - time()) * 1000; throw new RecoverableMessageHandlingException('Rate limit for mailer transport exceeded.', previous: $e, retryDelay: $retryDelay); } From f311ac42a6a93522fc196d75b3930139ac434ef0 Mon Sep 17 00:00:00 2001 From: Fritz Michael Gschwantner Date: Sun, 16 Mar 2025 12:09:58 +0000 Subject: [PATCH 3/4] update the CHANGELOG.md files --- src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md | 1 + src/Symfony/Component/Mailer/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 2337f5ae0bd87..c3e30ad56e168 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -14,6 +14,7 @@ CHANGELOG * Add support for signal plain name in the `messenger.stop_worker_on_signals` configuration * Deprecate the `framework.validation.cache` option * Add `--method` option to the `debug:router` command + * Add support for rate limited mailer transports 7.2 --- diff --git a/src/Symfony/Component/Mailer/CHANGELOG.md b/src/Symfony/Component/Mailer/CHANGELOG.md index 3816cc474948b..00014bf1ebc5b 100644 --- a/src/Symfony/Component/Mailer/CHANGELOG.md +++ b/src/Symfony/Component/Mailer/CHANGELOG.md @@ -9,6 +9,7 @@ CHANGELOG * Add DSN param `source_ip` to allow binding to a (specific) IPv4 or IPv6 address. * Add DSN param `require_tls` to enforce use of TLS/STARTTLS * Add `DkimSignedMessageListener`, `SmimeEncryptedMessageListener`, and `SmimeSignedMessageListener` + * Add support for rate limited transports 7.2 --- From ff8614960f93bd01eb38a0b1ca02d7d0439362d5 Mon Sep 17 00:00:00 2001 From: Fritz Michael Gschwantner Date: Sun, 16 Mar 2025 15:01:23 +0000 Subject: [PATCH 4/4] do not use named arguments --- src/Symfony/Component/Mailer/Messenger/MessageHandler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Mailer/Messenger/MessageHandler.php b/src/Symfony/Component/Mailer/Messenger/MessageHandler.php index 59a9cbd82a2b2..3ecd410eef968 100644 --- a/src/Symfony/Component/Mailer/Messenger/MessageHandler.php +++ b/src/Symfony/Component/Mailer/Messenger/MessageHandler.php @@ -33,7 +33,7 @@ public function __invoke(SendEmailMessage $message): ?SentMessage } catch (RateLimitExceededException $e) { $retryDelay = ($e->getRetryAfter()->getTimestamp() - time()) * 1000; - throw new RecoverableMessageHandlingException('Rate limit for mailer transport exceeded.', previous: $e, retryDelay: $retryDelay); + throw new RecoverableMessageHandlingException('Rate limit for mailer transport exceeded.', 0, $e, $retryDelay); } } }