set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); clearMailboxSeed(); Settings::set('mail.host', 'mail.example.test'); Settings::set('mail.port', 587); Settings::set('mail.encryption', 'tls'); }); /** * Reach into the same private delegate() that send() itself calls. * * __toString() and send() decide "log, array, null, or a real mailbox" * through describe()/delegate() — two DIFFERENT methods. A mutation that * broke only delegate() once passed the whole suite, because every test * checked (string) $transport, which never calls it. Where a behavioural * proof (actually sending, see the log-guard test below) is impractical * without real I/O, this is how those tests reach the one that matters. */ function mailboxDelegate(TransportInterface $transport): TransportInterface { return Closure::bind(fn () => $this->delegate(), $transport, MailboxTransport::class)(); } it('registers a mailer for every purpose', 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('defines every purpose mailer as a plain literal, so building it cannot touch the database', function () { // A slice with no parenthesis in it cannot contain a function call — // env(), config(), DB::table(), all of them need one. Checked at the // SOURCE level, the same technique DisplayTimezoneTest and IconLayoutTest // use for a property of the file rather than of one particular run. $source = (string) file_get_contents(config_path('mail.php')); foreach (MailPurpose::ALL as $purpose) { preg_match('/\'cp_'.preg_quote($purpose, '/').'\'\s*=>\s*(\[[^\]]*\])/', $source, $matches); expect($matches)->toHaveCount(2, "cp_{$purpose} is missing or not a plain array literal"); expect($matches[1])->not->toContain('(') ->and($matches[1])->toContain("'mailbox'") ->and($matches[1])->toContain("'{$purpose}'"); } }); it('resolves the purpose to the address of the mailbox it is currently mapped to', 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('marks the DSN as unconfigured rather than showing a healthy-looking address with no password', function () { // The exact harm the finding named: describe() only ever showed the // address, so a mailbox with an active row, a real address, and no // password looked identical to a working one — right up until send() // threw. The address stays visible (an operator needs to know WHICH // mailbox is broken); the marker is what stops it from reading as fine. Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com', 'password' => null]); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); config()->set('mail.default', 'smtp'); $transport = Mail::mailer('cp_support')->getSymfonyTransport(); expect((string) $transport) ->toContain('support@clupilot.com') ->toContain('unconfigured'); // Agreement, not just a matching label: the DSN's warning must correspond // to send() actually refusing, not to a marker painted on independently. expect(fn () => mailboxDelegate($transport))->toThrow(RuntimeException::class); }); 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'); }); /* | LOAD-BEARING for the MAIL_MAILER=log guard — this is the one that actually | SENDS, so a broken guard cannot hide behind a __toString() that still looks | right. Settings point at a closed loopback port: nothing listens on | 127.0.0.1:1, so a real attempt is refused in milliseconds — no DNS, no | dependency on this environment's network egress policy, and the failure | (a thrown TransportException) names the actual harm rather than a class | mismatch. */ it('actually sends through 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'); Settings::set('mail.host', '127.0.0.1'); Settings::set('mail.port', 1); Log::shouldReceive('channel')->andReturnSelf(); Log::shouldReceive('debug')->once(); Mail::mailer('cp_support')->raw('body', function ($message) { $message->to('customer@example.test')->subject('Test'); }); }); it('does not deliver for real when MAIL_MAILER is array, the suite-wide test default', function () { // phpunit.xml forces MAIL_MAILER=array for the whole suite. Task 5 wires // real mailables to these mailers next — the first feature test that // triggers one without Mail::fake() must not open a real socket just // because 'array' is not the exact string 'log'. Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); config()->set('mail.default', 'array'); $transport = Mail::mailer('cp_support')->getSymfonyTransport(); expect(mailboxDelegate($transport))->toBeInstanceOf(ArrayTransport::class); }); it('does not deliver for real when MAIL_MAILER is not configured at all', function () { Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); config()->set('mail.default', null); $transport = Mail::mailer('cp_support')->getSymfonyTransport(); expect(mailboxDelegate($transport))->toBeInstanceOf(NullTransport::class); }); it('reuses the same delegate until the mailbox credentials actually change', function () { // The headline feature of the class. A worker builds its mailer once and // keeps it for hours, so a password the operator corrects in the console // must reach the NEXT mail without a restart — but not open a new SMTP // connection for every single message when nothing changed either. $box = Mailbox::factory()->create([ 'key' => 'support', 'address' => 'support@clupilot.com', 'password' => 'first-password', ]); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); config()->set('mail.default', 'smtp'); $transport = Mail::mailer('cp_support')->getSymfonyTransport(); $first = mailboxDelegate($transport); $second = mailboxDelegate($transport); expect($second)->toBe($first); // nothing changed — no new connection per mail $box->update(['password' => 'second-password']); $third = mailboxDelegate($transport); expect($third)->not->toBe($first); // the operator's correction takes effect immediately }); // --- Codex R15#4, P1b: an unauthenticated relay must not have credentials // forced onto it. smtpUsername() always returns something non-empty (it // falls back to the address), so a delegate built without checking // authenticates first would still call setUsername() with a real value — // which alone is enough to make Symfony attempt AUTH against any server // that advertises it, something a trusted no-auth relay was never asked to // answer. it('never calls setUsername or setPassword on the delegate when the mailbox does not authenticate', function () { Mailbox::factory()->create([ 'key' => 'support', 'address' => 'support@clupilot.com', 'username' => null, 'password' => null, 'authenticates' => false, ]); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); config()->set('mail.default', 'smtp'); $transport = Mail::mailer('cp_support')->getSymfonyTransport(); $delegate = mailboxDelegate($transport); expect($delegate)->toBeInstanceOf(EsmtpTransport::class) ->and($delegate->getUsername())->toBe('') ->and($delegate->getPassword())->toBe(''); }); it('still sets a real username and password on the delegate when the mailbox does authenticate', function () { // The control case for the test above: the fix must not stop // authenticated mailboxes from authenticating. Mailbox::factory()->create([ 'key' => 'support', 'address' => 'support@clupilot.com', 'username' => null, 'password' => 'a-real-password', 'authenticates' => true, ]); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); config()->set('mail.default', 'smtp'); $transport = Mail::mailer('cp_support')->getSymfonyTransport(); $delegate = mailboxDelegate($transport); expect($delegate->getUsername())->toBe('support@clupilot.com') // smtpUsername()'s address fallback ->and($delegate->getPassword())->toBe('a-real-password'); }); it('rebuilds the delegate when authenticates toggles, even though host, port, encryption and password all stay the same', function () { // reuses the same delegate...' above proves the fingerprint reacts to a // PASSWORD change; this proves it also reacts to authenticates itself — // without that, an operator unchecking "requires a password" on a // long-running queue worker would keep sending through the OLD delegate, // which already has setUsername()/setPassword() baked in from before the // toggle, still attempting AUTH the operator just turned off. $box = Mailbox::factory()->create([ 'key' => 'support', 'address' => 'support@clupilot.com', 'username' => null, 'password' => 'unchanged-password', 'authenticates' => true, ]); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); config()->set('mail.default', 'smtp'); $transport = Mail::mailer('cp_support')->getSymfonyTransport(); $first = mailboxDelegate($transport); expect($first->getUsername())->not->toBe(''); $box->update(['authenticates' => false]); $second = mailboxDelegate($transport); expect($second)->not->toBe($first) ->and($second->getUsername())->toBe(''); }); // --- Codex R15#5, P2 comparison pass: the fingerprint line above reads // (string) $box->password UNCONDITIONALLY, even when authenticates is false // and setUsername()/setPassword() are never called on the result. $box // ->password decrypts under SECRETS_KEY, so a mailbox that once // authenticated, stored a password, and later had authenticates unchecked — // leaving the old ciphertext in place, since an empty password field in // EditMailbox means "leave it alone" — would crash a real send the moment // SECRETS_KEY became unusable, purely to compute a cache key for a value it // was never going to use. Same root cause as the MailboxTester fix above. it('does not decrypt a stale password to build the delegate for an unauthenticated mailbox, even without a usable cipher', function () { $box = Mailbox::factory()->create([ 'key' => 'support', 'address' => 'support@clupilot.com', 'password' => 'a-stale-password', 'authenticates' => false, ]); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); config()->set('mail.default', 'smtp'); config()->set('admin_access.secrets_key', ''); $transport = Mail::mailer('cp_support')->getSymfonyTransport(); // No expect(fn () => ...)->not->toThrow(Throwable::class) here on // purpose: Throwable is an interface, and Pest's toThrow() falls back to // treating a class-string it cannot class_exists() as a MESSAGE // substring to search for instead — so that matcher silently passes // regardless of what is thrown (confirmed by hand: a bare `throw new // RuntimeException('boom')` inside such a closure still reports as not // having thrown). Calling delegate() directly and asserting on its // return type instead means an uncaught exception fails this test the // ordinary way, with its real stack trace, rather than being silently // absorbed by a matcher that never engages. $delegate = mailboxDelegate($transport); expect($delegate)->toBeInstanceOf(EsmtpTransport::class) ->and($delegate->getUsername())->toBe('') ->and($delegate->getPassword())->toBe(''); }); it('opens a plain connection with a STARTTLS upgrade for the submission port', function () { Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); config()->set('mail.default', 'smtp'); Settings::set('mail.port', 587); Settings::set('mail.encryption', 'tls'); $transport = Mail::mailer('cp_support')->getSymfonyTransport(); $delegate = mailboxDelegate($transport); expect((string) $delegate)->toBe('smtp://mail.example.test:587') ->and($delegate)->toBeInstanceOf(EsmtpTransport::class) // The P1 fix: 'tls' is a REQUIREMENT, not a suggestion Symfony is // free to skip when the server omits STARTTLS. autoTls must stay on // too — without it, the STARTTLS upgrade this mode depends on would // never even be attempted, and requireTls would then fail every send. ->and($delegate->isTlsRequired())->toBeTrue() ->and($delegate->isAutoTls())->toBeTrue(); }); it('opens an implicit-TLS connection for the SMTPS port, never a hanging STARTTLS one', function () { Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); config()->set('mail.default', 'smtp'); Settings::set('mail.port', 465); Settings::set('mail.encryption', 'ssl'); $transport = Mail::mailer('cp_support')->getSymfonyTransport(); $delegate = mailboxDelegate($transport); expect((string) $delegate)->toBe('smtps://mail.example.test') ->and($delegate)->toBeInstanceOf(EsmtpTransport::class) ->and($delegate->isTlsRequired())->toBeTrue(); }); it('disables both implicit TLS and the opportunistic STARTTLS upgrade when encryption is none', function () { // 'none' is a deliberate, explicit choice — not the absence of one — so // it must not silently upgrade just because a server happens to offer // STARTTLS. autoTls defaults to true in Symfony; this is the one mode // that has to turn it off rather than merely leave requireTls false. Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); config()->set('mail.default', 'smtp'); Settings::set('mail.port', 587); Settings::set('mail.encryption', 'none'); $transport = Mail::mailer('cp_support')->getSymfonyTransport(); $delegate = mailboxDelegate($transport); expect((string) $delegate)->toBe('smtp://mail.example.test:587') ->and($delegate)->toBeInstanceOf(EsmtpTransport::class) ->and($delegate->isAutoTls())->toBeFalse() ->and($delegate->isTlsRequired())->toBeFalse(); }); /* | LOAD-BEARING for the P1 security fix — property assertions above prove the | transport is CONFIGURED correctly, but the recurring defect on this branch | has been tests that pass whether or not the code is right, so this proves | the configuration actually changes what goes out on the wire. FakeSmtpServer | never advertises STARTTLS (see its own docblock), which is exactly the | shape of the threat Codex named: a server — or a network attacker sitting in | front of one — that omits the capability. Before the fix, 'tls' passed | false as EsmtpTransport's third constructor argument and left requireTls at | its default of false: autoTls found no STARTTLS to use, nothing else | objected, and the send completed in the clear. after the fix, the same | conversation must throw instead. */ it('refuses to send in the clear when TLS is required but the server never offers STARTTLS', function () { $server = FakeSmtpServer::succeeding(); try { Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); config()->set('mail.default', 'smtp'); Settings::set('mail.host', '127.0.0.1'); Settings::set('mail.port', $server->port); Settings::set('mail.encryption', 'tls'); expect(fn () => Mail::mailer('cp_support')->raw('body', function ($message) { $message->to('customer@example.test')->subject('Test'); }))->toThrow(TransportException::class, 'TLS required but neither TLS or STARTTLS are in use.'); } finally { $server->stop(); } }); it('still sends over a plain connection when encryption is none, against a server that never offers TLS', function () { // The control case for the test above: requireTls must stay OFF for // 'none', or a legitimately plaintext-only relay would break the moment // this fix landed. $server = FakeSmtpServer::succeeding(); try { Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); config()->set('mail.default', 'smtp'); Settings::set('mail.host', '127.0.0.1'); Settings::set('mail.port', $server->port); Settings::set('mail.encryption', 'none'); expect(fn () => Mail::mailer('cp_support')->raw('body', function ($message) { $message->to('customer@example.test')->subject('Test'); }))->not->toThrow(Throwable::class); } finally { $server->stop(); } }); it('refuses an implicit-TLS connection against a server that only ever speaks plain text', function () { // The 'ssl' mirror of the two tests above: FakeSmtpServer only ever // speaks plain text (see its docblock), so a correct implicit-TLS attempt // must fail the OpenSSL handshake immediately rather than falling back to // a plaintext conversation. $server = FakeSmtpServer::succeeding(); try { Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); config()->set('mail.default', 'smtp'); Settings::set('mail.host', '127.0.0.1'); Settings::set('mail.port', $server->port); Settings::set('mail.encryption', 'ssl'); expect(fn () => Mail::mailer('cp_support')->raw('body', function ($message) { $message->to('customer@example.test')->subject('Test'); }))->toThrow(TransportException::class); } finally { $server->stop(); } }); it('refuses to send when the mail server host is blank', function () { Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); config()->set('mail.default', 'smtp'); Settings::set('mail.host', ''); // Tasks 6-7 are what write this — nothing has yet $transport = Mail::mailer('cp_support')->getSymfonyTransport(); expect(fn () => mailboxDelegate($transport))->toThrow(RuntimeException::class); }); it('refuses to send when the mail server port is not a positive number', function () { Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']); Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support'); config()->set('mail.default', 'smtp'); Settings::set('mail.port', null); // what a stored NULL json-decodes to, then casts to 0 $transport = Mail::mailer('cp_support')->getSymfonyTransport(); expect(fn () => mailboxDelegate($transport))->toThrow(RuntimeException::class); });