Skip to content

[RFC][Mailer][FrameworkBundle] Add support for rate limited mailer transports #59985

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: 7.4
Choose a base branch
from

Conversation

fritzmg
Copy link
Contributor

@fritzmg fritzmg commented Mar 16, 2025

Q A
Branch? 7.3
Bug fix? no
New feature? yes
Deprecations? no
Issues
License MIT

Assume you have to use multiple SMTP servers in your application in order to be able to send emails on behalf of specific email accounts on a variety of hosting providers. Some of these hosting providers may impose limits on how many emails you can send per timeframe. For example on Hetzner the limit is 500 emails per hour, on webgo 50 emails per hour. Hetzner does not force this limit - but they may disable your account completely, if you break this rule. The same applies to services like sendgrid etc., where you will have certain limits.

Now, if you use symfony/messenger in order to send your emails asynchronously, you are able to rate limit the messenger transport:

framework:
    messenger:
        transports:
            mailer:
                dsn: 'doctrine://default?queue_name=mailer'
                rate_limiter: mailer
        routing:
            'Symfony\Component\Mailer\Messenger\SendEmailMessage': mailer
    rate_limiter:
        mailer:
            policy: fixed_window
            limit: 500
            interval: 60 minutes

However, this will only rate limit the email queue globally - and not per SMTP server. So if you have to use multiple SMTP servers, where some use different rate limits and others do not need to be rate limited at all, this is not ideal.

Thus this PR introduces support for rate limited mailer transports. The changes are fairly straight forward:

  • For each mailer transport you can now also optionally define a rate_limiter, just as with messenger transports.
  • This rate limiter will then be set by the Transport factory, if applicable.
  • The actual rate limiting is done in the AbstractTransport::send() method - using ensureAccepted(), so that a RateLimitExceededException will be thrown.
  • This means that - if you do not use a messenger queue for sending emails, your application is responsible for handling this exception, when you defined a rate limiter on a mailer transport.
  • If you do use a messenger queue then the email message will be put back on the queue, with available_at set to the respective time in the future according to the rate limit.
framework:
    mailer:
        transports:
            hetzner: 
                dsn: smtp://foo:bar@mail.your-server.de:587
                rate_limiter: hetzner
            webgo:
                dsn: smtp://lorem:ipsum@v123.goserver.host:587
                rate_limiter: webgo
            not_rate_limited: smtp://dolor:sit@example.com:587

    rate_limiter:
        # In actuality you will want to set a lower rate limit, depending on whether
        # other applications will also send via the same credentials.
        hetzner:
            policy: fixed_window
            limit: 500
            interval: 60 minutes
        webgo:
            policy: fixed_window
            limit: 50
            interval: 60 minutes

    messenger:
        transports:
            mailer: 
                dsn: 'doctrine://default?queue_name=mailer'
                failure_transport: mailer_failed
            mailer_failed: 'doctrine://default?queue_name=mailer_failed'
        routing:
            'Symfony\Component\Mailer\Messenger\SendEmailMessage': mailer

Tests are still missing, as I wanted to hear comments first :)

@carsonbot carsonbot added this to the 7.3 milestone Mar 16, 2025
@fritzmg fritzmg changed the title Add support for rate limited mailer transports [Mailer][FrameworkBundle] Add support for rate limited mailer transports Mar 16, 2025
@fritzmg fritzmg changed the title [Mailer][FrameworkBundle] Add support for rate limited mailer transports [RFC][Mailer][FrameworkBundle] Add support for rate limited mailer transports Mar 18, 2025
Copy link
Contributor

@Spomky Spomky left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi,

Many thanks for the proposal. THis is a nice and useful idea.
I reviewed this PR and it is a good start except the missing tests and a few things I spotted.


foreach ($transports as $name => $transport) {
if ($transport['rate_limiter']) {
if (!interface_exists(LimiterInterface::class)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This if could be moved outside the for loop.
The verification can be done just after if $transportRateLimiterReferences is not empty and the interface is missing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - however, the same would apply to this existing code:

if (!interface_exists(LimiterInterface::class)) {
throw new LogicException('Rate limiter cannot be used within Messenger as the RateLimiter component is not installed. Try running "composer require symfony/rate-limiter".');
}

Do you want me to change it in the unrelated section as well?


/**
* @author Fabien Potencier <fabien@symfony.com>
*/
abstract class AbstractTransport implements TransportInterface
{
private LoggerInterface $logger;
private ?RateLimiterFactoryInterface $rateLimiterFactory = null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New argument should be placed after to avoid BC breaks.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, how is this related to BC? This is just a private member variable - and not a constructor variable with constructor property promotion.

$transportRateLimiterReferences = [];

foreach ($transports as $name => $transport) {
if ($transport['rate_limiter']) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isset or array_key_exists?

Copy link
Contributor Author

@fritzmg fritzmg Apr 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessary as the key will always exist due to the processed configuration. See also:


foreach ($transports as $name => $transport) {
if ($transport['rate_limiter']) {
if (!interface_exists(LimiterInterface::class)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this rather check isConfigEnabled() for the rate_limiter config ? the fact that the component is installed does not guarantee that the config is enabled for it (smart defaults are based on the availability of the component, but projects can still configure things explicitly)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am using the same logic as here:

$senderAliases = [];
$transportRetryReferences = [];
$transportRateLimiterReferences = [];
foreach ($config['transports'] as $name => $transport) {
$serializerId = $transport['serializer'] ?? 'messenger.default_serializer';
$tags = [
'alias' => $name,
'is_failure_transport' => \in_array($name, $failureTransports, true),
];
if (str_starts_with($transport['dsn'], 'sync://')) {
$tags['is_consumable'] = false;
}
$transportDefinition = (new Definition(TransportInterface::class))
->setFactory([new Reference('messenger.transport_factory'), 'createTransport'])
->setArguments([$transport['dsn'], $transport['options'] + ['transport_name' => $name], new Reference($serializerId)])
->addTag('messenger.receiver', $tags)
;
$container->setDefinition($transportId = 'messenger.transport.'.$name, $transportDefinition);
$senderAliases[$name] = $transportId;
if (null !== $transport['retry_strategy']['service']) {
$transportRetryReferences[$name] = new Reference($transport['retry_strategy']['service']);
} else {
$retryServiceId = \sprintf('messenger.retry.multiplier_retry_strategy.%s', $name);
$retryDefinition = new ChildDefinition('messenger.retry.abstract_multiplier_retry_strategy');
$retryDefinition
->replaceArgument(0, $transport['retry_strategy']['max_retries'])
->replaceArgument(1, $transport['retry_strategy']['delay'])
->replaceArgument(2, $transport['retry_strategy']['multiplier'])
->replaceArgument(3, $transport['retry_strategy']['max_delay'])
->replaceArgument(4, $transport['retry_strategy']['jitter']);
$container->setDefinition($retryServiceId, $retryDefinition);
$transportRetryReferences[$name] = new Reference($retryServiceId);
}
if ($transport['rate_limiter']) {
if (!interface_exists(LimiterInterface::class)) {
throw new LogicException('Rate limiter cannot be used within Messenger as the RateLimiter component is not installed. Try running "composer require symfony/rate-limiter".');
}
$transportRateLimiterReferences[$name] = new Reference('limiter.'.$transport['rate_limiter']);
}
}

Should this not employ the same logic as the rate_limiter config for messenger for consistency?

{
$this->rateLimiterFactory = $rateLimiterFactory;

return $this;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should return $this here. It makes the API less clear whether this is a method mutating the instance or returning a new one. Our setters generally don't return $this in Symfony.

Copy link
Contributor Author

@fritzmg fritzmg Apr 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setters in Symfony\Component\Mailer\Transport\AbstractTransport all return $this - so I think we should also return $this here, otherwise it would be inconsistent, would it not?

@fabpot fabpot modified the milestones: 7.3, 7.4 May 26, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants