diff --git a/database/migrations/2026_07_28_100000_seed_mailboxes_from_environment.php b/database/migrations/2026_07_28_100000_seed_mailboxes_from_environment.php index 6a0ba55..8c7e339 100644 --- a/database/migrations/2026_07_28_100000_seed_mailboxes_from_environment.php +++ b/database/migrations/2026_07_28_100000_seed_mailboxes_from_environment.php @@ -121,6 +121,50 @@ return new class extends Migration return array_merge($config, (new ConfigurationUrlParser)->parseConfiguration($config)); } + /** + * The scheme an operator actually stated, or null if nobody did. + * + * Two different fields can carry this, and they are not interchangeable: + * + * - $smtp['scheme'] is config/mail.php's 'scheme' => env('MAIL_SCHEME'), + * Laravel's own single-purpose encryption knob. createSmtpTransport() + * reads it directly (MailManager.php:196) with no other meaning + * possible — an operator who set it MEANT it. + * - $smtp['driver'] exists only after resolveSmtpConfig() has run the URL + * through ConfigurationUrlParser, and only when MAIL_URL was set. It is + * NOT the same slot: getPrimaryOptions() maps a URL's scheme into + * 'driver' (verified directly against the installed class), because + * Laravel's MailManager::getConfig() reuses that same field to pick + * WHICH TRANSPORT to build ($config['transport'] = Arr::pull($config, + * 'driver')) — the URL scheme is doing double duty as a transport + * selector, not purely an encryption statement. + * + * That difference is why 'smtps' is trusted from either field but 'smtp' + * only from the first: 'smtps://' in a MAIL_URL is unambiguous — nothing + * else lets an operator say implicit TLS through a URL alone. A plain + * 'smtp://' is simply the ordinary, only real-world way to write this + * mailer's URL and says nothing about TLS either way; the port heuristic + * below already carries that intent, so it is deliberately NOT promoted + * to "none" here the way an explicit MAIL_SCHEME=smtp is. + * + * Anything else — including this machine's own MAIL_SCHEME=tls, which + * Symfony's EsmtpTransportFactory rejects outright (it supports only + * 'smtp' and 'smtps', see getSupportedSchemes()) — is not a real scheme + * and must not silently steer the result toward either 'ssl' or 'none'. + * Returning null for it here is what sends seed() to the port heuristic + * instead, the same place a genuinely absent MAIL_SCHEME already goes. + */ + private function resolveScheme(array $smtp): ?string + { + $scheme = $smtp['scheme'] ?? null; + + if (in_array($scheme, ['smtp', 'smtps'], true)) { + return $scheme; + } + + return ($smtp['driver'] ?? null) === 'smtps' ? 'smtps' : null; + } + private function seed(): void { $smtp = $this->resolveSmtpConfig(); @@ -135,15 +179,22 @@ return new class extends Migration $port = $configuredPort === self::UNCONFIGURED_PORT ? 0 : $configuredPort; Settings::set('mail.port', $port); - // NOT copied from MAIL_SCHEME. That variable currently reads "tls", - // which is not a Symfony mailer scheme — Symfony knows smtp and smtps, - // and Laravel 13's MailManager passes scheme straight through with no - // encryption fallback. It has never failed only because MAIL_MAILER is - // log, so nothing has opened a real connection. - // - // Stored as a human-facing choice instead, translated to a scheme where - // it is used: 465 means implicit TLS, everything else means STARTTLS. - Settings::set('mail.encryption', $port === 465 ? 'ssl' : 'tls'); + // Codex R15#4, P1a: a scheme actually STATED (MAIL_SCHEME=smtps, or an + // smtps:// MAIL_URL) must be believed over the port — an implicit-TLS + // relay on a non-465 port must not be downgraded to STARTTLS just + // because this migration guessed from the port instead of the scheme + // that was already sitting there. Only when nobody stated one + // (resolveScheme() returns null — including for this machine's own + // invalid MAIL_SCHEME=tls, which is not a real Symfony scheme under + // either spelling) does the port heuristic from before still apply: + // 465 means implicit TLS, everything else means STARTTLS. + $scheme = $this->resolveScheme($smtp); + + Settings::set('mail.encryption', match ($scheme) { + 'smtps' => 'ssl', + 'smtp' => 'none', + default => $port === 465 ? 'ssl' : 'tls', + }); $configuredUser = (string) ($smtp['username'] ?? ''); diff --git a/tests/Feature/Mail/MailboxSeedMigrationTest.php b/tests/Feature/Mail/MailboxSeedMigrationTest.php index 89575a7..80074f9 100644 --- a/tests/Feature/Mail/MailboxSeedMigrationTest.php +++ b/tests/Feature/Mail/MailboxSeedMigrationTest.php @@ -43,6 +43,103 @@ it('derives ssl from port 465, regardless of what scheme is configured', functio ->and(Settings::get('mail.port'))->toBe(465); }); +it('keeps implicit TLS when MAIL_SCHEME is smtps on a non-standard port — Codex R15#4, P1a', function () { + // The finding, first half: an operator who already has MAIL_SCHEME=smtps + // (Laravel's own dedicated, unambiguous encryption knob) must not have + // that downgraded to STARTTLS just because their submission port is not + // 465 — a relay speaking implicit TLS on, say, 587 stops accepting mail + // the moment this migration replaces a working config with a guess. + clearMailboxSeed(); + config()->set('mail.mailers.smtp.scheme', 'smtps'); + config()->set('mail.mailers.smtp.port', 587); + + loadMailboxSeedMigration()->up(); + + expect(Settings::get('mail.encryption'))->toBe('ssl'); +}); + +it('keeps implicit TLS when MAIL_URL uses the smtps scheme on a non-standard port', function () { + // The finding, second half: the same relay, described through MAIL_URL + // instead of MAIL_SCHEME. 'smtps' in the URL is resolved through the + // same Illuminate\Support\ConfigurationUrlParser Laravel itself uses — + // it surfaces on the merged config as the 'driver' key (parseConfiguration + // maps a URL scheme into 'driver', not 'scheme' — verified directly + // against the installed class), not re-derived by hand from the URL + // string a second time. + clearMailboxSeed(); + config()->set('mail.mailers.smtp.scheme', null); + config()->set('mail.mailers.smtp.url', 'smtps://user:pass@smtp.clupilot.test:587'); + + loadMailboxSeedMigration()->up(); + + expect(Settings::get('mail.encryption'))->toBe('ssl'); +}); + +it('treats the invalid MAIL_SCHEME=tls value this machine\'s own .env carries as no scheme at all', function () { + // This container's real .env has exactly this value. Symfony's + // EsmtpTransportFactory::getSupportedSchemes() only knows 'smtp' and + // 'smtps' — 'tls' is not a real scheme under either spelling, so it must + // fall through to the port heuristic (587 -> the ENCRYPTION CHOICE + // named 'tls', a coincidence of spelling, not the scheme) exactly like + // no MAIL_SCHEME at all — reproducing this installation's actual + // behaviour unchanged. The point: an unrecognised scheme string must not + // silently steer the result toward 'ssl' or 'none' either. + clearMailboxSeed(); + config()->set('mail.mailers.smtp.scheme', 'tls'); + config()->set('mail.mailers.smtp.port', 587); + + loadMailboxSeedMigration()->up(); + + expect(Settings::get('mail.encryption'))->toBe('tls'); +}); + +it('derives none from an explicit smtp MAIL_SCHEME, even on port 465', function () { + // The other explicit direction: MAIL_SCHEME=smtp is just as deliberate a + // statement as smtps, and must win over the port heuristic the same way + // — an operator who said "plain smtp" was never asking for implicit TLS + // just because their port happens to be 465. + clearMailboxSeed(); + config()->set('mail.mailers.smtp.scheme', 'smtp'); + config()->set('mail.mailers.smtp.port', 465); + + loadMailboxSeedMigration()->up(); + + expect(Settings::get('mail.encryption'))->toBe('none'); +}); + +it('does not treat a plain smtp:// MAIL_URL scheme as a stated encryption choice', function () { + // Asymmetric with 'smtps://' on purpose: the URL's scheme slot is doing + // double duty in Laravel's own MailManager (it is also how "which + // transport" gets chosen) and 'smtp://' is the ordinary, only + // real-world spelling for this mailer's URL — not a deliberate "no + // encryption" statement. Only 'smtps://' is unambiguous enough to + // promote; 'smtp://' is left to the port heuristic below, same as no + // scheme at all — proven here by still reaching 'ssl' on port 465 + // despite the explicit smtp:// scheme in the URL. + clearMailboxSeed(); + config()->set('mail.mailers.smtp.scheme', null); + config()->set('mail.mailers.smtp.url', 'smtp://user:pass@smtp.clupilot.test:465'); + + loadMailboxSeedMigration()->up(); + + expect(Settings::get('mail.encryption'))->toBe('ssl'); +}); + +it('prefers an explicit MAIL_SCHEME over a conflicting MAIL_URL scheme', function () { + // MAIL_SCHEME is Laravel's single-purpose encryption knob; the URL's + // scheme is doing double duty (see above). When both are present and + // disagree, the unambiguous one wins — matching createSmtpTransport()'s + // own precedence ($config['scheme'] ?? , checked before + // anything born from the URL even enters the picture). + clearMailboxSeed(); + config()->set('mail.mailers.smtp.scheme', 'smtp'); + config()->set('mail.mailers.smtp.url', 'smtps://user:pass@smtp.clupilot.test:587'); + + loadMailboxSeedMigration()->up(); + + expect(Settings::get('mail.encryption'))->toBe('none'); +}); + it('treats config/mail.php\'s own 127.0.0.1 / 2525 defaults as unset, not as a real relay', function () { clearMailboxSeed(); config()->set('mail.mailers.smtp.host', '127.0.0.1');