diff --git a/app/Mail/Transport/MailboxTransport.php b/app/Mail/Transport/MailboxTransport.php new file mode 100644 index 0000000..0137540 --- /dev/null +++ b/app/Mail/Transport/MailboxTransport.php @@ -0,0 +1,106 @@ +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; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index aeab946..6929113 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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. diff --git a/config/mail.php b/config/mail.php index e32e88d..a980d38 100644 --- a/config/mail.php +++ b/config/mail.php @@ -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'], + ], /* diff --git a/tests/Feature/Mail/MailboxTransportTest.php b/tests/Feature/Mail/MailboxTransportTest.php new file mode 100644 index 0000000..6b52087 --- /dev/null +++ b/tests/Feature/Mail/MailboxTransportTest.php @@ -0,0 +1,78 @@ +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); +});