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
parent
06b84c5cd5
commit
095d8e694a
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,10 +25,12 @@ use App\Mail\MaintenanceCancelledMail;
|
||||||
use App\Models\MaintenanceNotification;
|
use App\Models\MaintenanceNotification;
|
||||||
use App\Services\Maintenance\MaintenanceNotifier;
|
use App\Services\Maintenance\MaintenanceNotifier;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
use App\Mail\Transport\MailboxTransport;
|
||||||
use Illuminate\Mail\Events\MessageSending;
|
use Illuminate\Mail\Events\MessageSending;
|
||||||
use Illuminate\Mail\Events\MessageSent;
|
use Illuminate\Mail\Events\MessageSent;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
|
@ -58,6 +60,11 @@ class AppServiceProvider extends ServiceProvider
|
||||||
*/
|
*/
|
||||||
public function boot(): void
|
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()
|
// Every timestamp that gets shown to somebody goes through ->local()
|
||||||
// first. Storage is UTC and stays UTC; this is the last step before a
|
// first. Storage is UTC and stays UTC; this is the last step before a
|
||||||
// human reads it.
|
// human reads it.
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,20 @@ return [
|
||||||
'retry_after' => 60,
|
'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'],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue