151 lines
7.6 KiB
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];
|
|
}
|
|
}
|