CluPilotCloud/app/Services/Mail/MailboxTester.php

151 lines
7.6 KiB
PHP

<?php
namespace App\Services\Mail;
use App\Models\Mailbox;
use App\Services\Secrets\SecretCipher;
use App\Support\Settings;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Sends one real message with one mailbox's credentials.
*
* Deliberately built with Mail::build() rather than going through the
* configured mailers: on a development machine the default is the log, and a
* button that honoured that would report success while writing to a file. A
* check that quietly examines something else is worse than no check — the
* same mistake the timezone tests made by computing the expected value with
* the implementation they were testing.
*
* @return array{ok: bool, error: ?string}
*/
final class MailboxTester
{
public function __construct(private readonly SecretCipher $cipher) {}
public function run(Mailbox $box, string $recipient): array
{
// Guarded BEFORE anything reads $box->password: that accessor
// decrypts under SECRETS_KEY, and it is not set on this installation
// at all (the mail settings page's own banner says so). Reading it
// first would let a RuntimeException out of a Livewire action —
// Task 7's EditMailbox::save() shipped a Critical fix for exactly
// this shape of bug. The tester touches the same cipher and gets the
// same guard, checked here rather than left for the property access
// below to throw through.
//
// Codex R15#5, P2: gated on authenticates, same as the password
// presence check right below. When this mailbox does not
// authenticate, $box->password is never read (see the credentials
// ternary further down) — requiring a usable cipher anyway refused a
// configuration real sending accepts, on exactly the "fresh install,
// no SECRETS_KEY yet" installation the unauthenticated-relay support
// exists for.
if ($box->authenticates && ! $this->cipher->isUsable()) {
return ['ok' => false, 'error' => __('mail_settings.no_key')];
}
// Codex R15#9, P2: isUsable() only proves SECRETS_KEY is well-formed
// (right length, right shape) — never that THIS row's ciphertext was
// ever encrypted under it. A rotated key, or a row seeded/encrypted
// under a different one, sails straight past the guard above and
// THEN throws a DecryptException the instant $box->password is
// actually read: the same uncaught-out-of-a-Livewire-action shape
// the guard above exists for, just a failure isUsable() cannot see
// by design. Read once, here, behind its own guard, and reuse the
// plaintext below — the null-check on the next line and the
// credentials ternary further down used to both call this SAME
// decrypting accessor a second and third time, each one another
// uncaught throw waiting to happen.
try {
$password = $box->authenticates ? $box->password : null;
} catch (DecryptException) {
return ['ok' => false, 'error' => __('mail_settings.test_password_undecryptable')];
}
// Codex R15#4, P1b: a password is only REQUIRED when this mailbox
// actually authenticates — a trusted local or private-network relay
// can legitimately need none at all, and refusing it here is exactly
// the "no password stored" outage the finding names, even though the
// relay was never broken.
if ($box->authenticates && $password === null) {
return ['ok' => false, 'error' => __('mail_settings.test_no_password')];
}
$port = (int) Settings::get('mail.port', 587);
// Codex R15#5, P2 comparison pass: MailboxTransport::delegate() has
// an equivalent guard (host==='' too — but that one is not repeated
// here, since Mail::build() with a blank host already fails just as
// fast on its own, confirmed by hand). Port is different in kind, not
// merely in wording: a stored NULL setting casts to 0, and left
// unguarded, Symfony's EsmtpTransportFactory silently reinterprets
// port 0 as port 25 rather than rejecting it — confirmed by hand,
// not assumed. Without this guard, a test-send against a mailbox
// whose port was never (or no longer) configured could actually
// reach whatever happens to listen on 25 (a local MTA, say) and
// report success for a configuration MailboxTransport refuses
// outright before it ever opens a socket.
if ($port < 1) {
return ['ok' => false, 'error' => __('mail_settings.test_port_not_configured')];
}
$policy = MailTlsPolicy::for((string) Settings::get('mail.encryption', 'tls'), $port);
// Omitted entirely, not merely blank, when this mailbox does not
// authenticate: Mailbox::smtpUsername() always returns something
// non-empty (it falls back to the address), so passing it through
// regardless would still hand Mail::build() a real 'username' —
// enough by itself to make Symfony attempt AUTH against any server
// that advertises it, which a trusted no-auth relay was never asked
// to answer. Mirrors MailboxTransport's own setUsername()/
// setPassword() guard for the real send path.
$credentials = $box->authenticates
? ['username' => $box->smtpUsername(), 'password' => $password]
: [];
try {
Mail::build([
'transport' => 'smtp',
'host' => (string) Settings::get('mail.host', ''),
'port' => $port,
// 'scheme', NOT 'encryption'. Laravel 13's MailManager reads
// scheme only (MailManager.php:196) — an 'encryption' key is
// silently ignored, and the connection would go out in the
// clear while the console reported TLS.
'scheme' => $policy->implicit ? 'smtps' : 'smtp',
// auto_tls / require_tls: MailManager::createSmtpTransport()
// forwards this whole config array to EsmtpTransportFactory
// as DSN options, and the factory reads exactly these two
// keys itself (see EsmtpTransportFactory::create() in the
// installed vendor source) — so the same MailTlsPolicy that
// configures the real MailboxTransport reaches this path too,
// through the only channel Mail::build()'s config array
// actually offers for it. Without these, this path would
// silently prove a different policy than real sends enforce.
'auto_tls' => $policy->autoTls,
'require_tls' => $policy->requireTls,
...$credentials,
])->raw(
__('mail_settings.test_body', ['key' => $box->key]),
function ($message) use ($box, $recipient) {
$message->to($recipient)
->from($box->address, $box->display_name ?: null)
->subject(__('mail_settings.test_subject'));
},
);
} catch (Throwable $e) {
// Verbatim. With wrong credentials, or a server that rejects the
// message outright, the server's own sentence IS the entire
// diagnosis — "sending failed" would throw that away.
return ['ok' => false, 'error' => $e->getMessage()];
}
$box->forceFill(['last_verified_at' => now()])->save();
return ['ok' => true, 'error' => null];
}
}