Sending mail was at the core of our customer's business. A reliable email provider was necessary to send both transactional and non-transactional messages. Then, they needed to switch providers — and fast.

The challenge was clear. I was tasked with routing our customer's mail traffic in a gradual manner from SendGrid to Mailgun. I had to do it in a way to ensure the IPs were warmed up so that we weren’t blocklisted by the service or network providers.

To make it more interesting, the non-transactional type of mail was sent from a large number of different sending domains owned by our customer. How could I ensure proper mail delivery to the end users?

The strategy for a mail provider migration in Laravel

Because of the quickly approaching deadline, I was aiming for a simple solution that would be robust enough to support us in this transition period. When exploring options on how to approach the problem, I reviewed Laravel's mail.php config file.

Among other entries, this file has some top level key-value pairs related to a sole SMTP configuration. If the MAIL_DRIVER environment variable was set to smtp, the SMTP configuration would be used.

However, the updated format of that file has a mailers array that contains different mailer configurations, one of which can be selected using the MAIL_MAILER environment variable.

A plan was forming, one where I could manually control which Laravel mailer to use for the next outgoing mail based on assigned mailer weights.

Step 1: Weights for mail providers

Allowing for gradual control of how much mail has to be routed to one mail provider or the other necessitated assigning weights to these mail providers. So it felt very natural to leverage the existing mail.php config file mailers array and supplement it with a weight for each mailer.

use App\Mail\MailProvider;

return [
    'mailers' => [
        MailProvider::SENDGRID->value => [
            // ...
            'weight' => env('SENDGRID_MAILER_WEIGHT', 100),
        ],
        MailProvider::MAILGUN->value => [
            // ...
            'weight' => env('MAILGUN_MAILER_WEIGHT', 0),
        ],
    ],
];

Now that each mailer could have a weight value assigned, it was a matter of making the mail go through a probabilistically chosen mailer. You can send mail via a specific mailer like so:

Mail::mailer('mailgun')
        ->to($request->user())
        ->send(new OrderShipped($order));

We can use the mailer method to pass a mailer name, corresponding to a key from the mailers array in mail.php. Here's a class that we can use to pick a mailer:

namespace App\Mail\Mailer;

use App\Infrastructure\Random\RandomInt;
use Illuminate\Support\Collection;

class ProbabilisticMailerChoice
{
    public function __construct(private readonly RandomInt $randomInt) {
	
    }

    public function forDomain(string $domain): string
    {
        $possibleMailers = self::getMailersForDomain($domain);

        $maxRandomValue = $possibleMailers->sum('weight');
        if ($maxRandomValue === 0) {
            throw FailedToChooseMailer::forDomain($domain);
        }

        $randomValue = $this->randomInt->fromRange(1, $maxRandomValue);

        $partialSum = 0;
        foreach ($possibleMailers as $mailer => $mailerConfig) {
            $weight = $mailerConfig['weight'];

            if ($randomValue <= $partialSum + $weight) {
                return $mailer;
            }

            $partialSum += $weight;
        }

        throw FailedToChooseMailer::forDomain($domain);
    }

    private function getMailersForDomain(string $domain): Collection
    {
        return collect(config('mail.mailers'))
            ->filter(fn ($mailer) => key_exists('weight', $mailer) && $mailer['weight'] > 0)
            ->sortByDesc('weight');
    }
}

This class has a forDomain method, which takes the domain name as an argument. For transactional mail, it would be the apex domain of our customer. But for non-transactional mail, there was a large pool of possible values to be passed. All these domains had to have DNS configured correctly to work as sending domains; more on this later.

First, we retrieve the mailers from mail.php config, those with a defined weight (and the weight must be greater than 0). This means it's as easy as defining a (positive) weight on one of the mailers to include it in the pool. We then return them sorted in descending weight order.

Formally, probability is expressed as a number in the range from 0 and 1. However, our weights were simply integers, usually (but not necessarily) adding up to 100 across all mailers which defined a weight.

Next, we sum the weights and make sure the sum is larger than 0. Otherwise we can't reasonably choose a mailer to use, so we throw an exception in that case.

Next, we pick a random integer in the range from 1 to {sum of weights} and assign it to a variable, $randomValue.

Finally, we iterate over the pool and add the weight of the current mailer. When we exceed the $randomValue, we know we found the mailer to use and we return its name, which would in turn be passed to the Mail::mailer method.

Because this code only depends on a config file value (read by the framework when it boots) and a random number, it executes nearly instantaneously.

To be able to unit test the ProbabilisticMailerChoice class, we had to decouple from the element that provided the randomness, so we inject an instance bound to the following interface in the container:

namespace App\Infrastructure\Random;

interface RandomInt
{
    public function fromRange(int $min, int $max): int;
}

Because of this slight abstraction, we can easily mock the RandomInt in our unit tests and have deterministic results. Here's the implementation we use in production:

namespace App\Infrastructure\Random;

use Exception;

class NativeRandomInt implements RandomInt
{
    /**
     * @throws Exception
     */
    public function fromRange(int $min, int $max): int
    {
        return random_int($min, $max);
    }
}

Here's a sample mail.php config content before we started the migration. Note the weight keys added to the mailer configurations:

use App\Mail\MailProvider;

return [
    'default' => env('MAIL_MAILER', MailProvider::SENDGRID->value),

    'mailers' => [
        MailProvider::SENDGRID->value => [
            'transport' => 'sendgrid',
            // ...
            'weight' => env('SENDGRID_MAILER_WEIGHT', 100),
        ],

        MailProvider::MAILGUN->value => [
            'transport' => 'mailgun',
            // ...
            'weight' => env('MAILGUN_MAILER_WEIGHT', 0),
        ],

        // ...
    ],
];

This also relies on environment variables to set the values. You can use SENDGRID_MAILER_WEIGHT and MAILGUN_MAILER_WEIGHT to start gradually moving your transactional mail from one provider to another at a reasonable pace for your total sending volume.

Migrating non-transactional emails using Laravel

The non-transactional mail was just a tad more involved. As mentioned earlier, we had a large pool of sending domains to support. Part of the migration from SendGrid to Mailgun required other code which was responsible for configuring the DNS for these domains so that Mailgun could use them (SPF and DKIM for outbound mail, then MX for inbound mail).

One fact related to domain configuration is worth mentioning: while you can have two mail providers configured for outbound mail for a given domain, you can only have one of the mail providers configured for inbound mail.

Why was this case more involved? Because in Mailgun, SMTP credentials are created via their API for each domain separately, so we can't easily just put them in mail.php and call it a day. They have to be injected dynamically.

For that reason, the transactional mail and non-transactional mail were sent from two completely separate services (which happen to live in two separate repositories). I decided to keep it simple and allowed myself to reuse this implementation (a.k.a. copy-paste) but freely supplement it to satisfy requirements. There was only one service that sent non-transactional mail, so creating a composer package (shared code!) did not make sense.

Here's the "upgraded" implementation of the same class in the second service. We'll go through it together below. Do note that, while developed in test-driven style, I made some reasonable shortcuts in terms of code design due to the short deadline.

<?php

namespace App\Mail\Mailer;

use App\Infrastructure\Random\RandomInt;
// Some imports skipped...

class ProbabilisticMailerChoice implements MailerChoice
{
    public function __construct(
        private readonly RandomInt $randomInt
    ) {
    }

    public function forDomain(string $domain): string
    {
        $possibleMailers = self::getMailersForDomain($domain);

        $maxRandomValue = $possibleMailers->sum('weight');
        if ($maxRandomValue === 0) {
            throw FailedToChooseMailer::forDomain($domain);
        }

        $randomValue = $this->randomInt->fromRange(1, $maxRandomValue);

        $partialSum = 0;
        foreach ($possibleMailers as $mailer => $mailerConfig) {
            $percentage = $mailerConfig['weight'];

            if ($randomValue <= $partialSum + $weight) {
                if ($this->mailerNeedsSmtpCredentialInjected($mailerConfig)) { // New condition here.
                    $this->injectMailerSmtpCredentialsForDomain($mailer, $domain);
                }

                return $mailer;
            }

            $partialSum += $weight;
        }

        throw FailedToChooseMailer::forDomain($domain);
    }

    private function getMailersForDomain(string $domain): Collection
    {
        $providersWithTheDomainVerified = DB::table((new DomainSetupStatus())->getTable())
            ->where([
                'domain_name' => $domain,
                'outbound_status' => DomainVerificationStatus::VERIFIED->value,
            ])
            ->pluck('provider');

        return collect(config('mail.mailers'))
            ->filter(fn ($mailer) => key_exists('weight', $mailer) && $mailer['weight'] > 0)
            ->filter(fn ($mailer, $key) => $providersWithTheDomainVerified->contains($key)) // New filter here.
            ->sortByDesc('percentage');
    }

    /**
     * @throws FailedToInjectSmtpCredentials
     */
    private function injectMailerSmtpCredentialsForDomain(
        string $mailer,
        string $domainName
    ): void {
        // We could have a repository factory here, and internally
        // use the Eloquent model for the query. I think it's a bit
        // overkill at this point, considering this code is only called
        // when credential injection is required, so this service only.
        $table = sprintf('domain_%s_setup_trackers', Str::lower($mailer));

        $result = DB::table($table)
            ->select(['smtp_username', 'smtp_password'])
            ->where('domain_name', $domainName)
            ->first();

        if (empty($result->smtp_username) || empty($result->smtp_password)) {
            throw FailedToInjectSmtpCredentials::forDomain($domainName);
        }

        $usernameKey = sprintf('mail.mailers.%s.username', $mailer);
        config([$usernameKey => EncryptionService::decrypt($result->smtp_username)]);

        $passwordKey = sprintf('mail.mailers.%s.password', $mailer);
        config([$passwordKey => EncryptionService::decrypt($result->smtp_password)]);
    }

    private function mailerNeedsSmtpCredentialInjected(array $mailerConfig): bool
    {
        return key_exists('inject_credentials', $mailerConfig)
            && $mailerConfig['inject_credentials'] === true;
    }
}

In the first line, a call to the getMailersForDomain method, we get a list of providers from a database that have our specific domain configured (the VERIFIED status). Then we filter the configurations by weight (positive number) and our verified list (matching a key from the mailers array).

That is the reason why, as mentioned earlier, the weights don't have to add up to 100. If there are 3 providers with weights (50, 30, 20), and the last provider isn’t configured for this particular domain, we need to pick a random number in the range of 1...80.

In the main method, forDomain, while finding our mailer, we also check if an inject_credentials configuration key is also defined and has a value of true. If so, we dynamically inject the SMTP username and password into the mail config, based on the credentials retrieved for the domain from the database.

This way, it’s possible to ensure that mail goes through a correctly configured mailer with a simple one liner: Mail::mailer(MailerChoice::forDomain($sendingDomain))->...

Note that MailerChoice here is simply a Laravel facade pointing at an instance bound to the MailerChoice interface in the container. You're free to use dependency injection in your code to call it like so instead:

Mail::mailer($mailerChoice::forDomain($sendingDomain))->...

Improving Laravel mail providers in the future

A fun exercise would be to automate the migration process based on specific criteria (e.g. "no more than x number of mails sent for a given domain via a given provider per day"). This would allow for a transition between mail providers over a designated period of time without human intervention.

That's it! Hopefully this article has inspired you with an idea how you can gradually move across mail providers in a controlled fashion as well as how to dynamically inject credentials into the Laravel mail config when a sending domain requires a unique set of credentials.