Trust a stated mail scheme over the port guess when seeding encryption

MAIL_SCHEME=smtps or an smtps:// MAIL_URL on a non-465 port used to be
downgraded to STARTTLS because mail.encryption was derived from the port
alone. Resolve the scheme first (from MAIL_SCHEME directly, or from the
URL's own scheme via the same ConfigurationUrlParser Laravel uses) and
only fall back to the port heuristic when nothing was actually stated -
which is also what keeps this machine's invalid MAIL_SCHEME=tls from
silently steering the result.
feat/mailboxes
nexxo 2026-07-28 05:53:51 +02:00
parent 74ac406fc0
commit 40a6b3269d
2 changed files with 157 additions and 9 deletions

View File

@ -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'] ?? '');

View File

@ -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'] ?? <port fallback>, 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');