DEV Community

Cover image for Mail::alwaysTo() Guards One Mailer: The Staging Leak It Leaves Open
Ivan Mykhavko
Ivan Mykhavko

Posted on AI-assisted

Mail::alwaysTo() Guards One Mailer: The Staging Leak It Leaves Open

A few weeks ago I saved a link about keeping test emails away from real users. The plan was simple: add Mail::alwaysTo() to our staging setup and stop worrying. The Laravel docs list it for local development, and Laravel News plus at least five other blog posts I found carry it over to staging. None of them mention a limit.

Before shipping it, I opened the framework source to see what that call actually does. Turns out, it guards only one of your mailers.

The Safety Net Everyone Copies

Here is the usual version, adapted from the docs and wrapped in an environment check:

use Illuminate\Support\Facades\Mail;

public function boot(): void
{
    if (! $this->app->isProduction()) {
        Mail::alwaysTo('staging@example.com');
    }
}
Enter fullscreen mode Exit fullscreen mode

Every message sent through it gets its to replaced, and cc and bcc are removed. Looks complete.

The Problem

Now add a second mailer. Say transactional mail goes through the default one and invoices through another:

// config/mail.php
'default' => env('MAIL_MAILER', 'smtp'),

'mailers' => [
    'smtp' => [/* ... */],
    'billing' => ['transport' => 'postmark'],
],
Enter fullscreen mode Exit fullscreen mode

I tested this in a sandbox with Laravel 12.43, both mailers on the array transport, and Mail::alwaysTo('staging@example.com') in place:

Mail::to('client@example.com')->send(new InvoiceMail());
// sent to: staging@example.com

Mail::mailer('billing')->to('client@example.com')->send(new InvoiceMail());
// sent to: client@example.com
Enter fullscreen mode Exit fullscreen mode

Notifications have the same hole. MailMessage lets you pick a mailer too:

public function toMail(object $notifiable): MailMessage
{
    return (new MailMessage)
        ->mailer('billing')
        ->line('Your invoice is ready.');
}
// sent to: the real customer
Enter fullscreen mode Exit fullscreen mode

Even with scrubbed staging data, real addresses slip through: admins, seeded clients, imported leads. Each one gets the invoice.

Pointing MAIL_MAILER at Mailpit or log on staging doesn't close this either. It swaps the default mailer only, so if staging has the real Postmark key, Mail::mailer('billing') still uses it.

Why It Happens

Mail::alwaysTo() is not a global setting. The facade points to MailManager, and MailManager forwards unknown calls to the default mailer only:

// Illuminate\Mail\MailManager
public function __call($method, $parameters)
{
    return $this->mailer()->$method(...$parameters);
}
Enter fullscreen mode Exit fullscreen mode

So alwaysTo is stored on one Mailer instance. When you ask for Mail::mailer('billing'), the manager builds a separate instance in resolve(), and that instance gets its global addresses from config:

foreach (['from', 'reply_to', 'to', 'return_path'] as $type) {
    $this->setGlobalAddress($mailer, $config, $type);
}
Enter fullscreen mode Exit fullscreen mode

setGlobalAddress() reads mail.mailers.billing.to first, then falls back to mail.to. Your alwaysTo call is never part of that.

This isn't new. Issue #44024 described the same thing in 2022 and was closed as a support question. The logic is the same in Laravel 12 and on the 13.x branch.

There is a second way it fails, under Octane. Service providers boot once, but Octane forgets the resolved mailers between requests, so the redirect set in boot() is lost even in a single-mailer app. I didn't run Octane for this one. A Laravel maintainer confirmed it in discussion #53727.

The Fix

Put the redirect where every configured mailer reads it: the config. config/mail.php has no to key by default, but MailManager picks one up if you add it:

// config/mail.php
'to' => [
    'address' => env('MAIL_TO_ADDRESS'),
    'name' => env('MAIL_TO_NAME', 'Staging Inbox'),
],
Enter fullscreen mode Exit fullscreen mode
# .env on staging only
MAIL_TO_ADDRESS=staging@example.com
Enter fullscreen mode Exit fullscreen mode

In the same sandbox, both mailers now sent to staging@example.com. The address is applied each time a mailer is built, so it also survives Mail::forgetMailers(), which is what Octane does between requests. I checked that part with a plain call, not a running Octane server.

When MAIL_TO_ADDRESS is not set, nothing changes, so production is unaffected without any if in a provider. Leave the key out of the production .env entirely. An empty MAIL_TO_ADDRESS= is still a value, and every send then fails with RfcComplianceException.

One trade-off: this fails open. Forget the variable on a new staging box and mail goes out for real. I'd add a guard for that:

public function boot(): void
{
    if ($this->app->environment('staging') && blank(config('mail.to.address'))) {
        throw new RuntimeException('MAIL_TO_ADDRESS must be set on staging.');
    }
}
Enter fullscreen mode Exit fullscreen mode

Heads up: two exceptions. A mailer with its own to key in mail.mailers wins over the global one, even 'to' => null. And a mailer built on the fly with Mail::build([...]) skips global addresses completely.

A Test That Keeps It Fixed

The per-mailer to is the one that bites later. Someone adds a to to one mailer months later, never having read this config. So I'd rather have a test that checks every mailer against the real config. First, give the test environment the address:

<!-- phpunit.xml -->
<env name="MAIL_TO_ADDRESS" value="staging@example.com"/>
Enter fullscreen mode Exit fullscreen mode
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;

final class MailRedirectTest extends TestCase
{
    public function test_every_mailer_sends_to_the_staging_inbox(): void
    {
        // Arrange
        $mailers = array_keys(config('mail.mailers'));

        foreach ($mailers as $name) {
            config(["mail.mailers.{$name}.transport" => 'array']);
        }

        Mail::forgetMailers();

        foreach ($mailers as $name) {
            // Act
            Mail::mailer($name)->raw('ping', fn ($message) => $message->to('client@example.com'));

            // Assert
            $sent = Mail::mailer($name)->getSymfonyTransport()->messages()->last()->getOriginalMessage();

            $this->assertSame('staging@example.com', $sent->getTo()[0]->getAddress(), "Mailer [{$name}] escaped the redirect");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It swaps every transport to array, so nothing is actually sent. Mail::forgetMailers() matters here: a mailer resolved before the test keeps its old transport, and with a log default the assertion crashes on LogTransport, which has no messages().

With the nine mailers from the default config (smtp, ses, postmark, resend, sendmail, log, array, failover, roundrobin) it passes. Give billing its own to and it fails with Mailer [billing] escaped the redirect. Delete the to block from config/mail.php and it fails on smtp. Don't use Mail::fake() here: the fake replaces the manager, so it can't tell you anything about redirects.

Final Thoughts

Mail::alwaysTo() is fine on your laptop, with one mailer and no Octane. For staging, where real addresses live in the database, I'd use mail.to in config, a boot guard for the missing variable, and a test that walks every mailer.


Author's Note

Thanks for sticking around!
Find me on dev.to, linkedin, or you can check out my work on github.

Laravel, after the happy path.

Top comments (1)

Collapse
 
officialmailkr profile image
오피셜메일 •

Mail::alwaysTo()가 기본 mailer 인스턴스에만 적용되고 Mail::mailer("billing")는 빠진다는 재현이 명확했습니다. 특히 Mail::fake()로는 실제 리다이렉트를 검증할 수 없어서 모든 설정된 mailer를 array 전송으로 시험한다는 부분이 유용하네요. 글에서 예외로 짚은 Mail::build() 같은 동적 생성 경로도 별도 테스트 목록에 넣고, 스테이징에는 실서비스 발송 자격 증명 자체가 없는지 확인하면 방어선을 하나 더 둘 수 있겠습니다.