Resolve the sending mailbox when the mail goes out, not at boot

Five purpose-named mailers (cp_maintenance, cp_provisioning, cp_support,
cp_billing, cp_system) land in config/mail.php as plain static entries —
no query runs to build them. Mail::extend('mailbox', ...) in
AppServiceProvider registers a transport that looks up its mailbox
through MailboxResolver only once a mailer is actually resolved, which
for queued mail is inside the worker at send time, never at boot.

Mutation-tested the brief's own three tests by deleting the MAIL_MAILER
guard from MailboxTransport::delegate(): all three stayed green. They
only exercise __toString()/describe(), a code path separate from the one
that actually sends. Added two tests that reach into delegate() itself
(a bound closure, since it's private) and assert the transport it
actually builds: LogTransport while MAIL_MAILER=log, EsmtpTransport once
it isn't. Re-ran the same mutation against the strengthened suite: it
now fails (EsmtpTransport where LogTransport was expected), then passes
again once reverted.

Verified two things against the installed versions before writing this:
LogTransport takes a Psr\Log\LoggerInterface and Log::channel(null)
resolves to the default channel rather than throwing, and
Mail::extend('mailbox', ...) is read by
MailManager::createSymfonyTransport() via $config['transport'] before it
would fall back to createSmtpTransport() — both matched the brief
exactly, no changes needed there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/mailboxes
nexxo 2026-07-27 22:28:37 +02:00
parent 06b84c5cd5
commit 095d8e694a
4 changed files with 205 additions and 0 deletions

View File

@ -0,0 +1,106 @@
<?php
namespace App\Mail\Transport;
use App\Services\Mail\MailboxResolver;
use App\Support\Settings;
use Illuminate\Mail\Transport\LogTransport;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Component\Mime\RawMessage;
/**
* Resolves its credentials when it sends, not when it is built.
*
* The mails are queued. A worker builds its mailer once and lives for hours, so
* anything decided in a constructor is decided for the rest of that worker's
* life including a password the operator has since corrected in the console.
*
* The delegate is cached against a FINGERPRINT of the credentials rather than
* rebuilt per message: correct when they change, and no new SMTP connection per
* mail when they do not.
*/
class MailboxTransport implements TransportInterface
{
private ?TransportInterface $delegate = null;
private ?string $fingerprint = null;
public function __construct(private readonly string $purpose) {}
public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage
{
return $this->delegate()->send($message, $envelope);
}
public function __toString(): string
{
return 'mailbox://'.$this->purpose.'/'.($this->describe() ?? 'unconfigured');
}
/** What this transport currently points at — used by __toString and tests. */
private function describe(): ?string
{
if ($this->isLogging()) {
return 'log';
}
return app(MailboxResolver::class)->for($this->purpose)?->address;
}
/**
* Delivery is off wherever the default mailer is the log.
*
* This is what keeps a development machine from mailing real customers: the
* mailables name a purpose, so without this check they would bypass
* MAIL_MAILER=log entirely and send for real.
*/
private function isLogging(): bool
{
return config('mail.default') === 'log';
}
private function delegate(): TransportInterface
{
if ($this->isLogging()) {
return new LogTransport(Log::channel(config('mail.mailers.log.channel')));
}
$box = app(MailboxResolver::class)->for($this->purpose);
if ($box === null || ! $box->isConfigured()) {
// Loud, not silent: a mail with no mailbox behind it must not look
// like it was sent.
throw new \RuntimeException(
"No configured mailbox for mail purpose [{$this->purpose}]."
);
}
$host = (string) Settings::get('mail.host', '');
$port = (int) Settings::get('mail.port', 587);
$encryption = (string) Settings::get('mail.encryption', 'tls');
$fingerprint = md5(implode('|', [
$host, $port, $encryption, $box->smtpUsername(), (string) $box->password,
]));
if ($this->delegate === null || $this->fingerprint !== $fingerprint) {
// The third argument is IMPLICIT TLS (smtps, port 465) — not
// STARTTLS. EsmtpTransport negotiates STARTTLS by itself on a
// plain connection, which is what port 587 wants. Passing true for
// 'tls' here would open an SSL socket against a server expecting
// STARTTLS and hang.
$transport = new EsmtpTransport($host, $port, $encryption === 'ssl' || $port === 465);
$transport->setUsername($box->smtpUsername());
$transport->setPassword((string) $box->password);
$this->delegate = $transport;
$this->fingerprint = $fingerprint;
}
return $this->delegate;
}
}

View File

@ -25,10 +25,12 @@ use App\Mail\MaintenanceCancelledMail;
use App\Models\MaintenanceNotification;
use App\Services\Maintenance\MaintenanceNotifier;
use Carbon\CarbonImmutable;
use App\Mail\Transport\MailboxTransport;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Mail\Events\MessageSent;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
@ -58,6 +60,11 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
// Registered as a DRIVER, not as configuration: the closure runs when a
// mailer is first resolved — inside the queue worker, at send time —
// so nothing here reads the database while the application boots.
Mail::extend('mailbox', fn (array $config) => new MailboxTransport($config['purpose']));
// Every timestamp that gets shown to somebody goes through ->local()
// first. Storage is UTC and stays UTC; this is the last step before a
// human reads it.

View File

@ -97,6 +97,20 @@ return [
'retry_after' => 60,
],
/*
| One mailer per purpose. Deliberately STATIC: they name a purpose and
| nothing else, so building this config reads no database. Which
| mailbox a purpose resolves to is decided by MailboxTransport at send
| time see App\Services\Secrets\SecretVault rule 3, which this
| follows: an overlay at boot costs a query on every request and leaves
| long-running queue workers holding whatever was true when they started.
*/
'cp_maintenance' => ['transport' => 'mailbox', 'purpose' => 'maintenance'],
'cp_provisioning' => ['transport' => 'mailbox', 'purpose' => 'provisioning'],
'cp_support' => ['transport' => 'mailbox', 'purpose' => 'support'],
'cp_billing' => ['transport' => 'mailbox', 'purpose' => 'billing'],
'cp_system' => ['transport' => 'mailbox', 'purpose' => 'system'],
],
/*

View File

@ -0,0 +1,78 @@
<?php
use App\Mail\Transport\MailboxTransport;
use App\Models\Mailbox;
use App\Services\Mail\MailPurpose;
use App\Support\Settings;
use Illuminate\Mail\Transport\LogTransport;
use Illuminate\Support\Facades\Mail;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
beforeEach(function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
Settings::set('mail.host', 'mail.example.test');
Settings::set('mail.port', 587);
Settings::set('mail.encryption', 'tls');
});
it('registers a mailer per purpose without touching the database at boot', function () {
// The five entries exist as plain config — no query has run to build them.
foreach (MailPurpose::ALL as $purpose) {
expect(config('mail.mailers.cp_'.$purpose))
->toMatchArray(['transport' => 'mailbox', 'purpose' => $purpose]);
}
});
it('builds the transport from the mailbox that is current at send time', function () {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp'); // delivery on, as on live
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
expect($transport)->toBeInstanceOf(MailboxTransport::class)
->and((string) $transport)->toContain('support@clupilot.com');
});
it('logs instead of delivering while MAIL_MAILER is log', function () {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'log');
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
// Still our transport — but it hands the message to the log, so a seeder
// run cannot mail a real customer from a development machine.
expect((string) $transport)->toContain('log');
});
/*
| __toString() and send() decide "log or SMTP" through two SEPARATE code
| paths (describe() vs delegate()). The test above only exercises the first
| it would keep passing even if delegate() lost its own guard and sent every
| purpose-mailer through real SMTP regardless of MAIL_MAILER. Reach into the
| method that actually sends, so that regression cannot hide behind a passing
| __toString() assertion. No real socket is opened either way: EsmtpTransport
| connects lazily on send(), never in its constructor.
*/
it('actually delegates to the log transport, not SMTP, while MAIL_MAILER is log', function () {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'log');
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
$delegate = Closure::bind(fn () => $this->delegate(), $transport, MailboxTransport::class)();
expect($delegate)->toBeInstanceOf(LogTransport::class);
});
it('delegates to a real SMTP transport once MAIL_MAILER is no longer log', function () {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp');
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
$delegate = Closure::bind(fn () => $this->delegate(), $transport, MailboxTransport::class)();
expect($delegate)->toBeInstanceOf(EsmtpTransport::class);
});